This commit is contained in:
Shusui MOYATANI
2023-05-05 17:35:46 +09:00
parent a0674fa9e0
commit 7831b62bda
16 changed files with 865 additions and 797 deletions

View File

@@ -27,9 +27,9 @@ const App: Component = () => {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Routes> <Routes>
<Route path="/hello" element={() => <Hello />} /> <Route path="/hello" element={<Hello />} />
<Route path="/" element={() => <Home />} /> <Route path="/" element={<Home />} />
<Route path="/*" element={() => <NotFound />} /> <Route path="/*" element={<NotFound />} />
</Routes> </Routes>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -5,7 +5,7 @@ type ColumnItemProps = {
}; };
const ColumnItem: Component<ColumnItemProps> = (props) => { const ColumnItem: Component<ColumnItemProps> = (props) => {
return <li class="block shrink-0 overflow-hidden border-b p-1">{props.children}</li>; return <div class="block shrink-0 overflow-hidden border-b p-1">{props.children}</div>;
}; };
export default ColumnItem; export default ColumnItem;

View File

@@ -1,390 +0,0 @@
import { Component, createSignal, createMemo, Show, Switch, Match, createEffect } from 'solid-js';
import { createMutation } from '@tanstack/solid-query';
import ArrowPath from 'heroicons/24/outline/arrow-path.svg';
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
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 uniq from 'lodash/uniq';
import Modal from '@/components/Modal';
import Timeline from '@/components/Timeline';
import Copy from '@/components/utils/Copy';
import SafeLink from '@/components/utils/SafeLink';
import useConfig from '@/core/useConfig';
import useModalState from '@/hooks/useModalState';
import useCommands from '@/nostr/useCommands';
import useFollowers from '@/nostr/useFollowers';
import useFollowings from '@/nostr/useFollowings';
import useProfile from '@/nostr/useProfile';
import usePubkey from '@/nostr/usePubkey';
import useSubscription from '@/nostr/useSubscription';
import useVerification from '@/nostr/useVerification';
import ensureNonNull from '@/utils/ensureNonNull';
import epoch from '@/utils/epoch';
import npubEncodeFallback from '@/utils/npubEncodeFallback';
import timeout from '@/utils/timeout';
import ContextMenu, { MenuItem } from './ContextMenu';
export type ProfileDisplayProps = {
pubkey: string;
onClose?: () => void;
};
const FollowersCount: Component<{ pubkey: string }> = (props) => {
const { count } = useFollowers(() => ({
pubkey: props.pubkey,
}));
return <>{count()}</>;
};
const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
const { config, addMutedPubkey, removeMutedPubkey, isPubkeyMuted } = useConfig();
const commands = useCommands();
const myPubkey = usePubkey();
const { showProfileEdit } = useModalState();
const npub = createMemo(() => npubEncodeFallback(props.pubkey));
const [hoverFollowButton, setHoverFollowButton] = createSignal(false);
const [showFollowers, setShowFollowers] = createSignal(false);
const { profile, query: profileQuery } = useProfile(() => ({
pubkey: props.pubkey,
}));
const { verification, query: verificationQuery } = useVerification(() =>
ensureNonNull([profile()?.nip05] as const)(([nip05]) => ({ nip05 })),
);
const nip05Identifier = () => {
const ident = profile()?.nip05;
if (ident == null) return null;
const [user, domain] = ident.split('@');
if (domain == null) return null;
if (user === '_') return { domain, ident: domain };
return { user, domain, ident };
};
const isVerified = () => verification()?.pubkey === props.pubkey;
const isMuted = () => isPubkeyMuted(props.pubkey);
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 }),
);
const followed = () => {
const p = myPubkey();
return p != null && userFollowingPubkeys().includes(p);
};
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: uniq([...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);
}
},
},
{
when: () => props.pubkey === myPubkey(),
content: () => (!following() ? '自分をフォロー' : '自分をフォロー解除'),
onSelect: () => {
if (!following()) {
follow();
} else {
unfollow();
}
},
},
];
const { events } = useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [1, 6],
authors: [props.pubkey],
limit: 10,
until: epoch(),
},
],
continuous: false,
}));
return (
<Modal onClose={() => props.onClose?.()}>
<div class="h-screen w-[640px] max-w-full">
<button
class="w-full pt-1 text-start text-stone-800"
aria-label="Close"
onClick={() => props.onClose?.()}
>
<span class="inline-block h-8 w-8">
<XMark />
</span>
</button>
<div class="flex h-full flex-col overflow-y-scroll rounded-xl border bg-white pb-16 text-stone-700 shadow-lg">
<Show when={profileQuery.isFetched} fallback={<>loading</>}>
<Show when={profile()?.banner} fallback={<div class="h-12 shrink-0" />} keyed>
{(bannerUrl) => (
<div class="h-40 w-full shrink-0 sm:h-52">
<img src={bannerUrl} alt="header" class="h-full w-full object-cover" />
</div>
)}
</Show>
<div class="mt-[-54px] flex items-end gap-4 px-4 pt-4">
<div class="flex-1 shrink-0">
<div class="h-28 w-28 rounded-lg shadow-md">
<Show when={profile()?.picture} keyed>
{(pictureUrl) => (
<img
src={pictureUrl}
alt="user icon"
class="h-full w-full rounded-lg object-cover"
/>
)}
</Show>
</div>
</div>
<div class="flex shrink-0 flex-col items-center gap-1">
<div class="flex flex-row justify-start gap-1">
<Switch>
<Match when={props.pubkey === myPubkey()}>
<button
class="rounded-full border border-primary px-4 py-2
text-center font-bold text-primary hover:bg-primary hover:text-white sm:w-20"
onClick={() => showProfileEdit()}
>
</button>
</Match>
<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={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">
<Show when={(profile()?.display_name?.length ?? 0) > 0}>
<div class="truncate text-xl font-bold">{profile()?.display_name}</div>
</Show>
<div class="flex items-center gap-2">
<Show when={(profile()?.name?.length ?? 0) > 0}>
<div class="truncate text-xs">@{profile()?.name}</div>
</Show>
<Show when={(profile()?.nip05?.length ?? 0) > 0}>
<div class="flex items-center text-xs">
{nip05Identifier()?.ident}
<Switch
fallback={
<span class="inline-block h-4 w-4 text-rose-500">
<ExclamationCircle />
</span>
}
>
<Match when={verificationQuery.isLoading}>
<span class="inline-block h-3 w-3">
<ArrowPath />
</span>
</Match>
<Match when={isVerified()}>
<span class="inline-block h-4 w-4 text-blue-400">
<CheckCircle />
</span>
</Match>
</Switch>
</div>
</Show>
</div>
<div class="flex gap-1">
<div class="truncate text-xs">{npub()}</div>
</div>
</div>
</div>
<Show when={(profile()?.about ?? '').length > 0}>
<div class="max-h-40 shrink-0 overflow-y-auto whitespace-pre-wrap px-4 py-2 text-sm">
{profile()?.about}
</div>
</Show>
<div class="flex border-t px-4 py-2">
<div class="flex flex-1 flex-col items-start">
<div class="text-sm"></div>
<div class="text-xl">
<Show
when={userFollowingQuery.isFetched}
fallback={<span class="text-sm"></span>}
>
{userFollowingPubkeys().length}
</Show>
</div>
</div>
<Show when={!config().hideCount}>
<div class="flex flex-1 flex-col items-start">
<div class="text-sm"></div>
<div class="text-xl">
<Show
when={showFollowers()}
fallback={
<button
class="text-sm hover:text-stone-800 hover:underline"
onClick={() => setShowFollowers(true)}
>
</button>
}
keyed
>
<FollowersCount pubkey={props.pubkey} />
</Show>
</div>
</div>
</Show>
</div>
<Show when={(profile()?.website ?? '').length > 0}>
<ul class="border-t px-5 py-2 text-xs">
<Show when={profile()?.website} keyed>
{(website) => (
<li class="flex items-center gap-1">
<span class="inline-block h-4 w-4" area-label="website" title="website">
<GlobeAlt />
</span>
<SafeLink class="text-blue-500 underline" href={website} />
</li>
)}
</Show>
</ul>
</Show>
</Show>
<ul class="border-t p-1">
<Timeline events={events()} />
</ul>
</div>
</div>
</Modal>
);
};
export default ProfileDisplay;

