feat: follow and mute

This commit is contained in:
Shusui MOYATANI
2023-04-21 23:42:41 +09:00
parent 748e12df7b
commit 02d9969945
14 changed files with 440 additions and 154 deletions

34
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@tanstack/query-persist-client-core": "^4.24.10", "@tanstack/query-persist-client-core": "^4.24.10",
"@tanstack/query-sync-storage-persister": "^4.24.10", "@tanstack/query-sync-storage-persister": "^4.24.10",
"@tanstack/solid-query": "^4.24.10", "@tanstack/solid-query": "^4.24.10",
"@tanstack/solid-virtual": "^3.0.0-beta.6",
"@thisbeyond/solid-dnd": "^0.7.3", "@thisbeyond/solid-dnd": "^0.7.3",
"@types/lodash": "^4.14.191", "@types/lodash": "^4.14.191",
"heroicons": "^2.0.15", "heroicons": "^2.0.15",
@@ -1166,6 +1167,11 @@
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==", "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==",
"dev": true "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": { "node_modules/@scure/base": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
@@ -1305,6 +1311,21 @@
"solid-js": "^1.5.7" "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": { "node_modules/@thisbeyond/solid-dnd": {
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.3.tgz", "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": { "@scure/base": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
@@ -8138,6 +8164,14 @@
"@tanstack/query-core": "4.24.10" "@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": { "@thisbeyond/solid-dnd": {
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.3.tgz", "resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.3.tgz",

View File

@@ -50,6 +50,7 @@
"@tanstack/query-persist-client-core": "^4.24.10", "@tanstack/query-persist-client-core": "^4.24.10",
"@tanstack/query-sync-storage-persister": "^4.24.10", "@tanstack/query-sync-storage-persister": "^4.24.10",
"@tanstack/solid-query": "^4.24.10", "@tanstack/solid-query": "^4.24.10",
"@tanstack/solid-virtual": "^3.0.0-beta.6",
"@thisbeyond/solid-dnd": "^0.7.3", "@thisbeyond/solid-dnd": "^0.7.3",
"@types/lodash": "^4.14.191", "@types/lodash": "^4.14.191",
"heroicons": "^2.0.15", "heroicons": "^2.0.15",

View File

@@ -3,6 +3,7 @@ import { createSignal, For, type JSX } from 'solid-js';
import XMark from 'heroicons/24/outline/x-mark.svg'; import XMark from 'heroicons/24/outline/x-mark.svg';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import UserNameDisplay from './UserDisplayName';
type ConfigProps = { type ConfigProps = {
onClose: () => void; onClose: () => void;
@@ -21,7 +22,7 @@ const RelayConfig = () => {
}; };
return ( return (
<div> <div class="py-2">
<h3 class="font-bold"></h3> <h3 class="font-bold"></h3>
<ul> <ul>
<For each={config().relayUrls}> <For each={config().relayUrls}>
@@ -83,7 +84,7 @@ const DateFormatConfig = () => {
}; };
return ( return (
<div> <div class="py-2">
<h3 class="font-bold"></h3> <h3 class="font-bold"></h3>
<div class="flex flex-col justify-evenly gap-2 sm:flex-row"> <div class="flex flex-col justify-evenly gap-2 sm:flex-row">
<For each={dateFormats}> <For each={dateFormats}>
@@ -132,6 +133,68 @@ const ToggleButton = (props: {
); );
}; };
const MuteConfig = () => {
const { config, removeMutedPubkey, addMutedKeyword, removeMutedKeyword } = useConfig();
const [keywordInput, setKeywordInput] = createSignal('');
const handleClickAddKeyword: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
ev.preventDefault();
if (keywordInput().length === 0) return;
addMutedKeyword(keywordInput());
setKeywordInput('');
};
return (
<>
<div class="py-2">
<h3 class="font-bold"></h3>
<ul class="flex flex-col">
<For each={config().mutedPubkeys}>
{(pubkey) => (
<li class="flex items-center">
<div class="flex-1 truncate">
<UserNameDisplay pubkey={pubkey} />
</div>
<button class="h-3 w-3 shrink-0" onClick={() => removeMutedPubkey(pubkey)}>
<XMark />
</button>
</li>
)}
</For>
</ul>
</div>
<div class="py-2">
<h3 class="font-bold"></h3>
<ul class="flex flex-col">
<For each={config().mutedKeywords}>
{(keyword) => (
<li class="flex items-center">
<div class="flex-1 truncate">{keyword}</div>
<button class="h-3 w-3 shrink-0" onClick={() => removeMutedKeyword(keyword)}>
<XMark />
</button>
</li>
)}
</For>
</ul>
<form class="flex gap-2" onSubmit={handleClickAddKeyword}>
<input
class="flex-1"
type="text"
name="keyword"
value={keywordInput()}
onChange={(ev) => setKeywordInput(ev.currentTarget.value)}
/>
<button type="submit" class="rounded bg-rose-300 p-2 font-bold text-white">
</button>
</form>
</div>
</>
);
};
const OtherConfig = () => { const OtherConfig = () => {
const { config, setConfig } = useConfig(); const { config, setConfig } = useConfig();
@@ -150,7 +213,7 @@ const OtherConfig = () => {
}; };
return ( return (
<div> <div class="py-2">
<h3 class="font-bold"></h3> <h3 class="font-bold"></h3>
<div class="flex flex-col justify-evenly gap-2"> <div class="flex flex-col justify-evenly gap-2">
<div class="flex w-full"> <div class="flex w-full">
@@ -194,6 +257,7 @@ const ConfigUI = (props: ConfigProps) => {
<RelayConfig /> <RelayConfig />
<DateFormatConfig /> <DateFormatConfig />
<OtherConfig /> <OtherConfig />
<MuteConfig />
</div> </div>
</Modal> </Modal>
); );

View File

@@ -11,7 +11,9 @@ export type ContextMenuProps = {
}; };
export type MenuDisplayProps = { export type MenuDisplayProps = {
menuRef: (elem: HTMLUListElement) => void;
menu: MenuItem[]; menu: MenuItem[];
isOpen: boolean;
onClose: () => void; onClose: () => void;
}; };
@@ -37,7 +39,11 @@ const MenuItemDisplay: Component<MenuItemDisplayProps> = (props) => {
const MenuDisplay: Component<MenuDisplayProps> = (props) => { const MenuDisplay: Component<MenuDisplayProps> = (props) => {
return ( return (
<ul> <ul
ref={props.menuRef}
class="absolute z-20 min-w-[48px] rounded border bg-white shadow-md"
classList={{ hidden: !props.isOpen, block: props.isOpen }}
>
<For each={props.menu}> <For each={props.menu}>
{(item) => <MenuItemDisplay item={item} onClose={props.onClose} />} {(item) => <MenuItemDisplay item={item} onClose={props.onClose} />}
</For> </For>
@@ -46,7 +52,7 @@ const MenuDisplay: Component<MenuDisplayProps> = (props) => {
}; };
const ContextMenu: Component<ContextMenuProps> = (props) => { const ContextMenu: Component<ContextMenuProps> = (props) => {
let menuRef: HTMLDivElement | undefined; let menuRef: HTMLUListElement | undefined;
const [isOpen, setIsOpen] = createSignal(false); const [isOpen, setIsOpen] = createSignal(false);
@@ -66,8 +72,9 @@ const ContextMenu: Component<ContextMenuProps> = (props) => {
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => { const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
if (menuRef == null) return; if (menuRef == null) return;
const buttonRect = ev.target.getBoundingClientRect(); const buttonRect = ev.currentTarget.getBoundingClientRect();
menuRef.style.left = `${buttonRect.left}px`; const menuRect = menuRef.getBoundingClientRect();
menuRef.style.left = `${buttonRect.left - buttonRect.width}px`;
menuRef.style.top = `${buttonRect.top + buttonRect.height}px`; menuRef.style.top = `${buttonRect.top + buttonRect.height}px`;
setIsOpen(true); setIsOpen(true);
@@ -86,13 +93,14 @@ const ContextMenu: Component<ContextMenuProps> = (props) => {
return ( return (
<div> <div>
<button onClick={handleClick}>{props.children}</button> <button onClick={handleClick}>{props.children}</button>
<div <MenuDisplay
ref={menuRef} menuRef={(e) => {
class="absolute z-20 min-w-[48px] rounded border bg-white shadow-md" menuRef = e;
classList={{ hidden: !isOpen(), block: isOpen() }} }}
> menu={props.menu}
<MenuDisplay menu={props.menu} onClose={() => setIsOpen(false)} /> isOpen={isOpen()}
</div> onClose={() => setIsOpen(false)}
/>
</div> </div>
); );
}; };

View File

@@ -1,10 +1,12 @@
import { Component, createSignal, createMemo, Show, Switch, Match, createEffect } from 'solid-js'; 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 GlobeAlt from 'heroicons/24/outline/globe-alt.svg';
import XMark from 'heroicons/24/outline/x-mark.svg'; import XMark from 'heroicons/24/outline/x-mark.svg';
import CheckCircle from 'heroicons/24/solid/check-circle.svg'; import CheckCircle from 'heroicons/24/solid/check-circle.svg';
import ExclamationCircle from 'heroicons/24/solid/exclamation-circle.svg'; import ExclamationCircle from 'heroicons/24/solid/exclamation-circle.svg';
import ArrowPath from 'heroicons/24/outline/arrow-path.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 Modal from '@/components/Modal';
import Timeline from '@/components/Timeline'; import Timeline from '@/components/Timeline';
@@ -17,11 +19,14 @@ import useVerification from '@/nostr/useVerification';
import useFollowings from '@/nostr/useFollowings'; import useFollowings from '@/nostr/useFollowings';
import useFollowers from '@/nostr/useFollowers'; import useFollowers from '@/nostr/useFollowers';
import useConfig from '@/nostr/useConfig'; import useConfig from '@/nostr/useConfig';
import useCommands from '@/nostr/useCommands';
import useSubscription from '@/nostr/useSubscription'; import useSubscription from '@/nostr/useSubscription';
import npubEncodeFallback from '@/utils/npubEncodeFallback'; import npubEncodeFallback from '@/utils/npubEncodeFallback';
import ensureNonNull from '@/utils/ensureNonNull'; import ensureNonNull from '@/utils/ensureNonNull';
import epoch from '@/utils/epoch'; import epoch from '@/utils/epoch';
import timeout from '@/utils/timeout';
import ContextMenu, { MenuItem } from './ContextMenu';
export type ProfileDisplayProps = { export type ProfileDisplayProps = {
pubkey: string; pubkey: string;
@@ -37,8 +42,11 @@ const FollowersCount: Component<{ pubkey: string }> = (props) => {
}; };
const ProfileDisplay: Component<ProfileDisplayProps> = (props) => { const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
const { config } = useConfig(); const { config, addMutedPubkey, removeMutedPubkey, isPubkeyMuted } = useConfig();
const pubkey = usePubkey(); const commands = useCommands();
const myPubkey = usePubkey();
const npub = createMemo(() => npubEncodeFallback(props.pubkey));
const [hoverFollowButton, setHoverFollowButton] = createSignal(false); const [hoverFollowButton, setHoverFollowButton] = createSignal(false);
const [showFollowers, setShowFollowers] = createSignal(false); const [showFollowers, setShowFollowers] = createSignal(false);
@@ -58,25 +66,103 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
return { user, domain, ident }; return { user, domain, ident };
}; };
const isVerified = () => verification()?.pubkey === props.pubkey; const isVerified = () => verification()?.pubkey === props.pubkey;
const isMuted = () => isPubkeyMuted(props.pubkey);
const { followingPubkeys: myFollowingPubkeys } = useFollowings(() => const {
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({ followingPubkeys: myFollowingPubkeys,
invalidateFollowings: invalidateMyFollowings,
query: myFollowingQuery,
} = useFollowings(() =>
ensureNonNull([myPubkey()] as const)(([pubkeyNonNull]) => ({
pubkey: pubkeyNonNull, pubkey: pubkeyNonNull,
})), })),
); );
const following = () => myFollowingPubkeys().includes(props.pubkey); const following = () => myFollowingPubkeys().includes(props.pubkey);
const { followingPubkeys: userFollowingPubkeys, query: userFollowingQuery } = useFollowings( const { followingPubkeys: userFollowingPubkeys, query: userFollowingQuery } = useFollowings(
() => ({ () => ({ pubkey: props.pubkey }),
pubkey: props.pubkey,
}),
); );
const followed = () => { const followed = () => {
const p = pubkey(); const p = myPubkey();
return p != null && userFollowingPubkeys().includes(p); return p != null && userFollowingPubkeys().includes(p);
}; };
const npub = createMemo(() => npubEncodeFallback(props.pubkey)); const updateContactsMutation = createMutation({
mutationKey: ['updateContacts'],
mutationFn: (...params: Parameters<typeof commands.updateContacts>) =>
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(() => ({ const { events } = useSubscription(() => ({
relayUrls: config().relayUrls, relayUrls: config().relayUrls,
@@ -113,7 +199,8 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
)} )}
</Show> </Show>
<div class="mt-[-54px] flex items-end gap-4 px-4 pt-4"> <div class="mt-[-54px] flex items-end gap-4 px-4 pt-4">
<div class="h-28 w-28 shrink-0 rounded-lg shadow-md"> <div class="flex-1 shrink-0">
<div class="h-28 w-28 rounded-lg shadow-md">
<Show when={profile()?.picture} keyed> <Show when={profile()?.picture} keyed>
{(pictureUrl) => ( {(pictureUrl) => (
<img <img
@@ -124,7 +211,67 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
)} )}
</Show> </Show>
</div> </div>
<div class="flex items-start overflow-hidden"> </div>
<div class="flex shrink-0 flex-col items-center gap-1">
<div class="flex flex-row justify-start gap-1">
<Switch>
<Match when={myFollowingQuery.isLoading || myFollowingQuery.isFetching}>
<span class="rounded-full border border-primary px-4 py-2 text-primary sm:text-base">
</span>
</Match>
<Match when={updateContactsMutation.isLoading}>
<span class="rounded-full border border-primary px-4 py-2 text-primary sm:text-base">
</span>
</Match>
{/*
<Match when={props.pubkey === myPubkey()}>
<span class="rounded-full border border-primary px-4 py-2 text-primary">
あなたです
</span>
</Match>
*/}
<Match when={following()}>
<button
class="rounded-full border border-primary bg-primary px-4 py-2
text-center font-bold text-white hover:bg-rose-500 sm:w-32"
onMouseEnter={() => setHoverFollowButton(true)}
onMouseLeave={() => setHoverFollowButton(false)}
onClick={() => unfollow()}
disabled={updateContactsMutation.isLoading}
>
<Show when={!hoverFollowButton()} fallback="フォロー解除">
</Show>
</button>
</Match>
<Match when={!following()}>
<button
class="w-24 rounded-full border border-primary px-4 py-2 text-primary
hover:border-rose-400 hover:text-rose-400"
onClick={() => follow()}
disabled={updateContactsMutation.isLoading}
>
</button>
</Match>
</Switch>
<ContextMenu menu={menu}>
<button
class="w-10 rounded-full border border-primary p-2 text-primary
hover:border-rose-400 hover:text-rose-400"
>
<EllipsisHorizontal />
</button>
</ContextMenu>
</div>
<Show when={followed()}>
<div class="shrink-0 text-xs"></div>
</Show>
</div>
</div>
<div class="flex items-start px-4 pt-2">
<div class="h-16 shrink overflow-hidden"> <div class="h-16 shrink overflow-hidden">
<Show when={(profile()?.display_name?.length ?? 0) > 0}> <Show when={(profile()?.display_name?.length ?? 0) > 0}>
<div class="truncate text-xl font-bold">{profile()?.display_name}</div> <div class="truncate text-xl font-bold">{profile()?.display_name}</div>
@@ -159,54 +306,11 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
</div> </div>
<div class="flex gap-1"> <div class="flex gap-1">
<div class="truncate text-xs">{npub()}</div> <div class="truncate text-xs">{npub()}</div>
<Copy
class="h-4 w-4 shrink-0 text-stone-500 hover:text-stone-700"
text={npub()}
/>
</div>
</div>
<div class="flex shrink-0 flex-col items-center justify-center gap-1">
{/*
<Switch
fallback={
<button
class="w-24 rounded-full border border-primary px-4 py-2 text-primary
hover:border-rose-400 hover:text-rose-400"
>
フォロー
</button>
}
>
<Match when={props.pubkey === pubkey()}>
<button
class="w-20 rounded-full border border-primary px-4 py-2 text-primary
hover:border-rose-400 hover:text-rose-400"
>
編集
</button>
</Match>
<Match when={following()}>
<button
class="w-32 rounded-full border border-primary bg-primary px-4 py-2
text-center font-bold text-white hover:bg-rose-500"
onMouseEnter={() => setHoverFollowButton(true)}
onMouseLeave={() => setHoverFollowButton(false)}
>
<Show when={!hoverFollowButton()} fallback="フォロー解除">
フォロー中
</Show>
</button>
</Match>
</Switch>
*/}
<Show when={followed()}>
<div class="shrink-0 text-xs"></div>
</Show>
</div> </div>
</div> </div>
</div> </div>
<Show when={(profile()?.about ?? '').length > 0}> <Show when={(profile()?.about ?? '').length > 0}>
<div class="max-h-40 shrink-0 overflow-y-auto whitespace-pre-wrap px-5 py-3 text-sm"> <div class="max-h-40 shrink-0 overflow-y-auto whitespace-pre-wrap px-4 py-2 text-sm">
{profile()?.about} {profile()?.about}
</div> </div>
</Show> </Show>

View File

@@ -1,15 +1,20 @@
import { type Component } from 'solid-js'; import { Show, type Component } from 'solid-js';
import ColumnItem from '@/components/ColumnItem'; import ColumnItem from '@/components/ColumnItem';
import useConfig from '@/nostr/useConfig';
import TextNoteDisplay, { TextNoteDisplayProps } from './textNote/TextNoteDisplay'; import TextNoteDisplay, { TextNoteDisplayProps } from './textNote/TextNoteDisplay';
export type TextNoteProps = TextNoteDisplayProps; export type TextNoteProps = TextNoteDisplayProps;
const TextNote: Component<TextNoteProps> = (props) => { const TextNote: Component<TextNoteProps> = (props) => {
const { shouldMuteEvent } = useConfig();
return ( return (
<Show when={!shouldMuteEvent(props.event)}>
<ColumnItem> <ColumnItem>
<TextNoteDisplay {...props} /> <TextNoteDisplay {...props} />
</ColumnItem> </ColumnItem>
</Show>
); );
}; };

View File

@@ -54,7 +54,9 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
{ {
content: () => 'JSONとしてコピー', content: () => 'JSONとしてコピー',
onSelect: () => { 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<TextNoteDisplayProps> = (props) => {
mutationFn: commands.publishReaction.bind(commands), mutationFn: commands.publishReaction.bind(commands),
onSuccess: () => { onSuccess: () => {
console.log('succeeded to publish reaction'); console.log('succeeded to publish reaction');
invalidateReactions().catch((err) => console.error('failed to refetch reactions', err));
}, },
onError: (err) => { onError: (err) => {
console.error('failed to publish reaction: ', err); console.error('failed to publish reaction: ', err);
}, },
onSettled: () => {
invalidateReactions().catch((err) => console.error('failed to refetch reactions', err));
},
}); });
const publishDeprecatedRepostMutation = createMutation({ const publishDeprecatedRepostMutation = createMutation({
@@ -106,11 +110,13 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
mutationFn: commands.publishDeprecatedRepost.bind(commands), mutationFn: commands.publishDeprecatedRepost.bind(commands),
onSuccess: () => { onSuccess: () => {
console.log('succeeded to publish reposts'); console.log('succeeded to publish reposts');
invalidateDeprecatedReposts().catch((err) => console.error('failed to refetch reposts', err));
}, },
onError: (err) => { onError: (err) => {
console.error('failed to publish repost: ', err); console.error('failed to publish repost: ', err);
}, },
onSettled: () => {
invalidateDeprecatedReposts().catch((err) => console.error('failed to refetch reposts', err));
},
}); });
const isReactedByMe = createMemo(() => { const isReactedByMe = createMemo(() => {

View File

@@ -14,14 +14,21 @@ type TextNoteDisplayByIdProps = Omit<TextNoteDisplayProps, 'event'> & {
}; };
const TextNoteDisplayById: Component<TextNoteDisplayByIdProps> = (props) => { const TextNoteDisplayById: Component<TextNoteDisplayByIdProps> = (props) => {
const { shouldMuteEvent } = useConfig();
const { event, query: eventQuery } = useEvent(() => const { event, query: eventQuery } = useEvent(() =>
ensureNonNull([props.eventId] as const)(([eventIdNonNull]) => ({ ensureNonNull([props.eventId] as const)(([eventIdNonNull]) => ({
eventId: eventIdNonNull, eventId: eventIdNonNull,
})), })),
); );
const hidden = (): boolean => {
const ev = event();
return ev != null && shouldMuteEvent(ev);
};
return ( return (
<Switch fallback="投稿が見つかりません"> <Switch fallback="投稿が見つかりません">
<Match when={hidden()}>{null}</Match>
<Match when={event()} keyed> <Match when={event()} keyed>
{(ev) => <TextNoteDisplay event={ev} {...props} />} {(ev) => <TextNoteDisplay event={ev} {...props} />}
</Match> </Match>

View File

@@ -1,5 +1,5 @@
// import { z } from 'zod'; // 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'; import { type ColumnProps } from '@/components/Column';
export type NotificationType = export type NotificationType =
@@ -43,42 +43,42 @@ type BulidOptions = {
export type BaseColumn = { export type BaseColumn = {
title: string; title: string;
columnWidth: ColumnProps['width']; width: ColumnProps['width'];
}; };
/** A column which shows posts by following users */ /** A column which shows posts by following users */
export type FollowingColumn = { export type FollowingColumn = BaseColumn & {
columnType: 'Following'; columnType: 'Following';
pubkey: string; pubkey: string;
}; };
/** A column which shows replies, reactions, reposts to the specific user */ /** A column which shows replies, reactions, reposts to the specific user */
export type NotificationColumn = { export type NotificationColumn = BaseColumn & {
columnType: 'Notification'; columnType: 'Notification';
notificationTypes: NotificationType[]; // notificationTypes: NotificationType[];
pubkey: string; pubkey: string;
}; };
/** A column which shows posts from the specific user */ /** A column which shows posts from the specific user */
export type PostsColumn = { export type PostsColumn = BaseColumn & {
columnType: 'Posts'; columnType: 'Posts';
pubkey: string; pubkey: string;
}; };
/** A column which shows reactions published by the specific user */ /** A column which shows reactions published by the specific user */
export type ReactionsColumn = { export type ReactionsColumn = BaseColumn & {
columnType: 'Reactions'; columnType: 'Reactions';
pubkey: string; pubkey: string;
}; };
/** A column which shows text notes and reposts posted to the specific relays */ /** A column which shows text notes and reposts posted to the specific relays */
export type GlobalColumn = { export type GlobalColumn = BaseColumn & {
columnType: 'Global'; columnType: 'Global';
relayUrls: string[]; relayUrls: string[];
}; };
/** A column which shows text notes and reposts posted to the specific relays */ /** A column which shows text notes and reposts posted to the specific relays */
export type CustomFilterColumn = { export type CustomFilterColumn = BaseColumn & {
columnType: 'CustomFilter'; columnType: 'CustomFilter';
filters: Filter[]; filters: Filter[];
}; };

View File

@@ -109,6 +109,7 @@ type Following = {
export type UseFollowings = { export type UseFollowings = {
followings: () => Following[]; followings: () => Following[];
followingPubkeys: () => string[]; followingPubkeys: () => string[];
invalidateFollowings: () => Promise<void>;
query: CreateQueryResult<NostrEvent | null>; query: CreateQueryResult<NostrEvent | null>;
}; };
@@ -205,7 +206,7 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
}); });
}; };
const { config } = useConfig(); const { config, shouldMuteEvent } = useConfig();
const pool = usePool(); const pool = usePool();
const sub = pool().sub(config().relayUrls, filters, {}); const sub = pool().sub(config().relayUrls, filters, {});
@@ -213,12 +214,15 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
count += 1; count += 1;
sub.on('event', (event: NostrEvent & { id: string }) => { sub.on('event', (event: NostrEvent & { id: string }) => {
if (config().mutedPubkeys.includes(event.id)) return;
if (event.kind === Kind.Metadata) { if (event.kind === Kind.Metadata) {
const registeredTasks = profileTasks.get(event.pubkey) ?? []; const registeredTasks = profileTasks.get(event.pubkey) ?? [];
resolveTasks(registeredTasks, event); resolveTasks(registeredTasks, event);
} else if (event.kind === Kind.Text) { return;
if (config().mutedPubkeys.includes(event.pubkey)) return; }
if (shouldMuteEvent(event)) return;
if (event.kind === Kind.Text) {
const registeredTasks = textNoteTasks.get(event.id) ?? []; const registeredTasks = textNoteTasks.get(event.id) ?? [];
resolveTasks(registeredTasks, event); resolveTasks(registeredTasks, event);
} else if (event.kind === Kind.Reaction) { } else if (event.kind === Kind.Reaction) {
@@ -229,7 +233,6 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
resolveTasks(registeredTasks, event); resolveTasks(registeredTasks, event);
}); });
} else if ((event.kind as number) === 6) { } else if ((event.kind as number) === 6) {
if (config().mutedPubkeys.includes(event.pubkey)) return;
const eventTags = eventWrapper(event).taggedEvents(); const eventTags = eventWrapper(event).taggedEvents();
eventTags.forEach((eventTag) => { eventTags.forEach((eventTag) => {
const taggedEventId = eventTag.id; const taggedEventId = eventTag.id;
@@ -494,5 +497,7 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U
const followingPubkeys = (): string[] => followings().map((follow) => follow.pubkey); const followingPubkeys = (): string[] => followings().map((follow) => follow.pubkey);
return { followings, followingPubkeys, query }; const invalidateFollowings = (): Promise<void> => queryClient.invalidateQueries(genQueryKey());
return { followings, followingPubkeys, invalidateFollowings, query };
}; };

View File

@@ -148,7 +148,7 @@ const useCommands = () => {
return publishEvent(relayUrls, preSignedEvent); return publishEvent(relayUrls, preSignedEvent);
}, },
// NIP-18 // NIP-18
publishDeprecatedRepost({ async publishDeprecatedRepost({
relayUrls, relayUrls,
pubkey, pubkey,
eventId, eventId,
@@ -171,8 +171,6 @@ const useCommands = () => {
}; };
return publishEvent(relayUrls, preSignedEvent); return publishEvent(relayUrls, preSignedEvent);
}, },
// useFollowingsのisFetchedが呼ばれたとしても全てのリレーから取得できたとは限らない
// 半数以上、あるいは5秒待ってみて応答があればそれを利用するみたいな仕組みが必要か
updateContacts({ updateContacts({
relayUrls, relayUrls,
pubkey, pubkey,
@@ -194,7 +192,6 @@ const useCommands = () => {
content, content,
}; };
return publishEvent(relayUrls, preSignedEvent); return publishEvent(relayUrls, preSignedEvent);
// TODO publishできたら、invalidateをしないといけない
}, },
}; };
}; };

View File

@@ -1,15 +1,19 @@
import { type Accessor, type Setter } from 'solid-js'; import { type Accessor, type Setter } from 'solid-js';
import { Kind, type Event as NostrEvent } from 'nostr-tools';
import { import {
createStorageWithSerializer, createStorageWithSerializer,
createStoreWithStorage, createStoreWithStorage,
} from '@/hooks/createSignalWithStorage'; } from '@/hooks/createSignalWithStorage';
import { ColumnConfig } from '@/core/column';
export type Config = { export type Config = {
relayUrls: string[]; relayUrls: string[];
columns: ColumnConfig[];
dateFormat: 'relative' | 'absolute-long' | 'absolute-short'; dateFormat: 'relative' | 'absolute-long' | 'absolute-short';
keepOpenPostForm: boolean; keepOpenPostForm: boolean;
showImage: boolean; showImage: boolean;
mutedPubkeys: string[]; mutedPubkeys: string[];
mutedKeywords: string[];
}; };
type UseConfig = { type UseConfig = {
@@ -19,31 +23,46 @@ type UseConfig = {
removeRelay: (url: string) => void; removeRelay: (url: string) => void;
addMutedPubkey: (pubkey: string) => void; addMutedPubkey: (pubkey: string) => void;
removeMutedPubkey: (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 InitialConfig = (): Config => { const relaysGlobal = [
const relayUrls = [
'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',
]; ];
if (navigator.language === 'ja') {
relayUrls.push( const relaysOnlyAvailableInJP = [
'wss://nostr.h3z.jp',
'wss://relay.nostr.wirednet.jp',
'wss://relay-jp.nostr.wirednet.jp', 'wss://relay-jp.nostr.wirednet.jp',
'wss://nostr.h3z.jp',
'wss://nostr.holybea.com',
];
const relaysInJP = [
...relaysOnlyAvailableInJP,
'wss://nostr.holybea.com', 'wss://nostr.holybea.com',
'wss://nostr-relay.nokotaro.com', 'wss://nostr-relay.nokotaro.com',
); ];
const InitialConfig = (): Config => {
const relayUrls = [...relaysGlobal];
if (navigator.language === 'ja') {
relayUrls.push(...relaysInJP);
} }
return { return {
relayUrls, relayUrls,
columns: [],
dateFormat: 'relative', dateFormat: 'relative',
keepOpenPostForm: false, keepOpenPostForm: false,
showImage: true, showImage: true,
mutedPubkeys: [], mutedPubkeys: [],
mutedKeywords: [],
}; };
}; };
@@ -75,6 +94,40 @@ const useConfig = (): UseConfig => {
setConfig('mutedPubkeys', (current) => current.filter((e) => e !== pubkey)); 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 { return {
config: () => config, config: () => config,
setConfig, setConfig,
@@ -82,6 +135,11 @@ const useConfig = (): UseConfig => {
removeRelay, removeRelay,
addMutedPubkey, addMutedPubkey,
removeMutedPubkey, removeMutedPubkey,
addMutedKeyword,
removeMutedKeyword,
isPubkeyMuted,
shouldMuteEvent,
initializeColumns,
}; };
}; };

View File

@@ -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 type { Event as NostrEvent, Filter, SubscriptionOptions } from 'nostr-tools';
import uniqBy from 'lodash/uniqBy'; import uniqBy from 'lodash/uniqBy';
import usePool from '@/nostr/usePool'; import usePool from '@/nostr/usePool';
@@ -35,10 +35,20 @@ setInterval(() => {
}, 1000); }, 1000);
const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => { const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
const { config } = useConfig(); const { config, shouldMuteEvent } = useConfig();
const pool = usePool(); const pool = usePool();
const [events, setEvents] = createSignal<NostrEvent[]>([]); const [events, setEvents] = createSignal<NostrEvent[]>([]);
createEffect(
on(
() => [config().mutedPubkeys, config().mutedKeywords],
() => {
setEvents((currentEvents) => currentEvents.filter((event) => !shouldMuteEvent(event)));
},
{ defer: true },
),
);
const startSubscription = () => { const startSubscription = () => {
const props = propsProvider(); const props = propsProvider();
if (props == null) return; if (props == null) return;
@@ -58,7 +68,7 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
if (onEvent != null) { if (onEvent != null) {
onEvent(event as NostrEvent & { id: string }); onEvent(event as NostrEvent & { id: string });
} }
if (config().mutedPubkeys.includes(event.pubkey)) { if (shouldMuteEvent(event)) {
return; return;
} }
if (props.clientEventFilter != null && !props.clientEventFilter(event)) { if (props.clientEventFilter != null && !props.clientEventFilter(event)) {

View File

@@ -9,6 +9,7 @@ import {
type Component, type Component,
} from 'solid-js'; } from 'solid-js';
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import { createVirtualizer } from '@tanstack/solid-virtual';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import Column from '@/components/Column'; 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(() => { onMount(() => {
if (!persistStatus().loggedIn) { if (!persistStatus().loggedIn) {
navigate('/hello'); navigate('/hello');