Merge branch 'next'

This commit is contained in:
hzrd149 2023-11-19 09:14:12 -06:00
commit 0b08d61f42
22 changed files with 462 additions and 308 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add option to pin notes

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show pinned notes on user profile

View File

@ -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 (
<Image src={emojiTag[2]} h="1.2em" w="1.2em" display="inline-block" verticalAlign="middle" title={match[1]} />
<Image
src={emojiTag[2]}
h="1.5em"
maxW="3em"
display="inline-block"
verticalAlign="middle"
title={match[1]}
/>
);
}
return null;

View File

@ -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;

View File

@ -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 (
<Card variant="outline">
<CardHeader px="4" py="4" pb="2" display="flex" gap="2" alignItems="center">
<UserAvatarLink pubkey={result.pubkey} size="sm" />
<UserLink pubkey={result.pubkey} fontWeight="bold" />
{lang && <Text>Translated to {lang.nativeName}</Text>}
<Timestamp timestamp={result.created_at} />
</CardHeader>
<CardBody px="4" pt="0" pb="4">
{requester && (
<Text fontStyle="italic" mb="2">
Requested by <UserLink pubkey={requester} fontWeight="bold" />
</Text>
)}
<NoteContents event={result} />
</CardBody>
</Card>
);
}
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<string, NostrEvent> = {};
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 (
<Card variant="outline">
<CardHeader px="4" py="4" pb="2" display="flex" gap="2" alignItems="center" flexWrap="wrap">
<UserAvatarLink pubkey={request.pubkey} size="sm" />
<UserLink pubkey={request.pubkey} fontWeight="bold" />
<Text>Requested translation to {lang?.nativeName}</Text>
<Text>
Requested translation to <strong>{lang?.nativeName}</strong>
</Text>
<Timestamp timestamp={request.created_at} />
</CardHeader>
<CardBody px="4" pt="0" pb="4">
{offers.length === 0 ? (
<Flex gap="2" alignItems="center">
<Spinner />
Waiting for offers
</Flex>
) : (
<Heading size="md" mb="2">
Offers ({offers.length})
</Heading>
)}
{offers.map((offer) => (
<TranslationOffer key={offer.id} offer={offer} />
))}
</CardBody>
{Object.keys(dvmStatuses).length === 0 && (
<Flex gap="2" alignItems="center" m="4">
<Spinner />
Waiting for offers
</Flex>
)}
<Accordion allowMultiple>
{Object.values(dvmStatuses).map((event) => {
switch (event.kind) {
case DMV_STATUS_KIND:
return <TranslationOffer key={event.id} offer={event} />;
case DMV_TRANSLATE_RESULT_KIND:
return <TranslationResult key={event.id} result={event} />;
}
})}
</Accordion>
</Card>
);
}
@ -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 (
<Flex gap="2" direction="column">
<Flex gap="2" alignItems="center">
<UserAvatarLink pubkey={offer.pubkey} size="sm" />
<UserLink pubkey={offer.pubkey} fontWeight="bold" />
<AccordionItem>
<AccordionButton>
<Flex gap="2" alignItems="center" grow={1}>
<UserAvatarLink pubkey={offer.pubkey} size="sm" />
<UserLink pubkey={offer.pubkey} fontWeight="bold" />
<Text>Offered</Text>
<Spacer />
{invoice && amountMsat && (
<Button
colorScheme="yellow"
ml="auto"
size="sm"
leftIcon={<LightningIcon />}
onClick={payInvoice}
isLoading={paying || paid}
isDisabled={!window.webln}
>
Pay {readablizeSats(amountMsat / 1000)} sats
</Button>
)}
</Flex>
<Text>{offer.content}</Text>
</Flex>
{invoice && amountMsat && (
<Button
colorScheme="yellow"
size="sm"
leftIcon={<LightningIcon />}
onClick={payInvoice}
isLoading={paying || paid}
isDisabled={!window.webln}
>
Pay {readablizeSats(amountMsat / 1000)} sats
</Button>
)}
<AccordionIcon />
</Flex>
</AccordionButton>
<AccordionPanel pb={4}>
<Text>{offer.content}</Text>
</AccordionPanel>
</AccordionItem>
);
}
function TranslationResult({ result }: { result: NostrEvent }) {
return (
<AccordionItem>
<AccordionButton>
<Flex gap="2" alignItems="center" grow={1}>
<UserAvatarLink pubkey={result.pubkey} size="sm" />
<UserLink pubkey={result.pubkey} fontWeight="bold" />
<Text>Translated Note</Text>
<AccordionIcon ml="auto" />
</Flex>
</AccordionButton>
<AccordionPanel pb={4}>
<NoteContents event={result} />
</AccordionPanel>
</AccordionItem>
);
}
@ -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 (
<Modal isOpen={isOpen} onClose={onClose} size="4xl" {...props}>
@ -217,16 +233,9 @@ export default function NoteTranslationModal({
Request new translation
</Button>
</Flex>
{filteredEvents.map((event) => {
switch (event.kind) {
case DMV_TRANSLATE_JOB_KIND:
return <TranslationRequest key={event.id} request={event} />;
case DMV_TRANSLATE_RESULT_KIND:
const requestId = event.tags.find(isETag)?.[1];
const request = events.find((e) => e.id === requestId);
return <TranslationResult key={event.id} result={event} request={request} />;
}
})}
{jobs.map((event) => (
<TranslationRequest key={event.id} request={event} />
))}
</ModalBody>
</ModalContent>
</Modal>

View File

@ -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<IconButtonProps, "aria-label"> & { event: NostrEvent }) {
const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr" || t[0] === "proxy"), [event])?.[1];
if (!externalLink) return null;
return (
<IconButton
as={Link}
icon={<ExternalLinkIcon />}
href={externalLink}
target="_blank"
aria-label="Open External"
title="Open External"
{...props}
/>
);
}

View File

@ -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) => {

View File

@ -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<CardProps, "children"> & {
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 && <NoteReactions event={event} flexWrap="wrap" variant="ghost" size="xs" />;
@ -138,17 +136,7 @@ export const Note = React.memo(
</ButtonGroup>
{!showReactionsOnNewLine && reactionButtons}
<Box flexGrow={1} />
{externalLink && (
<IconButton
as={Link}
icon={<ExternalLinkIcon />}
aria-label="Open External"
href={externalLink}
size="xs"
variant="ghost"
target="_blank"
/>
)}
<NoteProxyLink event={event} size="xs" variant="ghost" />
<EventRelays event={event} />
<BookmarkButton event={event} aria-label="Bookmark note" size="xs" variant="ghost" />
<NoteMenu event={event} size="xs" variant="ghost" aria-label="More Options" />

View File

@ -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 (
<MenuItem onClick={togglePin} icon={<PinIcon />} isDisabled={loading || account.readonly}>
{label}
</MenuItem>
);
}
export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
const account = useCurrentAccount();
@ -90,6 +134,7 @@ export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Om
<MenuItem onClick={broadcast} icon={<BroadcastEventIcon />}>
Broadcast
</MenuItem>
<PinNoteItem event={event} />
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>

View File

@ -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<HTMLSelectElement, Omit<SelectProps, "children">>(({ ...props }, ref) => {
const account = useCurrentAccount();
const { pointers } = useJoinedCommunitiesList(account?.pubkey);
const { pointers } = useUserCommunitiesList(account?.pubkey);
return (
<Select placeholder="Select community" {...props} ref={ref}>

View File

@ -4,7 +4,7 @@ import { RequestOptions } from "../services/replaceable-event-requester";
import useCurrentAccount from "./use-current-account";
import useReplaceableEvent from "./use-replaceable-event";
export default function useJoinedCommunitiesList(pubkey?: string, opts?: RequestOptions) {
export default function useUserCommunitiesList(pubkey?: string, relays: string[] = [], opts?: RequestOptions) {
const account = useCurrentAccount();
const key = pubkey ?? account?.pubkey;
@ -22,10 +22,9 @@ export default function useJoinedCommunitiesList(pubkey?: string, opts?: Request
[],
opts,
);
const list = useReplaceableEvent(key ? { kind: COMMUNITIES_LIST_KIND, pubkey: key } : undefined, [], opts);
const list = useReplaceableEvent(key ? { kind: COMMUNITIES_LIST_KIND, pubkey: key } : undefined, relays, opts);
let useList = list || oldList;
console.log(list, oldList);
// if both exist, use the newest one
if (list && oldList) {

View File

@ -0,0 +1,15 @@
import { PIN_LIST_KIND, getEventsFromList } from "../helpers/nostr/lists";
import { RequestOptions } from "../services/replaceable-event-requester";
import useCurrentAccount from "./use-current-account";
import useReplaceableEvent from "./use-replaceable-event";
export default function useUserPinList(pubkey?: string, relays: string[] = [], opts?: RequestOptions) {
const account = useCurrentAccount();
const key = pubkey ?? account?.pubkey;
const list = useReplaceableEvent(key ? { kind: PIN_LIST_KIND, pubkey: key } : undefined, relays, opts);
const events = list ? getEventsFromList(list) : [];
return { list, events };
}

View File

@ -57,15 +57,23 @@ class EventExistsService {
const nextRelay = relayScoreboardService.getRankedRelays(Array.from(relays))[0];
if (!nextRelay) continue;
relays.delete(nextRelay);
(async () => {
const sub = this.answers.get(key);
const request = new NostrRequest([nextRelay], 500);
const limitFilter = Array.isArray(filter) ? filter.map((f) => ({ ...f, limit: 1 })) : { ...filter, limit: 1 };
request.start(limitFilter);
request.onEvent.subscribe(() => sub.next(true));
request.onEvent.subscribe(() => {
this.log("Found event for", filter);
sub.next(true);
this.pending.delete(key);
});
await request.onComplete;
if (sub.value === undefined) sub.next(false);
if (sub.value === undefined && this.asked.get(key).size > this.pending.get(key).size) {
this.log("Could not find event for", filter);
sub.next(false);
}
})();
}
}

View File

@ -3,7 +3,7 @@ import dayjs from "dayjs";
import { Button, ButtonProps, useToast } from "@chakra-ui/react";
import { DraftNostrEvent, NostrEvent, isDTag } from "../../../types/nostr-event";
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 { COMMUNITIES_LIST_KIND, listAddCoordinate, listRemoveCoordinate } from "../../../helpers/nostr/lists";
@ -16,10 +16,10 @@ export default function CommunityJoinButton({
community,
...props
}: Omit<ButtonProps, "children"> & { community: NostrEvent }) {
const account = useCurrentAccount();
const { list, pointers } = useJoinedCommunitiesList(account?.pubkey);
const { requestSignature } = useSigningContext();
const toast = useToast();
const account = useCurrentAccount();
const { list, pointers } = useUserCommunitiesList(account?.pubkey);
const { requestSignature } = useSigningContext();
const isSubscribed = pointers.find(
(cord) => cord.identifier === getCommunityName(community) && cord.pubkey === community.pubkey,
@ -29,7 +29,7 @@ export default function CommunityJoinButton({
try {
const favList = {
kind: COMMUNITIES_LIST_KIND,
content: "",
content: list?.content ?? "",
created_at: dayjs().unix(),
tags: list?.tags.filter((t) => !isDTag(t)) ?? [],
};

View File

@ -25,7 +25,7 @@ import dayjs from "dayjs";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { ErrorBoundary } from "../../components/error-boundary";
import useJoinedCommunitiesList from "../../hooks/use-communities-joined-list";
import useUserCommunitiesList from "../../hooks/use-user-communities-list";
import useCurrentAccount from "../../hooks/use-current-account";
import CommunityCard from "./components/community-card";
import CommunityCreateModal, { FormValues } from "./components/community-create-modal";
@ -62,7 +62,9 @@ function CommunitiesHomePage() {
const createModal = useDisclosure();
const readRelays = useReadRelayUrls();
const { pointers: communityCoordinates } = useJoinedCommunitiesList(account.pubkey, { alwaysRequest: true });
const { pointers: communityCoordinates } = useUserCommunitiesList(account.pubkey, readRelays, {
alwaysRequest: true,
});
const communities = useReplaceableEvents(communityCoordinates, readRelays).sort(
(a, b) => b.created_at - a.created_at,
);

View File

@ -7,6 +7,7 @@ import {
Flex,
IconButton,
Link,
Spacer,
useColorMode,
useDisclosure,
} from "@chakra-ui/react";
@ -34,6 +35,8 @@ import { useBreakpointValue } from "../../../providers/breakpoint-provider";
import NoteReactions from "../../../components/note/components/note-reactions";
import BookmarkButton from "../../../components/note/components/bookmark-button";
import NoteCommunityMetadata from "../../../components/note/note-community-metadata";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
import NoteProxyLink from "../../../components/note/components/note-proxy-link";
const LEVEL_COLORS = ["green", "blue", "red", "purple", "yellow", "cyan", "pink"];
@ -78,6 +81,7 @@ export const ThreadPost = ({ post, initShowReplies, focusId, level = -1 }: Threa
<Flex gap="2" alignItems="center">
<UserAvatarLink pubkey={post.event.pubkey} size="sm" />
<UserLink pubkey={post.event.pubkey} fontWeight="bold" isTruncated />
<UserDnsIdentityIcon pubkey={post.event.pubkey} onlyIcon />
<Link as={RouterLink} whiteSpace="nowrap" color="current" to={`/n/${nip19.noteEncode(post.event.id)}`}>
<Timestamp timestamp={post.event.created_at} />
</Link>
@ -124,7 +128,9 @@ export const ThreadPost = ({ post, initShowReplies, focusId, level = -1 }: Threa
<NoteZapButton event={post.event} />
</ButtonGroup>
{!showReactionsOnNewLine && reactionButtons}
<BookmarkButton event={post.event} variant="ghost" aria-label="Bookmark" size="sm" ml="auto" />
<Spacer />
<NoteProxyLink event={post.event} variant="ghost" size="sm" />
<BookmarkButton event={post.event} variant="ghost" aria-label="Bookmark" size="sm" />
<NoteMenu event={post.event} variant="ghost" size="sm" aria-label="More Options" />
</Flex>
);

View File

@ -1,35 +1,11 @@
import { useOutletContext, Link as RouterLink } from "react-router-dom";
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Button,
Flex,
Heading,
IconButton,
Image,
Link,
SimpleGrid,
Stat,
StatGroup,
StatHelpText,
StatLabel,
StatNumber,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { useAsync } from "react-use";
import { Kind, nip19 } from "nostr-tools";
import { Box, Button, Flex, Heading, IconButton, Image, Link, Text, useDisclosure } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import { readablizeSats } from "../../../helpers/bolt11";
import { getUserDisplayName } from "../../../helpers/user-metadata";
import { getLudEndpoint } from "../../../helpers/lnurl";
import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
import { truncatedId } from "../../../helpers/nostr/events";
import trustedUserStatsService from "../../../services/trusted-user-stats";
import { parseAddress } from "../../../services/dns-identity";
import { useAdditionalRelayContext } from "../../../providers/additional-relay-context";
import { useUserMetadata } from "../../../hooks/use-user-metadata";
@ -51,15 +27,10 @@ import { UserFollowButton } from "../../../components/user-follow-button";
import UserZapButton from "../components/user-zap-button";
import { UserProfileMenu } from "../components/user-profile-menu";
import { useSharableProfileId } from "../../../hooks/use-shareable-profile-id";
import useUserContactList from "../../../hooks/use-user-contact-list";
import { getPubkeysFromList } from "../../../helpers/nostr/lists";
import Timestamp from "../../../components/timestamp";
import UserProfileBadges from "./user-profile-badges";
import useEventCount from "../../../hooks/use-event-count";
import useJoinedCommunitiesList from "../../../hooks/use-communities-joined-list";
import { PointerCommunityCard } from "../../communities/components/community-card";
import { ErrorBoundary } from "../../../components/error-boundary";
import { useBreakpointValue } from "../../../providers/breakpoint-provider";
import UserJoinedCommunities from "./user-joined-communities";
import UserPinnedEvents from "./user-pinned-events";
import UserStatsAccordion from "./user-stats-accordion";
function buildDescriptionContent(description: string) {
let content: EmbedableContent = [description.trim()];
@ -70,153 +41,6 @@ function buildDescriptionContent(description: string) {
return content;
}
function UserStatsAccordion({ pubkey }: { pubkey: string }) {
const contextRelays = useAdditionalRelayContext();
const contacts = useUserContactList(pubkey, contextRelays);
const { value: stats } = useAsync(() => trustedUserStatsService.getUserStats(pubkey), [pubkey]);
const followerCount = useEventCount({ "#p": [pubkey], kinds: [Kind.Contacts] });
return (
<Accordion allowMultiple>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Network Stats
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb="2">
<StatGroup gap="4" whiteSpace="pre">
<Stat>
<StatLabel>Following</StatLabel>
<StatNumber>{contacts ? readablizeSats(getPubkeysFromList(contacts).length) : "Unknown"}</StatNumber>
{contacts && (
<StatHelpText>
Updated <Timestamp timestamp={contacts.created_at} />
</StatHelpText>
)}
</Stat>
{stats && (
<>
<Stat>
<StatLabel>Followers</StatLabel>
<StatNumber>{readablizeSats(followerCount ?? 0) || 0}</StatNumber>
</Stat>
<Stat>
<StatLabel>Notes & replies</StatLabel>
<StatNumber>{readablizeSats(stats.pub_note_count) || 0}</StatNumber>
</Stat>
<Stat>
<StatLabel>Reactions</StatLabel>
<StatNumber>{readablizeSats(stats.pub_reaction_count) || 0}</StatNumber>
</Stat>
</>
)}
</StatGroup>
</AccordionPanel>
</AccordionItem>
{(stats?.zaps_sent || stats?.zaps_received) && (
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Zap Stats
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb="2">
<StatGroup gap="4" whiteSpace="pre">
{stats.zaps_sent && (
<>
<Stat>
<StatLabel>Zap Sent</StatLabel>
<StatNumber>{stats.zaps_sent.count}</StatNumber>
</Stat>
<Stat>
<StatLabel>Total Sats Sent</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_sent.msats / 1000)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Avg Zap Sent</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_sent.avg_msats / 1000)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Biggest Zap Sent</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_sent.max_msats / 1000)}</StatNumber>
</Stat>
</>
)}
{stats.zaps_received && (
<>
<Stat>
<StatLabel>Zap Received</StatLabel>
<StatNumber>{stats.zaps_received.count}</StatNumber>
</Stat>
<Stat>
<StatLabel>Total Sats Received</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_received.msats / 1000)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Avg Zap Received</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_received.avg_msats / 1000)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Biggest Zap Received</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_received.max_msats / 1000)}</StatNumber>
</Stat>
</>
)}
</StatGroup>
<Text color="slategrey">
Stats from{" "}
<Link href="https://nostr.band" isExternal color="blue.500">
nostr.band
</Link>
</Text>
</AccordionPanel>
</AccordionItem>
)}
</Accordion>
);
}
function UserJoinedCommunities({ pubkey }: { pubkey: string }) {
const { pointers: communities } = useJoinedCommunitiesList(pubkey);
const columns = useBreakpointValue({ base: 1, lg: 2, xl: 3 }) ?? 1;
const showAllCommunities = useDisclosure();
if (communities.length === 0) return null;
return (
<Flex direction="column" px="2">
<Heading size="md" my="2">
Joined Communities ({communities.length})
</Heading>
<SimpleGrid spacing="4" columns={columns}>
{(showAllCommunities.isOpen ? communities : communities.slice(0, columns * 2)).map((pointer) => (
<ErrorBoundary key={pointer.identifier + pointer.pubkey}>
<PointerCommunityCard pointer={pointer} />
</ErrorBoundary>
))}
</SimpleGrid>
{!showAllCommunities.isOpen && communities.length > columns * 2 && (
<Button variant="link" py="4" onClick={showAllCommunities.onOpen}>
Show All
</Button>
)}
</Flex>
);
}
export default function UserAboutTab() {
const expanded = useDisclosure();
const { pubkey } = useOutletContext() as { pubkey: string };
@ -366,6 +190,7 @@ export default function UserAboutTab() {
</Button>
</Flex>
<UserPinnedEvents pubkey={pubkey} />
<UserJoinedCommunities pubkey={pubkey} />
</Flex>
);

View File

@ -0,0 +1,36 @@
import { Button, Flex, Heading, SimpleGrid, useDisclosure } from "@chakra-ui/react";
import { useAdditionalRelayContext } from "../../../providers/additional-relay-context";
import useUserCommunitiesList from "../../../hooks/use-user-communities-list";
import { PointerCommunityCard } from "../../communities/components/community-card";
import { ErrorBoundary } from "../../../components/error-boundary";
import { useBreakpointValue } from "../../../providers/breakpoint-provider";
export default function UserJoinedCommunities({ pubkey }: { pubkey: string }) {
const contextRelays = useAdditionalRelayContext();
const { pointers: communities } = useUserCommunitiesList(pubkey, contextRelays, { alwaysRequest: true });
const columns = useBreakpointValue({ base: 1, lg: 2, xl: 3 }) ?? 1;
const showAllCommunities = useDisclosure();
if (communities.length === 0) return null;
return (
<Flex direction="column" px="2">
<Heading size="md" my="2">
Joined Communities ({communities.length})
</Heading>
<SimpleGrid spacing="4" columns={columns}>
{(showAllCommunities.isOpen ? communities : communities.slice(0, columns * 2)).map((pointer) => (
<ErrorBoundary key={pointer.identifier + pointer.pubkey}>
<PointerCommunityCard pointer={pointer} />
</ErrorBoundary>
))}
</SimpleGrid>
{!showAllCommunities.isOpen && communities.length > columns * 2 && (
<Button variant="link" pt="4" onClick={showAllCommunities.onOpen}>
Show All
</Button>
)}
</Flex>
);
}

View File

@ -0,0 +1,32 @@
import { Button, Flex, Heading, useDisclosure } from "@chakra-ui/react";
import { useAdditionalRelayContext } from "../../../providers/additional-relay-context";
import useUserPinList from "../../../hooks/use-user-pin-list";
import { EmbedEventPointer } from "../../../components/embed-event";
export default function UserPinnedEvents({ pubkey }: { pubkey: string }) {
const contextRelays = useAdditionalRelayContext();
const { events, list } = useUserPinList(pubkey, contextRelays);
const showAll = useDisclosure();
if (events.length === 0) return null;
return (
<Flex direction="column" gap="2">
<Heading size="md" my="2">
Pinned
</Heading>
{(showAll.isOpen ? events : events.slice(0, 2)).map((event) => (
<EmbedEventPointer
key={event.id}
pointer={{ type: "nevent", data: { id: event.id, relays: event.relay ? [event.relay] : [] } }}
/>
))}
{!showAll.isOpen && events.length > 2 && (
<Button variant="link" pt="4" onClick={showAll.onOpen}>
Show All
</Button>
)}
</Flex>
);
}

View File

@ -0,0 +1,144 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Link,
Stat,
StatGroup,
StatHelpText,
StatLabel,
StatNumber,
Text,
} from "@chakra-ui/react";
import { useAsync } from "react-use";
import { Kind } from "nostr-tools";
import { readablizeSats } from "../../../helpers/bolt11";
import trustedUserStatsService from "../../../services/trusted-user-stats";
import { useAdditionalRelayContext } from "../../../providers/additional-relay-context";
import useUserContactList from "../../../hooks/use-user-contact-list";
import { getPubkeysFromList } from "../../../helpers/nostr/lists";
import Timestamp from "../../../components/timestamp";
import useEventCount from "../../../hooks/use-event-count";
export default function UserStatsAccordion({ pubkey }: { pubkey: string }) {
const contextRelays = useAdditionalRelayContext();
const contacts = useUserContactList(pubkey, contextRelays);
const { value: stats } = useAsync(() => trustedUserStatsService.getUserStats(pubkey), [pubkey]);
const followerCount = useEventCount({ "#p": [pubkey], kinds: [Kind.Contacts] });
return (
<Accordion allowMultiple>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Network Stats
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb="2">
<StatGroup gap="4" whiteSpace="pre">
<Stat>
<StatLabel>Following</StatLabel>
<StatNumber>{contacts ? readablizeSats(getPubkeysFromList(contacts).length) : "Unknown"}</StatNumber>
{contacts && (
<StatHelpText>
Updated <Timestamp timestamp={contacts.created_at} />
</StatHelpText>
)}
</Stat>
{stats && (
<>
<Stat>
<StatLabel>Followers</StatLabel>
<StatNumber>{readablizeSats(followerCount ?? 0) || 0}</StatNumber>
</Stat>
<Stat>
<StatLabel>Notes & replies</StatLabel>
<StatNumber>{readablizeSats(stats.pub_note_count) || 0}</StatNumber>
</Stat>
<Stat>
<StatLabel>Reactions</StatLabel>
<StatNumber>{readablizeSats(stats.pub_reaction_count) || 0}</StatNumber>
</Stat>
</>
)}
</StatGroup>
</AccordionPanel>
</AccordionItem>
{(stats?.zaps_sent || stats?.zaps_received) && (
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Zap Stats
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb="2">
<StatGroup gap="4" whiteSpace="pre">
{stats.zaps_sent && (
<>
<Stat>
<StatLabel>Zap Sent</StatLabel>
<StatNumber>{stats.zaps_sent.count}</StatNumber>
</Stat>
<Stat>
<StatLabel>Total Sats Sent</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_sent.msats / 1000)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Avg Zap Sent</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_sent.avg_msats / 1000)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Biggest Zap Sent</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_sent.max_msats / 1000)}</StatNumber>
</Stat>
</>
)}
{stats.zaps_received && (
<>
<Stat>
<StatLabel>Zap Received</StatLabel>
<StatNumber>{stats.zaps_received.count}</StatNumber>
</Stat>
<Stat>
<StatLabel>Total Sats Received</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_received.msats / 1000)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Avg Zap Received</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_received.avg_msats / 1000)}</StatNumber>
</Stat>
<Stat>
<StatLabel>Biggest Zap Received</StatLabel>
<StatNumber>{readablizeSats(stats.zaps_received.max_msats / 1000)}</StatNumber>
</Stat>
</>
)}
</StatGroup>
<Text color="slategrey">
Stats from{" "}
<Link href="https://nostr.band" isExternal color="blue.500">
nostr.band
</Link>
</Text>
</AccordionPanel>
</AccordionItem>
)}
</Accordion>
);
}

View File

@ -109,9 +109,9 @@ const UserView = () => {
]);
const hasArticles = useEventExists({ kinds: [Kind.Article], authors: [pubkey] }, readRelays);
const hasStreams = useEventExists({ kinds: [STREAM_KIND], authors: [pubkey] }, [
"wss://relay.snort.social",
"wss://nos.lol",
"wss://relay.damus.io",
"wss://relay.snort.social",
"wss://nostr.wine",
...readRelays,
]);
@ -124,7 +124,7 @@ const UserView = () => {
if (tab.path === "streams" && hasStreams === false) return false;
return true;
}),
[hasTracks, hasArticles, tabs],
[hasTracks, hasArticles, hasStreams, tabs],
);
const matches = useMatches();

View File

@ -3,15 +3,16 @@ import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
const isProd = process.env.NODE_ENV === "production";
process.env.VITE_ANALYTICS_SCRIPT = isProd
? `
process.env.VITE_ANALYTICS_SCRIPT =
isProd && process.env.ACKEE_DOMAIN_ID
? `
<script
async defer
src="//ackee.nostrudel.ninja/tracker.js"
data-ackee-server="//ackee.nostrudel.ninja"
data-ackee-domain-id="58b1c39f-43f9-422b-bc7d-06aff35e764e"
data-ackee-domain-id="${process.env.ACKEE_DOMAIN_ID}"
></script>`
: "";
: "";
// https://vitejs.dev/config/
export default defineConfig({