mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 22:14:26 +01:00
update
This commit is contained in:
50
src/components/column/BookmarkColumn.tsx
Normal file
50
src/components/column/BookmarkColumn.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Component, Show, createEffect, onCleanup, onMount } from 'solid-js';
|
||||
|
||||
import BookmarkIcon from 'heroicons/24/outline/bookmark.svg';
|
||||
|
||||
import BasicColumnHeader from '@/components/column/BasicColumnHeader';
|
||||
import Column from '@/components/column/Column';
|
||||
import ColumnSettings from '@/components/column/ColumnSettings';
|
||||
import Bookmark from '@/components/timeline/Bookmark';
|
||||
import { BookmarkColumnType } from '@/core/column';
|
||||
import useConfig from '@/core/useConfig';
|
||||
import useDecrypt from '@/nostr/useDecrypt';
|
||||
import useParameterizedReplaceableEvent from '@/nostr/useParameterizedReplaceableEvent';
|
||||
|
||||
type BookmarkColumnDisplayProps = {
|
||||
columnIndex: number;
|
||||
lastColumn: boolean;
|
||||
column: BookmarkColumnType;
|
||||
};
|
||||
|
||||
const BookmarkColumn: Component<BookmarkColumnDisplayProps> = (props) => {
|
||||
const { removeColumn } = useConfig();
|
||||
|
||||
const { event } = useParameterizedReplaceableEvent(() => ({
|
||||
kind: 30001,
|
||||
author: props.column.pubkey,
|
||||
identifier: props.column.identifier,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Column
|
||||
header={
|
||||
<BasicColumnHeader
|
||||
name={props.column.name ?? 'ブックマーク'}
|
||||
icon={<BookmarkIcon />}
|
||||
settings={() => <ColumnSettings column={props.column} columnIndex={props.columnIndex} />}
|
||||
onClose={() => removeColumn(props.column.id)}
|
||||
/>
|
||||
}
|
||||
width={props.column.width}
|
||||
columnIndex={props.columnIndex}
|
||||
lastColumn={props.lastColumn}
|
||||
>
|
||||
<Show when={event()} keyed>
|
||||
{(ev) => <Bookmark event={ev} />}
|
||||
</Show>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkColumn;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { For, Switch, Match } from 'solid-js';
|
||||
|
||||
import BookmarkColumn from '@/components/column/BookmarkColumn';
|
||||
import FollowingColumn from '@/components/column/FollwingColumn';
|
||||
import NotificationColumn from '@/components/column/NotificationColumn';
|
||||
import PostsColumn from '@/components/column/PostsColumn';
|
||||
@@ -64,6 +65,15 @@ const Columns = () => {
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={column.columnType === 'Bookmark' && column} keyed>
|
||||
{(bookmarkColumn) => (
|
||||
<BookmarkColumn
|
||||
column={bookmarkColumn}
|
||||
columnIndex={columnIndex()}
|
||||
lastColumn={lastColumn()}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={column.columnType === 'Search' && column} keyed>
|
||||
{(reactionsColumn) => (
|
||||
<SearchColumn
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createSearchColumn } from '@/core/column';
|
||||
import useConfig from '@/core/useConfig';
|
||||
import { useRequestCommand } from '@/hooks/useCommandBus';
|
||||
import { textNote } from '@/nostr/event';
|
||||
import parseTextNote, { type ParsedTextNoteNode } from '@/nostr/parseTextNote';
|
||||
import { type ParsedTextNoteNode } from '@/nostr/parseTextNote';
|
||||
import { isImageUrl } from '@/utils/imageUrl';
|
||||
|
||||
export type TextNoteContentDisplayProps = {
|
||||
@@ -112,6 +112,7 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
||||
class="inline-block h-8 max-w-[128px] align-middle"
|
||||
src={emojiUrl}
|
||||
alt={item.content}
|
||||
title={item.shortcode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component } from 'solid-js';
|
||||
|
||||
import Bell from 'heroicons/24/outline/bell.svg';
|
||||
import BookmarkIcon from 'heroicons/24/outline/bookmark.svg';
|
||||
import ChatBubbleLeftRight from 'heroicons/24/outline/chat-bubble-left-right.svg';
|
||||
import GlobeAlt from 'heroicons/24/outline/globe-alt.svg';
|
||||
import Heart from 'heroicons/24/outline/heart.svg';
|
||||
@@ -115,6 +116,17 @@ const AddColumn: Component<AddColumnProps> = (props) => {
|
||||
チャンネル
|
||||
</button>
|
||||
*/}
|
||||
{/*
|
||||
<button
|
||||
class="flex basis-1/2 flex-col items-center gap-2 py-8 sm:basis-1/4"
|
||||
onClick={() => addBookmarkColumn()}
|
||||
>
|
||||
<span class="inline-block h-8 w-8">
|
||||
<BookmarkIcon />
|
||||
</span>
|
||||
ブックマーク
|
||||
</button>
|
||||
*/}
|
||||
<button
|
||||
class="flex basis-1/2 flex-col items-center gap-2 py-8 sm:basis-1/4"
|
||||
onClick={() => addSearchColumn()}
|
||||
|
||||
50
src/components/timeline/Bookmark.tsx
Normal file
50
src/components/timeline/Bookmark.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { For, type Component, createMemo } from 'solid-js';
|
||||
|
||||
import { Kind, type Event as NostrEvent } from 'nostr-tools';
|
||||
|
||||
import ColumnItem from '@/components/ColumnItem';
|
||||
import EventDisplayById from '@/components/event/EventDisplayById';
|
||||
import { genericEvent } from '@/nostr/event';
|
||||
import { parseTags } from '@/nostr/event/Tags';
|
||||
import useDecrypt from '@/nostr/useDecrypt';
|
||||
|
||||
export type BookmarkProps = {
|
||||
event: NostrEvent;
|
||||
};
|
||||
|
||||
const Bookmark: Component<BookmarkProps> = (props) => {
|
||||
const bookmark = createMemo(() => genericEvent(props.event));
|
||||
|
||||
const decrypted = useDecrypt(() => {
|
||||
const { content } = bookmark();
|
||||
if (content == null || content.length === 0) return null;
|
||||
return { id: bookmark().id, encrypted: content };
|
||||
});
|
||||
|
||||
const bookmarkedEventIdsPrivate = () => {
|
||||
const json = decrypted();
|
||||
if (json == null) return [];
|
||||
console.log(json);
|
||||
|
||||
try {
|
||||
return parseTags(json).taggedEventIds();
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const bookmarkedEventIds = () => bookmark().taggedEventIds();
|
||||
|
||||
return (
|
||||
<For each={[...bookmarkedEventIds(), ...bookmarkedEventIdsPrivate()]}>
|
||||
{(eventId) => (
|
||||
<ColumnItem>
|
||||
<EventDisplayById eventId={eventId} ensureKinds={[Kind.Text]} />
|
||||
</ColumnItem>
|
||||
)}
|
||||
</For>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bookmark;
|
||||
@@ -95,6 +95,13 @@ export type SearchColumnType = BaseColumn & {
|
||||
query: string;
|
||||
};
|
||||
|
||||
/** A column which shows events in the bookmark */
|
||||
export type BookmarkColumnType = BaseColumn & {
|
||||
columnType: 'Bookmark';
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
};
|
||||
|
||||
/** A column which shows text notes and reposts posted to the specific relays */
|
||||
export type CustomFilterColumnType = BaseColumn & {
|
||||
columnType: 'CustomFilter';
|
||||
@@ -109,6 +116,7 @@ export type ColumnType =
|
||||
| ChannelColumnType
|
||||
| RelaysColumnType
|
||||
| SearchColumnType
|
||||
| BookmarkColumnType
|
||||
| CustomFilterColumnType;
|
||||
|
||||
type CreateParams<T extends BaseColumn> = Omit<T, keyof BaseColumn | 'columnType'> &
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import uniq from 'lodash/uniq';
|
||||
import { Kind, Event as NostrEvent } from 'nostr-tools';
|
||||
|
||||
import isValidId from '@/nostr/event/isValidId';
|
||||
import TagsBase from '@/nostr/event/TagsBase';
|
||||
|
||||
export default class GenericEvent {
|
||||
constructor(readonly rawEvent: NostrEvent) {}
|
||||
export default class GenericEvent extends TagsBase {
|
||||
constructor(readonly rawEvent: NostrEvent) {
|
||||
super();
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.rawEvent.id;
|
||||
@@ -37,39 +38,4 @@ export default class GenericEvent {
|
||||
createdAtAsDate(): Date {
|
||||
return new Date(this.rawEvent.created_at * 1000);
|
||||
}
|
||||
|
||||
findTagsByName(name: string): string[][] {
|
||||
return this.rawEvent.tags.filter(([tagName]) => tagName === name);
|
||||
}
|
||||
|
||||
findFirstTagByName(name: string): string[] | undefined {
|
||||
return this.rawEvent.tags.find(([tagName]) => tagName === name);
|
||||
}
|
||||
|
||||
findLastTagByName(name: string): string[] | undefined {
|
||||
return this.rawEvent.tags.findLast(([tagName]) => tagName === name);
|
||||
}
|
||||
|
||||
pTags(): string[][] {
|
||||
return this.rawEvent.tags.filter(([tagName, pubkey]) => tagName === 'p' && isValidId(pubkey));
|
||||
}
|
||||
|
||||
eTags(): string[][] {
|
||||
return this.rawEvent.tags.filter(([tagName, eventId]) => tagName === 'e' && isValidId(eventId));
|
||||
}
|
||||
|
||||
taggedPubkeys(): string[] {
|
||||
return uniq(this.pTags().map(([, pubkey]) => pubkey));
|
||||
}
|
||||
|
||||
taggedEventIds(): string[] {
|
||||
return this.eTags().map(([, eventId]) => eventId);
|
||||
}
|
||||
|
||||
lastTaggedEventId(): string | undefined {
|
||||
// for compatibility. some clients include additional event ids for reaction (kind:7).
|
||||
const ids = this.taggedEventIds();
|
||||
if (ids.length === 0) return undefined;
|
||||
return ids[ids.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
20
src/nostr/event/Tags.ts
Normal file
20
src/nostr/event/Tags.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import TagsBase from '@/nostr/event/TagsBase';
|
||||
|
||||
const TagsSchema = z.array(z.array(z.string()));
|
||||
|
||||
export default class Tags extends TagsBase {
|
||||
constructor(readonly tags: string[][]) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export const parseTags = (content: string): Tags => {
|
||||
try {
|
||||
const tags = TagsSchema.parse(JSON.parse(content));
|
||||
return new Tags(tags);
|
||||
} catch (err) {
|
||||
throw new TypeError('failed to parse tags schema: ', { cause: err });
|
||||
}
|
||||
};
|
||||
42
src/nostr/event/TagsBase.ts
Normal file
42
src/nostr/event/TagsBase.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import uniq from 'lodash/uniq';
|
||||
|
||||
import isValidId from '@/nostr/event/isValidId';
|
||||
|
||||
export default abstract class TagsBase {
|
||||
abstract get tags(): string[][];
|
||||
|
||||
findTagsByName(name: string): string[][] {
|
||||
return this.tags.filter(([tagName]) => tagName === name);
|
||||
}
|
||||
|
||||
findFirstTagByName(name: string): string[] | undefined {
|
||||
return this.tags.find(([tagName]) => tagName === name);
|
||||
}
|
||||
|
||||
findLastTagByName(name: string): string[] | undefined {
|
||||
return this.tags.findLast(([tagName]) => tagName === name);
|
||||
}
|
||||
|
||||
pTags(): string[][] {
|
||||
return this.tags.filter(([tagName, pubkey]) => tagName === 'p' && isValidId(pubkey));
|
||||
}
|
||||
|
||||
eTags(): string[][] {
|
||||
return this.tags.filter(([tagName, eventId]) => tagName === 'e' && isValidId(eventId));
|
||||
}
|
||||
|
||||
taggedPubkeys(): string[] {
|
||||
return uniq(this.pTags().map(([, pubkey]) => pubkey));
|
||||
}
|
||||
|
||||
taggedEventIds(): string[] {
|
||||
return this.eTags().map(([, eventId]) => eventId);
|
||||
}
|
||||
|
||||
lastTaggedEventId(): string | undefined {
|
||||
// for compatibility. some clients include additional event ids for reaction (kind:7).
|
||||
const ids = this.taggedEventIds();
|
||||
if (ids.length === 0) return undefined;
|
||||
return ids[ids.length - 1];
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export const { exec } = useBatch<TaskArg, TaskRes>(() => ({
|
||||
const {
|
||||
args: { kind, author, identifier },
|
||||
} = firstTask;
|
||||
filters.push({ kinds: [Kind.Contacts], authors: [author], '#d': [identifier] });
|
||||
filters.push({ kinds: [kind], authors: [author], '#d': [identifier] });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
23
src/nostr/useBookMarks.ts
Normal file
23
src/nostr/useBookMarks.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createMemo } from 'solid-js';
|
||||
|
||||
import { Kind } from 'nostr-tools';
|
||||
|
||||
import useConfig from '@/core/useConfig';
|
||||
import useSubscription from '@/nostr/useSubscription';
|
||||
|
||||
export type UseBookmarksProps = {
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
export default function useBookmarks(propsProvider: () => UseBookmarksProps) {
|
||||
const { config } = useConfig();
|
||||
const props = createMemo(propsProvider);
|
||||
|
||||
const { events } = useSubscription(() => ({
|
||||
relayUrls: config().relayUrls,
|
||||
filters: [{ kinds: [30001 as Kind], authors: [props().pubkey] }],
|
||||
continuous: true,
|
||||
}));
|
||||
|
||||
return { bookmarks: events };
|
||||
}
|
||||
49
src/nostr/useDecrypt.ts
Normal file
49
src/nostr/useDecrypt.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createEffect, createRoot, createSignal } from 'solid-js';
|
||||
|
||||
import usePubkey from '@/nostr/usePubkey';
|
||||
|
||||
type UseDecryptProps = {
|
||||
encrypted: string;
|
||||
};
|
||||
|
||||
const [memo, setMemo] = createRoot(() => createSignal<Record<string, string>>({}));
|
||||
const [decrypting, setDecrypting] = createRoot(() => createSignal<Record<string, boolean>>({}));
|
||||
|
||||
const useDecrypt = (propsProvider: () => UseDecryptProps | null) => {
|
||||
const pubkey = usePubkey();
|
||||
const [decrypted, setDecrypted] = createSignal<string | null>(null);
|
||||
|
||||
createEffect(() => {
|
||||
const props = propsProvider();
|
||||
if (props == null) return;
|
||||
|
||||
const { encrypted } = props;
|
||||
if (encrypted in memo()) {
|
||||
setDecrypted(memo()[encrypted]);
|
||||
return;
|
||||
}
|
||||
|
||||
const p = pubkey();
|
||||
if (p == null) return;
|
||||
|
||||
if (decrypting()[encrypted]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDecrypting((current) => ({ ...current, [encrypted]: true }));
|
||||
|
||||
window.nostr?.nip04
|
||||
?.decrypt?.(p, encrypted)
|
||||
?.then((result) => {
|
||||
setMemo((current) => ({ ...current, [encrypted]: result }));
|
||||
setDecrypted(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`failed to decrypt "${encrypted}"`, err);
|
||||
});
|
||||
});
|
||||
|
||||
return decrypted;
|
||||
};
|
||||
|
||||
export default useDecrypt;
|
||||
@@ -17,7 +17,7 @@ export default function useFollowers(propsProvider: () => UseFollowersProps) {
|
||||
const { events } = useSubscription(() => ({
|
||||
relayUrls: config().relayUrls,
|
||||
filters: [{ kinds: [Kind.Contacts], '#p': [props().pubkey] }],
|
||||
limit: Infinity,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
continuous: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export type UseParameterizedReplaceableEvent = {
|
||||
query: CreateQueryResult<NostrEvent | null>;
|
||||
};
|
||||
|
||||
export const useParameterizedReplaceableEvent = (
|
||||
const useParameterizedReplaceableEvent = (
|
||||
propsProvider: () => UseParameterizedReplaceableEventProps | null,
|
||||
): UseParameterizedReplaceableEvent => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createMemo, observable } from 'solid-js';
|
||||
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
|
||||
import { Event as NostrEvent } from 'nostr-tools';
|
||||
|
||||
import useConfig from '@/core/useConfig';
|
||||
import { exec } from '@/nostr/useBatchedEvents';
|
||||
import timeout from '@/utils/timeout';
|
||||
|
||||
@@ -22,6 +23,7 @@ export type UseReactions = {
|
||||
const EmojiRegex = /\p{Emoji_Presentation}/u;
|
||||
|
||||
const useReactions = (propsProvider: () => UseReactionsProps | null): UseReactions => {
|
||||
const { shouldMuteEvent } = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
const props = createMemo(propsProvider);
|
||||
const genQueryKey = createMemo(() => ['useReactions', props()] as const);
|
||||
@@ -51,7 +53,10 @@ const useReactions = (propsProvider: () => UseReactionsProps | null): UseReactio
|
||||
},
|
||||
);
|
||||
|
||||
const reactions = () => query.data ?? [];
|
||||
const reactions = () => {
|
||||
const data = query.data ?? [];
|
||||
return data.filter((ev) => !shouldMuteEvent(ev));
|
||||
};
|
||||
|
||||
const reactionsGroupedByContent = () => {
|
||||
const result = new Map<string, NostrEvent[]>();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createMemo, observable } from 'solid-js';
|
||||
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
|
||||
import { Event as NostrEvent } from 'nostr-tools';
|
||||
|
||||
import useConfig from '@/core/useConfig';
|
||||
import { exec } from '@/nostr/useBatchedEvents';
|
||||
import timeout from '@/utils/timeout';
|
||||
|
||||
@@ -18,6 +19,7 @@ export type UseReposts = {
|
||||
};
|
||||
|
||||
const useReposts = (propsProvider: () => UseRepostsProps): UseReposts => {
|
||||
const { shouldMuteEvent } = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
const props = createMemo(propsProvider);
|
||||
const genQueryKey = createMemo(() => ['useReposts', props()] as const);
|
||||
@@ -44,7 +46,10 @@ const useReposts = (propsProvider: () => UseRepostsProps): UseReposts => {
|
||||
},
|
||||
);
|
||||
|
||||
const reposts = () => query.data ?? [];
|
||||
const reposts = () => {
|
||||
const data = query.data ?? [];
|
||||
return data.filter((ev) => !shouldMuteEvent(ev));
|
||||
};
|
||||
|
||||
const isRepostedBy = (pubkey: string): boolean =>
|
||||
reposts().findIndex((event) => event.pubkey === pubkey) !== -1;
|
||||
|
||||
Reference in New Issue
Block a user