View File

@@ -1,340 +0,0 @@
import { createSignal, type Component, batch, onMount, For, JSX, Show } from 'solid-js';
import { createMutation } from '@tanstack/solid-query';
import ArrowLeft from 'heroicons/24/outline/arrow-left.svg';
import omit from 'lodash/omit';
import omitBy from 'lodash/omitBy';
import Modal from '@/components/Modal';
import useConfig from '@/core/useConfig';
import { Profile, useProfile } from '@/nostr/useBatchedEvents';
import useCommands from '@/nostr/useCommands';
import usePubkey from '@/nostr/usePubkey';
import ensureNonNull from '@/utils/ensureNonNull';
import timeout from '@/utils/timeout';
export type ProfileEditProps = {
onClose: () => void;
};
const LNURLRegexString = 'LNURL1[AC-HJ-NP-Zac-hj-np-z02-9]+';
const InternetIdentiferRegexString = '[-_a-zA-Z0-9.]+@[-a-zA-Z0-9.]+';
const LUDAddressRegexString = `^(${LNURLRegexString}|${InternetIdentiferRegexString})$`;
const LNURLRegex = new RegExp(`^${LNURLRegexString}$`);
const InternetIdentiferRegex = new RegExp(`^${InternetIdentiferRegexString}$`);
const isLNURL = (s: string) => LNURLRegex.test(s);
const isInternetIdentifier = (s: string) => InternetIdentiferRegex.test(s);
const ProfileEdit: Component<ProfileEditProps> = (props) => {
const pubkey = usePubkey();
const { config } = useConfig();
const { profile, invalidateProfile, query } = useProfile(() =>
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
pubkey: pubkeyNonNull,
})),
);
const { updateProfile } = useCommands();
const [picture, setPicture] = createSignal('');
const [banner, setBanner] = createSignal('');
const [name, setName] = createSignal('');
const [displayName, setDisplayName] = createSignal('');
const [about, setAbout] = createSignal('');
const [website, setWebsite] = createSignal('');
const [nip05, setNIP05] = createSignal('');
const [lightningAddress, setLightningAddress] = createSignal('');
const mutation = createMutation({
mutationKey: ['updateProfile'],
mutationFn: (...params: Parameters<typeof updateProfile>) =>
updateProfile(...params).then((promeses) => Promise.allSettled(promeses.map(timeout(10000)))),
onSuccess: (results) => {
const succeeded = results.filter((res) => res.status === 'fulfilled').length;
const failed = results.length - succeeded;
if (succeeded === results.length) {
window.alert('更新しました');
} else if (succeeded > 0) {
window.alert(`${failed}個のリレーで更新に失敗しました`);
} else {
window.alert('すべてのリレーで更新に失敗しました');
}
invalidateProfile()
.then(() => query.refetch())
.catch((err) => console.error(err));
props.onClose();
},
onError: (err) => {
console.error('failed to delete', err);
},
});
const disabled = () => query.isLoading || query.isError || mutation.isLoading;
const otherProperties = () =>
omit(profile(), [
'picture',
'banner',
'name',
'display_name',
'about',
'website',
'nip05',
'lud06',
'lud16',
]);
const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
ev.preventDefault();
const p = pubkey();
if (p == null) return;
const newProfile: Profile = omitBy(
{
picture: picture(),
banner: banner(),
name: name(),
display_name: displayName(),
about: about(),
website: website(),
nip05: nip05(),
lud06: isLNURL(lightningAddress()) ? lightningAddress() : null,
lud16: isInternetIdentifier(lightningAddress()) ? lightningAddress() : null,
},
(v) => v == null || v.length === 0,
);
mutation.mutate({
relayUrls: config().relayUrls,
pubkey: p,
profile: newProfile,
otherProperties: otherProperties(),
});
};
const ignoreEnter = (ev: KeyboardEvent) => ev.key === 'Enter' && ev.preventDefault();
onMount(() => {
const currentProfile = profile();
if (currentProfile == null) return;
query.refetch().catch((err) => console.error(err));
batch(() => {
setPicture((current) => currentProfile.picture ?? current);
setBanner((current) => currentProfile.banner ?? current);
setName((current) => currentProfile.name ?? current);
setDisplayName((current) => currentProfile.display_name ?? current);
setAbout((current) => currentProfile.about ?? current);
setWebsite((current) => currentProfile.website ?? current);
setNIP05((current) => currentProfile.nip05 ?? current);
if (currentProfile.lud16 != null) {
setLightningAddress(currentProfile.lud16);
} else if (currentProfile.lud06 != null) {
setLightningAddress(currentProfile.lud06);
}
});
});
return (
<Modal onClose={props.onClose}>
<div class="h-screen w-[640px] max-w-full">
<button
class="w-full pt-1 text-start text-stone-800"
aria-label="Close"
onClick={() => props.onClose?.()}
>
<span class="inline-block h-8 w-8">
<ArrowLeft />
</span>
</button>
<div class="flex h-full flex-col overflow-y-scroll rounded-xl border bg-white pb-32 text-stone-700 shadow-lg">
<div>
<Show when={banner().length > 0} fallback={<div class="h-12 shrink-0" />} keyed>
<div class="h-40 w-full shrink-0 sm:h-52">
<img src={banner()} alt="header" class="h-full w-full object-cover" />
</div>
</Show>
<div class="ml-4 mt-[-64px] h-28 w-28 rounded-lg shadow-md">
<Show when={picture().length > 0}>
<img
src={picture()}
alt="user icon"
class="h-full w-full rounded-lg object-cover"
/>
</Show>
</div>
</div>
<div>
<form class="flex flex-col gap-4 p-4" onSubmit={handleSubmit}>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="picture">
</label>
<input
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
id="picture"
name="picture"
value={picture()}
placeholder="https://....../picture.png"
disabled={disabled()}
onBlur={(ev) => setPicture(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="picture">
</label>
<input
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
id="banner"
name="banner"
value={banner()}
placeholder="https://....../banner.png"
disabled={disabled()}
onBlur={(ev) => setBanner(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="name">
</label>
<div class="flex w-full items-center gap-2">
<span>@</span>
<input
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
id="name"
name="name"
value={name()}
// pattern="^[a-zA-Z_][a-zA-Z0-9_]+$"
maxlength="32"
required
disabled={disabled()}
onChange={(ev) => setName(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="name">
</label>
<input
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="displayName"
value={displayName()}
maxlength="32"
disabled={disabled()}
onChange={(ev) => setDisplayName(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="name">
</label>
<textarea
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
name="about"
value={about()}
rows="5"
onChange={(ev) => setAbout(ev.currentTarget.value)}
disabled={disabled()}
/>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="name">
</label>
<input
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="website"
value={website()}
placeholder="https://....../"
disabled={disabled()}
onChange={(ev) => setWebsite(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="name">
NIP-05
</label>
<input
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="nip05"
value={nip05()}
placeholder="yourname@domain.example.com"
pattern={InternetIdentiferRegex.source}
disabled={disabled()}
onChange={(ev) => setNIP05(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="name">
LNURLアドレス /
</label>
<span class="text-xs"></span>
<input
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="website"
value={lightningAddress()}
pattern={LUDAddressRegexString}
placeholder="LNURL1XXXXXX / abcdef@wallet.example.com"
disabled={disabled()}
onChange={(ev) => setLightningAddress(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
<Show when={Object.entries(otherProperties()).length > 0}>
<div>
<span class="font-bold"></span>
<div>
<For each={Object.entries(otherProperties())}>
{([key, value]) => (
<div class="flex flex-col items-start ">
<span class="text-sm font-bold">{key}</span>
<span class="whitespace-pre-wrap break-all text-sm">{value}</span>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="flex gap-2">
<button
type="submit"
class="rounded bg-rose-300 p-2 font-bold text-white hover:bg-rose-400"
disabled={mutation.isLoading}
>
</button>
<button
type="button"
class="rounded border border-rose-300 p-2 font-bold text-rose-300 hover:border-rose-400 hover:text-rose-400"
onClick={() => props.onClose()}
>
</button>
</div>
<Show when={mutation.isLoading}>...</Show>
</form>
</div>
</div>
</div>
</Modal>
);
};
export default ProfileEdit;

View File

@@ -4,7 +4,7 @@ import Cog6Tooth from 'heroicons/24/outline/cog-6-tooth.svg';
import MagnifyingGlass from 'heroicons/24/solid/magnifying-glass.svg'; import MagnifyingGlass from 'heroicons/24/solid/magnifying-glass.svg';
import PencilSquare from 'heroicons/24/solid/pencil-square.svg'; import PencilSquare from 'heroicons/24/solid/pencil-square.svg';
import Config from '@/components/Config'; import Config from '@/components/modal/Config';
import NotePostForm from '@/components/NotePostForm'; import NotePostForm from '@/components/NotePostForm';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
import { useHandleCommand } from '@/hooks/useCommandBus'; import { useHandleCommand } from '@/hooks/useCommandBus';

View File

@@ -0,0 +1,22 @@
import BasicModal from '@/components/modal/BasicModal';
import useConfig from '@/core/useConfig';
const AddColumn = () => {
const { addColumn } = useConfig();
return (
<BasicModal onClose={() => console.log('closed')}>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</BasicModal>
);
};
export default AddColumn;

View File

@@ -0,0 +1,36 @@
import { Component, JSX, Show } from 'solid-js';
import XMark from 'heroicons/24/outline/x-mark.svg';
import Modal from '@/components/Modal';
export type BasicModalProps = {
onClose: () => void;
closeButton?: () => JSX.Element;
children: JSX.Element;
};
const BasicModal: Component<BasicModalProps> = (props) => {
return (
<Modal onClose={() => props.onClose?.()}>
<div class="h-screen w-[640px] max-w-full">
<button
class="w-full pt-1 text-start text-stone-800"
aria-label="Close"
onClick={() => props.onClose?.()}
>
<span class="inline-block h-8 w-8">
<Show when={props?.closeButton} fallback={<XMark />} keyed>
{(button) => button()}
</Show>
</span>
</button>
<div class="flex h-full flex-col overflow-y-scroll rounded-xl border bg-white pb-32 text-stone-700 shadow-lg">
{props.children}
</div>
</div>
</Modal>
);
};
export default BasicModal;

View File

@@ -2,14 +2,13 @@ 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 BasicModal from '@/components/modal/BasicModal';
import UserNameDisplay from '@/components/UserDisplayName';
import useConfig, { type Config } from '@/core/useConfig'; import useConfig, { type Config } from '@/core/useConfig';
import useModalState from '@/hooks/useModalState'; import useModalState from '@/hooks/useModalState';
import usePubkey from '@/nostr/usePubkey'; import usePubkey from '@/nostr/usePubkey';
import ensureNonNull from '@/utils/ensureNonNull'; import ensureNonNull from '@/utils/ensureNonNull';
import UserNameDisplay from './UserDisplayName';
type ConfigProps = { type ConfigProps = {
onClose: () => void; onClose: () => void;
}; };
@@ -288,24 +287,18 @@ const OtherConfig = () => {
}; };
const ConfigUI = (props: ConfigProps) => { const ConfigUI = (props: ConfigProps) => {
// <div class="max-h-[90vh] w-[640px] max-w-[100vw] overflow-y-scroll rounded bg-white p-4 shadow">
return ( return (
<Modal onClose={props.onClose}> <BasicModal onClose={props.onClose}>
<div class="max-h-[90vh] w-[640px] max-w-[100vw] overflow-y-scroll rounded bg-white p-4 shadow"> <div class="p-4">
<div class="relative"> <h2 class="flex-1 text-center text-lg font-bold"></h2>
<div class="flex flex-col gap-1">
<h2 class="flex-1 text-center font-bold"></h2>
<button class="absolute top-1 right-0 z-0 h-4 w-4" onClick={() => props.onClose?.()}>
<XMark />
</button>
</div>
</div>
<ProfileSection /> <ProfileSection />
<RelayConfig /> <RelayConfig />
<DateFormatConfig /> <DateFormatConfig />
<OtherConfig /> <OtherConfig />
<MuteConfig /> <MuteConfig />
</div> </div>
</Modal> </BasicModal>
); );
}; };

View File

@@ -0,0 +1,374 @@
import { Component, createSignal, createMemo, Show, Switch, Match, createEffect } from 'solid-js';
import { createMutation } from '@tanstack/solid-query';
import ArrowPath from 'heroicons/24/outline/arrow-path.svg';
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
import GlobeAlt from 'heroicons/24/outline/globe-alt.svg';
import CheckCircle from 'heroicons/24/solid/check-circle.svg';
import ExclamationCircle from 'heroicons/24/solid/exclamation-circle.svg';
import uniq from 'lodash/uniq';
import ContextMenu, { MenuItem } from '@/components/ContextMenu';
import BasicModal from '@/components/modal/BasicModal';
import Timeline from '@/components/Timeline';
import SafeLink from '@/components/utils/SafeLink';
import useConfig from '@/core/useConfig';
import useModalState from '@/hooks/useModalState';
import useCommands from '@/nostr/useCommands';
import useFollowers from '@/nostr/useFollowers';
import useFollowings from '@/nostr/useFollowings';
import useProfile from '@/nostr/useProfile';
import usePubkey from '@/nostr/usePubkey';
import useSubscription from '@/nostr/useSubscription';
import useVerification from '@/nostr/useVerification';
import ensureNonNull from '@/utils/ensureNonNull';
import epoch from '@/utils/epoch';
import npubEncodeFallback from '@/utils/npubEncodeFallback';
import timeout from '@/utils/timeout';
export type ProfileDisplayProps = {
pubkey: string;
onClose?: () => void;
};
const FollowersCount: Component<{ pubkey: string }> = (props) => {
const { count } = useFollowers(() => ({
pubkey: props.pubkey,
}));
return <>{count()}</>;
};
const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
const { config, addMutedPubkey, removeMutedPubkey, isPubkeyMuted } = useConfig();
const commands = useCommands();
const myPubkey = usePubkey();
const { showProfileEdit } = useModalState();
const npub = createMemo(() => npubEncodeFallback(props.pubkey));
const [hoverFollowButton, setHoverFollowButton] = createSignal(false);
const [showFollowers, setShowFollowers] = createSignal(false);
const { profile, query: profileQuery } = useProfile(() => ({
pubkey: props.pubkey,
}));
const { verification, query: verificationQuery } = useVerification(() =>
ensureNonNull([profile()?.nip05] as const)(([nip05]) => ({ nip05 })),
);
const nip05Identifier = () => {
const ident = profile()?.nip05;
if (ident == null) return null;
const [user, domain] = ident.split('@');
if (domain == null) return null;
if (user === '_') return { domain, ident: domain };
return { user, domain, ident };
};
const isVerified = () => verification()?.pubkey === props.pubkey;
const isMuted = () => isPubkeyMuted(props.pubkey);
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 }),
);
const followed = () => {
const p = myPubkey();
return p != null && userFollowingPubkeys().includes(p);
};
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: uniq([...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);
}
},
},
{
when: () => props.pubkey === myPubkey(),
content: () => (!following() ? '自分をフォロー' : '自分をフォロー解除'),
onSelect: () => {
if (!following()) {
follow();
} else {
unfollow();
}
},
},
];
const { events } = useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [1, 6],
authors: [props.pubkey],
limit: 10,
until: epoch(),
},
],
continuous: false,
}));
return (
<BasicModal onClose={() => props.onClose?.()}>
<Show when={profileQuery.isFetched} fallback={<>loading</>}>
<Show when={profile()?.banner} fallback={<div class="h-12 shrink-0" />} keyed>
{(bannerUrl) => (
<div class="h-40 w-full shrink-0 sm:h-52">
<img src={bannerUrl} alt="header" class="h-full w-full object-cover" />
</div>
)}
</Show>
<div class="mt-[-54px] flex items-end gap-4 px-4 pt-4">
<div class="flex-1 shrink-0">
<div class="h-28 w-28 rounded-lg shadow-md">
<Show when={profile()?.picture} keyed>
{(pictureUrl) => (
<img
src={pictureUrl}
alt="user icon"
class="h-full w-full rounded-lg object-cover"
/>
)}
</Show>
</div>
</div>
<div class="flex shrink-0 flex-col items-center gap-1">
<div class="flex flex-row justify-start gap-1">
<Switch>
<Match when={props.pubkey === myPubkey()}>
<button
class="rounded-full border border-primary px-4 py-2
text-center font-bold text-primary hover:bg-primary hover:text-white sm:w-20"
onClick={() => showProfileEdit()}
>
</button>
</Match>
<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={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">
<Show when={(profile()?.display_name?.length ?? 0) > 0}>
<div class="truncate text-xl font-bold">{profile()?.display_name}</div>
</Show>
<div class="flex items-center gap-2">
<Show when={(profile()?.name?.length ?? 0) > 0}>
<div class="truncate text-xs">@{profile()?.name}</div>
</Show>
<Show when={(profile()?.nip05?.length ?? 0) > 0}>
<div class="flex items-center text-xs">
{nip05Identifier()?.ident}
<Switch
fallback={
<span class="inline-block h-4 w-4 text-rose-500">
<ExclamationCircle />
</span>
}
>
<Match when={verificationQuery.isLoading}>
<span class="inline-block h-3 w-3">
<ArrowPath />
</span>
</Match>
<Match when={isVerified()}>
<span class="inline-block h-4 w-4 text-blue-400">
<CheckCircle />
</span>
</Match>
</Switch>
</div>
</Show>
</div>
<div class="flex gap-1">
<div class="truncate text-xs">{npub()}</div>
</div>
</div>
</div>
<Show when={(profile()?.about ?? '').length > 0}>
<div class="max-h-40 shrink-0 overflow-y-auto whitespace-pre-wrap px-4 py-2 text-sm">
{profile()?.about}
</div>
</Show>
<div class="flex border-t px-4 py-2">
<div class="flex flex-1 flex-col items-start">
<div class="text-sm"></div>
<div class="text-xl">
<Show
when={userFollowingQuery.isFetched}
fallback={<span class="text-sm"></span>}
>
{userFollowingPubkeys().length}
</Show>
</div>
</div>
<Show when={!config().hideCount}>
<div class="flex flex-1 flex-col items-start">
<div class="text-sm"></div>
<div class="text-xl">
<Show
when={showFollowers()}
fallback={
<button
class="text-sm hover:text-stone-800 hover:underline"
onClick={() => setShowFollowers(true)}
>
</button>
}
keyed
>
<FollowersCount pubkey={props.pubkey} />
</Show>
</div>
</div>
</Show>
</div>
<Show when={(profile()?.website ?? '').length > 0}>
<ul class="border-t px-5 py-2 text-xs">
<Show when={profile()?.website} keyed>
{(website) => (
<li class="flex items-center gap-1">
<span class="inline-block h-4 w-4" area-label="website" title="website">
<GlobeAlt />
</span>
<SafeLink class="text-blue-500 underline" href={website} />
</li>
)}
</Show>
</ul>
</Show>
</Show>
<ul class="border-t p-1">
<Timeline events={events()} />
</ul>
</BasicModal>
);
};
export default ProfileDisplay;

View File

@@ -0,0 +1,323 @@
import { createSignal, type Component, batch, onMount, For, JSX, Show } from 'solid-js';
import { createMutation } from '@tanstack/solid-query';
import ArrowLeft from 'heroicons/24/outline/arrow-left.svg';
import omit from 'lodash/omit';
import omitBy from 'lodash/omitBy';
import BasicModal from '@/components/modal/BasicModal';
import useConfig from '@/core/useConfig';
import { Profile, useProfile } from '@/nostr/useBatchedEvents';
import useCommands from '@/nostr/useCommands';
import usePubkey from '@/nostr/usePubkey';
import ensureNonNull from '@/utils/ensureNonNull';
import timeout from '@/utils/timeout';
export type ProfileEditProps = {
onClose: () => void;
};
const LNURLRegexString = 'LNURL1[AC-HJ-NP-Zac-hj-np-z02-9]+';
const InternetIdentiferRegexString = '[-_a-zA-Z0-9.]+@[-a-zA-Z0-9.]+';
const LUDAddressRegexString = `^(${LNURLRegexString}|${InternetIdentiferRegexString})$`;
const LNURLRegex = new RegExp(`^${LNURLRegexString}$`);
const InternetIdentiferRegex = new RegExp(`^${InternetIdentiferRegexString}$`);
const isLNURL = (s: string) => LNURLRegex.test(s);
const isInternetIdentifier = (s: string) => InternetIdentiferRegex.test(s);
const ProfileEdit: Component<ProfileEditProps> = (props) => {
const pubkey = usePubkey();
const { config } = useConfig();
const { profile, invalidateProfile, query } = useProfile(() =>
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
pubkey: pubkeyNonNull,
})),
);
const { updateProfile } = useCommands();
const [picture, setPicture] = createSignal('');
const [banner, setBanner] = createSignal('');
const [name, setName] = createSignal('');
const [displayName, setDisplayName] = createSignal('');
const [about, setAbout] = createSignal('');
const [website, setWebsite] = createSignal('');
const [nip05, setNIP05] = createSignal('');
const [lightningAddress, setLightningAddress] = createSignal('');
const mutation = createMutation({
mutationKey: ['updateProfile'],
mutationFn: (...params: Parameters<typeof updateProfile>) =>
updateProfile(...params).then((promeses) => Promise.allSettled(promeses.map(timeout(10000)))),
onSuccess: (results) => {
const succeeded = results.filter((res) => res.status === 'fulfilled').length;
const failed = results.length - succeeded;
if (succeeded === results.length) {
window.alert('更新しました');
} else if (succeeded > 0) {
window.alert(`${failed}個のリレーで更新に失敗しました`);
} else {
window.alert('すべてのリレーで更新に失敗しました');
}
invalidateProfile()
.then(() => query.refetch())
.catch((err) => console.error(err));
props.onClose();
},
onError: (err) => {
console.error('failed to delete', err);
},
});
const disabled = () => query.isLoading || query.isError || mutation.isLoading;
const otherProperties = () =>
omit(profile(), [
'picture',
'banner',
'name',
'display_name',
'about',
'website',
'nip05',
'lud06',
'lud16',
]);
const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
ev.preventDefault();
const p = pubkey();
if (p == null) return;
const newProfile: Profile = omitBy(
{
picture: picture(),
banner: banner(),
name: name(),
display_name: displayName(),
about: about(),
website: website(),
nip05: nip05(),
lud06: isLNURL(lightningAddress()) ? lightningAddress() : null,
lud16: isInternetIdentifier(lightningAddress()) ? lightningAddress() : null,
},
(v) => v == null || v.length === 0,
);
mutation.mutate({
relayUrls: config().relayUrls,
pubkey: p,
profile: newProfile,
otherProperties: otherProperties(),
});
};
const ignoreEnter = (ev: KeyboardEvent) => ev.key === 'Enter' && ev.preventDefault();
onMount(() => {
const currentProfile = profile();
if (currentProfile == null) return;
query.refetch().catch((err) => console.error(err));
batch(() => {
setPicture((current) => currentProfile.picture ?? current);
setBanner((current) => currentProfile.banner ?? current);
setName((current) => currentProfile.name ?? current);
setDisplayName((current) => currentProfile.display_name ?? current);
setAbout((current) => currentProfile.about ?? current);
setWebsite((current) => currentProfile.website ?? current);
setNIP05((current) => currentProfile.nip05 ?? current);
if (currentProfile.lud16 != null) {
setLightningAddress(currentProfile.lud16);
} else if (currentProfile.lud06 != null) {
setLightningAddress(currentProfile.lud06);
}
});
});
return (
<BasicModal closeButton={() => <ArrowLeft />} onClose={props.onClose}>
<div>
<Show when={banner().length > 0} fallback={<div class="h-12 shrink-0" />} keyed>
<div class="h-40 w-full shrink-0 sm:h-52">
<img src={banner()} alt="header" class="h-full w-full object-cover" />
</div>
</Show>
<div class="ml-4 mt-[-64px] h-28 w-28 rounded-lg shadow-md">
<Show when={picture().length > 0}>
<img src={picture()} alt="user icon" class="h-full w-full rounded-lg object-cover" />
</Show>
</div>
</div>
<div>
<form class="flex flex-col gap-4 p-4" onSubmit={handleSubmit}>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="picture">
</label>
<input
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
id="picture"
name="picture"
value={picture()}
placeholder="https://....../picture.png"
disabled={disabled()}
onBlur={(ev) => setPicture(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="picture">
</label>
<input
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
id="banner"
name="banner"
value={banner()}
placeholder="https://....../banner.png"
disabled={disabled()}
onBlur={(ev) => setBanner(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="name">
</label>
<div class="flex w-full items-center gap-2">
<span>@</span>
<input
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
id="name"
name="name"
value={name()}
// pattern="^[a-zA-Z_][a-zA-Z0-9_]+$"
maxlength="32"
required
disabled={disabled()}
onChange={(ev) => setName(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="name">
</label>
<input
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="displayName"
value={displayName()}
maxlength="32"
disabled={disabled()}
onChange={(ev) => setDisplayName(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="name">
</label>
<textarea
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
name="about"
value={about()}
rows="5"
onChange={(ev) => setAbout(ev.currentTarget.value)}
disabled={disabled()}
/>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="name">
</label>
<input
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="website"
value={website()}
placeholder="https://....../"
disabled={disabled()}
onChange={(ev) => setWebsite(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="name">
NIP-05
</label>
<input
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="nip05"
value={nip05()}
placeholder="yourname@domain.example.com"
pattern={InternetIdentiferRegex.source}
disabled={disabled()}
onChange={(ev) => setNIP05(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
<div class="flex flex-col items-start gap-1">
<label class="font-bold" for="name">
LNURLアドレス /
</label>
<span class="text-xs"></span>
<input
class="w-full rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="website"
value={lightningAddress()}
pattern={LUDAddressRegexString}
placeholder="LNURL1XXXXXX / abcdef@wallet.example.com"
disabled={disabled()}
onChange={(ev) => setLightningAddress(ev.currentTarget.value)}
onKeyDown={ignoreEnter}
/>
</div>
<Show when={Object.entries(otherProperties()).length > 0}>
<div>
<span class="font-bold"></span>
<div>
<For each={Object.entries(otherProperties())}>
{([key, value]) => (
<div class="flex flex-col items-start ">
<span class="text-sm font-bold">{key}</span>
<span class="whitespace-pre-wrap break-all text-sm">{value}</span>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="flex gap-2">
<button
type="submit"
class="rounded bg-rose-300 p-2 font-bold text-white hover:bg-rose-400"
disabled={mutation.isLoading}
>
</button>
<button
type="button"
class="rounded border border-rose-300 p-2 font-bold text-rose-300 hover:border-rose-400 hover:text-rose-400"
onClick={() => props.onClose()}
>
</button>
</div>
<Show when={mutation.isLoading}>...</Show>
</form>
</div>
</BasicModal>
);
};
export default ProfileEdit;

View File

@@ -43,6 +43,7 @@ type BulidOptions = {
// export const buildFilter = (options: BuildOptions) => {}; // export const buildFilter = (options: BuildOptions) => {};
export type BaseColumn = { export type BaseColumn = {
id: string;
title: string; title: string;
width: ColumnProps['width']; width: ColumnProps['width'];
}; };

View File

@@ -8,6 +8,7 @@ import {
createStorageWithSerializer, createStorageWithSerializer,
createStoreWithStorage, createStoreWithStorage,
} from '@/hooks/createSignalWithStorage'; } from '@/hooks/createSignalWithStorage';
import generateId from '@/utils/generateId';
export type Config = { export type Config = {
relayUrls: string[]; relayUrls: string[];
@@ -29,6 +30,8 @@ type UseConfig = {
removeMutedPubkey: (pubkey: string) => void; removeMutedPubkey: (pubkey: string) => void;
addMutedKeyword: (keyword: string) => void; addMutedKeyword: (keyword: string) => void;
removeMutedKeyword: (keyword: string) => void; removeMutedKeyword: (keyword: string) => void;
addColumn: (column: ColumnConfig) => void;
removeColumn: (columnId: string) => void;
isPubkeyMuted: (pubkey: string) => boolean; isPubkeyMuted: (pubkey: string) => boolean;
shouldMuteEvent: (event: NostrEvent) => boolean; shouldMuteEvent: (event: NostrEvent) => boolean;
initializeColumns: (param: { pubkey: string }) => void; initializeColumns: (param: { pubkey: string }) => void;
@@ -53,14 +56,17 @@ const relaysInJP = [
'wss://nostr-relay.nokotaro.com', 'wss://nostr-relay.nokotaro.com',
]; ];
const InitialConfig = (): Config => { const initialRelays = (): string[] => {
const relayUrls = [...relaysGlobal]; const relayUrls = [...relaysGlobal];
if (navigator.language === 'ja') { if (navigator.language === 'ja') {
relayUrls.push(...relaysInJP); relayUrls.push(...relaysInJP);
} }
return { return relayUrls;
relayUrls, };
const InitialConfig = (): Config => ({
relayUrls: initialRelays(),
columns: [], columns: [],
dateFormat: 'relative', dateFormat: 'relative',
keepOpenPostForm: false, keepOpenPostForm: false,
@@ -68,8 +74,7 @@ const InitialConfig = (): Config => {
hideCount: false, hideCount: false,
mutedPubkeys: [], mutedPubkeys: [],
mutedKeywords: [], mutedKeywords: [],
}; });
};
const serializer = (config: Config): string => JSON.stringify(config); const serializer = (config: Config): string => JSON.stringify(config);
// TODO zod使う // TODO zod使う
@@ -107,6 +112,14 @@ const useConfig = (): UseConfig => {
setConfig('mutedKeywords', (current) => current.filter((e) => e !== keyword)); setConfig('mutedKeywords', (current) => current.filter((e) => e !== keyword));
}; };
const addColumn = (column: ColumnConfig) => {
setConfig('columns', (current) => [...current, column]);
};
const removeColumn = (columnId: string) => {
setConfig('columns', (current) => current.filter((e) => e.id !== columnId));
};
const isPubkeyMuted = (pubkey: string) => config.mutedPubkeys.includes(pubkey); const isPubkeyMuted = (pubkey: string) => config.mutedPubkeys.includes(pubkey);
const hasMutedKeyword = (event: NostrEvent) => { const hasMutedKeyword = (event: NostrEvent) => {
@@ -124,10 +137,16 @@ const useConfig = (): UseConfig => {
if ((config.columns?.length ?? 0) > 0) return; if ((config.columns?.length ?? 0) > 0) return;
const myColumns: ColumnConfig[] = [ const myColumns: ColumnConfig[] = [
{ columnType: 'Following', title: 'ホーム', width: 'widest', pubkey }, { id: generateId(), columnType: 'Following', title: 'ホーム', width: 'widest', pubkey },
{ columnType: 'Notification', title: '通知', width: 'medium', pubkey }, { id: generateId(), columnType: 'Notification', title: '通知', width: 'medium', pubkey },
{ columnType: 'Posts', title: '自分の投稿', width: 'medium', pubkey }, { id: generateId(), columnType: 'Posts', title: '自分の投稿', width: 'medium', pubkey },
{ columnType: 'Reactions', title: '自分のリアクション', width: 'medium', pubkey }, {
id: generateId(),
columnType: 'Reactions',
title: '自分のリアクション',
width: 'medium',
pubkey,
},
// { columnType: 'Global', relays: [] }, // { columnType: 'Global', relays: [] },
]; ];
@@ -143,6 +162,8 @@ const useConfig = (): UseConfig => {
removeMutedPubkey, removeMutedPubkey,
addMutedKeyword, addMutedKeyword,
removeMutedKeyword, removeMutedKeyword,
addColumn,
removeColumn,
isPubkeyMuted, isPubkeyMuted,
shouldMuteEvent, shouldMuteEvent,
initializeColumns, initializeColumns,

View File

@@ -57,13 +57,25 @@ const Hello: Component = () => {
</p> </p>
</div> </div>
<div class="p-8 shadow-md"> <div class="rounded-md p-8 shadow-md">
<Switch> <Switch>
<Match when={signerStatus() === 'checking'}> <Match when={signerStatus() === 'checking'}>
... ...
</Match> </Match>
<Match when={signerStatus() === 'unavailable'}> <Match when={signerStatus() === 'unavailable'}>
<div class="pb-1 text-lg font-bold"></div> <h2 class="text-lg font-bold">Nostrをはじめる</h2>
<p class="pt-2">
<a
class="text-blue-500 underline"
target="_blank"
rel="noopener noreferrer"
href="https://scrapbox.io/nostr/%E3%81%AF%E3%81%98%E3%82%81%E3%81%A6%E3%81%AENostr%E3%80%90%E3%81%AF%E3%81%98%E3%82%81%E3%81%A6%E3%81%AE%E6%96%B9%E3%81%AF%E3%81%93%E3%81%A1%E3%82%89%E3%80%91"
>
Nostr
</a>
</p>
<h2 class="pt-2 text-lg font-bold"></h2>
<p class="pt-2"> <p class="pt-2">
NIP-07 NIP-07
<br /> <br />
@@ -77,18 +89,6 @@ const Hello: Component = () => {
</a> </a>
</p> </p>
<p class="pt-2">
Nostrを利用する方は
<a
class="text-blue-500 underline"
target="_blank"
rel="noopener noreferrer"
href="https://scrapbox.io/nostr/%E3%81%AF%E3%81%98%E3%82%81%E3%81%A6%E3%81%AENostr%E3%80%90%E3%81%AF%E3%81%98%E3%82%81%E3%81%A6%E3%81%AE%E6%96%B9%E3%81%AF%E3%81%93%E3%81%A1%E3%82%89%E3%80%91"
>
Nostr
</a>
</p>
</Match> </Match>
<Match when={signerStatus() === 'available'}> <Match when={signerStatus() === 'available'}>
<button <button

View File

@@ -1,22 +1,13 @@
import { import { createEffect, onMount, Show, Switch, Match, type Component } from 'solid-js';
createSignal,
createEffect,
onMount,
onCleanup,
Show,
Switch,
Match,
type Component,
} from 'solid-js';
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import { createVirtualizer } from '@tanstack/solid-virtual'; 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';
import ProfileDisplay from '@/components/modal/ProfileDisplay';
import ProfileEdit from '@/components/modal/ProfileEdit';
import Notification from '@/components/Notification'; import Notification from '@/components/Notification';
import ProfileDisplay from '@/components/ProfileDisplay';
import ProfileEdit from '@/components/ProfileEdit';
import SideBar from '@/components/SideBar'; import SideBar from '@/components/SideBar';
import Timeline from '@/components/Timeline'; import Timeline from '@/components/Timeline';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
@@ -65,8 +56,8 @@ const Home: Component = () => {
{ {
kinds: [1, 6], kinds: [1, 6],
authors, authors,
limit: 25, limit: 10,
since: epoch() - 12 * 60 * 60, since: epoch() - 4 * 60 * 60,
}, },
], ],
}; };
@@ -79,7 +70,7 @@ const Home: Component = () => {
{ {
kinds: [1, 6], kinds: [1, 6],
authors: [pubkeyNonNull], authors: [pubkeyNonNull],
limit: 25, limit: 10,
}, },
], ],
})), })),
@@ -92,7 +83,7 @@ const Home: Component = () => {
{ {
kinds: [7], kinds: [7],
authors: [pubkeyNonNull], authors: [pubkeyNonNull],
limit: 25, limit: 10,
}, },
], ],
})), })),
@@ -105,7 +96,7 @@ const Home: Component = () => {
{ {
kinds: [1, 6, 7], kinds: [1, 6, 7],
'#p': [pubkeyNonNull], '#p': [pubkeyNonNull],
limit: 25, limit: 10,
}, },
], ],
})), })),
@@ -121,7 +112,7 @@ const Home: Component = () => {
{ {
kinds: [1, 6], kinds: [1, 6],
limit: 25, limit: 25,
since: epoch() - 12 * 60 * 60, since: epoch() - 4 * 60 * 60,
}, },
], ],
clientEventFilter: (ev) => { clientEventFilter: (ev) => {

View File

@@ -1,7 +1,16 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
const NotFound: Component = () => { const NotFound: Component = () => {
return 'not found'; return (
<div class="container mx-auto max-w-[640px] py-10">
<h1 class="text-4xl font-bold text-stone-700"></h1>
<p class="pt-4">
<a class="text-blue-500 underline" href="/">
</a>
</p>
</div>
);
}; };
export default NotFound; export default NotFound;

28
src/utils/generateId.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* Generate a random string which compatible with UUIDv4.
*/
const generateId = () => {
const arr = [];
if (window.crypto?.getRandomValues != null) {
const randomValues = crypto.getRandomValues(new Uint8Array(16));
arr.push(...randomValues);
} else {
for (let i = 0; i < 16; i += 1) {
// eslint-disable-next-line no-bitwise
arr[i] = Math.round(Math.random() * 0xffff) >> 8;
}
}
// Version: 4
// eslint-disable-next-line no-bitwise
arr[6] = (arr[6] & 0x0f) | 0x40;
// Variant: 0b10 (RFC4122)
// eslint-disable-next-line no-bitwise
arr[8] = (arr[8] & 0x3f) | 0x80;
const binaryString = String.fromCharCode(...arr);
const b64String = btoa(binaryString);
// base64url
const urlSafeString = b64String.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
return urlSafeString;
};
export default generateId;