This commit is contained in:
Shusui MOYATANI
2023-03-21 09:40:30 +09:00
parent 85ef6e4def
commit 07c02fd937
15 changed files with 227 additions and 208 deletions

View File

@@ -25,7 +25,7 @@ const Column: Component<ColumnProps> = (props) => {
useHandleCommand(() => ({
commandType: 'moveToLastColumn',
handler: (command) => {
handler: () => {
if (props.lastColumn) {
columnDivRef?.scrollIntoView({ behavior: 'smooth' });
}

View File

@@ -125,7 +125,7 @@ const ToggleButton = (props: {
'justify-end': props.value,
}}
area-label={props.value}
onClick={props.onClick}
onClick={(event) => props.onClick(event)}
>
<span class="m-[-2px] inline-block h-5 w-5 rounded-full border border-primary bg-white shadow" />
</button>

View File

@@ -1,15 +1,10 @@
// NIP-18 (DEPRECATED)
import { Show, Switch, Match, type Component, createMemo } from 'solid-js';
import { type Component, createMemo } from 'solid-js';
import { Event as NostrEvent } from 'nostr-tools';
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
import useConfig from '@/nostr/useConfig';
import useEvent from '@/nostr/useEvent';
import useProfile from '@/nostr/useProfile';
import ColumnItem from '@/components/ColumnItem';
import UserDisplayName from '@/components/UserDisplayName';
import TextNote from '@/components/TextNote';
import eventWrapper from '@/core/event';
import useFormatDate from '@/hooks/useFormatDate';
import TextNoteDisplayById from './textNote/TextNoteDisplayById';

View File

@@ -8,7 +8,6 @@ import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-squa
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
import ColumnItem from '@/components/ColumnItem';
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
import ContentWarningDisplay from '@/components/textNote/ContentWarningDisplay';
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
@@ -107,7 +106,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
const createdAt = () => formatDate(event().createdAtAsDate());
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = () => {
if (isRepostedByMe()) {
// TODO remove reaction
return;
@@ -123,7 +122,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
});
};
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = () => {
if (isReactedByMe()) {
// TODO remove reaction
return;

View File

@@ -1,18 +1,16 @@
// import { z } from 'zod';
import { Event as NostrEvent } from 'nostr-tools';
import { type Event as NostrEvent, type Filter } from 'nostr-tools';
import ColumnComponent from '@/components/Column';
export type NotificationTypes =
export type NotificationType =
// The event which includes ["p", ...] tags.
| 'PubkeyMention'
// The event which includes ["e", ...] tags.
| 'EventMention'
// The event which has the deprecated kind 6.
| 'DeprecatedRepost'
// The event which has
| 'Followed'
| 'PlusReaction'
| 'MinusReaction'
| 'EmojiReaction';
// The event which has the deprecated kind 7.
| 'Reaction';
export type GenericFilterOptions = {
matching: string;
@@ -21,24 +19,73 @@ export type GenericFilterOptions = {
};
export type NotificationFilterOptions = {
allowedTypes: NotificationTypes[];
allowedTypes: NotificationType[];
};
type BulidOptions = {
supportedNips: string[];
};
const notificationFilter =
(filterOption: NotificationFilterOptions) =>
(event: NostrEvent): boolean => {};
// const notificationFilter =
// (filterOption: NotificationFilterOptions) =>
// (event: NostrEvent): boolean => {};
//
// const genericFilter =
// (filterOption: GenericFilterOptions) =>
// (event: NostrEvent): boolean => {
// event.content;
// };
//
// /**
// * build a filter for the subscription ("REQ")
// */
// export const buildFilter = (options: BuildOptions) => {};
const genericFilter =
(filterOption: GenericFilterOptions) =>
(event: NostrEvent): boolean => {
event.content;
};
export type BaseColumn = {
columnWidth: (typeof ColumnComponent)['width'];
};
/**
* build a filter for the subscription ("REQ")
*/
export const buildFilter = (options: BuildOptions) => {};
/** A column which shows posts by following users */
export type FollowingColumn = {
columnType: 'Following';
pubkey: string;
};
/** A column which shows replies, reactions, reposts to the specific user */
export type NotificationColumn = {
columnType: 'Notification';
notificationTypes: NotificationType[];
pubkey: string;
};
/** A column which shows posts from the specific user */
export type PostsColumn = {
columnType: 'Posts';
pubkey: string;
};
/** A column which shows reactions published by the specific user */
export type ReactionsColumn = {
columnType: 'Reactions';
pubkey: string;
};
/** A column which shows text notes and reposts posted to the specific relays */
export type GlobalColumn = {
columnType: 'Global';
relayUrls: string[];
};
/** A column which shows text notes and reposts posted to the specific relays */
export type CustomFilterColumn = {
columnType: 'CustomFilter';
filters: Filter[];
};
export type ColumnConfig =
| FollowingColumn
| NotificationColumn
| GlobalColumn
| PostsColumn
| ReactionsColumn
| CustomFilterColumn;

View File

@@ -30,7 +30,6 @@ export type Bech32Entity = {
| { type: 'nprofile'; data: ProfilePointer }
| { type: 'nevent'; data: EventPointer };
};
// | { type: 'naddr'; data: AddressPointer };
export type HashTag = {
type: 'HashTag';
@@ -53,13 +52,12 @@ export type ParsedTextNoteNode =
export type ParsedTextNote = ParsedTextNoteNode[];
export const parseTextNote = (event: NostrEvent): ParsedTextNote => {
const parseTextNote = (event: NostrEvent): ParsedTextNote => {
const matches = [
...event.content.matchAll(/(?:#\[(?<idx>\d+)\])/g),
...event.content.matchAll(/#(?<hashtag>[^[-^`:-@!-/{-~\d\s][^[-^`:-@!-/{-~\s]+)/g),
...event.content.matchAll(
/(?<nip19>(npub|note|nprofile|nevent|nrelay|naddr)1[ac-hj-np-z02-9]+)/gi,
),
// nrelay and naddr is not supported by nostr-tools
...event.content.matchAll(/(?<nip19>(npub|note|nprofile|nevent)1[ac-hj-np-z02-9]+)/gi),
...event.content.matchAll(
/(?<url>(https?|wss?):\/\/[-a-zA-Z0-9.]+(?:\/[-\w.@%:]+|\/)*(?:\?[-\w=.@%:&]*)?(?:#[-\w=.%:&]*)?)/g,
),

View File

@@ -20,7 +20,8 @@ type TaskArg =
| { type: 'Profile'; pubkey: string }
| { type: 'TextNote'; eventId: string }
| { type: 'Reactions'; mentionedEventId: string }
| { type: 'DeprecatedReposts'; mentionedEventId: string };
| { type: 'DeprecatedReposts'; mentionedEventId: string }
| { type: 'Followings'; pubkey: string };
type BatchedEvents = { completed: boolean; events: NostrEvent[] };
@@ -92,12 +93,30 @@ export type UseDeprecatedReposts = {
query: CreateQueryResult<NostrEvent[]>;
};
// Followings
type UseFollowingsProps = {
pubkey: string;
};
type Following = {
pubkey: string;
mainRelayUrl?: string;
petname?: string;
};
export type UseFollowings = {
followings: Accessor<Following[]>;
followingPubkeys: Accessor<string[]>;
query: CreateQueryResult<NostrEvent | undefined>;
};
const { exec } = useBatch<TaskArg, TaskRes>(() => ({
executor: (tasks) => {
const profileTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
const textNoteTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
const reactionsTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
const repostsTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
const followingsTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
tasks.forEach((task) => {
if (task.args.type === 'Profile') {
@@ -112,6 +131,9 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
} else if (task.args.type === 'DeprecatedReposts') {
const current = repostsTasks.get(task.args.mentionedEventId) ?? [];
repostsTasks.set(task.args.mentionedEventId, [...current, task]);
} else if (task.args.type === 'Followings') {
const current = followingsTasks.get(task.args.pubkey) ?? [];
followingsTasks.set(task.args.pubkey, [...current, task]);
}
});
@@ -119,6 +141,7 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
const textNoteIds = [...textNoteTasks.keys()];
const reactionsIds = [...reactionsTasks.keys()];
const repostsIds = [...repostsTasks.keys()];
const followingsIds = [...followingsTasks.keys()];
const filters: Filter[] = [];
@@ -134,6 +157,9 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
if (repostsIds.length > 0) {
filters.push({ kinds: [6], '#e': repostsIds });
}
if (followingsIds.length > 0) {
filters.push({ kinds: [Kind.Contacts], authors: followingsIds });
}
if (filters.length === 0) return;
@@ -194,6 +220,9 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
const registeredTasks = repostsTasks.get(taggedEventId) ?? [];
resolveTasks(registeredTasks, event);
});
} else if (event.kind === Kind.Contacts) {
const registeredTasks = followingsTasks.get(event.pubkey) ?? [];
resolveTasks(registeredTasks, event);
}
},
onEOSE: () => {
@@ -203,6 +232,11 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
},
}));
const pickLatestEvent = (events: NostrEvent[]): NostrEvent | undefined => {
if (events.length === 0) return undefined;
return events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
};
export const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => {
const props = createMemo(propsProvider);
const queryClient = useQueryClient();
@@ -215,9 +249,8 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf
const { pubkey } = currentProps;
const promise = exec({ type: 'Profile', pubkey }, signal).then((batchedEvents) => {
const latestEvent = () => {
const { events } = batchedEvents();
if (events.length === 0) throw new Error(`profile not found: ${pubkey}`);
const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
const latest = pickLatestEvent(batchedEvents().events);
if (latest == null) throw new Error(`profile not found: ${pubkey}`);
return latest;
};
observable(batchedEvents).subscribe(() => {
@@ -233,9 +266,10 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf
return timeout(15000, `useProfile: ${pubkey}`)(promise);
},
{
// profile is updated occasionally
staleTime: 5 * 60 * 1000, // 5min
cacheTime: 24 * 60 * 60 * 1000, // 1day
// Profiles are updated occasionally, so a short staleTime is used here.
// cacheTime is long so that the user see profiles instantly.
staleTime: 5 * 60 * 1000, // 5 min
cacheTime: 24 * 60 * 60 * 1000, // 1 day
},
);
@@ -273,9 +307,9 @@ export const useTextNote = (propsProvider: () => UseTextNoteProps | null): UseTe
return timeout(15000, `useTextNote: ${eventId}`)(promise);
},
{
// text note cannot be updated.
staleTime: 24 * 60 * 60 * 1000, // 1 day
cacheTime: 24 * 60 * 60 * 1000, // 1 day
// Text notes never change, so they can be stored for a long time.
staleTime: 4 * 60 * 60 * 1000, // 4 hour
cacheTime: 4 * 60 * 60 * 1000, // 4 hour
},
);
@@ -299,10 +333,8 @@ export const useReactions = (propsProvider: () => UseReactionsProps | null): Use
const promise = exec({ type: 'Reactions', mentionedEventId }, signal).then(
(batchedEvents) => {
const events = () => batchedEvents().events;
setTimeout(() => {
observable(batchedEvents).subscribe(() => {
queryClient.setQueryData(queryKey, events());
});
observable(batchedEvents).subscribe(() => {
queryClient.setQueryData(queryKey, events());
});
return events();
},
@@ -311,7 +343,7 @@ export const useReactions = (propsProvider: () => UseReactionsProps | null): Use
},
{
staleTime: 1 * 60 * 1000, // 1 min
cacheTime: 5 * 60 * 1000, // 5 min
cacheTime: 4 * 60 * 60 * 1000, // 4 hour
},
);
@@ -351,10 +383,8 @@ export const useDeprecatedReposts = (
const promise = exec({ type: 'DeprecatedReposts', mentionedEventId }, signal).then(
(batchedEvents) => {
const events = () => batchedEvents().events;
setTimeout(() => {
observable(batchedEvents).subscribe(() => {
queryClient.setQueryData(queryKey, events());
});
observable(batchedEvents).subscribe(() => {
queryClient.setQueryData(queryKey, events());
});
return events();
},
@@ -363,7 +393,7 @@ export const useDeprecatedReposts = (
},
{
staleTime: 1 * 60 * 1000, // 1 min
cacheTime: 5 * 60 * 1000, // 5 min
cacheTime: 4 * 60 * 60 * 1000, // 4 hour
},
);
@@ -377,3 +407,70 @@ export const useDeprecatedReposts = (
return { reposts, isRepostedBy, invalidateDeprecatedReposts, query };
};
export const useFollowings = (propsProvider: () => UseFollowingsProps | null): UseFollowings => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const genQueryKey = () => ['useFollowings', props()] as const;
const query = createQuery(
genQueryKey,
({ queryKey, signal }) => {
const [, currentProps] = queryKey;
if (currentProps == null) return undefined;
const { pubkey } = currentProps;
const promise = exec({ type: 'Followings', pubkey }, signal).then((batchedEvents) => {
const latestEvent = () => {
const latest = pickLatestEvent(batchedEvents().events);
if (latest == null) throw new Error(`followings not found: ${pubkey}`);
return latest;
};
observable(batchedEvents).subscribe(() => {
try {
queryClient.setQueryData(queryKey, latestEvent());
} catch (err) {
console.error(err);
}
});
return latestEvent();
});
return timeout(15000, `useFollowings: ${pubkey}`)(promise);
},
{
staleTime: 5 * 60 * 1000, // 5 min
cacheTime: 4 * 60 * 60 * 1000, // 4 hour
refetchOnWindowFocus: false,
},
);
const followings = () => {
if (query.data == null) return [];
const event = query.data;
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;
};
const followingPubkeys = (): string[] => {
return followings().map((follow) => follow.pubkey);
};
return { followings, followingPubkeys, query };
};
export default useFollowings;

View File

@@ -1,69 +0,0 @@
import { createQuery } from '@tanstack/solid-query';
import { type UseSubscriptionProps } from '@/nostr/useSubscription';
import type { Event as NostrEvent, Filter, SimplePool, SubscriptionOptions } from 'nostr-tools';
import usePool from './usePool';
type GetEventsArgs = {
pool: SimplePool;
relayUrls: string[];
filters: Filter[];
// TODO 継続的に取得する場合、Promiseでは無理なので、無理やりキャッシュにストアする仕組みを使う
continuous?: boolean;
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 is useful when you want to fetch some data which change occasionally:
* profile or following list, reactions, and something like that.
*/
const useCachedEvents = (propsProvider: () => UseSubscriptionProps | null) => {
const pool = usePool();
return createQuery(
() => {
const currentProps = propsProvider();
return ['useCachedEvents', currentProps] as const;
},
({ queryKey, signal }) => {
const [, currentProps] = queryKey;
if (currentProps == null) return [];
return getEvents({ pool: pool(), signal, ...currentProps });
},
{
staleTime: 5 * 60 * 1000,
cacheTime: 15 * 60 * 1000,
},
);
};
export default useCachedEvents;

View File

@@ -6,7 +6,7 @@ import usePool from '@/nostr/usePool';
const currentDate = (): number => Math.floor(Date.now() / 1000);
// NIP-20: Command Result
const waitCommandResult = (pub: Pub): Promise<void> => {
const waitCommandResult = (pub: Pub, relayUrl: string): Promise<void> => {
return new Promise((resolve, reject) => {
pub.on('ok', () => {
console.log(`${relayUrl} has accepted our event`);
@@ -34,7 +34,7 @@ const useCommands = () => {
return relayUrls.map(async (relayUrl) => {
const relay = await pool().ensureRelay(relayUrl);
const pub = relay.publish(signedEvent);
return waitCommandResult(pub);
return waitCommandResult(pub, relayUrl);
});
};

View File

@@ -17,28 +17,40 @@ type UseConfig = {
removeRelay: (url: string) => void;
};
const InitialConfig: Config = {
relayUrls: [
'wss://relay-jp.nostr.wirednet.jp',
'wss://nostr.h3z.jp',
const InitialConfig = (): Config => {
const relayUrls = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.snort.social',
'wss://relay.current.fyi',
'wss://relay.nostr.wirednet.jp',
'wss://nostr-relay.nokotaro.com',
'wss://nostr.holybea.com',
],
dateFormat: 'relative',
keepOpenPostForm: false,
];
if (navigator.language === 'ja') {
relayUrls.push(
'wss://nostr.h3z.jp',
'wss://relay.nostr.wirednet.jp',
'wss://relay-jp.nostr.wirednet.jp',
'wss://nostr.holybea.com',
'wss://nostr-relay.nokotaro.com',
);
}
return {
relayUrls,
dateFormat: 'relative',
keepOpenPostForm: false,
};
};
const serializer = (config: Config): string => JSON.stringify(config);
// TODO zod使う
const deserializer = (json: string): Config => JSON.parse(json) as Config;
const deserializer = (json: string): Config =>
({
...InitialConfig(),
...JSON.parse(json),
} as Config);
const storage = createStorageWithSerializer(() => window.localStorage, serializer, deserializer);
const [config, setConfig] = createSignalWithStorage('RabbitConfig', InitialConfig, storage);
const [config, setConfig] = createSignalWithStorage('RabbitConfig', InitialConfig(), storage);
const useConfig = (): UseConfig => {
const addRelay = (relayUrl: string) => {
@@ -56,7 +68,7 @@ const useConfig = (): UseConfig => {
};
return {
config: () => ({ ...InitialConfig, ...config() }),
config,
setConfig,
addRelay,
removeRelay,

View File

@@ -1,65 +1,3 @@
import { createMemo } from 'solid-js';
import useCachedEvents from '@/nostr/useCachedEvents';
type UseFollowingsProps = {
relayUrls: string[];
pubkey: string;
};
type Following = {
pubkey: string;
mainRelayUrl?: string;
petname?: string;
};
const useFollowings = (propsProvider: () => UseFollowingsProps | null) => {
const props = createMemo(propsProvider);
const query = useCachedEvents(() => {
const currentProps = props();
if (currentProps == null) return null;
const { relayUrls, pubkey } = currentProps;
return {
relayUrls,
filters: [
{
kinds: [3],
authors: [pubkey],
limit: 1,
},
],
};
});
const followings = () => {
if (query.data != null && query.data.length === 0) return [];
const event = query.data?.reduce((a, b) => (a.created_at > b.created_at ? a : b));
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;
};
const followingPubkeys = (): string[] => {
return followings().map((follow) => follow.pubkey);
};
return { followings, followingPubkeys };
};
import { useFollowings } from '@/nostr/useBatchedEvents';
export default useFollowings;

View File

@@ -25,7 +25,7 @@ const usePubkey = (): Accessor<string | undefined> => {
window.nostr
.getPublicKey()
.then((key) => setPubkey(key))
.catch((err) => console.error(`failed to obtain public key: ${err}`));
.catch((err) => console.error('failed to obtain public key: ', err));
}
count += 1;
}, 1000);

View File

@@ -1,7 +1,6 @@
import { createSignal, onMount, Switch, Match, type Component } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import usePersistStatus from '@/hooks/usePersistStatus';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
type SignerStatus = 'checking' | 'available' | 'unavailable';

View File

@@ -30,8 +30,7 @@ const Home: Component = () => {
createEffect(() => {
config().relayUrls.map(async (relayUrl) => {
const relay = await pool().ensureRelay(relayUrl);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
relay.on('notice', (msg: any) => {
relay.on('notice', (msg: string) => {
console.error(`NOTICE: ${relayUrl}: ${msg}`);
});
});
@@ -44,6 +43,10 @@ const Home: Component = () => {
})),
);
createEffect(() => {
console.log(followingPubkeys());
});
const { events: followingsPosts } = useSubscription(() =>
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
relayUrls: config().relayUrls,

View File

@@ -14,7 +14,7 @@
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client", "mocha", "node"],
"types": ["vite/client", "node"],
"noEmit": true,
"isolatedModules": true,
"baseUrl": ".",