add emoji pack edit view

This commit is contained in:
hzrd149 2023-09-04 15:55:28 -05:00
parent 8bf5d8213c
commit 1b5ee345b7
8 changed files with 299 additions and 70 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add emoji edit view

View File

@ -0,0 +1,36 @@
import { Card, CardBody, CardHeader, CardProps, Flex, Heading, Link, Text } from "@chakra-ui/react";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { NostrEvent } from "../../../types/nostr-event";
import { UserAvatarLink } from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import { truncatedId } from "../../../helpers/nostr/events";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { UserDnsIdentityIcon } from "../../user-dns-identity-icon";
import dayjs from "dayjs";
export default function EmbeddedUnknown({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const address = getSharableEventAddress(event);
return (
<Card {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="md" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Link ml="auto" href={address ? buildAppSelectUrl(address) : ""} isExternal>
{dayjs.unix(event.created_at).fromNow()}
</Link>
</CardHeader>
<CardBody p="2">
<Flex gap="2">
<Text>Kind: {event.kind}</Text>
<Link href={address ? buildAppSelectUrl(address) : ""} isExternal color="blue.500">
{address && truncatedId(address)}
</Link>
</Flex>
<Text whiteSpace="pre-wrap">{event.content}</Text>
</CardBody>
</Card>
);
}

View File

@ -1,5 +1,4 @@
import type { DecodeResult } from "nostr-tools/lib/nip19";
import { Link } from "@chakra-ui/react";
import EmbeddedNote from "./event-types/embedded-note";
import useSingleEvent from "../../hooks/use-single-event";
@ -10,13 +9,12 @@ import useReplaceableEvent from "../../hooks/use-replaceable-event";
import RelayCard from "../../views/relays/components/relay-card";
import { STREAM_KIND } from "../../helpers/nostr/stream";
import { GOAL_KIND } from "../../helpers/nostr/goal";
import GoalCard from "../../views/goals/components/goal-card";
import { getSharableEventAddress, safeDecode } from "../../helpers/nip19";
import { safeDecode } from "../../helpers/nip19";
import EmbeddedStream from "./event-types/embedded-stream";
import { EMOJI_PACK_KIND } from "../../helpers/nostr/emoji-packs";
import EmbeddedEmojiPack from "./event-types/embedded-emoji-pack";
import { buildAppSelectUrl } from "../../helpers/nostr/apps";
import EmbeddedGoal from "./event-types/embedded-goal";
import EmbeddedUnknown from "./event-types/embedded-unknown";
export function EmbedEvent({ event }: { event: NostrEvent }) {
switch (event.kind) {
@ -30,12 +28,7 @@ export function EmbedEvent({ event }: { event: NostrEvent }) {
return <EmbeddedEmojiPack pack={event} />;
}
const address = getSharableEventAddress(event);
return (
<Link href={address ? buildAppSelectUrl(address) : ""} isExternal color="blue.500">
{address}
</Link>
);
return <EmbeddedUnknown event={event} />;
}
export function EmbedEventPointer({ pointer }: { pointer: DecodeResult }) {

View File

@ -2,18 +2,19 @@ import { useMemo } from "react";
import { Link, LinkProps } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { getSharableNoteId } from "../helpers/nip19";
import { truncatedId } from "../helpers/nostr/events";
export type NoteLinkProps = LinkProps & {
noteId: string;
};
export const NoteLink = ({ children, noteId, color = "blue.500", ...props }: NoteLinkProps) => {
const encoded = useMemo(() => getSharableNoteId(noteId), [noteId]);
const encoded = useMemo(() => nip19.noteEncode(noteId), [noteId]);
return (
<Link as={RouterLink} to={`/n/${encoded}`} color={color} {...props}>
{children || nip19.noteEncode(noteId)}
{children || truncatedId(nip19.noteEncode(noteId))}
</Link>
);
};

View File

@ -0,0 +1,80 @@
import {
Button,
FormControl,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
ModalProps,
useToast,
} from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import dayjs from "dayjs";
import { EMOJI_PACK_KIND } from "../../helpers/nostr/emoji-packs";
import { DraftNostrEvent } from "../../types/nostr-event";
import { useSigningContext } from "../../providers/signing-provider";
import NostrPublishAction from "../../classes/nostr-publish-action";
import clientRelaysService from "../../services/client-relays";
import replaceableEventLoaderService from "../../services/replaceable-event-requester";
import { getSharableEventAddress } from "../../helpers/nip19";
export default function EmojiPackCreateModal({ onClose, ...props }: Omit<ModalProps, "children">) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const navigate = useNavigate();
const { register, handleSubmit } = useForm({
defaultValues: {
title: "",
},
});
const submit = handleSubmit(async (values) => {
const draft: DraftNostrEvent = {
kind: EMOJI_PACK_KIND,
created_at: dayjs().unix(),
content: "",
tags: [["d", values.title]],
};
try {
const signed = await requestSignature(draft);
const pub = new NostrPublishAction("Create emoji pack", clientRelaysService.getWriteUrls(), signed);
replaceableEventLoaderService.handleEvent(signed);
navigate(`/emojis/${getSharableEventAddress(signed)}`);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
});
return (
<Modal onClose={onClose} size="xl" {...props}>
<ModalOverlay />
<ModalContent as="form" onSubmit={submit}>
<ModalHeader p="4">Create Emoji pack</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" py="0">
<FormControl isRequired>
<FormLabel>Pack name</FormLabel>
<Input type="name" {...register("title", { required: true })} autoComplete="off" />
</FormControl>
</ModalBody>
<ModalFooter p="4">
<Button mr="2" onClick={onClose}>
Cancel
</Button>
<Button colorScheme="brand" type="submit">
Create
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@ -1,56 +1,118 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { useForm } from "react-hook-form";
import { useThrottle } from "react-use";
import dayjs from "dayjs";
import { UserLink } from "../../components/user-link";
import {
Button,
ButtonGroup,
Card,
CardBody,
Divider,
Flex,
Heading,
Image,
Input,
SimpleGrid,
Spacer,
Tag,
Tooltip,
TagCloseButton,
TagLabel,
} from "@chakra-ui/react";
import { UserLink } from "../../components/user-link";
import { ArrowLeftSIcon } from "../../components/icons";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useDeleteEventContext } from "../../providers/delete-event-provider";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import EmojiPackMenu from "./components/emoji-pack-menu";
import EmojiPackFavoriteButton from "./components/emoji-pack-favorite-button";
import { getEmojisFromPack, getPackName } from "../../helpers/nostr/emoji-packs";
import { useState } from "react";
import { EMOJI_PACK_KIND, getEmojisFromPack, getPackName } from "../../helpers/nostr/emoji-packs";
import { useSigningContext } from "../../providers/signing-provider";
import NostrPublishAction from "../../classes/nostr-publish-action";
import clientRelaysService from "../../services/client-relays";
import replaceableEventLoaderService from "../../services/replaceable-event-requester";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
function useListCoordinate() {
const { addr } = useParams() as { addr: string };
const parsed = nip19.decode(addr);
if (parsed.type !== "naddr") throw new Error(`Unknown type ${parsed.type}`);
return parsed.data;
function AddEmojiForm({ onAdd }: { onAdd: (values: { name: string; url: string }) => void }) {
const { register, handleSubmit, watch, getValues, reset } = useForm({
defaultValues: {
name: "",
url: "",
},
});
const submit = handleSubmit((values) => {
onAdd(values);
reset();
});
watch("url");
const previewURL = useThrottle(getValues().url);
return (
<Flex as="form" gap="2" onSubmit={submit}>
<Input placeholder="name" {...register("name", { required: true })} autoComplete="off" />
<Input placeholder="https://example.com/emoji.png" {...register("url", { required: true })} autoComplete="off" />
{previewURL && <Image aspectRatio={1} h="10" src={previewURL} />}
<Button flexShrink={0} type="submit">
Add
</Button>
</Flex>
);
}
export default function EmojiPackView() {
function EmojiTag({ name, url, onRemove, scale }: { name: string; url: string; onRemove?: () => void; scale: number }) {
return (
<Tag>
<Image key={name + url} src={url} title={name} w={scale} h={scale} ml="-1" mr="2" my="1" borderRadius="md" />
<TagLabel flex={1}>{name}</TagLabel>
{onRemove && <TagCloseButton onClick={onRemove} />}
</Tag>
);
}
function EmojiPackPage({ pack }: { pack: NostrEvent }) {
const navigate = useNavigate();
const coordinate = useListCoordinate();
const { deleteEvent } = useDeleteEventContext();
const account = useCurrentAccount();
const { deleteEvent } = useDeleteEventContext();
const { requestSignature } = useSigningContext();
const [scale, setScale] = useState(10);
const pack = useReplaceableEvent(coordinate);
if (!pack)
return (
<>
Looking for pack "{coordinate.identifier}" created by <UserLink pubkey={coordinate.pubkey} />
</>
);
const isAuthor = account?.pubkey === pack.pubkey;
const emojis = getEmojisFromPack(pack);
const [editing, setEditing] = useState(false);
const [draftEmojis, setDraft] = useState(emojis);
const startEdit = () => {
setDraft(emojis);
setEditing(true);
};
const addEmoji = (emoji: { name: string; url: string }) => {
setDraft((a) => a.concat(emoji));
};
const removeEmoji = (name: string) => {
setDraft((a) => a.filter((e) => e.name !== name));
};
const cancelEdit = () => {
setDraft([]);
setEditing(false);
};
const saveEdit = async () => {
const draft: DraftNostrEvent = {
kind: EMOJI_PACK_KIND,
content: pack.content || "",
created_at: dayjs().unix(),
tags: [...pack.tags.filter((t) => t[0] !== "emoji"), ...draftEmojis.map(({ name, url }) => ["emoji", name, url])],
};
const signed = await requestSignature(draft);
const pub = new NostrPublishAction("Update emoji pack", clientRelaysService.getWriteUrls(), signed);
replaceableEventLoaderService.handleEvent(signed);
setEditing(false);
};
return (
<Flex direction="column" px="2" pt="2" pb="8" overflowY="auto" overflowX="hidden" h="full" gap="2">
<Flex gap="2" alignItems="center">
@ -65,45 +127,89 @@ export default function EmojiPackView() {
<Spacer />
{isAuthor && (
<Button colorScheme="red" onClick={() => deleteEvent(pack).then(() => navigate("/lists"))}>
Delete
</Button>
)}
<EmojiPackMenu aria-label="More options" pack={pack} />
<ButtonGroup>
{isAuthor && (
<>
{!editing && (
<Button colorScheme="brand" onClick={startEdit}>
Edit
</Button>
)}
<Button colorScheme="red" onClick={() => deleteEvent(pack).then(() => navigate("/lists"))}>
Delete
</Button>
</>
)}
<EmojiPackMenu aria-label="More options" pack={pack} />
</ButtonGroup>
</Flex>
{emojis.length > 0 && (
<>
<Flex alignItems="flex-end">
<Heading size="md">Emojis</Heading>
<ButtonGroup size="sm" isAttached ml="auto">
<Button variant={scale === 10 ? "solid" : "outline"} onClick={() => setScale(10)}>
SM
</Button>
<Button variant={scale === 16 ? "solid" : "outline"} onClick={() => setScale(16)}>
MD
</Button>
<Button variant={scale === 24 ? "solid" : "outline"} onClick={() => setScale(24)}>
LG
</Button>
</ButtonGroup>
</Flex>
{!editing && (
<Flex alignItems="flex-end">
<Heading size="md">Emojis</Heading>
<ButtonGroup size="sm" isAttached ml="auto">
<Button variant={scale === 10 ? "solid" : "outline"} onClick={() => setScale(10)}>
SM
</Button>
<Button variant={scale === 16 ? "solid" : "outline"} onClick={() => setScale(16)}>
MD
</Button>
<Button variant={scale === 24 ? "solid" : "outline"} onClick={() => setScale(24)}>
LG
</Button>
</ButtonGroup>
</Flex>
)}
<Divider />
<Card variant="elevated">
<CardBody p="2">
<SimpleGrid columns={{ base: 2, sm: 3, md: 2, lg: 4, xl: 6 }} gap="2">
{emojis.map(({ name, url }) => (
<Flex gap="2" alignItems="center">
<Image key={name + url} src={url} title={name} w={scale} h={scale} />
<Tag>{name}</Tag>
</Flex>
))}
</SimpleGrid>
</CardBody>
</Card>
<SimpleGrid columns={{ base: 2, sm: 3, md: 2, lg: 4, xl: 6 }} gap="2">
{(editing ? draftEmojis : emojis).map(({ name, url }) => (
<EmojiTag
key={name + url}
scale={scale}
name={name}
url={url}
onRemove={editing ? () => removeEmoji(name) : undefined}
/>
))}
</SimpleGrid>
</>
)}
{editing && (
<Flex gap="2">
<AddEmojiForm onAdd={addEmoji} />
<Button ml="auto" onClick={cancelEdit}>
Cancel
</Button>
<Button colorScheme="brand" onClick={saveEdit}>
Save
</Button>
</Flex>
)}
</Flex>
);
}
function useListCoordinate() {
const { addr } = useParams() as { addr: string };
const parsed = nip19.decode(addr);
if (parsed.type !== "naddr") throw new Error(`Unknown type ${parsed.type}`);
return parsed.data;
}
export default function EmojiPackView() {
const coordinate = useListCoordinate();
const pack = useReplaceableEvent(coordinate);
if (!pack) {
return (
<>
Looking for pack "{coordinate.identifier}" created by <UserLink pubkey={coordinate.pubkey} />
</>
);
}
return <EmojiPackPage pack={pack} />;
}

View File

@ -1,4 +1,4 @@
import { Button, Divider, Flex, Heading, Link, SimpleGrid, Spacer } from "@chakra-ui/react";
import { Button, Divider, Flex, Heading, Link, SimpleGrid, Spacer, useDisclosure } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { useCurrentAccount } from "../../hooks/use-current-account";
@ -11,6 +11,7 @@ import useSubject from "../../hooks/use-subject";
import EmojiPackCard from "./components/emoji-pack-card";
import useFavoriteEmojiPacks from "../../hooks/use-favorite-emoji-packs";
import useReplaceableEvents from "../../hooks/use-replaceable-events";
import EmojiPackCreateModal from "./create-modal";
function UserEmojiPackMangerPage() {
const account = useCurrentAccount()!;
@ -67,6 +68,7 @@ function UserEmojiPackMangerPage() {
export default function EmojiPacksView() {
const account = useCurrentAccount();
const createModal = useDisclosure();
return (
<Flex direction="column" pt="2" pb="10" gap="2" px={["2", "2", 0]}>
@ -78,9 +80,15 @@ export default function EmojiPacksView() {
<Button as={Link} href="https://emojis-iota.vercel.app/" isExternal rightIcon={<ExternalLinkIcon />}>
Emoji pack manager
</Button>
{account && (
<Button colorScheme="brand" onClick={createModal.onOpen}>
Create Emoji pack
</Button>
)}
</Flex>
{account && <UserEmojiPackMangerPage />}
{createModal.isOpen && <EmojiPackCreateModal isOpen={createModal.isOpen} onClose={createModal.onClose} />}
</Flex>
);
}

View File

@ -28,7 +28,7 @@ function UserGoalsManagerPage() {
if (goals.length === 0) {
return (
<Center p="10" fontSize="lg" whiteSpace="pre-wrap">
<Center p="10" fontSize="lg" whiteSpace="pre">
You don't have any goals,{" "}
<Link as={RouterLink} to="/goals/browse" color="blue.500">
Find a goal