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/meta": "^0.28.2",
|
||||||
"@solidjs/router": "^0.6.0",
|
"@solidjs/router": "^0.6.0",
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
|
"@tanstack/solid-query": "^4.24.6",
|
||||||
"@thisbeyond/solid-dnd": "^0.7.3",
|
"@thisbeyond/solid-dnd": "^0.7.3",
|
||||||
"heroicons": "^2.0.15",
|
"heroicons": "^2.0.15",
|
||||||
"nostr-tools": "^1.3.2",
|
"nostr-tools": "^1.3.2",
|
||||||
@@ -1346,6 +1347,30 @@
|
|||||||
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1"
|
"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": {
|
"node_modules/@thisbeyond/solid-dnd": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.3.tgz",
|
"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"
|
"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": {
|
"@thisbeyond/solid-dnd": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.3.tgz",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"@solidjs/meta": "^0.28.2",
|
"@solidjs/meta": "^0.28.2",
|
||||||
"@solidjs/router": "^0.6.0",
|
"@solidjs/router": "^0.6.0",
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
|
"@tanstack/solid-query": "^4.24.6",
|
||||||
"@thisbeyond/solid-dnd": "^0.7.3",
|
"@thisbeyond/solid-dnd": "^0.7.3",
|
||||||
"heroicons": "^2.0.15",
|
"heroicons": "^2.0.15",
|
||||||
"nostr-tools": "^1.3.2",
|
"nostr-tools": "^1.3.2",
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { Routes, Route } from '@solidjs/router';
|
import { Routes, Route } from '@solidjs/router';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query';
|
||||||
|
|
||||||
import Home from '@/pages/Home';
|
import Home from '@/pages/Home';
|
||||||
import NotFound from '@/pages/NotFound';
|
import NotFound from '@/pages/NotFound';
|
||||||
import AccountRecovery from '@/pages/AccountRecovery';
|
import AccountRecovery from '@/pages/AccountRecovery';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const App: Component = () => (
|
const App: Component = () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/recovery" element={<AccountRecovery />} />
|
<Route path="/recovery" element={<AccountRecovery />} />
|
||||||
<Route path="/*" element={<NotFound />} />
|
<Route path="/*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
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 { createSignal, createEffect } from 'solid-js';
|
||||||
|
import type { Event as NostrEvent } from 'nostr-tools/event';
|
||||||
import type { Filter } from 'nostr-tools/filter';
|
import type { Filter } from 'nostr-tools/filter';
|
||||||
import type { SubscriptionOptions } from 'nostr-tools/relay';
|
import type { SubscriptionOptions } from 'nostr-tools/relay';
|
||||||
import usePool from '@/clients/usePool';
|
import usePool from '@/clients/usePool';
|
||||||
|
|
||||||
type UseSubscriptionProps = {
|
export type UseSubscriptionProps = {
|
||||||
relayUrls: string[];
|
relayUrls: string[];
|
||||||
filters: Filter[];
|
filters: Filter[];
|
||||||
options?: SubscriptionOptions;
|
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 pool = usePool();
|
||||||
const [events, setEvents] = createSignal<Event[]>([]);
|
const [events, setEvents] = createSignal<NostrEvent[]>([]);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const sub = pool().sub(relayUrls, filters, options);
|
const { relayUrls, filters, options } = propsProvider();
|
||||||
const tempEvents: Event[] = [];
|
|
||||||
|
|
||||||
sub.on('event', (event: Event) => {
|
const sub = pool().sub(relayUrls, filters, options);
|
||||||
tempEvents.push(event);
|
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 intervalId = setInterval(() => {
|
||||||
const newEvents = tempEvents.splice(0, tempEvents.length);
|
if (eose) {
|
||||||
setEvents((prevEvents) => [...newEvents, ...prevEvents]);
|
clearInterval(intervalId);
|
||||||
}, 500);
|
return;
|
||||||
|
}
|
||||||
|
setEvents(sortEvents(storedEvents));
|
||||||
|
}, 100);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
sub.unsub();
|
sub.unsub();
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component, JSX } from 'solid-js';
|
||||||
|
|
||||||
type ColumnProps = {
|
|
||||||
width: 'wide' | 'medium' | 'narrow' | null | undefined;
|
|
||||||
children: JSX.Element;
|
|
||||||
};
|
|
||||||
|
|
||||||
const widthToClass = {
|
const widthToClass = {
|
||||||
widest: 'w-[500px]',
|
widest: 'w-[500px]',
|
||||||
@@ -12,6 +7,12 @@ const widthToClass = {
|
|||||||
narrow: 'w-[270px]',
|
narrow: 'w-[270px]',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
type ColumnProps = {
|
||||||
|
name: string;
|
||||||
|
width: keyof typeof widthToClass | null | undefined;
|
||||||
|
children: JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
const Column: Component<ColumnProps> = (props) => {
|
const Column: Component<ColumnProps> = (props) => {
|
||||||
const width = () => {
|
const width = () => {
|
||||||
if (props.width == null) {
|
if (props.width == null) {
|
||||||
@@ -23,8 +24,8 @@ const Column: Component<ColumnProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div class={`h-full shrink-0 border-r ${width()}`}>
|
<div class={`h-full shrink-0 border-r ${width()}`}>
|
||||||
<div class="flex h-8 items-center border-b bg-white px-2">
|
<div class="flex h-8 items-center border-b bg-white px-2">
|
||||||
<span class="column-icon">🏠</span>
|
{/* <span class="column-icon">🏠</span> */}
|
||||||
<span class="column-name">Home</span>
|
<span class="column-name">{props.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full overflow-y-scroll pb-8">{props.children}</div>
|
<div class="h-full overflow-y-scroll pb-8">{props.children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type SideBarProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SideBar: Component<SideBarProps> = (props) => {
|
const SideBar: Component<SideBarProps> = (props) => {
|
||||||
const [formOpened, setFormOpened] = createSignal(true);
|
const [formOpened, setFormOpened] = createSignal(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex shrink-0 flex-row border-r bg-sidebar-bg">
|
<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">
|
<button class="h-9 w-9 rounded-full border border-primary p-2 text-2xl font-bold text-primary">
|
||||||
<MagnifyingGlass />
|
<MagnifyingGlass />
|
||||||
</button>
|
</button>
|
||||||
<div>column 1</div>
|
{/* <div>column 1</div> */}
|
||||||
<div>column 2</div>
|
{/* <div>column 2</div> */}
|
||||||
</div>
|
</div>
|
||||||
<Show when={formOpened()}>{() => props.postForm()}</Show>
|
<Show when={formOpened()}>{() => props.postForm()}</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +1,69 @@
|
|||||||
|
import { createMemo, Show, For } from 'solid-js';
|
||||||
import type { Component } 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 = {
|
export type TextNoteProps = {
|
||||||
content: string;
|
event: NostrEvent;
|
||||||
createdAt: Date;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const TextNote: Component<TextNoteProps> = (props) => {
|
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 (
|
return (
|
||||||
<div class="textnote flex w-full flex-row gap-1 overflow-hidden border-b p-1">
|
<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
|
<img
|
||||||
src="https://i.gyazo.com/883119a7763e594d30c5706a62969d52.jpg"
|
src={author()?.picture}
|
||||||
alt="author icon"
|
alt="icon"
|
||||||
// TODO autofit
|
// TODO autofit
|
||||||
class="w-10 rounded"
|
class="h-10 w-10 rounded"
|
||||||
/>
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-auto">
|
<div class="min-w-0 flex-auto">
|
||||||
<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">
|
<div class="author flex min-w-0 truncate">
|
||||||
{/* TODO link to profile */}
|
{/* TODO link to author */}
|
||||||
<div class="author-name font-bold">Author</div>
|
<Show when={author()?.display_name}>
|
||||||
<div class="author-username truncate pl-1">@aauthorauthorauthorauthorauthoruthor</div>
|
<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>
|
||||||
<div class="created-at shrink-0">{props.createdAt.toLocaleTimeString()}</div>
|
|
||||||
</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>
|
||||||
</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;
|
// const commands = ['openPostForm'] as const;
|
||||||
// type Commands = (typeof commands)[number];
|
// type Commands = (typeof commands)[number];
|
||||||
|
|
||||||
import { createMemo, createEffect } from 'solid-js';
|
import { onMount, type JSX } from 'solid-js';
|
||||||
|
|
||||||
type Shortcut = { key: string; command: string };
|
type Shortcut = { key: string; command: string };
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ const createShortcutsMap = (shortcuts: Shortcut[]) => {
|
|||||||
const useShortcutKeys = ({ shortcuts = defaultShortcut, onShortcut }: UseShortcutKeysProps) => {
|
const useShortcutKeys = ({ shortcuts = defaultShortcut, onShortcut }: UseShortcutKeysProps) => {
|
||||||
const shortcutsMap = createShortcutsMap(shortcuts);
|
const shortcutsMap = createShortcutsMap(shortcuts);
|
||||||
|
|
||||||
createEffect(() => {
|
onMount(() => {
|
||||||
const handleKeydown: JSX.EventHandler<Window, KeyboardEvent> = (ev) => {
|
const handleKeydown: JSX.EventHandler<Window, KeyboardEvent> = (ev) => {
|
||||||
console.log(ev);
|
console.log(ev);
|
||||||
if (ev.type !== 'keydown') return;
|
if (ev.type !== 'keydown') return;
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import NotePostForm from '@/components/NotePostForm';
|
|||||||
import SideBar from '@/components/SideBar';
|
import SideBar from '@/components/SideBar';
|
||||||
import TextNote from '@/components/TextNote';
|
import TextNote from '@/components/TextNote';
|
||||||
import useCommands from '@/clients/useCommands';
|
import useCommands from '@/clients/useCommands';
|
||||||
|
import useConfig from '@/clients/useConfig';
|
||||||
import useSubscription from '@/clients/useSubscription';
|
import useSubscription from '@/clients/useSubscription';
|
||||||
import useShortcutKeys from '@/hooks/useShortcutKeys';
|
import useShortcutKeys from '@/hooks/useShortcutKeys';
|
||||||
|
import useFollowings from '@/clients/useFollowings';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
type UseRelayProps = { pubkey: string };
|
type UseRelayProps = { pubkey: string };
|
||||||
@@ -21,61 +23,57 @@ const publish = async (pool, event) => {
|
|||||||
*/
|
*/
|
||||||
// const relays = ['ws://localhost:8008'];
|
// 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 pubkey = 'npub1jcsr6e38dcepf65nkmrc54mu8jd8y70eael9rv308wxpwep6sxwqgsscyc';
|
||||||
const pubkeyHex = '96203d66276e3214ea93b6c78a577c3c9a7279f9ee7e51b22f3b8c17643a819c';
|
const pubkeyHex = '96203d66276e3214ea93b6c78a577c3c9a7279f9ee7e51b22f3b8c17643a819c';
|
||||||
|
|
||||||
const Home: Component = () => {
|
useShortcutKeys({
|
||||||
useShortcutKeys({
|
|
||||||
onShortcut: (s) => console.log(s),
|
onShortcut: (s) => console.log(s),
|
||||||
});
|
});
|
||||||
const { publishTextNote } = useCommands();
|
|
||||||
|
|
||||||
const { events } = useSubscription({
|
const Home: Component = () => {
|
||||||
relayUrls,
|
const [config] = useConfig();
|
||||||
|
const commands = useCommands();
|
||||||
|
const { followings } = useFollowings(() => ({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
pubkey: pubkeyHex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { events: myPosts } = useSubscription(() => ({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
kinds: [1],
|
kinds: [1],
|
||||||
authors: [pubkeyHex],
|
authors: [pubkeyHex],
|
||||||
limit: 100,
|
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 }) => {
|
const handlePost = ({ content }) => {
|
||||||
publishTextNote({ relayUrls, pubkey: pubkeyHex, content });
|
commands.publishTextNote({ relayUrls: config().relayUrls, pubkey: pubkeyHex, content });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-screen w-screen flex-row overflow-hidden">
|
<div class="flex h-screen w-screen flex-row overflow-hidden">
|
||||||
<SideBar postForm={() => <NotePostForm onPost={handlePost} />} />
|
<SideBar postForm={() => <NotePostForm onPost={handlePost} />} />
|
||||||
<div class="flex flex-row overflow-x-scroll">
|
<div class="flex flex-row overflow-y-hidden overflow-x-scroll">
|
||||||
<Column width="widest">
|
<Column name="ホーム" width="widest">
|
||||||
<For each={events()}>
|
<For each={followingsPosts()}>{(ev) => <TextNote event={ev} />}</For>
|
||||||
{(ev) => <TextNote content={ev.content} createdAt={new Date(ev.created_at * 1000)} />}
|
|
||||||
</For>
|
|
||||||
</Column>
|
</Column>
|
||||||
<Column width="medium">
|
<Column name="自分の投稿" width="medium">
|
||||||
<For each={events()}>
|
<For each={myPosts()}>{(ev) => <TextNote event={ev} />}</For>
|
||||||
{(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>
|
</Column>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user