mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 14:04:21 +01:00
update
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
50
src/components/TimelineContentDisplay.tsx
Normal file
50
src/components/TimelineContentDisplay.tsx
Normal 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;
|
||||
32
src/components/TimelineContext.tsx
Normal file
32
src/components/TimelineContext.tsx
Normal 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),
|
||||
};
|
||||
};
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
76
src/utils/batch/BatchExecutor.ts
Normal file
76
src/utils/batch/BatchExecutor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
97
src/utils/batch/ObservableTask.ts
Executable file
97
src/utils/batch/ObservableTask.ts
Executable 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
32
src/utils/batch/nextId.ts
Normal 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;
|
||||
Reference in New Issue
Block a user