mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-26 17:52:18 +01:00
add goal views
This commit is contained in:
parent
11f98c8967
commit
2a490dd6c1
5
.changeset/flat-scissors-do.md
Normal file
5
.changeset/flat-scissors-do.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add goal views
|
5
.changeset/silent-wombats-flow.md
Normal file
5
.changeset/silent-wombats-flow.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Improve event embed card
|
19
src/app.tsx
19
src/app.tsx
@ -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 /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
33
src/components/embed-event/event-types/embedded-goal.tsx
Normal file
33
src/components/embed-event/event-types/embedded-goal.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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);
|
68
src/components/embed-event/event-types/embedded-stream.tsx
Normal file
68
src/components/embed-event/event-types/embedded-stream.tsx
Normal 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>
|
||||
);
|
||||
}
|
68
src/components/embed-event/index.tsx
Normal file
68
src/components/embed-event/index.tsx
Normal 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}</>;
|
||||
}
|
@ -18,5 +18,5 @@ export function renderGenericUrl(match: URL) {
|
||||
}
|
||||
|
||||
export function renderOpenGraphUrl(match: URL) {
|
||||
return <OpenGraphCard url={match} maxW="lg" />;
|
||||
return <OpenGraphCard url={match} />;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 && (
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
79
src/helpers/nostr/goal.ts
Normal 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);
|
||||
}
|
@ -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;
|
||||
|
@ -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) || [];
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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}`}>
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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>
|
||||
</>
|
67
src/views/goals/browse.tsx
Normal file
67
src/views/goals/browse.tsx
Normal 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>
|
||||
);
|
||||
}
|
50
src/views/goals/components/goal-card.tsx
Normal file
50
src/views/goals/components/goal-card.tsx
Normal 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);
|
21
src/views/goals/components/goal-contents.tsx
Normal file
21
src/views/goals/components/goal-contents.tsx
Normal 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)} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
50
src/views/goals/components/goal-menu.tsx
Normal file
50
src/views/goals/components/goal-menu.tsx
Normal 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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
24
src/views/goals/components/goal-progress.tsx
Normal file
24
src/views/goals/components/goal-progress.tsx
Normal 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>
|
||||
);
|
||||
}
|
45
src/views/goals/components/goal-zap-button.tsx
Normal file
45
src/views/goals/components/goal-zap-button.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
39
src/views/goals/components/goal-zap-list.tsx
Normal file
39
src/views/goals/components/goal-zap-list.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
71
src/views/goals/goal-details.tsx
Normal file
71
src/views/goals/goal-details.tsx
Normal 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
81
src/views/goals/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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}`}>
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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>
|
||||
|
@ -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();
|
@ -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);
|
||||
|
||||
|
47
src/views/relays/components/relay-share-button.tsx
Normal file
47
src/views/relays/components/relay-share-button.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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
36
src/views/user/goals.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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" },
|
||||
|
@ -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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user