diff --git a/src/batchClient.ts b/src/batchClient.ts deleted file mode 100644 index 8d7b88d..0000000 --- a/src/batchClient.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * This file is licensed under MIT license, not AGPL. - * - * Copyright (c) 2023 Syusui Moyatani - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -import { matchFilter, type Filter, type Event as NostrEvent, type SimplePool } from 'nostr-tools'; - -export type BatchExecutorConstructor = { - executor: (reqs: Task[]) => void; - interval: number; - size: number; -}; - -let incrementalId = 0; - -const nextId = (): number => { - const currentId = incrementalId; - incrementalId += 1; - return currentId; -}; - -export class ObservableTask { - id: number; - - req: BatchRequest; - - res: BatchResponse | undefined; - - isCompleted = false; - - #updateListeners: ((res: BatchResponse) => void)[] = []; - - #completeListeners: (() => void)[] = []; - - #promise: Promise; - - constructor(req: BatchRequest) { - this.id = nextId(); - this.req = req; - this.#promise = new Promise((resolve, reject) => { - this.onComplete(() => { - if (this.res != null) { - resolve(this.res); - } else { - reject(); - } - }); - }); - } - - #executeUpdateListeners() { - const { res } = this; - if (res != null) { - this.#updateListeners.forEach((listener) => { - listener(res); - }); - } - } - - update(res: BatchResponse) { - this.res = res; - this.#executeUpdateListeners(); - } - - updateWith(f: (current: BatchResponse | undefined) => BatchResponse) { - this.res = f(this.res); - this.#executeUpdateListeners(); - } - - complete() { - this.isCompleted = true; - this.#completeListeners.forEach((listener) => { - listener(); - }); - } - - onUpdate(f: (res: BatchResponse) => void) { - this.#updateListeners.push(f); - } - - onComplete(f: () => void) { - this.#completeListeners.push(f); - } - - toPromise(): Promise { - return this.#promise; - } -} - -export class BatchExecutor { - #executor: (reqs: Task[]) => void; - - #interval: number; - - #size: number; - - #tasks: Task[] = []; - - #timerId: ReturnType | null = null; - - constructor({ executor, interval, size }: BatchExecutorConstructor) { - this.#executor = executor; - this.#interval = interval; - this.#size = size; - } - - #executeTasks() { - this.#executor(this.#tasks); - this.#tasks = []; - } - - #startTimerIfNotStarted() { - if (this.#timerId == null) { - this.#timerId = setTimeout(() => { - this.#executeTasks(); - this.stop(); - }, this.#interval); - } - } - - pushTask(task: Task) { - this.#tasks.push(task); - if (this.#tasks.length < this.#size) { - this.#startTimerIfNotStarted(); - } else { - this.#executeTasks(); - } - } - - stop() { - if (this.#timerId != null) { - clearTimeout(this.#timerId); - this.#timerId = null; - } - } -} - -export type BatchSubscriptionTask = ObservableTask; - -export class BatchSubscription { - #batchExecutor: BatchExecutor; - - constructor(pool: SimplePool, relays: string[]) { - this.#batchExecutor = new BatchExecutor({ - interval: 2000, - size: 50, - executor: (tasks) => { - const filterTaskMap = new Map(); - tasks.forEach((task) => { - const filters = task.req; - filters.forEach((filter) => { - filterTaskMap.set(filter, task); - }); - }); - - const mergedFilter = [...filterTaskMap.keys()]; - const sub = pool.sub(relays, mergedFilter); - const filterEvents = new Map(); - - sub.on('event', (event: NostrEvent & { id: string }) => { - mergedFilter.forEach((filter) => { - if (matchFilter(filter, event)) { - const task = filterTaskMap.get(filter); - if (task == null) { - console.error('task for filter not found', filter); - return; - } - task.updateWith((current) => { - if (current == null) return [event]; - return [...current, event]; - }); - } - }); - }); - - sub.on('eose', () => { - tasks.forEach((task) => { - task.complete(); - }); - sub.unsub(); - }); - }, - }); - } - - sub(filters: Filter[]): BatchSubscriptionTask { - const task = new ObservableTask(filters); - this.#batchExecutor.pushTask(task); - return task; - } -} diff --git a/src/components/Column.tsx b/src/components/Column.tsx index b3b79ab..5949e6c 100644 --- a/src/components/Column.tsx +++ b/src/components/Column.tsx @@ -2,8 +2,8 @@ import { Show, type JSX, type Component } from 'solid-js'; import ArrowLeft from 'heroicons/24/outline/arrow-left.svg'; import { useHandleCommand } from '@/hooks/useCommandBus'; -import { ColumnContext, useColumnState } from '@/components/ColumnContext'; -import ColumnContentDisplay from '@/components/ColumnContentDisplay'; +import { TimelineContext, useTimelineState } from '@/components/TimelineContext'; +import TimelineContentDisplay from '@/components/TimelineContentDisplay'; export type ColumnProps = { name: string; @@ -16,7 +16,7 @@ export type ColumnProps = { const Column: Component = (props) => { let columnDivRef: HTMLDivElement | undefined; - const columnState = useColumnState(); + const timelineState = useTimelineState(); const width = () => props.width ?? 'medium'; @@ -39,7 +39,7 @@ const Column: Component = (props) => { })); return ( - +
= (props) => { {/* 🏠 */} {props.name}
-
    {props.children}
- - {(columnContent) => ( +
{props.children}
+ + {(timeline) => (
    - +
)}
-
+ ); }; diff --git a/src/components/ColumnContentDisplay.tsx b/src/components/ColumnContentDisplay.tsx deleted file mode 100644 index 7a74d27..0000000 --- a/src/components/ColumnContentDisplay.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Switch, Match, type Component } from 'solid-js'; - -import useConfig from '@/nostr/useConfig'; - -import { type ColumnContent } from '@/components/ColumnContext'; -import Timeline from '@/components/Timeline'; -import useSubscription from '@/nostr/useSubscription'; - -const RepliesDisplay: Component<{ eventId: string }> = (props) => { - const { config } = useConfig(); - - const { events } = useSubscription(() => ({ - relayUrls: config().relayUrls, - filters: [ - { kinds: [1], ids: [props.eventId], limit: 25 }, - { kinds: [1], '#e': [props.eventId], limit: 25 }, - ], - })); - - return ; -}; - -const ColumnContentDisplay: Component<{ columnContent: ColumnContent }> = (props) => { - return ( - - - {(replies) => } - - - ); -}; - -export default ColumnContentDisplay; diff --git a/src/components/ColumnContext.tsx b/src/components/ColumnContext.tsx deleted file mode 100644 index 8f52b37..0000000 --- a/src/components/ColumnContext.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { createContext, useContext, type JSX } from 'solid-js'; -import { createStore } from 'solid-js/store'; - -export type ColumnContent = { - type: 'Replies'; - eventId: string; -}; - -export type ColumnState = { - content?: ColumnContent; -}; - -export type UseColumnState = { - columnState: ColumnState; - setColumnContent: (content: ColumnContent) => void; - clearColumnContext: () => void; -}; - -export const ColumnContext = createContext(); - -export const useColumnContext = () => useContext(ColumnContext); - -export const useColumnState = (): UseColumnState => { - const [columnState, setColumnState] = createStore({}); - - return { - columnState, - setColumnContent: (content: ColumnContent) => setColumnState('content', content), - clearColumnContext: () => setColumnState('content', undefined), - }; -}; diff --git a/src/components/NotePostForm.tsx b/src/components/NotePostForm.tsx index 0790470..f178e63 100644 --- a/src/components/NotePostForm.tsx +++ b/src/components/NotePostForm.tsx @@ -183,6 +183,23 @@ const NotePostForm: Component = (props) => { uploadFilesMutation.mutate(files); }; + const handlePaste: JSX.EventHandler = (ev) => { + if (uploadFilesMutation.isLoading) return; + const items = [...(ev?.clipboardData?.items ?? [])]; + + const files: File[] = []; + items.forEach((item) => { + if (item.kind === 'file') { + ev.preventDefault(); + const file = item.getAsFile(); + if (file == null) return; + files.push(file); + } + }); + if (files.length === 0) return; + uploadFilesMutation.mutate(files); + }; + const handleDragOver: JSX.EventHandler = (ev) => { ev.preventDefault(); }; @@ -239,6 +256,7 @@ const NotePostForm: Component = (props) => { onKeyDown={handleKeyDown} onDragOver={handleDragOver} onDrop={handleDrop} + onPaste={handlePaste} value={text()} />
diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index 2190872..f1c12ca 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -6,16 +6,15 @@ import DeprecatedRepost from '@/components/DeprecatedRepost'; export type TimelineProps = { events: NostrEvent[]; - embedding?: boolean; }; const Timeline: Component = (props) => { return ( {(event) => ( - unknown event
}> + 未対応のイベント種別({event.kind})}> - + diff --git a/src/components/TimelineContentDisplay.tsx b/src/components/TimelineContentDisplay.tsx new file mode 100644 index 0000000..4d066f0 --- /dev/null +++ b/src/components/TimelineContentDisplay.tsx @@ -0,0 +1,50 @@ +import { Switch, Match, type Component } from 'solid-js'; +import { Filter, Event as NostrEvent } from 'nostr-tools'; +import uniq from 'lodash/uniq'; + +import useConfig from '@/nostr/useConfig'; + +import { type TimelineContent } from '@/components/TimelineContext'; +import Timeline from '@/components/Timeline'; +import useSubscription from '@/nostr/useSubscription'; +import eventWrapper from '@/core/event'; + +const relatedEvents = (rawEvent: NostrEvent) => { + const event = () => eventWrapper(rawEvent); + const ids = [rawEvent.id]; + + const rootId = event().rootEvent()?.id; + if (rootId != null) ids.push(rootId); + + const replyId = event().replyingToEvent()?.id; + if (replyId != null) ids.push(replyId); + + return uniq(ids); +}; + +const RepliesDisplay: Component<{ event: NostrEvent }> = (props) => { + const { config } = useConfig(); + + const { events } = useSubscription(() => ({ + relayUrls: config().relayUrls, + filters: [ + { kinds: [1], ids: relatedEvents(props.event), limit: 25 }, + { kinds: [1], '#e': [props.event.id], limit: 25 } as Filter, + ], + limit: 200, + })); + + return ; +}; + +const TimelineContentDisplay: Component<{ timelineContent: TimelineContent }> = (props) => { + return ( + + + {(replies) => } + + + ); +}; + +export default TimelineContentDisplay; diff --git a/src/components/TimelineContext.tsx b/src/components/TimelineContext.tsx new file mode 100644 index 0000000..874d036 --- /dev/null +++ b/src/components/TimelineContext.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext } from 'solid-js'; +import { createStore } from 'solid-js/store'; +import { Event as NostrEvent } from 'nostr-tools'; + +export type TimelineContent = { + type: 'Replies'; + event: NostrEvent; +}; + +export type TimelineState = { + content?: TimelineContent; +}; + +export type UseTimelineState = { + timelineState: TimelineState; + setTimeline: (content: TimelineContent) => void; + clearTimeline: () => void; +}; + +export const TimelineContext = createContext(); + +export const useTimelineContext = () => useContext(TimelineContext); + +export const useTimelineState = (): UseTimelineState => { + const [timelineState, setTimelineState] = createStore({}); + + return { + timelineState, + setTimeline: (content: TimelineContent) => setTimelineState('content', content), + clearTimeline: () => setTimelineState('content', undefined), + }; +}; diff --git a/src/components/notification/Reaction.tsx b/src/components/notification/Reaction.tsx index dc94d83..b71147c 100644 --- a/src/components/notification/Reaction.tsx +++ b/src/components/notification/Reaction.tsx @@ -42,7 +42,7 @@ const Reaction: Component = (props) => { -
+
= (props) => { />
-
+
+ + */} ); }; diff --git a/src/components/textNote/TextNoteContentDisplay.tsx b/src/components/textNote/TextNoteContentDisplay.tsx index c2434ec..4094f42 100644 --- a/src/components/textNote/TextNoteContentDisplay.tsx +++ b/src/components/textNote/TextNoteContentDisplay.tsx @@ -43,10 +43,10 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
); } - if (item.data.type === 'npub' && props.embedding) { + if (item.data.type === 'npub') { return ; } - if (item.data.type === 'nprofile' && props.embedding) { + if (item.data.type === 'nprofile') { return ; } return {item.content}; diff --git a/src/components/textNote/TextNoteDisplay.tsx b/src/components/textNote/TextNoteDisplay.tsx index 2e16970..20234c0 100644 --- a/src/components/textNote/TextNoteDisplay.tsx +++ b/src/components/textNote/TextNoteDisplay.tsx @@ -22,7 +22,7 @@ import useModalState from '@/hooks/useModalState'; import UserNameDisplay from '@/components/UserDisplayName'; import TextNoteDisplayById from '@/components/textNote/TextNoteDisplayById'; -import { useColumnContext } from '@/components/ColumnContext'; +import { useTimelineContext } from '@/components/TimelineContext'; import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay'; import ContentWarningDisplay from '@/components/textNote/ContentWarningDisplay'; import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay'; @@ -45,7 +45,7 @@ const TextNoteDisplay: Component = (props) => { const formatDate = useFormatDate(); const pubkey = usePubkey(); const { showProfile } = useModalState(); - const columnContext = useColumnContext(); + const timelineContext = useTimelineContext(); const [showReplyForm, setShowReplyForm] = createSignal(false); const closeReplyForm = () => setShowReplyForm(false); @@ -209,9 +209,9 @@ const TextNoteDisplay: Component = (props) => { type="button" class="hover:underline" onClick={() => { - columnContext?.setColumnContent({ + timelineContext?.setTimeline({ type: 'Replies', - eventId: event().rootEvent()?.id ?? props.event.id, + event: props.event, }); }} > diff --git a/src/core/parseTextNote.ts b/src/core/parseTextNote.ts index 3c9ad08..d44d98f 100644 --- a/src/core/parseTextNote.ts +++ b/src/core/parseTextNote.ts @@ -56,18 +56,20 @@ export type ParsedTextNoteNode = export type ParsedTextNote = ParsedTextNoteNode[]; +const tagRefRegex = /(?:#\[(?\d+)\])/g; +const hashTagRegex = /#(?[^[-^`:-@!-/{-~\d\s][^[-^`:-@!-/{-~\s]+)/g; +// raw NIP-19 codes, NIP-21 links (NIP-27) +// nrelay and naddr is not supported by nostr-tools +const mentionRegex = /(?:nostr:)?(?(npub|note|nprofile|nevent)1[ac-hj-np-z02-9]+)/gi; +const urlRegex = + /(?(?:https?|wss?):\/\/[-a-zA-Z0-9.:]+(?:\/[-[\]~!$&'()*+.,:;@&=%\w]+|\/)*(?:\?[-[\]~!$&'()*+.,/:;%@&=\w?]+)?(?:#[-[\]~!$&'()*+.,/:;%@\w&=?#]+)?)/g; + const parseTextNote = (event: NostrEvent): ParsedTextNote => { const matches = [ - ...event.content.matchAll(/(?:#\[(?\d+)\])/g), - ...event.content.matchAll(/#(?[^[-^`:-@!-/{-~\d\s][^[-^`:-@!-/{-~\s]+)/g), - // raw NIP-19 codes, NIP-21 links (NIP-27) - // nrelay and naddr is not supported by nostr-tools - ...event.content.matchAll( - /(?:nostr:)?(?(npub|note|nprofile|nevent)1[ac-hj-np-z02-9]+)/gi, - ), - ...event.content.matchAll( - /(?(?:https?|wss?):\/\/[-a-zA-Z0-9.]+(?:\/[-[\]~!$&'()*+.,:;@%\w]+|\/)*(?:\?[-[\]~!$&'()*+.,/:;%@\w&=]+)?(?:#[-[\]~!$&'()*+.,/:;%@\w?&=#]+)?)/g, - ), + ...event.content.matchAll(tagRefRegex), + ...event.content.matchAll(hashTagRegex), + ...event.content.matchAll(mentionRegex), + ...event.content.matchAll(urlRegex), ].sort((a, b) => (a.index as number) - (b.index as number)); let pos = 0; const result: ParsedTextNote = []; diff --git a/src/hooks/useResizedImage.ts b/src/hooks/useResizedImage.ts index 6acd3c4..5caffc7 100644 --- a/src/hooks/useResizedImage.ts +++ b/src/hooks/useResizedImage.ts @@ -39,7 +39,7 @@ const useResizedImage = ({ ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, dw, dh); - const dataUrl = canvas.toDataURL('image/jpeg', encoderOption); + const dataUrl = canvas.toDataURL('image/jpeg'); setResizedImage(dataUrl); }); diff --git a/src/nostr/useBatchedEvents.ts b/src/nostr/useBatchedEvents.ts index 19960cb..f53a165 100644 --- a/src/nostr/useBatchedEvents.ts +++ b/src/nostr/useBatchedEvents.ts @@ -179,8 +179,7 @@ const { exec } = useBatch(() => ({ const resolveTasks = (registeredTasks: Task[], event: NostrEvent) => { registeredTasks.forEach((task) => { - const signal = - signals.get(task.id) ?? createRoot(() => createSignal({ events: [], completed: false })); + const signal = signals.get(task.id) ?? createSignal({ events: [], completed: false }); signals.set(task.id, signal); const [batchedEvents, setBatchedEvents] = signal; setBatchedEvents((current) => ({ @@ -213,10 +212,12 @@ const { exec } = useBatch(() => ({ count += 1; sub.on('event', (event: NostrEvent & { id: string }) => { + if (config().mutedPubkeys.includes(event.id)) return; if (event.kind === Kind.Metadata) { const registeredTasks = profileTasks.get(event.pubkey) ?? []; resolveTasks(registeredTasks, event); } else if (event.kind === Kind.Text) { + if (config().mutedPubkeys.includes(event.pubkey)) return; const registeredTasks = textNoteTasks.get(event.id) ?? []; resolveTasks(registeredTasks, event); } else if (event.kind === Kind.Reaction) { @@ -227,6 +228,7 @@ const { exec } = useBatch(() => ({ resolveTasks(registeredTasks, event); }); } else if ((event.kind as number) === 6) { + if (config().mutedPubkeys.includes(event.pubkey)) return; const eventTags = eventWrapper(event).taggedEvents(); eventTags.forEach((eventTag) => { const taggedEventId = eventTag.id; @@ -461,6 +463,8 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U staleTime: 5 * 60 * 1000, // 5 min cacheTime: 24 * 60 * 60 * 1000, // 24 hour refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchInterval: 0, }, ); diff --git a/src/nostr/useCommands.ts b/src/nostr/useCommands.ts index ed9381c..4adef47 100644 --- a/src/nostr/useCommands.ts +++ b/src/nostr/useCommands.ts @@ -11,6 +11,18 @@ import usePool from '@/nostr/usePool'; import epoch from '@/utils/epoch'; +export type PublishTextNoteParams = { + relayUrls: string[]; + pubkey: string; + content: string; + tags?: string[][]; + notifyPubkeys?: string[]; + rootEventId?: string; + mentionEventIds?: string[]; + replyEventId?: string; + contentWarning?: string; +}; + // NIP-20: Command Result const waitCommandResult = (pub: Pub, relayUrl: string): Promise => { return new Promise((resolve, reject) => { @@ -25,6 +37,41 @@ const waitCommandResult = (pub: Pub, relayUrl: string): Promise => { }); }; +export const buildTags = ({ + notifyPubkeys, + rootEventId, + mentionEventIds, + replyEventId, + contentWarning, + tags, +}: PublishTextNoteParams): string[][] => { + // NIP-10 + const eTags = []; + const pTags = notifyPubkeys?.map((p) => ['p', p]) ?? []; + const otherTags = []; + + // the order of e tags should be [rootId, ...mentionIds, replyIds] for old clients + if (rootEventId != null) { + eTags.push(['e', rootEventId, '', 'root']); + } + if (mentionEventIds != null) { + mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention'])); + } + if (replyEventId != null) { + eTags.push(['e', replyEventId, '', 'reply']); + } + + if (contentWarning != null) { + otherTags.push(['content-warning', contentWarning]); + } + + if (tags != null && tags.length > 0) { + otherTags.push(...tags); + } + + return [...eTags, ...pTags, ...otherTags]; +}; + const useCommands = () => { const pool = usePool(); @@ -32,7 +79,7 @@ const useCommands = () => { relayUrls: string[], event: UnsignedEvent, ): Promise[]> => { - const preSignedEvent: UnsignedEvent = { ...event }; + const preSignedEvent: UnsignedEvent & { id?: string } = { ...event }; preSignedEvent.id = getEventHash(preSignedEvent); if (window.nostr == null) { @@ -48,47 +95,15 @@ const useCommands = () => { }; // NIP-01 - const publishTextNote = ({ - relayUrls, - pubkey, - content, - tags, - contentWarning, - notifyPubkeys, - rootEventId, - mentionEventIds, - replyEventId, - }: { - relayUrls: string[]; - pubkey: string; - content: string; - tags?: string[][]; - notifyPubkeys?: string[]; - rootEventId?: string; - mentionEventIds?: string[]; - replyEventId?: string; - contentWarning?: string; - }): Promise[]> => { - // NIP-10 - const pTags = notifyPubkeys?.map((p) => ['p', p]) ?? []; - const eTags = []; - if (rootEventId != null) eTags.push(['e', rootEventId, '', 'root']); - if (mentionEventIds != null) - mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention'])); - if (replyEventId != null) eTags.push(['e', replyEventId, '', 'reply']); - - const additionalTags = tags != null ? [...tags] : []; - if (contentWarning != null && content.length > 0) { - additionalTags.push(['content-warning', contentWarning]); - } - - const mergedTags = [...eTags, ...pTags, ...additionalTags]; + const publishTextNote = (params: PublishTextNoteParams): Promise[]> => { + const { relayUrls, pubkey, content } = params; + const tags = buildTags(params); const preSignedEvent: UnsignedEvent = { kind: 1, pubkey, created_at: epoch(), - tags: mergedTags, + tags, content, }; return publishEvent(relayUrls, preSignedEvent); diff --git a/src/nostr/useConfig.ts b/src/nostr/useConfig.ts index 34c82b7..9a52feb 100644 --- a/src/nostr/useConfig.ts +++ b/src/nostr/useConfig.ts @@ -9,6 +9,7 @@ export type Config = { dateFormat: 'relative' | 'absolute-long' | 'absolute-short'; keepOpenPostForm: boolean; showImage: boolean; + mutedPubkeys: string[]; }; type UseConfig = { @@ -16,6 +17,8 @@ type UseConfig = { setConfig: Setter; addRelay: (url: string) => void; removeRelay: (url: string) => void; + addMutedPubkey: (pubkey: string) => void; + removeMutedPubkey: (pubkey: string) => void; }; const InitialConfig = (): Config => { @@ -40,6 +43,7 @@ const InitialConfig = (): Config => { dateFormat: 'relative', keepOpenPostForm: false, showImage: true, + mutedPubkeys: [], }; }; @@ -63,11 +67,21 @@ const useConfig = (): UseConfig => { setConfig('relayUrls', (current) => current.filter((e) => e !== relayUrl)); }; + const addMutedPubkey = (pubkey: string) => { + setConfig('mutedPubkeys', (current) => [...current, pubkey]); + }; + + const removeMutedPubkey = (pubkey: string) => { + setConfig('mutedPubkeys', (current) => current.filter((e) => e !== pubkey)); + }; + return { config: () => config, setConfig, addRelay, removeRelay, + addMutedPubkey, + removeMutedPubkey, }; }; diff --git a/src/nostr/useSubscription.ts b/src/nostr/useSubscription.ts index 5f72c3b..d46a541 100644 --- a/src/nostr/useSubscription.ts +++ b/src/nostr/useSubscription.ts @@ -3,6 +3,7 @@ import type { Event as NostrEvent, Filter, SubscriptionOptions } from 'nostr-too import uniqBy from 'lodash/uniqBy'; import usePool from '@/nostr/usePool'; import useStats from './useStats'; +import useConfig from './useConfig'; export type UseSubscriptionProps = { relayUrls: string[]; @@ -34,6 +35,7 @@ setInterval(() => { }, 1000); const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => { + const { config } = useConfig(); const pool = usePool(); const [events, setEvents] = createSignal([]); @@ -56,6 +58,9 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => { if (onEvent != null) { onEvent(event as NostrEvent & { id: string }); } + if (config().mutedPubkeys.includes(event.pubkey)) { + return; + } if (props.clientEventFilter != null && !props.clientEventFilter(event)) { return; } diff --git a/src/utils/batch/BatchExecutor.ts b/src/utils/batch/BatchExecutor.ts new file mode 100644 index 0000000..151f553 --- /dev/null +++ b/src/utils/batch/BatchExecutor.ts @@ -0,0 +1,76 @@ +/** + * This file is licensed under MIT license, not AGPL. + * + * Copyright (c) 2023 Syusui Moyatani + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +export type BatchExecutorConstructor = { + executor: (reqs: Task[]) => void; + interval: number; + size: number; +}; + +export class BatchExecutor { + #executor: (reqs: Task[]) => void; + + #interval: number; + + #size: number; + + #tasks: Task[] = []; + + #timerId: ReturnType | null = null; + + constructor({ executor, interval, size }: BatchExecutorConstructor) { + this.#executor = executor; + this.#interval = interval; + this.#size = size; + } + + #executeTasks() { + this.#executor(this.#tasks); + this.#tasks = []; + } + + #startTimerIfNotStarted() { + if (this.#timerId == null) { + this.#timerId = setTimeout(() => { + this.#executeTasks(); + this.stop(); + }, this.#interval); + } + } + + pushTask(task: Task) { + this.#tasks.push(task); + if (this.#tasks.length < this.#size) { + this.#startTimerIfNotStarted(); + } else { + this.#executeTasks(); + } + } + + stop() { + if (this.#timerId != null) { + clearTimeout(this.#timerId); + this.#timerId = null; + } + } +} diff --git a/src/utils/batch/ObservableTask.ts b/src/utils/batch/ObservableTask.ts new file mode 100755 index 0000000..9006d89 --- /dev/null +++ b/src/utils/batch/ObservableTask.ts @@ -0,0 +1,97 @@ +/** + * This file is licensed under MIT license, not AGPL. + * + * Copyright (c) 2023 Syusui Moyatani + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import nextId from './nextId'; + +export default class ObservableTask { + id: number; + + req: BatchRequest; + + res: BatchResponse | undefined; + + isCompleted = false; + + #updateListeners: ((res: BatchResponse) => void)[] = []; + + #completeListeners: (() => void)[] = []; + + #promise: Promise; + + constructor(req: BatchRequest) { + this.id = nextId(); + this.req = req; + this.#promise = new Promise((resolve, reject) => { + this.onComplete(() => { + if (this.res != null) { + resolve(this.res); + } else { + reject(); + } + }); + }); + } + + #executeUpdateListeners() { + const { res } = this; + if (res != null) { + this.#updateListeners.forEach((listener) => { + listener(res); + }); + } + } + + update(res: BatchResponse) { + this.res = res; + this.#executeUpdateListeners(); + } + + updateWith(f: (current: BatchResponse | undefined) => BatchResponse) { + this.res = f(this.res); + this.#executeUpdateListeners(); + } + + complete() { + this.isCompleted = true; + this.#completeListeners.forEach((listener) => { + listener(); + }); + } + + onUpdate(f: (res: BatchResponse) => void) { + this.#updateListeners.push(f); + } + + // alias for onUpdate. + subscribe(fn: (v: BatchResponse) => void) { + this.onUpdate(fn); + } + + onComplete(f: () => void) { + this.#completeListeners.push(f); + } + + toPromise(): Promise { + return this.#promise; + } +} diff --git a/src/utils/batch/nextId.ts b/src/utils/batch/nextId.ts new file mode 100644 index 0000000..6b97d14 --- /dev/null +++ b/src/utils/batch/nextId.ts @@ -0,0 +1,32 @@ +/** + * This file is licensed under MIT license, not AGPL. + * + * Copyright (c) 2023 Syusui Moyatani + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +let incrementalId = 0; + +const nextId = (): number => { + const currentId = incrementalId; + incrementalId += 1; + return currentId; +}; + +export default nextId;