add goal views

This commit is contained in:
hzrd149 2023-09-01 16:19:48 -05:00
parent 11f98c8967
commit 2a490dd6c1
49 changed files with 1110 additions and 170 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add goal views

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Improve event embed card

View File

@ -37,14 +37,18 @@ import RelaysView from "./views/relays";
import RelayView from "./views/relays/relay";
import RelayReviewsView from "./views/relays/reviews";
import ListsView from "./views/lists";
import ListView from "./views/lists/list";
import ListDetailsView from "./views/lists/list-details";
import UserListsTab from "./views/user/lists";
import BrowseListView from "./views/lists/browse";
import EmojiPacksBrowseView from "./views/emoji-packs/browse";
import EmojiPackView from "./views/emoji-packs/pack";
import EmojiPackView from "./views/emoji-packs/emoji-pack";
import UserEmojiPacksTab from "./views/user/emoji-packs";
import EmojiPacksView from "./views/emoji-packs";
import GoalsView from "./views/goals";
import GoalsBrowseView from "./views/goals/browse";
import GoalDetailsView from "./views/goals/goal-details";
import UserGoalsTab from "./views/user/goals";
const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));
@ -131,6 +135,7 @@ const router = createHashRouter([
{ path: "lists", element: <UserListsTab /> },
{ path: "followers", element: <UserFollowersTab /> },
{ path: "following", element: <UserFollowingTab /> },
{ path: "goals", element: <UserGoalsTab /> },
{ path: "emojis", element: <UserEmojiPacksTab /> },
{ path: "relays", element: <UserRelaysTab /> },
{ path: "reports", element: <UserReportsTab /> },
@ -158,7 +163,15 @@ const router = createHashRouter([
children: [
{ path: "", element: <ListsView /> },
{ path: "browse", element: <BrowseListView /> },
{ path: ":addr", element: <ListView /> },
{ path: ":addr", element: <ListDetailsView /> },
],
},
{
path: "goals",
children: [
{ path: "", element: <GoalsView /> },
{ path: "browse", element: <GoalsBrowseView /> },
{ path: ":id", element: <GoalDetailsView /> },
],
},
{

View File

@ -0,0 +1,59 @@
import {
ButtonGroup,
Card,
CardBody,
CardFooter,
CardHeader,
CardProps,
Flex,
Heading,
Image,
Link,
Text,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import dayjs from "dayjs";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { getEmojisFromPack, getPackName } from "../../../helpers/nostr/emoji-packs";
import { UserAvatarLink } from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import EmojiPackFavoriteButton from "../../../views/emoji-packs/components/emoji-pack-favorite-button";
import EmojiPackMenu from "../../../views/emoji-packs/components/emoji-pack-menu";
import { NostrEvent } from "../../../types/nostr-event";
export default function EmbeddedEmojiPack({ pack, ...props }: Omit<CardProps, "children"> & { pack: NostrEvent }) {
const emojis = getEmojisFromPack(pack);
const naddr = getSharableEventAddress(pack);
return (
<Card {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<Heading size="md">
<Link as={RouterLink} to={`/emojis/${naddr}`}>
{getPackName(pack)}
</Link>
</Heading>
<Text>by</Text>
<UserAvatarLink pubkey={pack.pubkey} size="xs" />
<UserLink pubkey={pack.pubkey} isTruncated fontWeight="bold" fontSize="md" />
<ButtonGroup size="sm" ml="auto">
<EmojiPackFavoriteButton pack={pack} />
<EmojiPackMenu pack={pack} aria-label="emoji pack menu" />
</ButtonGroup>
</CardHeader>
<CardBody p="2">
{emojis.length > 0 && (
<Flex mb="2" wrap="wrap" gap="2">
{emojis.map(({ name, url }) => (
<Image key={name + url} src={url} title={name} w={8} h={8} />
))}
</Flex>
)}
</CardBody>
<CardFooter p="2" display="flex" pt="0">
<Text>Updated: {dayjs.unix(pack.created_at).fromNow()}</Text>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,33 @@
import { Card, CardBody, CardHeader, CardProps, Heading, Link, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { NostrEvent } from "../../../types/nostr-event";
import { getGoalName } from "../../../helpers/nostr/goal";
import { UserAvatarLink } from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import GoalProgress from "../../../views/goals/components/goal-progress";
import GoalZapButton from "../../../views/goals/components/goal-zap-button";
export default function EmbeddedGoal({ goal, ...props }: Omit<CardProps, "children"> & { goal: NostrEvent }) {
const nevent = getSharableEventAddress(goal);
return (
<Card {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<Heading size="md">
<Link as={RouterLink} to={`/goals/${nevent}`}>
{getGoalName(goal)}
</Link>
</Heading>
<Text>by</Text>
<UserAvatarLink pubkey={goal.pubkey} size="xs" />
<UserLink pubkey={goal.pubkey} isTruncated fontWeight="bold" fontSize="md" />
</CardHeader>
<CardBody p="2">
<GoalProgress goal={goal} />
<GoalZapButton goal={goal} mt="2" />
</CardBody>
</Card>
);
}

View File

@ -1,17 +1,17 @@
import dayjs from "dayjs";
import { Button, Card, CardBody, CardHeader, Spacer, useDisclosure } from "@chakra-ui/react";
import { NoteContents } from "./note-contents";
import { NostrEvent } from "../../types/nostr-event";
import { UserAvatarLink } from "../user-avatar-link";
import { UserLink } from "../user-link";
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
import useSubject from "../../hooks/use-subject";
import appSettings from "../../services/settings/app-settings";
import EventVerificationIcon from "../event-verification-icon";
import { TrustProvider } from "../../providers/trust";
import { NoteLink } from "../note-link";
import { ArrowDownSIcon, ArrowUpSIcon } from "../icons";
import { NoteContents } from "../../note/note-contents";
import { NostrEvent } from "../../../types/nostr-event";
import { UserAvatarLink } from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import { UserDnsIdentityIcon } from "../../user-dns-identity-icon";
import useSubject from "../../../hooks/use-subject";
import appSettings from "../../../services/settings/app-settings";
import EventVerificationIcon from "../../event-verification-icon";
import { TrustProvider } from "../../../providers/trust";
import { NoteLink } from "../../note-link";
import { ArrowDownSIcon, ArrowUpSIcon } from "../../icons";
export default function EmbeddedNote({ event }: { event: NostrEvent }) {
const { showSignatureVerification } = useSubject(appSettings);

View File

@ -0,0 +1,68 @@
import { Card, CardBody, CardProps, Flex, Heading, Image, Link, Tag, Text, useBreakpointValue } from "@chakra-ui/react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import dayjs from "dayjs";
import { parseStreamEvent } from "../../../helpers/nostr/stream";
import { NostrEvent } from "../../../types/nostr-event";
import StreamStatusBadge from "../../../views/streams/components/status-badge";
import { UserLink } from "../../user-link";
import { UserAvatar } from "../../user-avatar";
import useEventNaddr from "../../../hooks/use-event-naddr";
export default function EmbeddedStream({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const stream = parseStreamEvent(event);
const naddr = useEventNaddr(stream.event, stream.relays);
const isVertical = useBreakpointValue({ base: true, md: false });
const navigate = useNavigate();
return (
<Card {...props} position="relative">
<CardBody p="2" gap="2">
<StreamStatusBadge stream={stream} position="absolute" top="4" left="4" />
{isVertical ? (
<Image
src={stream.image}
borderRadius="md"
cursor="pointer"
onClick={() => navigate(`/streams/${naddr}`)}
maxH="2in"
mx="auto"
mb="2"
/>
) : (
<Image
src={stream.image}
borderRadius="md"
maxH="2in"
maxW="30%"
mr="2"
float="left"
cursor="pointer"
onClick={() => navigate(`/streams/${naddr}`)}
/>
)}
<Heading size="md">
<Link as={RouterLink} to={`/streams/${naddr}`}>
{stream.title}
</Link>
</Heading>
<Flex gap="2" alignItems="center" my="2">
<UserAvatar pubkey={stream.host} size="xs" noProxy />
<Heading size="sm">
<UserLink pubkey={stream.host} />
</Heading>
</Flex>
{stream.starts && <Text>Started: {dayjs.unix(stream.starts).fromNow()}</Text>}
{stream.tags.length > 0 && (
<Flex gap="2" wrap="wrap">
{stream.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Flex>
)}
</CardBody>
</Card>
);
}

View File

@ -0,0 +1,68 @@
import type { DecodeResult } from "nostr-tools/lib/nip19";
import { Link } from "@chakra-ui/react";
import EmbeddedNote from "./event-types/embedded-note";
import useSingleEvent from "../../hooks/use-single-event";
import { NoteLink } from "../note-link";
import { NostrEvent } from "../../types/nostr-event";
import { Kind, nip19 } from "nostr-tools";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import RelayCard from "../../views/relays/components/relay-card";
import { STREAM_KIND } from "../../helpers/nostr/stream";
import { GOAL_KIND } from "../../helpers/nostr/goal";
import GoalCard from "../../views/goals/components/goal-card";
import { getSharableEventAddress, safeDecode } from "../../helpers/nip19";
import EmbeddedStream from "./event-types/embedded-stream";
import { EMOJI_PACK_KIND } from "../../helpers/nostr/emoji-packs";
import EmbeddedEmojiPack from "./event-types/embedded-emoji-pack";
import { buildAppSelectUrl } from "../../helpers/nostr/apps";
import EmbeddedGoal from "./event-types/embedded-goal";
export function EmbedEvent({ event }: { event: NostrEvent }) {
switch (event.kind) {
case Kind.Text:
return <EmbeddedNote event={event} />;
case STREAM_KIND:
return <EmbeddedStream event={event} />;
case GOAL_KIND:
return <EmbeddedGoal goal={event} />;
case EMOJI_PACK_KIND:
return <EmbeddedEmojiPack pack={event} />;
}
const address = getSharableEventAddress(event);
return (
<Link href={address ? buildAppSelectUrl(address) : ""} isExternal color="blue.500">
{address}
</Link>
);
}
export function EmbedEventPointer({ pointer }: { pointer: DecodeResult }) {
switch (pointer.type) {
case "note": {
const { event } = useSingleEvent(pointer.data);
if (event === undefined) return <NoteLink noteId={pointer.data} />;
return <EmbedEvent event={event} />;
}
case "nevent": {
const { event } = useSingleEvent(pointer.data.id, pointer.data.relays);
if (event === undefined) return <NoteLink noteId={pointer.data.id} />;
return <EmbedEvent event={event} />;
}
case "naddr": {
const event = useReplaceableEvent(pointer.data);
if (!event) return <span>{nip19.naddrEncode(pointer.data)}</span>;
return <EmbedEvent event={event} />;
}
case "nrelay":
return <RelayCard url={pointer.data} />;
}
return null;
}
export function EmbedEventNostrLink({ link }: { link: string }) {
const pointer = safeDecode(link);
return pointer ? <EmbedEventPointer pointer={pointer} /> : <>{link}</>;
}

View File

@ -18,5 +18,5 @@ export function renderGenericUrl(match: URL) {
}
export function renderOpenGraphUrl(match: URL) {
return <OpenGraphCard url={match} maxW="lg" />;
return <OpenGraphCard url={match} />;
}

View File

@ -1,4 +1,3 @@
import { nip19 } from "nostr-tools";
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import QuoteNote from "../note/quote-note";
@ -6,6 +5,8 @@ import { UserLink } from "../user-link";
import { Link } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { getMatchHashtag, getMatchNostrLink, stripInvisibleChar } from "../../helpers/regexp";
import { safeDecode } from "../../helpers/nip19";
import { EmbedEventPointer } from "../embed-event";
// nostr:nevent1qqsthg2qlxp9l7egtwa92t8lusm7pjknmjwa75ctrrpcjyulr9754fqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq36amnwvaz7tmwdaehgu3dwp6kytnhv4kxcmmjv3jhytnwv46q2qg5q9
// nostr:nevent1qqsq3wc73lqxd70lg43m5rul57d4mhcanttjat56e30yx5zla48qzlspz9mhxue69uhkummnw3e82efwvdhk6qgdwaehxw309ahx7uewd3hkcq5hsum
@ -14,23 +15,21 @@ export function embedNostrLinks(content: EmbedableContent) {
name: "nostr-link",
regexp: getMatchNostrLink(),
render: (match) => {
try {
const decoded = nip19.decode(match[2]);
const decoded = safeDecode(match[2]);
if (!decoded) return null;
switch (decoded.type) {
case "npub":
return <UserLink color="blue.500" pubkey={decoded.data} showAt />;
case "nprofile":
return <UserLink color="blue.500" pubkey={decoded.data.pubkey} showAt />;
case "note":
return <QuoteNote noteId={decoded.data} />;
case "nevent":
return <QuoteNote noteId={decoded.data.id} relays={decoded.data.relays} />;
default:
return null;
}
} catch (e) {
return null;
switch (decoded.type) {
case "npub":
return <UserLink color="blue.500" pubkey={decoded.data} showAt />;
case "nprofile":
return <UserLink color="blue.500" pubkey={decoded.data.pubkey} showAt />;
case "note":
case "nevent":
case "naddr":
case "nrelay":
return <EmbedEventPointer pointer={decoded} />;
default:
return null;
}
},
});

View File

@ -373,3 +373,9 @@ export const EmojiIcon = createIcon({
d: "M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM7 12H9C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12H17C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12Z",
defaultProps,
});
export const GoalIcon = createIcon({
displayName: "GoalIcon",
d: "M5 3V19H21V21H3V3H5ZM20.2929 6.29289L21.7071 7.70711L16 13.4142L13 10.415L8.70711 14.7071L7.29289 13.2929L13 7.58579L16 10.585L20.2929 6.29289Z",
defaultProps,
});

View File

@ -4,6 +4,7 @@ import {
ChatIcon,
EmojiIcon,
FeedIcon,
GoalIcon,
ListIcon,
LiveStreamIcon,
MapIcon,
@ -49,6 +50,9 @@ export default function NavItems({ isInDrawer = false }: { isInDrawer?: boolean
<Button onClick={() => navigate("/lists")} leftIcon={<ListIcon />} justifyContent="flex-start">
Lists
</Button>
<Button onClick={() => navigate("/goals")} leftIcon={<GoalIcon />} justifyContent="flex-start">
Goals
</Button>
<Button onClick={() => navigate("/emojis")} leftIcon={<EmojiIcon />} justifyContent="flex-start">
Emojis
</Button>

View File

@ -1,7 +1,6 @@
import { useMemo } from "react";
import { Link, LinkProps } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { truncatedId } from "../helpers/nostr/events";
import { nip19 } from "nostr-tools";
import { getSharableNoteId } from "../helpers/nip19";
@ -14,7 +13,7 @@ export const NoteLink = ({ children, noteId, color = "blue.500", ...props }: Not
return (
<Link as={RouterLink} to={`/n/${encoded}`} color={color} {...props}>
{children || truncatedId(nip19.noteEncode(noteId))}
{children || nip19.noteEncode(noteId)}
</Link>
);
};

View File

@ -3,7 +3,7 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { nip19 } from "nostr-tools";
import { getSharableNoteId } from "../../helpers/nip19";
import { getSharableEventAddress } from "../../helpers/nip19";
import { NostrEvent } from "../../types/nostr-event";
import { MenuIconButton, MenuIconButtonProps } from "../menu-icon-button";
@ -39,19 +39,20 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
});
}, []);
const address = getSharableEventAddress(event);
return (
<>
<MenuIconButton {...props}>
<MenuItem onClick={reactionsModal.onOpen} icon={<LikeIcon />}>
Zaps/Reactions
</MenuItem>
<MenuItem
onClick={() => window.open(buildAppSelectUrl(getSharableNoteId(event.id)), "_blank")}
icon={<ExternalLinkIcon />}
>
View in app...
</MenuItem>
<MenuItem onClick={() => copyToClipboard("nostr:" + getSharableNoteId(event.id))} icon={<RepostIcon />}>
{address && (
<MenuItem onClick={() => window.open(buildAppSelectUrl(address), "_blank")} icon={<ExternalLinkIcon />}>
View in app...
</MenuItem>
)}
<MenuItem onClick={() => copyToClipboard("nostr:" + address)} icon={<RepostIcon />}>
Copy Share Link
</MenuItem>
{noteId && (

View File

@ -11,6 +11,7 @@ import { LightningIcon } from "../icons";
import ZapModal from "../zap-modal";
import { useInvoiceModalContext } from "../../providers/invoice-modal";
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
import { getEventUID } from "../../helpers/nostr/events";
export type NoteZapButtonProps = Omit<ButtonProps, "children"> & {
event: NostrEvent;
@ -30,7 +31,7 @@ export default function NoteZapButton({ event, allowComment, showEventPreview, .
const handleInvoice = async (invoice: string) => {
onClose();
await requestPay(invoice);
eventZapsService.requestZaps(event.id, clientRelaysService.getReadUrls(), true);
eventZapsService.requestZaps(getEventUID(event), clientRelaysService.getReadUrls(), true);
};
const total = totalZaps(zaps);

View File

@ -1,8 +1,9 @@
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useSingleEvent from "../../hooks/use-single-event";
import EmbeddedNote from "./embedded-note";
import EmbeddedNote from "../embed-event/event-types/embedded-note";
import { NoteLink } from "../note-link";
/** @deprecated */
const QuoteNote = ({ noteId, relays }: { noteId: string; relays?: string[] }) => {
const readRelays = useReadRelayUrls(relays);
const { event, loading } = useSingleEvent(noteId, readRelays);

View File

@ -1,4 +1,17 @@
import { Box, CardProps, Heading, Image, Link, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
import {
Box,
Card,
CardBody,
CardProps,
Flex,
Heading,
Image,
Link,
LinkBox,
LinkOverlay,
Text,
useBreakpointValue,
} from "@chakra-ui/react";
import useOpenGraphData from "../hooks/use-open-graph-data";
export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<CardProps, "children">) {
@ -10,23 +23,40 @@ export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<Car
</Link>
);
const isVertical = useBreakpointValue({ base: true, md: false });
if (!data) return link;
return (
<LinkBox borderRadius="lg" borderWidth={1} overflow="hidden" {...props}>
{data.ogImage?.length === 1 && (
<Image key={data.ogImage[0].url} src={new URL(data.ogImage[0].url, url).toString()} mx="auto" maxH="3in" />
)}
<Box m="2" mt="4">
<Heading size="sm" my="2">
<LinkOverlay href={url.toString()} isExternal>
{data.ogTitle?.trim() ?? data.dcTitle?.trim()}
</LinkOverlay>
</Heading>
<Text isTruncated>{data.ogDescription || data.dcDescription}</Text>
{link}
</Box>
</LinkBox>
<Card {...props}>
<LinkBox
as={CardBody}
display="flex"
gap="2"
p="0"
overflow="hidden"
flexDirection={{ base: "column", md: "row" }}
>
{data.ogImage?.length === 1 && (
<Image
key={data.ogImage[0].url}
src={new URL(data.ogImage[0].url, url).toString()}
borderRadius="md"
maxH="2in"
maxW={isVertical ? "none" : "30%"}
mx={isVertical ? "auto" : 0}
/>
)}
<Box p="2">
<Heading size="sm">
<LinkOverlay href={url.toString()} isExternal>
{data.ogTitle?.trim() ?? data.dcTitle?.trim()}
</LinkOverlay>
</Heading>
<Text isTruncated>{data.ogDescription || data.dcDescription}</Text>
{link}
</Box>
</LinkBox>
</Card>
);
}

View File

@ -19,7 +19,7 @@ import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { useForm } from "react-hook-form";
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
import { DraftNostrEvent, NostrEvent, isDTag } from "../types/nostr-event";
import { UserAvatar } from "./user-avatar";
import { UserLink } from "./user-link";
import { parsePaymentRequest, readablizeSats } from "../helpers/bolt11";
@ -32,12 +32,14 @@ import useSubject from "../hooks/use-subject";
import useUserLNURLMetadata from "../hooks/use-user-lnurl-metadata";
import { requestZapInvoice } from "../helpers/zaps";
import { ParsedStream, getATag } from "../helpers/nostr/stream";
import EmbeddedNote from "./note/embedded-note";
import EmbeddedNote from "./embed-event/event-types/embedded-note";
import { unique } from "../helpers/array";
import { useUserRelays } from "../hooks/use-user-relays";
import { RelayMode } from "../classes/relay";
import relayScoreboardService from "../services/relay-scoreboard";
import { useAdditionalRelayContext } from "../providers/additional-relay-context";
import { getEventCoordinate, isReplaceable } from "../helpers/nostr/events";
import { EmbedEvent } from "./embed-event";
type FormValues = {
amount: number;
@ -47,7 +49,7 @@ type FormValues = {
export type ZapModalProps = Omit<ModalProps, "children"> & {
pubkey: string;
event?: NostrEvent;
stream?: ParsedStream;
relays?: string[];
initialComment?: string;
initialAmount?: number;
onInvoice: (invoice: string) => void;
@ -59,7 +61,7 @@ export type ZapModalProps = Omit<ModalProps, "children"> & {
export default function ZapModal({
event,
pubkey,
stream,
relays,
onClose,
initialComment,
initialAmount,
@ -132,8 +134,11 @@ export default function ZapModal({
],
};
if (event) zapRequest.tags.push(["e", event.id]);
if (stream) zapRequest.tags.push(["a", getATag(stream)]);
if (event) {
if (isReplaceable(event.kind) && event.tags.some(isDTag)) {
zapRequest.tags.push(["a", getEventCoordinate(event)]);
} else zapRequest.tags.push(["e", event.id]);
}
const signed = await requestSignature(zapRequest);
if (signed) {
@ -175,15 +180,7 @@ export default function ZapModal({
</Box>
</Flex>
{showEventPreview && stream && (
<Box>
<Heading size="sm" mb="2">
Stream: {stream.title}
</Heading>
{stream.image && <Image src={stream.image} />}
</Box>
)}
{showEventPreview && event && <EmbeddedNote event={event} />}
{showEventPreview && event && <EmbedEvent event={event} />}
{allowComment && (canZap || lnurlMetadata?.commentAllowed) && (
<Input

View File

@ -2,8 +2,9 @@ import { bech32 } from "bech32";
import { getPublicKey, nip19 } from "nostr-tools";
import { getEventRelays } from "../services/event-relays";
import relayScoreboardService from "../services/relay-scoreboard";
import { NostrEvent, isDTag } from "../types/nostr-event";
import { getEventUID } from "./nostr/events";
import { NostrEvent, Tag, isATag, isDTag, isETag, isPTag } from "../types/nostr-event";
import { getEventUID, isReplaceable } from "./nostr/events";
import { DecodeResult } from "nostr-tools/lib/nip19";
export function isHexKey(key?: string) {
if (key?.toLowerCase()?.match(/^[0-9a-f]{64}$/)) return true;
@ -58,12 +59,14 @@ export function getPubkey(result: nip19.DecodeResult) {
}
}
/** @deprecated */
export function normalizeToHex(hex: string) {
if (isHexKey(hex)) return hex;
if (isBech32Key(hex)) return bech32ToHex(hex);
return null;
}
/** @deprecated */
export function getSharableNoteId(eventId: string) {
const relays = getEventRelays(eventId).value;
const ranked = relayScoreboardService.getRankedRelays(relays);
@ -74,14 +77,75 @@ export function getSharableNoteId(eventId: string) {
} else return nip19.noteEncode(eventId);
}
export function getSharableEventNaddr(event: NostrEvent) {
export function getSharableEventAddress(event: NostrEvent) {
const relays = getEventRelays(getEventUID(event)).value;
const ranked = relayScoreboardService.getRankedRelays(relays);
const onlyTwo = ranked.slice(0, 2);
const d = event.tags.find(isDTag)?.[1];
if (!d) return null;
return nip19.naddrEncode({ kind: event.kind, identifier: d, pubkey: event.pubkey, relays: onlyTwo });
if (isReplaceable(event.kind)) {
const d = event.tags.find(isDTag)?.[1];
if (!d) return null;
return nip19.naddrEncode({ kind: event.kind, identifier: d, pubkey: event.pubkey, relays: onlyTwo });
} else {
if (onlyTwo.length > 0) {
return nip19.neventEncode({ id: event.id, relays: onlyTwo });
} else return nip19.noteEncode(event.id);
}
}
export function encodePointer(pointer: DecodeResult) {
switch (pointer.type) {
case "naddr":
return nip19.naddrEncode(pointer.data);
case "nprofile":
return nip19.nprofileEncode(pointer.data);
case "nevent":
return nip19.neventEncode(pointer.data);
case "nrelay":
return nip19.nrelayEncode(pointer.data);
case "nsec":
return nip19.nsecEncode(pointer.data);
case "npub":
return nip19.npubEncode(pointer.data);
case "note":
return nip19.noteEncode(pointer.data);
}
}
export function getPointerFromTag(tag: Tag): DecodeResult | null {
if (isETag(tag)) {
if (!tag[1]) return null;
return {
type: "nevent",
data: {
id: tag[1],
relays: tag[2] ? [tag[2]] : undefined,
},
};
} else if (isATag(tag)) {
const [_, coordinate, relay] = tag;
const parts = coordinate.split(":") as (string | undefined)[];
const kind = parts[0] && parseInt(parts[0]);
const pubkey = parts[1];
const d = parts[2];
if (!kind) return null;
if (!pubkey) return null;
if (!d) return null;
return {
type: "naddr",
data: {
kind,
pubkey,
identifier: d,
relays: relay ? [relay] : undefined,
},
};
} else if (isPTag(tag)) {
const [_, pubkey, relay] = tag;
if (!pubkey) return null;
return { type: "nprofile", data: { pubkey, relays: relay ? [relay] : undefined } };
}
return null;
}

View File

@ -2,11 +2,11 @@ import dayjs from "dayjs";
import { Kind, nip19 } from "nostr-tools";
import { getEventRelays } from "../../services/event-relays";
import { DraftNostrEvent, isETag, isPTag, NostrEvent, RTag, Tag } from "../../types/nostr-event";
import { ATag, DraftNostrEvent, ETag, isETag, isPTag, NostrEvent, RTag, Tag } from "../../types/nostr-event";
import { RelayConfig, RelayMode } from "../../classes/relay";
import { getMatchNostrLink } from "../regexp";
import relayScoreboardService from "../../services/relay-scoreboard";
import { AddressPointer } from "nostr-tools/lib/nip19";
import type { AddressPointer, EventPointer } from "nostr-tools/lib/nip19";
export function truncatedId(str: string, keep = 6) {
if (str.length < keep * 2 + 3) return str;

79
src/helpers/nostr/goal.ts Normal file
View File

@ -0,0 +1,79 @@
import dayjs from "dayjs";
import { NostrEvent, isRTag } from "../../types/nostr-event";
import { DecodeResult } from "nostr-tools/lib/nip19";
import { getPointerFromTag } from "../nip19";
export const GOAL_KIND = 9041;
export type ParsedGoal = {
event: NostrEvent;
author: string;
amount: number;
relays: string[];
};
export function getGoalPointerFromEvent(event: NostrEvent) {
const tag = event.tags.find((t) => t[0] === "goal");
const id = tag?.[1];
const relay = tag?.[2];
return id ? { id, relay } : undefined;
}
export function getGoalName(goal: NostrEvent) {
return goal.content;
}
export function getGoalRelays(goal: NostrEvent) {
const relays = goal.tags.find((t) => t[0] === "relays");
return relays ? relays.slice(1) : [];
}
export function getGoalAmount(goal: NostrEvent) {
const amount = goal.tags.find((t) => t[0] === "amount")?.[1];
if (amount === undefined) throw new Error("Missing amount");
const int = parseInt(amount);
if (!Number.isFinite(int)) throw new Error("Amount not a number");
if (int <= 0) throw new Error("Amount less than or equal to zero");
return int;
}
export function getGoalClosedDate(goal: NostrEvent) {
const value = goal.tags.find((t) => t[0] === "closed_at")?.[1];
if (value === undefined) return;
const date = dayjs.unix(parseInt(value));
if (!date.isValid) throw new Error("Invalid date");
return date.unix();
}
export function getGoalLinks(goal: NostrEvent) {
return goal.tags.filter(isRTag).map((t) => t[1]);
}
export function getGoalEventPointers(goal: NostrEvent) {
const pointers: DecodeResult[] = [];
for (const tag of goal.tags) {
const decoded = getPointerFromTag(tag);
if (decoded?.type === "naddr" || decoded?.type === "nevent") {
pointers.push(decoded);
}
}
return pointers;
}
export function validateGoal(goal: NostrEvent) {
const amount = getGoalAmount(goal);
const relays = getGoalRelays(goal);
if (relays.length) throw new Error("zero relays");
return true;
}
export function safeValidateGoal(goal: NostrEvent) {
try {
return validateGoal(goal);
} catch (e) {}
return false;
}
export function getGoalTag(goal: NostrEvent, relay?: string) {
const id = goal.id;
return ["goal", id, relay].filter(Boolean);
}

View File

@ -1,5 +1,5 @@
export const getMatchNostrLink = () =>
/(nostr:|@)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi;
/(nostr:|@)?((npub|note|nprofile|nevent|nrelay|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi;
export const getMatchHashtag = () => /(^|[^\p{L}])#([\p{L}\p{N}]+)/gu;
export const getMatchLink = () =>
/https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{Letter}\p{Number}&\.-\/\?=#\-@%\+_,:]*)/gu;

View File

@ -1,15 +1,16 @@
import { useMemo } from "react";
import eventZapsService from "../services/event-zaps";
import { useReadRelayUrls } from "./use-client-relays";
import useSubject from "./use-subject";
import { parseZapEvent } from "../helpers/zaps";
export default function useEventZaps(eventId: string, additionalRelays: string[] = [], alwaysFetch = true) {
export default function useEventZaps(eventUID: string, additionalRelays: string[] = [], alwaysFetch = true) {
const relays = useReadRelayUrls(additionalRelays);
const subject = useMemo(
() => eventZapsService.requestZaps(eventId, relays, alwaysFetch),
[eventId, relays.join("|"), alwaysFetch],
() => eventZapsService.requestZaps(eventUID, relays, alwaysFetch),
[eventUID, relays.join("|"), alwaysFetch],
);
const events = useSubject(subject) || [];

View File

@ -1,10 +1,13 @@
import { useAsync } from "react-use";
import singleEventService from "../services/single-event";
export default function useSingleEvent(id?: string, relays: string[] = []) {
import singleEventService from "../services/single-event";
import { useReadRelayUrls } from "./use-client-relays";
export default function useSingleEvent(id?: string, additionalRelays: string[] = []) {
const readRelays = useReadRelayUrls(additionalRelays);
const { loading, value: event } = useAsync(async () => {
if (id) return singleEventService.requestEvent(id, relays);
}, [id, relays.join("|")]);
if (id) return singleEventService.requestEvent(id, readRelays);
}, [id, readRelays.join("|")]);
return {
event,

View File

@ -1,23 +1,26 @@
import { Kind } from "nostr-tools";
import { NostrRequest } from "../classes/nostr-request";
import Subject from "../classes/subject";
import { SuperMap } from "../classes/super-map";
import { getReferences } from "../helpers/nostr/events";
import { NostrEvent } from "../types/nostr-event";
import { NostrRequestFilter } from "../types/nostr-query";
import { isHexKey } from "../helpers/nip19";
type eventId = string;
type eventUID = string;
type relay = string;
class EventZapsService {
subjects = new SuperMap<eventId, Subject<NostrEvent[]>>(() => new Subject<NostrEvent[]>([]));
pending = new SuperMap<eventId, Set<relay>>(() => new Set());
subjects = new SuperMap<eventUID, Subject<NostrEvent[]>>(() => new Subject<NostrEvent[]>([]));
pending = new SuperMap<eventUID, Set<relay>>(() => new Set());
requestZaps(eventId: string, relays: relay[], alwaysFetch = true) {
const subject = this.subjects.get(eventId);
requestZaps(eventUID: eventUID, relays: relay[], alwaysFetch = true) {
const subject = this.subjects.get(eventUID);
if (!subject.value || alwaysFetch) {
for (const relay of relays) {
this.pending.get(eventId).add(relay);
this.pending.get(eventUID).add(relay);
}
}
@ -41,7 +44,7 @@ class EventZapsService {
batchRequests() {
if (this.pending.size === 0) return;
const idsFromRelays: Record<relay, eventId[]> = {};
const idsFromRelays: Record<relay, eventUID[]> = {};
for (const [id, relays] of this.pending) {
for (const relay of relays) {
idsFromRelays[relay] = idsFromRelays[relay] ?? [];
@ -52,7 +55,18 @@ class EventZapsService {
for (const [relay, ids] of Object.entries(idsFromRelays)) {
const request = new NostrRequest([relay]);
request.onEvent.subscribe(this.handleEvent, this);
request.start({ "#e": ids, kinds: [Kind.Zap] });
const eventIds = ids.filter(isHexKey);
const coordinates = ids.filter((id) => id.includes(":"));
const queries: NostrRequestFilter = [];
if (eventIds.length > 0) {
queries.push({ "#e": eventIds, kinds: [Kind.Zap] });
}
if (coordinates.length > 0) {
queries.push({ "#a": coordinates, kinds: [Kind.Zap] });
}
request.start(queries);
}
this.pending.clear();
}

View File

@ -12,13 +12,12 @@ import {
Image,
Link,
Text,
Tooltip,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import { getSharableEventNaddr } from "../../../helpers/nip19";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { NostrEvent } from "../../../types/nostr-event";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import EmojiPackFavoriteButton from "./emoji-pack-favorite-button";
@ -28,14 +27,14 @@ import EmojiPackMenu from "./emoji-pack-menu";
export default function EmojiPackCard({ pack, ...props }: Omit<CardProps, "children"> & { pack: NostrEvent }) {
const emojis = getEmojisFromPack(pack);
const naddr = getSharableEventNaddr(pack);
const naddr = getSharableEventAddress(pack);
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(pack));
return (
<Card ref={ref} {...props}>
<Card ref={ref} variant="outline" {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<Heading size="md">
<Link as={RouterLink} to={`/emojis/${naddr}`}>

View File

@ -6,7 +6,7 @@ import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-ic
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 { getSharableEventAddress } from "../../../helpers/nip19";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { useDeleteEventContext } from "../../../providers/delete-event-provider";
@ -21,7 +21,7 @@ export default function EmojiPackMenu({
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const naddr = getSharableEventNaddr(pack);
const naddr = getSharableEventAddress(pack);
return (
<>

View File

@ -92,7 +92,6 @@ export default function EmojiPackView() {
<Divider />
<Card variant="elevated">
<CardBody p="2">
{/* <Flex gap="2" wrap="wrap"> */}
<SimpleGrid columns={{ base: 2, sm: 3, md: 2, lg: 4, xl: 6 }} gap="2">
{emojis.map(({ name, url }) => (
<Flex gap="2" alignItems="center">
@ -101,7 +100,6 @@ export default function EmojiPackView() {
</Flex>
))}
</SimpleGrid>
{/* </Flex> */}
</CardBody>
</Card>
</>

View File

@ -0,0 +1,67 @@
import { Flex, SimpleGrid, Switch, useDisclosure } from "@chakra-ui/react";
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import useSubject from "../../hooks/use-subject";
import GoalCard from "./components/goal-card";
import { getEventUID } from "../../helpers/nostr/events";
import { GOAL_KIND, getGoalClosedDate } from "../../helpers/nostr/goal";
import { SwipeState } from "yet-another-react-lightbox";
import { useCallback } from "react";
import { NostrEvent } from "../../types/nostr-event";
import dayjs from "dayjs";
function GoalsBrowsePage() {
const { filter, listId } = usePeopleListContext();
const showClosed = useDisclosure();
const readRelays = useReadRelayUrls();
const eventFilter = useCallback(
(event: NostrEvent) => {
const closed = getGoalClosedDate(event);
if (!showClosed.isOpen && closed && dayjs().isAfter(dayjs.unix(closed))) return false;
return true;
},
[showClosed.isOpen],
);
const timeline = useTimelineLoader(
`${listId}-browse-goals`,
readRelays,
{ ...filter, kinds: [GOAL_KIND] },
{ enabled: !!filter, eventFilter },
);
const goals = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<Flex direction="column" gap="2" p="2" pb="10">
<Flex gap="2" alignItems="center" wrap="wrap">
<PeopleListSelection />
<Switch isChecked={showClosed.isOpen} onChange={showClosed.onToggle}>
Show ended
</Switch>
</Flex>
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing="2">
{goals.map((event) => (
<GoalCard key={getEventUID(event)} goal={event} />
))}
</SimpleGrid>
</Flex>
</IntersectionObserverProvider>
);
}
export default function GoalsBrowseView() {
return (
<PeopleListProvider>
<GoalsBrowsePage />
</PeopleListProvider>
);
}

View File

@ -0,0 +1,50 @@
import { memo, useRef } from "react";
import { Link as RouterLink } from "react-router-dom";
import { ButtonGroup, Card, CardBody, CardHeader, CardProps, Heading, Link, Text } from "@chakra-ui/react";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { NostrEvent } from "../../../types/nostr-event";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { getEventUID } from "../../../helpers/nostr/events";
import { getGoalClosedDate, getGoalName } from "../../../helpers/nostr/goal";
import GoalMenu from "./goal-menu";
import GoalProgress from "./goal-progress";
import GoalContents from "./goal-contents";
import dayjs from "dayjs";
function GoalCard({ goal, ...props }: Omit<CardProps, "children"> & { goal: NostrEvent }) {
const nevent = getSharableEventAddress(goal);
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(goal));
const closed = getGoalClosedDate(goal);
return (
<Card ref={ref} variant="outline" {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<Heading size="md">
<Link as={RouterLink} to={`/goals/${nevent}`}>
{getGoalName(goal)}
</Link>
</Heading>
<Text>by</Text>
<UserAvatarLink pubkey={goal.pubkey} size="xs" />
<UserLink pubkey={goal.pubkey} isTruncated fontWeight="bold" fontSize="md" />
<ButtonGroup size="xs" ml="auto">
<GoalMenu goal={goal} aria-label="emoji pack menu" />
</ButtonGroup>
</CardHeader>
<CardBody p="2" display="flex" gap="4" flexDirection="column">
{closed && <Text>Ends: {dayjs.unix(closed).fromNow()}</Text>}
<GoalProgress goal={goal} />
<GoalContents goal={goal} />
</CardBody>
</Card>
);
}
export default memo(GoalCard);

View File

@ -0,0 +1,21 @@
import { EmbedEventPointer } from "../../../components/embed-event";
import { getGoalEventPointers, getGoalLinks } from "../../../helpers/nostr/goal";
import { NostrEvent } from "../../../types/nostr-event";
import { encodePointer } from "../../../helpers/nip19";
import OpenGraphCard from "../../../components/open-graph-card";
export default function GoalContents({ goal }: { goal: NostrEvent }) {
const pointers = getGoalEventPointers(goal);
const links = getGoalLinks(goal);
return (
<>
{pointers.map((pointer) => (
<EmbedEventPointer key={encodePointer(pointer)} pointer={pointer} />
))}
{links.map((link) => (
<OpenGraphCard url={new URL(link)} />
))}
</>
);
}

View File

@ -0,0 +1,50 @@
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 { getSharableEventAddress } from "../../../helpers/nip19";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { useDeleteEventContext } from "../../../providers/delete-event-provider";
export default function GoalMenu({ goal, ...props }: { goal: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
// const account = useCurrentAccount();
const infoModal = useDisclosure();
// const { deleteEvent } = useDeleteEventContext();
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const nevent = getSharableEventAddress(goal);
return (
<>
<MenuIconButton {...props}>
{nevent && (
<>
<MenuItem onClick={() => window.open(buildAppSelectUrl(nevent), "_blank")} icon={<ExternalLinkIcon />}>
View in app...
</MenuItem>
<MenuItem onClick={() => copyToClipboard("nostr:" + nevent)} icon={<RepostIcon />}>
Copy Share Link
</MenuItem>
</>
)}
{/* {account?.pubkey === goal.pubkey && (
<MenuItem icon={<TrashIcon />} color="red.500" onClick={() => deleteEvent(goal)}>
Delete Goal
</MenuItem>
)} */}
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
</MenuIconButton>
{infoModal.isOpen && (
<NoteDebugModal event={goal} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
)}
</>
);
}

View File

@ -0,0 +1,24 @@
import { Flex, Progress, Text } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { getGoalAmount, getGoalRelays } from "../../../helpers/nostr/goal";
import { LightningIcon } from "../../../components/icons";
import useEventZaps from "../../../hooks/use-event-zaps";
import { getEventUID } from "../../../helpers/nostr/events";
import { totalZaps } from "../../../helpers/zaps";
import { readablizeSats } from "../../../helpers/bolt11";
export default function GoalProgress({ goal }: { goal: NostrEvent }) {
const amount = getGoalAmount(goal);
const zaps = useEventZaps(getEventUID(goal), getGoalRelays(goal), true);
const raised = totalZaps(zaps);
return (
<Flex gap="2" alignItems="center">
<LightningIcon />
<Progress value={(raised / amount) * 100} colorScheme="yellow" flex={1} />
<Text>
{readablizeSats(raised / 1000)} / {readablizeSats(amount / 1000)} ({Math.round((raised / amount) * 1000) / 10}%)
</Text>
</Flex>
);
}

View File

@ -0,0 +1,45 @@
import { Button, ButtonProps, useDisclosure } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import ZapModal from "../../../components/zap-modal";
import eventZapsService from "../../../services/event-zaps";
import { getEventUID } from "../../../helpers/nostr/events";
import { useInvoiceModalContext } from "../../../providers/invoice-modal";
import { getGoalRelays } from "../../../helpers/nostr/goal";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
export default function GoalZapButton({
goal,
...props
}: Omit<ButtonProps, "children" | "onClick"> & { goal: NostrEvent }) {
const modal = useDisclosure();
const { requestPay } = useInvoiceModalContext();
const readRelays = useReadRelayUrls(getGoalRelays(goal));
const handleInvoice = async (invoice: string) => {
modal.onClose();
await requestPay(invoice);
setTimeout(() => {
eventZapsService.requestZaps(getEventUID(goal), readRelays, true);
}, 1000);
};
return (
<>
<Button colorScheme="yellow" onClick={modal.onOpen} {...props}>
Zap Goal
</Button>
{modal.isOpen && (
<ZapModal
isOpen
onClose={modal.onClose}
event={goal}
onInvoice={handleInvoice}
pubkey={goal.pubkey}
relays={getGoalRelays(goal)}
allowComment
showEventPreview={false}
/>
)}
</>
);
}

View File

@ -0,0 +1,39 @@
import { Box, Flex, Spacer, Text } from "@chakra-ui/react";
import { getEventUID } from "../../../helpers/nostr/events";
import { getGoalRelays } from "../../../helpers/nostr/goal";
import useEventZaps from "../../../hooks/use-event-zaps";
import { NostrEvent } from "../../../types/nostr-event";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import { readablizeSats } from "../../../helpers/bolt11";
import { LightningIcon } from "../../../components/icons";
import dayjs from "dayjs";
export default function GoalZapList({ goal }: { goal: NostrEvent }) {
const zaps = useEventZaps(getEventUID(goal), getGoalRelays(goal), true);
const sorted = Array.from(zaps).sort((a, b) => b.event.created_at - a.event.created_at);
return (
<>
{sorted.map((zap) => (
<Flex key={zap.eventId} gap="2">
<UserAvatarLink pubkey={zap.request.pubkey} size="md" />
<Box>
<Text>
<UserLink fontSize="lg" fontWeight="bold" pubkey={zap.request.pubkey} mr="2" />
<Text as="span">{dayjs.unix(zap.event.created_at).fromNow()}</Text>
</Text>
{zap.request.content && <Text>{zap.request.content}</Text>}
</Box>
<Spacer />
{zap.payment.amount && (
<Text>
<LightningIcon /> {readablizeSats(zap.payment.amount / 1000)}
</Text>
)}
</Flex>
))}
</>
);
}

View File

@ -0,0 +1,71 @@
import { useNavigate, useParams } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { Button, ButtonGroup, Divider, Flex, Heading, Spacer, Spinner } from "@chakra-ui/react";
import { ArrowLeftSIcon } from "../../components/icons";
import GoalMenu from "./components/goal-menu";
import { getGoalAmount, getGoalName } from "../../helpers/nostr/goal";
import GoalProgress from "./components/goal-progress";
import useSingleEvent from "../../hooks/use-single-event";
import { isHexKey } from "../../helpers/nip19";
import { EventPointer } from "nostr-tools/lib/nip19";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import GoalContents from "./components/goal-contents";
import GoalZapList from "./components/goal-zap-list";
import { readablizeSats } from "../../helpers/bolt11";
import GoalZapButton from "./components/goal-zap-button";
function useGoalPointerFromParams(): EventPointer {
const { id } = useParams() as { id: string };
if (isHexKey(id)) return { id };
const parsed = nip19.decode(id);
if (parsed.type === "nevent") return parsed.data;
if (parsed.type === "note") return { id: parsed.data };
throw new Error("bad goal id");
}
export default function GoalDetailsView() {
const navigate = useNavigate();
const pointer = useGoalPointerFromParams();
const { event: goal } = useSingleEvent(pointer.id, pointer.relays);
if (!goal) return <Spinner />;
return (
<Flex direction="column" px="2" pt="2" pb="8" overflowY="auto" overflowX="hidden" h="full" gap="2">
<Flex gap="2" alignItems="center">
<Button onClick={() => navigate(-1)} leftIcon={<ArrowLeftSIcon />}>
Back
</Button>
<Heading size="md" isTruncated>
{getGoalName(goal)} ({readablizeSats(getGoalAmount(goal) / 1000)})
</Heading>
<Spacer />
<ButtonGroup>
<GoalZapButton goal={goal} />
<GoalMenu aria-label="More options" goal={goal} />
</ButtonGroup>
</Flex>
<Flex gap="2" alignItems="center">
<UserAvatar pubkey={goal.pubkey} size="sm" />
<UserLink pubkey={goal.pubkey} fontWeight="bold" fontSize="lg" />
</Flex>
<GoalContents goal={goal} />
<Heading size="md" mt="2">
Progress:
</Heading>
<GoalProgress goal={goal} />
<Heading size="md" mt="2">
Contributors:
</Heading>
<Divider />
<GoalZapList goal={goal} />
</Flex>
);
}

81
src/views/goals/index.tsx Normal file
View File

@ -0,0 +1,81 @@
import { Button, Center, Divider, Flex, Heading, Link, SimpleGrid, Spacer } from "@chakra-ui/react";
import { Navigate, Link as RouterLink } from "react-router-dom";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { ExternalLinkIcon } from "../../components/icons";
import { getEventUID } from "../../helpers/nostr/events";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useSubject from "../../hooks/use-subject";
import GoalCard from "./components/goal-card";
import { GOAL_KIND } from "../../helpers/nostr/goal";
function UserGoalsManagerPage() {
const account = useCurrentAccount()!;
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader(
`${account.pubkey}-goals`,
readRelays,
{
authors: [account.pubkey],
kinds: [GOAL_KIND],
},
{ enabled: !!account.pubkey },
);
const goals = useSubject(timeline.timeline);
if (goals.length === 0) {
return (
<Center p="10" fontSize="lg" whiteSpace="pre-wrap">
You don't have any goals,{" "}
<Link as={RouterLink} to="/goals/browse" color="blue.500">
Find a goal
</Link>{" "}
to support or{" "}
<Link href="https://goals-silk.vercel.app/new" isExternal color="blue.500">
Create one
</Link>
</Center>
);
}
return (
<>
{goals.length > 0 && (
<>
<Heading size="md" mt="2">
Created goals
</Heading>
<Divider />
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing="2">
{goals.map((event) => (
<GoalCard key={getEventUID(event)} goal={event} />
))}
</SimpleGrid>
</>
)}
</>
);
}
export default function GoalsView() {
const account = useCurrentAccount();
return (
<Flex direction="column" pt="2" pb="10" gap="2" px={["2", "2", 0]}>
<Flex gap="2">
<Button as={RouterLink} to="/goals/browse">
Explore goals
</Button>
<Spacer />
<Button as={Link} href="https://goals-silk.vercel.app/" isExternal rightIcon={<ExternalLinkIcon />}>
Goal manager
</Button>
</Flex>
{account ? <UserGoalsManagerPage /> : <Navigate to="/goals/browse" />}
</Flex>
);
}

View File

@ -6,6 +6,7 @@ import {
CardBody,
CardFooter,
CardHeader,
CardProps,
Flex,
Heading,
Link,
@ -17,7 +18,7 @@ import dayjs from "dayjs";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import { getEventsFromList, getListName, getPubkeysFromList } from "../../../helpers/nostr/lists";
import { getSharableEventNaddr } from "../../../helpers/nip19";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { NostrEvent } from "../../../types/nostr-event";
import useReplaceableEvent from "../../../hooks/use-replaceable-event";
import { createCoordinate } from "../../../services/replaceable-event-requester";
@ -29,18 +30,18 @@ import ListFavoriteButton from "./list-favorite-button";
import { getEventUID } from "../../../helpers/nostr/events";
import ListMenu from "./list-menu";
function ListCardRender({ event }: { event: NostrEvent }) {
function ListCardRender({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const people = getPubkeysFromList(event);
const notes = getEventsFromList(event);
const link =
event.kind === Kind.Contacts ? createCoordinate(Kind.Contacts, event.pubkey) : getSharableEventNaddr(event);
event.kind === Kind.Contacts ? createCoordinate(Kind.Contacts, event.pubkey) : getSharableEventAddress(event);
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(event));
return (
<Card ref={ref}>
<Card ref={ref} variant="outline" {...props}>
<CardHeader display="flex" alignItems="center" p="2" pb="0">
<Heading size="md">
<Link as={RouterLink} to={`/lists/${link}`}>

View File

@ -6,7 +6,7 @@ import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-ic
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 { getSharableEventAddress } from "../../../helpers/nip19";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { useDeleteEventContext } from "../../../providers/delete-event-provider";
@ -18,7 +18,7 @@ export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const naddr = getSharableEventNaddr(list);
const naddr = getSharableEventAddress(list);
return (
<>

View File

@ -9,7 +9,7 @@ import ListCard from "./components/list-card";
import { getEventUID } from "../../helpers/nostr/events";
import useUserLists from "../../hooks/use-user-lists";
import NewListModal from "./components/new-list-modal";
import { getSharableEventNaddr } from "../../helpers/nip19";
import { getSharableEventAddress } from "../../helpers/nip19";
import { MUTE_LIST_KIND, NOTE_LIST_KIND, PEOPLE_LIST_KIND, PIN_LIST_KIND } from "../../helpers/nostr/lists";
import useFavoriteLists from "../../hooks/use-favorite-lists";
@ -89,7 +89,7 @@ function ListsPage() {
<NewListModal
isOpen
onClose={newList.onClose}
onCreated={(list) => navigate(`/lists/${getSharableEventNaddr(list)}`)}
onCreated={(list) => navigate(`/lists/${getSharableEventAddress(list)}`)}
/>
)}
</Flex>

View File

@ -31,7 +31,7 @@ function useListCoordinate() {
return parsed.data;
}
export default function ListView() {
export default function ListDetailsView() {
const navigate = useNavigate();
const coordinate = useListCoordinate();
const { deleteEvent } = useDeleteEventContext();

View File

@ -1,3 +1,4 @@
import { PropsWithChildren } from "react";
import {
Box,
Button,
@ -21,9 +22,10 @@ import {
ModalOverlay,
Tag,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import styled from "@emotion/styled";
import { Link as RouterLink } from "react-router-dom";
import { useRelayInfo } from "../../../hooks/use-relay-info";
import { RelayFavicon } from "../../../components/relay-favicon";
import { CodeIcon, RepostIcon } from "../../../components/icons";
@ -34,13 +36,8 @@ import clientRelaysService from "../../../services/client-relays";
import { RelayMode } from "../../../classes/relay";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import styled from "@emotion/styled";
import { PropsWithChildren, useCallback } from "react";
import RawJson from "../../../components/debug-modals/raw-json";
import { DraftNostrEvent } from "../../../types/nostr-event";
import dayjs from "dayjs";
import { useSigningContext } from "../../../providers/signing-provider";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import { RelayShareButton } from "./relay-share-button";
const B = styled.span`
font-weight: bold;
@ -146,44 +143,6 @@ export function RelayDebugButton({ url, ...props }: { url: string } & Omit<IconB
);
}
export function RelayShareButton({
relay,
...props
}: { relay: string } & Omit<IconButtonProps, "icon" | "aria-label">) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const recommendRelay = useCallback(async () => {
try {
const writeRelays = clientRelaysService.getWriteUrls();
const draft: DraftNostrEvent = {
kind: 2,
content: relay,
tags: [],
created_at: dayjs().unix(),
};
const signed = await requestSignature(draft);
const post = new NostrPublishAction("Share Relay", writeRelays, signed);
await post.onComplete;
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
}, []);
return (
<IconButton
icon={<RepostIcon />}
aria-label="Recommend Relay"
title="Recommend Relay"
onClick={recommendRelay}
variant="ghost"
{...props}
/>
);
}
export function RelayPaidTag({ url }: { url: string }) {
const { info } = useRelayInfo(url);

View File

@ -0,0 +1,47 @@
import { useCallback } from "react";
import dayjs from "dayjs";
import { IconButton, IconButtonProps, useToast } from "@chakra-ui/react";
import { useSigningContext } from "../../../providers/signing-provider";
import clientRelaysService from "../../../services/client-relays";
import { DraftNostrEvent } from "../../../types/nostr-event";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import { RepostIcon } from "../../../components/icons";
export function RelayShareButton({
relay,
...props
}: { relay: string } & Omit<IconButtonProps, "icon" | "aria-label">) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const recommendRelay = useCallback(async () => {
try {
const writeRelays = clientRelaysService.getWriteUrls();
const draft: DraftNostrEvent = {
kind: 2,
content: relay,
tags: [],
created_at: dayjs().unix(),
};
const signed = await requestSignature(draft);
const post = new NostrPublishAction("Share Relay", writeRelays, signed);
await post.onComplete;
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
}, []);
return (
<IconButton
icon={<RepostIcon />}
aria-label="Recommend Relay"
title="Recommend Relay"
onClick={recommendRelay}
variant="ghost"
{...props}
/>
);
}

View File

@ -8,13 +8,13 @@ export default function StreamStatusBadge({
switch (stream.status) {
case "live":
return (
<Badge colorScheme="green" {...props}>
<Badge colorScheme="green" variant="solid" {...props}>
live
</Badge>
);
case "ended":
return (
<Badge colorScheme="red" {...props}>
<Badge colorScheme="red" variant="solid" {...props}>
ended
</Badge>
);

View File

@ -34,7 +34,7 @@ function StreamerCard({ cord, relay, ...props }: { cord: string; relay?: string
const link = card.tags.find((t) => t[0] === "r")?.[1];
return (
<Card as={LinkBox} {...props}>
<Card as={LinkBox} variant="outline" {...props}>
{image && <Image src={image} />}
{title && (
<CardHeader p="2">
@ -42,10 +42,10 @@ function StreamerCard({ cord, relay, ...props }: { cord: string; relay?: string
</CardHeader>
)}
<CardBody p="2">
<NoteContents event={card} noOpenGraphLinks />
<NoteContents event={card} />
{link && (
<LinkOverlay isExternal href={link} color="blue.500">
{link}
{!image && link}
</LinkOverlay>
)}
</CardBody>

View File

@ -78,7 +78,7 @@ export default function ChatMessageForm({ stream }: { stream: ParsedStream }) {
{zapModal.isOpen && (
<ZapModal
isOpen
stream={stream}
event={stream.event}
pubkey={stream.host}
onInvoice={async (invoice) => {
reset();

36
src/views/user/goals.tsx Normal file
View File

@ -0,0 +1,36 @@
import { useOutletContext } from "react-router-dom";
import { Divider, Flex, Heading, SimpleGrid } from "@chakra-ui/react";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useSubject from "../../hooks/use-subject";
import { getEventUID } from "../../helpers/nostr/events";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import { GOAL_KIND } from "../../helpers/nostr/goal";
import GoalCard from "../goals/components/goal-card";
export default function UserGoalsTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const readRelays = useAdditionalRelayContext();
const timeline = useTimelineLoader(pubkey + "-goals", readRelays, {
authors: [pubkey],
kinds: [GOAL_KIND],
});
const goals = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<Flex gap="2" pt="2" pb="10" px={["2", "2", 0]} direction="column">
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing="2">
{goals.map((goal) => (
<GoalCard key={getEventUID(goal)} goal={goal} />
))}
</SimpleGrid>
</Flex>
</IntersectionObserverProvider>
);
}

View File

@ -50,6 +50,7 @@ const tabs = [
{ label: "Following", path: "following" },
{ label: "Likes", path: "likes" },
{ label: "Relays", path: "relays" },
{ label: "Goals", path: "goals" },
{ label: "Emoji Packs", path: "emojis" },
{ label: "Reports", path: "reports" },
{ label: "Followers", path: "followers" },

View File

@ -9,11 +9,12 @@ import useSubject from "../../hooks/use-subject";
import { NostrEvent } from "../../types/nostr-event";
import RelayReviewNote from "../relays/components/relay-review-note";
import { RelayFavicon } from "../../components/relay-favicon";
import { RelayDebugButton, RelayJoinAction, RelayMetadata, RelayShareButton } from "../relays/components/relay-card";
import { RelayDebugButton, RelayJoinAction, RelayMetadata } from "../relays/components/relay-card";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import { useRelayInfo } from "../../hooks/use-relay-info";
import { ErrorBoundary } from "../../components/error-boundary";
import { RelayShareButton } from "../relays/components/relay-share-button";
function Relay({ url, reviews }: { url: string; reviews: NostrEvent[] }) {
const { info } = useRelayInfo(url);