diff --git a/package-lock.json b/package-lock.json
index e445ddf..1628f80 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"@tanstack/query-persist-client-core": "^4.24.10",
"@tanstack/query-sync-storage-persister": "^4.24.10",
"@tanstack/solid-query": "^4.24.10",
+ "@tanstack/solid-virtual": "^3.0.0-beta.6",
"@thisbeyond/solid-dnd": "^0.7.3",
"@types/lodash": "^4.14.191",
"heroicons": "^2.0.15",
@@ -1166,6 +1167,11 @@
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==",
"dev": true
},
+ "node_modules/@reach/observe-rect": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz",
+ "integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ=="
+ },
"node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
@@ -1305,6 +1311,21 @@
"solid-js": "^1.5.7"
}
},
+ "node_modules/@tanstack/solid-virtual": {
+ "version": "3.0.0-beta.6",
+ "resolved": "https://registry.npmjs.org/@tanstack/solid-virtual/-/solid-virtual-3.0.0-beta.6.tgz",
+ "integrity": "sha512-/HjeHZb4UZxxFSAkICUEWOozGwHQpKlvtnUoS5uSMSuLOz0XM5vFq6zR6ENwAczKWDtkh8ntddk+zXAhyXOlEw==",
+ "dependencies": {
+ "@reach/observe-rect": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@thisbeyond/solid-dnd": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.3.tgz",
@@ -8051,6 +8072,11 @@
}
}
},
+ "@reach/observe-rect": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz",
+ "integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ=="
+ },
"@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
@@ -8138,6 +8164,14 @@
"@tanstack/query-core": "4.24.10"
}
},
+ "@tanstack/solid-virtual": {
+ "version": "3.0.0-beta.6",
+ "resolved": "https://registry.npmjs.org/@tanstack/solid-virtual/-/solid-virtual-3.0.0-beta.6.tgz",
+ "integrity": "sha512-/HjeHZb4UZxxFSAkICUEWOozGwHQpKlvtnUoS5uSMSuLOz0XM5vFq6zR6ENwAczKWDtkh8ntddk+zXAhyXOlEw==",
+ "requires": {
+ "@reach/observe-rect": "^1.1.0"
+ }
+ },
"@thisbeyond/solid-dnd": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.3.tgz",
diff --git a/package.json b/package.json
index cc913b8..9170c87 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
"@tanstack/query-persist-client-core": "^4.24.10",
"@tanstack/query-sync-storage-persister": "^4.24.10",
"@tanstack/solid-query": "^4.24.10",
+ "@tanstack/solid-virtual": "^3.0.0-beta.6",
"@thisbeyond/solid-dnd": "^0.7.3",
"@types/lodash": "^4.14.191",
"heroicons": "^2.0.15",
diff --git a/src/components/Config.tsx b/src/components/Config.tsx
index bdff71b..63653ff 100644
--- a/src/components/Config.tsx
+++ b/src/components/Config.tsx
@@ -3,6 +3,7 @@ import { createSignal, For, type JSX } from 'solid-js';
import XMark from 'heroicons/24/outline/x-mark.svg';
import Modal from '@/components/Modal';
+import UserNameDisplay from './UserDisplayName';
type ConfigProps = {
onClose: () => void;
@@ -21,7 +22,7 @@ const RelayConfig = () => {
};
return (
-
+
リレー
@@ -83,7 +84,7 @@ const DateFormatConfig = () => {
};
return (
-
+
時刻の表記
@@ -132,6 +133,68 @@ const ToggleButton = (props: {
);
};
+const MuteConfig = () => {
+ const { config, removeMutedPubkey, addMutedKeyword, removeMutedKeyword } = useConfig();
+
+ const [keywordInput, setKeywordInput] = createSignal('');
+
+ const handleClickAddKeyword: JSX.EventHandler = (ev) => {
+ ev.preventDefault();
+ if (keywordInput().length === 0) return;
+ addMutedKeyword(keywordInput());
+ setKeywordInput('');
+ };
+
+ return (
+ <>
+
+
ミュートしたユーザ
+
+
+ {(pubkey) => (
+ -
+
+
+
+
+
+ )}
+
+
+
+
+
ミュートした単語
+
+
+ {(keyword) => (
+ -
+
{keyword}
+
+
+ )}
+
+
+
+
+ >
+ );
+};
+
const OtherConfig = () => {
const { config, setConfig } = useConfig();
@@ -150,7 +213,7 @@ const OtherConfig = () => {
};
return (
-
+
その他
@@ -194,6 +257,7 @@ const ConfigUI = (props: ConfigProps) => {
+
);
diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx
index d0ca0bc..6469507 100644
--- a/src/components/ContextMenu.tsx
+++ b/src/components/ContextMenu.tsx
@@ -11,7 +11,9 @@ export type ContextMenuProps = {
};
export type MenuDisplayProps = {
+ menuRef: (elem: HTMLUListElement) => void;
menu: MenuItem[];
+ isOpen: boolean;
onClose: () => void;
};
@@ -37,7 +39,11 @@ const MenuItemDisplay: Component
= (props) => {
const MenuDisplay: Component = (props) => {
return (
-
+
{(item) => }
@@ -46,7 +52,7 @@ const MenuDisplay: Component = (props) => {
};
const ContextMenu: Component = (props) => {
- let menuRef: HTMLDivElement | undefined;
+ let menuRef: HTMLUListElement | undefined;
const [isOpen, setIsOpen] = createSignal(false);
@@ -66,8 +72,9 @@ const ContextMenu: Component = (props) => {
const handleClick: JSX.EventHandler = (ev) => {
if (menuRef == null) return;
- const buttonRect = ev.target.getBoundingClientRect();
- menuRef.style.left = `${buttonRect.left}px`;
+ const buttonRect = ev.currentTarget.getBoundingClientRect();
+ const menuRect = menuRef.getBoundingClientRect();
+ menuRef.style.left = `${buttonRect.left - buttonRect.width}px`;
menuRef.style.top = `${buttonRect.top + buttonRect.height}px`;
setIsOpen(true);
@@ -86,13 +93,14 @@ const ContextMenu: Component = (props) => {
return (
-
- setIsOpen(false)} />
-
+
{
+ menuRef = e;
+ }}
+ menu={props.menu}
+ isOpen={isOpen()}
+ onClose={() => setIsOpen(false)}
+ />
);
};
diff --git a/src/components/ProfileDisplay.tsx b/src/components/ProfileDisplay.tsx
index 29ee90d..3578447 100644
--- a/src/components/ProfileDisplay.tsx
+++ b/src/components/ProfileDisplay.tsx
@@ -1,10 +1,12 @@
import { Component, createSignal, createMemo, Show, Switch, Match, createEffect } from 'solid-js';
+import { createMutation } from '@tanstack/solid-query';
import GlobeAlt from 'heroicons/24/outline/globe-alt.svg';
import XMark from 'heroicons/24/outline/x-mark.svg';
import CheckCircle from 'heroicons/24/solid/check-circle.svg';
import ExclamationCircle from 'heroicons/24/solid/exclamation-circle.svg';
import ArrowPath from 'heroicons/24/outline/arrow-path.svg';
+import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
import Modal from '@/components/Modal';
import Timeline from '@/components/Timeline';
@@ -17,11 +19,14 @@ import useVerification from '@/nostr/useVerification';
import useFollowings from '@/nostr/useFollowings';
import useFollowers from '@/nostr/useFollowers';
import useConfig from '@/nostr/useConfig';
+import useCommands from '@/nostr/useCommands';
import useSubscription from '@/nostr/useSubscription';
import npubEncodeFallback from '@/utils/npubEncodeFallback';
import ensureNonNull from '@/utils/ensureNonNull';
import epoch from '@/utils/epoch';
+import timeout from '@/utils/timeout';
+import ContextMenu, { MenuItem } from './ContextMenu';
export type ProfileDisplayProps = {
pubkey: string;
@@ -37,8 +42,11 @@ const FollowersCount: Component<{ pubkey: string }> = (props) => {
};
const ProfileDisplay: Component = (props) => {
- const { config } = useConfig();
- const pubkey = usePubkey();
+ const { config, addMutedPubkey, removeMutedPubkey, isPubkeyMuted } = useConfig();
+ const commands = useCommands();
+ const myPubkey = usePubkey();
+
+ const npub = createMemo(() => npubEncodeFallback(props.pubkey));
const [hoverFollowButton, setHoverFollowButton] = createSignal(false);
const [showFollowers, setShowFollowers] = createSignal(false);
@@ -58,25 +66,103 @@ const ProfileDisplay: Component = (props) => {
return { user, domain, ident };
};
const isVerified = () => verification()?.pubkey === props.pubkey;
+ const isMuted = () => isPubkeyMuted(props.pubkey);
- const { followingPubkeys: myFollowingPubkeys } = useFollowings(() =>
- ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
+ const {
+ followingPubkeys: myFollowingPubkeys,
+ invalidateFollowings: invalidateMyFollowings,
+ query: myFollowingQuery,
+ } = useFollowings(() =>
+ ensureNonNull([myPubkey()] as const)(([pubkeyNonNull]) => ({
pubkey: pubkeyNonNull,
})),
);
const following = () => myFollowingPubkeys().includes(props.pubkey);
const { followingPubkeys: userFollowingPubkeys, query: userFollowingQuery } = useFollowings(
- () => ({
- pubkey: props.pubkey,
- }),
+ () => ({ pubkey: props.pubkey }),
);
+
const followed = () => {
- const p = pubkey();
+ const p = myPubkey();
return p != null && userFollowingPubkeys().includes(p);
};
- const npub = createMemo(() => npubEncodeFallback(props.pubkey));
+ const updateContactsMutation = createMutation({
+ mutationKey: ['updateContacts'],
+ mutationFn: (...params: Parameters) =>
+ commands
+ .updateContacts(...params)
+ .then((promises) => Promise.allSettled(promises.map(timeout(5000)))),
+ onSuccess: (results) => {
+ const succeeded = results.filter((res) => res.status === 'fulfilled').length;
+ const failed = results.length - succeeded;
+ if (succeeded === results.length) {
+ console.log('succeeded to update contacts');
+ } else if (succeeded > 0) {
+ console.log(
+ `succeeded to update contacts for ${succeeded} relays but failed for ${failed} relays`,
+ );
+ } else {
+ console.error('failed to update contacts');
+ }
+ },
+ onError: (err) => {
+ console.error('failed to update contacts: ', err);
+ },
+ onSettled: () => {
+ invalidateMyFollowings()
+ .then(() => myFollowingQuery.refetch())
+ .catch((err) => console.error('failed to refetch contacts', err));
+ },
+ });
+
+ const follow = () => {
+ const p = myPubkey();
+ if (p == null) return;
+ if (!myFollowingQuery.isFetched) return;
+
+ updateContactsMutation.mutate({
+ relayUrls: config().relayUrls,
+ pubkey: p,
+ content: myFollowingQuery.data?.content ?? '',
+ followingPubkeys: [...myFollowingPubkeys(), props.pubkey],
+ });
+ };
+
+ const unfollow = () => {
+ const p = myPubkey();
+ if (p == null) return;
+ if (!myFollowingQuery.isFetched) return;
+
+ if (!window.confirm('本当にフォロー解除しますか?')) return;
+
+ updateContactsMutation.mutate({
+ relayUrls: config().relayUrls,
+ pubkey: p,
+ content: myFollowingQuery.data?.content ?? '',
+ followingPubkeys: myFollowingPubkeys().filter((k) => k !== props.pubkey),
+ });
+ };
+
+ const menu: MenuItem[] = [
+ {
+ content: () => 'IDをコピー',
+ onSelect: () => {
+ navigator.clipboard.writeText(npub()).catch((err) => window.alert(err));
+ },
+ },
+ {
+ content: () => (!isMuted() ? 'ミュート' : 'ミュート解除'),
+ onSelect: () => {
+ if (!isMuted()) {
+ addMutedPubkey(props.pubkey);
+ } else {
+ removeMutedPubkey(props.pubkey);
+ }
+ },
+ },
+ ];
const { events } = useSubscription(() => ({
relayUrls: config().relayUrls,
@@ -113,100 +199,118 @@ const ProfileDisplay: Component = (props) => {
)}
-
-
- {(pictureUrl) => (
-
- )}
-
-
-
-
-
0}>
- {profile()?.display_name}
+
+
+
+ {(pictureUrl) => (
+
+ )}
-
-
0}>
- @{profile()?.name}
-
-
0}>
-
- {nip05Identifier()?.ident}
-
-
-
- }
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/*
-
- フォロー
-
- }
- >
-
-
+
+
+
+
+
+
+ 読み込み中
+
+
+
+ 更新中
+
+
+ {/*
+
+
+ あなたです
+
+
+ */}
+
+
+
- */}
-
- フォローされています
+
+
+
+
+
+ フォローされています
+
+
+
+
+
+
0}>
+ {profile()?.display_name}
+
+
+
0}>
+ @{profile()?.name}
+
0}>
+
+ {nip05Identifier()?.ident}
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0}>
-
+
{profile()?.about}
diff --git a/src/components/TextNote.tsx b/src/components/TextNote.tsx
index d676a0a..327be8a 100644
--- a/src/components/TextNote.tsx
+++ b/src/components/TextNote.tsx
@@ -1,15 +1,20 @@
-import { type Component } from 'solid-js';
+import { Show, type Component } from 'solid-js';
import ColumnItem from '@/components/ColumnItem';
+import useConfig from '@/nostr/useConfig';
import TextNoteDisplay, { TextNoteDisplayProps } from './textNote/TextNoteDisplay';
export type TextNoteProps = TextNoteDisplayProps;
const TextNote: Component
= (props) => {
+ const { shouldMuteEvent } = useConfig();
+
return (
-
-
-
+
+
+
+
+
);
};
diff --git a/src/components/textNote/TextNoteDisplay.tsx b/src/components/textNote/TextNoteDisplay.tsx
index 81397ea..242369b 100644
--- a/src/components/textNote/TextNoteDisplay.tsx
+++ b/src/components/textNote/TextNoteDisplay.tsx
@@ -54,7 +54,9 @@ const TextNoteDisplay: Component = (props) => {
{
content: () => 'JSONとしてコピー',
onSelect: () => {
- navigator.clipboard.writeText(JSON.stringify(props.event)).catch((err) => window.alert(err));
+ navigator.clipboard
+ .writeText(JSON.stringify(props.event))
+ .catch((err) => window.alert(err));
},
},
];
@@ -94,11 +96,13 @@ const TextNoteDisplay: Component = (props) => {
mutationFn: commands.publishReaction.bind(commands),
onSuccess: () => {
console.log('succeeded to publish reaction');
- invalidateReactions().catch((err) => console.error('failed to refetch reactions', err));
},
onError: (err) => {
console.error('failed to publish reaction: ', err);
},
+ onSettled: () => {
+ invalidateReactions().catch((err) => console.error('failed to refetch reactions', err));
+ },
});
const publishDeprecatedRepostMutation = createMutation({
@@ -106,11 +110,13 @@ const TextNoteDisplay: Component = (props) => {
mutationFn: commands.publishDeprecatedRepost.bind(commands),
onSuccess: () => {
console.log('succeeded to publish reposts');
- invalidateDeprecatedReposts().catch((err) => console.error('failed to refetch reposts', err));
},
onError: (err) => {
console.error('failed to publish repost: ', err);
},
+ onSettled: () => {
+ invalidateDeprecatedReposts().catch((err) => console.error('failed to refetch reposts', err));
+ },
});
const isReactedByMe = createMemo(() => {
diff --git a/src/components/textNote/TextNoteDisplayById.tsx b/src/components/textNote/TextNoteDisplayById.tsx
index 26b54ba..570f640 100644
--- a/src/components/textNote/TextNoteDisplayById.tsx
+++ b/src/components/textNote/TextNoteDisplayById.tsx
@@ -14,14 +14,21 @@ type TextNoteDisplayByIdProps = Omit & {
};
const TextNoteDisplayById: Component = (props) => {
+ const { shouldMuteEvent } = useConfig();
const { event, query: eventQuery } = useEvent(() =>
ensureNonNull([props.eventId] as const)(([eventIdNonNull]) => ({
eventId: eventIdNonNull,
})),
);
+ const hidden = (): boolean => {
+ const ev = event();
+ return ev != null && shouldMuteEvent(ev);
+ };
+
return (
+ {null}
{(ev) => }
diff --git a/src/core/column.ts b/src/core/column.ts
index 619e775..d02b6e3 100644
--- a/src/core/column.ts
+++ b/src/core/column.ts
@@ -1,5 +1,5 @@
// import { z } from 'zod';
-import { type Event as NostrEvent, type Filter } from 'nostr-tools';
+import { type Filter } from 'nostr-tools';
import { type ColumnProps } from '@/components/Column';
export type NotificationType =
@@ -43,42 +43,42 @@ type BulidOptions = {
export type BaseColumn = {
title: string;
- columnWidth: ColumnProps['width'];
+ width: ColumnProps['width'];
};
/** A column which shows posts by following users */
-export type FollowingColumn = {
+export type FollowingColumn = BaseColumn & {
columnType: 'Following';
pubkey: string;
};
/** A column which shows replies, reactions, reposts to the specific user */
-export type NotificationColumn = {
+export type NotificationColumn = BaseColumn & {
columnType: 'Notification';
- notificationTypes: NotificationType[];
+ // notificationTypes: NotificationType[];
pubkey: string;
};
/** A column which shows posts from the specific user */
-export type PostsColumn = {
+export type PostsColumn = BaseColumn & {
columnType: 'Posts';
pubkey: string;
};
/** A column which shows reactions published by the specific user */
-export type ReactionsColumn = {
+export type ReactionsColumn = BaseColumn & {
columnType: 'Reactions';
pubkey: string;
};
/** A column which shows text notes and reposts posted to the specific relays */
-export type GlobalColumn = {
+export type GlobalColumn = BaseColumn & {
columnType: 'Global';
relayUrls: string[];
};
/** A column which shows text notes and reposts posted to the specific relays */
-export type CustomFilterColumn = {
+export type CustomFilterColumn = BaseColumn & {
columnType: 'CustomFilter';
filters: Filter[];
};
diff --git a/src/nostr/useBatchedEvents.ts b/src/nostr/useBatchedEvents.ts
index 987aeaf..a1fd62d 100644
--- a/src/nostr/useBatchedEvents.ts
+++ b/src/nostr/useBatchedEvents.ts
@@ -109,6 +109,7 @@ type Following = {
export type UseFollowings = {
followings: () => Following[];
followingPubkeys: () => string[];
+ invalidateFollowings: () => Promise;
query: CreateQueryResult;
};
@@ -205,7 +206,7 @@ const { exec } = useBatch(() => ({
});
};
- const { config } = useConfig();
+ const { config, shouldMuteEvent } = useConfig();
const pool = usePool();
const sub = pool().sub(config().relayUrls, filters, {});
@@ -213,12 +214,15 @@ const { exec } = useBatch(() => ({
count += 1;
sub.on('event', (event: NostrEvent & { id: string }) => {
- if (config().mutedPubkeys.includes(event.id)) return;
if (event.kind === Kind.Metadata) {
const registeredTasks = profileTasks.get(event.pubkey) ?? [];
resolveTasks(registeredTasks, event);
- } else if (event.kind === Kind.Text) {
- if (config().mutedPubkeys.includes(event.pubkey)) return;
+ return;
+ }
+
+ if (shouldMuteEvent(event)) return;
+
+ if (event.kind === Kind.Text) {
const registeredTasks = textNoteTasks.get(event.id) ?? [];
resolveTasks(registeredTasks, event);
} else if (event.kind === Kind.Reaction) {
@@ -229,7 +233,6 @@ const { exec } = useBatch(() => ({
resolveTasks(registeredTasks, event);
});
} else if ((event.kind as number) === 6) {
- if (config().mutedPubkeys.includes(event.pubkey)) return;
const eventTags = eventWrapper(event).taggedEvents();
eventTags.forEach((eventTag) => {
const taggedEventId = eventTag.id;
@@ -494,5 +497,7 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U
const followingPubkeys = (): string[] => followings().map((follow) => follow.pubkey);
- return { followings, followingPubkeys, query };
+ const invalidateFollowings = (): Promise => queryClient.invalidateQueries(genQueryKey());
+
+ return { followings, followingPubkeys, invalidateFollowings, query };
};
diff --git a/src/nostr/useCommands.ts b/src/nostr/useCommands.ts
index d8e676f..7deb553 100644
--- a/src/nostr/useCommands.ts
+++ b/src/nostr/useCommands.ts
@@ -148,7 +148,7 @@ const useCommands = () => {
return publishEvent(relayUrls, preSignedEvent);
},
// NIP-18
- publishDeprecatedRepost({
+ async publishDeprecatedRepost({
relayUrls,
pubkey,
eventId,
@@ -171,8 +171,6 @@ const useCommands = () => {
};
return publishEvent(relayUrls, preSignedEvent);
},
- // useFollowingsのisFetchedが呼ばれたとしても全てのリレーから取得できたとは限らない
- // 半数以上、あるいは5秒待ってみて応答があればそれを利用するみたいな仕組みが必要か?
updateContacts({
relayUrls,
pubkey,
@@ -194,7 +192,6 @@ const useCommands = () => {
content,
};
return publishEvent(relayUrls, preSignedEvent);
- // TODO publishできたら、invalidateをしないといけない
},
};
};
diff --git a/src/nostr/useConfig.ts b/src/nostr/useConfig.ts
index 9a52feb..429cfdb 100644
--- a/src/nostr/useConfig.ts
+++ b/src/nostr/useConfig.ts
@@ -1,15 +1,19 @@
import { type Accessor, type Setter } from 'solid-js';
+import { Kind, type Event as NostrEvent } from 'nostr-tools';
import {
createStorageWithSerializer,
createStoreWithStorage,
} from '@/hooks/createSignalWithStorage';
+import { ColumnConfig } from '@/core/column';
export type Config = {
relayUrls: string[];
+ columns: ColumnConfig[];
dateFormat: 'relative' | 'absolute-long' | 'absolute-short';
keepOpenPostForm: boolean;
showImage: boolean;
mutedPubkeys: string[];
+ mutedKeywords: string[];
};
type UseConfig = {
@@ -19,31 +23,46 @@ type UseConfig = {
removeRelay: (url: string) => void;
addMutedPubkey: (pubkey: string) => void;
removeMutedPubkey: (pubkey: string) => void;
+ addMutedKeyword: (keyword: string) => void;
+ removeMutedKeyword: (keyword: string) => void;
+ isPubkeyMuted: (pubkey: string) => boolean;
+ shouldMuteEvent: (event: NostrEvent) => boolean;
+ initializeColumns: (param: { pubkey: string }) => void;
};
+const relaysGlobal = [
+ 'wss://relay.damus.io',
+ 'wss://nos.lol',
+ 'wss://relay.snort.social',
+ 'wss://relay.current.fyi',
+];
+
+const relaysOnlyAvailableInJP = [
+ 'wss://relay-jp.nostr.wirednet.jp',
+ 'wss://nostr.h3z.jp',
+ 'wss://nostr.holybea.com',
+];
+
+const relaysInJP = [
+ ...relaysOnlyAvailableInJP,
+ 'wss://nostr.holybea.com',
+ 'wss://nostr-relay.nokotaro.com',
+];
+
const InitialConfig = (): Config => {
- const relayUrls = [
- 'wss://relay.damus.io',
- 'wss://nos.lol',
- 'wss://relay.snort.social',
- 'wss://relay.current.fyi',
- ];
+ const relayUrls = [...relaysGlobal];
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',
- );
+ relayUrls.push(...relaysInJP);
}
return {
relayUrls,
+ columns: [],
dateFormat: 'relative',
keepOpenPostForm: false,
showImage: true,
mutedPubkeys: [],
+ mutedKeywords: [],
};
};
@@ -75,6 +94,40 @@ const useConfig = (): UseConfig => {
setConfig('mutedPubkeys', (current) => current.filter((e) => e !== pubkey));
};
+ const addMutedKeyword = (keyword: string) => {
+ setConfig('mutedKeywords', (current) => [...current, keyword]);
+ };
+
+ const removeMutedKeyword = (keyword: string) => {
+ setConfig('mutedKeywords', (current) => current.filter((e) => e !== keyword));
+ };
+
+ const isPubkeyMuted = (pubkey: string) => config.mutedPubkeys.includes(pubkey);
+
+ const hasMutedKeyword = (event: NostrEvent) => {
+ if (event.kind === Kind.Text) {
+ return config.mutedKeywords.some((keyword) => event.content.includes(keyword));
+ }
+ return false;
+ };
+
+ const shouldMuteEvent = (event: NostrEvent) => isPubkeyMuted(event.pubkey) || hasMutedKeyword(event);
+
+ const initializeColumns = ({ pubkey }: { pubkey: string }) => {
+ // すでに設定されている場合は終了
+ if ((config.columns?.length ?? 0) > 0) return;
+
+ const myColumns: ColumnConfig[] = [
+ { columnType: 'Following', title: 'ホーム', width: 'widest', pubkey },
+ { columnType: 'Notification', title: '通知', width: 'medium', pubkey },
+ { columnType: 'Posts', title: '自分の投稿', width: 'medium', pubkey },
+ { columnType: 'Reactions', title: '自分のリアクション', width: 'medium', pubkey },
+ // { columnType: 'Global', relays: [] },
+ ];
+
+ setConfig('columns', () => [...myColumns]);
+ };
+
return {
config: () => config,
setConfig,
@@ -82,6 +135,11 @@ const useConfig = (): UseConfig => {
removeRelay,
addMutedPubkey,
removeMutedPubkey,
+ addMutedKeyword,
+ removeMutedKeyword,
+ isPubkeyMuted,
+ shouldMuteEvent,
+ initializeColumns,
};
};
diff --git a/src/nostr/useSubscription.ts b/src/nostr/useSubscription.ts
index d46a541..fa03443 100644
--- a/src/nostr/useSubscription.ts
+++ b/src/nostr/useSubscription.ts
@@ -1,4 +1,4 @@
-import { createSignal, createEffect, onCleanup } from 'solid-js';
+import { createSignal, createEffect, onCleanup, on } from 'solid-js';
import type { Event as NostrEvent, Filter, SubscriptionOptions } from 'nostr-tools';
import uniqBy from 'lodash/uniqBy';
import usePool from '@/nostr/usePool';
@@ -35,10 +35,20 @@ setInterval(() => {
}, 1000);
const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
- const { config } = useConfig();
+ const { config, shouldMuteEvent } = useConfig();
const pool = usePool();
const [events, setEvents] = createSignal([]);
+ createEffect(
+ on(
+ () => [config().mutedPubkeys, config().mutedKeywords],
+ () => {
+ setEvents((currentEvents) => currentEvents.filter((event) => !shouldMuteEvent(event)));
+ },
+ { defer: true },
+ ),
+ );
+
const startSubscription = () => {
const props = propsProvider();
if (props == null) return;
@@ -58,7 +68,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
if (onEvent != null) {
onEvent(event as NostrEvent & { id: string });
}
- if (config().mutedPubkeys.includes(event.pubkey)) {
+ if (shouldMuteEvent(event)) {
return;
}
if (props.clientEventFilter != null && !props.clientEventFilter(event)) {
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 3c74909..b4c821a 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -9,6 +9,7 @@ import {
type Component,
} from 'solid-js';
import { useNavigate } from '@solidjs/router';
+import { createVirtualizer } from '@tanstack/solid-virtual';
import uniq from 'lodash/uniq';
import Column from '@/components/Column';
@@ -125,20 +126,6 @@ const Home: Component = () => {
],
}));
- /*
- const { events: searchPosts } = useSubscription(() => ({
- relayUrls: ['wss://relay.nostr.band/'],
- filters: [
- {
- kinds: [1],
- search: '#nostrstudy',
- limit: 25,
- since: epoch() - 12 * 60 * 60,
- },
- ],
- }));
- */
-
onMount(() => {
if (!persistStatus().loggedIn) {
navigate('/hello');