From 748e12df7b32b50919ff1d39712fec90cc639d81 Mon Sep 17 00:00:00 2001 From: Shusui MOYATANI Date: Wed, 12 Apr 2023 12:18:47 +0900 Subject: [PATCH] feat: parse textnote and include metadata as tags --- src/components/NotePostForm.tsx | 61 +++++++++++++++++++++++++++++---- src/core/parseTextNote.test.ts | 9 +++++ src/nostr/useBatchedEvents.ts | 5 +-- src/nostr/useCommands.test.ts | 21 ++++++++++++ src/nostr/useCommands.ts | 60 +++++++++++++++++++++++++------- src/utils/imageUrl.test.ts | 11 ++++++ vitest.config.ts | 6 ++++ 7 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 src/nostr/useCommands.test.ts create mode 100644 src/utils/imageUrl.test.ts diff --git a/src/components/NotePostForm.tsx b/src/components/NotePostForm.tsx index f178e63..c6b9d8a 100644 --- a/src/components/NotePostForm.tsx +++ b/src/components/NotePostForm.tsx @@ -21,11 +21,12 @@ import UserNameDisplay from '@/components/UserDisplayName'; import eventWrapper from '@/core/event'; import useConfig from '@/nostr/useConfig'; -import useCommands from '@/nostr/useCommands'; +import useCommands, { PublishTextNoteParams } from '@/nostr/useCommands'; import usePubkey from '@/nostr/usePubkey'; import { useHandleCommand } from '@/hooks/useCommandBus'; import { uploadNostrBuild, uploadFiles } from '@/utils/imageUpload'; +import parseTextNote from '@/core/parseTextNote'; type NotePostFormProps = { replyTo?: NostrEvent; @@ -45,6 +46,36 @@ const placeholder = (mode: NotePostFormProps['mode']) => { } }; +const parseAndExtract = (content: string) => { + const parsed = parseTextNote(content); + + const hashtags: string[] = []; + const pubkeyReferences: string[] = []; + const eventReferences: string[] = []; + const urlReferences: string[] = []; + + parsed.forEach((node) => { + if (node.type === 'HashTag') { + hashtags.push(node.tagName); + } else if (node.type === 'URL') { + urlReferences.push(node.content); + } else if (node.type === 'Bech32Entity') { + if (node.data.type === 'npub') { + pubkeyReferences.push(node.data.data); + } else if (node.data.type === 'note') { + eventReferences.push(node.data.data); + } + } + }); + + return { + hashtags, + pubkeyReferences, + eventReferences, + urlReferences, + }; +}; + const NotePostForm: Component = (props) => { let textAreaRef: HTMLTextAreaElement | undefined; let fileInputRef: HTMLInputElement | undefined; @@ -113,9 +144,19 @@ const NotePostForm: Component = (props) => { const mentionedPubkeys: Accessor = createMemo( () => replyTo()?.mentionedPubkeysWithoutAuthor() ?? [], ); - const notifyPubkeys = (pubkey: string): string[] | undefined => { - if (props.replyTo === undefined) return undefined; - return uniq([props.replyTo.pubkey, ...mentionedPubkeys(), pubkey]); + + const notifyPubkeys = (pubkey: string, pubkeyReferences: string[]): string[] => { + if (props.replyTo == null) return pubkeyReferences; + return uniq([ + // 返信先を先頭に + props.replyTo.pubkey, + // 自分も通知欄に表示するために表示(他アプリとの互換性) + pubkey, + // その他の返信先 + ...mentionedPubkeys(), + // 本文中の公開鍵(npub) + ...pubkeyReferences, + ]); }; const submit = () => { @@ -127,15 +168,23 @@ const NotePostForm: Component = (props) => { console.error('pubkey is not available'); return; } - let textNote: Parameters[0] = { + + const { hashtags, pubkeyReferences, eventReferences, urlReferences } = parseAndExtract(text()); + + let textNote: PublishTextNoteParams = { relayUrls: config().relayUrls, pubkey, content: text(), + notifyPubkeys: pubkeyReferences, + mentionEventIds: eventReferences, + hashtags, + urls: urlReferences, }; + if (replyTo() != null) { textNote = { ...textNote, - notifyPubkeys: notifyPubkeys(pubkey), + notifyPubkeys: notifyPubkeys(pubkey, pubkeyReferences), rootEventId: replyTo()?.rootEvent()?.id ?? replyTo()?.id, replyEventId: replyTo()?.id, }; diff --git a/src/core/parseTextNote.test.ts b/src/core/parseTextNote.test.ts index 8049394..20ffdba 100644 --- a/src/core/parseTextNote.test.ts +++ b/src/core/parseTextNote.test.ts @@ -118,6 +118,15 @@ describe('parseTextNote', () => { assert.deepStrictEqual(parsed, expected); }); + it('should parse text note which includes only a URL', () => { + const parsed = parseTextNote('https://syusui-s.github.io/rabbit/'); + const expected: ParsedTextNoteNode[] = [ + { type: 'URL', content: 'https://syusui-s.github.io/rabbit/' }, + ]; + + assert.deepStrictEqual(parsed, expected); + }); + it('should ignore invalid URL', () => { const parsed = parseTextNote('ws://localhost:port'); diff --git a/src/nostr/useBatchedEvents.ts b/src/nostr/useBatchedEvents.ts index f53a165..987aeaf 100644 --- a/src/nostr/useBatchedEvents.ts +++ b/src/nostr/useBatchedEvents.ts @@ -120,6 +120,9 @@ setInterval(() => { setActiveBatchSubscriptions(count); }, 1000); +const EmptyBatchedEvents = Object.freeze({ events: Object.freeze([]), completed: true }); +const emptyBatchedEvents = () => EmptyBatchedEvents; + const { exec } = useBatch(() => ({ interval: 2000, batchSize: 150, @@ -190,8 +193,6 @@ const { exec } = useBatch(() => ({ }); }; - const emptyBatchedEvents = () => ({ events: [], completed: true }); - const finalizeTasks = () => { tasks.forEach((task) => { const signal = signals.get(task.id); diff --git a/src/nostr/useCommands.test.ts b/src/nostr/useCommands.test.ts new file mode 100644 index 0000000..25eb1b3 --- /dev/null +++ b/src/nostr/useCommands.test.ts @@ -0,0 +1,21 @@ +import assert from 'assert'; +import { describe, it } from 'vitest'; +import { buildTags } from './useCommands'; + +describe('buildTags', () => { + it('should place a reply tag as first one if it is an only element', () => { + const replyEventId = '6b280916873768d752cb95a0d2787a184926db8b717394c66ae255b221e607a8a'; + const actual = buildTags({ replyEventId }); + const expect = [['e', replyEventId, '', 'reply']]; + + assert.deepStrictEqual(actual, expect); + }); + + it("should add a url as a 'r' tag", () => { + const urls = ['https://syusui-s.github.io/rabbit/']; + const actual = buildTags({ urls }); + const expect = [['r', 'https://syusui-s.github.io/rabbit/']]; + + assert.deepStrictEqual(actual, expect); + }); +}); diff --git a/src/nostr/useCommands.ts b/src/nostr/useCommands.ts index 4adef47..d8e676f 100644 --- a/src/nostr/useCommands.ts +++ b/src/nostr/useCommands.ts @@ -1,28 +1,27 @@ -import { - getEventHash, - type UnsignedEvent, - type Event as NostrEvent, - type Pub, - type Kind, -} from 'nostr-tools'; +import { getEventHash, Kind, type UnsignedEvent, type Pub } from 'nostr-tools'; -import '@/types/nostr.d'; +// import '@/types/nostr.d'; import usePool from '@/nostr/usePool'; import epoch from '@/utils/epoch'; -export type PublishTextNoteParams = { - relayUrls: string[]; - pubkey: string; - content: string; +export type TagParams = { tags?: string[][]; notifyPubkeys?: string[]; rootEventId?: string; mentionEventIds?: string[]; replyEventId?: string; + hashtags?: string[]; + urls?: string[]; contentWarning?: string; }; +export type PublishTextNoteParams = { + relayUrls: string[]; + pubkey: string; + content: string; +} & TagParams; + // NIP-20: Command Result const waitCommandResult = (pub: Pub, relayUrl: string): Promise => { return new Promise((resolve, reject) => { @@ -43,8 +42,10 @@ export const buildTags = ({ mentionEventIds, replyEventId, contentWarning, + hashtags, + urls, tags, -}: PublishTextNoteParams): string[][] => { +}: TagParams): string[][] => { // NIP-10 const eTags = []; const pTags = notifyPubkeys?.map((p) => ['p', p]) ?? []; @@ -61,6 +62,14 @@ export const buildTags = ({ eTags.push(['e', replyEventId, '', 'reply']); } + if (hashtags != null) { + hashtags.forEach((tag) => otherTags.push(['t', tag])); + } + + if (urls != null) { + urls.forEach((url) => otherTags.push(['r', url])); + } + if (contentWarning != null) { otherTags.push(['content-warning', contentWarning]); } @@ -162,6 +171,31 @@ const useCommands = () => { }; return publishEvent(relayUrls, preSignedEvent); }, + // useFollowingsのisFetchedが呼ばれたとしても全てのリレーから取得できたとは限らない + // 半数以上、あるいは5秒待ってみて応答があればそれを利用するみたいな仕組みが必要か? + updateContacts({ + relayUrls, + pubkey, + followingPubkeys, + content, + }: { + relayUrls: string[]; + pubkey: string; + followingPubkeys: string[]; + content: string; + }): Promise[]> { + const pTags = followingPubkeys.map((key) => ['p', key]); + + const preSignedEvent: UnsignedEvent = { + kind: Kind.Contacts, + pubkey, + created_at: epoch(), + tags: pTags, + content, + }; + return publishEvent(relayUrls, preSignedEvent); + // TODO publishできたら、invalidateをしないといけない + }, }; }; diff --git a/src/utils/imageUrl.test.ts b/src/utils/imageUrl.test.ts new file mode 100644 index 0000000..3c2fa50 --- /dev/null +++ b/src/utils/imageUrl.test.ts @@ -0,0 +1,11 @@ +import assert from 'assert'; +import { describe, it } from 'vitest'; +import { fixUrl } from './imageUrl'; + +describe('fixUrl', () => { + it('should return an image url for a given imgur.com URL with additional path', () => { + const actual = fixUrl('https://imgur.com/uBf5Qts.jpeg'); + const expected = 'https://i.imgur.com/uBf5Qtsl.webp'; + assert.deepStrictEqual(actual, expected); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 02ec1db..22cbb6a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ +import path from 'path'; // eslint-disable-next-line import/no-extraneous-dependencies import { defineConfig } from 'vitest/config'; export default defineConfig({ test: {}, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, });