mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 14:04:21 +01:00
implement parseTextNote
This commit is contained in:
38
package-lock.json
generated
38
package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@solidjs/meta": "^0.28.2",
|
||||
"@solidjs/router": "^0.6.0",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tanstack/solid-query": "^4.24.6",
|
||||
"@thisbeyond/solid-dnd": "^0.7.3",
|
||||
"heroicons": "^2.0.15",
|
||||
"nostr-tools": "^1.3.2",
|
||||
@@ -1346,6 +1347,30 @@
|
||||
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "4.24.6",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.24.6.tgz",
|
||||
"integrity": "sha512-Tfru6YTDTCpX7dKVwHp/sosw/dNjEdzrncduUjIkQxn7n7u+74HT2ZrGtwwrU6Orws4x7zp3FKRqBPWVVhpx9w==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/solid-query": {
|
||||
"version": "4.24.6",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/solid-query/-/solid-query-4.24.6.tgz",
|
||||
"integrity": "sha512-ksUfW4Lwwl85kogQuP46oyqPGBqbSfNMRTu9Ey3FDPjfYzObW4j9opI3TjRoSkOapqVg5KOaobhzu8N2Wp0JBg==",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "4.24.6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"solid-js": "^1.5.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@thisbeyond/solid-dnd": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.3.tgz",
|
||||
@@ -9312,6 +9337,19 @@
|
||||
"mini-svg-data-uri": "^1.2.3"
|
||||
}
|
||||
},
|
||||
"@tanstack/query-core": {
|
||||
"version": "4.24.6",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.24.6.tgz",
|
||||
"integrity": "sha512-Tfru6YTDTCpX7dKVwHp/sosw/dNjEdzrncduUjIkQxn7n7u+74HT2ZrGtwwrU6Orws4x7zp3FKRqBPWVVhpx9w=="
|
||||
},
|
||||
"@tanstack/solid-query": {
|
||||
"version": "4.24.6",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/solid-query/-/solid-query-4.24.6.tgz",
|
||||
"integrity": "sha512-ksUfW4Lwwl85kogQuP46oyqPGBqbSfNMRTu9Ey3FDPjfYzObW4j9opI3TjRoSkOapqVg5KOaobhzu8N2Wp0JBg==",
|
||||
"requires": {
|
||||
"@tanstack/query-core": "4.24.6"
|
||||
}
|
||||
},
|
||||
"@thisbeyond/solid-dnd": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.3.tgz",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"@solidjs/meta": "^0.28.2",
|
||||
"@solidjs/router": "^0.6.0",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tanstack/solid-query": "^4.24.6",
|
||||
"@thisbeyond/solid-dnd": "^0.7.3",
|
||||
"heroicons": "^2.0.15",
|
||||
"nostr-tools": "^1.3.2",
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Routes, Route } from '@solidjs/router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query';
|
||||
|
||||
import Home from '@/pages/Home';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
import AccountRecovery from '@/pages/AccountRecovery';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const App: Component = () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/recovery" element={<AccountRecovery />} />
|
||||
<Route path="/*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
67
src/clients/useCachedEvents.ts
Normal file
67
src/clients/useCachedEvents.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { UseSubscriptionProps } from '@/clients/useSubscription';
|
||||
import type { Event as NostrEvent } from 'nostr-tools/event';
|
||||
import type { Filter } from 'nostr-tools/filter';
|
||||
import type { SimplePool } from 'nostr-tools/pool';
|
||||
import type { SubscriptionOptions } from 'nostr-tools/relay';
|
||||
import usePool from './usePool';
|
||||
|
||||
type GetEventsArgs = {
|
||||
pool: SimplePool;
|
||||
relayUrls: string[];
|
||||
filters: Filter[];
|
||||
options?: SubscriptionOptions;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
const getEvents = async ({
|
||||
pool,
|
||||
relayUrls,
|
||||
filters,
|
||||
options,
|
||||
signal,
|
||||
}: GetEventsArgs): Promise<NostrEvent[]> => {
|
||||
const result: NostrEvent[] = [];
|
||||
|
||||
const sub = pool.sub(relayUrls, filters, options);
|
||||
sub.on('event', (event: NostrEvent) => result.push(event));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
sub.on('eose', () => {
|
||||
sub.unsub();
|
||||
resolve(result);
|
||||
});
|
||||
|
||||
if (signal != null) {
|
||||
signal.addEventListener('abort', () => {
|
||||
sub.unsub();
|
||||
reject(signal.reason);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This aims to fetch stored data, and doesn't support fetching streaming data continuously.
|
||||
*
|
||||
* This will be useful when you want to fetch profile or following list, reactions, and something like that.
|
||||
*/
|
||||
const useCachedEvents = (propsProvider: () => UseSubscriptionProps) => {
|
||||
const pool = usePool();
|
||||
|
||||
return createQuery(
|
||||
() => {
|
||||
const { relayUrls, filters, options } = propsProvider();
|
||||
return ['useCachedEvents', relayUrls, filters, options];
|
||||
},
|
||||
({ queryKey, signal }) => {
|
||||
const [, relayUrls, filters, options] = queryKey;
|
||||
return getEvents({ pool: pool(), relayUrls, filters, options, signal });
|
||||
},
|
||||
{
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes in ms
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export default useCachedEvents;
|
||||
35
src/clients/useConfig.ts
Normal file
35
src/clients/useConfig.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Signal } from 'solid-js';
|
||||
import {
|
||||
createStorageWithSerializer,
|
||||
createSignalWithStorage,
|
||||
} from '@/hooks/createSignalWithStorage';
|
||||
|
||||
type Config = {
|
||||
relayUrls: string[];
|
||||
};
|
||||
|
||||
const InitialConfig: Config = {
|
||||
relayUrls: [
|
||||
'wss://relay-jp.nostr.wirednet.jp',
|
||||
'wss://nostr.h3z.jp/',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://brb.io',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://relay.nostr.wirednet.jp',
|
||||
],
|
||||
};
|
||||
|
||||
const serializer = (config: Config): string => JSON.stringify(config);
|
||||
// TODO zod使う
|
||||
const deserializer = (json: string): Config => JSON.parse(json) as Config;
|
||||
|
||||
const storage = createStorageWithSerializer(() => window.localStorage, serializer, deserializer);
|
||||
const [config, setConfig] = createSignalWithStorage('rabbit_config', InitialConfig, storage);
|
||||
|
||||
const useConfig = (): Signal<Config> => {
|
||||
return [config, setConfig];
|
||||
};
|
||||
|
||||
export default useConfig;
|
||||
28
src/clients/useEvent.ts
Normal file
28
src/clients/useEvent.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import useCachedEvents from '@/clients/useCachedEvents';
|
||||
|
||||
type UseEventProps = {
|
||||
relayUrls: string[];
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
const useEvent = (propsProvider: () => UseEventProps) => {
|
||||
const query = useCachedEvents(() => {
|
||||
const { relayUrls, eventId } = propsProvider();
|
||||
return {
|
||||
relayUrls,
|
||||
filters: [
|
||||
{
|
||||
ids: [eventId],
|
||||
kinds: [1],
|
||||
limit: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const event = () => query.data?.[0];
|
||||
|
||||
return { event };
|
||||
};
|
||||
|
||||
export default useEvent;
|
||||
54
src/clients/useFollowings.ts
Normal file
54
src/clients/useFollowings.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import useCachedEvents from '@/clients/useCachedEvents';
|
||||
|
||||
type UseFollowingsProps = {
|
||||
relayUrls: string[];
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
type Following = {
|
||||
pubkey: string;
|
||||
mainRelayUrl?: string;
|
||||
petname?: string;
|
||||
};
|
||||
|
||||
const useFollowings = (propsProvider: () => UseFollowingsProps) => {
|
||||
const query = useCachedEvents(() => {
|
||||
const { relayUrls, pubkey } = propsProvider();
|
||||
return {
|
||||
relayUrls,
|
||||
filters: [
|
||||
{
|
||||
kinds: [3],
|
||||
authors: [pubkey],
|
||||
limit: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const followings = () => {
|
||||
const event = query?.data?.[0];
|
||||
if (event == null) return [];
|
||||
|
||||
const result: Following[] = [];
|
||||
event.tags.forEach((tag) => {
|
||||
// TODO zodにする
|
||||
const [tagName, followingPubkey, mainRelayUrl, petname] = tag;
|
||||
if (!tag.every((e) => typeof e === 'string')) return;
|
||||
if (tagName !== 'p') return;
|
||||
|
||||
const following: Following = { pubkey: followingPubkey, petname };
|
||||
if (mainRelayUrl != null && mainRelayUrl.length > 0) {
|
||||
following.mainRelayUrl = mainRelayUrl;
|
||||
}
|
||||
|
||||
result.push(following);
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return { followings };
|
||||
};
|
||||
|
||||
export default useFollowings;
|
||||
52
src/clients/useProfile.ts
Normal file
52
src/clients/useProfile.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import useCachedEvents from '@/clients/useCachedEvents';
|
||||
|
||||
type UseProfileProps = {
|
||||
relayUrls: string[];
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
// TODO zodにする
|
||||
// deleted等の特殊なもの
|
||||
type StandardProfile = {
|
||||
name?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
nip05?: string; // NIP-05
|
||||
lud06?: string; // NIP-57
|
||||
lud16?: string; // NIP-57
|
||||
};
|
||||
|
||||
type NonStandardProfile = {
|
||||
display_name?: string;
|
||||
website?: string;
|
||||
};
|
||||
|
||||
type Profile = StandardProfile & NonStandardProfile;
|
||||
|
||||
const useProfile = (propsProvider: () => UseProfileProps) => {
|
||||
const query = useCachedEvents(() => {
|
||||
const { relayUrls, pubkey } = propsProvider();
|
||||
return {
|
||||
relayUrls,
|
||||
filters: [
|
||||
{
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
limit: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const profile = () => {
|
||||
const maybeProfile = query.data?.[0];
|
||||
if (maybeProfile == null) return null;
|
||||
|
||||
// TODO 大きすぎたりしないかどうか、JSONかどうかのチェック
|
||||
return JSON.parse(maybeProfile.content) as Profile;
|
||||
};
|
||||
|
||||
return { profile };
|
||||
};
|
||||
|
||||
export default useProfile;
|
||||
@@ -1,30 +1,48 @@
|
||||
import { createSignal, createEffect } from 'solid-js';
|
||||
import type { Event as NostrEvent } from 'nostr-tools/event';
|
||||
import type { Filter } from 'nostr-tools/filter';
|
||||
import type { SubscriptionOptions } from 'nostr-tools/relay';
|
||||
import usePool from '@/clients/usePool';
|
||||
|
||||
type UseSubscriptionProps = {
|
||||
export type UseSubscriptionProps = {
|
||||
relayUrls: string[];
|
||||
filters: Filter[];
|
||||
options?: SubscriptionOptions;
|
||||
};
|
||||
|
||||
const useSubscription = ({ relayUrls, filters, options }: UseSubscriptionProps) => {
|
||||
const sortEvents = (events: NostrEvent[]) => events.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
const useSubscription = (propsProvider: () => UseSubscriptionProps) => {
|
||||
const pool = usePool();
|
||||
const [events, setEvents] = createSignal<Event[]>([]);
|
||||
const [events, setEvents] = createSignal<NostrEvent[]>([]);
|
||||
|
||||
createEffect(() => {
|
||||
const sub = pool().sub(relayUrls, filters, options);
|
||||
const tempEvents: Event[] = [];
|
||||
const { relayUrls, filters, options } = propsProvider();
|
||||
|
||||
sub.on('event', (event: Event) => {
|
||||
tempEvents.push(event);
|
||||
const sub = pool().sub(relayUrls, filters, options);
|
||||
let eose = false;
|
||||
const storedEvents: NostrEvent[] = [];
|
||||
|
||||
sub.on('event', (event: NostrEvent) => {
|
||||
if (!eose) {
|
||||
storedEvents.push(event);
|
||||
} else {
|
||||
setEvents((prevEvents) => sortEvents([event, ...prevEvents]));
|
||||
}
|
||||
});
|
||||
|
||||
sub.on('eose', () => {
|
||||
eose = true;
|
||||
setEvents(sortEvents(storedEvents));
|
||||
});
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
const newEvents = tempEvents.splice(0, tempEvents.length);
|
||||
setEvents((prevEvents) => [...newEvents, ...prevEvents]);
|
||||
}, 500);
|
||||
if (eose) {
|
||||
clearInterval(intervalId);
|
||||
return;
|
||||
}
|
||||
setEvents(sortEvents(storedEvents));
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
sub.unsub();
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type { Component } from 'solid-js';
|
||||
|
||||
type ColumnProps = {
|
||||
width: 'wide' | 'medium' | 'narrow' | null | undefined;
|
||||
children: JSX.Element;
|
||||
};
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
|
||||
const widthToClass = {
|
||||
widest: 'w-[500px]',
|
||||
@@ -12,6 +7,12 @@ const widthToClass = {
|
||||
narrow: 'w-[270px]',
|
||||
} as const;
|
||||
|
||||
type ColumnProps = {
|
||||
name: string;
|
||||
width: keyof typeof widthToClass | null | undefined;
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
const Column: Component<ColumnProps> = (props) => {
|
||||
const width = () => {
|
||||
if (props.width == null) {
|
||||
@@ -23,8 +24,8 @@ const Column: Component<ColumnProps> = (props) => {
|
||||
return (
|
||||
<div class={`h-full shrink-0 border-r ${width()}`}>
|
||||
<div class="flex h-8 items-center border-b bg-white px-2">
|
||||
<span class="column-icon">🏠</span>
|
||||
<span class="column-name">Home</span>
|
||||
{/* <span class="column-icon">🏠</span> */}
|
||||
<span class="column-name">{props.name}</span>
|
||||
</div>
|
||||
<div class="h-full overflow-y-scroll pb-8">{props.children}</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ type SideBarProps = {
|
||||
};
|
||||
|
||||
const SideBar: Component<SideBarProps> = (props) => {
|
||||
const [formOpened, setFormOpened] = createSignal(true);
|
||||
const [formOpened, setFormOpened] = createSignal(false);
|
||||
|
||||
return (
|
||||
<div class="flex shrink-0 flex-row border-r bg-sidebar-bg">
|
||||
@@ -22,8 +22,8 @@ const SideBar: Component<SideBarProps> = (props) => {
|
||||
<button class="h-9 w-9 rounded-full border border-primary p-2 text-2xl font-bold text-primary">
|
||||
<MagnifyingGlass />
|
||||
</button>
|
||||
<div>column 1</div>
|
||||
<div>column 2</div>
|
||||
{/* <div>column 1</div> */}
|
||||
{/* <div>column 2</div> */}
|
||||
</div>
|
||||
<Show when={formOpened()}>{() => props.postForm()}</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,69 @@
|
||||
import { createMemo, Show, For } from 'solid-js';
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Event as NostrEvent } from 'nostr-tools/event';
|
||||
import useProfile from '@/clients/useProfile';
|
||||
import useConfig from '@/clients/useConfig';
|
||||
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
|
||||
import GeneralUserMentionDisplay from './textNote/GeneralUserMentionDisplay';
|
||||
|
||||
export type TextNoteProps = {
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
event: NostrEvent;
|
||||
};
|
||||
|
||||
const TextNote: Component<TextNoteProps> = (props) => {
|
||||
const [config] = useConfig();
|
||||
const { profile: author } = useProfile(() => ({
|
||||
relayUrls: config().relayUrls,
|
||||
pubkey: props.event.pubkey,
|
||||
}));
|
||||
const replyingToPubKeys = createMemo(() =>
|
||||
props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1]),
|
||||
);
|
||||
// TODO 日付をいい感じにフォーマットする関数を作る
|
||||
const createdAt = () => new Date(props.event.created_at * 1000).toLocaleTimeString();
|
||||
|
||||
return (
|
||||
<div class="textnote flex w-full flex-row gap-1 overflow-hidden border-b p-1">
|
||||
<div class="author-icon shrink-0">
|
||||
<div class="author-icon max-w-10 max-h-10 shrink-0">
|
||||
<Show when={author()?.picture} fallback={<div class="h-10 w-10" />}>
|
||||
<img
|
||||
src="https://i.gyazo.com/883119a7763e594d30c5706a62969d52.jpg"
|
||||
alt="author icon"
|
||||
src={author()?.picture}
|
||||
alt="icon"
|
||||
// TODO autofit
|
||||
class="w-10 rounded"
|
||||
class="h-10 w-10 rounded"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="min-w-0 flex-auto">
|
||||
<div class="flex justify-between gap-1 text-xs">
|
||||
<div class="author flex min-w-0">
|
||||
{/* TODO link to profile */}
|
||||
<div class="author-name font-bold">Author</div>
|
||||
<div class="author-username truncate pl-1">@aauthorauthorauthorauthorauthoruthor</div>
|
||||
<div class="author flex min-w-0 truncate">
|
||||
{/* TODO link to author */}
|
||||
<Show when={author()?.display_name}>
|
||||
<div class="author-name pr-1 font-bold">{author()?.display_name}</div>
|
||||
</Show>
|
||||
<div class="author-username truncate">
|
||||
<Show when={author()?.name} fallback={props.event.pubkey}>
|
||||
@{author()?.name}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="created-at shrink-0">{props.createdAt.toLocaleTimeString()}</div>
|
||||
</div>
|
||||
<div class="content whitespace-pre-wrap break-all">{props.content}</div>
|
||||
<div class="created-at shrink-0">{createdAt()}</div>
|
||||
</div>
|
||||
<Show when={replyingToPubKeys().length > 0}>
|
||||
<div class="text-xs">
|
||||
{'Replying to '}
|
||||
<For each={replyingToPubKeys()}>
|
||||
{(pubkey: string) => (
|
||||
<span class="pr-1 text-blue-500 underline">
|
||||
<GeneralUserMentionDisplay pubkey={pubkey} />
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="content whitespace-pre-wrap break-all">
|
||||
<TextNoteContentDisplay event={props.event} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
24
src/components/textNote/GeneralUserMentionDisplay.tsx
Normal file
24
src/components/textNote/GeneralUserMentionDisplay.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { MentionedUser } from '@/core/parseTextNote';
|
||||
import useProfile from '@/clients/useProfile';
|
||||
import useConfig from '@/clients/useConfig';
|
||||
import { Show } from 'solid-js';
|
||||
|
||||
export type GeneralUserMentionDisplayProps = {
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
const GeneralUserMentionDisplay = (props: GeneralUserMentionDisplayProps) => {
|
||||
const [config] = useConfig();
|
||||
const { profile } = useProfile(() => ({
|
||||
relayUrls: config().relayUrls,
|
||||
pubkey: props.pubkey,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Show when={profile() != null} fallback={`@${props.pubkey}`}>
|
||||
@{profile()?.display_name ?? props.pubkey}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralUserMentionDisplay;
|
||||
11
src/components/textNote/MentionedEventDisplay.tsx
Normal file
11
src/components/textNote/MentionedEventDisplay.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { MentionedEvent } from '@/core/parseTextNote';
|
||||
|
||||
export type MentionedEventDisplayProps = {
|
||||
mentionedEvent: MentionedEvent;
|
||||
};
|
||||
|
||||
const MentionedEventDisplay = (props: MentionedEventDisplayProps) => {
|
||||
return <span class="text-blue-500 underline">@{props.mentionedEvent.eventId}</span>;
|
||||
};
|
||||
|
||||
export default MentionedEventDisplay;
|
||||
16
src/components/textNote/MentionedUserDisplay.tsx
Normal file
16
src/components/textNote/MentionedUserDisplay.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { MentionedUser } from '@/core/parseTextNote';
|
||||
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
|
||||
|
||||
export type MentionedUserDisplayProps = {
|
||||
mentionedUser: MentionedUser;
|
||||
};
|
||||
|
||||
const MentionedUserDisplay = (props: MentionedUserDisplayProps) => {
|
||||
return (
|
||||
<span class="text-blue-500 underline">
|
||||
<GeneralUserMentionDisplay pubkey={props.mentionedUser.pubkey} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default MentionedUserDisplay;
|
||||
11
src/components/textNote/PlainTextDisplay.tsx
Normal file
11
src/components/textNote/PlainTextDisplay.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PlainText } from '@/core/parseTextNote';
|
||||
|
||||
export type PlainTextDisplayProps = {
|
||||
plainText: PlainText;
|
||||
};
|
||||
|
||||
const PlainTextDisplay = (props: PlainTextDisplayProps) => {
|
||||
return <span>{props.plainText.content}</span>;
|
||||
};
|
||||
|
||||
export default PlainTextDisplay;
|
||||
34
src/components/textNote/TextNoteContentDisplay.tsx
Normal file
34
src/components/textNote/TextNoteContentDisplay.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { For } from 'solid-js';
|
||||
import parseTextNote, { type ParsedTextNoteNode } from '@/core/parseTextNote';
|
||||
import type { Event as NostrEvent } from 'nostr-tools/event';
|
||||
import PlainTextDisplay from '@/components/textNote/PlainTextDisplay';
|
||||
import MentionedUserDisplay from '@/components/textNote/MentionedUserDisplay';
|
||||
import MentionedEventDisplay from '@/components/textNote/MentionedEventDisplay';
|
||||
|
||||
export type TextNoteContentDisplayProps = {
|
||||
event: NostrEvent;
|
||||
};
|
||||
|
||||
export const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
||||
return (
|
||||
<For each={parseTextNote(props.event)}>
|
||||
{(item: ParsedTextNoteNode) => {
|
||||
if (item.type === 'PlainText') {
|
||||
return <PlainTextDisplay plainText={item} />;
|
||||
}
|
||||
if (item.type === 'MentionedUser') {
|
||||
return <MentionedUserDisplay mentionedUser={item} />;
|
||||
}
|
||||
if (item.type === 'MentionedEvent') {
|
||||
return <MentionedEventDisplay mentionedEvent={item} />;
|
||||
}
|
||||
if (item.type === 'HashTag') {
|
||||
return <span class="text-blue-500 underline ">{item.content}</span>;
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextNoteContentDisplay;
|
||||
95
src/core/parseTextNote.ts
Normal file
95
src/core/parseTextNote.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Event as NostrEvent } from 'nostr-tools/event';
|
||||
|
||||
export type PlainText = {
|
||||
type: 'PlainText';
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type MentionedEvent = {
|
||||
type: 'MentionedEvent';
|
||||
tagIndex: number;
|
||||
content: string;
|
||||
eventId: string;
|
||||
marker: string | null; // TODO 'reply' | 'root' | 'mention' | null;
|
||||
};
|
||||
|
||||
export type MentionedUser = {
|
||||
type: 'MentionedUser';
|
||||
tagIndex: number;
|
||||
content: string;
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
export type HashTag = {
|
||||
type: 'HashTag';
|
||||
content: string;
|
||||
tagName: string;
|
||||
};
|
||||
|
||||
export type ParsedTextNoteNode = PlainText | MentionedEvent | MentionedUser | HashTag;
|
||||
|
||||
export type ParsedTextNote = ParsedTextNoteNode[];
|
||||
|
||||
export const parseTextNote = (event: NostrEvent): ParsedTextNote => {
|
||||
const matches = Array.from(
|
||||
event.content.matchAll(/(?:#\[(?<idx>\d+)\]|#(?<hashtag>[^\[\]\(\)\s]+))/g),
|
||||
);
|
||||
let pos = 0;
|
||||
const result: ParsedTextNote = [];
|
||||
|
||||
matches.forEach((match) => {
|
||||
if (match.groups?.hashtag) {
|
||||
const tagName = match.groups?.hashtag;
|
||||
if (pos !== match.index) {
|
||||
const content = event.content.slice(pos, match.index);
|
||||
const plainText: PlainText = { type: 'PlainText', content };
|
||||
result.push(plainText);
|
||||
}
|
||||
const hashtag: HashTag = {
|
||||
type: 'HashTag',
|
||||
content: match[0],
|
||||
tagName,
|
||||
};
|
||||
result.push(hashtag);
|
||||
} else if (match.groups?.idx) {
|
||||
const tagIndex = parseInt(match.groups.idx, 10);
|
||||
const tag = event.tags[tagIndex];
|
||||
if (tag == null) return;
|
||||
if (pos !== match.index) {
|
||||
const content = event.content.slice(pos, match.index);
|
||||
const plainText: PlainText = { type: 'PlainText', content };
|
||||
result.push(plainText);
|
||||
}
|
||||
const tagName = tag[0];
|
||||
if (tagName === 'p') {
|
||||
const mentionedUser: MentionedUser = {
|
||||
type: 'MentionedUser',
|
||||
tagIndex,
|
||||
content: match[0],
|
||||
pubkey: tag[1],
|
||||
};
|
||||
result.push(mentionedUser);
|
||||
} else if (tagName === 'e') {
|
||||
const mentionedEvent: MentionedEvent = {
|
||||
type: 'MentionedEvent',
|
||||
tagIndex,
|
||||
content: match[0],
|
||||
eventId: tag[1],
|
||||
marker: tag[2],
|
||||
};
|
||||
result.push(mentionedEvent);
|
||||
}
|
||||
}
|
||||
pos = match.index + match[0].length;
|
||||
});
|
||||
|
||||
if (pos !== event.content.length) {
|
||||
const content = event.content.slice(pos);
|
||||
const plainText: PlainText = { type: 'PlainText', content };
|
||||
result.push(plainText);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export default parseTextNote;
|
||||
@@ -1,7 +1,7 @@
|
||||
// const commands = ['openPostForm'] as const;
|
||||
// type Commands = (typeof commands)[number];
|
||||
|
||||
import { createMemo, createEffect } from 'solid-js';
|
||||
import { onMount, type JSX } from 'solid-js';
|
||||
|
||||
type Shortcut = { key: string; command: string };
|
||||
|
||||
@@ -39,7 +39,7 @@ const createShortcutsMap = (shortcuts: Shortcut[]) => {
|
||||
const useShortcutKeys = ({ shortcuts = defaultShortcut, onShortcut }: UseShortcutKeysProps) => {
|
||||
const shortcutsMap = createShortcutsMap(shortcuts);
|
||||
|
||||
createEffect(() => {
|
||||
onMount(() => {
|
||||
const handleKeydown: JSX.EventHandler<Window, KeyboardEvent> = (ev) => {
|
||||
console.log(ev);
|
||||
if (ev.type !== 'keydown') return;
|
||||
|
||||
@@ -6,8 +6,10 @@ import NotePostForm from '@/components/NotePostForm';
|
||||
import SideBar from '@/components/SideBar';
|
||||
import TextNote from '@/components/TextNote';
|
||||
import useCommands from '@/clients/useCommands';
|
||||
import useConfig from '@/clients/useConfig';
|
||||
import useSubscription from '@/clients/useSubscription';
|
||||
import useShortcutKeys from '@/hooks/useShortcutKeys';
|
||||
import useFollowings from '@/clients/useFollowings';
|
||||
|
||||
/*
|
||||
type UseRelayProps = { pubkey: string };
|
||||
@@ -21,61 +23,57 @@ const publish = async (pool, event) => {
|
||||
*/
|
||||
// const relays = ['ws://localhost:8008'];
|
||||
//
|
||||
// 'wss://relay.damus.io',
|
||||
// 'wss://nos.lol',
|
||||
// 'wss://brb.io',
|
||||
// 'wss://relay.snort.social',
|
||||
// 'wss://relay.current.fyi',
|
||||
// 'wss://relay.nostr.wirednet.jp',
|
||||
const relayUrls = ['wss://relay-jp.nostr.wirednet.jp', 'wss://nostr.h3z.jp/'];
|
||||
const pubkey = 'npub1jcsr6e38dcepf65nkmrc54mu8jd8y70eael9rv308wxpwep6sxwqgsscyc';
|
||||
const pubkeyHex = '96203d66276e3214ea93b6c78a577c3c9a7279f9ee7e51b22f3b8c17643a819c';
|
||||
|
||||
const Home: Component = () => {
|
||||
useShortcutKeys({
|
||||
useShortcutKeys({
|
||||
onShortcut: (s) => console.log(s),
|
||||
});
|
||||
const { publishTextNote } = useCommands();
|
||||
});
|
||||
|
||||
const { events } = useSubscription({
|
||||
relayUrls,
|
||||
const Home: Component = () => {
|
||||
const [config] = useConfig();
|
||||
const commands = useCommands();
|
||||
const { followings } = useFollowings(() => ({
|
||||
relayUrls: config().relayUrls,
|
||||
pubkey: pubkeyHex,
|
||||
}));
|
||||
|
||||
const { events: myPosts } = useSubscription(() => ({
|
||||
relayUrls: config().relayUrls,
|
||||
filters: [
|
||||
{
|
||||
kinds: [1],
|
||||
authors: [pubkeyHex],
|
||||
limit: 100,
|
||||
since: Math.floor(Date.now() / 1000) - 48 * 60 * 60,
|
||||
},
|
||||
],
|
||||
});
|
||||
}));
|
||||
|
||||
const { events: followingsPosts } = useSubscription(() => ({
|
||||
relayUrls: config().relayUrls,
|
||||
filters: [
|
||||
{
|
||||
kinds: [1],
|
||||
authors: followings()?.map((f) => f.pubkey) ?? [pubkeyHex],
|
||||
limit: 100,
|
||||
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const handlePost = ({ content }) => {
|
||||
publishTextNote({ relayUrls, pubkey: pubkeyHex, content });
|
||||
commands.publishTextNote({ relayUrls: config().relayUrls, pubkey: pubkeyHex, content });
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex h-screen w-screen flex-row overflow-hidden">
|
||||
<SideBar postForm={() => <NotePostForm onPost={handlePost} />} />
|
||||
<div class="flex flex-row overflow-x-scroll">
|
||||
<Column width="widest">
|
||||
<For each={events()}>
|
||||
{(ev) => <TextNote content={ev.content} createdAt={new Date(ev.created_at * 1000)} />}
|
||||
</For>
|
||||
<div class="flex flex-row overflow-y-hidden overflow-x-scroll">
|
||||
<Column name="ホーム" width="widest">
|
||||
<For each={followingsPosts()}>{(ev) => <TextNote event={ev} />}</For>
|
||||
</Column>
|
||||
<Column width="medium">
|
||||
<For each={events()}>
|
||||
{(ev) => <TextNote content={ev.content} createdAt={new Date(ev.created_at * 1000)} />}
|
||||
</For>
|
||||
</Column>
|
||||
<Column width="narrow">
|
||||
<For each={events()}>
|
||||
{(ev) => <TextNote content={ev.content} createdAt={new Date(ev.created_at * 1000)} />}
|
||||
</For>
|
||||
</Column>
|
||||
<Column width="narrow">
|
||||
<For each={events()}>
|
||||
{(ev) => <TextNote content={ev.content} createdAt={new Date(ev.created_at * 1000)} />}
|
||||
</For>
|
||||
<Column name="自分の投稿" width="medium">
|
||||
<For each={myPosts()}>{(ev) => <TextNote event={ev} />}</For>
|
||||
</Column>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user