diff --git a/.changeset/brown-seas-visit.md b/.changeset/brown-seas-visit.md new file mode 100644 index 000000000..32e40e5c2 --- /dev/null +++ b/.changeset/brown-seas-visit.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Show emoji reactions on notes diff --git a/src/app.tsx b/src/app.tsx index 3ebb21995..38192236b 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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")); diff --git a/src/components/user-avatar-stack.tsx b/src/components/compact-user-stack.tsx similarity index 86% rename from src/components/user-avatar-stack.tsx rename to src/components/compact-user-stack.tsx index 06d997c63..d53db0b43 100644 --- a/src/components/user-avatar-stack.tsx +++ b/src/components/compact-user-stack.tsx @@ -34,13 +34,13 @@ function UserTag({ pubkey, ...props }: { pubkey: string } & Omit @@ -49,9 +49,9 @@ export function UserAvatarStack({ {clamped.map((pubkey) => ( ))} - {clamped.length !== users.length && ( + {clamped.length !== pubkeys.length && ( - +{users.length - clamped.length} + +{pubkeys.length - clamped.length} )} @@ -64,7 +64,7 @@ export function UserAvatarStack({ - {users.map((pubkey) => ( + {pubkeys.map((pubkey) => ( ))} diff --git a/src/components/event-reactions.tsx b/src/components/event-reactions.tsx new file mode 100644 index 000000000..a0a9868a0 --- /dev/null +++ b/src/components/event-reactions.tsx @@ -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 ; + if (emoji === "-") return ; + if (url) return {emoji}; + return {emoji}; + }; + + if (count > 1) { + return ( + <> + {renderIcon()} + {count} + + ); + } + return renderIcon(); +} + +export default function EventReactions({ event, ...props }: Omit & { event: NostrEvent }) { + const detailsModal = useDisclosure(); + const reactions = useEventReactions(event.id) ?? []; + const grouped = useMemo(() => groupReactions(reactions), [reactions]); + + if (grouped.length === 0) return null; + + return ( + <> + + {grouped.map((group) => ( + + ))} + + {detailsModal.isOpen && } + + ); +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 4ee698955..b026627b1 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -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, +}); diff --git a/src/components/note/buttons/reaction-button.tsx b/src/components/note/buttons/reaction-button.tsx deleted file mode 100644 index 2a6216fe7..000000000 --- a/src/components/note/buttons/reaction-button.tsx +++ /dev/null @@ -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) { - 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 ( - // - // - - // - // - // - // - // - // } onClick={() => handleClick("+")} aria-label="like" /> - // } onClick={() => handleClick("-")} aria-label="dislike" /> - // 🤙} onClick={() => handleClick("🤙")} aria-label="different like" /> - // ❤️} onClick={() => handleClick("❤️")} aria-label="different like" /> - // - // - // - // - // - ); -} diff --git a/src/components/note/buttons/bookmark-button.tsx b/src/components/note/components/bookmark-button.tsx similarity index 66% rename from src/components/note/buttons/bookmark-button.tsx rename to src/components/note/components/bookmark-button.tsx index 7cc65b99d..01aaa48cd 100644 --- a/src/components/note/buttons/bookmark-button.tsx +++ b/src/components/note/components/bookmark-button.tsx @@ -72,42 +72,49 @@ export default function BookmarkButton({ event, ...props }: { event: NostrEvent ); return ( - - 0 ? : } {...props} /> - - {lists.length > 0 && ( - getEventCoordinate(list))} - onChange={handleChange} - > - {lists.map((list) => ( - - {getListName(list)} - - ))} - - )} - - } onClick={newListModal.onOpen}> - New list - - - {newListModal.isOpen && ( - - )} - - + <> + + 0 ? : } + isDisabled={account?.readonly ?? true} + {...props} + /> + + {lists.length > 0 && ( + getEventCoordinate(list))} + onChange={handleChange} + > + {lists.map((list) => ( + + {getListName(list)} + + ))} + + )} + + } onClick={newListModal.onOpen}> + New list + + + + {newListModal.isOpen && ( + + )} + ); } diff --git a/src/components/note/buttons/quote-repost-button.tsx b/src/components/note/components/quote-repost-button.tsx similarity index 58% rename from src/components/note/buttons/quote-repost-button.tsx rename to src/components/note/components/quote-repost-button.tsx index 4d10752bb..231baaaa6 100644 --- a/src/components/note/buttons/quote-repost-button.tsx +++ b/src/components/note/components/quote-repost-button.tsx @@ -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 & { + 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 ( - } - onClick={handleClick} - aria-label="Quote repost" - title="Quote repost" - isDisabled={account?.readonly ?? true} - /> + } onClick={handleClick} aria-label={ariaLabel} title={title} {...props} /> ); } diff --git a/src/components/note/components/reaction-button.tsx b/src/components/note/components/reaction-button.tsx new file mode 100644 index 000000000..2f0f983c8 --- /dev/null +++ b/src/components/note/components/reaction-button.tsx @@ -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) { + 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 ( + + + } aria-label="Add Reaction" {...props}> + {reactions?.length ?? 0} + + + + + + + + + + ); +} diff --git a/src/components/note/buttons/repost-button.tsx b/src/components/note/components/repost-button.tsx similarity index 85% rename from src/components/note/buttons/repost-button.tsx rename to src/components/note/components/repost-button.tsx index 4cb39f66b..fbc003de7 100644 --- a/src/components/note/buttons/repost-button.tsx +++ b/src/components/note/components/repost-button.tsx @@ -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 && ( diff --git a/src/components/note/index.tsx b/src/components/note/index.tsx index 4180efc07..2c556ad4e 100644 --- a/src/components/note/index.tsx +++ b/src/components/note/index.tsx @@ -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) => { - + - {showReactions && } + {showReactions && } + {externalLink && ( diff --git a/src/components/note/note-zap-button.tsx b/src/components/note/note-zap-button.tsx index aac1afd8b..d4b615dc3 100644 --- a/src/components/note/note-zap-button.tsx +++ b/src/components/note/note-zap-button.tsx @@ -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) { +export type NoteZapButtonProps = Omit & { + 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(); diff --git a/src/components/note/note-zaps-modal.tsx b/src/components/note/note-zaps-modal.tsx index 82cc39d46..c2b0ec436 100644 --- a/src/components/note/note-zaps-modal.tsx +++ b/src/components/note/note-zaps-modal.tsx @@ -78,6 +78,8 @@ export default function NoteReactionsModal({ const reactions = useEventReactions(noteId, [], true) ?? []; const [selected, setSelected] = useState("zaps"); + console.log(reactions); + return ( diff --git a/src/components/post-modal/index.tsx b/src/components/post-modal/index.tsx index e028492df..40afe8e3d 100644 --- a/src/components/post-modal/index.tsx +++ b/src/components/post-modal/index.tsx @@ -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} /> - + {draft.content.length > 0 && } + + + {packs.map((addr) => ( + + ))} + + ); +} diff --git a/src/components/zap-modal.tsx b/src/components/zap-modal.tsx index 16deb71af..5e5659061 100644 --- a/src/components/zap-modal.tsx +++ b/src/components/zap-modal.tsx @@ -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)]); diff --git a/src/helpers/nostr/emoji-packs.ts b/src/helpers/nostr/emoji-packs.ts new file mode 100644 index 000000000..e4c7d1c60 --- /dev/null +++ b/src/helpers/nostr/emoji-packs.ts @@ -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 })); +} diff --git a/src/helpers/nostr/reactions.ts b/src/helpers/nostr/reactions.ts new file mode 100644 index 000000000..f78cbb794 --- /dev/null +++ b/src/helpers/nostr/reactions.ts @@ -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 = {}; + 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); +} diff --git a/src/hooks/use-emoji-pack.ts b/src/hooks/use-emoji-pack.ts new file mode 100644 index 000000000..3007f9f26 --- /dev/null +++ b/src/hooks/use-emoji-pack.ts @@ -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); +} diff --git a/src/hooks/use-users-emoji-packs.ts b/src/hooks/use-users-emoji-packs.ts new file mode 100644 index 000000000..1fb1b402d --- /dev/null +++ b/src/hooks/use-users-emoji-packs.ts @@ -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); +} diff --git a/src/services/emoji-packs.ts b/src/services/emoji-packs.ts new file mode 100644 index 000000000..238d9d631 --- /dev/null +++ b/src/services/emoji-packs.ts @@ -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; diff --git a/src/views/lists/components/list-card.tsx b/src/views/lists/components/list-card.tsx index 8095a23ce..d53da1892 100644 --- a/src/views/lists/components/list-card.tsx +++ b/src/views/lists/components/list-card.tsx @@ -24,25 +24,23 @@ export default function ListCard({ cord, event: maybeEvent }: { cord?: string; e return ( - - - - - {getListName(event)} - - - - Created by: - - - - Updated: {dayjs.unix(event.created_at).fromNow()} - + + + + {getListName(event)} + + + + Created by: + + + + Updated: {dayjs.unix(event.created_at).fromNow()} {people.length > 0 && ( <> - {people.length} people + People ({people.length}): {people.map(({ pubkey, relay }) => ( @@ -52,7 +50,7 @@ export default function ListCard({ cord, event: maybeEvent }: { cord?: string; e )} {notes.length > 0 && ( <> - {notes.length} notes + Notes ({notes.length}): {notes.map(({ id, relay }) => ( @@ -61,7 +59,7 @@ export default function ListCard({ cord, event: maybeEvent }: { cord?: string; e )} - + diff --git a/src/views/lists/components/list-menu.tsx b/src/views/lists/components/list-menu.tsx new file mode 100644 index 000000000..b9d9a25e6 --- /dev/null +++ b/src/views/lists/components/list-menu.tsx @@ -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) { + const account = useCurrentAccount(); + const infoModal = useDisclosure(); + + const { deleteEvent } = useDeleteEventContext(); + + const [_clipboardState, copyToClipboard] = useCopyToClipboard(); + + const naddr = getSharableEventNaddr(list); + + return ( + <> + + {naddr && ( + <> + window.open(buildAppSelectUrl(naddr), "_blank")} icon={}> + View in app... + + copyToClipboard("nostr:" + naddr)} icon={}> + Copy Share Link + + + )} + {account?.pubkey === list.pubkey && ( + } color="red.500" onClick={() => deleteEvent(list)}> + Delete List + + )} + }> + View Raw + + + + {infoModal.isOpen && ( + + )} + + ); +} diff --git a/src/views/lists/components/new-list-modal.tsx b/src/views/lists/components/new-list-modal.tsx index 7a420ff24..67ec25011 100644 --- a/src/views/lists/components/new-list-modal.tsx +++ b/src/views/lists/components/new-list-modal.tsx @@ -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 & { + 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 }: - New List + + New List + - - - List kind - - + + {allowSelectKind && ( + + List kind + + + )} Name - + diff --git a/src/views/lists/index.tsx b/src/views/lists/index.tsx index 5e7e94ce9..08a34affa 100644 --- a/src/views/lists/index.tsx +++ b/src/views/lists/index.tsx @@ -34,7 +34,7 @@ function ListsPage() { > Listr - diff --git a/src/views/lists/list.tsx b/src/views/lists/list.tsx index dd89a1f74..b1ec3c531 100644 --- a/src/views/lists/list.tsx +++ b/src/views/lists/list.tsx @@ -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 )} - } aria-label="Show raw" onClick={debug.onOpen} /> + {people.length > 0 && ( @@ -96,8 +94,6 @@ export default function ListView() { )} - - {debug.isOpen && } ); } diff --git a/src/views/note/components/reply-form.tsx b/src/views/note/components/reply-form.tsx index 67ca5c1d4..4bbadd49e 100644 --- a/src/views/note/components/reply-form.tsx +++ b/src/views/note/components/reply-form.tsx @@ -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 - + {getValues().content.length > 0 && (