mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 14:04:21 +01:00
update
This commit is contained in:
@@ -25,7 +25,7 @@ const Column: Component<ColumnProps> = (props) => {
|
|||||||
|
|
||||||
useHandleCommand(() => ({
|
useHandleCommand(() => ({
|
||||||
commandType: 'moveToLastColumn',
|
commandType: 'moveToLastColumn',
|
||||||
handler: (command) => {
|
handler: () => {
|
||||||
if (props.lastColumn) {
|
if (props.lastColumn) {
|
||||||
columnDivRef?.scrollIntoView({ behavior: 'smooth' });
|
columnDivRef?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ const ToggleButton = (props: {
|
|||||||
'justify-end': props.value,
|
'justify-end': props.value,
|
||||||
}}
|
}}
|
||||||
area-label={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" />
|
<span class="m-[-2px] inline-block h-5 w-5 rounded-full border border-primary bg-white shadow" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
// NIP-18 (DEPRECATED)
|
// 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 { Event as NostrEvent } from 'nostr-tools';
|
||||||
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
|
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 ColumnItem from '@/components/ColumnItem';
|
||||||
import UserDisplayName from '@/components/UserDisplayName';
|
import UserDisplayName from '@/components/UserDisplayName';
|
||||||
import TextNote from '@/components/TextNote';
|
|
||||||
import eventWrapper from '@/core/event';
|
import eventWrapper from '@/core/event';
|
||||||
import useFormatDate from '@/hooks/useFormatDate';
|
import useFormatDate from '@/hooks/useFormatDate';
|
||||||
import TextNoteDisplayById from './textNote/TextNoteDisplayById';
|
import TextNoteDisplayById from './textNote/TextNoteDisplayById';
|
||||||
|
|||||||
@@ -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 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 ColumnItem from '@/components/ColumnItem';
|
|
||||||
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
|
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
|
||||||
import ContentWarningDisplay from '@/components/textNote/ContentWarningDisplay';
|
import ContentWarningDisplay from '@/components/textNote/ContentWarningDisplay';
|
||||||
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
|
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
|
||||||
@@ -107,7 +106,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
|||||||
|
|
||||||
const createdAt = () => formatDate(event().createdAtAsDate());
|
const createdAt = () => formatDate(event().createdAtAsDate());
|
||||||
|
|
||||||
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
|
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = () => {
|
||||||
if (isRepostedByMe()) {
|
if (isRepostedByMe()) {
|
||||||
// TODO remove reaction
|
// TODO remove reaction
|
||||||
return;
|
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()) {
|
if (isReactedByMe()) {
|
||||||
// TODO remove reaction
|
// TODO remove reaction
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
// import { z } from 'zod';
|
// 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.
|
// The event which includes ["p", ...] tags.
|
||||||
| 'PubkeyMention'
|
| 'PubkeyMention'
|
||||||
// The event which includes ["e", ...] tags.
|
// The event which includes ["e", ...] tags.
|
||||||
| 'EventMention'
|
| 'EventMention'
|
||||||
// The event which has the deprecated kind 6.
|
// The event which has the deprecated kind 6.
|
||||||
| 'DeprecatedRepost'
|
| 'DeprecatedRepost'
|
||||||
// The event which has
|
// The event which has the deprecated kind 7.
|
||||||
| 'Followed'
|
| 'Reaction';
|
||||||
| 'PlusReaction'
|
|
||||||
| 'MinusReaction'
|
|
||||||
| 'EmojiReaction';
|
|
||||||
|
|
||||||
export type GenericFilterOptions = {
|
export type GenericFilterOptions = {
|
||||||
matching: string;
|
matching: string;
|
||||||
@@ -21,24 +19,73 @@ export type GenericFilterOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type NotificationFilterOptions = {
|
export type NotificationFilterOptions = {
|
||||||
allowedTypes: NotificationTypes[];
|
allowedTypes: NotificationType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type BulidOptions = {
|
type BulidOptions = {
|
||||||
supportedNips: string[];
|
supportedNips: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const notificationFilter =
|
// const notificationFilter =
|
||||||
(filterOption: NotificationFilterOptions) =>
|
// (filterOption: NotificationFilterOptions) =>
|
||||||
(event: NostrEvent): boolean => {};
|
// (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 =
|
export type BaseColumn = {
|
||||||
(filterOption: GenericFilterOptions) =>
|
columnWidth: (typeof ColumnComponent)['width'];
|
||||||
(event: NostrEvent): boolean => {
|
};
|
||||||
event.content;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/** A column which shows posts by following users */
|
||||||
* build a filter for the subscription ("REQ")
|
export type FollowingColumn = {
|
||||||
*/
|
columnType: 'Following';
|
||||||
export const buildFilter = (options: BuildOptions) => {};
|
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;
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export type Bech32Entity = {
|
|||||||
| { type: 'nprofile'; data: ProfilePointer }
|
| { type: 'nprofile'; data: ProfilePointer }
|
||||||
| { type: 'nevent'; data: EventPointer };
|
| { type: 'nevent'; data: EventPointer };
|
||||||
};
|
};
|
||||||
// | { type: 'naddr'; data: AddressPointer };
|
|
||||||
|
|
||||||
export type HashTag = {
|
export type HashTag = {
|
||||||
type: 'HashTag';
|
type: 'HashTag';
|
||||||
@@ -53,13 +52,12 @@ export type ParsedTextNoteNode =
|
|||||||
|
|
||||||
export type ParsedTextNote = ParsedTextNoteNode[];
|
export type ParsedTextNote = ParsedTextNoteNode[];
|
||||||
|
|
||||||
export const parseTextNote = (event: NostrEvent): ParsedTextNote => {
|
const parseTextNote = (event: NostrEvent): ParsedTextNote => {
|
||||||
const matches = [
|
const matches = [
|
||||||
...event.content.matchAll(/(?:#\[(?<idx>\d+)\])/g),
|
...event.content.matchAll(/(?:#\[(?<idx>\d+)\])/g),
|
||||||
...event.content.matchAll(/#(?<hashtag>[^[-^`:-@!-/{-~\d\s][^[-^`:-@!-/{-~\s]+)/g),
|
...event.content.matchAll(/#(?<hashtag>[^[-^`:-@!-/{-~\d\s][^[-^`:-@!-/{-~\s]+)/g),
|
||||||
...event.content.matchAll(
|
// nrelay and naddr is not supported by nostr-tools
|
||||||
/(?<nip19>(npub|note|nprofile|nevent|nrelay|naddr)1[ac-hj-np-z02-9]+)/gi,
|
...event.content.matchAll(/(?<nip19>(npub|note|nprofile|nevent)1[ac-hj-np-z02-9]+)/gi),
|
||||||
),
|
|
||||||
...event.content.matchAll(
|
...event.content.matchAll(
|
||||||
/(?<url>(https?|wss?):\/\/[-a-zA-Z0-9.]+(?:\/[-\w.@%:]+|\/)*(?:\?[-\w=.@%:&]*)?(?:#[-\w=.%:&]*)?)/g,
|
/(?<url>(https?|wss?):\/\/[-a-zA-Z0-9.]+(?:\/[-\w.@%:]+|\/)*(?:\?[-\w=.@%:&]*)?(?:#[-\w=.%:&]*)?)/g,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ type TaskArg =
|
|||||||
| { type: 'Profile'; pubkey: string }
|
| { type: 'Profile'; pubkey: string }
|
||||||
| { type: 'TextNote'; eventId: string }
|
| { type: 'TextNote'; eventId: string }
|
||||||
| { type: 'Reactions'; mentionedEventId: string }
|
| { type: 'Reactions'; mentionedEventId: string }
|
||||||
| { type: 'DeprecatedReposts'; mentionedEventId: string };
|
| { type: 'DeprecatedReposts'; mentionedEventId: string }
|
||||||
|
| { type: 'Followings'; pubkey: string };
|
||||||
|
|
||||||
type BatchedEvents = { completed: boolean; events: NostrEvent[] };
|
type BatchedEvents = { completed: boolean; events: NostrEvent[] };
|
||||||
|
|
||||||
@@ -92,12 +93,30 @@ export type UseDeprecatedReposts = {
|
|||||||
query: CreateQueryResult<NostrEvent[]>;
|
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>(() => ({
|
const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
||||||
executor: (tasks) => {
|
executor: (tasks) => {
|
||||||
const profileTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
|
const profileTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
|
||||||
const textNoteTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
|
const textNoteTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
|
||||||
const reactionsTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
|
const reactionsTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
|
||||||
const repostsTasks = 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) => {
|
tasks.forEach((task) => {
|
||||||
if (task.args.type === 'Profile') {
|
if (task.args.type === 'Profile') {
|
||||||
@@ -112,6 +131,9 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
|||||||
} else if (task.args.type === 'DeprecatedReposts') {
|
} else if (task.args.type === 'DeprecatedReposts') {
|
||||||
const current = repostsTasks.get(task.args.mentionedEventId) ?? [];
|
const current = repostsTasks.get(task.args.mentionedEventId) ?? [];
|
||||||
repostsTasks.set(task.args.mentionedEventId, [...current, task]);
|
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 textNoteIds = [...textNoteTasks.keys()];
|
||||||
const reactionsIds = [...reactionsTasks.keys()];
|
const reactionsIds = [...reactionsTasks.keys()];
|
||||||
const repostsIds = [...repostsTasks.keys()];
|
const repostsIds = [...repostsTasks.keys()];
|
||||||
|
const followingsIds = [...followingsTasks.keys()];
|
||||||
|
|
||||||
const filters: Filter[] = [];
|
const filters: Filter[] = [];
|
||||||
|
|
||||||
@@ -134,6 +157,9 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
|||||||
if (repostsIds.length > 0) {
|
if (repostsIds.length > 0) {
|
||||||
filters.push({ kinds: [6], '#e': repostsIds });
|
filters.push({ kinds: [6], '#e': repostsIds });
|
||||||
}
|
}
|
||||||
|
if (followingsIds.length > 0) {
|
||||||
|
filters.push({ kinds: [Kind.Contacts], authors: followingsIds });
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.length === 0) return;
|
if (filters.length === 0) return;
|
||||||
|
|
||||||
@@ -194,6 +220,9 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
|||||||
const registeredTasks = repostsTasks.get(taggedEventId) ?? [];
|
const registeredTasks = repostsTasks.get(taggedEventId) ?? [];
|
||||||
resolveTasks(registeredTasks, event);
|
resolveTasks(registeredTasks, event);
|
||||||
});
|
});
|
||||||
|
} else if (event.kind === Kind.Contacts) {
|
||||||
|
const registeredTasks = followingsTasks.get(event.pubkey) ?? [];
|
||||||
|
resolveTasks(registeredTasks, event);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onEOSE: () => {
|
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 => {
|
export const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => {
|
||||||
const props = createMemo(propsProvider);
|
const props = createMemo(propsProvider);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -215,9 +249,8 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf
|
|||||||
const { pubkey } = currentProps;
|
const { pubkey } = currentProps;
|
||||||
const promise = exec({ type: 'Profile', pubkey }, signal).then((batchedEvents) => {
|
const promise = exec({ type: 'Profile', pubkey }, signal).then((batchedEvents) => {
|
||||||
const latestEvent = () => {
|
const latestEvent = () => {
|
||||||
const { events } = batchedEvents();
|
const latest = pickLatestEvent(batchedEvents().events);
|
||||||
if (events.length === 0) throw new Error(`profile not found: ${pubkey}`);
|
if (latest == null) throw new Error(`profile not found: ${pubkey}`);
|
||||||
const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
|
|
||||||
return latest;
|
return latest;
|
||||||
};
|
};
|
||||||
observable(batchedEvents).subscribe(() => {
|
observable(batchedEvents).subscribe(() => {
|
||||||
@@ -233,9 +266,10 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf
|
|||||||
return timeout(15000, `useProfile: ${pubkey}`)(promise);
|
return timeout(15000, `useProfile: ${pubkey}`)(promise);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// profile is updated occasionally
|
// Profiles are updated occasionally, so a short staleTime is used here.
|
||||||
staleTime: 5 * 60 * 1000, // 5min
|
// cacheTime is long so that the user see profiles instantly.
|
||||||
cacheTime: 24 * 60 * 60 * 1000, // 1day
|
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);
|
return timeout(15000, `useTextNote: ${eventId}`)(promise);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// text note cannot be updated.
|
// Text notes never change, so they can be stored for a long time.
|
||||||
staleTime: 24 * 60 * 60 * 1000, // 1 day
|
staleTime: 4 * 60 * 60 * 1000, // 4 hour
|
||||||
cacheTime: 24 * 60 * 60 * 1000, // 1 day
|
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(
|
const promise = exec({ type: 'Reactions', mentionedEventId }, signal).then(
|
||||||
(batchedEvents) => {
|
(batchedEvents) => {
|
||||||
const events = () => batchedEvents().events;
|
const events = () => batchedEvents().events;
|
||||||
setTimeout(() => {
|
observable(batchedEvents).subscribe(() => {
|
||||||
observable(batchedEvents).subscribe(() => {
|
queryClient.setQueryData(queryKey, events());
|
||||||
queryClient.setQueryData(queryKey, events());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return events();
|
return events();
|
||||||
},
|
},
|
||||||
@@ -311,7 +343,7 @@ export const useReactions = (propsProvider: () => UseReactionsProps | null): Use
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
staleTime: 1 * 60 * 1000, // 1 min
|
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(
|
const promise = exec({ type: 'DeprecatedReposts', mentionedEventId }, signal).then(
|
||||||
(batchedEvents) => {
|
(batchedEvents) => {
|
||||||
const events = () => batchedEvents().events;
|
const events = () => batchedEvents().events;
|
||||||
setTimeout(() => {
|
observable(batchedEvents).subscribe(() => {
|
||||||
observable(batchedEvents).subscribe(() => {
|
queryClient.setQueryData(queryKey, events());
|
||||||
queryClient.setQueryData(queryKey, events());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return events();
|
return events();
|
||||||
},
|
},
|
||||||
@@ -363,7 +393,7 @@ export const useDeprecatedReposts = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
staleTime: 1 * 60 * 1000, // 1 min
|
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 };
|
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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -6,7 +6,7 @@ import usePool from '@/nostr/usePool';
|
|||||||
const currentDate = (): number => Math.floor(Date.now() / 1000);
|
const currentDate = (): number => Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
// NIP-20: Command Result
|
// NIP-20: Command Result
|
||||||
const waitCommandResult = (pub: Pub): Promise<void> => {
|
const waitCommandResult = (pub: Pub, relayUrl: string): Promise<void> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
pub.on('ok', () => {
|
pub.on('ok', () => {
|
||||||
console.log(`${relayUrl} has accepted our event`);
|
console.log(`${relayUrl} has accepted our event`);
|
||||||
@@ -34,7 +34,7 @@ const useCommands = () => {
|
|||||||
return relayUrls.map(async (relayUrl) => {
|
return relayUrls.map(async (relayUrl) => {
|
||||||
const relay = await pool().ensureRelay(relayUrl);
|
const relay = await pool().ensureRelay(relayUrl);
|
||||||
const pub = relay.publish(signedEvent);
|
const pub = relay.publish(signedEvent);
|
||||||
return waitCommandResult(pub);
|
return waitCommandResult(pub, relayUrl);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,28 +17,40 @@ type UseConfig = {
|
|||||||
removeRelay: (url: string) => void;
|
removeRelay: (url: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const InitialConfig: Config = {
|
const InitialConfig = (): Config => {
|
||||||
relayUrls: [
|
const relayUrls = [
|
||||||
'wss://relay-jp.nostr.wirednet.jp',
|
|
||||||
'wss://nostr.h3z.jp',
|
|
||||||
'wss://relay.damus.io',
|
'wss://relay.damus.io',
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
'wss://relay.snort.social',
|
'wss://relay.snort.social',
|
||||||
'wss://relay.current.fyi',
|
'wss://relay.current.fyi',
|
||||||
'wss://relay.nostr.wirednet.jp',
|
];
|
||||||
'wss://nostr-relay.nokotaro.com',
|
if (navigator.language === 'ja') {
|
||||||
'wss://nostr.holybea.com',
|
relayUrls.push(
|
||||||
],
|
'wss://nostr.h3z.jp',
|
||||||
dateFormat: 'relative',
|
'wss://relay.nostr.wirednet.jp',
|
||||||
keepOpenPostForm: false,
|
'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);
|
const serializer = (config: Config): string => JSON.stringify(config);
|
||||||
// TODO zod使う
|
// 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 storage = createStorageWithSerializer(() => window.localStorage, serializer, deserializer);
|
||||||
const [config, setConfig] = createSignalWithStorage('RabbitConfig', InitialConfig, storage);
|
const [config, setConfig] = createSignalWithStorage('RabbitConfig', InitialConfig(), storage);
|
||||||
|
|
||||||
const useConfig = (): UseConfig => {
|
const useConfig = (): UseConfig => {
|
||||||
const addRelay = (relayUrl: string) => {
|
const addRelay = (relayUrl: string) => {
|
||||||
@@ -56,7 +68,7 @@ const useConfig = (): UseConfig => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: () => ({ ...InitialConfig, ...config() }),
|
config,
|
||||||
setConfig,
|
setConfig,
|
||||||
addRelay,
|
addRelay,
|
||||||
removeRelay,
|
removeRelay,
|
||||||
|
|||||||
@@ -1,65 +1,3 @@
|
|||||||
import { createMemo } from 'solid-js';
|
import { useFollowings } from '@/nostr/useBatchedEvents';
|
||||||
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 };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useFollowings;
|
export default useFollowings;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const usePubkey = (): Accessor<string | undefined> => {
|
|||||||
window.nostr
|
window.nostr
|
||||||
.getPublicKey()
|
.getPublicKey()
|
||||||
.then((key) => setPubkey(key))
|
.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;
|
count += 1;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createSignal, onMount, Switch, Match, type Component } from 'solid-js';
|
import { createSignal, onMount, Switch, Match, type Component } from 'solid-js';
|
||||||
import { useNavigate } from '@solidjs/router';
|
import { useNavigate } from '@solidjs/router';
|
||||||
import usePersistStatus from '@/hooks/usePersistStatus';
|
import usePersistStatus from '@/hooks/usePersistStatus';
|
||||||
import { persistQueryClient } from '@tanstack/react-query-persist-client';
|
|
||||||
|
|
||||||
type SignerStatus = 'checking' | 'available' | 'unavailable';
|
type SignerStatus = 'checking' | 'available' | 'unavailable';
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ const Home: Component = () => {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
config().relayUrls.map(async (relayUrl) => {
|
config().relayUrls.map(async (relayUrl) => {
|
||||||
const relay = await pool().ensureRelay(relayUrl);
|
const relay = await pool().ensureRelay(relayUrl);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
relay.on('notice', (msg: string) => {
|
||||||
relay.on('notice', (msg: any) => {
|
|
||||||
console.error(`NOTICE: ${relayUrl}: ${msg}`);
|
console.error(`NOTICE: ${relayUrl}: ${msg}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -44,6 +43,10 @@ const Home: Component = () => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
console.log(followingPubkeys());
|
||||||
|
});
|
||||||
|
|
||||||
const { events: followingsPosts } = useSubscription(() =>
|
const { events: followingsPosts } = useSubscription(() =>
|
||||||
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
|
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
|
||||||
relayUrls: config().relayUrls,
|
relayUrls: config().relayUrls,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"jsxImportSource": "solid-js",
|
"jsxImportSource": "solid-js",
|
||||||
"types": ["vite/client", "mocha", "node"],
|
"types": ["vite/client", "node"],
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
|||||||
Reference in New Issue
Block a user