mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-19 23:14:27 +01:00
feat: parse textnote and include metadata as tags
This commit is contained in:
@@ -21,11 +21,12 @@ import UserNameDisplay from '@/components/UserDisplayName';
|
|||||||
import eventWrapper from '@/core/event';
|
import eventWrapper from '@/core/event';
|
||||||
|
|
||||||
import useConfig from '@/nostr/useConfig';
|
import useConfig from '@/nostr/useConfig';
|
||||||
import useCommands from '@/nostr/useCommands';
|
import useCommands, { PublishTextNoteParams } from '@/nostr/useCommands';
|
||||||
import usePubkey from '@/nostr/usePubkey';
|
import usePubkey from '@/nostr/usePubkey';
|
||||||
import { useHandleCommand } from '@/hooks/useCommandBus';
|
import { useHandleCommand } from '@/hooks/useCommandBus';
|
||||||
|
|
||||||
import { uploadNostrBuild, uploadFiles } from '@/utils/imageUpload';
|
import { uploadNostrBuild, uploadFiles } from '@/utils/imageUpload';
|
||||||
|
import parseTextNote from '@/core/parseTextNote';
|
||||||
|
|
||||||
type NotePostFormProps = {
|
type NotePostFormProps = {
|
||||||
replyTo?: NostrEvent;
|
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) => {
|
const NotePostForm: Component<NotePostFormProps> = (props) => {
|
||||||
let textAreaRef: HTMLTextAreaElement | undefined;
|
let textAreaRef: HTMLTextAreaElement | undefined;
|
||||||
let fileInputRef: HTMLInputElement | undefined;
|
let fileInputRef: HTMLInputElement | undefined;
|
||||||
@@ -113,9 +144,19 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
const mentionedPubkeys: Accessor<string[]> = createMemo(
|
const mentionedPubkeys: Accessor<string[]> = createMemo(
|
||||||
() => replyTo()?.mentionedPubkeysWithoutAuthor() ?? [],
|
() => replyTo()?.mentionedPubkeysWithoutAuthor() ?? [],
|
||||||
);
|
);
|
||||||
const notifyPubkeys = (pubkey: string): string[] | undefined => {
|
|
||||||
if (props.replyTo === undefined) return undefined;
|
const notifyPubkeys = (pubkey: string, pubkeyReferences: string[]): string[] => {
|
||||||
return uniq([props.replyTo.pubkey, ...mentionedPubkeys(), pubkey]);
|
if (props.replyTo == null) return pubkeyReferences;
|
||||||
|
return uniq([
|
||||||
|
// 返信先を先頭に
|
||||||
|
props.replyTo.pubkey,
|
||||||
|
// 自分も通知欄に表示するために表示(他アプリとの互換性)
|
||||||
|
pubkey,
|
||||||
|
// その他の返信先
|
||||||
|
...mentionedPubkeys(),
|
||||||
|
// 本文中の公開鍵(npub)
|
||||||
|
...pubkeyReferences,
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
@@ -127,15 +168,23 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
console.error('pubkey is not available');
|
console.error('pubkey is not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let textNote: Parameters<typeof commands.publishTextNote>[0] = {
|
|
||||||
|
const { hashtags, pubkeyReferences, eventReferences, urlReferences } = parseAndExtract(text());
|
||||||
|
|
||||||
|
let textNote: PublishTextNoteParams = {
|
||||||
relayUrls: config().relayUrls,
|
relayUrls: config().relayUrls,
|
||||||
pubkey,
|
pubkey,
|
||||||
content: text(),
|
content: text(),
|
||||||
|
notifyPubkeys: pubkeyReferences,
|
||||||
|
mentionEventIds: eventReferences,
|
||||||
|
hashtags,
|
||||||
|
urls: urlReferences,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (replyTo() != null) {
|
if (replyTo() != null) {
|
||||||
textNote = {
|
textNote = {
|
||||||
...textNote,
|
...textNote,
|
||||||
notifyPubkeys: notifyPubkeys(pubkey),
|
notifyPubkeys: notifyPubkeys(pubkey, pubkeyReferences),
|
||||||
rootEventId: replyTo()?.rootEvent()?.id ?? replyTo()?.id,
|
rootEventId: replyTo()?.rootEvent()?.id ?? replyTo()?.id,
|
||||||
replyEventId: replyTo()?.id,
|
replyEventId: replyTo()?.id,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -118,6 +118,15 @@ describe('parseTextNote', () => {
|
|||||||
assert.deepStrictEqual(parsed, expected);
|
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', () => {
|
it('should ignore invalid URL', () => {
|
||||||
const parsed = parseTextNote('ws://localhost:port');
|
const parsed = parseTextNote('ws://localhost:port');
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ setInterval(() => {
|
|||||||
setActiveBatchSubscriptions(count);
|
setActiveBatchSubscriptions(count);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
const EmptyBatchedEvents = Object.freeze({ events: Object.freeze([]), completed: true });
|
||||||
|
const emptyBatchedEvents = () => EmptyBatchedEvents;
|
||||||
|
|
||||||
const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
||||||
interval: 2000,
|
interval: 2000,
|
||||||
batchSize: 150,
|
batchSize: 150,
|
||||||
@@ -190,8 +193,6 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyBatchedEvents = () => ({ events: [], completed: true });
|
|
||||||
|
|
||||||
const finalizeTasks = () => {
|
const finalizeTasks = () => {
|
||||||
tasks.forEach((task) => {
|
tasks.forEach((task) => {
|
||||||
const signal = signals.get(task.id);
|
const signal = signals.get(task.id);
|
||||||
|
|||||||
21
src/nostr/useCommands.test.ts
Normal file
21
src/nostr/useCommands.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,28 +1,27 @@
|
|||||||
import {
|
import { getEventHash, Kind, type UnsignedEvent, type Pub } from 'nostr-tools';
|
||||||
getEventHash,
|
|
||||||
type UnsignedEvent,
|
|
||||||
type Event as NostrEvent,
|
|
||||||
type Pub,
|
|
||||||
type Kind,
|
|
||||||
} from 'nostr-tools';
|
|
||||||
|
|
||||||
import '@/types/nostr.d';
|
// import '@/types/nostr.d';
|
||||||
import usePool from '@/nostr/usePool';
|
import usePool from '@/nostr/usePool';
|
||||||
|
|
||||||
import epoch from '@/utils/epoch';
|
import epoch from '@/utils/epoch';
|
||||||
|
|
||||||
export type PublishTextNoteParams = {
|
export type TagParams = {
|
||||||
relayUrls: string[];
|
|
||||||
pubkey: string;
|
|
||||||
content: string;
|
|
||||||
tags?: string[][];
|
tags?: string[][];
|
||||||
notifyPubkeys?: string[];
|
notifyPubkeys?: string[];
|
||||||
rootEventId?: string;
|
rootEventId?: string;
|
||||||
mentionEventIds?: string[];
|
mentionEventIds?: string[];
|
||||||
replyEventId?: string;
|
replyEventId?: string;
|
||||||
|
hashtags?: string[];
|
||||||
|
urls?: string[];
|
||||||
contentWarning?: string;
|
contentWarning?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PublishTextNoteParams = {
|
||||||
|
relayUrls: string[];
|
||||||
|
pubkey: string;
|
||||||
|
content: string;
|
||||||
|
} & TagParams;
|
||||||
|
|
||||||
// NIP-20: Command Result
|
// NIP-20: Command Result
|
||||||
const waitCommandResult = (pub: Pub, relayUrl: string): Promise<void> => {
|
const waitCommandResult = (pub: Pub, relayUrl: string): Promise<void> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -43,8 +42,10 @@ export const buildTags = ({
|
|||||||
mentionEventIds,
|
mentionEventIds,
|
||||||
replyEventId,
|
replyEventId,
|
||||||
contentWarning,
|
contentWarning,
|
||||||
|
hashtags,
|
||||||
|
urls,
|
||||||
tags,
|
tags,
|
||||||
}: PublishTextNoteParams): string[][] => {
|
}: TagParams): string[][] => {
|
||||||
// NIP-10
|
// NIP-10
|
||||||
const eTags = [];
|
const eTags = [];
|
||||||
const pTags = notifyPubkeys?.map((p) => ['p', p]) ?? [];
|
const pTags = notifyPubkeys?.map((p) => ['p', p]) ?? [];
|
||||||
@@ -61,6 +62,14 @@ export const buildTags = ({
|
|||||||
eTags.push(['e', replyEventId, '', 'reply']);
|
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) {
|
if (contentWarning != null) {
|
||||||
otherTags.push(['content-warning', contentWarning]);
|
otherTags.push(['content-warning', contentWarning]);
|
||||||
}
|
}
|
||||||
@@ -162,6 +171,31 @@ const useCommands = () => {
|
|||||||
};
|
};
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
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をしないといけない
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
11
src/utils/imageUrl.test.ts
Normal file
11
src/utils/imageUrl.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import path from 'path';
|
||||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {},
|
test: {},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user