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 && (
- }
- onClick={payInvoice}
- isLoading={paying || paid}
- isDisabled={!window.webln}
- >
- Pay {readablizeSats(amountMsat / 1000)} sats
-
- )}
-
- {offer.content}
-
+ {invoice && amountMsat && (
+ }
+ onClick={payInvoice}
+ isLoading={paying || paid}
+ isDisabled={!window.webln}
+ >
+ Pay {readablizeSats(amountMsat / 1000)} sats
+
+ )}
+
+
+
+
+ {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 (