add inline reply form

This commit is contained in:
hzrd149 2023-08-11 15:17:00 -05:00
parent 8308409b72
commit e0529916bb
11 changed files with 365 additions and 83 deletions

@ -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;

@ -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

@ -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[]> = {};

@ -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>
);
}

@ -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>
);
};