mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-18 06:24:25 +01:00
feat: profile update
This commit is contained in:
@@ -48,6 +48,8 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// disabled because jsx-a11y doesn't recognize <label for=...>
|
||||||
|
'jsx-a11y/label-has-associated-control': ['off'],
|
||||||
'prettier/prettier': 'error',
|
'prettier/prettier': 'error',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const RelayConfig = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
<form class="flex gap-2" onSubmit={handleClickAddRelay}>
|
<form class="flex gap-2" onSubmit={handleClickAddRelay}>
|
||||||
<input
|
<input
|
||||||
class="flex-1"
|
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
|
||||||
type="text"
|
type="text"
|
||||||
name="relayUrl"
|
name="relayUrl"
|
||||||
value={relayUrlInput()}
|
value={relayUrlInput()}
|
||||||
@@ -182,7 +182,7 @@ const MuteConfig = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
<form class="flex gap-2" onSubmit={handleClickAddKeyword}>
|
<form class="flex gap-2" onSubmit={handleClickAddKeyword}>
|
||||||
<input
|
<input
|
||||||
class="flex-1"
|
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
|
||||||
type="text"
|
type="text"
|
||||||
name="keyword"
|
name="keyword"
|
||||||
value={keywordInput()}
|
value={keywordInput()}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createSignal, onCleanup, createEffect, For, type Component, type JSX }
|
|||||||
|
|
||||||
export type MenuItem = {
|
export type MenuItem = {
|
||||||
content: () => JSX.Element;
|
content: () => JSX.Element;
|
||||||
|
when?: () => boolean;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -10,13 +11,6 @@ export type ContextMenuProps = {
|
|||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MenuDisplayProps = {
|
|
||||||
menuRef: (elem: HTMLUListElement) => void;
|
|
||||||
menu: MenuItem[];
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MenuItemDisplayProps = {
|
export type MenuItemDisplayProps = {
|
||||||
item: MenuItem;
|
item: MenuItem;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -30,27 +24,13 @@ const MenuItemDisplay: Component<MenuItemDisplayProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li class="border-b hover:bg-stone-200">
|
<li class="border-b hover:bg-stone-200">
|
||||||
<button class="px-4 py-1" onClick={handleClick}>
|
<button class="w-full px-4 py-1 text-start" onClick={handleClick}>
|
||||||
{props.item.content()}
|
{props.item.content()}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MenuDisplay: Component<MenuDisplayProps> = (props) => {
|
|
||||||
return (
|
|
||||||
<ul
|
|
||||||
ref={props.menuRef}
|
|
||||||
class="absolute z-20 min-w-[48px] rounded border bg-white shadow-md"
|
|
||||||
classList={{ hidden: !props.isOpen, block: props.isOpen }}
|
|
||||||
>
|
|
||||||
<For each={props.menu}>
|
|
||||||
{(item) => <MenuItemDisplay item={item} onClose={props.onClose} />}
|
|
||||||
</For>
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ContextMenu: Component<ContextMenuProps> = (props) => {
|
const ContextMenu: Component<ContextMenuProps> = (props) => {
|
||||||
let menuRef: HTMLUListElement | undefined;
|
let menuRef: HTMLUListElement | undefined;
|
||||||
|
|
||||||
@@ -69,15 +49,18 @@ const ContextMenu: Component<ContextMenuProps> = (props) => {
|
|||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const open = () => setIsOpen(true);
|
||||||
|
const close = () => setIsOpen(false);
|
||||||
|
|
||||||
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
|
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
|
||||||
if (menuRef == null) return;
|
if (menuRef == null) return;
|
||||||
|
|
||||||
const buttonRect = ev.currentTarget.getBoundingClientRect();
|
const buttonRect = ev.currentTarget.getBoundingClientRect();
|
||||||
const menuRect = menuRef.getBoundingClientRect();
|
// const menuRect = menuRef.getBoundingClientRect();
|
||||||
menuRef.style.left = `${buttonRect.left - buttonRect.width}px`;
|
menuRef.style.left = `${buttonRect.left - buttonRect.width}px`;
|
||||||
menuRef.style.top = `${buttonRect.top + buttonRect.height}px`;
|
menuRef.style.top = `${buttonRect.top + buttonRect.height}px`;
|
||||||
|
|
||||||
setIsOpen(true);
|
open();
|
||||||
};
|
};
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -93,14 +76,15 @@ const ContextMenu: Component<ContextMenuProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button onClick={handleClick}>{props.children}</button>
|
<button onClick={handleClick}>{props.children}</button>
|
||||||
<MenuDisplay
|
<ul
|
||||||
menuRef={(e) => {
|
ref={menuRef}
|
||||||
menuRef = e;
|
class="absolute z-20 min-w-[48px] rounded border bg-white shadow-md"
|
||||||
}}
|
classList={{ hidden: !isOpen(), block: isOpen() }}
|
||||||
menu={props.menu}
|
>
|
||||||
isOpen={isOpen()}
|
<For each={props.menu.filter((e) => e.when == null || e.when())}>
|
||||||
onClose={() => setIsOpen(false)}
|
{(item: MenuItem) => <MenuItemDisplay item={item} onClose={close} />}
|
||||||
/>
|
</For>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
props.textAreaRef?.(el);
|
props.textAreaRef?.(el);
|
||||||
}}
|
}}
|
||||||
name="text"
|
name="text"
|
||||||
class="min-h-[40px] rounded border-none"
|
class="min-h-[40px] rounded-md border-none focus:ring-rose-300"
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder={placeholder(mode())}
|
placeholder={placeholder(mode())}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import Timeline from '@/components/Timeline';
|
|||||||
import Copy from '@/components/utils/Copy';
|
import Copy from '@/components/utils/Copy';
|
||||||
import SafeLink from '@/components/utils/SafeLink';
|
import SafeLink from '@/components/utils/SafeLink';
|
||||||
import useConfig from '@/core/useConfig';
|
import useConfig from '@/core/useConfig';
|
||||||
|
import useModalState from '@/hooks/useModalState';
|
||||||
import useCommands from '@/nostr/useCommands';
|
import useCommands from '@/nostr/useCommands';
|
||||||
import useFollowers from '@/nostr/useFollowers';
|
import useFollowers from '@/nostr/useFollowers';
|
||||||
import useFollowings from '@/nostr/useFollowings';
|
import useFollowings from '@/nostr/useFollowings';
|
||||||
@@ -45,6 +46,7 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
|
|||||||
const { config, addMutedPubkey, removeMutedPubkey, isPubkeyMuted } = useConfig();
|
const { config, addMutedPubkey, removeMutedPubkey, isPubkeyMuted } = useConfig();
|
||||||
const commands = useCommands();
|
const commands = useCommands();
|
||||||
const myPubkey = usePubkey();
|
const myPubkey = usePubkey();
|
||||||
|
const { showProfileEdit } = useModalState();
|
||||||
|
|
||||||
const npub = createMemo(() => npubEncodeFallback(props.pubkey));
|
const npub = createMemo(() => npubEncodeFallback(props.pubkey));
|
||||||
|
|
||||||
@@ -162,6 +164,17 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
when: () => props.pubkey === myPubkey(),
|
||||||
|
content: () => (!following() ? '自分をフォロー' : '自分をフォロー解除'),
|
||||||
|
onSelect: () => {
|
||||||
|
if (!following()) {
|
||||||
|
follow();
|
||||||
|
} else {
|
||||||
|
unfollow();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { events } = useSubscription(() => ({
|
const { events } = useSubscription(() => ({
|
||||||
@@ -215,6 +228,15 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
|
|||||||
<div class="flex shrink-0 flex-col items-center gap-1">
|
<div class="flex shrink-0 flex-col items-center gap-1">
|
||||||
<div class="flex flex-row justify-start gap-1">
|
<div class="flex flex-row justify-start gap-1">
|
||||||
<Switch>
|
<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}>
|
<Match when={myFollowingQuery.isLoading || myFollowingQuery.isFetching}>
|
||||||
<span class="rounded-full border border-primary px-4 py-2 text-primary sm:text-base">
|
<span class="rounded-full border border-primary px-4 py-2 text-primary sm:text-base">
|
||||||
読み込み中
|
読み込み中
|
||||||
@@ -225,13 +247,6 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
|
|||||||
更新中
|
更新中
|
||||||
</span>
|
</span>
|
||||||
</Match>
|
</Match>
|
||||||
{/*
|
|
||||||
<Match when={props.pubkey === myPubkey()}>
|
|
||||||
<span class="rounded-full border border-primary px-4 py-2 text-primary">
|
|
||||||
あなたです
|
|
||||||
</span>
|
|
||||||
</Match>
|
|
||||||
*/}
|
|
||||||
<Match when={following()}>
|
<Match when={following()}>
|
||||||
<button
|
<button
|
||||||
class="rounded-full border border-primary bg-primary px-4 py-2
|
class="rounded-full border border-primary bg-primary px-4 py-2
|
||||||
|
|||||||
271
src/components/ProfileEdit.tsx
Normal file
271
src/components/ProfileEdit.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
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 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 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 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']);
|
||||||
|
|
||||||
|
const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const p = pubkey();
|
||||||
|
if (p == null) return;
|
||||||
|
|
||||||
|
const newProfile: Profile = {
|
||||||
|
picture: picture(),
|
||||||
|
banner: banner(),
|
||||||
|
name: name(),
|
||||||
|
display_name: displayName(),
|
||||||
|
about: about(),
|
||||||
|
website: website(),
|
||||||
|
nip05: nip05(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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-16 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="mt-[-64px] ml-4 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"
|
||||||
|
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>
|
||||||
|
<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="text-sm">{value}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded bg-rose-300 p-2 font-bold text-white hover:bg-rose-400"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default ProfileEdit;
|
||||||
@@ -101,10 +101,10 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = createMutation({
|
const deleteMutation = createMutation({
|
||||||
mutationKey: ['delete', event().id],
|
mutationKey: ['deleteEvent', event().id],
|
||||||
mutationFn: (...params: Parameters<typeof commands.delete>) =>
|
mutationFn: (...params: Parameters<typeof commands.deleteEvent>) =>
|
||||||
commands
|
commands
|
||||||
.delete(...params)
|
.deleteEvent(...params)
|
||||||
.then((promeses) => Promise.allSettled(promeses.map(timeout(10000)))),
|
.then((promeses) => Promise.allSettled(promeses.map(timeout(10000)))),
|
||||||
onSuccess: (results) => {
|
onSuccess: (results) => {
|
||||||
// TODO タイムラインから削除する
|
// TODO タイムラインから削除する
|
||||||
@@ -113,7 +113,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
|||||||
if (succeeded === results.length) {
|
if (succeeded === results.length) {
|
||||||
window.alert('削除しました(画面の反映にはリロード)');
|
window.alert('削除しました(画面の反映にはリロード)');
|
||||||
} else if (succeeded > 0) {
|
} else if (succeeded > 0) {
|
||||||
window.alert('一部のリレーで削除に失敗しました');
|
window.alert(`${failed}個のリレーで削除に失敗しました`);
|
||||||
} else {
|
} else {
|
||||||
window.alert('すべてのリレーで削除に失敗しました');
|
window.alert('すべてのリレーで削除に失敗しました');
|
||||||
}
|
}
|
||||||
@@ -123,29 +123,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const myPostMenu = (): MenuItem[] => {
|
|
||||||
if (event().pubkey !== pubkey()) return [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
content: () => '削除',
|
|
||||||
onSelect: () => {
|
|
||||||
const p = pubkey();
|
|
||||||
if (p == null) return;
|
|
||||||
|
|
||||||
if (!window.confirm('本当に削除しますか?')) return;
|
|
||||||
deleteMutation.mutate({
|
|
||||||
relayUrls: config().relayUrls,
|
|
||||||
pubkey: p,
|
|
||||||
eventId: event().id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const menu: MenuItem[] = [
|
const menu: MenuItem[] = [
|
||||||
...myPostMenu(),
|
|
||||||
{
|
{
|
||||||
content: () => 'IDをコピー',
|
content: () => 'IDをコピー',
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
@@ -160,6 +138,21 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
|||||||
.catch((err) => window.alert(err));
|
.catch((err) => window.alert(err));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
when: () => event().pubkey === pubkey(),
|
||||||
|
content: () => <span class="text-red-500">削除</span>,
|
||||||
|
onSelect: () => {
|
||||||
|
const p = pubkey();
|
||||||
|
if (p == null) return;
|
||||||
|
|
||||||
|
if (!window.confirm('本当に削除しますか?')) return;
|
||||||
|
deleteMutation.mutate({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
pubkey: p,
|
||||||
|
eventId: event().id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const isReactedByMe = createMemo(() => {
|
const isReactedByMe = createMemo(() => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createSignal, type Signal } from 'solid-js';
|
import { createSignal } from 'solid-js';
|
||||||
|
|
||||||
type ModalState =
|
type ModalState =
|
||||||
| { type: 'Profile'; pubkey: string }
|
| { type: 'Profile'; pubkey: string }
|
||||||
|
| { type: 'ProfileEdit' }
|
||||||
| { type: 'UserTimeline'; pubkey: string }
|
| { type: 'UserTimeline'; pubkey: string }
|
||||||
| { type: 'Closed' };
|
| { type: 'Closed' };
|
||||||
|
|
||||||
@@ -11,10 +12,13 @@ const useModalState = () => {
|
|||||||
const showProfile = (pubkey: string) => {
|
const showProfile = (pubkey: string) => {
|
||||||
setModalState({ type: 'Profile', pubkey });
|
setModalState({ type: 'Profile', pubkey });
|
||||||
};
|
};
|
||||||
|
const showProfileEdit = () => {
|
||||||
|
setModalState({ type: 'ProfileEdit' });
|
||||||
|
};
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
setModalState({ type: 'Closed' });
|
setModalState({ type: 'Closed' });
|
||||||
};
|
};
|
||||||
return { modalState, setModalState, showProfile, closeModal };
|
return { modalState, setModalState, showProfile, showProfileEdit, closeModal };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useModalState;
|
export default useModalState;
|
||||||
|
|||||||
@@ -51,12 +51,15 @@ export type NonStandardProfile = {
|
|||||||
|
|
||||||
export type Profile = StandardProfile & NonStandardProfile;
|
export type Profile = StandardProfile & NonStandardProfile;
|
||||||
|
|
||||||
|
export type ProfileWithOtherProperties = Profile & Record<string, any>;
|
||||||
|
|
||||||
export type UseProfileProps = {
|
export type UseProfileProps = {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseProfile = {
|
type UseProfile = {
|
||||||
profile: () => Profile | null;
|
profile: () => ProfileWithOtherProperties | null;
|
||||||
|
invalidateProfile: () => Promise<void>;
|
||||||
query: CreateQueryResult<NostrEvent | null>;
|
query: CreateQueryResult<NostrEvent | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,11 +277,12 @@ const pickLatestEvent = (events: NostrEvent[]): NostrEvent | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => {
|
export const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => {
|
||||||
const props = createMemo(propsProvider);
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const props = createMemo(propsProvider);
|
||||||
|
const genQueryKey = createMemo(() => ['useProfile', props()] as const);
|
||||||
|
|
||||||
const query = createQuery(
|
const query = createQuery(
|
||||||
() => ['useProfile', props()] as const,
|
genQueryKey,
|
||||||
({ queryKey, signal }) => {
|
({ queryKey, signal }) => {
|
||||||
const [, currentProps] = queryKey;
|
const [, currentProps] = queryKey;
|
||||||
if (currentProps == null) return Promise.resolve(null);
|
if (currentProps == null) return Promise.resolve(null);
|
||||||
@@ -324,7 +328,9 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { profile, query };
|
const invalidateProfile = (): Promise<void> => queryClient.invalidateQueries(genQueryKey());
|
||||||
|
|
||||||
|
return { profile, invalidateProfile, query };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTextNote = (propsProvider: () => UseTextNoteProps | null): UseTextNote => {
|
export const useTextNote = (propsProvider: () => UseTextNoteProps | null): UseTextNote => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getEventHash, Kind, type UnsignedEvent, type Pub } from 'nostr-tools';
|
import { getEventHash, Kind, type UnsignedEvent, type Pub } from 'nostr-tools';
|
||||||
|
|
||||||
// import '@/types/nostr.d';
|
// import '@/types/nostr.d';
|
||||||
|
import { Profile } from '@/nostr/useBatchedEvents';
|
||||||
import usePool from '@/nostr/usePool';
|
import usePool from '@/nostr/usePool';
|
||||||
import epoch from '@/utils/epoch';
|
import epoch from '@/utils/epoch';
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ const useCommands = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// NIP-01
|
// NIP-01
|
||||||
const publishTextNote = (params: PublishTextNoteParams): Promise<Promise<void>[]> => {
|
const publishTextNote = async (params: PublishTextNoteParams): Promise<Promise<void>[]> => {
|
||||||
const { relayUrls, pubkey, content } = params;
|
const { relayUrls, pubkey, content } = params;
|
||||||
const tags = buildTags(params);
|
const tags = buildTags(params);
|
||||||
|
|
||||||
@@ -117,10 +118,8 @@ const useCommands = () => {
|
|||||||
return publishEvent(relayUrls, preSignedEvent);
|
return publishEvent(relayUrls, preSignedEvent);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
|
||||||
publishTextNote,
|
|
||||||
// NIP-25
|
// NIP-25
|
||||||
publishReaction({
|
const publishReaction = async ({
|
||||||
relayUrls,
|
relayUrls,
|
||||||
pubkey,
|
pubkey,
|
||||||
eventId,
|
eventId,
|
||||||
@@ -132,7 +131,7 @@ const useCommands = () => {
|
|||||||
eventId: string;
|
eventId: string;
|
||||||
content: string;
|
content: string;
|
||||||
notifyPubkey: string;
|
notifyPubkey: string;
|
||||||
}): Promise<Promise<void>[]> {
|
}): Promise<Promise<void>[]> => {
|
||||||
// TODO ensure that content is + or - or emoji.
|
// TODO ensure that content is + or - or emoji.
|
||||||
const preSignedEvent: UnsignedEvent = {
|
const preSignedEvent: UnsignedEvent = {
|
||||||
kind: 7,
|
kind: 7,
|
||||||
@@ -145,9 +144,10 @@ const useCommands = () => {
|
|||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
return publishEvent(relayUrls, preSignedEvent);
|
||||||
},
|
};
|
||||||
|
|
||||||
// NIP-18
|
// NIP-18
|
||||||
async publishRepost({
|
const publishRepost = async ({
|
||||||
relayUrls,
|
relayUrls,
|
||||||
pubkey,
|
pubkey,
|
||||||
eventId,
|
eventId,
|
||||||
@@ -157,7 +157,7 @@ const useCommands = () => {
|
|||||||
pubkey: string;
|
pubkey: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
notifyPubkey: string;
|
notifyPubkey: string;
|
||||||
}): Promise<Promise<void>[]> {
|
}): Promise<Promise<void>[]> => {
|
||||||
const preSignedEvent: UnsignedEvent = {
|
const preSignedEvent: UnsignedEvent = {
|
||||||
kind: 6 as Kind,
|
kind: 6 as Kind,
|
||||||
pubkey,
|
pubkey,
|
||||||
@@ -169,8 +169,34 @@ const useCommands = () => {
|
|||||||
content: '',
|
content: '',
|
||||||
};
|
};
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
return publishEvent(relayUrls, preSignedEvent);
|
||||||
},
|
};
|
||||||
updateContacts({
|
|
||||||
|
const updateProfile = async ({
|
||||||
|
relayUrls,
|
||||||
|
pubkey,
|
||||||
|
profile,
|
||||||
|
otherProperties,
|
||||||
|
}: {
|
||||||
|
relayUrls: string[];
|
||||||
|
pubkey: string;
|
||||||
|
profile: Profile;
|
||||||
|
otherProperties: Record<string, any>;
|
||||||
|
}): Promise<Promise<void>[]> => {
|
||||||
|
const content = {
|
||||||
|
...profile,
|
||||||
|
...otherProperties,
|
||||||
|
};
|
||||||
|
const preSignedEvent: UnsignedEvent = {
|
||||||
|
kind: Kind.Metadata,
|
||||||
|
pubkey,
|
||||||
|
created_at: epoch(),
|
||||||
|
tags: [],
|
||||||
|
content: JSON.stringify(content),
|
||||||
|
};
|
||||||
|
return publishEvent(relayUrls, preSignedEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateContacts = async ({
|
||||||
relayUrls,
|
relayUrls,
|
||||||
pubkey,
|
pubkey,
|
||||||
followingPubkeys,
|
followingPubkeys,
|
||||||
@@ -180,7 +206,7 @@ const useCommands = () => {
|
|||||||
pubkey: string;
|
pubkey: string;
|
||||||
followingPubkeys: string[];
|
followingPubkeys: string[];
|
||||||
content: string;
|
content: string;
|
||||||
}): Promise<Promise<void>[]> {
|
}): Promise<Promise<void>[]> => {
|
||||||
const pTags = followingPubkeys.map((key) => ['p', key]);
|
const pTags = followingPubkeys.map((key) => ['p', key]);
|
||||||
|
|
||||||
const preSignedEvent: UnsignedEvent = {
|
const preSignedEvent: UnsignedEvent = {
|
||||||
@@ -191,8 +217,9 @@ const useCommands = () => {
|
|||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
return publishEvent(relayUrls, preSignedEvent);
|
||||||
},
|
};
|
||||||
async delete({
|
|
||||||
|
const deleteEvent = async ({
|
||||||
relayUrls,
|
relayUrls,
|
||||||
pubkey,
|
pubkey,
|
||||||
eventId,
|
eventId,
|
||||||
@@ -200,7 +227,7 @@ const useCommands = () => {
|
|||||||
relayUrls: string[];
|
relayUrls: string[];
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
}): Promise<Promise<void>[]> {
|
}): Promise<Promise<void>[]> => {
|
||||||
const preSignedEvent: UnsignedEvent = {
|
const preSignedEvent: UnsignedEvent = {
|
||||||
kind: Kind.EventDeletion,
|
kind: Kind.EventDeletion,
|
||||||
pubkey,
|
pubkey,
|
||||||
@@ -209,7 +236,15 @@ const useCommands = () => {
|
|||||||
content: '',
|
content: '',
|
||||||
};
|
};
|
||||||
return publishEvent(relayUrls, preSignedEvent);
|
return publishEvent(relayUrls, preSignedEvent);
|
||||||
},
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
publishTextNote,
|
||||||
|
publishReaction,
|
||||||
|
publishRepost,
|
||||||
|
updateProfile,
|
||||||
|
updateContacts,
|
||||||
|
deleteEvent,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import uniq from 'lodash/uniq';
|
|||||||
import Column from '@/components/Column';
|
import Column from '@/components/Column';
|
||||||
import Notification from '@/components/Notification';
|
import Notification from '@/components/Notification';
|
||||||
import ProfileDisplay from '@/components/ProfileDisplay';
|
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';
|
||||||
@@ -33,7 +34,7 @@ const Home: Component = () => {
|
|||||||
useMountShortcutKeys();
|
useMountShortcutKeys();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { persistStatus } = usePersistStatus();
|
const { persistStatus } = usePersistStatus();
|
||||||
const { modalState, closeModal } = useModalState();
|
const { modalState, showProfile, closeModal } = useModalState();
|
||||||
|
|
||||||
const pool = usePool();
|
const pool = usePool();
|
||||||
const { config } = useConfig();
|
const { config } = useConfig();
|
||||||
@@ -162,6 +163,15 @@ const Home: Component = () => {
|
|||||||
<ProfileDisplay pubkey={pubkeyNonNull} onClose={closeModal} />
|
<ProfileDisplay pubkey={pubkeyNonNull} onClose={closeModal} />
|
||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={state.type === 'ProfileEdit'} keyed>
|
||||||
|
<ProfileEdit
|
||||||
|
onClose={() =>
|
||||||
|
ensureNonNull([pubkey()])(([pubkeyNonNull]) => {
|
||||||
|
showProfile(pubkeyNonNull);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
Reference in New Issue
Block a user