This commit is contained in:
Shusui MOYATANI
2023-06-13 00:39:03 +09:00
parent 9dac970495
commit df8bc01e92
17 changed files with 286 additions and 45 deletions

View 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;

View File

@@ -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

View File

@@ -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}
/>
);
}

View File

@@ -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()}

View 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;

View File

@@ -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'> &

View File

@@ -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
View 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 });
}
};

View 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];
}
}

View File

@@ -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
View 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
View 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;

View File

@@ -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,
}));

View File

@@ -18,7 +18,7 @@ export type UseParameterizedReplaceableEvent = {
query: CreateQueryResult<NostrEvent | null>;
};
export const useParameterizedReplaceableEvent = (
const useParameterizedReplaceableEvent = (
propsProvider: () => UseParameterizedReplaceableEventProps | null,
): UseParameterizedReplaceableEvent => {
const queryClient = useQueryClient();

View File

@@ -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[]>();

View File

@@ -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;