feat: parse textnote and include metadata as tags

This commit is contained in:
Shusui MOYATANI
2023-04-12 12:18:47 +09:00
parent a532d5ebf5
commit 748e12df7b
7 changed files with 152 additions and 21 deletions

View File

@@ -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<NotePostFormProps> = (props) => {
let textAreaRef: HTMLTextAreaElement | undefined;
let fileInputRef: HTMLInputElement | undefined;
@@ -113,9 +144,19 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
const mentionedPubkeys: Accessor<string[]> = 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<NotePostFormProps> = (props) => {
console.error('pubkey is not available');
return;
}
let textNote: Parameters<typeof commands.publishTextNote>[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,
};

View File

@@ -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');

View File

@@ -120,6 +120,9 @@ setInterval(() => {
setActiveBatchSubscriptions(count);
}, 1000);
const EmptyBatchedEvents = Object.freeze({ events: Object.freeze([]), completed: true });
const emptyBatchedEvents = () => EmptyBatchedEvents;
const { exec } = useBatch<TaskArg, TaskRes>(() => ({
interval: 2000,
batchSize: 150,
@@ -190,8 +193,6 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
});
};
const emptyBatchedEvents = () => ({ events: [], completed: true });
const finalizeTasks = () => {
tasks.forEach((task) => {
const signal = signals.get(task.id);

View File

@@ -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);
});
});

View File

@@ -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<void> => {
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<Promise<void>[]> {
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をしないといけない
},
};
};

View File

@@ -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);
});
});

View File

@@ -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'),
},
},
});