add new post modal

add reply button
cleanup helpers
This commit is contained in:
hzrd149 2023-02-07 17:04:19 -06:00
parent f69a120c22
commit 33d57ddc71
18 changed files with 274 additions and 163 deletions

@ -2,7 +2,7 @@ import { Subject, Subscription } from "rxjs";
import { relayPool } from "../services/relays";
import { NostrEvent } from "../types/nostr-event";
type PostResult = { url: string; message?: string; status: boolean };
export type PostResult = { url: string; message?: string; status: boolean };
export function nostrPostAction(relays: string[], event: NostrEvent, timeout: number = 5000) {
const subject = new Subject<PostResult>();

@ -133,3 +133,9 @@ export const ShareIcon = createIcon({
d: "M13.12 17.023l-4.199-2.29a4 4 0 1 1 0-5.465l4.2-2.29a4 4 0 1 1 .959 1.755l-4.2 2.29a4.008 4.008 0 0 1 0 1.954l4.199 2.29a4 4 0 1 1-.959 1.755zM6 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm11-6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4z",
defaultProps,
});
export const ReplyIcon = createIcon({
displayName: "reply-icon",
d: "M11 20L1 12l10-8v5c5.523 0 10 4.477 10 10 0 .273-.01.543-.032.81-1.463-2.774-4.33-4.691-7.655-4.805L13 15h-2v5zm-2-7h4.034l.347.007c1.285.043 2.524.31 3.676.766C15.59 12.075 13.42 11 11 11H9V8.161L4.202 12 9 15.839V13z",
defaultProps,
});

@ -1,48 +0,0 @@
import { Button, useDisclosure } from "@chakra-ui/react";
import moment from "moment";
import { useState } from "react";
import { lastValueFrom } from "rxjs";
import { nostrPostAction } from "../classes/nostr-post-action";
import settings from "../services/settings";
import { DraftNostrEvent } from "../types/nostr-event";
import { AddIcon } from "./icons";
import { PostForm, PostFormValues } from "./post-modal/post-form";
export type InlineNewPostProps = {};
export const InlineNewPost = ({}: InlineNewPostProps) => {
const { isOpen, onClose, onOpen } = useDisclosure();
const [loading, setLoading] = useState(false);
const handlePostSubmit = async (values: PostFormValues) => {
setLoading(true);
const draft: DraftNostrEvent = {
content: values.content,
tags: [],
kind: 1,
created_at: moment().unix(),
};
if (window.nostr) {
const event = await window.nostr.signEvent(draft);
const postResults = nostrPostAction(settings.relays.value, event);
postResults.subscribe((result) => {
console.log(`Posted event to ${result.url}: ${result.message}`);
});
await lastValueFrom(postResults);
}
setLoading(false);
};
if (isOpen) {
return <PostForm onSubmit={handlePostSubmit} onCancel={onClose} loading={loading} />;
}
return (
<Button variant="outline" leftIcon={<AddIcon />} onClick={onOpen}>
New Post
</Button>
);
};

@ -11,7 +11,7 @@ export const NoteLink = ({ noteId, ...props }: NoteLinkProps) => {
const note1 = normalizeToBech32(noteId, Bech32Prefix.Note) ?? noteId;
return (
<Link as={RouterLink} to={`/n/${note1}`} {...props}>
<Link as={RouterLink} to={`/n/${note1}`} color="blue.500" {...props}>
{truncatedId(note1)}
</Link>
);

@ -1,7 +1,7 @@
import React from "react";
import React, { useContext } from "react";
import { Link as RouterLink } from "react-router-dom";
import moment from "moment";
import { Box, Card, CardBody, CardFooter, CardHeader, Flex, Heading, Link } from "@chakra-ui/react";
import { Box, Card, CardBody, CardFooter, CardHeader, Flex, Heading, IconButton, Link } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import { UserAvatarLink } from "../user-avatar-link";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
@ -15,20 +15,26 @@ import { UserTipButton } from "../user-tip-button";
import { NoteRelays } from "./note-relays";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { UserLink } from "../user-link";
import { ReplyIcon } from "../icons";
import { PostModalContext } from "../../providers/post-modal-provider";
import { buildReply } from "../../helpers/nostr-event";
export type NoteProps = {
event: NostrEvent;
};
export const Note = React.memo(({ event }: NoteProps) => {
const isMobile = useIsMobile();
const { openModal } = useContext(PostModalContext);
const pubkey = useSubject(identity.pubkey);
const contacts = useUserContacts(pubkey);
const following = contacts?.contacts || [];
const reply = () => openModal(buildReply(event));
return (
<Card padding="2" variant="outline">
<CardHeader padding="0" mb="2">
<Card variant="outline">
<CardHeader padding="2" mb="2">
<Flex flex="1" gap="2" alignItems="center" wrap="wrap">
<UserAvatarLink pubkey={event.pubkey} size={isMobile ? "xs" : "sm"} />
@ -40,12 +46,12 @@ export const Note = React.memo(({ event }: NoteProps) => {
</Link>
</Flex>
</CardHeader>
<CardBody padding="0">
<Box overflow="hidden" width="100%">
<NoteContents event={event} trusted={following.includes(event.pubkey)} />
</Box>
<CardBody px="2" py="0">
<NoteContents event={event} trusted={following.includes(event.pubkey)} />
</CardBody>
<CardFooter padding="0" display="flex" gap="2" justifyContent="flex-end">
<CardFooter padding="2" display="flex" gap="2">
<IconButton icon={<ReplyIcon />} title="Reply" aria-label="Reply" onClick={reply} size="xs" />
<Box flexGrow={1} />
<UserTipButton pubkey={event.pubkey} size="xs" />
<NoteRelays event={event} size="xs" />
<NoteMenu event={event} />

@ -21,7 +21,11 @@ import settings from "../../services/settings";
const BlurredImage = (props: ImageProps) => {
const { isOpen, onToggle } = useDisclosure();
return <Image onClick={onToggle} cursor="pointer" filter={isOpen ? "" : "blur(1.5rem)"} {...props} />;
return (
<Box overflow="hidden">
<Image onClick={onToggle} cursor="pointer" filter={isOpen ? "" : "blur(1.5rem)"} {...props} />
</Box>
);
};
type EmbedType = {

@ -9,6 +9,7 @@ import { useIsMobile } from "../hooks/use-is-mobile";
import identity from "../services/identity";
import { FollowingList } from "./following-list";
import { ReloadPrompt } from "./reload-prompt";
import { PostModalProvider } from "../providers/post-modal-provider";
export const Page = ({ children }: { children: React.ReactNode }) => {
const navigate = useNavigate();
@ -19,7 +20,9 @@ export const Page = ({ children }: { children: React.ReactNode }) => {
<Flex direction="column" height="100%">
<ReloadPrompt />
<Flex flexGrow={1} direction="column" overflow="hidden">
{children}
<ErrorBoundary>
<PostModalProvider>{children}</PostModalProvider>
</ErrorBoundary>
</Flex>
<Flex flexShrink={0} gap="2" padding="2">
<IconButton icon={<FeedIcon />} aria-label="Home" onClick={() => navigate("/")} flexGrow="1" size="lg" />
@ -72,7 +75,9 @@ export const Page = ({ children }: { children: React.ReactNode }) => {
<ConnectedRelays />
</VStack>
<Flex flexGrow={1} direction="column" overflow="hidden">
<ErrorBoundary>{children}</ErrorBoundary>
<ErrorBoundary>
<PostModalProvider>{children}</PostModalProvider>
</ErrorBoundary>
</Flex>
<VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
<Heading size="md">Following</Heading>

@ -1,20 +1,111 @@
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalCloseButton } from "@chakra-ui/react";
import { PostForm, PostFormProps } from "./post-form";
import {
Modal,
ModalOverlay,
ModalContent,
ModalBody,
ModalFooter,
Flex,
Button,
Textarea,
Text,
} from "@chakra-ui/react";
import moment from "moment";
import React, { useState } from "react";
import { useList } from "react-use";
import { nostrPostAction, PostResult } from "../../classes/nostr-post-action";
import { getReferences } from "../../helpers/nostr-event";
import { useIsMobile } from "../../hooks/use-is-mobile";
import settings from "../../services/settings";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { NoteLink } from "../note-link";
import { PostResults } from "./post-results";
type PostModalProps = PostFormProps & {
function emptyDraft(): DraftNostrEvent {
return {
content: "",
kind: 1,
tags: [],
created_at: moment().unix(),
};
}
type PostModalProps = {
isOpen: boolean;
onClose: () => void;
initialDraft?: Partial<DraftNostrEvent>;
};
export const PostModal = ({ isOpen, onClose, onSubmit, onCancel }: PostModalProps) => (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>New Post</ModalHeader>
<ModalCloseButton />
<ModalBody>
<PostForm onSubmit={onSubmit} onCancel={onCancel} />
</ModalBody>
</ModalContent>
</Modal>
);
export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) => {
const isMobile = useIsMobile();
const pad = isMobile ? "2" : "4";
const [waiting, setWaiting] = useState(false);
const [signedEvent, setSignedEvent] = useState<NostrEvent | null>(null);
const [results, resultsActions] = useList<PostResult>();
const [draft, setDraft] = useState<DraftNostrEvent>(() => Object.assign(emptyDraft(), initialDraft));
const handleContentChange: React.ChangeEventHandler<HTMLTextAreaElement> = (event) => {
setDraft((d) => ({ ...d, content: event.target.value }));
};
const handleSubmit = async () => {
if (window.nostr) {
setWaiting(true);
const updatedDraft: DraftNostrEvent = { ...draft, created_at: moment().unix() };
const event = await window.nostr.signEvent(updatedDraft);
setWaiting(false);
setSignedEvent(event);
const postResults = nostrPostAction(settings.relays.value, event);
postResults.subscribe({
next(result) {
resultsActions.push(result);
},
});
}
};
const refs = getReferences(draft);
const canSubmit = draft.content.length > 0;
const renderContent = () => {
if (signedEvent) {
return (
<ModalBody padding="4">
<PostResults event={signedEvent} results={results} onClose={onClose} />
</ModalBody>
);
}
return (
<>
<ModalBody pr={pad} pl={pad}>
{refs.replyId && (
<Text mb="2">
Replying to: <NoteLink noteId={refs.replyId} />
</Text>
)}
<Textarea autoFocus mb="2" value={draft.content} onChange={handleContentChange} rows={5} />
</ModalBody>
<ModalFooter pr={pad} pl={pad} pb={pad} pt="0">
<Flex gap="2" alignItems="center">
<Button onClick={onClose} isDisabled={waiting} ml="auto">
Cancel
</Button>
<Button colorScheme="blue" type="submit" isLoading={waiting} onClick={handleSubmit} isDisabled={!canSubmit}>
Post
</Button>
</Flex>
</ModalFooter>
</>
);
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent>{renderContent()}</ModalContent>
</Modal>
);
};

@ -1,36 +0,0 @@
import { Button, Flex, Textarea } from "@chakra-ui/react";
import { SubmitHandler, useForm } from "react-hook-form";
export type PostFormValues = {
content: string;
};
export type PostFormProps = {
onSubmit: SubmitHandler<PostFormValues>;
onCancel: () => void;
loading?: boolean;
};
export const PostForm = ({ onSubmit, onCancel, loading }: PostFormProps) => {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<PostFormValues>();
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Textarea {...register("content")} autoFocus mb="2" />
<Flex gap="2" justifyContent="flex-end">
<Button size="sm" onClick={onCancel} isDisabled={loading}>
Cancel
</Button>
<Button colorScheme="brand" size="sm" type="submit" isLoading={loading}>
Post
</Button>
</Flex>
</form>
);
};

@ -0,0 +1,40 @@
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Button, Flex, Heading } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { PostResult } from "../../classes/nostr-post-action";
import { NostrEvent } from "../../types/nostr-event";
export type PostResultsProps = {
event: NostrEvent;
results: PostResult[];
onClose: () => void;
};
export const PostResults = ({ event, results, onClose }: PostResultsProps) => {
const navigate = useNavigate();
const viewPost = () => {
onClose();
navigate(`/n/${event.id}`);
};
return (
<Flex direction="column" gap="2">
<Heading size="md">Posted to relays:</Heading>
{results.map((result) => (
<Alert key={result.url} status={result.status ? "success" : "warning"}>
<AlertIcon />
<Box>
<AlertTitle>{result.url}</AlertTitle>
{result.message && <AlertDescription>{result.message}</AlertDescription>}
</Box>
</Alert>
))}
<Flex gap="4" ml="auto">
<Button onClick={viewPost} variant="link">
View Post
</Button>
<Button onClick={onClose}>Done</Button>
</Flex>
</Flex>
);
};

@ -1,8 +0,0 @@
export function debounce<T>(func: T, timeout = 300) {
let timer: number | undefined;
return (...args: any[]) => {
clearTimeout(timer);
// @ts-ignore
timer = setTimeout(() => func(args), timeout);
};
}

@ -1,10 +1,12 @@
import { isETag, isPTag, NostrEvent } from "../types/nostr-event";
import moment from "moment";
import { getEventRelays } from "../services/event-relays";
import { DraftNostrEvent, isETag, isPTag, NostrEvent } from "../types/nostr-event";
export function isReply(event: NostrEvent) {
export function isReply(event: NostrEvent | DraftNostrEvent) {
return !!event.tags.find((tag) => isETag(tag) && tag[3] !== "mention");
}
export function isNote(event: NostrEvent) {
export function isNote(event: NostrEvent | DraftNostrEvent) {
return !isReply(event);
}
@ -13,7 +15,7 @@ export function truncatedId(id: string) {
}
export type EventReferences = ReturnType<typeof getReferences>;
export function getReferences(event: NostrEvent) {
export function getReferences(event: NostrEvent | DraftNostrEvent) {
const eTags = event.tags.filter(isETag);
const pTags = event.tags.filter(isPTag);
@ -51,3 +53,33 @@ export function getReferences(event: NostrEvent) {
replyId,
};
}
export function buildReply(event: NostrEvent): DraftNostrEvent {
const refs = getReferences(event);
const relay = getEventRelays(event.id).getValue()[0];
const tags: NostrEvent["tags"] = [];
const rootId = refs.rootId ?? event.id;
const replyId = event.id;
tags.push(["e", rootId, relay, "root"]);
if (replyId !== rootId) {
tags.push(["e", replyId, relay, "reply"]);
}
// add all ptags
// TODO: omit my own pubkey
const ptags = event.tags.filter(isPTag);
tags.push(...ptags);
if (!ptags.find((t) => t[1] === event.pubkey)) {
tags.push(["p", event.pubkey]);
}
return {
kind: 1,
// TODO: be smarter about picking relay
tags,
content: "",
created_at: moment().unix(),
};
}

@ -0,0 +1,32 @@
import React, { useCallback, useMemo, useState } from "react";
import { ErrorBoundary } from "../components/error-boundary";
import { PostModal } from "../components/post-modal";
import { DraftNostrEvent } from "../types/nostr-event";
export type PostModalContextType = {
openModal: (draft?: Partial<DraftNostrEvent>) => void;
};
export const PostModalContext = React.createContext<PostModalContextType>({
openModal: () => {},
});
export const PostModalProvider = ({ children }: { children: React.ReactNode }) => {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<Partial<DraftNostrEvent> | undefined>(undefined);
const openModal = useCallback(
(draft?: Partial<DraftNostrEvent>) => {
setDraft(draft);
setOpen(true);
},
[setDraft, setOpen]
);
const context = useMemo(() => ({ openModal }), [openModal]);
return (
<PostModalContext.Provider value={context}>
{open && <PostModal isOpen initialDraft={draft} onClose={() => setOpen(false)} />}
{children}
</PostModalContext.Provider>
);
};

@ -22,28 +22,8 @@ const MIGRATIONS: MigrationFunction[] = [
contacts.createIndex("created_at", "created_at");
contacts.createIndex("contacts", "contacts", { multiEntry: true });
const events = db.createObjectStore("text-events", {
keyPath: "id",
autoIncrement: false,
});
events.createIndex("pubkey", "pubkey");
events.createIndex("created_at", "created_at");
events.createIndex("kind", "kind");
db.createObjectStore("identicon");
// setup data
const settings = db.createObjectStore("settings");
settings.put(
[
"wss://relay.damus.io",
"wss://nostr-pub.wellorder.net",
"wss://nostr.zebedee.cloud",
"wss://satstacker.cloud",
"wss://brb.io",
],
"relays"
);
},
];
@ -64,8 +44,8 @@ const db = await openDB<CustomSchema>("storage", version, {
export async function clearData() {
await db.clear("user-metadata");
await db.clear("user-contacts");
await db.clear("text-events");
await db.clear("identicon");
await db.clear("settings");
window.location.reload();
}
if (import.meta.env.DEV) {

@ -20,15 +20,6 @@ export interface CustomSchema extends DBSchema {
};
indexes: { created_at: number; contacts: string };
};
"text-events": {
key: string;
value: NostrEvent;
indexes: { created_at: number; pubkey: string; kind: number };
};
identicon: {
key: string;
value: string;
};
settings: {
key: string;
value: any;

@ -1,13 +1,8 @@
import Identicon from "identicon.js";
import db from "./db";
export async function getIdenticon(pubkey: string) {
if (pubkey.length < 15) return "";
// const cached = await db.get("identicon", pubkey);
// if (cached) return cached;
const svg = new Identicon(pubkey, { format: "svg" }).toString();
await db.put("identicon", svg, pubkey);
return svg;
}

@ -1,6 +1,9 @@
import { BehaviorSubject } from "rxjs";
import { unique } from "../helpers/array";
import settings from "./settings";
export type PresetRelays = Record<string, { read: boolean; write: boolean }>;
export type SavedIdentity = {
pubkey: string;
secKey?: string;
@ -11,6 +14,8 @@ class IdentityService {
loading = new BehaviorSubject(true);
setup = new BehaviorSubject(false);
pubkey = new BehaviorSubject("");
// TODO: remove this when there is a service to manage user relays
relays = new BehaviorSubject<PresetRelays>({});
private useExtension: boolean = false;
private secKey: string | undefined = undefined;
@ -38,6 +43,12 @@ class IdentityService {
pubkey,
useExtension: true,
});
// disabled because I dont want to load the preset relays yet (ably dose not support changing them)
// const relays = await window.nostr.getRelays();
// if (Array.isArray(relays)) {
// this.relays.next(relays.reduce<PresetRelays>((d, r) => ({ ...d, [r]: { read: true, write: true } }), {}));
// } else this.relays.next(relays);
}
}
@ -61,4 +72,9 @@ if (import.meta.env.DEV) {
window.identity = identity;
}
// TODO: create a service to manage user relays (download latest and handle conflicts)
identity.relays.subscribe((presetRelays) => {
settings.relays.next(unique([...settings.relays.value, ...Object.keys(presetRelays)]));
});
export default identity;

@ -8,11 +8,14 @@ import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useUserContacts } from "../../hooks/use-user-contacts";
import identity from "../../services/identity";
import settings from "../../services/settings";
import { InlineNewPost } from "../../components/inline-new-post";
import { AddIcon } from "@chakra-ui/icons";
import { useContext } from "react";
import { PostModalContext } from "../../providers/post-modal-provider";
export const FollowingTab = () => {
const pubkey = useSubject(identity.pubkey);
const relays = useSubject(settings.relays);
const { openModal } = useContext(PostModalContext);
const contacts = useUserContacts(pubkey);
const [search, setSearch] = useSearchParams();
const showReplies = search.has("replies");
@ -32,7 +35,9 @@ export const FollowingTab = () => {
return (
<Flex direction="column" gap="2">
<InlineNewPost />
<Button variant="outline" leftIcon={<AddIcon />} onClick={() => openModal()}>
New Post
</Button>
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="show-replies" mb="0">
Show Replies