mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 22:14:26 +01:00
refactor
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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';
|
||||||
|
|||||||
22
src/components/modal/AddColumn.tsx
Normal file
22
src/components/modal/AddColumn.tsx
Normal 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;
|
||||||
36
src/components/modal/BasicModal.tsx
Normal file
36
src/components/modal/BasicModal.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
374
src/components/modal/ProfileDisplay.tsx
Normal file
374
src/components/modal/ProfileDisplay.tsx
Normal 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;
|
||||||
323
src/components/modal/ProfileEdit.tsx
Normal file
323
src/components/modal/ProfileEdit.tsx
Normal 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;
|
||||||
@@ -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'];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,24 +56,26 @@ 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,
|
|
||||||
columns: [],
|
|
||||||
dateFormat: 'relative',
|
|
||||||
keepOpenPostForm: false,
|
|
||||||
showImage: true,
|
|
||||||
hideCount: false,
|
|
||||||
mutedPubkeys: [],
|
|
||||||
mutedKeywords: [],
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const InitialConfig = (): Config => ({
|
||||||
|
relayUrls: initialRelays(),
|
||||||
|
columns: [],
|
||||||
|
dateFormat: 'relative',
|
||||||
|
keepOpenPostForm: false,
|
||||||
|
showImage: true,
|
||||||
|
hideCount: false,
|
||||||
|
mutedPubkeys: [],
|
||||||
|
mutedKeywords: [],
|
||||||
|
});
|
||||||
|
|
||||||
const serializer = (config: Config): string => JSON.stringify(config);
|
const serializer = (config: Config): string => JSON.stringify(config);
|
||||||
// TODO zod使う
|
// TODO zod使う
|
||||||
const deserializer = (json: string): Config =>
|
const deserializer = (json: string): Config =>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
28
src/utils/generateId.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user