This commit is contained in:
Shusui MOYATANI
2023-04-03 10:47:53 +09:00
parent 3e52a24f87
commit ed03386e50
21 changed files with 483 additions and 348 deletions

View File

@@ -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<Task> = {
executor: (reqs: Task[]) => void;
interval: number;
size: number;
};
let incrementalId = 0;
const nextId = (): number => {
const currentId = incrementalId;
incrementalId += 1;
return currentId;
};
export class ObservableTask<BatchRequest, BatchResponse> {
id: number;
req: BatchRequest;
res: BatchResponse | undefined;
isCompleted = false;
#updateListeners: ((res: BatchResponse) => void)[] = [];
#completeListeners: (() => void)[] = [];
#promise: Promise<BatchResponse>;
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<BatchResponse> {
return this.#promise;
}
}
export class BatchExecutor<Task> {
#executor: (reqs: Task[]) => void;
#interval: number;
#size: number;
#tasks: Task[] = [];
#timerId: ReturnType<typeof setTimeout> | null = null;
constructor({ executor, interval, size }: BatchExecutorConstructor<Task>) {
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<Filter[], NostrEvent[]>;
export class BatchSubscription {
#batchExecutor: BatchExecutor<BatchSubscriptionTask>;
constructor(pool: SimplePool, relays: string[]) {
this.#batchExecutor = new BatchExecutor<BatchSubscriptionTask>({
interval: 2000,
size: 50,
executor: (tasks) => {
const filterTaskMap = new Map<Filter, BatchSubscriptionTask>();
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<Filter, NostrEvent[]>();
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<Filter[], NostrEvent[]>(filters);
this.#batchExecutor.pushTask(task);
return task;
}
}

View File

@@ -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<ColumnProps> = (props) => {
let columnDivRef: HTMLDivElement | undefined;
const columnState = useColumnState();
const timelineState = useTimelineState();
const width = () => props.width ?? 'medium';
@@ -39,7 +39,7 @@ const Column: Component<ColumnProps> = (props) => {
}));
return (
<ColumnContext.Provider value={columnState}>
<TimelineContext.Provider value={timelineState}>
<div
ref={columnDivRef}
class="relative flex w-[80vw] shrink-0 snap-center snap-always flex-col border-r sm:snap-align-none"
@@ -54,14 +54,14 @@ const Column: Component<ColumnProps> = (props) => {
{/* <span class="column-icon">🏠</span> */}
<span class="column-name">{props.name}</span>
</div>
<ul class="flex flex-col overflow-y-scroll scroll-smooth">{props.children}</ul>
<Show when={columnState.columnState.content} keyed>
{(columnContent) => (
<div class="flex flex-col overflow-y-scroll scroll-smooth">{props.children}</div>
<Show when={timelineState.timelineState.content} keyed>
{(timeline) => (
<div class="absolute h-full w-full bg-white">
<div class="flex h-8 shrink-0 items-center border-b bg-white px-2">
<button
class="flex w-full items-center gap-1"
onClick={() => columnState?.clearColumnContext()}
onClick={() => timelineState?.clearTimeline()}
>
<div class="inline-block h-4 w-4">
<ArrowLeft />
@@ -70,13 +70,13 @@ const Column: Component<ColumnProps> = (props) => {
</button>
</div>
<ul class="flex h-full flex-col overflow-y-scroll scroll-smooth">
<ColumnContentDisplay columnContent={columnContent} />
<TimelineContentDisplay timelineContent={timeline} />
</ul>
</div>
)}
</Show>
</div>
</ColumnContext.Provider>
</TimelineContext.Provider>
);
};

View File

@@ -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 <Timeline events={[...events()].reverse()} embedding={false} />;
};
const ColumnContentDisplay: Component<{ columnContent: ColumnContent }> = (props) => {
return (
<Switch>
<Match when={props.columnContent.type === 'Replies' && props.columnContent} keyed>
{(replies) => <RepliesDisplay eventId={replies.eventId} />}
</Match>
</Switch>
);
};
export default ColumnContentDisplay;

View File

@@ -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<UseColumnState>();
export const useColumnContext = () => useContext(ColumnContext);
export const useColumnState = (): UseColumnState => {
const [columnState, setColumnState] = createStore<ColumnState>({});
return {
columnState,
setColumnContent: (content: ColumnContent) => setColumnState('content', content),
clearColumnContext: () => setColumnState('content', undefined),
};
};

View File

@@ -183,6 +183,23 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
uploadFilesMutation.mutate(files);
};
const handlePaste: JSX.EventHandler<HTMLTextAreaElement, ClipboardEvent> = (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<HTMLTextAreaElement, DragEvent> = (ev) => {
ev.preventDefault();
};
@@ -239,6 +256,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
onKeyDown={handleKeyDown}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPaste={handlePaste}
value={text()}
/>
<div class="flex items-end justify-end gap-1">

View File

@@ -6,16 +6,15 @@ import DeprecatedRepost from '@/components/DeprecatedRepost';
export type TimelineProps = {
events: NostrEvent[];
embedding?: boolean;
};
const Timeline: Component<TimelineProps> = (props) => {
return (
<For each={props.events}>
{(event) => (
<Switch fallback={<div>unknown event</div>}>
<Switch fallback={<div>{event.kind}</div>}>
<Match when={event.kind === Kind.Text}>
<TextNote event={event} embedding={props.embedding ?? true} />
<TextNote event={event} />
</Match>
<Match when={(event.kind as number) === 6}>
<DeprecatedRepost event={event} />

View File

@@ -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 <Timeline events={[...events()].reverse()} />;
};
const TimelineContentDisplay: Component<{ timelineContent: TimelineContent }> = (props) => {
return (
<Switch>
<Match when={props.timelineContent.type === 'Replies' && props.timelineContent} keyed>
{(replies) => <RepliesDisplay event={replies.event} />}
</Match>
</Switch>
);
};
export default TimelineContentDisplay;

View File

@@ -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<UseTimelineState>();
export const useTimelineContext = () => useContext(TimelineContext);
export const useTimelineState = (): UseTimelineState => {
const [timelineState, setTimelineState] = createStore<TimelineState>({});
return {
timelineState,
setTimeline: (content: TimelineContent) => setTimelineState('content', content),
clearTimeline: () => setTimelineState('content', undefined),
};
};

View File

@@ -42,7 +42,7 @@ const Reaction: Component<ReactionProps> = (props) => {
</Match>
</Switch>
</div>
<div class="notification-user flex gap-1">
<div class="notification-user flex gap-1 overflow-hidden">
<div class="author-icon h-5 w-5 shrink-0 overflow-hidden object-cover">
<Show when={profile()?.picture != null}>
<img
@@ -53,9 +53,9 @@ const Reaction: Component<ReactionProps> = (props) => {
/>
</Show>
</div>
<div>
<div class="flex-1 overflow-hidden">
<button
class="truncate whitespace-pre-wrap break-all font-bold hover:text-blue-500 hover:underline"
class="truncate font-bold hover:text-blue-500 hover:underline"
onClick={() => showProfile(props.event.pubkey)}
>
<UserDisplayName pubkey={props.event.pubkey} />

View File

@@ -1,4 +1,4 @@
import { Component, createEffect, createSignal, Show } from 'solid-js';
import { Component, createEffect, createSignal, onMount, Show, JSX } from 'solid-js';
import { fixUrl } from '@/utils/imageUrl';
import SafeLink from '../utils/SafeLink';
@@ -8,7 +8,37 @@ type ImageDisplayProps = {
};
const ImageDisplay: Component<ImageDisplayProps> = (props) => {
let imageRef: HTMLImageElement | undefined;
let canvasRef: HTMLCanvasElement | undefined;
const [hidden, setHidden] = createSignal(props.initialHidden);
const [playing, setPlaying] = createSignal(true);
const isGIF = () => props.url.match(/\.gif/i);
const play = () => {
setPlaying(true);
};
const stop = () => {
if (canvasRef == null || imageRef == null) return;
canvasRef.width = imageRef.width;
canvasRef.height = imageRef.height;
canvasRef
.getContext('2d')
?.drawImage(
imageRef,
0,
0,
imageRef.naturalWidth,
imageRef.naturalHeight,
0,
0,
imageRef.width,
imageRef.height,
);
setPlaying(false);
};
return (
<Show
@@ -24,11 +54,45 @@ const ImageDisplay: Component<ImageDisplayProps> = (props) => {
>
<SafeLink class="my-2 block" href={props.url}>
<img
class="inline-block max-h-64 max-w-full rounded object-contain shadow hover:shadow-md"
src={fixUrl(props.url)}
ref={imageRef}
class="max-h-64 max-w-full rounded object-contain shadow hover:shadow-md"
classList={{
'inline-block': playing(),
hidden: !playing(),
}}
src={playing() ? fixUrl(props.url) : undefined}
alt={props.url}
/>
<canvas
ref={canvasRef}
class="inline-block max-h-64 max-w-full rounded object-contain shadow hover:shadow-md"
classList={{
'w-0': playing(),
'h-0': playing(),
'w-auto': !playing(),
'h-auto': !playing(),
}}
onClick={(ev) => {
ev.preventDefault();
play();
}}
/>
</SafeLink>
{/*
<Show when={isGIF()}>
<button
class=""
onClick={() => {
if (playing()) stop();
else play();
}}
>
<Show when={!playing()} fallback="⏸">
</Show>
</button>
</Show>
*/}
</Show>
);
};

View File

@@ -43,10 +43,10 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
</div>
);
}
if (item.data.type === 'npub' && props.embedding) {
if (item.data.type === 'npub') {
return <MentionedUserDisplay pubkey={item.data.data} />;
}
if (item.data.type === 'nprofile' && props.embedding) {
if (item.data.type === 'nprofile') {
return <MentionedUserDisplay pubkey={item.data.data.pubkey} />;
}
return <span class="text-blue-500 underline">{item.content}</span>;

View File

@@ -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<TextNoteDisplayProps> = (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<TextNoteDisplayProps> = (props) => {
type="button"
class="hover:underline"
onClick={() => {
columnContext?.setColumnContent({
timelineContext?.setTimeline({
type: 'Replies',
eventId: event().rootEvent()?.id ?? props.event.id,
event: props.event,
});
}}
>

View File

@@ -56,18 +56,20 @@ export type ParsedTextNoteNode =
export type ParsedTextNote = ParsedTextNoteNode[];
const tagRefRegex = /(?:#\[(?<idx>\d+)\])/g;
const hashTagRegex = /#(?<hashtag>[^[-^`:-@!-/{-~\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:)?(?<mention>(npub|note|nprofile|nevent)1[ac-hj-np-z02-9]+)/gi;
const urlRegex =
/(?<url>(?:https?|wss?):\/\/[-a-zA-Z0-9.:]+(?:\/[-[\]~!$&'()*+.,:;@&=%\w]+|\/)*(?:\?[-[\]~!$&'()*+.,/:;%@&=\w?]+)?(?:#[-[\]~!$&'()*+.,/:;%@\w&=?#]+)?)/g;
const parseTextNote = (event: NostrEvent): ParsedTextNote => {
const matches = [
...event.content.matchAll(/(?:#\[(?<idx>\d+)\])/g),
...event.content.matchAll(/#(?<hashtag>[^[-^`:-@!-/{-~\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:)?(?<mention>(npub|note|nprofile|nevent)1[ac-hj-np-z02-9]+)/gi,
),
...event.content.matchAll(
/(?<url>(?: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 = [];

View File

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

View File

@@ -179,8 +179,7 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
const resolveTasks = (registeredTasks: Task<TaskArg, TaskRes>[], 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<TaskArg, TaskRes>(() => ({
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<TaskArg, TaskRes>(() => ({
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,
},
);

View File

@@ -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<void> => {
return new Promise((resolve, reject) => {
@@ -25,6 +37,41 @@ const waitCommandResult = (pub: Pub, relayUrl: string): Promise<void> => {
});
};
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<Promise<void>[]> => {
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<Promise<void>[]> => {
// 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<Promise<void>[]> => {
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);

View File

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

View File

@@ -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<NostrEvent[]>([]);
@@ -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;
}

View File

@@ -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<Task> = {
executor: (reqs: Task[]) => void;
interval: number;
size: number;
};
export class BatchExecutor<Task> {
#executor: (reqs: Task[]) => void;
#interval: number;
#size: number;
#tasks: Task[] = [];
#timerId: ReturnType<typeof setTimeout> | null = null;
constructor({ executor, interval, size }: BatchExecutorConstructor<Task>) {
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;
}
}
}

View File

@@ -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<BatchRequest, BatchResponse> {
id: number;
req: BatchRequest;
res: BatchResponse | undefined;
isCompleted = false;
#updateListeners: ((res: BatchResponse) => void)[] = [];
#completeListeners: (() => void)[] = [];
#promise: Promise<BatchResponse>;
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<BatchResponse> {
return this.#promise;
}
}

32
src/utils/batch/nextId.ts Normal file
View File

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