show emoji reactions on notes

This commit is contained in:
hzrd149 2023-08-28 15:43:05 -05:00
parent 6dbd880b5a
commit c79c292315
29 changed files with 32551 additions and 4808 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show emoji reactions on notes

View File

@ -38,6 +38,8 @@ import ListsView from "./views/lists";
import ListView from "./views/lists/list";
import UserListsTab from "./views/user/lists";
import "./services/emoji-packs";
const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));
const SearchView = React.lazy(() => import("./views/search"));

View File

@ -34,13 +34,13 @@ function UserTag({ pubkey, ...props }: { pubkey: string } & Omit<TagProps, "chil
}
export function UserAvatarStack({
users,
pubkeys,
maxUsers,
label = "Users",
...props
}: { users: string[]; maxUsers?: number; label?: string } & FlexProps) {
}: { pubkeys: string[]; maxUsers?: number; label?: string } & FlexProps) {
const { isOpen, onOpen, onClose } = useDisclosure();
const clamped = maxUsers ? users.slice(0, maxUsers) : users;
const clamped = maxUsers ? pubkeys.slice(0, maxUsers) : pubkeys;
return (
<>
@ -49,9 +49,9 @@ export function UserAvatarStack({
{clamped.map((pubkey) => (
<UserAvatar key={pubkey} pubkey={pubkey} size="2xs" />
))}
{clamped.length !== users.length && (
{clamped.length !== pubkeys.length && (
<Text mx="1" fontSize="sm" lineHeight={0}>
+{users.length - clamped.length}
+{pubkeys.length - clamped.length}
</Text>
)}
</Flex>
@ -64,7 +64,7 @@ export function UserAvatarStack({
<ModalCloseButton />
<ModalBody px="4" pb="4" pt="0">
<Flex gap="2" wrap="wrap">
{users.map((pubkey) => (
{pubkeys.map((pubkey) => (
<UserTag key={pubkey} pubkey={pubkey} p="2" fontWeight="bold" fontSize="md" />
))}
</Flex>

View File

@ -0,0 +1,53 @@
import { Flex, FlexProps, Image, useDisclosure } from "@chakra-ui/react";
import { useMemo } from "react";
import { NostrEvent } from "../types/nostr-event";
import useEventReactions from "../hooks/use-event-reactions";
import { DislikeIcon, LikeIcon } from "./icons";
import { groupReactions } from "../helpers/nostr/reactions";
import ReactionDetailsModal from "./reaction-details-modal";
export function ReactionIcon({ emoji, url, count }: { emoji: string; count: number; url?: string }) {
const renderIcon = () => {
if (emoji === "+") return <LikeIcon w="0.8em" h="0.8em" />;
if (emoji === "-") return <DislikeIcon w="0.8em" h="0.8em" />;
if (url) return <Image src={url} w="0.8em" h="0.8em" title={emoji} alt={emoji} />;
return <span>{emoji}</span>;
};
if (count > 1) {
return (
<>
{renderIcon()}
<span>{count}</span>
</>
);
}
return renderIcon();
}
export default function EventReactions({ event, ...props }: Omit<FlexProps, "children"> & { event: NostrEvent }) {
const detailsModal = useDisclosure();
const reactions = useEventReactions(event.id) ?? [];
const grouped = useMemo(() => groupReactions(reactions), [reactions]);
if (grouped.length === 0) return null;
return (
<>
<Flex
maxW="lg"
overflow="hidden"
gap="1"
alignItems="center"
cursor="pointer"
onClick={detailsModal.onOpen}
{...props}
>
{grouped.map((group) => (
<ReactionIcon key={group.emoji} emoji={group.emoji} url={group.url} count={group.count} />
))}
</Flex>
{detailsModal.isOpen && <ReactionDetailsModal isOpen onClose={detailsModal.onClose} reactions={reactions} />}
</>
);
}

View File

@ -361,3 +361,9 @@ export const StopIcon = createIcon({
d: "M7 7V17H17V7H7ZM6 5H18C18.5523 5 19 5.44772 19 6V18C19 18.5523 18.5523 19 18 19H6C5.44772 19 5 18.5523 5 18V6C5 5.44772 5.44772 5 6 5Z",
defaultProps,
});
export const AddReactionIcon = createIcon({
displayName: "AddReactionIcon",
d: "M19.0001 13.9999V16.9999H22.0001V18.9999H18.9991L19.0001 21.9999H17.0001L16.9991 18.9999H14.0001V16.9999H17.0001V13.9999H19.0001ZM20.2426 4.75736C22.505 7.0244 22.5829 10.636 20.4795 12.992L19.06 11.574C20.3901 10.0499 20.3201 7.65987 18.827 6.1701C17.3244 4.67092 14.9076 4.60701 13.337 6.01688L12.0019 7.21524L10.6661 6.01781C9.09098 4.60597 6.67506 4.66808 5.17157 6.17157C3.68183 7.66131 3.60704 10.0473 4.97993 11.6232L13.412 20.069L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736Z",
defaultProps,
});

View File

@ -1,82 +0,0 @@
import { Button, ButtonProps } from "@chakra-ui/react";
import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { useState } from "react";
import { random } from "../../../helpers/array";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useEventReactions from "../../../hooks/use-event-reactions";
import { useSigningContext } from "../../../providers/signing-provider";
import clientRelaysService from "../../../services/client-relays";
import eventReactionsService from "../../../services/event-reactions";
import { getEventRelays } from "../../../services/event-relays";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import { LikeIcon } from "../../icons";
import NostrPublishAction from "../../../classes/nostr-publish-action";
export default function ReactionButton({
event: note,
...props
}: { event: NostrEvent } & Omit<ButtonProps, "children">) {
const { requestSignature } = useSigningContext();
const account = useCurrentAccount();
const reactions = useEventReactions(note.id) ?? [];
const [loading, setLoading] = useState(false);
const handleClick = async (reaction = "+") => {
const eventRelays = getEventRelays(note.id).value;
const event: DraftNostrEvent = {
kind: Kind.Reaction,
content: reaction,
tags: [
["e", note.id, random(eventRelays)],
["p", note.pubkey], // TODO: pick a relay for the user
],
created_at: dayjs().unix(),
};
const signed = await requestSignature(event);
if (signed) {
const writeRelays = clientRelaysService.getWriteUrls();
new NostrPublishAction("Reaction", writeRelays, signed);
eventReactionsService.handleEvent(signed);
}
setLoading(false);
};
const customReaction = () => {
const input = window.prompt("Enter Reaction");
if (!input || [...input].length !== 1) return;
handleClick(input);
};
const isLiked = !!account && reactions.some((event) => event.pubkey === account.pubkey);
return (
// <Popover placement="bottom" trigger="hover" openDelay={500}>
// <PopoverTrigger>
<Button
leftIcon={<LikeIcon />}
aria-label="Like Note"
title="Like Note"
onClick={() => handleClick("+")}
isLoading={loading}
colorScheme={isLiked ? "brand" : undefined}
{...props}
>
{reactions?.length ?? 0}
</Button>
// </PopoverTrigger>
// <PopoverContent>
// <PopoverArrow />
// <PopoverBody>
// <Flex gap="2">
// <IconButton icon={<LikeIcon />} onClick={() => handleClick("+")} aria-label="like" />
// <IconButton icon={<DislikeIcon />} onClick={() => handleClick("-")} aria-label="dislike" />
// <IconButton icon={<span>🤙</span>} onClick={() => handleClick("🤙")} aria-label="different like" />
// <IconButton icon={<span>❤️</span>} onClick={() => handleClick("❤️")} aria-label="different like" />
// <Button onClick={customReaction}>Custom</Button>
// </Flex>
// </PopoverBody>
// </PopoverContent>
// </Popover>
);
}

View File

@ -72,42 +72,49 @@ export default function BookmarkButton({ event, ...props }: { event: NostrEvent
);
return (
<Menu closeOnSelect={false}>
<MenuButton as={IconButton} icon={inLists.length > 0 ? <BookmarkedIcon /> : <BookmarkIcon />} {...props} />
<MenuList minWidth="240px">
{lists.length > 0 && (
<MenuOptionGroup
type="checkbox"
value={inLists.map((list) => getEventCoordinate(list))}
onChange={handleChange}
>
{lists.map((list) => (
<MenuItemOption
key={getEventCoordinate(list)}
value={getEventCoordinate(list)}
isDisabled={account?.readonly && isLoading}
isTruncated
maxW="90vw"
>
{getListName(list)}
</MenuItemOption>
))}
</MenuOptionGroup>
)}
<MenuDivider />
<MenuItem icon={<PlusCircleIcon />} onClick={newListModal.onOpen}>
New list
</MenuItem>
{newListModal.isOpen && (
<NewListModal
onClose={newListModal.onClose}
isOpen
onCreated={newListModal.onClose}
initKind={NOTE_LIST_KIND}
/>
)}
</MenuList>
</Menu>
<>
<Menu closeOnSelect={false}>
<MenuButton
as={IconButton}
icon={inLists.length > 0 ? <BookmarkedIcon /> : <BookmarkIcon />}
isDisabled={account?.readonly ?? true}
{...props}
/>
<MenuList minWidth="240px">
{lists.length > 0 && (
<MenuOptionGroup
type="checkbox"
value={inLists.map((list) => getEventCoordinate(list))}
onChange={handleChange}
>
{lists.map((list) => (
<MenuItemOption
key={getEventCoordinate(list)}
value={getEventCoordinate(list)}
isDisabled={account?.readonly && isLoading}
isTruncated
maxW="90vw"
>
{getListName(list)}
</MenuItemOption>
))}
</MenuOptionGroup>
)}
<MenuDivider />
<MenuItem icon={<PlusCircleIcon />} onClick={newListModal.onOpen}>
New list
</MenuItem>
</MenuList>
</Menu>
{newListModal.isOpen && (
<NewListModal
onClose={newListModal.onClose}
isOpen
onCreated={newListModal.onClose}
initKind={NOTE_LIST_KIND}
allowSelectKind={false}
/>
)}
</>
);
}

View File

@ -1,16 +1,23 @@
import { useContext } from "react";
import { IconButton } from "@chakra-ui/react";
import { ButtonProps, IconButton, IconButtonProps } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import dayjs from "dayjs";
import { NostrEvent } from "../../../types/nostr-event";
import { QuoteRepostIcon } from "../../icons";
import { PostModalContext } from "../../../providers/post-modal-provider";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import { getSharableNoteId } from "../../../helpers/nip19";
export function QuoteRepostButton({ event }: { event: NostrEvent }) {
const account = useCurrentAccount();
export type QuoteRepostButtonProps = Omit<ButtonProps, "children" | "onClick"> & {
event: NostrEvent;
};
export function QuoteRepostButton({
event,
"aria-label": ariaLabel = "Quote repost",
title = "Quote repost",
...props
}: QuoteRepostButtonProps) {
const { openModal } = useContext(PostModalContext);
const handleClick = () => {
@ -25,12 +32,6 @@ export function QuoteRepostButton({ event }: { event: NostrEvent }) {
};
return (
<IconButton
icon={<QuoteRepostIcon />}
onClick={handleClick}
aria-label="Quote repost"
title="Quote repost"
isDisabled={account?.readonly ?? true}
/>
<IconButton icon={<QuoteRepostIcon />} onClick={handleClick} aria-label={ariaLabel} title={title} {...props} />
);
}

View File

@ -0,0 +1,74 @@
import { useMemo, useState } from "react";
import {
Box,
Button,
ButtonProps,
Flex,
HStack,
IconButton,
IconButtonProps,
Image,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useEventReactions from "../../../hooks/use-event-reactions";
import { useSigningContext } from "../../../providers/signing-provider";
import clientRelaysService from "../../../services/client-relays";
import eventReactionsService from "../../../services/event-reactions";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import { AddReactionIcon } from "../../icons";
import ReactionPicker from "../../reaction-picker";
export default function ReactionButton({
event: note,
...props
}: { event: NostrEvent } & Omit<ButtonProps, "children">) {
const { requestSignature } = useSigningContext();
const account = useCurrentAccount();
const reactions = useEventReactions(note.id) ?? [];
const addReaction = async (emoji = "+", url?: string) => {
const event: DraftNostrEvent = {
kind: Kind.Reaction,
content: url ? ":" + emoji + ":" : emoji,
tags: [
["e", note.id],
["p", note.pubkey], // TODO: pick a relay for the user
],
created_at: dayjs().unix(),
};
if (url) event.tags.push(["emoji", emoji, url]);
const signed = await requestSignature(event);
if (signed) {
const writeRelays = clientRelaysService.getWriteUrls();
new NostrPublishAction("Reaction", writeRelays, signed);
eventReactionsService.handleEvent(signed);
}
};
return (
<Popover isLazy>
<PopoverTrigger>
<IconButton icon={<AddReactionIcon />} aria-label="Add Reaction" {...props}>
{reactions?.length ?? 0}
</IconButton>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverBody>
<ReactionPicker onSelect={addReaction} />
</PopoverBody>
</PopoverContent>
</Popover>
);
}

View File

@ -12,27 +12,26 @@ import {
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { RepostIcon } from "../../icons";
import { buildRepost } from "../../../helpers/nostr/events";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import clientRelaysService from "../../../services/client-relays";
import signingService from "../../../services/signing";
import QuoteNote from "../quote-note";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import { useSigningContext } from "../../../providers/signing-provider";
export function RepostButton({ event }: { event: NostrEvent }) {
const { isOpen, onClose, onOpen } = useDisclosure();
const account = useCurrentAccount();
const [loading, setLoading] = useState(false);
const toast = useToast();
const { requestSignature } = useSigningContext();
const handleClick = async () => {
try {
if (!account) throw new Error("not logged in");
setLoading(true);
const draftRepost = buildRepost(event);
const signed = await signingService.requestSignature(draftRepost, account);
const signed = await requestSignature(draftRepost);
const pub = new NostrPublishAction("Repost", clientRelaysService.getWriteUrls(), signed);
await pub.onComplete;
onClose();
@ -49,7 +48,6 @@ export function RepostButton({ event }: { event: NostrEvent }) {
onClick={onOpen}
aria-label="Repost Note"
title="Repost Note"
isDisabled={account?.readonly ?? true}
isLoading={loading}
/>
{isOpen && (

View File

@ -19,26 +19,29 @@ import { NoteMenu } from "./note-menu";
import { EventRelays } from "./note-relays";
import { UserLink } from "../user-link";
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
import ReactionButton from "./buttons/reaction-button";
import ReactionButton from "./components/reaction-button";
import NoteZapButton from "./note-zap-button";
import { ExpandProvider } from "./expanded";
import useSubject from "../../hooks/use-subject";
import appSettings from "../../services/settings/app-settings";
import EventVerificationIcon from "../event-verification-icon";
import { RepostButton } from "./buttons/repost-button";
import { QuoteRepostButton } from "./buttons/quote-repost-button";
import { RepostButton } from "./components/repost-button";
import { QuoteRepostButton } from "./components/quote-repost-button";
import { ExternalLinkIcon } from "../icons";
import NoteContentWithWarning from "./note-content-with-warning";
import { TrustProvider } from "../../providers/trust";
import { NoteLink } from "../note-link";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import BookmarkButton from "./buttons/bookmark-button";
import BookmarkButton from "./components/bookmark-button";
import EventReactions from "../event-reactions";
import { useCurrentAccount } from "../../hooks/use-current-account";
export type NoteProps = {
event: NostrEvent;
variant?: CardProps["variant"];
};
export const Note = React.memo(({ event, variant = "outline" }: NoteProps) => {
const account = useCurrentAccount();
const { showReactions, showSignatureVerification } = useSubject(appSettings);
// if there is a parent intersection observer, register this card
@ -68,11 +71,12 @@ export const Note = React.memo(({ event, variant = "outline" }: NoteProps) => {
<NoteContentWithWarning event={event} />
</CardBody>
<CardFooter padding="2" display="flex" gap="2">
<ButtonGroup size="sm" variant="link">
<ButtonGroup size="sm" variant="link" isDisabled={account?.readonly ?? true}>
<RepostButton event={event} />
<QuoteRepostButton event={event} />
<NoteZapButton event={event} size="sm" />
{showReactions && <ReactionButton event={event} size="sm" />}
{showReactions && <EventReactions event={event} />}
<ReactionButton event={event} size="sm" />
</ButtonGroup>
<Box flexGrow={1} />
{externalLink && (

View File

@ -1,4 +1,5 @@
import { Button, ButtonProps, IconButton, useDisclosure } from "@chakra-ui/react";
import { readablizeSats } from "../../helpers/bolt11";
import { totalZaps } from "../../helpers/zaps";
import { useCurrentAccount } from "../../hooks/use-current-account";
@ -11,12 +12,13 @@ import ZapModal from "../zap-modal";
import { useInvoiceModalContext } from "../../providers/invoice-modal";
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
export default function NoteZapButton({
event,
allowComment,
showEventPreview,
...props
}: { event: NostrEvent; allowComment?: boolean; showEventPreview?: boolean } & Omit<ButtonProps, "children">) {
export type NoteZapButtonProps = Omit<ButtonProps, "children"> & {
event: NostrEvent;
allowComment?: boolean;
showEventPreview?: boolean;
};
export default function NoteZapButton({ event, allowComment, showEventPreview, ...props }: NoteZapButtonProps) {
const account = useCurrentAccount();
const { metadata } = useUserLNURLMetadata(event.pubkey);
const { requestPay } = useInvoiceModalContext();

View File

@ -78,6 +78,8 @@ export default function NoteReactionsModal({
const reactions = useEventReactions(noteId, [], true) ?? [];
const [selected, setSelected] = useState("zaps");
console.log(reactions);
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />

View File

@ -26,7 +26,7 @@ import { NoteContents } from "../note/note-contents";
import { PublishDetails } from "../publish-details";
import { TrustProvider } from "../../providers/trust";
import { ensureNotifyPubkeys, finalizeNote, getContentMentions } from "../../helpers/nostr/post";
import { UserAvatarStack } from "../user-avatar-stack";
import { UserAvatarStack } from "../compact-user-stack";
function emptyDraft(): DraftNostrEvent {
return {
@ -151,7 +151,7 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
isLoading={uploading}
/>
</Flex>
<UserAvatarStack label="Mentions" users={getContentMentions(draft.content)} />
<UserAvatarStack label="Mentions" pubkeys={getContentMentions(draft.content)} />
{draft.content.length > 0 && <Button onClick={togglePreview}>Preview</Button>}
<Button onClick={onClose}>Cancel</Button>
<Button colorScheme="blue" type="submit" isLoading={waiting} onClick={handleSubmit} isDisabled={!canSubmit}>

View File

@ -0,0 +1,51 @@
import {
AvatarGroup,
Box,
Divider,
Heading,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
ModalProps,
} from "@chakra-ui/react";
import { useMemo } from "react";
import { NostrEvent } from "../types/nostr-event";
import { groupReactions } from "../helpers/nostr/reactions";
import { ReactionIcon } from "./event-reactions";
import { UserAvatarLink } from "./user-avatar-link";
export type ReactionDetailsModalProps = Omit<ModalProps, "children"> & {
reactions: NostrEvent[];
};
export default function ReactionDetailsModal({ reactions, onClose, ...props }: ReactionDetailsModalProps) {
const groups = useMemo(() => groupReactions(reactions), [reactions]);
return (
<Modal onClose={onClose} {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader px="4" pb="0">
Reactions
</ModalHeader>
<ModalCloseButton />
<ModalBody display="flex" gap="2" px="4" pt="0" flexWrap="wrap">
{groups.map((group) => (
<Box key={group.emoji}>
<ReactionIcon emoji={group.emoji} count={group.count} url={group.url} />
<AvatarGroup size="sm" flexWrap="wrap">
{group.pubkeys.map((pubkey) => (
<UserAvatarLink key={pubkey} pubkey={pubkey} />
))}
</AvatarGroup>
</Box>
))}
</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@ -0,0 +1,79 @@
import { Button, Divider, Flex, IconButton, Image, Input, Text } from "@chakra-ui/react";
import { DislikeIcon, LikeIcon } from "./icons";
import { useCurrentAccount } from "../hooks/use-current-account";
import useUserEmojiPacks from "../hooks/use-users-emoji-packs";
import useEmojiPack from "../hooks/use-emoji-pack";
export type ReactionPickerProps = {
onSelect: (emoji: string, url?: string) => void;
};
function EmojiPack({ addr, onSelect }: { addr: string; onSelect: ReactionPickerProps["onSelect"] }) {
const pack = useEmojiPack(addr);
if (!pack) return null;
return (
<>
<Flex gap="2" alignItems="center">
<Text whiteSpace="pre">{pack.name}</Text>
<Divider />
</Flex>
<Flex wrap="wrap" gap="2">
{pack.emojis.map((emoji) => (
<IconButton
key={emoji.name}
icon={<Image src={emoji.url} height="1.2rem" />}
aria-label={emoji.name}
variant="outline"
size="sm"
onClick={() => onSelect(emoji.name, emoji.url)}
/>
))}
</Flex>
</>
);
}
export default function ReactionPicker({ onSelect }: ReactionPickerProps) {
const account = useCurrentAccount();
const { packs = [] } = useUserEmojiPacks(account?.pubkey) ?? {};
return (
<Flex direction="column" gap="2">
<Flex wrap="wrap" gap="2">
<IconButton icon={<LikeIcon />} aria-label="Like" variant="outline" size="sm" onClick={() => onSelect("+")} />
<IconButton
icon={<DislikeIcon />}
aria-label="Dislike"
variant="outline"
size="sm"
onClick={() => onSelect("-")}
/>
<IconButton
icon={<span>🤙</span>}
aria-label="Shaka"
variant="outline"
size="sm"
onClick={() => onSelect("🤙")}
/>
<IconButton
icon={<span>🫂</span>}
aria-label="Hug"
variant="outline"
size="sm"
onClick={() => onSelect("🫂")}
/>
<Flex>
<Input placeholder="🔥" display="inline" size="sm" minW="2rem" w="5rem" />
<Button variant="solid" colorScheme="brand" size="sm">
Add
</Button>
</Flex>
</Flex>
{packs.map((addr) => (
<EmojiPack key={addr} addr={addr} onSelect={onSelect} />
))}
</Flex>
);
}

View File

@ -131,8 +131,6 @@ export default function ZapModal({
],
};
console.log(zapRequest);
if (event) zapRequest.tags.push(["e", event.id]);
if (stream) zapRequest.tags.push(["a", getATag(stream)]);

View File

@ -0,0 +1,7 @@
import { NostrEvent } from "../../types/nostr-event";
export function getEmojisFromPack(pack: NostrEvent) {
return pack.tags
.filter((t) => t[0] === "emoji" && t[1] && t[2])
.map((t) => ({ name: t[1] as string, url: t[2] as string }));
}

View File

@ -0,0 +1,19 @@
import { NostrEvent } from "../../types/nostr-event";
export type ReactionGroup = { emoji: string; url?: string; name?: string; count: number; pubkeys: string[] };
export function groupReactions(reactions: NostrEvent[]) {
const groups: Record<string, ReactionGroup> = {};
for (const reactionEvent of reactions) {
const emoji = reactionEvent.content;
const emojiTag = reactionEvent.tags.find((t) => t[0] === "emoji");
const name = emojiTag?.[2];
const url = emojiTag?.[2];
groups[emoji] = groups[emoji] || { emoji, url, name, count: 0, pubkeys: [] };
groups[emoji].count++;
if (!groups[emoji].pubkeys.includes(reactionEvent.pubkey)) {
groups[emoji].pubkeys.push(reactionEvent.pubkey);
}
}
return Array.from(Object.values(groups)).sort((a, b) => b.count - a.count);
}

View File

@ -0,0 +1,11 @@
import { useMemo } from "react";
import { useReadRelayUrls } from "./use-client-relays";
import emojiPacksService from "../services/emoji-packs";
import useSubject from "./use-subject";
export default function useEmojiPack(addr: string, additionalRelays?: string[]) {
const readRelays = useReadRelayUrls(additionalRelays);
const subject = useMemo(() => emojiPacksService.requestEmojiPack(addr, readRelays), [addr, readRelays.join("|")]);
return useSubject(subject);
}

View File

@ -0,0 +1,13 @@
import { useMemo } from "react";
import { useReadRelayUrls } from "./use-client-relays";
import emojiPacksService from "../services/emoji-packs";
import useSubject from "./use-subject";
export default function useUserEmojiPacks(pubkey?: string, additionalRelays?: string[]) {
const readRelays = useReadRelayUrls(additionalRelays);
const subject = useMemo(() => {
if (pubkey) return emojiPacksService.requestUserEmojiList(pubkey, readRelays);
}, [pubkey, readRelays.join("|")]);
return useSubject(subject);
}

View File

@ -0,0 +1,65 @@
import Subject from "../classes/subject";
import { SuperMap } from "../classes/super-map";
import { getEmojisFromPack } from "../helpers/nostr/emoji-packs";
import { NostrEvent } from "../types/nostr-event";
import replaceableEventLoaderService from "./replaceable-event-requester";
const EMOJI_PACK_KIND = 30030;
const USER_EMOJI_LIST_KIND = 10030;
class EmojiPacksService {
emojiPacks = new SuperMap(
() => new Subject<{ event: NostrEvent; name: string; emojis: { name: string; url: string }[] }>(),
);
userEmojiPacks = new SuperMap(() => new Subject<{ packs: string[]; event: NostrEvent }>());
getEmojiPacks(pubkey: string) {
return this.emojiPacks.get(pubkey);
}
requestEmojiPack(addr: string, relays: string[]) {
const [kind, pubkey, name] = addr.split(":");
const sub = this.emojiPacks.get(addr);
if (!sub.value) {
const request = replaceableEventLoaderService.requestEvent(relays, EMOJI_PACK_KIND, pubkey, name);
sub.connectWithHandler(request, (event, next) => {
const name = event.tags.find((t) => t[0] === "d" && t[1])?.[1];
if (!name) return;
next({
name,
emojis: getEmojisFromPack(event),
event,
});
});
}
return sub;
}
requestUserEmojiList(pubkey: string, relays: string[]) {
const sub = this.userEmojiPacks.get(pubkey);
const request = replaceableEventLoaderService.requestEvent(relays, USER_EMOJI_LIST_KIND, pubkey);
if (!sub.value) {
sub.connectWithHandler(request, (event, next) => {
next({
packs: event.tags.filter((t) => t[0] === "a" && t[1]).map((t) => t[1] as string),
event,
});
});
}
return sub;
}
}
const emojiPacksService = new EmojiPacksService();
if (import.meta.env.DEV) {
//@ts-ignore
window.emojiPacksService = emojiPacksService;
}
export default emojiPacksService;

View File

@ -24,25 +24,23 @@ export default function ListCard({ cord, event: maybeEvent }: { cord?: string; e
return (
<Card>
<CardHeader display="flex" p="2" flex="1" gap="2">
<Box>
<Heading size="md">
<Link as={RouterLink} to={`/lists/${link}`}>
{getListName(event)}
</Link>
</Heading>
<Flex gap="2">
<Text>Created by:</Text>
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
</Flex>
<Text>Updated: {dayjs.unix(event.created_at).fromNow()}</Text>
</Box>
<CardHeader p="2" pb="0" flex="1">
<Heading size="md">
<Link as={RouterLink} to={`/lists/${link}`}>
{getListName(event)}
</Link>
</Heading>
</CardHeader>
<CardBody p="2">
<Flex gap="2">
<Text>Created by:</Text>
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
</Flex>
<Text>Updated: {dayjs.unix(event.created_at).fromNow()}</Text>
{people.length > 0 && (
<>
<Text>{people.length} people</Text>
<Text>People ({people.length}):</Text>
<AvatarGroup overflow="hidden" mb="2" max={16} size="sm">
{people.map(({ pubkey, relay }) => (
<UserAvatarLink key={pubkey} pubkey={pubkey} relay={relay} />
@ -52,7 +50,7 @@ export default function ListCard({ cord, event: maybeEvent }: { cord?: string; e
)}
{notes.length > 0 && (
<>
<Text>{notes.length} notes</Text>
<Text>Notes ({notes.length}):</Text>
<Flex gap="2" wrap="wrap">
{notes.map(({ id, relay }) => (
<NoteLink key={id} noteId={id} />
@ -61,7 +59,7 @@ export default function ListCard({ cord, event: maybeEvent }: { cord?: string; e
</>
)}
</CardBody>
<CardFooter p="2" display="flex">
<CardFooter p="2" display="flex" pt="0">
<EventRelays event={event} ml="auto" />
</CardFooter>
</Card>

View File

@ -0,0 +1,51 @@
import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { NostrEvent } from "../../../types/nostr-event";
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons";
import { getSharableEventNaddr } from "../../../helpers/nip19";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { useDeleteEventContext } from "../../../providers/delete-event-provider";
export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
const account = useCurrentAccount();
const infoModal = useDisclosure();
const { deleteEvent } = useDeleteEventContext();
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const naddr = getSharableEventNaddr(list);
return (
<>
<MenuIconButton {...props}>
{naddr && (
<>
<MenuItem onClick={() => window.open(buildAppSelectUrl(naddr), "_blank")} icon={<ExternalLinkIcon />}>
View in app...
</MenuItem>
<MenuItem onClick={() => copyToClipboard("nostr:" + naddr)} icon={<RepostIcon />}>
Copy Share Link
</MenuItem>
</>
)}
{account?.pubkey === list.pubkey && (
<MenuItem icon={<TrashIcon />} color="red.500" onClick={() => deleteEvent(list)}>
Delete List
</MenuItem>
)}
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
</MenuIconButton>
{infoModal.isOpen && (
<NoteDebugModal event={list} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
)}
</>
);
}

View File

@ -23,12 +23,19 @@ import { useSigningContext } from "../../../providers/signing-provider";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import clientRelaysService from "../../../services/client-relays";
export type NewListModalProps = { onCreated?: (list: NostrEvent) => void; initKind?: number } & Omit<
ModalProps,
"children"
>;
export type NewListModalProps = Omit<ModalProps, "children"> & {
onCreated?: (list: NostrEvent) => void;
initKind?: number;
allowSelectKind?: boolean;
};
export default function NewListModal({ onClose, onCreated, initKind, ...props }: NewListModalProps) {
export default function NewListModal({
onClose,
onCreated,
initKind,
allowSelectKind = true,
...props
}: NewListModalProps) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const { handleSubmit, register, formState } = useForm({
@ -59,19 +66,29 @@ export default function NewListModal({ onClose, onCreated, initKind, ...props }:
<Modal onClose={onClose} {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader p="4">New List</ModalHeader>
<ModalHeader px="4" pb="0">
New List
</ModalHeader>
<ModalCloseButton />
<ModalBody as="form" p="4" display="flex" gap="2" flexDirection="column" onSubmit={submit}>
<FormControl isRequired>
<FormLabel>List kind</FormLabel>
<Select {...register("kind", { valueAsNumber: true, required: true })}>
<option value={PEOPLE_LIST_KIND}>People List</option>
<option value={NOTE_LIST_KIND}>Note List</option>
</Select>
</FormControl>
<ModalBody as="form" px="4" pt="0" display="flex" gap="2" flexDirection="column" onSubmit={submit}>
{allowSelectKind && (
<FormControl isRequired>
<FormLabel>List kind</FormLabel>
<Select {...register("kind", { valueAsNumber: true, required: true })}>
<option value={PEOPLE_LIST_KIND}>People List</option>
<option value={NOTE_LIST_KIND}>Note List</option>
</Select>
</FormControl>
)}
<FormControl isRequired>
<FormLabel>Name</FormLabel>
<Input type="text" {...register("name", { required: true })} autoComplete="off" />
<Input
type="text"
{...register("name", { required: true })}
autoComplete="off"
placeholder="List name"
autoFocus
/>
</FormControl>
<ButtonGroup ml="auto">
<Button onClick={onClose}>Cancel</Button>

View File

@ -34,7 +34,7 @@ function ListsPage() {
>
Listr
</Button>
<Button leftIcon={<PlusCircleIcon />} onClick={newList.onOpen}>
<Button leftIcon={<PlusCircleIcon />} onClick={newList.onOpen} colorScheme="brand">
New List
</Button>
</Flex>

View File

@ -2,8 +2,8 @@ import { Link as RouterList, useNavigate, useParams } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { UserLink } from "../../components/user-link";
import { Button, Divider, Flex, Heading, IconButton, SimpleGrid, useDisclosure } from "@chakra-ui/react";
import { ArrowLeftSIcon, CodeIcon } from "../../components/icons";
import { Button, Divider, Flex, Heading, SimpleGrid } from "@chakra-ui/react";
import { ArrowLeftSIcon } from "../../components/icons";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useDeleteEventContext } from "../../providers/delete-event-provider";
import { parseCoordinate } from "../../helpers/nostr/events";
@ -11,10 +11,9 @@ import { getEventsFromList, getListName, getPubkeysFromList } from "../../helper
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import { EventRelays } from "../../components/note/note-relays";
import UserCard from "./components/user-card";
import NoteDebugModal from "../../components/debug-modals/note-debug-modal";
import { Note } from "../../components/note";
import NoteCard from "./components/note-card";
import { TrustProvider } from "../../providers/trust";
import ListMenu from "./components/list-menu";
function useListCoordinate() {
const { addr } = useParams() as { addr: string };
@ -32,7 +31,6 @@ function useListCoordinate() {
export default function ListView() {
const navigate = useNavigate();
const debug = useDisclosure();
const coordinate = useListCoordinate();
const { deleteEvent } = useDeleteEventContext();
const account = useCurrentAccount();
@ -68,7 +66,7 @@ export default function ListView() {
Delete
</Button>
)}
<IconButton icon={<CodeIcon />} aria-label="Show raw" onClick={debug.onOpen} />
<ListMenu aria-label="More options" list={event} />
</Flex>
{people.length > 0 && (
@ -96,8 +94,6 @@ export default function ListView() {
</TrustProvider>
</>
)}
{debug.isOpen && <NoteDebugModal event={event} isOpen onClose={debug.onClose} size="4xl" />}
</Flex>
);
}

View File

@ -5,7 +5,7 @@ import { Kind } from "nostr-tools";
import dayjs from "dayjs";
import { NostrEvent } from "../../../types/nostr-event";
import { UserAvatarStack } from "../../../components/user-avatar-stack";
import { UserAvatarStack } from "../../../components/compact-user-stack";
import { ThreadItem, getThreadMembers } from "../../../helpers/thread";
import { NoteContents } from "../../../components/note/note-contents";
import { addReplyTags, ensureNotifyPubkeys, finalizeNote, getContentMentions } from "../../../helpers/nostr/post";
@ -79,7 +79,7 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
<Button onClick={onCancel}>Cancel</Button>
<Button type="submit">Submit</Button>
</ButtonGroup>
<UserAvatarStack label="Notify" users={notifyPubkeys} />
<UserAvatarStack label="Notify" pubkeys={notifyPubkeys} />
{getValues().content.length > 0 && (
<Button size="sm" ml="auto" onClick={showPreview.onToggle}>
Preview

36580
stats.html

File diff suppressed because one or more lines are too long