mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-27 18:22:02 +01:00
add inline reply form
This commit is contained in:
parent
8308409b72
commit
e0529916bb
.changeset
src
components
helpers
views/note
5
.changeset/tricky-hounds-double.md
Normal file
5
.changeset/tricky-hounds-double.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add inline reply form
|
@ -25,7 +25,6 @@ import { ExpandProvider } from "./expanded";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import appSettings from "../../services/settings/app-settings";
|
||||
import EventVerificationIcon from "../event-verification-icon";
|
||||
import { ReplyButton } from "./buttons/reply-button";
|
||||
import { RepostButton } from "./buttons/repost-button";
|
||||
import { QuoteRepostButton } from "./buttons/quote-repost-button";
|
||||
import { ExternalLinkIcon } from "../icons";
|
||||
@ -69,7 +68,6 @@ export const Note = React.memo(({ event, variant = "outline" }: NoteProps) => {
|
||||
</CardBody>
|
||||
<CardFooter padding="2" display="flex" gap="2">
|
||||
<ButtonGroup size="sm" variant="link">
|
||||
<ReplyButton event={event} />
|
||||
<RepostButton event={event} />
|
||||
<QuoteRepostButton event={event} />
|
||||
<NoteZapButton note={event} size="sm" />
|
||||
|
@ -16,9 +16,7 @@ import dayjs from "dayjs";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useList } from "react-use";
|
||||
import { nostrPostAction, PostResult } from "../../classes/nostr-post-action";
|
||||
import { normalizeToHex } from "../../helpers/nip19";
|
||||
import { getReferences } from "../../helpers/nostr/event";
|
||||
import { matchHashtag, mentionNpubOrNote } from "../../helpers/regexp";
|
||||
import { useWriteRelayUrls } from "../../hooks/use-client-relays";
|
||||
import { useSigningContext } from "../../providers/signing-provider";
|
||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||
@ -27,6 +25,7 @@ import { NoteLink } from "../note-link";
|
||||
import { NoteContents } from "../note/note-contents";
|
||||
import { PostResults } from "./post-results";
|
||||
import { TrustProvider } from "../../providers/trust";
|
||||
import { finalizeNote } from "../../helpers/nostr/post";
|
||||
|
||||
function emptyDraft(): DraftNostrEvent {
|
||||
return {
|
||||
@ -37,40 +36,6 @@ function emptyDraft(): DraftNostrEvent {
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeNote(draft: DraftNostrEvent) {
|
||||
const updatedDraft: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags), created_at: dayjs().unix() };
|
||||
|
||||
// replace all occurrences of @npub and @note
|
||||
while (true) {
|
||||
const match = mentionNpubOrNote.exec(updatedDraft.content);
|
||||
if (!match || match.index === undefined) break;
|
||||
|
||||
const hex = normalizeToHex(match[1]);
|
||||
if (!hex) continue;
|
||||
const mentionType = match[2] === "npub1" ? "p" : "e";
|
||||
// TODO: find the best relay for this user or note
|
||||
const existingMention = updatedDraft.tags.find((t) => t[0] === mentionType && t[1] === hex);
|
||||
const index = existingMention
|
||||
? updatedDraft.tags.indexOf(existingMention)
|
||||
: updatedDraft.tags.push([mentionType, hex, "", "mention"]) - 1;
|
||||
|
||||
// replace the npub1 or note1 with a mention tag #[0]
|
||||
const c = updatedDraft.content;
|
||||
updatedDraft.content = c.slice(0, match.index) + `#[${index}]` + c.slice(match.index + match[0].length);
|
||||
}
|
||||
|
||||
// replace all uses of #hashtag
|
||||
const matches = updatedDraft.content.matchAll(new RegExp(matchHashtag, "giu"));
|
||||
for (const [_, space, hashtag] of matches) {
|
||||
const lower = hashtag.toLocaleLowerCase();
|
||||
if (!updatedDraft.tags.find((t) => t[0] === "t" && t[1] === lower)) {
|
||||
updatedDraft.tags.push(["t", lower]);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedDraft;
|
||||
}
|
||||
|
||||
type PostModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
76
src/components/user-avatar-stack.tsx
Normal file
76
src/components/user-avatar-stack.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import {
|
||||
Flex,
|
||||
FlexProps,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Tag,
|
||||
TagProps,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
import { getUserDisplayName } from "../helpers/user-metadata";
|
||||
import { useUserMetadata } from "../hooks/use-user-metadata";
|
||||
|
||||
function UserTag({ pubkey, ...props }: { pubkey: string } & Omit<TagProps, "children">) {
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
const displayName = getUserDisplayName(metadata, pubkey);
|
||||
|
||||
return (
|
||||
<Tag as={RouterLink} to={`/u/${npub}`} {...props}>
|
||||
<UserAvatar pubkey={pubkey} size="xs" mr="2" title={displayName} />
|
||||
{displayName}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserAvatarStack({
|
||||
users,
|
||||
maxUsers,
|
||||
label = "Users",
|
||||
...props
|
||||
}: { users: string[]; maxUsers?: number; label?: string } & FlexProps) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const clamped = maxUsers ? users.slice(0, maxUsers) : users;
|
||||
|
||||
return (
|
||||
<>
|
||||
{label && <span>{label}</span>}
|
||||
<Flex alignItems="center" gap="-4" overflow="hidden" cursor="pointer" onClick={onOpen} {...props}>
|
||||
{clamped.map((pubkey) => (
|
||||
<UserAvatar key={pubkey} pubkey={pubkey} size="2xs" />
|
||||
))}
|
||||
{clamped.length !== users.length && (
|
||||
<Text mx="1" fontSize="sm" lineHeight={0}>
|
||||
+{users.length - clamped.length}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader px="4" pt="4" pb="2">
|
||||
{label}:
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody px="4" pb="4" pt="0">
|
||||
<Flex gap="2" wrap="wrap">
|
||||
{users.map((pubkey) => (
|
||||
<UserTag key={pubkey} pubkey={pubkey} p="2" fontWeight="bold" fontSize="md" />
|
||||
))}
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
@ -6,6 +6,7 @@ import { getIdenticon } from "../helpers/identicon";
|
||||
import { safeUrl } from "../helpers/parse";
|
||||
import appSettings from "../services/settings/app-settings";
|
||||
import useSubject from "../hooks/use-subject";
|
||||
import { getUserDisplayName } from "../helpers/user-metadata";
|
||||
|
||||
export const UserIdenticon = React.memo(({ pubkey }: { pubkey: string }) => {
|
||||
const { value: identicon } = useAsync(() => getIdenticon(pubkey), [pubkey]);
|
||||
@ -35,6 +36,14 @@ export const UserAvatar = React.memo(({ pubkey, noProxy, ...props }: UserAvatarP
|
||||
}
|
||||
}, [metadata?.picture, imageProxy]);
|
||||
|
||||
return <Avatar src={picture} icon={<UserIdenticon pubkey={pubkey} />} overflow="hidden" {...props} />;
|
||||
return (
|
||||
<Avatar
|
||||
src={picture}
|
||||
icon={<UserIdenticon pubkey={pubkey} />}
|
||||
overflow="hidden"
|
||||
title={getUserDisplayName(metadata, pubkey)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
UserAvatar.displayName = "UserAvatar";
|
||||
|
109
src/helpers/nostr/post.ts
Normal file
109
src/helpers/nostr/post.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { DraftNostrEvent, NostrEvent, PTag, Tag } from "../../types/nostr-event";
|
||||
import { matchHashtag, mentionNpubOrNote } from "../regexp";
|
||||
import { normalizeToHex } from "../nip19";
|
||||
import { getReferences } from "./event";
|
||||
import { getEventRelays } from "../../services/event-relays";
|
||||
import relayScoreboardService from "../../services/relay-scoreboard";
|
||||
|
||||
function addTag(tags: Tag[], tag: Tag, overwrite = false) {
|
||||
if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) {
|
||||
if (overwrite) {
|
||||
return tags.map((t) => {
|
||||
if (t[0] === tag[0] && t[1] === tag[1]) return tag;
|
||||
return t;
|
||||
});
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
return [...tags, tag];
|
||||
}
|
||||
function AddEtag(tags: Tag[], eventId: string, type?: string, overwrite = false) {
|
||||
const relays = getEventRelays(eventId).value ?? [];
|
||||
const top = relayScoreboardService.getRankedRelays(relays)[0] ?? "";
|
||||
|
||||
const tag = type ? ["e", eventId, top, type] : ["e", eventId, top];
|
||||
|
||||
if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1] && t[3] === tag[3])) {
|
||||
if (overwrite) {
|
||||
return tags.map((t) => {
|
||||
if (t[0] === tag[0] && t[1] === tag[1]) return tag;
|
||||
return t;
|
||||
});
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
return [...tags, tag];
|
||||
}
|
||||
|
||||
/** adds the "root" and "reply" E tags */
|
||||
export function addReplyTags(draft: DraftNostrEvent, replyTo: NostrEvent) {
|
||||
const updated: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
||||
const refs = getReferences(replyTo);
|
||||
|
||||
const rootId = refs.rootId ?? replyTo.id;
|
||||
const replyId = replyTo.id;
|
||||
|
||||
updated.tags = AddEtag(updated.tags, rootId, "root", true);
|
||||
updated.tags = AddEtag(updated.tags, replyId, "reply", true);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** ensure a list of pubkeys are present on an event */
|
||||
export function ensureNotifyUsers(draft: DraftNostrEvent, pubkeys: string[]) {
|
||||
const updated: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
||||
|
||||
for (const pubkey of pubkeys) {
|
||||
updated.tags = addTag(updated.tags, ["p", pubkey], false);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function replaceAtMentions(draft: DraftNostrEvent) {
|
||||
const updatedDraft: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
||||
|
||||
// replace all occurrences of @npub and @note
|
||||
while (true) {
|
||||
const match = mentionNpubOrNote.exec(updatedDraft.content);
|
||||
if (!match || match.index === undefined) break;
|
||||
|
||||
const hex = normalizeToHex(match[1]);
|
||||
if (!hex) continue;
|
||||
const mentionType = match[2] === "npub1" ? "p" : "e";
|
||||
|
||||
// TODO: find the best relay for this user or note
|
||||
const existingMention = updatedDraft.tags.find((t) => t[0] === mentionType && t[1] === hex);
|
||||
const index = existingMention
|
||||
? updatedDraft.tags.indexOf(existingMention)
|
||||
: updatedDraft.tags.push([mentionType, hex, "", "mention"]) - 1;
|
||||
|
||||
// replace the npub1 or note1 with a mention tag #[0]
|
||||
const c = updatedDraft.content;
|
||||
updatedDraft.content = c.slice(0, match.index) + `#[${index}]` + c.slice(match.index + match[0].length);
|
||||
}
|
||||
|
||||
return updatedDraft;
|
||||
}
|
||||
|
||||
export function createHashtagTags(draft: DraftNostrEvent) {
|
||||
const updatedDraft: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
||||
|
||||
// create tags for all occurrences of #hashtag
|
||||
const matches = updatedDraft.content.matchAll(new RegExp(matchHashtag, "giu"));
|
||||
for (const [_, space, hashtag] of matches) {
|
||||
const lower = hashtag.toLocaleLowerCase();
|
||||
if (!updatedDraft.tags.find((t) => t[0] === "t" && t[1] === lower)) {
|
||||
updatedDraft.tags.push(["t", lower]);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedDraft;
|
||||
}
|
||||
|
||||
export function finalizeNote(draft: DraftNostrEvent) {
|
||||
let updated = draft;
|
||||
updated = replaceAtMentions(updated);
|
||||
updated = createHashtagTags(updated);
|
||||
return updated;
|
||||
}
|
@ -6,13 +6,32 @@ export function countReplies(thread: ThreadItem): number {
|
||||
}
|
||||
|
||||
export type ThreadItem = {
|
||||
/** underlying nostr event */
|
||||
event: NostrEvent;
|
||||
/** the thread root, according to this event */
|
||||
root?: ThreadItem;
|
||||
/** the parent event this is replying to */
|
||||
reply?: ThreadItem;
|
||||
/** refs from nostr event */
|
||||
refs: EventReferences;
|
||||
/** direct child replies */
|
||||
replies: ThreadItem[];
|
||||
};
|
||||
|
||||
/** Returns an array of all pubkeys participating in the thread */
|
||||
export function getThreadMembers(item: ThreadItem, omit?: string) {
|
||||
const pubkeys = new Set<string>();
|
||||
|
||||
let i = item;
|
||||
while (true) {
|
||||
if (i.event.pubkey !== omit) pubkeys.add(i.event.pubkey);
|
||||
if (!i.reply) break;
|
||||
else i = i.reply;
|
||||
}
|
||||
|
||||
return Array.from(pubkeys);
|
||||
}
|
||||
|
||||
export function linkEvents(events: NostrEvent[]) {
|
||||
const idToChildren: Record<string, NostrEvent[]> = {};
|
||||
|
||||
|
90
src/views/note/components/reply-form.tsx
Normal file
90
src/views/note/components/reply-form.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Button, ButtonGroup, Flex, Textarea, useDisclosure, useToast } from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Kind } from "nostr-tools";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { UserAvatarStack } from "../../../components/user-avatar-stack";
|
||||
import { ThreadItem, getThreadMembers } from "../../../helpers/thread";
|
||||
import { NoteContents } from "../../../components/note/note-contents";
|
||||
import { addReplyTags, ensureNotifyUsers, finalizeNote } from "../../../helpers/nostr/post";
|
||||
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||
import { useSigningContext } from "../../../providers/signing-provider";
|
||||
import { useWriteRelayUrls } from "../../../hooks/use-client-relays";
|
||||
import { nostrPostAction } from "../../../classes/nostr-post-action";
|
||||
|
||||
function NoteContentPreview({ content }: { content: string }) {
|
||||
const draft = useMemo(
|
||||
() => finalizeNote({ kind: Kind.Text, content, created_at: dayjs().unix(), tags: [] }),
|
||||
[content]
|
||||
);
|
||||
|
||||
return <NoteContents event={draft} />;
|
||||
}
|
||||
|
||||
export type ReplyFormProps = {
|
||||
item: ThreadItem;
|
||||
onCancel: () => void;
|
||||
onSubmitted?: (event: NostrEvent) => void;
|
||||
};
|
||||
|
||||
export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProps) {
|
||||
const toast = useToast();
|
||||
const account = useCurrentAccount();
|
||||
const { requestSignature } = useSigningContext();
|
||||
const writeRelays = useWriteRelayUrls();
|
||||
|
||||
const threadMembers = useMemo(() => getThreadMembers(item, account?.pubkey), [item, account?.pubkey]);
|
||||
const { register, getValues, watch, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
content: "",
|
||||
},
|
||||
});
|
||||
|
||||
const showPreview = useDisclosure();
|
||||
|
||||
watch("content");
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
try {
|
||||
let draft = finalizeNote({ kind: Kind.Text, content: values.content, created_at: dayjs().unix(), tags: [] });
|
||||
draft = addReplyTags(draft, item.event);
|
||||
draft = ensureNotifyUsers(draft, threadMembers);
|
||||
|
||||
const signed = await requestSignature(draft);
|
||||
if (!signed) return;
|
||||
// TODO: write to other users inbox relays
|
||||
const pub = nostrPostAction(writeRelays, signed);
|
||||
await pub.onComplete;
|
||||
|
||||
if (onSubmitted) onSubmitted(signed);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{showPreview.isOpen ? (
|
||||
<Box p="2" borderWidth={1} borderRadius="md" mb="2">
|
||||
<NoteContentPreview content={getValues().content} />
|
||||
</Box>
|
||||
) : (
|
||||
<Textarea placeholder="Reply" {...register("content")} autoFocus mb="2" rows={5} required />
|
||||
)}
|
||||
<Flex gap="2" alignItems="center">
|
||||
<ButtonGroup size="sm">
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit">Submit</Button>
|
||||
</ButtonGroup>
|
||||
<UserAvatarStack label="Notify" users={threadMembers} />
|
||||
{getValues().content.length > 0 && (
|
||||
<Button size="sm" ml="auto" onClick={showPreview.onToggle}>
|
||||
Preview
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
}
|
53
src/views/note/components/thread-post.tsx
Normal file
53
src/views/note/components/thread-post.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Button, ButtonGroup, Flex, useDisclosure } from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import { ArrowDownSIcon, ArrowUpSIcon, ReplyIcon } from "../../../components/icons";
|
||||
import { Note } from "../../../components/note";
|
||||
import { countReplies, ThreadItem } from "../../../helpers/thread";
|
||||
import { TrustProvider } from "../../../providers/trust";
|
||||
import ReplyForm from "./reply-form";
|
||||
|
||||
export type ThreadItemProps = {
|
||||
post: ThreadItem;
|
||||
initShowReplies?: boolean;
|
||||
focusId?: string;
|
||||
};
|
||||
|
||||
export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps) => {
|
||||
const [showReplies, setShowReplies] = useState(initShowReplies ?? post.replies.length === 1);
|
||||
const toggle = () => setShowReplies((v) => !v);
|
||||
const showReplyForm = useDisclosure();
|
||||
|
||||
const numberOfReplies = countReplies(post);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2">
|
||||
<TrustProvider trust={focusId === post.event.id ? true : undefined}>
|
||||
<Note event={post.event} variant={focusId === post.event.id ? "filled" : "outline"} />
|
||||
</TrustProvider>
|
||||
{showReplyForm.isOpen && (
|
||||
<ReplyForm item={post} onCancel={showReplyForm.onClose} onSubmitted={showReplyForm.onClose} />
|
||||
)}
|
||||
<ButtonGroup variant="ghost" size="sm" alignSelf="flex-start">
|
||||
{!showReplyForm.isOpen && (
|
||||
<Button onClick={showReplyForm.onOpen} leftIcon={<ReplyIcon />}>
|
||||
Write relay
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{post.replies.length > 0 && (
|
||||
<Button onClick={toggle}>
|
||||
{numberOfReplies} {numberOfReplies > 1 ? "Replies" : "Reply"}
|
||||
{showReplies ? <ArrowDownSIcon /> : <ArrowUpSIcon />}
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
{post.replies.length > 0 && showReplies && (
|
||||
<Flex direction="column" gap="2" pl={[2, 2, 4]} borderLeftColor="gray.500" borderLeftWidth="1px">
|
||||
{post.replies.map((child) => (
|
||||
<ThreadPost key={child.event.id} post={child} focusId={focusId} />
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
@ -4,7 +4,7 @@ import { useParams } from "react-router-dom";
|
||||
import { Note } from "../../components/note";
|
||||
import { isHex } from "../../helpers/nip19";
|
||||
import { useThreadLoader } from "../../hooks/use-thread-loader";
|
||||
import { ThreadPost } from "./thread-post";
|
||||
import { ThreadPost } from "./components/thread-post";
|
||||
|
||||
function useNotePointer() {
|
||||
const { id } = useParams() as { id: string };
|
||||
@ -61,7 +61,7 @@ export default function NoteView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="4" flex={1} pb="4" pt="4" pl="1" pr="1">
|
||||
<Flex direction="column" gap="4" flex={1} pb="12" pt="4" pl="1" pr="1">
|
||||
{pageContent}
|
||||
</Flex>
|
||||
);
|
||||
|
@ -1,42 +0,0 @@
|
||||
import { Button, Flex, useDisclosure } from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import { ArrowDownSIcon, ArrowUpSIcon } from "../../components/icons";
|
||||
import { Note } from "../../components/note";
|
||||
import { countReplies, ThreadItem as ThreadItemData } from "../../helpers/thread";
|
||||
import { TrustProvider } from "../../providers/trust";
|
||||
|
||||
export type ThreadItemProps = {
|
||||
post: ThreadItemData;
|
||||
initShowReplies?: boolean;
|
||||
focusId?: string;
|
||||
};
|
||||
|
||||
export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps) => {
|
||||
const [showReplies, setShowReplies] = useState(initShowReplies ?? post.replies.length === 1);
|
||||
const toggle = () => setShowReplies((v) => !v);
|
||||
|
||||
const numberOfReplies = countReplies(post);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2">
|
||||
<TrustProvider trust={focusId === post.event.id ? true : undefined}>
|
||||
<Note event={post.event} variant={focusId === post.event.id ? "filled" : "outline"} />
|
||||
</TrustProvider>
|
||||
{post.replies.length > 0 && (
|
||||
<>
|
||||
<Button variant="link" size="sm" alignSelf="flex-start" onClick={toggle}>
|
||||
{numberOfReplies} {numberOfReplies > 1 ? "Replies" : "Reply"}
|
||||
{showReplies ? <ArrowDownSIcon /> : <ArrowUpSIcon />}
|
||||
</Button>
|
||||
{showReplies && (
|
||||
<Flex direction="column" gap="2" pl={[2, 2, 4]} borderLeftColor="gray.500" borderLeftWidth="1px">
|
||||
{post.replies.map((child) => (
|
||||
<ThreadPost key={child.event.id} post={child} focusId={focusId} />
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user