diff --git a/src/sidebar/services/annotation-activity.ts b/src/sidebar/services/annotation-activity.ts index 02156ae576c..71eaa64e5a2 100644 --- a/src/sidebar/services/annotation-activity.ts +++ b/src/sidebar/services/annotation-activity.ts @@ -14,10 +14,12 @@ import * as postMessageJsonRpc from '../util/postmessage-json-rpc'; export class AnnotationActivityService { private _rpc: SidebarSettings['rpc']; private _reportConfig: SidebarSettings['reportActivity']; + private _reportDocumentInfo: boolean; constructor(settings: SidebarSettings) { this._rpc = settings.rpc; this._reportConfig = settings.reportActivity; + this._reportDocumentInfo = settings.reportDocumentInfo ?? false; } reportActivity(eventType: AnnotationEventType, annotation: Annotation) { @@ -51,6 +53,18 @@ export class AnnotationActivityService { } } + /** + * Notify the embedder frame of the loaded document's identity. + * + * Only sent when the embedder opts in via the `reportDocumentInfo` setting. + */ + reportDocumentInfo(uri: string) { + if (!this._reportDocumentInfo) { + return; + } + this.notify('reportDocumentInfo', [{ uri }]); + } + /** * Notify the embedder frame that the client has, or does not have, * unsaved changes to annotations. diff --git a/src/sidebar/services/frame-sync.ts b/src/sidebar/services/frame-sync.ts index 2c739990faa..240859c60e0 100644 --- a/src/sidebar/services/frame-sync.ts +++ b/src/sidebar/services/frame-sync.ts @@ -37,6 +37,7 @@ import { isPrivate } from '../helpers/permissions'; import type { SidebarStore } from '../store'; import type { Frame } from '../store/modules/frames'; import { watch } from '../util/watch'; +import type { AnnotationActivityService } from './annotation-activity'; import type { AnnotationsService } from './annotations'; import type { ToastMessengerService } from './toast-messenger'; @@ -111,6 +112,7 @@ function frameForAnnotation(frames: Frame[], ann: Annotation): Frame | null { * @inject */ export class FrameSyncService { + private _annotationActivity: AnnotationActivityService; private _annotationsService: AnnotationsService; /** @@ -183,11 +185,13 @@ export class FrameSyncService { constructor( $window: Window, + annotationActivity: AnnotationActivityService, annotationsService: AnnotationsService, store: SidebarStore, toastMessenger: ToastMessengerService, ) { this._window = $window; + this._annotationActivity = annotationActivity; this._annotationsService = annotationsService; this._store = store; this._toastMessenger = toastMessenger; @@ -395,6 +399,16 @@ export class FrameSyncService { segment: info.segmentInfo, persistent: info.persistent, }); + + if (sourceId === null) { + // For a PDF the main URI is the (possibly temporary) URL it was served + // from, so prefer the fingerprint URN that `PDFMetadata` puts in + // `link` — that is the stable identity annotations resolve onto. + const fingerprintURN = info.metadata?.link?.find(link => + link.href.startsWith('urn:x-pdf:'), + )?.href; + this._annotationActivity.reportDocumentInfo(fingerprintURN ?? info.uri); + } }); // TODO - Close connection if we don't receive a "connect" message within diff --git a/src/sidebar/services/test/annotation-activity-test.js b/src/sidebar/services/test/annotation-activity-test.js index 52b4231655a..9ed8aec9594 100644 --- a/src/sidebar/services/test/annotation-activity-test.js +++ b/src/sidebar/services/test/annotation-activity-test.js @@ -157,6 +157,34 @@ describe('AnnotationActivityService', () => { }); }); + describe('#reportDocumentInfo', () => { + it('sends reportDocumentInfo notification if the embedder opted in', () => { + const svc = new AnnotationActivityService({ + ...fakeSettings, + reportDocumentInfo: true, + }); + + svc.reportDocumentInfo('urn:x-pdf:FINGERPRINT'); + + assert.calledOnce(fakePostMessageJsonRpc.notify); + assert.calledWith( + fakePostMessageJsonRpc.notify, + window, + 'https://www.example.com', + 'reportDocumentInfo', + [{ uri: 'urn:x-pdf:FINGERPRINT' }], + ); + }); + + it('does not send notification if the embedder did not opt in', () => { + const svc = new AnnotationActivityService(fakeSettings); + + svc.reportDocumentInfo('urn:x-pdf:FINGERPRINT'); + + assert.notCalled(fakePostMessageJsonRpc.notify); + }); + }); + describe('#notifyUnsavedChanges', () => { [true, false].forEach(unsaved => { it('sends reportUnsavedChanges notification with unsaved state', () => { diff --git a/src/sidebar/services/test/frame-sync-test.js b/src/sidebar/services/test/frame-sync-test.js index a14933547f5..ab6041862da 100644 --- a/src/sidebar/services/test/frame-sync-test.js +++ b/src/sidebar/services/test/frame-sync-test.js @@ -37,6 +37,19 @@ const fixtures = { persistent: false, }, + // Argument to the `documentInfoChanged` call made by a guest displaying a PDF. + pdfDocumentInfo: { + uri: 'https://example.com/test.pdf', + metadata: { + documentFingerprint: 'FINGERPRINT', + link: [ + { href: 'urn:x-pdf:FINGERPRINT' }, + { href: 'https://example.com/test.pdf' }, + ], + }, + persistent: false, + }, + // Argument to the `documentInfoChanged` call made by a guest displaying an EPUB // document. epubDocumentInfo: { @@ -55,6 +68,7 @@ const fixtures = { describe('FrameSyncService', () => { let FakePortRPC; + let fakeAnnotationActivity; let fakeAnnotationsService; let fakeToastMessenger; let fakePortRPCs; @@ -76,6 +90,7 @@ describe('FrameSyncService', () => { let setupPortRPC; beforeEach(() => { + fakeAnnotationActivity = { reportDocumentInfo: sinon.stub() }; fakeAnnotationsService = { create: sinon.stub() }; fakeToastMessenger = new EventEmitter(); fakePortRPCs = []; @@ -198,6 +213,7 @@ describe('FrameSyncService', () => { frameSync = new Injector() .register('$window', { value: fakeWindow }) + .register('annotationActivity', { value: fakeAnnotationActivity }) .register('annotationsService', { value: fakeAnnotationsService }) .register('store', { value: fakeStore }) .register('toastMessenger', { value: fakeToastMessenger }) @@ -817,6 +833,35 @@ describe('FrameSyncService', () => { assert.calledWith(channel.call, 'setHighlightsVisible', false); }); + describe("reporting the document's identity to the embedder", () => { + it('reports the main URI for an HTML document', async () => { + await connectGuest(); + emitGuestEvent('documentInfoChanged', fixtures.htmlDocumentInfo); + + assert.calledWith( + fakeAnnotationActivity.reportDocumentInfo, + fixtures.htmlDocumentInfo.uri, + ); + }); + + it('reports the fingerprint URN for a PDF', async () => { + await connectGuest(); + emitGuestEvent('documentInfoChanged', fixtures.pdfDocumentInfo); + + assert.calledWith( + fakeAnnotationActivity.reportDocumentInfo, + 'urn:x-pdf:FINGERPRINT', + ); + }); + + it('does not report the identity of a non-main frame', async () => { + await connectGuest('test-frame'); + emitGuestEvent('documentInfoChanged', fixtures.pdfDocumentInfo); + + assert.notCalled(fakeAnnotationActivity.reportDocumentInfo); + }); + }); + [true, false].forEach(contentInfoAvailable => { it('sends content info to guest if available', async () => { let channel; diff --git a/src/types/config.ts b/src/types/config.ts index 5054e685af0..61f555808b2 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -331,6 +331,12 @@ export type ConfigFromEmbedder = ConfigFromHost & { */ reportActivity?: ReportAnnotationActivityConfig; + /** + * Request that the loaded document's identity — be reported to the embedder frame via a + * `reportDocumentInfo` RPC call when the document loads. + */ + reportDocumentInfo?: boolean; + /** Configuration for menu items etc. related to LMS instructor dashboard */ dashboard?: DashboardConfig;