mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 22:14:26 +01:00
update
This commit is contained in:
@@ -25,7 +25,14 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
plugins: ['import', 'solid', 'jsx-a11y', 'prettier', '@typescript-eslint', 'tailwindcss'],
|
plugins: ['import', 'solid', 'jsx-a11y', 'prettier', '@typescript-eslint', 'tailwindcss'],
|
||||||
rules: {
|
rules: {
|
||||||
'import/extensions': ['error', 'ignorePackages', { ts: 'never', tsx: 'never' }],
|
'import/extensions': [
|
||||||
|
'error',
|
||||||
|
'ignorePackages',
|
||||||
|
{
|
||||||
|
ts: 'never',
|
||||||
|
tsx: 'never',
|
||||||
|
},
|
||||||
|
],
|
||||||
'prettier/prettier': 'error',
|
'prettier/prettier': 'error',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "nostiger",
|
"name": "rabbit",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ const useCachedEvents = (propsProvider: () => UseSubscriptionProps) => {
|
|||||||
return getEvents({ pool: pool(), relayUrls, filters, options, signal });
|
return getEvents({ pool: pool(), relayUrls, filters, options, signal });
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
// 5 minutes
|
||||||
cacheTime: 24 * 60 * 60 * 1000, // 24 hours
|
staleTime: 5 * 60 * 1000,
|
||||||
|
cacheTime: 15 * 60 * 1000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getEventHash } from 'nostr-tools/event';
|
|||||||
import type { Event as NostrEvent } from 'nostr-tools/event';
|
import type { Event as NostrEvent } from 'nostr-tools/event';
|
||||||
import type { Pub } from 'nostr-tools/relay';
|
import type { Pub } from 'nostr-tools/relay';
|
||||||
|
|
||||||
|
import '@/types/nostr.d';
|
||||||
import usePool from '@/clients/usePool';
|
import usePool from '@/clients/usePool';
|
||||||
|
|
||||||
const currentDate = (): number => Math.floor(Date.now() / 1000);
|
const currentDate = (): number => Math.floor(Date.now() / 1000);
|
||||||
@@ -26,8 +27,11 @@ const useCommands = () => {
|
|||||||
const publishEvent = async (relayUrls: string[], event: NostrEvent): Promise<Promise<void>[]> => {
|
const publishEvent = async (relayUrls: string[], event: NostrEvent): Promise<Promise<void>[]> => {
|
||||||
const preSignedEvent: NostrEvent = { ...event };
|
const preSignedEvent: NostrEvent = { ...event };
|
||||||
preSignedEvent.id = getEventHash(preSignedEvent);
|
preSignedEvent.id = getEventHash(preSignedEvent);
|
||||||
// TODO define window.nostr
|
|
||||||
const signedEvent = (await window.nostr.signEvent(preSignedEvent)) as NostrEvent;
|
if (window.nostr == null) {
|
||||||
|
throw new Error('NIP-07 implementation not found');
|
||||||
|
}
|
||||||
|
const signedEvent = await window.nostr.signEvent(preSignedEvent);
|
||||||
|
|
||||||
return relayUrls.map(async (relayUrl) => {
|
return relayUrls.map(async (relayUrl) => {
|
||||||
const relay = await pool().ensureRelay(relayUrl);
|
const relay = await pool().ensureRelay(relayUrl);
|
||||||
@@ -60,14 +64,14 @@ const useCommands = () => {
|
|||||||
publishReaction({
|
publishReaction({
|
||||||
relayUrls,
|
relayUrls,
|
||||||
pubkey,
|
pubkey,
|
||||||
content,
|
|
||||||
eventId,
|
eventId,
|
||||||
|
content,
|
||||||
notifyPubkey,
|
notifyPubkey,
|
||||||
}: {
|
}: {
|
||||||
relayUrls: string[];
|
relayUrls: string[];
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
content: string;
|
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
content: string;
|
||||||
notifyPubkey: string;
|
notifyPubkey: string;
|
||||||
}): Promise<Promise<void>[]> {
|
}): Promise<Promise<void>[]> {
|
||||||
// TODO ensure that content is + or - or emoji.
|
// TODO ensure that content is + or - or emoji.
|
||||||
@@ -76,11 +80,12 @@ const useCommands = () => {
|
|||||||
pubkey,
|
pubkey,
|
||||||
created_at: currentDate(),
|
created_at: currentDate(),
|
||||||
tags: [
|
tags: [
|
||||||
['e', eventId],
|
['e', eventId, ''],
|
||||||
['p', notifyPubkey],
|
['p', notifyPubkey],
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
|
console.log(preSignedEvent);
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
return publishEvent(relayUrls, preSignedEvent);
|
||||||
},
|
},
|
||||||
// NIP-18
|
// NIP-18
|
||||||
@@ -100,12 +105,9 @@ const useCommands = () => {
|
|||||||
pubkey,
|
pubkey,
|
||||||
created_at: currentDate(),
|
created_at: currentDate(),
|
||||||
tags: [
|
tags: [
|
||||||
['e', eventId],
|
['e', eventId, ''],
|
||||||
['p', notifyPubkey],
|
['p', notifyPubkey],
|
||||||
],
|
],
|
||||||
// Some clients includes some contents here.
|
|
||||||
// Damus includes an original event. Iris includes #[0] as a mention.
|
|
||||||
// We just follow the specification.
|
|
||||||
content: '',
|
content: '',
|
||||||
};
|
};
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
return publishEvent(relayUrls, preSignedEvent);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const InitialConfig: Config = {
|
|||||||
'wss://nostr.h3z.jp/',
|
'wss://nostr.h3z.jp/',
|
||||||
'wss://relay.damus.io',
|
'wss://relay.damus.io',
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
'wss://brb.io',
|
|
||||||
'wss://relay.snort.social',
|
'wss://relay.snort.social',
|
||||||
'wss://relay.current.fyi',
|
'wss://relay.current.fyi',
|
||||||
'wss://relay.nostr.wirednet.jp',
|
'wss://relay.nostr.wirednet.jp',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type Event as NostrEvent } from 'nostr-tools/event';
|
|
||||||
import { type Accessor } from 'solid-js';
|
import { type Accessor } from 'solid-js';
|
||||||
|
import { type Event as NostrEvent } from 'nostr-tools/event';
|
||||||
|
import { type CreateQueryResult } from '@tanstack/solid-query';
|
||||||
|
|
||||||
import useCachedEvents from '@/clients/useCachedEvents';
|
import useCachedEvents from '@/clients/useCachedEvents';
|
||||||
|
|
||||||
@@ -9,7 +10,8 @@ export type UseEventProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type UseEvent = {
|
export type UseEvent = {
|
||||||
event: Accessor<NostrEvent>;
|
event: Accessor<NostrEvent | undefined>;
|
||||||
|
query: CreateQueryResult<NostrEvent[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useEvent = (propsProvider: () => UseEventProps): UseEvent => {
|
const useEvent = (propsProvider: () => UseEventProps): UseEvent => {
|
||||||
@@ -27,9 +29,9 @@ const useEvent = (propsProvider: () => UseEventProps): UseEvent => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const event = () => query.data?.[0] as NostrEvent;
|
const event = () => query.data?.[0];
|
||||||
|
|
||||||
return { event };
|
return { event, query };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useEvent;
|
export default useEvent;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { type Accessor } from 'solid-js';
|
||||||
|
import { type Event as NostrEvent } from 'nostr-tools/event';
|
||||||
|
import { type CreateQueryResult } from '@tanstack/solid-query';
|
||||||
|
|
||||||
import useCachedEvents from '@/clients/useCachedEvents';
|
import useCachedEvents from '@/clients/useCachedEvents';
|
||||||
|
|
||||||
type UseProfileProps = {
|
type UseProfileProps = {
|
||||||
@@ -23,7 +27,12 @@ type NonStandardProfile = {
|
|||||||
|
|
||||||
type Profile = StandardProfile & NonStandardProfile;
|
type Profile = StandardProfile & NonStandardProfile;
|
||||||
|
|
||||||
const useProfile = (propsProvider: () => UseProfileProps) => {
|
type UseProfile = {
|
||||||
|
profile: Accessor<Profile | undefined>;
|
||||||
|
query: CreateQueryResult<NostrEvent[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useProfile = (propsProvider: () => UseProfileProps): UseProfile => {
|
||||||
const query = useCachedEvents(() => {
|
const query = useCachedEvents(() => {
|
||||||
const { relayUrls, pubkey } = propsProvider();
|
const { relayUrls, pubkey } = propsProvider();
|
||||||
return {
|
return {
|
||||||
@@ -40,13 +49,13 @@ const useProfile = (propsProvider: () => UseProfileProps) => {
|
|||||||
|
|
||||||
const profile = () => {
|
const profile = () => {
|
||||||
const maybeProfile = query.data?.[0];
|
const maybeProfile = query.data?.[0];
|
||||||
if (maybeProfile == null) return null;
|
if (maybeProfile == null) return undefined;
|
||||||
|
|
||||||
// TODO 大きすぎたりしないかどうか、JSONかどうかのチェック
|
// TODO 大きすぎたりしないかどうか、JSONかどうかのチェック
|
||||||
return JSON.parse(maybeProfile.content) as Profile;
|
return JSON.parse(maybeProfile.content) as Profile;
|
||||||
};
|
};
|
||||||
|
|
||||||
return { profile };
|
return { profile, query };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useProfile;
|
export default useProfile;
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import '@/types/nostr.d';
|
||||||
import { createSignal, onMount, type Accessor } from 'solid-js';
|
import { createSignal, onMount, type Accessor } from 'solid-js';
|
||||||
|
|
||||||
const usePubkey = (): Accessor<string | undefined> => {
|
let asking = false;
|
||||||
const [pubkey, setPubkey] = createSignal<string | undefined>(undefined);
|
const [pubkey, setPubkey] = createSignal<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const usePubkey = (): Accessor<string | undefined> => {
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (window.nostr != null) {
|
if (window.nostr != null && pubkey() == null && !asking) {
|
||||||
window.nostr.getPublicKey().then((pubkey) => setPubkey(pubkey));
|
asking = true;
|
||||||
|
window.nostr
|
||||||
|
.getPublicKey()
|
||||||
|
.then((key) => setPubkey(key))
|
||||||
|
.catch((err) => console.error(`failed to obtain public key: ${err}`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
51
src/clients/useReactions.ts
Normal file
51
src/clients/useReactions.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { type Accessor } from 'solid-js';
|
||||||
|
import { type Event as NostrEvent } from 'nostr-tools/event';
|
||||||
|
import { type CreateQueryResult } from '@tanstack/solid-query';
|
||||||
|
|
||||||
|
import useCachedEvents from '@/clients/useCachedEvents';
|
||||||
|
|
||||||
|
export type UseEventProps = {
|
||||||
|
relayUrls: string[];
|
||||||
|
eventId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseEvent = {
|
||||||
|
reactions: Accessor<NostrEvent[]>;
|
||||||
|
reactionsGroupedByContent: Accessor<Map<string, NostrEvent[]>>;
|
||||||
|
isReactedBy(pubkey: string): boolean;
|
||||||
|
query: CreateQueryResult<NostrEvent[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useReactions = (propsProvider: () => UseEventProps): UseEvent => {
|
||||||
|
const query = useCachedEvents(() => {
|
||||||
|
const { relayUrls, eventId } = propsProvider();
|
||||||
|
return {
|
||||||
|
relayUrls,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
'#e': [eventId],
|
||||||
|
kinds: [7],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const reactions = () => query.data ?? [];
|
||||||
|
|
||||||
|
const reactionsGroupedByContent = () => {
|
||||||
|
const result = new Map<string, NostrEvent[]>();
|
||||||
|
reactions().forEach((event) => {
|
||||||
|
const events = result.get(event.content) ?? [];
|
||||||
|
events.push(event);
|
||||||
|
result.set(event.content, events);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isReactedBy = (pubkey: string): boolean =>
|
||||||
|
reactions().findIndex((event) => event.pubkey === pubkey) !== -1;
|
||||||
|
|
||||||
|
return { reactions, reactionsGroupedByContent, isReactedBy, query };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useReactions;
|
||||||
@@ -33,7 +33,8 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined)
|
|||||||
pushed = true;
|
pushed = true;
|
||||||
storedEvents.push(event);
|
storedEvents.push(event);
|
||||||
} else {
|
} else {
|
||||||
setEvents((prevEvents) => sortEvents([event, ...prevEvents]));
|
// いったん1000件だけ保持
|
||||||
|
setEvents((prevEvents) => sortEvents([event, ...prevEvents].slice(0, 1000)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,10 +61,6 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined)
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
console.log(events());
|
|
||||||
});
|
|
||||||
|
|
||||||
return { events };
|
return { events };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,25 @@ const DeprecatedRepost: Component<DeprecatedRepostProps> = (props) => {
|
|||||||
const eventId = () => props.event.tags.find(([tagName]) => tagName === 'e')?.[1];
|
const eventId = () => props.event.tags.find(([tagName]) => tagName === 'e')?.[1];
|
||||||
|
|
||||||
const { profile } = useProfile(() => ({ relayUrls: config().relayUrls, pubkey: pubkey() }));
|
const { profile } = useProfile(() => ({ relayUrls: config().relayUrls, pubkey: pubkey() }));
|
||||||
const { event } = useEvent(() => ({ relayUrls: config().relayUrls, eventId: eventId() }));
|
const { event, query: eventQuery } = useEvent(() => ({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
eventId: eventId(),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div class="flex content-center px-1 text-xs">
|
<div class="flex content-center px-1 text-xs">
|
||||||
<div class="h-5 w-5 pr-1 text-green-500" aria-hidden="true">
|
<div class="h-5 w-5 shrink-0 pr-1 text-green-500" aria-hidden="true">
|
||||||
<ArrowPathRoundedSquare />
|
<ArrowPathRoundedSquare />
|
||||||
</div>
|
</div>
|
||||||
<div>{profile()?.display_name} Reposted</div>
|
<div class="truncate">
|
||||||
|
<Show when={(profile()?.display_name?.length ?? 0) > 0} fallback={props.event.pubkey}>
|
||||||
|
{profile()?.display_name}
|
||||||
|
</Show>
|
||||||
|
{' Reposted'}
|
||||||
</div>
|
</div>
|
||||||
<Show when={event() != null} fallback={'loading'}>
|
</div>
|
||||||
|
<Show when={event() != null} fallback={<Show when={eventQuery.isLoading}>loading</Show>}>
|
||||||
<TextNote event={event()} />
|
<TextNote event={event()} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,14 +8,17 @@ type NotePostFormProps = {
|
|||||||
const NotePostForm: Component<NotePostFormProps> = (props) => {
|
const NotePostForm: Component<NotePostFormProps> = (props) => {
|
||||||
const [text, setText] = createSignal<string>('');
|
const [text, setText] = createSignal<string>('');
|
||||||
|
|
||||||
|
const clearText = () => setText('');
|
||||||
|
|
||||||
const handleChangeText: JSX.EventHandler<HTMLTextAreaElement, Event> = (ev) => {
|
const handleChangeText: JSX.EventHandler<HTMLTextAreaElement, Event> = (ev) => {
|
||||||
setText(ev.currentTarget.value);
|
setText(ev.currentTarget.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
|
const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
// TODO 投稿完了したかどうかの検知をしたい
|
||||||
props.onPost({ content: text() });
|
props.onPost({ content: text() });
|
||||||
// TODO 投稿完了したらなんかする
|
clearText();
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitDisabled = createMemo(() => text().trim().length === 0);
|
const submitDisabled = createMemo(() => text().trim().length === 0);
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
import {
|
import { Show, For, createMemo, type JSX, type Component } from 'solid-js';
|
||||||
Show,
|
|
||||||
For,
|
|
||||||
createSignal,
|
|
||||||
createMemo,
|
|
||||||
onMount,
|
|
||||||
onCleanup,
|
|
||||||
type JSX,
|
|
||||||
Component,
|
|
||||||
} from 'solid-js';
|
|
||||||
import type { Event as NostrEvent } from 'nostr-tools/event';
|
import type { Event as NostrEvent } from 'nostr-tools/event';
|
||||||
|
|
||||||
import HeartOutlined from 'heroicons/24/outline/heart.svg';
|
import HeartOutlined from 'heroicons/24/outline/heart.svg';
|
||||||
|
import HeartSolid from 'heroicons/24/solid/heart.svg';
|
||||||
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
|
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
|
||||||
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
|
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
|
||||||
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
|
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
|
||||||
|
|
||||||
import useProfile from '@/clients/useProfile';
|
import useProfile from '@/clients/useProfile';
|
||||||
import useConfig from '@/clients/useConfig';
|
import useConfig from '@/clients/useConfig';
|
||||||
|
import usePubkey from '@/clients/usePubkey';
|
||||||
import useCommands from '@/clients/useCommands';
|
import useCommands from '@/clients/useCommands';
|
||||||
|
import useReactions from '@/clients/useReactions';
|
||||||
import useDatePulser from '@/hooks/useDatePulser';
|
import useDatePulser from '@/hooks/useDatePulser';
|
||||||
import { formatRelative } from '@/utils/formatDate';
|
import { formatRelative } from '@/utils/formatDate';
|
||||||
import ColumnItem from '@/components/ColumnItem';
|
import ColumnItem from '@/components/ColumnItem';
|
||||||
@@ -32,11 +26,24 @@ const TextNote: Component<TextNoteProps> = (props) => {
|
|||||||
const currentDate = useDatePulser();
|
const currentDate = useDatePulser();
|
||||||
const [config] = useConfig();
|
const [config] = useConfig();
|
||||||
const commands = useCommands();
|
const commands = useCommands();
|
||||||
|
const pubkey = usePubkey();
|
||||||
|
|
||||||
const { profile: author } = useProfile(() => ({
|
const { profile: author } = useProfile(() => ({
|
||||||
relayUrls: config().relayUrls,
|
relayUrls: config().relayUrls,
|
||||||
pubkey: props.event.pubkey,
|
pubkey: props.event.pubkey,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
reactions,
|
||||||
|
isReactedBy,
|
||||||
|
query: reactionsQuery,
|
||||||
|
} = useReactions(() => ({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
eventId: props.event.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isReactedByMe = createMemo(() => isReactedBy(pubkey()));
|
||||||
|
|
||||||
const replyingToPubKeys = createMemo(() =>
|
const replyingToPubKeys = createMemo(() =>
|
||||||
props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1]),
|
props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1]),
|
||||||
);
|
);
|
||||||
@@ -45,15 +52,30 @@ const TextNote: Component<TextNoteProps> = (props) => {
|
|||||||
|
|
||||||
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
|
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
commands.publishDeprecatedRepost({});
|
commands.publishDeprecatedRepost({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
pubkey: pubkey(),
|
||||||
|
eventId: props.event.id,
|
||||||
|
notifyPubkey: props.event.pubkey,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
|
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
|
||||||
|
if (isReactedByMe()) {
|
||||||
|
// TODO remove reaction
|
||||||
|
return;
|
||||||
|
}
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
commands.publishReaction({
|
commands
|
||||||
|
.publishReaction({
|
||||||
relayUrls: config().relayUrls,
|
relayUrls: config().relayUrls,
|
||||||
pubkey: pubkeyHex,
|
pubkey: pubkey(),
|
||||||
|
content: '+',
|
||||||
eventId: props.event.id,
|
eventId: props.event.id,
|
||||||
|
notifyPubkey: props.event.pubkey,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
reactionsQuery.refetch();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,8 +96,8 @@ const TextNote: Component<TextNoteProps> = (props) => {
|
|||||||
<div class="flex justify-between gap-1 text-xs">
|
<div class="flex justify-between gap-1 text-xs">
|
||||||
<div class="author flex min-w-0 truncate">
|
<div class="author flex min-w-0 truncate">
|
||||||
{/* TODO link to author */}
|
{/* TODO link to author */}
|
||||||
<Show when={author()?.display_name != null && author()?.display_name.length > 0}>
|
<Show when={(author()?.display_name?.length ?? 0) > 0}>
|
||||||
<div class="author-name pr-1 font-bold">{author()?.display_name}</div>
|
<div class="author-name truncate pr-1 font-bold">{author()?.display_name}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="author-username truncate text-zinc-600">
|
<div class="author-username truncate text-zinc-600">
|
||||||
<Show when={author()?.name} fallback={props.event.pubkey}>
|
<Show when={author()?.name} fallback={props.event.pubkey}>
|
||||||
@@ -89,9 +111,9 @@ const TextNote: Component<TextNoteProps> = (props) => {
|
|||||||
<div class="text-xs">
|
<div class="text-xs">
|
||||||
{'Replying to '}
|
{'Replying to '}
|
||||||
<For each={replyingToPubKeys()}>
|
<For each={replyingToPubKeys()}>
|
||||||
{(pubkey: string) => (
|
{(replyToPubkey: string) => (
|
||||||
<span class="pr-1 text-blue-500 underline">
|
<span class="pr-1 text-blue-500 underline">
|
||||||
<GeneralUserMentionDisplay pubkey={pubkey} />
|
<GeneralUserMentionDisplay pubkey={replyToPubkey} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -100,16 +122,27 @@ const TextNote: Component<TextNoteProps> = (props) => {
|
|||||||
<div class="content whitespace-pre-wrap break-all">
|
<div class="content whitespace-pre-wrap break-all">
|
||||||
<TextNoteContentDisplay event={props.event} embedding={true} />
|
<TextNoteContentDisplay event={props.event} embedding={true} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-16">
|
<div class="flex w-48 items-center justify-between gap-8 pt-1">
|
||||||
<button class="h-4 w-4 text-zinc-400">
|
<button class="h-4 w-4 text-zinc-400">
|
||||||
<ChatBubbleLeft />
|
<ChatBubbleLeft />
|
||||||
</button>
|
</button>
|
||||||
<button class="h-4 w-4 text-zinc-400" onClick={handleRepost}>
|
<button class="h-4 w-4 text-zinc-400" onClick={handleRepost}>
|
||||||
<ArrowPathRoundedSquare />
|
<ArrowPathRoundedSquare />
|
||||||
</button>
|
</button>
|
||||||
<button class="h-4 w-4 text-zinc-400" onClick={handleReaction}>
|
<div
|
||||||
<HeartOutlined />
|
class="flex items-center gap-1"
|
||||||
|
classList={{
|
||||||
|
'text-zinc-400': !isReactedByMe(),
|
||||||
|
'text-rose-400': isReactedByMe(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button class="h-4 w-4" onClick={handleReaction}>
|
||||||
|
<Show when={isReactedByMe()} fallback={<HeartOutlined />}>
|
||||||
|
<HeartSolid />
|
||||||
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="text-sm text-zinc-400">{reactions().length}</div>
|
||||||
|
</div>
|
||||||
<button class="h-4 w-4 text-zinc-400">
|
<button class="h-4 w-4 text-zinc-400">
|
||||||
<EllipsisHorizontal />
|
<EllipsisHorizontal />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -19,31 +19,54 @@ const Reaction: Component<ReactionProps> = (props) => {
|
|||||||
relayUrls: config().relayUrls,
|
relayUrls: config().relayUrls,
|
||||||
pubkey: props.event.pubkey,
|
pubkey: props.event.pubkey,
|
||||||
}));
|
}));
|
||||||
const { event } = useEvent(() => ({ relayUrls: config().relayUrls, eventId: eventId() }));
|
const { event: reactedEvent, query: reactedEventQuery } = useEvent(() => ({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
eventId: eventId(),
|
||||||
|
}));
|
||||||
|
const isRemoved = () => reactedEventQuery.isSuccess && reactedEvent() == null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// if the reacted event is not found, it should be a removed event
|
||||||
|
<Show when={!isRemoved()}>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex gap-1 text-sm">
|
<div class="notification-icon flex gap-1 px-1 text-sm">
|
||||||
<div>
|
<div class="flex place-items-center">
|
||||||
<Switch fallback={props.event.content}>
|
<Switch fallback={props.event.content}>
|
||||||
<Match when={props.event.content === '+'}>
|
<Match when={props.event.content === '+'}>
|
||||||
<span class="inline-block h-4 w-4 text-rose-400">
|
<span class="h-4 w-4 pt-[1px] text-rose-400">
|
||||||
<HeartSolid />
|
<HeartSolid />
|
||||||
</span>
|
</span>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="notification-user flex gap-1 pt-1">
|
||||||
|
<div class="author-icon h-5 w-5 shrink-0">
|
||||||
|
<Show when={profile()?.picture != null}>
|
||||||
|
<img
|
||||||
|
src={profile()?.picture}
|
||||||
|
alt="icon"
|
||||||
|
// TODO autofit
|
||||||
|
class="rounded"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="font-bold">{profile()?.display_name}</span>
|
<span class="truncate whitespace-pre-wrap break-all font-bold">
|
||||||
|
<Show when={profile() != null} fallback={props.event.pubkey}>
|
||||||
|
{profile()?.display_name}
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
{' reacted'}
|
{' reacted'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<Show when={event() != null} fallback={'loading'}>
|
<div class="notification-event">
|
||||||
<TextNote event={event()} />
|
<Show when={reactedEvent() != null} fallback="loading">
|
||||||
|
<TextNote event={reactedEvent()} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Event as NostrEvent } from 'nostr-tools/event';
|
import type { Event as NostrEvent } from 'nostr-tools/event';
|
||||||
|
|
||||||
type EventMarker = 'reply' | 'root' | 'mention';
|
export type EventMarker = 'reply' | 'root' | 'mention';
|
||||||
type TaggedEvent = {
|
export type TaggedEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
relayUrl?: string;
|
relayUrl?: string;
|
||||||
marker: EventMarker;
|
marker: EventMarker;
|
||||||
@@ -9,9 +9,9 @@ type TaggedEvent = {
|
|||||||
|
|
||||||
const eventWrapper = (event: NostrEvent) => {
|
const eventWrapper = (event: NostrEvent) => {
|
||||||
return {
|
return {
|
||||||
/**
|
event(): NostrEvent {
|
||||||
* "replyingTo"
|
return event;
|
||||||
*/
|
},
|
||||||
taggedUsers(): string[] {
|
taggedUsers(): string[] {
|
||||||
const pubkeys = new Set<string>();
|
const pubkeys = new Set<string>();
|
||||||
event.tags.forEach(([tagName, pubkey]) => {
|
event.tags.forEach(([tagName, pubkey]) => {
|
||||||
@@ -24,6 +24,7 @@ const eventWrapper = (event: NostrEvent) => {
|
|||||||
taggedEvents(): TaggedEvent[] {
|
taggedEvents(): TaggedEvent[] {
|
||||||
const events = event.tags.filter(([tagName]) => tagName === 'e');
|
const events = event.tags.filter(([tagName]) => tagName === 'e');
|
||||||
|
|
||||||
|
// NIP-10: Positional "e" tags (DEPRECATED)
|
||||||
const positionToMarker = (index: number): EventMarker => {
|
const positionToMarker = (index: number): EventMarker => {
|
||||||
// One "e" tag
|
// One "e" tag
|
||||||
if (events.length === 1) return 'reply';
|
if (events.length === 1) return 'reply';
|
||||||
@@ -32,9 +33,9 @@ const eventWrapper = (event: NostrEvent) => {
|
|||||||
// Two "e" tags
|
// Two "e" tags
|
||||||
if (events.length === 2) return 'reply';
|
if (events.length === 2) return 'reply';
|
||||||
// Many "e" tags
|
// Many "e" tags
|
||||||
// Last one is reply.
|
// The last one is reply.
|
||||||
if (index === events.length - 1) return 'reply';
|
if (index === events.length - 1) return 'reply';
|
||||||
// other ones are mentions.
|
// The rest are mentions.
|
||||||
return 'mention';
|
return 'mention';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,3 +56,5 @@ const eventWrapper = (event: NostrEvent) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default eventWrapper;
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ export type UseMessageChannelProps = {
|
|||||||
|
|
||||||
export type MessageChannelRequest<T> = {
|
export type MessageChannelRequest<T> = {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
message: T;
|
request: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageChannelResponse<T> = {
|
||||||
|
requestId: string;
|
||||||
|
response: T;
|
||||||
};
|
};
|
||||||
|
|
||||||
// https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
|
// https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
|
||||||
@@ -24,9 +29,9 @@ type Clonable =
|
|||||||
| Array<Clonable>
|
| Array<Clonable>
|
||||||
| Record<string, Clonable>;
|
| Record<string, Clonable>;
|
||||||
|
|
||||||
const useMessageChannel = <T extends Clonable>(propsProvider: () => UseMessageChannelProps) => {
|
const useMessageBus = <Req extends Clonable, Res extends Clonable>(
|
||||||
const channel = () => channels()[propsProvider().id];
|
propsProvider: () => UseMessageChannelProps,
|
||||||
|
) => {
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const { id } = propsProvider();
|
const { id } = propsProvider();
|
||||||
if (channel() == null) {
|
if (channel() == null) {
|
||||||
@@ -37,40 +42,50 @@ const useMessageChannel = <T extends Clonable>(propsProvider: () => UseMessageCh
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const listen = async (requestId: string, timeoutMs = 1000): Promise<T> => {
|
const channel = () => channels()[propsProvider().id];
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const listener = (event: MessageEvent) => {
|
|
||||||
if (event.origin !== window.location.origin) return;
|
|
||||||
if (typeof event.data !== 'string') return;
|
|
||||||
|
|
||||||
const data = JSON.parse(event.data) as MessageChannelRequest<T>;
|
const sendRequest = (requestId: string, message: Req) => {
|
||||||
|
const request: MessageChannelRequest<Req> = { requestId, request: message };
|
||||||
|
const messageStr = JSON.stringify(request);
|
||||||
|
channel().port1.postMessage(messageStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitResponse = (requestId: string, timeoutMs = 1000): Promise<Res> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const listener = (event: MessageEvent) => {
|
||||||
|
const data = event.data as MessageChannelResponse<Res>;
|
||||||
|
|
||||||
if (data.requestId !== requestId) return;
|
if (data.requestId !== requestId) return;
|
||||||
|
|
||||||
channel().port2.removeEventListener('message', listener);
|
channel().port1.removeEventListener('message', listener);
|
||||||
resolve(data.message);
|
resolve(data.response);
|
||||||
};
|
};
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
channel().port2.removeEventListener('message', listener);
|
channel().port1.removeEventListener('message', listener);
|
||||||
reject(new Error('TimeoutError'));
|
reject(new Error('TimeoutError'));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
channel().port2.addEventListener('message', listener, false);
|
channel().port1.addEventListener('message', listener, false);
|
||||||
channel().port2.start();
|
channel().port1.start();
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
const sendResponse = (res: Res) => {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async requst(message: T) {
|
async requst(message: Req): Promise<Res> {
|
||||||
const requestId = Math.random().toString();
|
const requestId = Math.random().toString();
|
||||||
const messageStr = JSON.stringify({ message, requestId });
|
const response = waitResponse(requestId);
|
||||||
const response = listen(requestId, timeoutMs);
|
sendRequest(requestId, message);
|
||||||
channel().postMessage(messageStr);
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
handle(handler) {},
|
handle(handler: (message: Req) => Res | Promise<Res>) {
|
||||||
|
channel().port2.addEventListener('message', (ev) => {
|
||||||
|
const request = event.data as MessageChannelRequest<Req>;
|
||||||
|
const res = handler(request.request).then((res) => {});
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useMessageChannel;
|
export default useMessageBus;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// const commands = ['openPostForm'] as const;
|
// const commands = ['openPostForm'] as const;
|
||||||
// type Commands = (typeof commands)[number];
|
// type Commands = (typeof commands)[number];
|
||||||
|
|
||||||
import { onMount, type JSX } from 'solid-js';
|
import { onMount, onCleanup, type JSX } from 'solid-js';
|
||||||
|
|
||||||
type Shortcut = { key: string; command: string };
|
type Shortcut = { key: string; command: string };
|
||||||
|
|
||||||
@@ -54,7 +54,9 @@ const useShortcutKeys = ({ shortcuts = defaultShortcut, onShortcut }: UseShortcu
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
return () => window.removeEventListener('keydown', handleKeydown);
|
onCleanup(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ const Home: Component = () => {
|
|||||||
kinds: [1, 6],
|
kinds: [1, 6],
|
||||||
authors: followings()?.map((f) => f.pubkey) ?? [pubkeyHex],
|
authors: followings()?.map((f) => f.pubkey) ?? [pubkeyHex],
|
||||||
limit: 25,
|
limit: 25,
|
||||||
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
@@ -135,7 +134,7 @@ const Home: Component = () => {
|
|||||||
<Column name="通知" width="medium">
|
<Column name="通知" width="medium">
|
||||||
<Notification events={notifications()} />
|
<Notification events={notifications()} />
|
||||||
</Column>
|
</Column>
|
||||||
<Column name="ローカル" width="medium">
|
<Column name="日本サーバ" width="medium">
|
||||||
<Timeline events={localTimeline()} />
|
<Timeline events={localTimeline()} />
|
||||||
</Column>
|
</Column>
|
||||||
<Column name="自分の投稿" width="medium">
|
<Column name="自分の投稿" width="medium">
|
||||||
|
|||||||
6
types/global.d.ts → src/types/nostr.d.ts
vendored
6
types/global.d.ts → src/types/nostr.d.ts
vendored
@@ -6,7 +6,7 @@ type NostrAPI = {
|
|||||||
/** returns a public key as hex */
|
/** returns a public key as hex */
|
||||||
getPublicKey(): Promise<string>;
|
getPublicKey(): Promise<string>;
|
||||||
/** takes an event object, adds `id`, `pubkey` and `sig` and returns it */
|
/** takes an event object, adds `id`, `pubkey` and `sig` and returns it */
|
||||||
signEvent(event: Event): Promise<NostrEvent>;
|
signEvent(event: NostrEvent): Promise<NostrEvent>;
|
||||||
|
|
||||||
// Optional
|
// Optional
|
||||||
|
|
||||||
@@ -22,6 +22,8 @@ type NostrAPI = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Window {
|
declare global {
|
||||||
|
interface Window {
|
||||||
nostr?: NostrAPI;
|
nostr?: NostrAPI;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"],
|
"@/*": ["src/*"],
|
||||||
"*": ["types/*"]
|
|
||||||
},
|
},
|
||||||
"incremental": true
|
"incremental": true
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user