mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
"Share on Nostr" published a kind-1 note whose body ended with a bare thumbnail
URL. Many clients only inline such a URL when a NIP-92 `imeta` entry describes
it, so the viewer often saw a plain link instead of a preview image.
- buildShareEvent now emits an `imeta` entry for the thumbnail. The media type
is inferred from the extension and OMITTED when unknown -- a wrong `m` is
worse than a missing one, since clients use it to decide whether to render
inline at all, so extensionless Blossom URLs ship url + alt only.
- inferImageMimeTypeFromUrl lives in a SEPARATE map from EXTENSION_MIME_MAP:
that map gates video upload/publish paths, and folding image types into it
would let an image pass a video check.
- shareNostrController was passing only { id, pubkey } to buildShareEvent, so
the tag could never be built; it now forwards title and thumbnail.
When a video has a NIP-71 mirror, the note instead quotes it with
`nostr:naddr1...`, which nostr clients render as a native video quote card --
the closest thing to a rich preview that does not depend on any client fetching
OpenGraph tags (most never do).
- The mirror is addressable (kind 34235/34236, author = video pubkey,
d = videoRootId), so js/nostr/mirrorPointer.js derives the coordinate with no
relay lookup, and the pointer survives the author editing the video where a
plain `nevent` would rot on the next replace.
- The pointer is only embedded after nip71MirrorService.findMirror() confirms
the mirror exists. A pointer to a missing event renders as a broken/empty
quote box, which is worse than the plain-link fallback, so a disabled
service, a throwing lookup, a 2.5s timeout or a missing videoRootId all fall
back to the thumbnail note.
- resolveMirrorKind prefers the kinds actually observed on relays and only then
falls back to the same portrait rule the mirror builder uses, so a derived
pointer matches what would be published rather than guessing independently.
- While quoting, the raw thumbnail URL is dropped from the body and the imeta
tag is skipped: the quote card already shows the thumbnail, and emitting both
rendered the same image twice. An `a` tag links the note to the coordinate.
Known limitation: a client rendering the quote card still has to play the
video, so mirrors whose imeta url is an .m3u8 hit the same HLS wall -- title and
thumbnail render, playback depends on the client.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
338 lines
10 KiB
JavaScript
338 lines
10 KiB
JavaScript
import test from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import ShareNostrController from '../js/ui/shareNostrController.js';
|
|
|
|
test('ShareNostrController', async (t) => {
|
|
const mockGetActiveSigner = t.mock.fn(() => ({
|
|
pubkey: 'signerpubkey',
|
|
signEvent: async () => ({ id: 'signed_event' }),
|
|
type: 'extension'
|
|
}));
|
|
|
|
const mockQueueSignEvent = t.mock.fn(async (signer, event) => ({
|
|
...event,
|
|
id: 'signed_event',
|
|
sig: 'signature'
|
|
}));
|
|
|
|
const mockPublishEventToRelays = t.mock.fn(async () => ({
|
|
accepted: ['wss://relay.example.com'],
|
|
failed: []
|
|
}));
|
|
|
|
const mockAssertAnyRelayAccepted = t.mock.fn((summary) => summary);
|
|
|
|
const mockSanitizeRelayList = t.mock.fn((list) => list);
|
|
|
|
const mockBuildShareEvent = t.mock.fn(({ pubkey, content, video }) => ({
|
|
kind: 1,
|
|
pubkey,
|
|
content,
|
|
tags: [['e', video.id], ['p', video.pubkey]]
|
|
}));
|
|
|
|
const mockNostrClient = {
|
|
pool: {},
|
|
ensureExtensionPermissions: async () => ({ ok: true })
|
|
};
|
|
|
|
const mockLogger = {
|
|
warn: t.mock.fn(),
|
|
error: t.mock.fn(),
|
|
info: t.mock.fn(),
|
|
log: t.mock.fn()
|
|
};
|
|
|
|
const services = {
|
|
sanitizeRelayList: mockSanitizeRelayList,
|
|
buildShareEvent: mockBuildShareEvent,
|
|
publishEventToRelays: mockPublishEventToRelays,
|
|
assertAnyRelayAccepted: mockAssertAnyRelayAccepted,
|
|
getActiveSigner: mockGetActiveSigner,
|
|
queueSignEvent: mockQueueSignEvent,
|
|
permissionMethods: {},
|
|
nostrClient: mockNostrClient,
|
|
userLogger: mockLogger,
|
|
devLogger: mockLogger
|
|
};
|
|
|
|
await t.test('handleShare shares video successfully', async () => {
|
|
const ui = {
|
|
showError: t.mock.fn(),
|
|
showSuccess: t.mock.fn(),
|
|
getModal: () => ({})
|
|
};
|
|
const state = {
|
|
getPubkey: () => 'signerpubkey', // Match the signer pubkey
|
|
normalizeHexPubkey: (k) => k,
|
|
getCurrentVideo: () => ({ id: 'video1', title: 'Video 1' })
|
|
};
|
|
|
|
const controller = new ShareNostrController({ ui, state, services });
|
|
|
|
const payload = {
|
|
video: { id: 'video1', title: 'Video 1', pubkey: 'creator1' },
|
|
content: 'Check this out',
|
|
relays: ['wss://relay.example.com']
|
|
};
|
|
|
|
const result = await controller.handleShare(payload);
|
|
|
|
assert.ok(result.ok);
|
|
assert.equal(ui.showSuccess.mock.calls.length, 1);
|
|
assert.equal(ui.showError.mock.calls.length, 0);
|
|
});
|
|
|
|
await t.test('handleShare forwards title and thumbnail for the imeta hint', async () => {
|
|
// The controller used to pass only { id, pubkey } to buildShareEvent, so
|
|
// the NIP-92 imeta entry could never be built and the thumbnail URL in the
|
|
// note body rendered as a bare link instead of a preview image.
|
|
const ui = {
|
|
showError: t.mock.fn(),
|
|
showSuccess: t.mock.fn(),
|
|
getModal: () => ({})
|
|
};
|
|
const state = {
|
|
getPubkey: () => 'signerpubkey',
|
|
normalizeHexPubkey: (k) => k,
|
|
getCurrentVideo: () => null
|
|
};
|
|
|
|
mockBuildShareEvent.mock.resetCalls();
|
|
const controller = new ShareNostrController({ ui, state, services });
|
|
|
|
await controller.handleShare({
|
|
video: {
|
|
id: 'video1',
|
|
title: 'Video 1',
|
|
pubkey: 'creator1',
|
|
thumbnail: ' https://cdn.example/thumb.jpg '
|
|
},
|
|
content: 'Check this out',
|
|
relays: ['wss://relay.example.com']
|
|
});
|
|
|
|
const passedVideo = mockBuildShareEvent.mock.calls[0].arguments[0].video;
|
|
assert.equal(passedVideo.title, 'Video 1');
|
|
assert.equal(passedVideo.thumbnail, 'https://cdn.example/thumb.jpg');
|
|
assert.equal(passedVideo.id, 'video1');
|
|
assert.equal(passedVideo.pubkey, 'creator1');
|
|
});
|
|
|
|
await t.test('handleShare tolerates a video with no thumbnail', async () => {
|
|
const ui = {
|
|
showError: t.mock.fn(),
|
|
showSuccess: t.mock.fn(),
|
|
getModal: () => ({})
|
|
};
|
|
const state = {
|
|
getPubkey: () => 'signerpubkey',
|
|
normalizeHexPubkey: (k) => k,
|
|
getCurrentVideo: () => null
|
|
};
|
|
|
|
mockBuildShareEvent.mock.resetCalls();
|
|
const controller = new ShareNostrController({ ui, state, services });
|
|
|
|
const result = await controller.handleShare({
|
|
video: { id: 'video1', title: 'Video 1', pubkey: 'creator1' },
|
|
content: 'Check this out',
|
|
relays: ['wss://relay.example.com']
|
|
});
|
|
|
|
assert.ok(result.ok);
|
|
assert.equal(
|
|
mockBuildShareEvent.mock.calls[0].arguments[0].video.thumbnail,
|
|
''
|
|
);
|
|
});
|
|
|
|
await t.test('handleShare throws error if missing video details', async () => {
|
|
const ui = {
|
|
showError: t.mock.fn(),
|
|
showSuccess: t.mock.fn(),
|
|
getModal: () => ({})
|
|
};
|
|
const state = {
|
|
getPubkey: () => 'userpubkey',
|
|
normalizeHexPubkey: (k) => k
|
|
};
|
|
const controller = new ShareNostrController({ ui, state, services });
|
|
|
|
const payload = {
|
|
video: { id: '', title: '' } // Invalid
|
|
};
|
|
|
|
await assert.rejects(async () => {
|
|
await controller.handleShare(payload);
|
|
}, /share-missing-video-details/);
|
|
|
|
assert.equal(ui.showError.mock.calls.length, 1);
|
|
});
|
|
|
|
await t.test('openModal shows error if no video', async () => {
|
|
const ui = {
|
|
showError: t.mock.fn(),
|
|
getModal: () => ({})
|
|
};
|
|
const state = {
|
|
getCurrentVideo: () => null
|
|
};
|
|
const controller = new ShareNostrController({ ui, state, services });
|
|
|
|
await controller.openModal({ video: null });
|
|
assert.equal(ui.showError.mock.calls.length, 1);
|
|
assert.match(ui.showError.mock.calls[0].arguments[0], /No video/);
|
|
});
|
|
|
|
await t.test('openModal opens modal with correct payload', async () => {
|
|
const modalMock = {
|
|
open: t.mock.fn(async () => {})
|
|
};
|
|
const ui = {
|
|
showError: t.mock.fn(),
|
|
getModal: () => modalMock
|
|
};
|
|
const video = { id: 'v1', title: 'T1', pubkey: 'p1' };
|
|
const state = {
|
|
getCurrentVideo: () => video,
|
|
buildShareUrlFromEventId: (id) => `https://example.com/${id}`
|
|
};
|
|
const controller = new ShareNostrController({ ui, state, services });
|
|
|
|
await controller.openModal();
|
|
|
|
assert.equal(modalMock.open.mock.calls.length, 1);
|
|
const openArg = modalMock.open.mock.calls[0].arguments[0];
|
|
assert.equal(openArg.video.id, 'v1');
|
|
assert.equal(openArg.video.shareUrl, 'https://example.com/v1');
|
|
});
|
|
});
|
|
|
|
// SCN-share-mirror-lookup
|
|
// The share modal embeds a quote pointer ONLY when a mirror is confirmed on
|
|
// relays. A pointer to a missing event renders as a broken/empty quote box in
|
|
// nostr clients — strictly worse than the plain-link fallback — so every
|
|
// failure path here must degrade to "no pointer" rather than guess.
|
|
|
|
test('ShareNostrController mirror pointer', async (t) => {
|
|
const NIP19 = { naddrEncode: (p) => `naddr1-${p.kind}-${p.identifier}` };
|
|
|
|
const baseUi = () => ({
|
|
showError: t.mock.fn(),
|
|
showSuccess: t.mock.fn(),
|
|
getModal: () => ({ open: async () => {} }),
|
|
});
|
|
const baseState = () => ({
|
|
getPubkey: () => 'signerpubkey',
|
|
normalizeHexPubkey: (k) => k,
|
|
getCurrentVideo: () => null,
|
|
buildShareUrlFromEventId: () => 'https://bitvid.network/?v=nevent1abc',
|
|
});
|
|
|
|
const VIDEO = {
|
|
id: 'video1',
|
|
title: 'Video 1',
|
|
pubkey: 'b'.repeat(64),
|
|
videoRootId: 'root-abc',
|
|
thumbnail: 'https://cdn.example/t.jpg',
|
|
width: 1280,
|
|
height: 720,
|
|
};
|
|
|
|
const makeController = (mirrorService) =>
|
|
new ShareNostrController({
|
|
ui: baseUi(),
|
|
state: baseState(),
|
|
services: {
|
|
mirrorService,
|
|
nip19: NIP19,
|
|
nostrClient: { pool: {}, writeRelays: ['wss://relay.one'] },
|
|
userLogger: { warn: () => {}, error: () => {}, info: () => {} },
|
|
devLogger: { warn: () => {}, error: () => {}, log: () => {} },
|
|
},
|
|
});
|
|
|
|
await t.test('confirmed mirror yields an naddr and coordinate', async () => {
|
|
const controller = makeController({
|
|
isAvailable: () => true,
|
|
findMirror: async () => ({ mirrored: true, kinds: [34235] }),
|
|
});
|
|
const out = await controller.resolveMirrorPointer(VIDEO);
|
|
assert.equal(out.naddr, 'naddr1-34235-root-abc');
|
|
assert.equal(out.coordinate, `34235:${'b'.repeat(64)}:root-abc`);
|
|
});
|
|
|
|
await t.test('the observed kind wins over the dimension heuristic', async () => {
|
|
// Portrait dims would suggest 34236; relays say 34235, so 34235 it is.
|
|
const controller = makeController({
|
|
isAvailable: () => true,
|
|
findMirror: async () => ({ mirrored: true, kinds: [34235] }),
|
|
});
|
|
const out = await controller.resolveMirrorPointer({
|
|
...VIDEO,
|
|
width: 720,
|
|
height: 1280,
|
|
});
|
|
assert.equal(out.naddr, 'naddr1-34235-root-abc');
|
|
});
|
|
|
|
await t.test('no mirror on relays yields no pointer', async () => {
|
|
const controller = makeController({
|
|
isAvailable: () => true,
|
|
findMirror: async () => ({ mirrored: false, kinds: [] }),
|
|
});
|
|
assert.deepEqual(await controller.resolveMirrorPointer(VIDEO), {
|
|
naddr: '',
|
|
coordinate: '',
|
|
});
|
|
});
|
|
|
|
await t.test('a throwing lookup degrades instead of breaking the share', async () => {
|
|
const controller = makeController({
|
|
isAvailable: () => true,
|
|
findMirror: async () => {
|
|
throw new Error('relay exploded');
|
|
},
|
|
});
|
|
assert.deepEqual(await controller.resolveMirrorPointer(VIDEO), {
|
|
naddr: '',
|
|
coordinate: '',
|
|
});
|
|
});
|
|
|
|
await t.test('a disabled mirror service is never consulted', async () => {
|
|
let called = false;
|
|
const controller = makeController({
|
|
isAvailable: () => false,
|
|
findMirror: async () => {
|
|
called = true;
|
|
return { mirrored: true, kinds: [34235] };
|
|
},
|
|
});
|
|
const out = await controller.resolveMirrorPointer(VIDEO);
|
|
assert.equal(out.naddr, '');
|
|
assert.equal(called, false);
|
|
});
|
|
|
|
await t.test('a video without a videoRootId cannot be addressed', async () => {
|
|
const controller = makeController({
|
|
isAvailable: () => true,
|
|
findMirror: async () => ({ mirrored: true, kinds: [34235] }),
|
|
});
|
|
const out = await controller.resolveMirrorPointer({
|
|
...VIDEO,
|
|
videoRootId: '',
|
|
});
|
|
assert.equal(out.naddr, '');
|
|
});
|
|
|
|
await t.test('a missing mirror service is tolerated', async () => {
|
|
const controller = makeController(null);
|
|
assert.deepEqual(await controller.resolveMirrorPointer(VIDEO), {
|
|
naddr: '',
|
|
coordinate: '',
|
|
});
|
|
});
|
|
});
|