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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/hello" element={() => <Hello />} />
|
||||
<Route path="/" element={() => <Home />} />
|
||||
<Route path="/*" element={() => <NotFound />} />
|
||||
<Route path="/hello" element={<Hello />} />
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ type ColumnItemProps = {
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 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 useConfig from '@/core/useConfig';
|
||||
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 Modal from '@/components/Modal';
|
||||
import BasicModal from '@/components/modal/BasicModal';
|
||||
import UserNameDisplay from '@/components/UserDisplayName';
|
||||
import useConfig, { type Config } from '@/core/useConfig';
|
||||
import useModalState from '@/hooks/useModalState';
|
||||
import usePubkey from '@/nostr/usePubkey';
|
||||
import ensureNonNull from '@/utils/ensureNonNull';
|
||||
|
||||
import UserNameDisplay from './UserDisplayName';
|
||||
|
||||
type ConfigProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
@@ -288,24 +287,18 @@ const OtherConfig = () => {
|
||||
};
|
||||
|
||||
const ConfigUI = (props: ConfigProps) => {
|
||||
// <div class="max-h-[90vh] w-[640px] max-w-[100vw] overflow-y-scroll rounded bg-white p-4 shadow">
|
||||
return (
|
||||
<Modal onClose={props.onClose}>
|
||||
<div class="max-h-[90vh] w-[640px] max-w-[100vw] overflow-y-scroll rounded bg-white p-4 shadow">
|
||||
<div class="relative">
|
||||
<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>
|
||||
<BasicModal onClose={props.onClose}>
|
||||
<div class="p-4">
|
||||
<h2 class="flex-1 text-center text-lg font-bold">設定</h2>
|
||||
<ProfileSection />
|
||||
<RelayConfig />
|
||||
<DateFormatConfig />
|
||||
<OtherConfig />
|
||||
<MuteConfig />
|
||||
</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 type BaseColumn = {
|
||||
id: string;
|
||||
title: string;
|
||||
width: ColumnProps['width'];
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createStorageWithSerializer,
|
||||
createStoreWithStorage,
|
||||
} from '@/hooks/createSignalWithStorage';
|
||||
import generateId from '@/utils/generateId';
|
||||
|
||||
export type Config = {
|
||||
relayUrls: string[];
|
||||
@@ -29,6 +30,8 @@ type UseConfig = {
|
||||
removeMutedPubkey: (pubkey: string) => void;
|
||||
addMutedKeyword: (keyword: string) => void;
|
||||
removeMutedKeyword: (keyword: string) => void;
|
||||
addColumn: (column: ColumnConfig) => void;
|
||||
removeColumn: (columnId: string) => void;
|
||||
isPubkeyMuted: (pubkey: string) => boolean;
|
||||
shouldMuteEvent: (event: NostrEvent) => boolean;
|
||||
initializeColumns: (param: { pubkey: string }) => void;
|
||||
@@ -53,24 +56,26 @@ const relaysInJP = [
|
||||
'wss://nostr-relay.nokotaro.com',
|
||||
];
|
||||
|
||||
const InitialConfig = (): Config => {
|
||||
const initialRelays = (): string[] => {
|
||||
const relayUrls = [...relaysGlobal];
|
||||
if (navigator.language === 'ja') {
|
||||
relayUrls.push(...relaysInJP);
|
||||
}
|
||||
|
||||
return {
|
||||
relayUrls,
|
||||
columns: [],
|
||||
dateFormat: 'relative',
|
||||
keepOpenPostForm: false,
|
||||
showImage: true,
|
||||
hideCount: false,
|
||||
mutedPubkeys: [],
|
||||
mutedKeywords: [],
|
||||
};
|
||||
return relayUrls;
|
||||
};
|
||||
|
||||
const InitialConfig = (): Config => ({
|
||||
relayUrls: initialRelays(),
|
||||
columns: [],
|
||||
dateFormat: 'relative',
|
||||
keepOpenPostForm: false,
|
||||
showImage: true,
|
||||
hideCount: false,
|
||||
mutedPubkeys: [],
|
||||
mutedKeywords: [],
|
||||
});
|
||||
|
||||
const serializer = (config: Config): string => JSON.stringify(config);
|
||||
// TODO zod使う
|
||||
const deserializer = (json: string): Config =>
|
||||
@@ -107,6 +112,14 @@ const useConfig = (): UseConfig => {
|
||||
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 hasMutedKeyword = (event: NostrEvent) => {
|
||||
@@ -124,10 +137,16 @@ const useConfig = (): UseConfig => {
|
||||
if ((config.columns?.length ?? 0) > 0) return;
|
||||
|
||||
const myColumns: ColumnConfig[] = [
|
||||
{ columnType: 'Following', title: 'ホーム', width: 'widest', pubkey },
|
||||
{ columnType: 'Notification', title: '通知', width: 'medium', pubkey },
|
||||
{ columnType: 'Posts', title: '自分の投稿', width: 'medium', pubkey },
|
||||
{ columnType: 'Reactions', title: '自分のリアクション', width: 'medium', pubkey },
|
||||
{ id: generateId(), columnType: 'Following', title: 'ホーム', width: 'widest', pubkey },
|
||||
{ id: generateId(), columnType: 'Notification', title: '通知', width: 'medium', pubkey },
|
||||
{ id: generateId(), columnType: 'Posts', title: '自分の投稿', width: 'medium', pubkey },
|
||||
{
|
||||
id: generateId(),
|
||||
columnType: 'Reactions',
|
||||
title: '自分のリアクション',
|
||||
width: 'medium',
|
||||
pubkey,
|
||||
},
|
||||
// { columnType: 'Global', relays: [] },
|
||||
];
|
||||
|
||||
@@ -143,6 +162,8 @@ const useConfig = (): UseConfig => {
|
||||
removeMutedPubkey,
|
||||
addMutedKeyword,
|
||||
removeMutedKeyword,
|
||||
addColumn,
|
||||
removeColumn,
|
||||
isPubkeyMuted,
|
||||
shouldMuteEvent,
|
||||
initializeColumns,
|
||||
|
||||
@@ -57,13 +57,25 @@ const Hello: Component = () => {
|
||||
未実装の機能やバグがあることを承知の上でご利用ください。
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-8 shadow-md">
|
||||
<div class="rounded-md p-8 shadow-md">
|
||||
<Switch>
|
||||
<Match when={signerStatus() === 'checking'}>
|
||||
拡張機能のインストール状況を確認中です...
|
||||
</Match>
|
||||
<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">
|
||||
利用にはNIP-07に対応した拡張機能が必要です。
|
||||
<br />
|
||||
@@ -77,18 +89,6 @@ const Hello: Component = () => {
|
||||
</a>
|
||||
に拡張機能をインストールしてください。
|
||||
</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 when={signerStatus() === 'available'}>
|
||||
<button
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import {
|
||||
createSignal,
|
||||
createEffect,
|
||||
onMount,
|
||||
onCleanup,
|
||||
Show,
|
||||
Switch,
|
||||
Match,
|
||||
type Component,
|
||||
} from 'solid-js';
|
||||
import { createEffect, onMount, Show, Switch, Match, type Component } from 'solid-js';
|
||||
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { createVirtualizer } from '@tanstack/solid-virtual';
|
||||
import uniq from 'lodash/uniq';
|
||||
|
||||
import Column from '@/components/Column';
|
||||
import ProfileDisplay from '@/components/modal/ProfileDisplay';
|
||||
import ProfileEdit from '@/components/modal/ProfileEdit';
|
||||
import Notification from '@/components/Notification';
|
||||
import ProfileDisplay from '@/components/ProfileDisplay';
|
||||
import ProfileEdit from '@/components/ProfileEdit';
|
||||
import SideBar from '@/components/SideBar';
|
||||
import Timeline from '@/components/Timeline';
|
||||
import useConfig from '@/core/useConfig';
|
||||
@@ -65,8 +56,8 @@ const Home: Component = () => {
|
||||
{
|
||||
kinds: [1, 6],
|
||||
authors,
|
||||
limit: 25,
|
||||
since: epoch() - 12 * 60 * 60,
|
||||
limit: 10,
|
||||
since: epoch() - 4 * 60 * 60,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -79,7 +70,7 @@ const Home: Component = () => {
|
||||
{
|
||||
kinds: [1, 6],
|
||||
authors: [pubkeyNonNull],
|
||||
limit: 25,
|
||||
limit: 10,
|
||||
},
|
||||
],
|
||||
})),
|
||||
@@ -92,7 +83,7 @@ const Home: Component = () => {
|
||||
{
|
||||
kinds: [7],
|
||||
authors: [pubkeyNonNull],
|
||||
limit: 25,
|
||||
limit: 10,
|
||||
},
|
||||
],
|
||||
})),
|
||||
@@ -105,7 +96,7 @@ const Home: Component = () => {
|
||||
{
|
||||
kinds: [1, 6, 7],
|
||||
'#p': [pubkeyNonNull],
|
||||
limit: 25,
|
||||
limit: 10,
|
||||
},
|
||||
],
|
||||
})),
|
||||
@@ -121,7 +112,7 @@ const Home: Component = () => {
|
||||
{
|
||||
kinds: [1, 6],
|
||||
limit: 25,
|
||||
since: epoch() - 12 * 60 * 60,
|
||||
since: epoch() - 4 * 60 * 60,
|
||||
},
|
||||
],
|
||||
clientEventFilter: (ev) => {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { Component } from 'solid-js';
|
||||
|
||||
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;
|
||||
|
||||
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