mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-18 06:24:25 +01:00
update
This commit is contained in:
@@ -5,7 +5,7 @@ type ColumnItemProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ColumnItem: Component<ColumnItemProps> = (props) => {
|
const ColumnItem: Component<ColumnItemProps> = (props) => {
|
||||||
return <div class="overflow-hidden border-b p-1">{props.children}</div>;
|
return <div class="shrink-0 overflow-hidden border-b p-1">{props.children}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ColumnItem;
|
export default ColumnItem;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// NIP-18 (DEPRECATED)
|
// NIP-18 (DEPRECATED)
|
||||||
import { Show, Switch, Match, type Component } from 'solid-js';
|
import { Show, Switch, Match, type Component, createMemo } from 'solid-js';
|
||||||
import { Event as NostrEvent } from 'nostr-tools';
|
import { Event as NostrEvent } from 'nostr-tools';
|
||||||
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
|
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
|
||||||
|
|
||||||
@@ -7,47 +7,38 @@ import useConfig from '@/nostr/useConfig';
|
|||||||
import useEvent from '@/nostr/useEvent';
|
import useEvent from '@/nostr/useEvent';
|
||||||
import useProfile from '@/nostr/useProfile';
|
import useProfile from '@/nostr/useProfile';
|
||||||
|
|
||||||
|
import ColumnItem from '@/components/ColumnItem';
|
||||||
import UserDisplayName from '@/components/UserDisplayName';
|
import UserDisplayName from '@/components/UserDisplayName';
|
||||||
import TextNote from '@/components/TextNote';
|
import TextNote from '@/components/TextNote';
|
||||||
|
import eventWrapper from '@/core/event';
|
||||||
|
import useFormatDate from '@/hooks/useFormatDate';
|
||||||
|
import TextNoteDisplayById from './textNote/TextNoteDisplayById';
|
||||||
|
|
||||||
export type DeprecatedRepostProps = {
|
export type DeprecatedRepostProps = {
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DeprecatedRepost: Component<DeprecatedRepostProps> = (props) => {
|
const DeprecatedRepost: Component<DeprecatedRepostProps> = (props) => {
|
||||||
const { config } = useConfig();
|
const formatDate = useFormatDate();
|
||||||
const pubkey = () => props.event.pubkey;
|
const repostedId = () => props.event.tags.find(([tagName]) => tagName === 'e')?.[1];
|
||||||
const eventId = () => props.event.tags.find(([tagName]) => tagName === 'e')?.[1];
|
const event = createMemo(() => eventWrapper(props.event));
|
||||||
|
|
||||||
const { profile } = useProfile(() => ({ relayUrls: config().relayUrls, pubkey: pubkey() }));
|
|
||||||
const { event, query: eventQuery } = useEvent(() => ({
|
|
||||||
relayUrls: config().relayUrls,
|
|
||||||
eventId: eventId(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<ColumnItem>
|
||||||
<div class="flex content-center px-1 text-xs">
|
<div class="flex content-center text-xs">
|
||||||
<div class="h-5 w-5 shrink-0 pr-1 text-green-500" aria-hidden="true">
|
<div class="h-5 w-5 shrink-0 pr-1 text-green-500" aria-hidden="true">
|
||||||
<ArrowPathRoundedSquare />
|
<ArrowPathRoundedSquare />
|
||||||
</div>
|
</div>
|
||||||
<div class="truncate break-all">
|
<div class="flex-1 truncate break-all">
|
||||||
<UserDisplayName pubkey={props.event.pubkey} />
|
<UserDisplayName pubkey={props.event.pubkey} />
|
||||||
{' Reposted'}
|
{' がリポスト'}
|
||||||
</div>
|
</div>
|
||||||
|
<div>{formatDate(event().createdAtAsDate())}</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch fallback="failed to load">
|
<div class="pt-1">
|
||||||
<Match when={event() != null}>
|
<TextNoteDisplayById eventId={repostedId()} />
|
||||||
<TextNote event={event()} />
|
</div>
|
||||||
</Match>
|
</ColumnItem>
|
||||||
<Match when={eventQuery.isLoading}>
|
|
||||||
<div class="truncate">
|
|
||||||
{'loading '}
|
|
||||||
<span>{eventId()}</span>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,11 @@ const SideBar: Component = () => {
|
|||||||
>
|
>
|
||||||
<PencilSquare />
|
<PencilSquare />
|
||||||
</button>
|
</button>
|
||||||
|
{/*
|
||||||
<button class="h-9 w-9 rounded-full border border-primary p-2 text-2xl font-bold text-primary">
|
<button class="h-9 w-9 rounded-full border border-primary p-2 text-2xl font-bold text-primary">
|
||||||
<MagnifyingGlass />
|
<MagnifyingGlass />
|
||||||
</button>
|
</button>
|
||||||
|
*/}
|
||||||
{/* <div>column 1</div> */}
|
{/* <div>column 1</div> */}
|
||||||
{/* <div>column 2</div> */}
|
{/* <div>column 2</div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,208 +1,18 @@
|
|||||||
import { Show, For, createSignal, createMemo, type JSX, type Component } from 'solid-js';
|
import { type Component } from 'solid-js';
|
||||||
import type { Event as NostrEvent } from 'nostr-tools';
|
import type { Event as NostrEvent } from 'nostr-tools';
|
||||||
import uniq from 'lodash/uniq';
|
|
||||||
|
|
||||||
import HeartOutlined from 'heroicons/24/outline/heart.svg';
|
|
||||||
import HeartSolid from 'heroicons/24/solid/heart.svg';
|
|
||||||
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
|
|
||||||
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
|
|
||||||
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
|
|
||||||
|
|
||||||
import useProfile from '@/nostr/useProfile';
|
|
||||||
import useConfig from '@/nostr/useConfig';
|
|
||||||
import usePubkey from '@/nostr/usePubkey';
|
|
||||||
import useCommands from '@/nostr/useCommands';
|
|
||||||
import useReactions from '@/nostr/useReactions';
|
|
||||||
import useDeprecatedReposts from '@/nostr/useDeprecatedReposts';
|
|
||||||
import useDatePulser from '@/hooks/useDatePulser';
|
|
||||||
import { formatRelative } from '@/utils/formatDate';
|
|
||||||
import ColumnItem from '@/components/ColumnItem';
|
import ColumnItem from '@/components/ColumnItem';
|
||||||
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
|
import TextNoteDisplay from './textNote/TextNoteDisplay';
|
||||||
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
|
|
||||||
import ReplyPostForm from '@/components/ReplyPostForm';
|
|
||||||
|
|
||||||
export type TextNoteProps = {
|
export type TextNoteProps = {
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
//
|
|
||||||
// displayinlineContent: boolean = true;
|
|
||||||
// リアクションやリポスト等の
|
|
||||||
// actions: boolean = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const TextNote: Component<TextNoteProps> = (props) => {
|
const TextNote: Component<TextNoteProps> = (props) => {
|
||||||
const currentDate = useDatePulser();
|
|
||||||
const { config } = useConfig();
|
|
||||||
const commands = useCommands();
|
|
||||||
const pubkey = usePubkey();
|
|
||||||
const [showReplyForm, setShowReplyForm] = createSignal(false);
|
|
||||||
|
|
||||||
const { profile: author } = useProfile(() => ({
|
|
||||||
relayUrls: config().relayUrls,
|
|
||||||
pubkey: props.event.pubkey,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { reactions, isReactedBy, invalidateReactions } = useReactions(() => ({
|
|
||||||
relayUrls: config().relayUrls,
|
|
||||||
eventId: props.event.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { reposts, isRepostedBy, invalidateDeprecatedReposts } = useDeprecatedReposts(() => ({
|
|
||||||
relayUrls: config().relayUrls,
|
|
||||||
eventId: props.event.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const isReactedByMe = createMemo(() => isReactedBy(pubkey()));
|
|
||||||
const isRepostedByMe = createMemo(() => isRepostedBy(pubkey()));
|
|
||||||
|
|
||||||
const replyingToPubKeys = createMemo(() =>
|
|
||||||
uniq(props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1])),
|
|
||||||
);
|
|
||||||
const createdAt = () => formatRelative(new Date(props.event.created_at * 1000), currentDate());
|
|
||||||
|
|
||||||
const handleReplyPost = ({ content }: { content: string }) => {
|
|
||||||
commands
|
|
||||||
.publishTextNote({
|
|
||||||
relayUrls: config().relayUrls,
|
|
||||||
pubkey: pubkey(),
|
|
||||||
content,
|
|
||||||
notifyPubkeys: [props.event.pubkey, ...replyingToPubKeys()],
|
|
||||||
replyEventId: props.event.id,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
setShowReplyForm(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
commands
|
|
||||||
.publishDeprecatedRepost({
|
|
||||||
relayUrls: config().relayUrls,
|
|
||||||
pubkey: pubkey(),
|
|
||||||
eventId: props.event.id,
|
|
||||||
notifyPubkey: props.event.pubkey,
|
|
||||||
})
|
|
||||||
.then(() => invalidateDeprecatedReposts());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
|
|
||||||
if (isReactedByMe()) {
|
|
||||||
// TODO remove reaction
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
commands
|
|
||||||
.publishReaction({
|
|
||||||
relayUrls: config().relayUrls,
|
|
||||||
pubkey: pubkey(),
|
|
||||||
content: '+',
|
|
||||||
eventId: props.event.id,
|
|
||||||
notifyPubkey: props.event.pubkey,
|
|
||||||
})
|
|
||||||
.then(() => invalidateReactions());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="textnote">
|
<ColumnItem>
|
||||||
<ColumnItem>
|
<TextNoteDisplay event={props.event} />
|
||||||
<div class="flex flex-col">
|
</ColumnItem>
|
||||||
<div class="flex w-full gap-1">
|
|
||||||
<div class="author-icon h-10 w-10 shrink-0">
|
|
||||||
<Show when={author()?.picture}>
|
|
||||||
{/* TODO 画像は脆弱性回避のためにimgじゃない方法で読み込みたい */}
|
|
||||||
<img
|
|
||||||
src={author()?.picture}
|
|
||||||
alt="icon"
|
|
||||||
// TODO autofit
|
|
||||||
class="h-10 w-10 rounded"
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-auto">
|
|
||||||
<div class="flex justify-between gap-1 text-xs">
|
|
||||||
<div class="author flex min-w-0 truncate">
|
|
||||||
{/* TODO link to author */}
|
|
||||||
<Show when={(author()?.display_name?.length ?? 0) > 0}>
|
|
||||||
<div class="author-name truncate pr-1 font-bold">{author()?.display_name}</div>
|
|
||||||
</Show>
|
|
||||||
<div class="author-username truncate text-zinc-600">
|
|
||||||
<Show when={author()?.name} fallback={props.event.pubkey}>
|
|
||||||
@{author()?.name}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="created-at shrink-0">{createdAt()}</div>
|
|
||||||
</div>
|
|
||||||
<Show when={replyingToPubKeys().length > 0}>
|
|
||||||
<div class="text-xs">
|
|
||||||
{'Replying to '}
|
|
||||||
<For each={replyingToPubKeys()}>
|
|
||||||
{(replyToPubkey: string) => (
|
|
||||||
<span class="pr-1 text-blue-500 underline">
|
|
||||||
<GeneralUserMentionDisplay pubkey={replyToPubkey} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="content whitespace-pre-wrap break-all">
|
|
||||||
<TextNoteContentDisplay event={props.event} embedding={true} />
|
|
||||||
</div>
|
|
||||||
<div class="flex w-48 items-center justify-between gap-8 pt-1">
|
|
||||||
<button
|
|
||||||
class="h-4 w-4 text-zinc-400"
|
|
||||||
onClick={() => setShowReplyForm((current) => !current)}
|
|
||||||
>
|
|
||||||
<ChatBubbleLeft />
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1"
|
|
||||||
classList={{
|
|
||||||
'text-zinc-400': !isRepostedByMe(),
|
|
||||||
'text-green-400': isRepostedByMe(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button class="h-4 w-4" onClick={handleRepost}>
|
|
||||||
<ArrowPathRoundedSquare />
|
|
||||||
</button>
|
|
||||||
<Show when={reposts().length > 0}>
|
|
||||||
<div class="text-sm text-zinc-400">{reposts().length}</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1"
|
|
||||||
classList={{
|
|
||||||
'text-zinc-400': !isReactedByMe(),
|
|
||||||
'text-rose-400': isReactedByMe(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button class="h-4 w-4" onClick={handleReaction}>
|
|
||||||
<Show when={isReactedByMe()} fallback={<HeartOutlined />}>
|
|
||||||
<HeartSolid />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
<Show when={reactions().length > 0}>
|
|
||||||
<div class="text-sm text-zinc-400">{reactions().length}</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<button class="h-4 w-4 text-zinc-400">
|
|
||||||
<EllipsisHorizontal />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={showReplyForm()}>
|
|
||||||
<ReplyPostForm
|
|
||||||
replyTo={props.event}
|
|
||||||
onPost={handleReplyPost}
|
|
||||||
onClose={() => setShowReplyForm(false)}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</ColumnItem>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { Switch, Match, type Component, Show } from 'solid-js';
|
|||||||
import { type Event as NostrEvent } from 'nostr-tools';
|
import { type Event as NostrEvent } from 'nostr-tools';
|
||||||
import HeartSolid from 'heroicons/24/solid/heart.svg';
|
import HeartSolid from 'heroicons/24/solid/heart.svg';
|
||||||
|
|
||||||
|
import ColumnItem from '@/components/ColumnItem';
|
||||||
|
import TextNoteDisplay from '@/components/textNote/TextNoteDisplay';
|
||||||
import UserDisplayName from '@/components/UserDisplayName';
|
import UserDisplayName from '@/components/UserDisplayName';
|
||||||
import TextNote from '@/components/TextNote';
|
|
||||||
|
|
||||||
import useConfig from '@/nostr/useConfig';
|
import useConfig from '@/nostr/useConfig';
|
||||||
import useProfile from '@/nostr/useProfile';
|
import useProfile from '@/nostr/useProfile';
|
||||||
@@ -30,9 +31,9 @@ const Reaction: Component<ReactionProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
// if the reacted event is not found, it should be a removed event
|
// if the reacted event is not found, it should be a removed event
|
||||||
<Show when={!isRemoved()}>
|
<Show when={!isRemoved()}>
|
||||||
<div>
|
<ColumnItem>
|
||||||
<div class="notification-icon flex gap-1 px-1 text-sm">
|
<div class="flex gap-1 px-1 text-sm">
|
||||||
<div class="flex place-items-center">
|
<div class="notification-icon flex place-items-center">
|
||||||
<Switch fallback={props.event.content}>
|
<Switch fallback={props.event.content}>
|
||||||
<Match when={props.event.content === '+'}>
|
<Match when={props.event.content === '+'}>
|
||||||
<span class="h-4 w-4 pt-[1px] text-rose-400">
|
<span class="h-4 w-4 pt-[1px] text-rose-400">
|
||||||
@@ -41,7 +42,7 @@ const Reaction: Component<ReactionProps> = (props) => {
|
|||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
<div class="notification-user flex gap-1 pt-1">
|
<div class="notification-user flex gap-1">
|
||||||
<div class="author-icon h-5 w-5 shrink-0">
|
<div class="author-icon h-5 w-5 shrink-0">
|
||||||
<Show when={profile()?.picture != null}>
|
<Show when={profile()?.picture != null}>
|
||||||
<img
|
<img
|
||||||
@@ -56,19 +57,19 @@ const Reaction: Component<ReactionProps> = (props) => {
|
|||||||
<span class="truncate whitespace-pre-wrap break-all font-bold">
|
<span class="truncate whitespace-pre-wrap break-all font-bold">
|
||||||
<UserDisplayName pubkey={props.event.pubkey} />
|
<UserDisplayName pubkey={props.event.pubkey} />
|
||||||
</span>
|
</span>
|
||||||
{' reacted'}
|
{' がリアクション'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="notification-event">
|
<div class="notification-event py-1">
|
||||||
<Show
|
<Show
|
||||||
when={reactedEvent() != null}
|
when={reactedEvent() != null}
|
||||||
fallback={<div class="truncate">loading {eventId()}</div>}
|
fallback={<div class="truncate">loading {eventId()}</div>}
|
||||||
>
|
>
|
||||||
<TextNote event={reactedEvent()} />
|
<TextNoteDisplay event={reactedEvent()} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ColumnItem>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,38 +1,25 @@
|
|||||||
import { Switch, Match } from 'solid-js';
|
import { Show } from 'solid-js';
|
||||||
import TextNote from '@/components/TextNote';
|
import TextNoteDisplayById from '@/components/textNote/TextNoteDisplayById';
|
||||||
|
import { type MentionedEvent } from '@/core/parseTextNote';
|
||||||
import useConfig from '@/nostr/useConfig';
|
|
||||||
import useEvent from '@/nostr/useEvent';
|
|
||||||
import useProfile from '@/nostr/useProfile';
|
|
||||||
|
|
||||||
import type { MentionedEvent } from '@/core/parseTextNote';
|
|
||||||
|
|
||||||
export type MentionedEventDisplayProps = {
|
export type MentionedEventDisplayProps = {
|
||||||
mentionedEvent: MentionedEvent;
|
mentionedEvent: MentionedEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MentionedEventDisplay = (props: MentionedEventDisplayProps) => {
|
const MentionedEventDisplay = (props: MentionedEventDisplayProps) => {
|
||||||
const { config } = useConfig();
|
|
||||||
const { event, query: eventQuery } = useEvent(() => ({
|
|
||||||
relayUrls: config().relayUrls,
|
|
||||||
eventId: props.mentionedEvent.eventId,
|
|
||||||
}));
|
|
||||||
// return <span class="text-blue-500 underline">#{props.mentionedEvent.eventId}</span>;
|
|
||||||
return (
|
return (
|
||||||
<div class="rounded border py-2 px-1">
|
<Show
|
||||||
{/* TODO なんかこのあたりの処理は統一したい */}
|
when={props.mentionedEvent.marker == null || props.mentionedEvent.marker === 'mention'}
|
||||||
<Switch fallback="failed to load">
|
fallback={<span class="text-blue-500 underline">{props.mentionedEvent.eventId}</span>}
|
||||||
<Match when={event() != null}>
|
>
|
||||||
<TextNote event={event()} />
|
<div class="my-1 rounded border p-1">
|
||||||
</Match>
|
<TextNoteDisplayById
|
||||||
<Match when={eventQuery.isLoading}>
|
eventId={props.mentionedEvent.eventId}
|
||||||
<div class="truncate">
|
embedding={false}
|
||||||
{'loading '}
|
actions={false}
|
||||||
<span>{props.mentionedEvent.eventId}</span>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Show>
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For } from 'solid-js';
|
import { For, Switch, Match } from 'solid-js';
|
||||||
import parseTextNote, { type ParsedTextNoteNode } from '@/core/parseTextNote';
|
import parseTextNote, { type ParsedTextNoteNode } from '@/core/parseTextNote';
|
||||||
import type { Event as NostrEvent } from 'nostr-tools';
|
import type { Event as NostrEvent } from 'nostr-tools';
|
||||||
import PlainTextDisplay from '@/components/textNote/PlainTextDisplay';
|
import PlainTextDisplay from '@/components/textNote/PlainTextDisplay';
|
||||||
@@ -21,15 +21,21 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
|||||||
if (item.type === 'MentionedUser') {
|
if (item.type === 'MentionedUser') {
|
||||||
return <MentionedUserDisplay mentionedUser={item} />;
|
return <MentionedUserDisplay mentionedUser={item} />;
|
||||||
}
|
}
|
||||||
if (item.type === 'MentionedEvent' && props.embedding) {
|
if (item.type === 'MentionedEvent') {
|
||||||
return <MentionedEventDisplay mentionedEvent={item} />;
|
if (props.embedding) {
|
||||||
|
return <MentionedEventDisplay mentionedEvent={item} />;
|
||||||
|
}
|
||||||
|
return <div>mention</div>;
|
||||||
}
|
}
|
||||||
if (item.type === 'HashTag') {
|
if (item.type === 'HashTag') {
|
||||||
return <span class="text-blue-500 underline">{item.content}</span>;
|
return <span class="text-blue-500 underline">{item.content}</span>;
|
||||||
}
|
}
|
||||||
if (item.type === 'URL') {
|
if (item.type === 'URL') {
|
||||||
if (item.content.match(/\.(jpeg|jpg|png|gif|webp)$/i)) {
|
if (item.content.match(/\.(jpeg|jpg|png|gif|webp)$/i)) {
|
||||||
return <ImageDisplay url={item.content} />;
|
if (props.embedding) {
|
||||||
|
return <ImageDisplay url={item.content} />;
|
||||||
|
}
|
||||||
|
return <div>image</div>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
|||||||
220
src/components/textNote/TextNoteDisplay.tsx
Normal file
220
src/components/textNote/TextNoteDisplay.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { Show, For, createSignal, createMemo, type JSX, type Component } from 'solid-js';
|
||||||
|
import type { Event as NostrEvent } from 'nostr-tools';
|
||||||
|
|
||||||
|
import HeartOutlined from 'heroicons/24/outline/heart.svg';
|
||||||
|
import HeartSolid from 'heroicons/24/solid/heart.svg';
|
||||||
|
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
|
||||||
|
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
|
||||||
|
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
|
||||||
|
|
||||||
|
import ColumnItem from '@/components/ColumnItem';
|
||||||
|
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
|
||||||
|
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
|
||||||
|
import ReplyPostForm from '@/components/ReplyPostForm';
|
||||||
|
|
||||||
|
import eventWrapper from '@/core/event';
|
||||||
|
|
||||||
|
import useProfile from '@/nostr/useProfile';
|
||||||
|
import useConfig from '@/nostr/useConfig';
|
||||||
|
import usePubkey from '@/nostr/usePubkey';
|
||||||
|
import useCommands from '@/nostr/useCommands';
|
||||||
|
import useReactions from '@/nostr/useReactions';
|
||||||
|
import useDeprecatedReposts from '@/nostr/useDeprecatedReposts';
|
||||||
|
|
||||||
|
import useFormatDate from '@/hooks/useFormatDate';
|
||||||
|
|
||||||
|
import ensureNonNull from '@/utils/ensureNonNull';
|
||||||
|
|
||||||
|
export type TextNoteDisplayProps = {
|
||||||
|
event: NostrEvent;
|
||||||
|
embedding?: boolean;
|
||||||
|
actions?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
||||||
|
const { config } = useConfig();
|
||||||
|
const formatDate = useFormatDate();
|
||||||
|
const commands = useCommands();
|
||||||
|
const pubkey = usePubkey();
|
||||||
|
const [showReplyForm, setShowReplyForm] = createSignal(false);
|
||||||
|
|
||||||
|
const event = createMemo(() => eventWrapper(props.event));
|
||||||
|
|
||||||
|
const embedding = () => props.embedding ?? true;
|
||||||
|
const actions = () => props.actions ?? true;
|
||||||
|
|
||||||
|
const { profile: author } = useProfile(() => ({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
pubkey: props.event.pubkey,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { reactions, isReactedBy, invalidateReactions } = useReactions(() => ({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
eventId: props.event.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { reposts, isRepostedBy, invalidateDeprecatedReposts } = useDeprecatedReposts(() => ({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
eventId: props.event.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isReactedByMe = createMemo(() => isReactedBy(pubkey()));
|
||||||
|
const isRepostedByMe = createMemo(() => isRepostedBy(pubkey()));
|
||||||
|
|
||||||
|
const createdAt = () => formatDate(event().createdAtAsDate());
|
||||||
|
|
||||||
|
const handleReplyPost = ({ content }: { content: string }) => {
|
||||||
|
commands
|
||||||
|
.publishTextNote({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
pubkey: pubkey(),
|
||||||
|
content,
|
||||||
|
notifyPubkeys: [event().pubkey, ...event().mentionedPubkeys()],
|
||||||
|
rootEventId: event().rootEvent()?.id ?? props.event.id,
|
||||||
|
replyEventId: props.event.id,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setShowReplyForm(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
|
||||||
|
if (isRepostedByMe()) {
|
||||||
|
// TODO remove reaction
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
ensureNonNull([pubkey(), props.event.id] as const)(([pubkeyNonNull, eventIdNonNull]) => {
|
||||||
|
commands
|
||||||
|
.publishDeprecatedRepost({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
pubkey: pubkeyNonNull,
|
||||||
|
eventId: eventIdNonNull,
|
||||||
|
notifyPubkey: props.event.pubkey,
|
||||||
|
})
|
||||||
|
.then(() => invalidateDeprecatedReposts());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
|
||||||
|
if (isReactedByMe()) {
|
||||||
|
// TODO remove reaction
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
ensureNonNull([pubkey(), props.event.id] as const)(([pubkeyNonNull, eventIdNonNull]) => {
|
||||||
|
commands
|
||||||
|
.publishReaction({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
pubkey: pubkeyNonNull,
|
||||||
|
content: '+',
|
||||||
|
eventId: eventIdNonNull,
|
||||||
|
notifyPubkey: props.event.pubkey,
|
||||||
|
})
|
||||||
|
.then(() => invalidateReactions());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex w-full gap-1">
|
||||||
|
<div class="author-icon h-10 w-10 shrink-0">
|
||||||
|
<Show when={author()?.picture}>
|
||||||
|
{/* TODO 画像は脆弱性回避のためにimgじゃない方法で読み込みたい */}
|
||||||
|
<img
|
||||||
|
src={author()?.picture}
|
||||||
|
alt="icon"
|
||||||
|
// TODO autofit
|
||||||
|
class="h-10 w-10 rounded"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-auto">
|
||||||
|
<div class="flex justify-between gap-1 text-xs">
|
||||||
|
<div class="author flex min-w-0 truncate">
|
||||||
|
{/* TODO link to author */}
|
||||||
|
<Show when={(author()?.display_name?.length ?? 0) > 0}>
|
||||||
|
<div class="author-name truncate pr-1 font-bold">{author()?.display_name}</div>
|
||||||
|
</Show>
|
||||||
|
<div class="author-username truncate text-zinc-600">
|
||||||
|
<Show when={author()?.name} fallback={props.event.pubkey}>
|
||||||
|
@{author()?.name}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="created-at shrink-0">{createdAt()}</div>
|
||||||
|
</div>
|
||||||
|
<Show when={event().mentionedPubkeys().length > 0}>
|
||||||
|
<div class="text-xs">
|
||||||
|
{'Replying to '}
|
||||||
|
<For each={event().mentionedPubkeys()}>
|
||||||
|
{(replyToPubkey: string) => (
|
||||||
|
<span class="pr-1 text-blue-500 underline">
|
||||||
|
<GeneralUserMentionDisplay pubkey={replyToPubkey} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="content whitespace-pre-wrap break-all">
|
||||||
|
<TextNoteContentDisplay event={props.event} embedding={embedding()} />
|
||||||
|
</div>
|
||||||
|
<Show when={actions()}>
|
||||||
|
<div class="flex w-48 items-center justify-between gap-8 pt-1">
|
||||||
|
<button
|
||||||
|
class="h-4 w-4 text-zinc-400"
|
||||||
|
onClick={() => setShowReplyForm((current) => !current)}
|
||||||
|
>
|
||||||
|
<ChatBubbleLeft />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
classList={{
|
||||||
|
'text-zinc-400': !isRepostedByMe(),
|
||||||
|
'text-green-400': isRepostedByMe(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button class="h-4 w-4" onClick={handleRepost}>
|
||||||
|
<ArrowPathRoundedSquare />
|
||||||
|
</button>
|
||||||
|
<Show when={reposts().length > 0}>
|
||||||
|
<div class="text-sm text-zinc-400">{reposts().length}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
classList={{
|
||||||
|
'text-zinc-400': !isReactedByMe(),
|
||||||
|
'text-rose-400': isReactedByMe(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button class="h-4 w-4" onClick={handleReaction}>
|
||||||
|
<Show when={isReactedByMe()} fallback={<HeartOutlined />}>
|
||||||
|
<HeartSolid />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
<Show when={reactions().length > 0}>
|
||||||
|
<div class="text-sm text-zinc-400">{reactions().length}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<button class="h-4 w-4 text-zinc-400">
|
||||||
|
<EllipsisHorizontal />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={showReplyForm()}>
|
||||||
|
<ReplyPostForm
|
||||||
|
replyTo={props.event}
|
||||||
|
onPost={handleReplyPost}
|
||||||
|
onClose={() => setShowReplyForm(false)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextNoteDisplay;
|
||||||
38
src/components/textNote/TextNoteDisplayById.tsx
Normal file
38
src/components/textNote/TextNoteDisplayById.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Switch, Match, type Component } from 'solid-js';
|
||||||
|
|
||||||
|
import TextNoteDisplay, { type TextNoteDisplayProps } from '@/components/textNote/TextNoteDisplay';
|
||||||
|
|
||||||
|
import useConfig from '@/nostr/useConfig';
|
||||||
|
import useEvent from '@/nostr/useEvent';
|
||||||
|
|
||||||
|
import ensureNonNull from '@/utils/ensureNonNull';
|
||||||
|
|
||||||
|
type TextNoteDisplayByIdProps = Omit<TextNoteDisplayProps, 'event'> & {
|
||||||
|
eventId: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TextNoteDisplayById: Component<TextNoteDisplayByIdProps> = (props) => {
|
||||||
|
const { config } = useConfig();
|
||||||
|
const { event, query: eventQuery } = useEvent(() =>
|
||||||
|
ensureNonNull([props.eventId] as const)(([eventIdNonNull]) => ({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
eventId: eventIdNonNull,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch fallback="投稿が見つかりません">
|
||||||
|
<Match when={event()} keyed>
|
||||||
|
{(ev) => <TextNoteDisplay event={ev} {...props} />}
|
||||||
|
</Match>
|
||||||
|
<Match when={eventQuery.isLoading}>
|
||||||
|
<div class="truncate">
|
||||||
|
{'読み込み中 '}
|
||||||
|
<span>{props.eventId}</span>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextNoteDisplayById;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Event as NostrEvent } from 'nostr-tools';
|
import type { Event as NostrEvent } from 'nostr-tools';
|
||||||
|
import uniq from 'lodash/uniq';
|
||||||
|
|
||||||
export type EventMarker = 'reply' | 'root' | 'mention';
|
export type EventMarker = 'reply' | 'root' | 'mention';
|
||||||
export type TaggedEvent = {
|
export type TaggedEvent = {
|
||||||
@@ -9,9 +10,24 @@ export type TaggedEvent = {
|
|||||||
|
|
||||||
const eventWrapper = (event: NostrEvent) => {
|
const eventWrapper = (event: NostrEvent) => {
|
||||||
return {
|
return {
|
||||||
event(): NostrEvent {
|
get rawEvent(): NostrEvent {
|
||||||
return event;
|
return event;
|
||||||
},
|
},
|
||||||
|
get id(): string | undefined {
|
||||||
|
return event.id;
|
||||||
|
},
|
||||||
|
get pubkey(): string {
|
||||||
|
return event.pubkey;
|
||||||
|
},
|
||||||
|
get createdAt(): number {
|
||||||
|
return event.created_at;
|
||||||
|
},
|
||||||
|
get content(): Date {
|
||||||
|
return new Date(event.created_at * 1000);
|
||||||
|
},
|
||||||
|
createdAtAsDate(): Date {
|
||||||
|
return new Date(event.created_at * 1000);
|
||||||
|
},
|
||||||
taggedUsers(): string[] {
|
taggedUsers(): string[] {
|
||||||
const pubkeys = new Set<string>();
|
const pubkeys = new Set<string>();
|
||||||
event.tags.forEach(([tagName, pubkey]) => {
|
event.tags.forEach(([tagName, pubkey]) => {
|
||||||
@@ -54,6 +70,9 @@ const eventWrapper = (event: NostrEvent) => {
|
|||||||
mentionedEvents(): TaggedEvent[] {
|
mentionedEvents(): TaggedEvent[] {
|
||||||
return this.taggedEvents().filter(({ marker }) => marker === 'mention');
|
return this.taggedEvents().filter(({ marker }) => marker === 'mention');
|
||||||
},
|
},
|
||||||
|
mentionedPubkeys(): string[] {
|
||||||
|
return uniq(event.tags.filter(([tagName]) => tagName === 'p').map((e) => e[1]));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
21
src/hooks/useFormatDate.ts
Normal file
21
src/hooks/useFormatDate.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createMemo } from 'solid-js';
|
||||||
|
|
||||||
|
import useConfig from '@/nostr/useConfig';
|
||||||
|
|
||||||
|
import useDatePulser from '@/hooks/useDatePulser';
|
||||||
|
|
||||||
|
import { formatRelative, formatAbsolute } from '@/utils/formatDate';
|
||||||
|
|
||||||
|
const useFormatDate = () => {
|
||||||
|
const { config } = useConfig();
|
||||||
|
const currentDate = useDatePulser();
|
||||||
|
|
||||||
|
return (date: Date) => {
|
||||||
|
if (config().dateFormat === 'absolute') {
|
||||||
|
return formatAbsolute(date);
|
||||||
|
}
|
||||||
|
return formatRelative(date, currentDate());
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFormatDate;
|
||||||
@@ -40,7 +40,7 @@ const useBatch = <TaskArgs, TaskResult>(
|
|||||||
) => {
|
) => {
|
||||||
const props = createMemo(propsProvider);
|
const props = createMemo(propsProvider);
|
||||||
const batchSize = createMemo(() => props().batchSize ?? 100);
|
const batchSize = createMemo(() => props().batchSize ?? 100);
|
||||||
const interval = createMemo(() => props().interval ?? 1000);
|
const interval = createMemo(() => props().interval ?? 2400);
|
||||||
|
|
||||||
const [seqId, setSeqId] = createSignal<number>(0);
|
const [seqId, setSeqId] = createSignal<number>(0);
|
||||||
const [taskQueue, setTaskQueue] = createSignal<Task<TaskArgs, TaskResult>[]>([]);
|
const [taskQueue, setTaskQueue] = createSignal<Task<TaskArgs, TaskResult>[]>([]);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const useCommands = () => {
|
|||||||
}): Promise<Promise<void>[]> {
|
}): Promise<Promise<void>[]> {
|
||||||
const pTags = notifyPubkeys?.map((p) => ['p', p]) ?? [];
|
const pTags = notifyPubkeys?.map((p) => ['p', p]) ?? [];
|
||||||
const eTags = [];
|
const eTags = [];
|
||||||
|
// NIP-10
|
||||||
if (rootEventId != null) eTags.push(['e', rootEventId, '', 'root']);
|
if (rootEventId != null) eTags.push(['e', rootEventId, '', 'root']);
|
||||||
if (mentionEventIds != null)
|
if (mentionEventIds != null)
|
||||||
mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention']));
|
mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention']));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
|
|
||||||
type Config = {
|
type Config = {
|
||||||
relayUrls: string[];
|
relayUrls: string[];
|
||||||
|
dateFormat: 'relative' | 'absolute';
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseConfig = {
|
type UseConfig = {
|
||||||
@@ -25,6 +26,7 @@ const InitialConfig: Config = {
|
|||||||
'wss://nostr-relay.nokotaro.com',
|
'wss://nostr-relay.nokotaro.com',
|
||||||
'wss://nostr.holybea.com',
|
'wss://nostr.holybea.com',
|
||||||
],
|
],
|
||||||
|
dateFormat: 'relative',
|
||||||
};
|
};
|
||||||
|
|
||||||
const serializer = (config: Config): string => JSON.stringify(config);
|
const serializer = (config: Config): string => JSON.stringify(config);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export type UseDeprecatedReposts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { exec } = useBatchedEvents<UseDeprecatedRepostsProps>(() => ({
|
const { exec } = useBatchedEvents<UseDeprecatedRepostsProps>(() => ({
|
||||||
interval: 3400,
|
interval: 5000,
|
||||||
generateKey: ({ eventId }) => eventId,
|
generateKey: ({ eventId }) => eventId,
|
||||||
mergeFilters: (args) => {
|
mergeFilters: (args) => {
|
||||||
const eventIds = args.map((arg) => arg.eventId);
|
const eventIds = args.map((arg) => arg.eventId);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type UseProfile = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { exec } = useBatchedEvent<UseProfileProps>(() => ({
|
const { exec } = useBatchedEvent<UseProfileProps>(() => ({
|
||||||
|
interval: 2000,
|
||||||
generateKey: ({ pubkey }: UseProfileProps): string => pubkey,
|
generateKey: ({ pubkey }: UseProfileProps): string => pubkey,
|
||||||
mergeFilters: (args: UseProfileProps[]): Filter[] => {
|
mergeFilters: (args: UseProfileProps[]): Filter[] => {
|
||||||
const pubkeys = args.map((arg) => arg.pubkey);
|
const pubkeys = args.map((arg) => arg.pubkey);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type UseReactions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { exec } = useBatchedEvents<UseReactionsProps>(() => ({
|
const { exec } = useBatchedEvents<UseReactionsProps>(() => ({
|
||||||
interval: 3400,
|
interval: 5000,
|
||||||
generateKey: ({ eventId }) => eventId,
|
generateKey: ({ eventId }) => eventId,
|
||||||
mergeFilters: (args) => {
|
mergeFilters: (args) => {
|
||||||
const eventIds = args.map((arg) => arg.eventId);
|
const eventIds = args.map((arg) => arg.eventId);
|
||||||
|
|||||||
@@ -69,6 +69,19 @@ const Home: Component = () => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { events: myReactions } = useSubscription(() =>
|
||||||
|
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
|
||||||
|
relayUrls: config().relayUrls,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
kinds: [7],
|
||||||
|
authors: [pubkeyNonNull],
|
||||||
|
limit: 25,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
const { events: notifications } = useSubscription(() =>
|
const { events: notifications } = useSubscription(() =>
|
||||||
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
|
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
|
||||||
relayUrls: config().relayUrls,
|
relayUrls: config().relayUrls,
|
||||||
@@ -136,6 +149,9 @@ const Home: Component = () => {
|
|||||||
<Column name="自分の投稿" columnIndex={4} lastColumn width="medium">
|
<Column name="自分の投稿" columnIndex={4} lastColumn width="medium">
|
||||||
<Timeline events={myPosts()} />
|
<Timeline events={myPosts()} />
|
||||||
</Column>
|
</Column>
|
||||||
|
<Column name="自分のいいね" columnIndex={4} lastColumn width="medium">
|
||||||
|
<Notification events={myReactions()} />
|
||||||
|
</Column>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user