diff --git a/.changeset/proud-forks-fry.md b/.changeset/proud-forks-fry.md new file mode 100644 index 000000000..a25b7999b --- /dev/null +++ b/.changeset/proud-forks-fry.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add option to pin notes diff --git a/.changeset/spicy-flowers-march.md b/.changeset/spicy-flowers-march.md new file mode 100644 index 000000000..39c1baca9 --- /dev/null +++ b/.changeset/spicy-flowers-march.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Show pinned notes on user profile diff --git a/src/components/embed-types/emoji.tsx b/src/components/embed-types/emoji.tsx index f8cd60553..0923ab902 100644 --- a/src/components/embed-types/emoji.tsx +++ b/src/components/embed-types/emoji.tsx @@ -10,7 +10,14 @@ export function embedEmoji(content: EmbedableContent, note: NostrEvent | DraftNo const emojiTag = note.tags.filter(isEmojiTag).find((t) => t[1].toLowerCase() === match[1].toLowerCase()); if (emojiTag) { return ( - + ); } return null; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 0706300a8..f088b9a20 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -60,6 +60,7 @@ import Wallet02 from "./icons/wallet-02"; import Download01 from "./icons/download-01"; import Repeat01 from "./icons/repeat-01"; import ReverseLeft from "./icons/reverse-left"; +import Pin01 from "./icons/pin-01"; const defaultProps: IconProps = { boxSize: 4 }; @@ -89,6 +90,7 @@ export const ChevronRightIcon = ChevronRight; export const LightningIcon = Zap; export const RelayIcon = Server04; export const BroadcastEventIcon = Share07; +export const PinIcon = Pin01; export const ExternalLinkIcon = Share04; diff --git a/src/components/note-translation-modal/index.tsx b/src/components/note-translation-modal/index.tsx index 9c86dd21b..5d50403f6 100644 --- a/src/components/note-translation-modal/index.tsx +++ b/src/components/note-translation-modal/index.tsx @@ -1,5 +1,11 @@ -import { useCallback, useState } from "react"; +import { MouseEventHandler, useCallback, useState } from "react"; import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, + Box, Button, Card, CardBody, @@ -14,6 +20,7 @@ import { ModalOverlay, ModalProps, Select, + Spacer, Spinner, Text, useToast, @@ -43,65 +50,53 @@ function getTranslationRequestLanguage(request: NostrEvent) { return codes.find((code) => code.iso639_1 === targetLanguage); } -function TranslationResult({ result, request }: { result: NostrEvent; request?: NostrEvent }) { - const requester = result.tags.find(isPTag)?.[1]; - const lang = request && getTranslationRequestLanguage(request); - - return ( - - - - - {lang && Translated to {lang.nativeName}} - - - - {requester && ( - - Requested by - - )} - - - - ); -} - function TranslationRequest({ request }: { request: NostrEvent }) { const lang = getTranslationRequestLanguage(request); const requestRelays = request.tags.find((t) => t[0] === "relays")?.slice(1); const readRelays = useReadRelayUrls(); - const timeline = useTimelineLoader(`${getEventUID(request)}-offers`, requestRelays || readRelays, { - kinds: [DMV_STATUS_KIND], + const timeline = useTimelineLoader(`${getEventUID(request)}-offers-results`, requestRelays || readRelays, { + kinds: [DMV_STATUS_KIND, DMV_TRANSLATE_RESULT_KIND], "#e": [request.id], }); - const offers = useSubject(timeline.timeline); + const events = useSubject(timeline.timeline); + const dvmStatuses: Record = {}; + for (const event of events) { + if ( + (event.kind === DMV_STATUS_KIND || event.kind === DMV_TRANSLATE_RESULT_KIND) && + (!dvmStatuses[event.pubkey] || dvmStatuses[event.pubkey].created_at < event.created_at) + ) { + dvmStatuses[event.pubkey] = event; + } + } return ( - Requested translation to {lang?.nativeName} + + Requested translation to {lang?.nativeName} + - - {offers.length === 0 ? ( - - - Waiting for offers - - ) : ( - - Offers ({offers.length}) - - )} - {offers.map((offer) => ( - - ))} - + {Object.keys(dvmStatuses).length === 0 && ( + + + Waiting for offers + + )} + + {Object.values(dvmStatuses).map((event) => { + switch (event.kind) { + case DMV_STATUS_KIND: + return ; + case DMV_TRANSLATE_RESULT_KIND: + return ; + } + })} + ); } @@ -115,10 +110,11 @@ function TranslationOffer({ offer }: { offer: NostrEvent }) { const [paid, setPaid] = useState(false); const [paying, setPaying] = useState(false); - const payInvoice = async () => { + const payInvoice: MouseEventHandler = async (e) => { try { if (window.webln && invoice) { setPaying(true); + e.stopPropagation(); await window.webln.sendPayment(invoice); setPaid(true); } @@ -129,27 +125,51 @@ function TranslationOffer({ offer }: { offer: NostrEvent }) { }; return ( - - - - + + + + + + Offered + - {invoice && amountMsat && ( - - )} - - {offer.content} - + {invoice && amountMsat && ( + + )} + + + + + {offer.content} + + + ); +} + +function TranslationResult({ result }: { result: NostrEvent }) { + return ( + + + + + + Translated Note + + + + + + + ); } @@ -187,16 +207,12 @@ export default function NoteTranslationModal({ }, [requestSignature, note, readRelays]); const timeline = useTimelineLoader(`${getEventUID(note)}-translations`, readRelays, { - kinds: [DMV_TRANSLATE_JOB_KIND, DMV_TRANSLATE_RESULT_KIND], + kinds: [DMV_TRANSLATE_JOB_KIND], "#i": [note.id], }); const events = useSubject(timeline.timeline); - const filteredEvents = events.filter( - (e, i, arr) => - e.kind === DMV_TRANSLATE_RESULT_KIND || - (e.kind === DMV_TRANSLATE_JOB_KIND && !arr.some((r) => r.tags.some((t) => isETag(t) && t[1] === e.id))), - ); + const jobs = events.filter((e) => e.kind === DMV_TRANSLATE_JOB_KIND); return ( @@ -217,16 +233,9 @@ export default function NoteTranslationModal({ Request new translation - {filteredEvents.map((event) => { - switch (event.kind) { - case DMV_TRANSLATE_JOB_KIND: - return ; - case DMV_TRANSLATE_RESULT_KIND: - const requestId = event.tags.find(isETag)?.[1]; - const request = events.find((e) => e.id === requestId); - return ; - } - })} + {jobs.map((event) => ( + + ))} diff --git a/src/components/note/components/note-proxy-link.tsx b/src/components/note/components/note-proxy-link.tsx new file mode 100644 index 000000000..d976696fc --- /dev/null +++ b/src/components/note/components/note-proxy-link.tsx @@ -0,0 +1,25 @@ +import { IconButton, IconButtonProps, Link } from "@chakra-ui/react"; +import { ExternalLinkIcon } from "../../icons"; +import { useMemo } from "react"; +import { NostrEvent } from "../../../types/nostr-event"; + +export default function NoteProxyLink({ + event, + ...props +}: Omit & { event: NostrEvent }) { + const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr" || t[0] === "proxy"), [event])?.[1]; + + if (!externalLink) return null; + + return ( + } + href={externalLink} + target="_blank" + aria-label="Open External" + title="Open External" + {...props} + /> + ); +} diff --git a/src/components/note/components/repost-modal.tsx b/src/components/note/components/repost-modal.tsx index 8c1ecfa21..f4712464e 100644 --- a/src/components/note/components/repost-modal.tsx +++ b/src/components/note/components/repost-modal.tsx @@ -24,7 +24,7 @@ import NostrPublishAction from "../../../classes/nostr-publish-action"; import clientRelaysService from "../../../services/client-relays"; import { useSigningContext } from "../../../providers/signing-provider"; import { ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon } from "../../icons"; -import useJoinedCommunitiesList from "../../../hooks/use-communities-joined-list"; +import useUserCommunitiesList from "../../../hooks/use-user-communities-list"; import useCurrentAccount from "../../../hooks/use-current-account"; import { AddressPointer } from "nostr-tools/lib/types/nip19"; import { createCoordinate } from "../../../services/replaceable-event-requester"; @@ -54,7 +54,7 @@ export default function RepostModal({ const toast = useToast(); const { requestSignature } = useSigningContext(); const showCommunities = useDisclosure(); - const { pointers } = useJoinedCommunitiesList(account?.pubkey); + const { pointers } = useUserCommunitiesList(account?.pubkey); const [loading, setLoading] = useState(false); const repost = async (communityPointer?: AddressPointer) => { diff --git a/src/components/note/index.tsx b/src/components/note/index.tsx index 6fe8d284a..7e8fd4ee1 100644 --- a/src/components/note/index.tsx +++ b/src/components/note/index.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef } from "react"; +import React, { useRef } from "react"; import { Box, ButtonGroup, @@ -29,7 +29,7 @@ import appSettings from "../../services/settings/app-settings"; import EventVerificationIcon from "../event-verification-icon"; import { RepostButton } from "./components/repost-button"; import { QuoteRepostButton } from "./components/quote-repost-button"; -import { ExternalLinkIcon, ReplyIcon } from "../icons"; +import { ReplyIcon } from "../icons"; import NoteContentWithWarning from "./note-content-with-warning"; import { TrustProvider } from "../../providers/trust"; import { useRegisterIntersectionEntity } from "../../providers/intersection-observer"; @@ -47,6 +47,7 @@ import { nip19 } from "nostr-tools"; import NoteCommunityMetadata from "./note-community-metadata"; import useSingleEvent from "../../hooks/use-single-event"; import { InlineNoteContent } from "./inline-note-content"; +import NoteProxyLink from "./components/note-proxy-link"; export type NoteProps = Omit & { event: NostrEvent; @@ -79,9 +80,6 @@ export const Note = React.memo( const refs = getReferences(event); const repliedTo = useSingleEvent(refs.replyId); - // find mostr external link - const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr" || t[0] === "proxy"), [event])?.[1]; - const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false }); const reactionButtons = showReactions && ; @@ -138,17 +136,7 @@ export const Note = React.memo( {!showReactionsOnNewLine && reactionButtons} - {externalLink && ( - } - aria-label="Open External" - href={externalLink} - size="xs" - variant="ghost" - target="_blank" - /> - )} + diff --git a/src/components/note/note-menu.tsx b/src/components/note/note-menu.tsx index 0fea76815..3b336c8ce 100644 --- a/src/components/note/note-menu.tsx +++ b/src/components/note/note-menu.tsx @@ -1,11 +1,8 @@ -import { useCallback } from "react"; -import { MenuItem, useDisclosure } from "@chakra-ui/react"; +import { useCallback, useState } from "react"; +import { MenuItem, useDisclosure, useToast } from "@chakra-ui/react"; import { useCopyToClipboard } from "react-use"; import { nip19 } from "nostr-tools"; - -import { getSharableEventAddress } from "../../helpers/nip19"; -import { NostrEvent } from "../../types/nostr-event"; -import { CustomMenuIconButton, MenuIconButtonProps } from "../menu-icon-button"; +import dayjs from "dayjs"; import { BroadcastEventIcon, @@ -17,7 +14,11 @@ import { RepostIcon, TrashIcon, UnmuteIcon, + PinIcon, } from "../icons"; +import { getSharableEventAddress } from "../../helpers/nip19"; +import { DraftNostrEvent, NostrEvent, isETag } from "../../types/nostr-event"; +import { CustomMenuIconButton, MenuIconButtonProps } from "../menu-icon-button"; import NoteReactionsModal from "./note-zaps-modal"; import NoteDebugModal from "../debug-modals/note-debug-modal"; import useCurrentAccount from "../../hooks/use-current-account"; @@ -30,6 +31,49 @@ import useUserMuteFunctions from "../../hooks/use-user-mute-functions"; import { useMuteModalContext } from "../../providers/mute-modal-provider"; import NoteTranslationModal from "../note-translation-modal"; import Translate01 from "../icons/translate-01"; +import useUserPinList from "../../hooks/use-user-pin-list"; +import { useSigningContext } from "../../providers/signing-provider"; +import { PIN_LIST_KIND, listAddEvent, listRemoveEvent } from "../../helpers/nostr/lists"; + +function PinNoteItem({ event }: { event: NostrEvent }) { + const toast = useToast(); + const account = useCurrentAccount(); + const { requestSignature } = useSigningContext(); + const { list } = useUserPinList(account?.pubkey); + + const isPinned = list?.tags.some((t) => isETag(t) && t[1] === event.id) ?? false; + const label = isPinned ? "Unpin Note" : "Pin Note"; + + const [loading, setLoading] = useState(false); + const togglePin = useCallback(async () => { + try { + setLoading(true); + let draft: DraftNostrEvent = { + kind: PIN_LIST_KIND, + created_at: dayjs().unix(), + content: list?.content ?? "", + tags: list?.tags ? Array.from(list.tags) : [], + }; + + if (isPinned) draft = listRemoveEvent(draft, event.id); + else draft = listAddEvent(draft, event.id); + + const signed = await requestSignature(draft); + new NostrPublishAction(label, clientRelaysService.getWriteUrls(), signed); + setLoading(false); + } catch (e) { + if (e instanceof Error) toast({ status: "error", description: e.message }); + } + }, [list, isPinned]); + + if (event.pubkey !== account?.pubkey) return null; + + return ( + } isDisabled={loading || account.readonly}> + {label} + + ); +} export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Omit) { const account = useCurrentAccount(); @@ -90,6 +134,7 @@ export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Om }> Broadcast + }> View Raw diff --git a/src/components/post-modal/community-select.tsx b/src/components/post-modal/community-select.tsx index c9a2cb0f0..442f822b7 100644 --- a/src/components/post-modal/community-select.tsx +++ b/src/components/post-modal/community-select.tsx @@ -1,7 +1,7 @@ import { forwardRef } from "react"; import { Select, SelectProps } from "@chakra-ui/react"; -import useJoinedCommunitiesList from "../../hooks/use-communities-joined-list"; +import useUserCommunitiesList from "../../hooks/use-user-communities-list"; import useCurrentAccount from "../../hooks/use-current-account"; import { getCommunityName } from "../../helpers/nostr/communities"; import { AddressPointer } from "nostr-tools/lib/types/nip19"; @@ -17,7 +17,7 @@ function CommunityOption({ pointer }: { pointer: AddressPointer }) { const CommunitySelect = forwardRef>(({ ...props }, ref) => { const account = useCurrentAccount(); - const { pointers } = useJoinedCommunitiesList(account?.pubkey); + const { pointers } = useUserCommunitiesList(account?.pubkey); return (