Skip to content

Commit ac226b7

Browse files
committed
Moved MarkersViewNotifier to its own file
1 parent 8958834 commit ac226b7

File tree

3 files changed

+221
-212
lines changed

3 files changed

+221
-212
lines changed

extensions/src/platform-scripture-editor/src/main.ts

Lines changed: 21 additions & 212 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
// eslint-disable-next-line max-classes-per-file
2-
import papi, { logger, WebViewFactory } from '@papi/backend';
1+
import papi, { logger, WebViewFactory } from '@papi/backend';
32
import type {
43
ExecutionActivationContext,
5-
ExecutionToken,
64
IWebViewProvider,
75
OpenWebViewOptions,
86
SavedWebViewDefinition,
@@ -12,14 +10,9 @@ import { SerializedVerseRef } from '@sillsdev/scripture';
1210
import {
1311
getErrorMessage,
1412
serialize,
15-
Unsubscriber,
16-
UnsubscriberAsync,
1713
USFM_MARKERS_MAP_PARATEXT_3_0,
1814
UsjNodeAndDocumentLocation,
1915
UsjReaderWriter,
20-
Mutex,
21-
MutexMap,
22-
formatReplacementString,
2316
} from 'platform-bible-utils';
2417
import {
2518
EditorDecorations,
@@ -32,21 +25,14 @@ import { AnnotationStyleDataProviderEngine } from './annotation-style.data-provi
3225
import { mergeDecorations } from './decorations.util';
3326
import platformScriptureEditorWebViewStyles from './platform-scripture-editor.web-view.scss?inline';
3427
import platformScriptureEditorWebView from './platform-scripture-editor.web-view?inline';
35-
import { formatEditorTitle } from './platform-scripture-editor.utils';
28+
import {
29+
formatEditorTitle,
30+
SCRIPTURE_EDITOR_WEBVIEW_TYPE,
31+
} from './platform-scripture-editor.utils';
32+
import { MarkersViewNotifier } from './markers-view-notifier.model';
3633

3734
logger.debug('Scripture Editor is importing!');
3835

39-
const scriptureEditorWebViewType = 'platformScriptureEditor.react';
40-
41-
/**
42-
* Time in milliseconds to wait before sending another notification informing the user that the
43-
* editor is read-only because they are in the markers view
44-
*/
45-
const READONLY_NOTIFICATION_DISMISS_DURATION_MS = 24 * 60 * 60 * 1000;
46-
47-
/** Time in milliseconds to throttle repeat notifications for the same project */
48-
const READONLY_NOTIFICATION_THROTTLE_MS = 5 * 1000;
49-
5036
interface PlatformScriptureEditorOptions extends OpenWebViewOptions {
5137
projectId: string | undefined;
5238
isReadOnly: boolean;
@@ -194,7 +180,7 @@ async function insertFootnoteAtSelection(webViewId: string | undefined): Promise
194180
}
195181

196182
const webViewController = await papi.webViews.getWebViewController(
197-
scriptureEditorWebViewType,
183+
SCRIPTURE_EDITOR_WEBVIEW_TYPE,
198184
webViewId,
199185
);
200186

@@ -213,7 +199,7 @@ async function insertCrossReferenceAtSelection(webViewId: string | undefined): P
213199
}
214200

215201
const webViewController = await papi.webViews.getWebViewController(
216-
scriptureEditorWebViewType,
202+
SCRIPTURE_EDITOR_WEBVIEW_TYPE,
217203
webViewId,
218204
);
219205

@@ -284,7 +270,7 @@ async function open(
284270
// REVIEW: If an editor is already open for the selected project, we open another.
285271
// This matches the current behavior in P9, though it might not be what we want long-term.
286272
return papi.webViews.openWebView(
287-
scriptureEditorWebViewType,
273+
SCRIPTURE_EDITOR_WEBVIEW_TYPE,
288274
existingTabIdToReplace
289275
? { type: 'replace-tab', targetTabId: existingTabIdToReplace }
290276
: undefined,
@@ -306,13 +292,13 @@ async function changeScriptureView(webViewId: string | undefined): Promise<void>
306292
return;
307293
}
308294

309-
if (webViewDefinition.webViewType !== scriptureEditorWebViewType) {
295+
if (webViewDefinition.webViewType !== SCRIPTURE_EDITOR_WEBVIEW_TYPE) {
310296
logger.debug(`WebView is not a Scripture editor!`);
311297
return;
312298
}
313299

314300
const controller = await papi.webViews.getWebViewController(
315-
scriptureEditorWebViewType,
301+
SCRIPTURE_EDITOR_WEBVIEW_TYPE,
316302
webViewId,
317303
);
318304

@@ -336,13 +322,13 @@ async function toggleFootnotesPane(webViewId: string | undefined): Promise<void>
336322
return;
337323
}
338324

339-
if (webViewDefinition.webViewType !== scriptureEditorWebViewType) {
325+
if (webViewDefinition.webViewType !== SCRIPTURE_EDITOR_WEBVIEW_TYPE) {
340326
logger.debug(`WebView is not a Scripture editor!`);
341327
return;
342328
}
343329

344330
const controller = await papi.webViews.getWebViewController(
345-
scriptureEditorWebViewType,
331+
SCRIPTURE_EDITOR_WEBVIEW_TYPE,
346332
webViewId,
347333
);
348334

@@ -366,13 +352,13 @@ async function changeFootnotesPaneLocation(webViewId: string | undefined): Promi
366352
return;
367353
}
368354

369-
if (webViewDefinition.webViewType !== scriptureEditorWebViewType) {
355+
if (webViewDefinition.webViewType !== SCRIPTURE_EDITOR_WEBVIEW_TYPE) {
370356
logger.debug(`WebView is not a Scripture editor!`);
371357
return;
372358
}
373359

374360
const controller = await papi.webViews.getWebViewController(
375-
scriptureEditorWebViewType,
361+
SCRIPTURE_EDITOR_WEBVIEW_TYPE,
376362
webViewId,
377363
);
378364

@@ -385,18 +371,18 @@ async function changeFootnotesPaneLocation(webViewId: string | undefined): Promi
385371
}
386372

387373
/** Simple WebView provider so PAPI can get a Scripture Editor upon request */
388-
class ScriptureEditorWebViewFactory extends WebViewFactory<typeof scriptureEditorWebViewType> {
374+
class ScriptureEditorWebViewFactory extends WebViewFactory<typeof SCRIPTURE_EDITOR_WEBVIEW_TYPE> {
389375
constructor() {
390-
super(scriptureEditorWebViewType);
376+
super(SCRIPTURE_EDITOR_WEBVIEW_TYPE);
391377
}
392378

393379
override async getWebViewDefinition(
394380
savedWebView: SavedWebViewDefinition,
395381
getWebViewOptions: PlatformScriptureEditorOptions,
396382
): Promise<WebViewDefinition | undefined> {
397-
if (savedWebView.webViewType !== scriptureEditorWebViewType)
383+
if (savedWebView.webViewType !== SCRIPTURE_EDITOR_WEBVIEW_TYPE)
398384
throw new Error(
399-
`${scriptureEditorWebViewType} provider received request to provide a ${savedWebView.webViewType} WebView`,
385+
`${SCRIPTURE_EDITOR_WEBVIEW_TYPE} provider received request to provide a ${savedWebView.webViewType} WebView`,
400386
);
401387

402388
// We know that the projectId (if present in the state) will be a string.
@@ -724,183 +710,6 @@ class ScriptureEditorWebViewFactory extends WebViewFactory<typeof scriptureEdito
724710
}
725711
const scriptureEditorWebViewProvider: IWebViewProvider = new ScriptureEditorWebViewFactory();
726712

727-
/**
728-
* Notifier that alerts users when a Scripture Editor WebView is opened or updated with
729-
* `state.viewType === 'markers'`. Allows dismissing notifications per-project for 24 hours.
730-
*/
731-
class MarkerViewNotifier {
732-
/** UserData storage key for the disabled map */
733-
private readonly disabledMapUserDataKey =
734-
'platformScriptureEditor.markerViewNotificationDisabled';
735-
736-
/** Map of notification IDs to project IDs */
737-
private readonly projectIdsByNotificationId = new Map<string | number, string>();
738-
739-
/** Map of project IDs to the time (in milliseconds) they were disabled */
740-
private disabledMap: Record<string, number> = {};
741-
/** Map of project IDs to timestamp (ms) of the last sent notification */
742-
private lastNotificationByProject: Record<string, number> = {};
743-
/** Map of per-project mutexes to avoid races when checking/updating last notification timestamps */
744-
private projectMutexes = new MutexMap();
745-
746-
constructor(
747-
private papiInstance: typeof papi,
748-
private executionToken: ExecutionToken,
749-
) {}
750-
751-
/**
752-
* Starts the notifier by loading persisted state and registering necessary commands and listeners
753-
*
754-
* @returns Array of disposables for the registered commands and listeners
755-
*/
756-
async start(): Promise<(Unsubscriber | UnsubscriberAsync)[]> {
757-
// Load persisted disable map once and cache it in memory
758-
try {
759-
const rawDisableMap =
760-
(await this.papiInstance.storage.readUserData(
761-
this.executionToken,
762-
this.disabledMapUserDataKey,
763-
)) ?? '{}';
764-
this.disabledMap = JSON.parse(rawDisableMap);
765-
} catch (e) {
766-
logger.warn(`Failed to load marker-view disable map from storage: ${getErrorMessage(e)}`);
767-
this.disabledMap = {};
768-
}
769-
// Register command to dismiss for a project for 24 hours
770-
const dismissCommand = this.papiInstance.commands.registerCommand(
771-
'platformScriptureEditor.dismissMarkerNotificationForProjectToday',
772-
async (notificationId) => {
773-
if (!notificationId) return;
774-
const projectId = this.projectIdsByNotificationId.get(notificationId);
775-
if (!projectId) return;
776-
// Update in-memory map and persist
777-
this.disabledMap[projectId] = Date.now();
778-
try {
779-
await this.papiInstance.storage.writeUserData(
780-
this.executionToken,
781-
this.disabledMapUserDataKey,
782-
JSON.stringify(this.disabledMap),
783-
);
784-
} catch (e) {
785-
logger.warn(
786-
`Failed to persist marker-view disable map for project ${projectId}: ${getErrorMessage(e)}`,
787-
);
788-
}
789-
// Clean up the runtime mapping
790-
this.projectIdsByNotificationId.delete(notificationId);
791-
},
792-
{
793-
method: {
794-
summary: "Don't show marker view readonly notification for this project for 24 hours",
795-
params: [{ name: 'notificationId', required: true, schema: { type: 'string' } }],
796-
result: { name: 'return value', schema: { type: 'null' } },
797-
},
798-
},
799-
);
800-
801-
// Listeners for new or updated webviews
802-
const onOpen = this.papiInstance.webViews.onDidOpenWebView(({ webView }) =>
803-
this.handleWebView(webView),
804-
);
805-
const onUpdate = this.papiInstance.webViews.onDidUpdateWebView(({ webView }) =>
806-
this.handleWebView(webView),
807-
);
808-
809-
return [await dismissCommand, onOpen, onUpdate];
810-
}
811-
812-
/**
813-
* Handles a WebView being opened or updated, sending a notification if it is a markers view and
814-
* notifications are not disabled for its project
815-
*
816-
* @param webViewDefinition WebView that was opened or updated
817-
*/
818-
private async handleWebView(webViewDefinition: SavedWebViewDefinition | WebViewDefinition) {
819-
try {
820-
// We are only interested in our editor WebViews, so ignore others
821-
if (webViewDefinition.webViewType !== scriptureEditorWebViewType) return;
822-
// TypeScript doesn't know the shape of state, but we know viewType (if present) is string
823-
// eslint-disable-next-line no-type-assertion/no-type-assertion
824-
const viewType = webViewDefinition.state?.viewType as string | undefined;
825-
826-
// Only need to send a notification if this is the markers view which is currently readonly
827-
if (viewType !== 'markers') return;
828-
829-
const { projectId, state } = webViewDefinition;
830-
// If no projectId, can't proceed. Just warn and be done
831-
if (!projectId) {
832-
logger.warn(
833-
`MarkerViewNotifier found an editor view WebView (${webViewDefinition.id}) with no projectId!`,
834-
);
835-
return;
836-
}
837-
838-
// If the view is already read-only, no need to notify
839-
if (state?.isReadOnly) return;
840-
841-
// Check if this notification is disabled for this project
842-
const disabledTime = this.disabledMap[projectId];
843-
// If the disable time hasn't elapsed yet, don't send another notification
844-
if (disabledTime && Date.now() < disabledTime + READONLY_NOTIFICATION_DISMISS_DURATION_MS)
845-
return;
846-
847-
// Acquire a per-project mutex and atomically check/reserve the last-notification timestamp
848-
// so we don't send the same notification multiple times in quick succession
849-
const projectMutex: Mutex = this.projectMutexes.get(projectId);
850-
await projectMutex.runExclusive(async () => {
851-
const lastSent = this.lastNotificationByProject[projectId];
852-
if (lastSent && Date.now() < lastSent + READONLY_NOTIFICATION_THROTTLE_MS) return;
853-
854-
// Resolve project name if possible
855-
let projectName = projectId;
856-
try {
857-
const pdp = await this.papiInstance.projectDataProviders.get('platform.base', projectId);
858-
projectName = (await pdp.getSetting('platform.name')) ?? projectId;
859-
} catch (e) {
860-
// fall back to id
861-
logger.warn(
862-
`MarkerViewNotifier failed to get project name for project ${projectId}: ${getErrorMessage(
863-
e,
864-
)}`,
865-
);
866-
}
867-
868-
// Localize and format the notification message and click label
869-
const messageKey = '%platformScriptureEditor_markersView_readonly_message_format%';
870-
const clickLabelKey = '%platformScriptureEditor_markersView_readonly_dismiss%';
871-
let localizedMessageTemplate = messageKey;
872-
let localizedClickLabel = clickLabelKey;
873-
try {
874-
const localized = await this.papiInstance.localization.getLocalizedStrings({
875-
localizeKeys: [messageKey, clickLabelKey],
876-
});
877-
localizedMessageTemplate = localized[messageKey] ?? messageKey;
878-
localizedClickLabel = localized[clickLabelKey] ?? localizedClickLabel;
879-
} catch (e) {
880-
logger.warn(
881-
`Failed to get localized strings for marker-view notification: ${getErrorMessage(e)}`,
882-
);
883-
}
884-
885-
const message = formatReplacementString(localizedMessageTemplate, { projectName });
886-
887-
// Send notification informing that markers view is read-only for this WebView/project
888-
const notificationId = await this.papiInstance.notifications.send({
889-
severity: 'info',
890-
message,
891-
clickCommand: 'platformScriptureEditor.dismissMarkerNotificationForProjectToday',
892-
clickCommandLabel: localizedClickLabel,
893-
});
894-
this.projectIdsByNotificationId.set(notificationId, projectId);
895-
896-
// Record the last-send time so we don't send duplicates too quickly
897-
this.lastNotificationByProject[projectId] = Date.now();
898-
});
899-
} catch (e) {
900-
logger.warn(`MarkerViewNotifier failed handling WebView: ${getErrorMessage(e)}`);
901-
}
902-
}
903-
}
904713
export async function activate(context: ExecutionActivationContext): Promise<void> {
905714
logger.debug('Scripture editor is activating!');
906715

@@ -1012,7 +821,7 @@ export async function activate(context: ExecutionActivationContext): Promise<voi
1012821
);
1013822

1014823
const scriptureEditorWebViewProviderPromise = papi.webViewProviders.registerWebViewProvider(
1015-
scriptureEditorWebViewType,
824+
SCRIPTURE_EDITOR_WEBVIEW_TYPE,
1016825
scriptureEditorWebViewProvider,
1017826
);
1018827

@@ -1084,7 +893,7 @@ export async function activate(context: ExecutionActivationContext): Promise<voi
1084893
);
1085894

1086895
// Await the registration promises at the end so we don't hold everything else up
1087-
const markerNotifier = new MarkerViewNotifier(papi, context.executionToken);
896+
const markerNotifier = new MarkersViewNotifier(papi, context.executionToken);
1088897
const markerNotifierUnsubscribers = await markerNotifier.start();
1089898

1090899
context.registrations.add(

0 commit comments

Comments
 (0)