Merge branch 'next'

This commit is contained in:
hzrd149 2023-11-13 21:11:17 -06:00
commit 61fee4e9d9
127 changed files with 2119 additions and 1280 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add "DM Feed" tool

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Thread view improvements

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add option to search communities in search view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add "create $prism" link to lists

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add people list to search and hashtag views

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix link cards breaking lines

2
.nvmrc
View File

@ -1 +1 @@
18
20

View File

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2023 Talha Buğra Bulut
Copyright (c) 2023 hzrd149
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1
FROM node:18
FROM node:20
WORKDIR /app
COPY . /app/
ENV VITE_COMMIT_HASH=""

View File

@ -70,6 +70,7 @@ import RelayView from "./views/relays/relay";
import RelayReviewsView from "./views/relays/reviews";
import PopularRelaysView from "./views/relays/popular";
import UserDMsTab from "./views/user/dms";
import DMFeedView from "./views/tools/dm-feed";
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const ToolsHomeView = lazy(() => import("./views/tools"));
@ -230,6 +231,7 @@ const router = createHashRouter([
{ path: "network", element: <NetworkView /> },
{ path: "network-mute-graph", element: <NetworkMuteGraphView /> },
{ path: "network-dm-graph", element: <NetworkDMGraphView /> },
{ path: "dm-feed", element: <DMFeedView /> },
],
},
{

View File

@ -0,0 +1,121 @@
import { useState } from "react";
import { Button, Card, CardBody, CardHeader, CloseButton, Flex, Heading, IconButton, useToast } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import dayjs from "dayjs";
import { useForm } from "react-hook-form";
import { ChevronDownIcon, ChevronUpIcon } from "../icons";
import UserName from "../user-name";
import MagicTextArea from "../magic-textarea";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useCurrentAccount from "../../hooks/use-current-account";
import { useReadRelayUrls, useWriteRelayUrls } from "../../hooks/use-client-relays";
import { useUserRelays } from "../../hooks/use-user-relays";
import { RelayMode } from "../../classes/relay";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import useSubject from "../../hooks/use-subject";
import Message from "../../views/messages/message";
import { LightboxProvider } from "../lightbox-provider";
import { useSigningContext } from "../../providers/signing-provider";
import { DraftNostrEvent } from "../../types/nostr-event";
import NostrPublishAction from "../../classes/nostr-publish-action";
import { correctContentMentions, createEmojiTags } from "../../helpers/nostr/post";
import { useContextEmojis } from "../../providers/emoji-provider";
export default function ChatWindow({ pubkey, onClose }: { pubkey: string; onClose: () => void }) {
const toast = useToast();
const account = useCurrentAccount()!;
const emojis = useContextEmojis();
const [expanded, setExpanded] = useState(true);
const usersRelays = useUserRelays(pubkey);
const readRelays = useReadRelayUrls(usersRelays.filter((c) => c.mode & RelayMode.WRITE).map((c) => c.url));
const writeRelays = useWriteRelayUrls(usersRelays.filter((c) => c.mode & RelayMode.WRITE).map((c) => c.url));
const timeline = useTimelineLoader(`${pubkey}-${account.pubkey}-messages`, readRelays, [
{ authors: [account.pubkey], kinds: [Kind.EncryptedDirectMessage], "#p": [pubkey] },
{ authors: [pubkey], kinds: [Kind.EncryptedDirectMessage], "#p": [account.pubkey] },
]);
const { handleSubmit, getValues, setValue, formState, watch, reset } = useForm({ defaultValues: { content: "" } });
watch("content");
const { requestSignature, requestEncrypt } = useSigningContext();
const submit = handleSubmit(async (values) => {
try {
if (!values.content) return;
let draft: DraftNostrEvent = {
kind: Kind.EncryptedDirectMessage,
content: values.content,
tags: [["p", pubkey]],
created_at: dayjs().unix(),
};
draft = createEmojiTags(draft, emojis);
draft.content = correctContentMentions(draft.content);
// encrypt content
draft.content = await requestEncrypt(draft.content, pubkey);
const signed = await requestSignature(draft);
const pub = new NostrPublishAction("Send DM", writeRelays, signed);
reset();
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
});
const messages = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<Card size="sm" borderRadius="md" w={expanded ? "md" : "xs"} variant="outline">
<CardHeader display="flex" gap="2" alignItems="center">
<Heading size="md" mr="8">
<UserName pubkey={pubkey} />
</Heading>
<IconButton
aria-label="Toggle Window"
onClick={() => setExpanded((v) => !v)}
variant="ghost"
icon={expanded ? <ChevronDownIcon /> : <ChevronUpIcon />}
ml="auto"
size="sm"
/>
<CloseButton onClick={onClose} />
</CardHeader>
{expanded && (
<>
<CardBody
maxH="lg"
overflowX="hidden"
overflowY="auto"
pt="0"
display="flex"
flexDirection="column-reverse"
gap="2"
>
<LightboxProvider>
<IntersectionObserverProvider callback={callback}>
{messages.map((event) => (
<Message key={event.id} event={event} />
))}
</IntersectionObserverProvider>
</LightboxProvider>
</CardBody>
<Flex as="form" onSubmit={submit} gap="2">
<MagicTextArea
isRequired
value={getValues().content}
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
/>
<Button type="submit" isLoading={formState.isSubmitting}>
Send
</Button>
</Flex>
</>
)}
</Card>
);
}

View File

@ -0,0 +1,90 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
AlertIcon,
Button,
Card,
CardBody,
CardHeader,
CloseButton,
Heading,
IconButton,
Input,
InputGroup,
InputLeftElement,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import { ChevronDownIcon, ChevronUpIcon, SearchIcon } from "../icons";
import useSubject from "../../hooks/use-subject";
import directMessagesService from "../../services/direct-messages";
import UserAvatar from "../user-avatar";
import UserName from "../user-name";
export default function ContactsWindow({
onClose,
onSelectPubkey,
}: {
onClose: () => void;
onSelectPubkey: (pubkey: string) => void;
}) {
const [expanded, setExpanded] = useState(true);
// TODO: find a better way to load recent contacts
const [from, setFrom] = useState(() => dayjs().subtract(2, "days"));
const conversations = useSubject(directMessagesService.conversations);
useEffect(() => directMessagesService.loadDateRange(from), [from]);
const sortedConversations = useMemo(() => {
return Array.from(conversations).sort((a, b) => {
const latestA = directMessagesService.getUserMessages(a).value[0]?.created_at ?? 0;
const latestB = directMessagesService.getUserMessages(b).value[0]?.created_at ?? 0;
return latestB - latestA;
});
}, [conversations]);
return (
<Card size="sm" borderRadius="md" minW={expanded ? "sm" : 0}>
<CardHeader display="flex" gap="2" alignItems="center">
<Heading size="md" mr="8">
Contacts
</Heading>
<IconButton
aria-label="Toggle Window"
onClick={() => setExpanded((v) => !v)}
variant="ghost"
icon={expanded ? <ChevronDownIcon /> : <ChevronUpIcon />}
ml="auto"
size="sm"
/>
<CloseButton onClick={onClose} />
</CardHeader>
{expanded && (
<CardBody maxH="lg" overflowX="hidden" overflowY="auto" pt="0" display="flex" flexDirection="column" gap="2">
<Alert status="warning">
<AlertIcon />
Work in progress!
</Alert>
{/* <InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon />
</InputLeftElement>
<Input autoFocus />
</InputGroup> */}
{sortedConversations.map((pubkey) => (
<Button
key={pubkey}
leftIcon={<UserAvatar pubkey={pubkey} size="sm" />}
justifyContent="flex-start"
p="2"
variant="ghost"
onClick={() => onSelectPubkey(pubkey)}
>
<UserName pubkey={pubkey} />
</Button>
))}
</CardBody>
)}
</Card>
);
}

View File

@ -0,0 +1,56 @@
import { Flex, IconButton } from "@chakra-ui/react";
import { useCallback, useState } from "react";
import { useLocalStorage } from "react-use";
import ContactsWindow from "./contacts-window";
import { DirectMessagesIcon } from "../icons";
import ChatWindow from "./chat-window";
import useCurrentAccount from "../../hooks/use-current-account";
export default function ChatWindows() {
const account = useCurrentAccount();
const [pubkeys, setPubkeys] = useState<string[]>([]);
const [show, setShow] = useLocalStorage("show-chat-windows", false);
const openPubkey = useCallback(
(pubkey: string) => {
setPubkeys((keys) => (keys.includes(pubkey) ? keys : keys.concat(pubkey)));
},
[setPubkeys],
);
const closePubkey = useCallback(
(pubkey: string) => {
setPubkeys((keys) => keys.filter((key) => key !== pubkey));
},
[setPubkeys],
);
if (!account) {
return null;
}
if (!show) {
return (
<IconButton
icon={<DirectMessagesIcon boxSize={6} />}
aria-label="Show Contacts"
onClick={() => setShow(true)}
position="fixed"
bottom="0"
right="0"
size="lg"
zIndex={1}
/>
);
}
return (
<Flex direction="row-reverse" position="fixed" bottom="0" right="0" gap="4" alignItems="flex-end" zIndex={1}>
<ContactsWindow onClose={() => setShow(false)} onSelectPubkey={openPubkey} />
{pubkeys.map((pubkey) => (
<ChatWindow key={pubkey} pubkey={pubkey} onClose={() => closePubkey(pubkey)} />
))}
</Flex>
);
}

View File

@ -0,0 +1,41 @@
import { Card, CardBody, CardHeader, CardProps, LinkBox, Spacer, Text } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { TrustProvider } from "../../../providers/trust";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import Timestamp from "../../timestamp";
import DecryptPlaceholder from "../../../views/messages/decrypt-placeholder";
import { MessageContent } from "../../../views/messages/message";
import { getMessageRecipient } from "../../../services/direct-messages";
import useCurrentAccount from "../../../hooks/use-current-account";
export default function EmbeddedDM({ dm, ...props }: Omit<CardProps, "children"> & { dm: NostrEvent }) {
const account = useCurrentAccount();
const isOwnMessage = account?.pubkey === dm.pubkey;
const sender = dm.pubkey;
const receiver = getMessageRecipient(dm);
if (!receiver) return "Broken DM";
return (
<TrustProvider event={dm}>
<Card as={LinkBox} variant="outline" {...props}>
<CardHeader display="flex" gap="2" p="2" alignItems="center">
<UserAvatarLink pubkey={sender} size="xs" />
<UserLink pubkey={sender} fontWeight="bold" isTruncated fontSize="lg" />
<Text mx="2">Messaged</Text>
<UserAvatarLink pubkey={receiver} size="xs" />
<UserLink pubkey={receiver} fontWeight="bold" isTruncated fontSize="lg" />
<Timestamp timestamp={dm.created_at} />
</CardHeader>
<CardBody px="2" pt="0" pb="2">
<DecryptPlaceholder data={dm.content} pubkey={isOwnMessage ? getMessageRecipient(dm) ?? "" : dm.pubkey}>
{(text) => <MessageContent event={dm} text={text} />}
</DecryptPlaceholder>
</CardBody>
</Card>
</TrustProvider>
);
}

View File

@ -0,0 +1,31 @@
import { Card, CardProps, Flex, LinkBox, Spacer, Text } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { TrustProvider } from "../../../providers/trust";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import Timestamp from "../../timestamp";
import ReactionIcon from "../../event-reactions/reaction-icon";
import { NoteLink } from "../../note-link";
import { nip25 } from "nostr-tools";
export default function EmbeddedReaction({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const pointer = nip25.getReactedEventPointer(event);
return (
<TrustProvider event={event}>
<Card as={LinkBox} {...props}>
<Flex p="2" gap="2" alignItems="center">
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} fontWeight="bold" isTruncated fontSize="lg" />
<Text as="span">Reacted with</Text>
<ReactionIcon emoji={event.content} url={event.tags.find((t) => t[0] === "emoji")?.[1]} />
<Text as="span">to</Text>
{pointer && <NoteLink noteId={pointer.id} />}
<Spacer />
<Timestamp timestamp={event.created_at} />
</Flex>
</Card>
</TrustProvider>
);
}

View File

@ -58,7 +58,7 @@ export default function EmbeddedStemstrTrack({ track, ...props }: Omit<CardProps
{hashtags.length > 0 && (
<Flex wrap="wrap" gap="2">
{hashtags.map((hashtag) => (
<Tag>#{hashtag}</Tag>
<Tag key={hashtag}>#{hashtag}</Tag>
))}
</Flex>
)}

View File

@ -26,6 +26,8 @@ import EmbeddedArticle from "./event-types/embedded-article";
import EmbeddedBadge from "./event-types/embedded-badge";
import EmbeddedStreamMessage from "./event-types/embedded-stream-message";
import EmbeddedCommunity from "./event-types/embedded-community";
import EmbeddedReaction from "./event-types/embedded-reaction";
import EmbeddedDM from "./event-types/embedded-dm";
const EmbeddedStemstrTrack = lazy(() => import("./event-types/embedded-stemstr-track"));
export type EmbedProps = {
@ -40,6 +42,10 @@ export function EmbedEvent({
switch (event.kind) {
case Kind.Text:
return <EmbeddedNote event={event} {...cardProps} />;
case Kind.Reaction:
return <EmbeddedReaction event={event} {...cardProps} />;
case Kind.EncryptedDirectMessage:
return <EmbeddedDM dm={event} {...cardProps} />;
case STREAM_KIND:
return <EmbeddedStream event={event} {...cardProps} />;
case GOAL_KIND:

View File

@ -2,6 +2,7 @@ import { Link } from "@chakra-ui/react";
import OpenGraphCard from "../open-graph-card";
import { isVideoURL } from "../../helpers/url";
import OpenGraphLink from "../open-graph-link";
export function renderVideoUrl(match: URL) {
if (!isVideoURL(match)) return null;
@ -23,6 +24,6 @@ export function renderGenericUrl(match: URL) {
);
}
export function renderOpenGraphUrl(match: URL) {
return <OpenGraphCard url={match} />;
export function renderOpenGraphUrl(match: URL, isEndOfLine: boolean) {
return isEndOfLine ? <OpenGraphCard url={match} /> : <OpenGraphLink url={match} />;
}

View File

@ -1,5 +1,6 @@
import { CSSProperties } from "react";
import { Box, useColorMode } from "@chakra-ui/react";
import { EmbedEventPointer } from "../embed-event";
const setZIndex: CSSProperties = { zIndex: 1, position: "relative" };
@ -110,3 +111,13 @@ export function renderSongDotLinkUrl(match: URL) {
></Box>
);
}
// nostr:nevent1qqs95384ynfcgugz29u25ltl7qs6d5chve8ksw7ms3ega8eyem3n5agpz9mhxue69uhkummnw3e82efwvdhk6qgnwaehxw309aex2mrp09skymr99ehhyec6lyxqd
export function renderStemstrUrl(match: URL) {
if (match.hostname !== "stemstr.app") return null;
const [_, base, id] = match.pathname.split("/");
if (base !== "thread" || id.length !== 64) return null;
return <EmbedEventPointer pointer={{ type: "nevent", data: { id, relays: ["wss://relay.stemstr.app"] } }} />;
}

View File

@ -3,12 +3,12 @@ import appSettings from "../../services/settings/app-settings";
import { renderOpenGraphUrl } from "./common";
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/twitter.js
export const TWITTER_DOMAINS = ["twitter.com", "www.twitter.com", "mobile.twitter.com", "pbs.twimg.com"];
export const TWITTER_DOMAINS = ["x.com", "twitter.com", "www.twitter.com", "mobile.twitter.com", "pbs.twimg.com"];
export function renderTwitterUrl(match: URL) {
export function renderTwitterUrl(match: URL, isLineEnd: boolean) {
if (!TWITTER_DOMAINS.includes(match.hostname)) return null;
const { twitterRedirect } = appSettings.value;
if (twitterRedirect) return renderOpenGraphUrl(replaceDomain(match, twitterRedirect));
else return renderOpenGraphUrl(match);
if (twitterRedirect) return renderOpenGraphUrl(replaceDomain(match, twitterRedirect), isLineEnd);
else return renderOpenGraphUrl(match, isLineEnd);
}

View File

@ -1,77 +0,0 @@
import { useCallback, useMemo } from "react";
import { Button, ButtonProps, IconButton, Image, useDisclosure } from "@chakra-ui/react";
import { NostrEvent } from "../types/nostr-event";
import useEventReactions from "../hooks/use-event-reactions";
import { DislikeIcon, LikeIcon } from "./icons";
import { draftEventReaction, groupReactions } from "../helpers/nostr/reactions";
import ReactionDetailsModal from "./reaction-details-modal";
import { useSigningContext } from "../providers/signing-provider";
import clientRelaysService from "../services/client-relays";
import NostrPublishAction from "../classes/nostr-publish-action";
import eventReactionsService from "../services/event-reactions";
import { useCurrentAccount } from "../hooks/use-current-account";
export function ReactionIcon({ emoji, url }: { emoji: string; url?: string }) {
if (emoji === "+") return <LikeIcon />;
if (emoji === "-") return <DislikeIcon />;
if (url) return <Image src={url} title={emoji} alt={emoji} w="1em" h="1em" display="inline" />;
return <span>{emoji}</span>;
}
function ReactionGroupButton({
emoji,
url,
count,
...props
}: Omit<ButtonProps, "leftIcon" | "children"> & { emoji: string; count: number; url?: string }) {
if (count <= 1) {
return <IconButton icon={<ReactionIcon emoji={emoji} url={url} />} aria-label="Reaction" {...props} />;
}
return (
<Button leftIcon={<ReactionIcon emoji={emoji} url={url} />} title={emoji} {...props}>
{count > 1 && count}
</Button>
);
}
export default function EventReactionButtons({ event, max }: { event: NostrEvent; max?: number }) {
const account = useCurrentAccount();
const detailsModal = useDisclosure();
const reactions = useEventReactions(event.id) ?? [];
const grouped = useMemo(() => groupReactions(reactions), [reactions]);
const { requestSignature } = useSigningContext();
const addReaction = useCallback(async (emoji = "+", url?: string) => {
const draft = draftEventReaction(event, emoji, url);
const signed = await requestSignature(draft);
if (signed) {
const writeRelays = clientRelaysService.getWriteUrls();
new NostrPublishAction("Reaction", writeRelays, signed);
eventReactionsService.handleEvent(signed);
}
}, []);
if (grouped.length === 0) return null;
const clamped = Array.from(grouped);
if (max !== undefined) clamped.length = max;
return (
<>
{clamped.map((group) => (
<ReactionGroupButton
key={group.emoji}
emoji={group.emoji}
url={group.url}
count={group.pubkeys.length}
onClick={() => addReaction(group.emoji, group.url)}
colorScheme={account && group.pubkeys.includes(account?.pubkey) ? "primary" : undefined}
/>
))}
<Button onClick={detailsModal.onOpen}>Show all</Button>
{detailsModal.isOpen && <ReactionDetailsModal isOpen onClose={detailsModal.onClose} reactions={reactions} />}
</>
);
}

View File

@ -0,0 +1,37 @@
import { useCallback } from "react";
import { useToast } from "@chakra-ui/react";
import { ReactionGroup, draftEventReaction } from "../../helpers/nostr/reactions";
import useCurrentAccount from "../../hooks/use-current-account";
import { useSigningContext } from "../../providers/signing-provider";
import { NostrEvent } from "../../types/nostr-event";
import clientRelaysService from "../../services/client-relays";
import NostrPublishAction from "../../classes/nostr-publish-action";
import eventReactionsService from "../../services/event-reactions";
export function useAddReaction(event: NostrEvent, grouped: ReactionGroup[]) {
const account = useCurrentAccount();
const toast = useToast();
const { requestSignature } = useSigningContext();
return useCallback(
async (emoji = "+", url?: string) => {
try {
const group = grouped.find((g) => g.emoji === emoji);
if (account && group && group.pubkeys.includes(account?.pubkey)) return;
const draft = draftEventReaction(event, emoji, url);
const signed = await requestSignature(draft);
if (signed) {
const writeRelays = clientRelaysService.getWriteUrls();
new NostrPublishAction("Reaction", writeRelays, signed);
eventReactionsService.handleEvent(signed);
}
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
},
[grouped, account, toast, requestSignature],
);
}

View File

@ -0,0 +1,41 @@
import { useMemo } from "react";
import { Button, useDisclosure } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import useEventReactions from "../../hooks/use-event-reactions";
import { groupReactions } from "../../helpers/nostr/reactions";
import ReactionDetailsModal from "../reaction-details-modal";
import useCurrentAccount from "../../hooks/use-current-account";
import ReactionGroupButton from "./reaction-group-button";
import { useAddReaction } from "./common-hooks";
export default function EventReactionButtons({ event, max }: { event: NostrEvent; max?: number }) {
const account = useCurrentAccount();
const detailsModal = useDisclosure();
const reactions = useEventReactions(event.id) ?? [];
const grouped = useMemo(() => groupReactions(reactions), [reactions]);
const addReaction = useAddReaction(event, grouped);
if (grouped.length === 0) return null;
const clamped = Array.from(grouped);
if (max !== undefined) clamped.length = max;
return (
<>
{clamped.map((group) => (
<ReactionGroupButton
key={group.emoji}
emoji={group.emoji}
url={group.url}
count={group.pubkeys.length}
onClick={() => addReaction(group.emoji, group.url)}
colorScheme={account && group.pubkeys.includes(account?.pubkey) ? "primary" : undefined}
/>
))}
<Button onClick={detailsModal.onOpen}>Show all</Button>
{detailsModal.isOpen && <ReactionDetailsModal isOpen onClose={detailsModal.onClose} reactions={reactions} />}
</>
);
}

View File

@ -0,0 +1,18 @@
import { Button, ButtonProps, IconButton } from "@chakra-ui/react";
import ReactionIcon from "./reaction-icon";
export default function ReactionGroupButton({
emoji,
url,
count,
...props
}: Omit<ButtonProps, "leftIcon" | "children"> & { emoji: string; count: number; url?: string }) {
if (count <= 1) {
return <IconButton icon={<ReactionIcon emoji={emoji} url={url} />} aria-label="Reaction" {...props} />;
}
return (
<Button leftIcon={<ReactionIcon emoji={emoji} url={url} />} title={emoji} {...props}>
{count > 1 && count}
</Button>
);
}

View File

@ -0,0 +1,9 @@
import { Image } from "@chakra-ui/react";
import { DislikeIcon, LikeIcon } from "../icons";
export default function ReactionIcon({ emoji, url }: { emoji: string; url?: string }) {
if (emoji === "+") return <LikeIcon />;
if (emoji === "-") return <DislikeIcon />;
if (url) return <Image src={url} title={emoji} alt={emoji} w="1em" h="1em" display="inline" />;
return <span>{emoji}</span>;
}

View File

@ -0,0 +1,28 @@
import { useMemo } from "react";
import { NostrEvent } from "../../types/nostr-event";
import useEventReactions from "../../hooks/use-event-reactions";
import { groupReactions } from "../../helpers/nostr/reactions";
import useCurrentAccount from "../../hooks/use-current-account";
import ReactionGroupButton from "./reaction-group-button";
import { useAddReaction } from "./common-hooks";
import { ButtonProps } from "@chakra-ui/react";
export default function SimpleLikeButton({ event, ...props }: Omit<ButtonProps, "children"> & { event: NostrEvent }) {
const account = useCurrentAccount();
const reactions = useEventReactions(event.id) ?? [];
const grouped = useMemo(() => groupReactions(reactions), [reactions]);
const addReaction = useAddReaction(event, grouped);
const group = grouped.find((g) => g.emoji === "+");
return (
<ReactionGroupButton
emoji="+"
count={group?.pubkeys.length ?? 0}
onClick={() => addReaction("+")}
colorScheme={account && group?.pubkeys.includes(account?.pubkey) ? "primary" : undefined}
{...props}
/>
);
}

View File

@ -3,7 +3,6 @@ import { createIcon, IconProps } from "@chakra-ui/icons";
import SearchMd from "./icons/search-md";
import Settings02 from "./icons/settings-02";
import Mail01 from "./icons/mail-01";
import BookmarkCheck from "./icons/bookmark-check";
import StickerSquare from "./icons/sticker-square";
import Code01 from "./icons/code-01";
import DistributeSpacingVertical from "./icons/distribute-spacing-vertical";
@ -60,6 +59,7 @@ import Bookmark from "./icons/bookmark";
import BankNote01 from "./icons/bank-note-01";
import Wallet02 from "./icons/wallet-02";
import Download01 from "./icons/download-01";
import Repeat01 from "./icons/repeat-01";
const defaultProps: IconProps = { boxSize: 4 };
@ -93,7 +93,7 @@ export const BroadcastEventIcon = Share07;
export const ExternalLinkIcon = Share04;
export const SearchIcon = SearchMd;
export const RepostIcon = Share07;
export const RepostIcon = Repeat01;
export const ReplyIcon = MessageCircle01;
@ -227,4 +227,4 @@ export const GhostIcon = createIcon({
export const ECashIcon = BankNote01;
export const WalletIcon = Wallet02;
export const DownloadIcon = Download01
export const DownloadIcon = Download01;

View File

@ -4,7 +4,7 @@ import { getDecodedToken, Token } from "@cashu/cashu-ts";
import { CopyIconButton } from "./copy-icon-button";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { useCurrentAccount } from "../hooks/use-current-account";
import useCurrentAccount from "../hooks/use-current-account";
import { ECashIcon, WalletIcon } from "./icons";
function RedeemButton({ token }: { token: string }) {

View File

@ -9,7 +9,7 @@ import accountService, { Account } from "../../services/account";
import { AddIcon, ChevronDownIcon, ChevronUpIcon } from "../icons";
import UserAvatar from "../user-avatar";
import AccountInfoBadge from "../account-info-badge";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
function AccountItem({ account, onClick }: { account: Account; onClick?: () => void }) {
const pubkey = account.pubkey;

View File

@ -3,7 +3,7 @@ import { Avatar, Box, Button, Flex, FlexProps, Heading, LinkOverlay } from "@cha
import { Link as RouterLink } from "react-router-dom";
import { css } from "@emotion/react";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import AccountSwitcher from "./account-switcher";
import PublishLog from "../publish-log";
import NavItems from "./nav-items";

View File

@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
import { useInterval } from "react-use";
import dayjs from "dayjs";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import useSubject from "../../hooks/use-subject";
import accountService from "../../services/account";
import UserAvatar from "../user-avatar";

View File

@ -12,6 +12,7 @@ import GhostToolbar from "./ghost-toolbar";
import { useBreakpointValue } from "../../providers/breakpoint-provider";
import SearchModal from "../search-modal";
import { useLocation } from "react-router-dom";
// import ChatWindows from "../chat-windows";
export default function Layout({ children }: { children: React.ReactNode }) {
const isMobile = useBreakpointValue({ base: true, md: false });
@ -65,6 +66,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</Flex>
{isGhost && <GhostToolbar />}
{searchModal.isOpen && <SearchModal isOpen onClose={searchModal.onClose} />}
{/* {!isMobile && <ChatWindows />} */}
</>
);
}

View File

@ -2,7 +2,7 @@ import { Avatar, Flex, FlexProps, IconButton, useDisclosure } from "@chakra-ui/r
import { useContext, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import { PostModalContext } from "../../providers/post-modal-provider";
import { DirectMessagesIcon, NotesIcon, NotificationsIcon, PlusCircleIcon, SearchIcon } from "../icons";
import UserAvatar from "../user-avatar";

View File

@ -14,7 +14,7 @@ import {
import { Link as RouterLink } from "react-router-dom";
import AccountSwitcher from "./account-switcher";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import NavItems from "./nav-items";
export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "children">) {

View File

@ -21,7 +21,7 @@ import {
NotesIcon,
LightningIcon,
} from "../icons";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import accountService from "../../services/account";
export default function NavItems() {

View File

@ -34,7 +34,7 @@ import UserAvatarLink from "./user-avatar-link";
import { UserLink } from "./user-link";
import { UserDnsIdentityIcon } from "./user-dns-identity-icon";
import styled from "@emotion/styled";
import { getSharableNoteId } from "../helpers/nip19";
import { getSharableEventAddress } from "../helpers/nip19";
type RefType = MutableRefObject<HTMLElement | null>;
@ -101,7 +101,7 @@ function getRefPath(ref: RefType) {
}
function EventSlideHeader({ event, ...props }: { event: NostrEvent } & Omit<FlexProps, "children">) {
const encoded = useMemo(() => getSharableNoteId(event.id), [event.id]);
const encoded = useMemo(() => getSharableEventAddress(event), [event]);
return (
<Flex gap="2" alignItems="center" p="2" {...props}>

View File

@ -13,7 +13,7 @@ import {
useToast,
} from "@chakra-ui/react";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useCurrentAccount from "../../../hooks/use-current-account";
import { useSigningContext } from "../../../providers/signing-provider";
import useUserLists from "../../../hooks/use-user-lists";
import {

View File

@ -2,7 +2,7 @@ import { ButtonGroup, ButtonGroupProps, Divider } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import ReactionButton from "./reaction-button";
import EventReactionButtons from "../../event-reactions";
import EventReactionButtons from "../../event-reactions/event-reactions";
import useEventReactions from "../../../hooks/use-event-reactions";
import { useBreakpointValue } from "../../../providers/breakpoint-provider";

View File

@ -25,7 +25,7 @@ import clientRelaysService from "../../../services/client-relays";
import { useSigningContext } from "../../../providers/signing-provider";
import { ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon } from "../../icons";
import useJoinedCommunitiesList from "../../../hooks/use-communities-joined-list";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useCurrentAccount from "../../../hooks/use-current-account";
import { AddressPointer } from "nostr-tools/lib/types/nip19";
import { createCoordinate } from "../../../services/replaceable-event-requester";

View File

@ -11,7 +11,6 @@ import {
IconButton,
Link,
LinkBox,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
@ -34,18 +33,17 @@ import NoteContentWithWarning from "./note-content-with-warning";
import { TrustProvider } from "../../providers/trust";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import BookmarkButton from "./components/bookmark-button";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import NoteReactions from "./components/note-reactions";
import ReplyForm from "../../views/note/components/reply-form";
import { getReferences } from "../../helpers/nostr/events";
import Timestamp from "../timestamp";
import OpenInDrawerButton from "../open-in-drawer-button";
import { getSharableEventAddress } from "../../helpers/nip19";
import { getCommunityName, getEventCommunityPointer } from "../../helpers/nostr/communities";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import { useBreakpointValue } from "../../providers/breakpoint-provider";
import HoverLinkOverlay from "../hover-link-overlay";
import { nip19 } from "nostr-tools";
import NoteCommunityMetadata from "./note-community-metadata";
export type NoteProps = Omit<CardProps, "children"> & {
event: NostrEvent;
@ -75,8 +73,6 @@ export const Note = React.memo(
// find mostr external link
const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr" || t[0] === "proxy"), [event])?.[1];
const communityPointer = useMemo(() => getEventCommunityPointer(event), [event]);
const community = useReplaceableEvent(communityPointer ?? undefined);
const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false });
@ -107,15 +103,7 @@ export const Note = React.memo(
<Timestamp timestamp={event.created_at} />
</Link>
</Flex>
{community && (
<Text fontStyle="italic">
Posted in{" "}
<Link as={RouterLink} to={`/c/${getCommunityName(community)}/${community.pubkey}`} color="blue.500">
{getCommunityName(community)}
</Link>{" "}
community
</Text>
)}
<NoteCommunityMetadata event={event} />
</CardHeader>
<CardBody p="0">
<NoteContentWithWarning event={event} />

View File

@ -0,0 +1,25 @@
import { useMemo } from "react";
import { Link as RouterLink } from "react-router-dom";
import { Link, Text, TextProps } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import { getEventCommunityPointer } from "../../helpers/nostr/communities";
export default function NoteCommunityMetadata({
event,
...props
}: Omit<TextProps, "children"> & { event: NostrEvent }) {
const communityPointer = useMemo(() => getEventCommunityPointer(event), [event]);
if (!communityPointer) return null;
return (
<Text fontStyle="italic" {...props}>
Posted in{" "}
<Link as={RouterLink} to={`/c/${communityPointer.identifier}/${communityPointer.pubkey}`} color="blue.500">
{communityPointer.identifier}
</Link>{" "}
community
</Text>
);
}

View File

@ -1,6 +1,6 @@
import { NostrEvent } from "../../types/nostr-event";
import { NoteContents } from "./note-contents";
import { NoteContents } from "./text-note-contents";
import { useExpand } from "../../providers/expanded";
import SensitiveContentWarning from "../sensitive-content-warning";
import useAppSettings from "../../hooks/use-app-settings";

View File

@ -20,7 +20,7 @@ import {
} from "../icons";
import NoteReactionsModal from "./note-zaps-modal";
import NoteDebugModal from "../debug-modals/note-debug-modal";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import { buildAppSelectUrl } from "../../helpers/nostr/apps";
import { useDeleteEventContext } from "../../providers/delete-event-provider";
import clientRelaysService from "../../services/client-relays";

View File

@ -2,7 +2,7 @@ import { Button, ButtonProps, IconButton, useDisclosure } from "@chakra-ui/react
import { readablizeSats } from "../../helpers/bolt11";
import { totalZaps } from "../../helpers/nostr/zaps";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import useEventZaps from "../../hooks/use-event-zaps";
import clientRelaysService from "../../services/client-relays";
import eventZapsService from "../../services/event-zaps";
@ -21,7 +21,7 @@ export type NoteZapButtonProps = Omit<ButtonProps, "children"> & {
export default function NoteZapButton({ event, allowComment, showEventPreview, ...props }: NoteZapButtonProps) {
const account = useCurrentAccount();
const { metadata } = useUserLNURLMetadata(event.pubkey);
const zaps = useEventZaps(event.id);
const zaps = useEventZaps(getEventUID(event));
const { isOpen, onOpen, onClose } = useDisclosure();
const hasZapped = !!account && zaps.some((zap) => zap.request.pubkey === account.pubkey);

View File

@ -22,6 +22,7 @@ import {
renderGenericUrl,
renderSongDotLinkUrl,
embedCashuTokens,
renderStemstrUrl,
} from "../embed-types";
import { LightboxProvider } from "../lightbox-provider";
import { renderRedditUrl } from "../embed-types/reddit";
@ -42,6 +43,7 @@ function buildContents(event: NostrEvent | DraftNostrEvent, simpleLinks = false)
renderSpotifyUrl,
renderTidalUrl,
renderSongDotLinkUrl,
renderStemstrUrl,
renderImageUrl,
renderVideoUrl,
simpleLinks ? renderGenericUrl : renderOpenGraphUrl,

View File

@ -0,0 +1,12 @@
import { Link, LinkProps } from "@chakra-ui/react";
import useOpenGraphData from "../hooks/use-open-graph-data";
export default function OpenGraphLink({ url, ...props }: { url: URL } & Omit<LinkProps, "children">) {
const { value: data } = useOpenGraphData(url);
return (
<Link href={url.toString()} isExternal color="blue.500" {...props}>
{data?.ogTitle?.trim() ?? data?.dcTitle?.trim() ?? decodeURI(url.toString())}
</Link>
);
}

View File

@ -11,7 +11,7 @@ import {
import { usePeopleListContext } from "../../providers/people-list-provider";
import useUserLists from "../../hooks/use-user-lists";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import { PEOPLE_LIST_KIND, getListName } from "../../helpers/nostr/lists";
import { getEventCoordinate } from "../../helpers/nostr/events";
import useFavoriteLists from "../../hooks/use-favorite-lists";

View File

@ -2,7 +2,7 @@ import { forwardRef } from "react";
import { Select, SelectProps } from "@chakra-ui/react";
import useJoinedCommunitiesList from "../../hooks/use-communities-joined-list";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import { getCommunityName } from "../../helpers/nostr/communities";
import { AddressPointer } from "nostr-tools/lib/types/nip19";
import useReplaceableEvent from "../../hooks/use-replaceable-event";

View File

@ -26,7 +26,7 @@ import { ChevronDownIcon, ChevronUpIcon, UploadImageIcon } from "../icons";
import NostrPublishAction from "../../classes/nostr-publish-action";
import { useWriteRelayUrls } from "../../hooks/use-client-relays";
import { useSigningContext } from "../../providers/signing-provider";
import { NoteContents } from "../note/note-contents";
import { NoteContents } from "../note/text-note-contents";
import { PublishDetails } from "../publish-details";
import { TrustProvider } from "../../providers/trust";
import {
@ -44,7 +44,7 @@ import { nostrBuildUploadImage as nostrBuildUpload } from "../../helpers/nostr-b
import CommunitySelect from "./community-select";
import ZapSplitCreator, { fillRemainingPercent } from "./zap-split-creator";
import { EventSplit } from "../../helpers/nostr/zaps";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import useCacheForm from "../../hooks/use-cache-form";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";

View File

@ -4,6 +4,7 @@ import { Link as RouterLink } from "react-router-dom";
import NostrPublishAction from "../classes/nostr-publish-action";
import useSubject from "../hooks/use-subject";
import { RelayPaidTag } from "../views/relays/components/relay-card";
import { EmbedEvent } from "./embed-event";
export type PostResultsProps = {
pub: NostrPublishAction;
@ -14,6 +15,7 @@ export const PublishDetails = ({ pub }: PostResultsProps & Omit<FlexProps, "chil
return (
<Flex direction="column" gap="2">
<EmbedEvent event={pub.event} />
<Progress value={(results.length / pub.relays.length) * 100} size="lg" hasStripe />
{results.map((result) => (
<Alert key={result.relay.url} status={result.status ? "success" : "warning"}>

View File

@ -63,7 +63,7 @@ function PublishAction({ pub }: { pub: NostrPublishAction }) {
<PublishActionStatusTag ml="auto" pub={pub} />
</Flex>
{details.isOpen && (
<Modal isOpen onClose={details.onClose}>
<Modal isOpen onClose={details.onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader pt="4" px="4" pb="0">

View File

@ -18,9 +18,9 @@ import { useMemo } from "react";
import { NostrEvent } from "../types/nostr-event";
import { groupReactions } from "../helpers/nostr/reactions";
import { ReactionIcon } from "./event-reactions";
import UserAvatarLink from "./user-avatar-link";
import { UserLink } from "./user-link";
import ReactionIcon from "./event-reactions/reaction-icon";
export type ReactionDetailsModalProps = Omit<ModalProps, "children"> & {
reactions: NostrEvent[];

View File

@ -1,7 +1,7 @@
import { Divider, Flex, IconButton, Image, Text } from "@chakra-ui/react";
import { DislikeIcon, LikeIcon } from "./icons";
import { useCurrentAccount } from "../hooks/use-current-account";
import useCurrentAccount from "../hooks/use-current-account";
import useReplaceableEvent from "../hooks/use-replaceable-event";
import { getEmojisFromPack, getPackCordsFromFavorites, getPackName } from "../helpers/nostr/emoji-packs";
import useFavoriteEmojiPacks from "../hooks/use-favorite-emoji-packs";

View File

@ -7,7 +7,7 @@ import useSubject from "../../../hooks/use-subject";
import { getMatchLink } from "../../../helpers/regexp";
import { LightboxProvider } from "../../lightbox-provider";
import { isImageURL } from "../../../helpers/url";
import { EmbeddedImage, EmbeddedImageProps, GalleryImage } from "../../embed-types";
import { EmbeddedImageProps, GalleryImage } from "../../embed-types";
import { TrustProvider } from "../../../providers/trust";
import PhotoGallery, { PhotoWithoutSize } from "../../photo-gallery";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";

View File

@ -13,7 +13,7 @@ import {
useDisclosure,
} from "@chakra-ui/react";
import { useCurrentAccount } from "../hooks/use-current-account";
import useCurrentAccount from "../hooks/use-current-account";
import { ChevronDownIcon, FollowIcon, MuteIcon, PlusCircleIcon, UnfollowIcon, UnmuteIcon } from "./icons";
import useUserLists from "../hooks/use-user-lists";
import {

View File

@ -0,0 +1,14 @@
import { Text, TextProps } from "@chakra-ui/react";
import { getUserDisplayName } from "../helpers/user-metadata";
import { useUserMetadata } from "../hooks/use-user-metadata";
export default function UserName({ pubkey, ...props }: Omit<TextProps, "children"> & { pubkey: string }) {
const metadata = useUserMetadata(pubkey);
return (
<Text as="span" whiteSpace="nowrap" fontWeight="bold" {...props}>
{getUserDisplayName(metadata, pubkey)}
</Text>
);
}

View File

@ -4,7 +4,7 @@ import { getMatchLink } from "./regexp";
export type EmbedableContent = (string | JSX.Element)[];
export type EmbedType = {
regexp: RegExp;
render: (match: RegExpMatchArray) => JSX.Element | string | null;
render: (match: RegExpMatchArray, isEndOfLine: boolean) => JSX.Element | string | null;
name: string;
getLocation?: (match: RegExpMatchArray) => { start: number; end: number };
};
@ -35,7 +35,8 @@ export function embedJSX(content: EmbedableContent, embed: EmbedType): Embedable
const before = str.slice(0, start - cursor);
const after = str.slice(end - cursor, str.length);
let render = embed.render(match);
const isEndOfLine = /^\p{Z}*(\n|$)/iu.test(after);
let render = embed.render(match, isEndOfLine);
if (render === null) continue;
if (typeof render !== "string" && !render.props.key) {
@ -68,18 +69,18 @@ export function embedJSX(content: EmbedableContent, embed: EmbedType): Embedable
.flat();
}
export type LinkEmbedHandler = (link: URL) => JSX.Element | string | null;
export type LinkEmbedHandler = (link: URL, isEndOfLine: boolean) => JSX.Element | string | null;
export function embedUrls(content: EmbedableContent, handlers: LinkEmbedHandler[]) {
return embedJSX(content, {
name: "embedUrls",
regexp: getMatchLink(),
render: (match) => {
render: (match, isEndOfLine) => {
try {
const url = new URL(match[0]);
for (const handler of handlers) {
try {
const content = handler(url);
const content = handler(url, isEndOfLine);
if (content) return content;
} catch (e) {}
}

View File

@ -67,30 +67,19 @@ export function normalizeToHex(hex: string) {
return null;
}
/** @deprecated */
export function getSharableNoteId(eventId: string) {
const relays = getEventRelays(eventId).value;
const ranked = relayScoreboardService.getRankedRelays(relays);
const onlyTwo = ranked.slice(0, 2);
if (onlyTwo.length > 0) {
return nip19.neventEncode({ id: eventId, relays: onlyTwo });
} else return nip19.noteEncode(eventId);
}
export function getSharableEventAddress(event: NostrEvent) {
const relays = getEventRelays(getEventUID(event)).value;
const ranked = relayScoreboardService.getRankedRelays(relays);
const onlyTwo = ranked.slice(0, 2);
const maxTwo = ranked.slice(0, 2);
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 });
return nip19.naddrEncode({ kind: event.kind, identifier: d, pubkey: event.pubkey, relays: maxTwo });
} else {
if (onlyTwo.length > 0) {
return nip19.neventEncode({ id: event.id, relays: onlyTwo });
} else return nip19.noteEncode(event.id);
if (maxTwo.length == 2) {
return nip19.neventEncode({ id: event.id, relays: maxTwo });
} else return nip19.neventEncode({ id: event.id, relays: maxTwo, author: event.pubkey });
}
}

View File

@ -1,6 +1,7 @@
import { Kind } from "nostr-tools";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { DraftNostrEvent, NostrEvent, Tag } from "../../types/nostr-event";
import dayjs from "dayjs";
import { getEventCoordinate, isReplaceable } from "./events";
export type ReactionGroup = { emoji: string; url?: string; name?: string; count: number; pubkeys: string[] };
@ -20,14 +21,15 @@ export function groupReactions(reactions: NostrEvent[]) {
return Array.from(Object.values(groups)).sort((a, b) => b.pubkeys.length - a.pubkeys.length);
}
export function draftEventReaction(reacted: NostrEvent, emoji = "+", url?: string) {
// only keep the e, and p tags on the parent event
const inheritedTags = reacted.tags.filter((tag) => tag.length >= 2 && (tag[0] === "e" || tag[0] === "p"));
export function draftEventReaction(event: NostrEvent, emoji = "+", url?: string) {
const tags: Tag[] = [
["e", event.id],
["p", event.pubkey],
];
const draft: DraftNostrEvent = {
kind: Kind.Reaction,
content: url ? ":" + emoji + ":" : emoji,
tags: [...inheritedTags, ["e", reacted.id], ["p", reacted.pubkey]],
tags: isReplaceable(event.kind) ? [...tags, ["a", getEventCoordinate(event)]] : tags,
created_at: dayjs().unix(),
};

View File

@ -1,6 +1,6 @@
import { useCallback } from "react";
import { useCurrentAccount } from "./use-current-account";
import useCurrentAccount from "./use-current-account";
import useWordMuteFilter from "./use-mute-word-filter";
import useUserMuteFilter from "./use-user-mute-filter";
import { NostrEvent } from "../types/nostr-event";

View File

@ -1,7 +1,7 @@
import { COMMUNITY_DEFINITION_KIND, SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER } from "../helpers/nostr/communities";
import { NOTE_LIST_KIND, getParsedCordsFromList } from "../helpers/nostr/lists";
import { RequestOptions } from "../services/replaceable-event-requester";
import { useCurrentAccount } from "./use-current-account";
import useCurrentAccount from "./use-current-account";
import useReplaceableEvent from "./use-replaceable-event";
export default function useJoinedCommunitiesList(pubkey?: string, opts?: RequestOptions) {

View File

@ -1,6 +1,6 @@
import accountService from "../services/account";
import useSubject from "./use-subject";
export function useCurrentAccount() {
export default function useCurrentAccount() {
return useSubject(accountService.current);
}

View File

@ -1,5 +1,5 @@
import useReplaceableEvent from "./use-replaceable-event";
import { useCurrentAccount } from "./use-current-account";
import useCurrentAccount from "./use-current-account";
import { USER_EMOJI_LIST_KIND } from "../helpers/nostr/emoji-packs";
import { RequestOptions } from "../services/replaceable-event-requester";

View File

@ -1,5 +1,5 @@
import useReplaceableEvent from "./use-replaceable-event";
import { useCurrentAccount } from "./use-current-account";
import useCurrentAccount from "./use-current-account";
import { getCoordinatesFromList } from "../helpers/nostr/lists";
import useReplaceableEvents from "./use-replaceable-events";

View File

@ -1,14 +1,20 @@
import { useAsync } from "react-use";
import extractMetaTags from "../lib/open-graph-scraper/extract";
import { fetchWithCorsFallback } from "../helpers/cors";
import { OgObjectInteral } from "../lib/open-graph-scraper/types";
import type { OgObjectInteral } from "../lib/open-graph-scraper/types";
import useAppSettings from "./use-app-settings";
const pageExtensions = [".html", ".php", "htm"];
const openGraphDataCache = new Map<string, OgObjectInteral>();
export default function useOpenGraphData(url: URL) {
const { loadOpenGraphData } = useAppSettings();
return useAsync(async () => {
if (!loadOpenGraphData) return null;
const { default: extractMetaTags } = await import("../lib/open-graph-scraper/extract");
if (openGraphDataCache.has(url.toString())) return openGraphDataCache.get(url.toString());
const ext = url.pathname.match(/\.[\w+d]+$/)?.[0];

View File

@ -1,6 +1,6 @@
import { useCallback, useMemo } from "react";
import { useCurrentAccount } from "./use-current-account";
import useCurrentAccount from "./use-current-account";
import useUserMuteList from "./use-user-mute-list";
import { getPubkeysFromList } from "../helpers/nostr/lists";
import { NostrEvent } from "../types/nostr-event";

View File

@ -11,7 +11,7 @@ import { useSigningContext } from "../providers/signing-provider";
import clientRelaysService from "../services/client-relays";
import replaceableEventLoaderService from "../services/replaceable-event-requester";
import useAsyncErrorHandler from "./use-async-error-handler";
import { useCurrentAccount } from "./use-current-account";
import useCurrentAccount from "./use-current-account";
import useUserMuteList from "./use-user-mute-list";
export default function useUserMuteFunctions(pubkey: string) {

View File

@ -23,7 +23,7 @@ import {
import { Event, Kind } from "nostr-tools";
import dayjs from "dayjs";
import { useCurrentAccount } from "../hooks/use-current-account";
import useCurrentAccount from "../hooks/use-current-account";
import signingService from "../services/signing";
import createDefer, { Deferred } from "../classes/deferred";
import useEventRelays from "../hooks/use-event-relays";

View File

@ -2,7 +2,7 @@ import { PropsWithChildren, createContext, useContext } from "react";
import { lib } from "emojilib";
import useReplaceableEvents from "../hooks/use-replaceable-events";
import { useCurrentAccount } from "../hooks/use-current-account";
import useCurrentAccount from "../hooks/use-current-account";
import { isEmojiTag } from "../types/nostr-event";
import useFavoriteEmojiPacks from "../hooks/use-favorite-emoji-packs";
import { getPackCordsFromFavorites } from "../helpers/nostr/emoji-packs";

View File

@ -23,7 +23,7 @@ import { useInterval } from "react-use";
import { getUserDisplayName } from "../helpers/user-metadata";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { useCurrentAccount } from "../hooks/use-current-account";
import useCurrentAccount from "../hooks/use-current-account";
import {
createEmptyMuteList,
getPubkeysExpiration,

View File

@ -2,7 +2,7 @@ import { PropsWithChildren, createContext, useCallback, useContext, useEffect, u
import { Kind } from "nostr-tools";
import { useReadRelayUrls } from "../hooks/use-client-relays";
import { useCurrentAccount } from "../hooks/use-current-account";
import useCurrentAccount from "../hooks/use-current-account";
import TimelineLoader from "../classes/timeline-loader";
import timelineCacheService from "../services/timeline-cache";
import { NostrEvent } from "../types/nostr-event";

View File

@ -2,7 +2,7 @@ import { PropsWithChildren, createContext, useCallback, useContext, useMemo } fr
import { Kind } from "nostr-tools";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useCurrentAccount } from "../hooks/use-current-account";
import useCurrentAccount from "../hooks/use-current-account";
import { getPubkeysFromList } from "../helpers/nostr/lists";
import useReplaceableEvent from "../hooks/use-replaceable-event";
import { NostrEvent } from "../types/nostr-event";

View File

@ -1,6 +1,6 @@
import React, { PropsWithChildren, useContext } from "react";
import { NostrEvent } from "../types/nostr-event";
import { useCurrentAccount } from "../hooks/use-current-account";
import useCurrentAccount from "../hooks/use-current-account";
import useUserContactList from "../hooks/use-user-contact-list";
import { getPubkeysFromList } from "../helpers/nostr/lists";

View File

@ -10,9 +10,10 @@ import accountService from "./account";
import { NostrQuery } from "../types/nostr-query";
export function getMessageRecipient(event: NostrEvent): string | undefined {
return event.tags.filter(isPTag)[0][1];
return event.tags.find(isPTag)?.[1];
}
/** @deprecated */
class DirectMessagesService {
incomingSub: NostrMultiSubscription;
outgoingSub: NostrMultiSubscription;
@ -129,6 +130,7 @@ class DirectMessagesService {
}
}
/** @deprecated */
const directMessagesService = new DirectMessagesService();
export default directMessagesService;

View File

@ -27,14 +27,9 @@ export type AppSettingsV1 = Omit<AppSettingsV0, "version"> & {
mutedWords?: string;
maxPageWidth: "none" | "md" | "lg" | "xl";
};
export type AppSettingsV2 = Omit<AppSettingsV1, "version"> & {
version: 2;
theme: string;
};
export type AppSettingsV3 = Omit<AppSettingsV2, "version"> & {
version: 3;
quickReactions: string[];
};
export type AppSettingsV2 = Omit<AppSettingsV1, "version"> & { version: 2; theme: string };
export type AppSettingsV3 = Omit<AppSettingsV2, "version"> & { version: 3; quickReactions: string[] };
export type AppSettingsV4 = Omit<AppSettingsV3, "version"> & { version: 4; loadOpenGraphData: boolean };
export function isV0(settings: { version: number }): settings is AppSettingsV0 {
return settings.version === undefined || settings.version === 0;
@ -48,17 +43,21 @@ export function isV2(settings: { version: number }): settings is AppSettingsV2 {
export function isV3(settings: { version: number }): settings is AppSettingsV3 {
return settings.version === 3;
}
export function isV4(settings: { version: number }): settings is AppSettingsV4 {
return settings.version === 4;
}
export type AppSettings = AppSettingsV3;
export type AppSettings = AppSettingsV4;
export const defaultSettings: AppSettings = {
version: 3,
version: 4,
theme: "default",
colorMode: "system",
maxPageWidth: "none",
blurImages: true,
autoShowMedia: true,
proxyUserMedia: false,
loadOpenGraphData: true,
showReactions: true,
showSignatureVerification: false,
@ -77,10 +76,11 @@ export const defaultSettings: AppSettings = {
};
export function upgradeSettings(settings: { version: number }): AppSettings | null {
if (isV0(settings)) return { ...defaultSettings, ...settings, version: 3 };
if (isV1(settings)) return { ...defaultSettings, ...settings, version: 3 };
if (isV2(settings)) return { ...defaultSettings, ...settings, version: 3 };
if (isV3(settings)) return settings;
if (isV0(settings)) return { ...defaultSettings, ...settings, version: 4 };
if (isV1(settings)) return { ...defaultSettings, ...settings, version: 4 };
if (isV2(settings)) return { ...defaultSettings, ...settings, version: 4 };
if (isV3(settings)) return { ...defaultSettings, ...settings, version: 4 };
if (isV4(settings)) return settings;
return null;
}

View File

@ -3,7 +3,7 @@ import { useCopyToClipboard } from "react-use";
import { NostrEvent } from "../../../types/nostr-event";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { useCurrentAccount } from "../../../hooks/use-current-account";
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";

View File

@ -28,7 +28,7 @@ import {
} from "@chakra-ui/react";
import { SubmitHandler, useForm } from "react-hook-form";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useCurrentAccount from "../../../hooks/use-current-account";
import UserAvatar from "../../../components/user-avatar";
import { UserLink } from "../../../components/user-link";
import { TrashIcon } from "../../../components/icons";

View File

@ -4,7 +4,7 @@ import { Button, ButtonProps, useToast } from "@chakra-ui/react";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import useJoinedCommunitiesList from "../../../hooks/use-communities-joined-list";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useCurrentAccount from "../../../hooks/use-current-account";
import { SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER, getCommunityName } from "../../../helpers/nostr/communities";
import { NOTE_LIST_KIND, listAddCoordinate, listRemoveCoordinate } from "../../../helpers/nostr/lists";
import { getEventCoordinate } from "../../../helpers/nostr/events";

View File

@ -26,7 +26,7 @@ import dayjs from "dayjs";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { ErrorBoundary } from "../../components/error-boundary";
import useJoinedCommunitiesList from "../../hooks/use-communities-joined-list";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import CommunityCard from "./components/community-card";
import CommunityCreateModal, { FormValues } from "./components/community-create-modal";
import { useSigningContext } from "../../providers/signing-provider";

View File

@ -8,6 +8,8 @@ import {
getCommunityImage,
getCommunityName,
COMMUNITY_APPROVAL_KIND,
getCommunityMods,
buildApprovalMap,
} from "../../helpers/nostr/communities";
import { NostrEvent } from "../../types/nostr-event";
import VerticalPageLayout from "../../components/vertical-page-layout";
@ -28,6 +30,8 @@ import { WritingIcon } from "../../components/icons";
import { PostModalContext } from "../../providers/post-modal-provider";
import CommunityEditModal from "./components/community-edit-modal";
import TimelineLoader from "../../classes/timeline-loader";
import useSubject from "../../hooks/use-subject";
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
function getCommunityPath(community: NostrEvent) {
return `/c/${encodeURIComponent(getCommunityName(community))}/${nip19.npubEncode(community.pubkey)}`;
@ -36,6 +40,7 @@ function getCommunityPath(community: NostrEvent) {
export type RouterContext = { community: NostrEvent; timeline: TimelineLoader };
export default function CommunityHomePage({ community }: { community: NostrEvent }) {
const muteFilter = useClientSideMuteFilter();
const image = getCommunityImage(community);
const location = useLocation();
const { openModal } = useContext(PostModalContext);
@ -51,6 +56,12 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
"#a": [communityCoordinate],
});
// get pending notes
const events = useSubject(timeline.timeline);
const mods = getCommunityMods(community);
const approvals = buildApprovalMap(events, mods);
const pending = events.filter((e) => e.kind !== COMMUNITY_APPROVAL_KIND && !approvals.has(e.id) && !muteFilter(e));
let active = "newest";
if (location.pathname.endsWith("/newest")) active = "newest";
if (location.pathname.endsWith("/pending")) active = "pending";
@ -127,7 +138,7 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
colorScheme={active == "pending" ? "primary" : "gray"}
replace
>
Pending
Pending ({pending.length})
</Button>
</ButtonGroup>

View File

@ -7,7 +7,7 @@ import { CodeIcon, ExternalLinkIcon, RepostIcon } from "../../../components/icon
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useCurrentAccount from "../../../hooks/use-current-account";
import PencilLine from "../../../components/icons/pencil-line";
export default function CommunityMenu({

View File

@ -2,7 +2,7 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useCurrentAccount from "../../../hooks/use-current-account";
import { NostrEvent } from "../../../types/nostr-event";
import { useMuteModalContext } from "../../../providers/mute-modal-provider";
import useUserMuteFunctions from "../../../hooks/use-user-mute-functions";

View File

@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from "react";
import { Card, CardProps, IconButton, Text, useToast } from "@chakra-ui/react";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useCurrentAccount from "../../../hooks/use-current-account";
import useEventReactions from "../../../hooks/use-event-reactions";
import { useSigningContext } from "../../../providers/signing-provider";
import { draftEventReaction, groupReactions } from "../../../helpers/nostr/reactions";

View File

@ -17,11 +17,12 @@ import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeli
import TimelineActionAndStatus from "../../../components/timeline-page/timeline-action-and-status";
import { CheckIcon } from "../../../components/icons";
import { useSigningContext } from "../../../providers/signing-provider";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useCurrentAccount from "../../../hooks/use-current-account";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import { useWriteRelayUrls } from "../../../hooks/use-client-relays";
import CommunityPost from "../components/community-post";
import { RouterContext } from "../community-home";
import useUserMuteFilter from "../../../hooks/use-user-mute-filter";
type PendingProps = {
event: NostrEvent;
@ -84,13 +85,14 @@ function ModPendingPost({ event, community, approvals }: PendingProps) {
export default function CommunityPendingView() {
const account = useCurrentAccount();
const muteFilter = useUserMuteFilter();
const { community, timeline } = useOutletContext<RouterContext>();
const events = useSubject(timeline.timeline);
const mods = getCommunityMods(community);
const approvals = buildApprovalMap(events, mods);
const pending = events.filter((e) => e.kind !== COMMUNITY_APPROVAL_KIND && !approvals.has(e.id));
const pending = events.filter((e) => e.kind !== COMMUNITY_APPROVAL_KIND && !approvals.has(e.id) && !muteFilter(e));
const callback = useTimelineCurserIntersectionCallback(timeline);

View File

@ -3,7 +3,7 @@ import { useCopyToClipboard } from "react-use";
import { NostrEvent } from "../../../types/nostr-event";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { useCurrentAccount } from "../../../hooks/use-current-account";
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";

View File

@ -22,7 +22,7 @@ import {
import { UserLink } from "../../components/user-link";
import { ChevronLeftIcon } from "../../components/icons";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import { useDeleteEventContext } from "../../providers/delete-event-provider";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import EmojiPackMenu from "./components/emoji-pack-menu";

View File

@ -1,7 +1,7 @@
import { Button, Divider, Flex, Heading, Link, SimpleGrid, useDisclosure } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import { ExternalLinkIcon } from "../../components/icons";
import { getEventCoordinate, getEventUID } from "../../helpers/nostr/events";
import { useReadRelayUrls } from "../../hooks/use-client-relays";

View File

@ -3,7 +3,7 @@ import { useCopyToClipboard } from "react-use";
import { NostrEvent } from "../../../types/nostr-event";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { useCurrentAccount } from "../../../hooks/use-current-account";
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";

View File

@ -1,7 +1,7 @@
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 useCurrentAccount from "../../hooks/use-current-account";
import { ExternalLinkIcon } from "../../components/icons";
import { getEventUID } from "../../helpers/nostr/events";
import { useReadRelayUrls } from "../../hooks/use-client-relays";

View File

@ -15,7 +15,7 @@ import {
useEditableControls,
} from "@chakra-ui/react";
import { CloseIcon } from "@chakra-ui/icons";
import { useNavigate, useParams } from "react-router-dom";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useAppTitle } from "../../hooks/use-app-title";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { isReply } from "../../helpers/nostr/events";
@ -27,6 +27,8 @@ import useRelaysChanged from "../../hooks/use-relays-changed";
import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page";
import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type";
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
function EditableControls() {
const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls();
@ -43,6 +45,7 @@ function EditableControls() {
function HashTagPage() {
const navigate = useNavigate();
const location = useLocation();
const { hashtag } = useParams() as { hashtag: string };
const [editableHashtag, setEditableHashtag] = useState(hashtag);
useEffect(() => setEditableHashtag(hashtag), [hashtag]);
@ -52,6 +55,7 @@ function HashTagPage() {
const readRelays = useRelaySelectionRelays();
const { isOpen: showReplies, onToggle } = useDisclosure();
const { listId, filter } = usePeopleListContext();
const timelinePageEventFilter = useTimelinePageEventFilter();
const muteFilter = useClientSideMuteFilter();
const eventFilter = useCallback(
@ -63,16 +67,16 @@ function HashTagPage() {
[showReplies, muteFilter, timelinePageEventFilter],
);
const timeline = useTimelineLoader(
`${hashtag}-hashtag`,
`${listId ?? "global"}-${hashtag}-hashtag`,
readRelays,
{ kinds: [1], "#t": [hashtag] },
{ kinds: [1], "#t": [hashtag], ...filter },
{ eventFilter },
);
useRelaysChanged(readRelays, () => timeline.reset());
const header = (
<Flex gap="4" alignItems="center" wrap="wrap">
<Flex gap="2" alignItems="center" wrap="wrap">
<Editable
value={editableHashtag}
onChange={(v) => setEditableHashtag(v)}
@ -82,7 +86,7 @@ function HashTagPage() {
gap="2"
alignItems="center"
selectAllOnFocus
onSubmit={(v) => navigate("/t/" + String(v).toLowerCase())}
onSubmit={(v) => navigate("/t/" + String(v).toLowerCase() + location.search)}
flexShrink={0}
>
<div>
@ -91,6 +95,7 @@ function HashTagPage() {
<Input as={EditableInput} maxW="md" />
<EditableControls />
</Editable>
<PeopleListSelection />
<RelaySelectionButton />
<FormControl display="flex" alignItems="center" w="auto">
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" />
@ -109,7 +114,9 @@ function HashTagPage() {
export default function HashTagView() {
return (
<RelaySelectionProvider>
<HashTagPage />
<PeopleListProvider initList="global">
<HashTagPage />
</PeopleListProvider>
</RelaySelectionProvider>
);
}

View File

@ -1,16 +1,17 @@
import { memo, useRef } from "react";
import { Link as RouterLink } from "react-router-dom";
import {
AvatarGroup,
ButtonGroup,
Card,
CardBody,
CardFooter,
CardHeader,
CardProps,
Flex,
Heading,
Link,
LinkBox,
LinkProps,
SimpleGrid,
Text,
} from "@chakra-ui/react";
import { Kind, nip19 } from "nostr-tools";
@ -29,15 +30,20 @@ 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";
import { NoteLink } from "../../../components/note-link";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import ListFavoriteButton from "./list-favorite-button";
import { getEventUID } from "../../../helpers/nostr/events";
import ListMenu from "./list-menu";
import Timestamp from "../../../components/timestamp";
import { COMMUNITY_DEFINITION_KIND } from "../../../helpers/nostr/communities";
import { getArticleTitle } from "../../../helpers/nostr/long-form";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { CommunityIcon, NotesIcon } from "../../../components/icons";
import User01 from "../../../components/icons/user-01";
import HoverLinkOverlay from "../../../components/hover-link-overlay";
import NoteZapButton from "../../../components/note/note-zap-button";
import Link01 from "../../../components/icons/link-01";
import File02 from "../../../components/icons/file-02";
import SimpleLikeButton from "../../../components/event-reactions/simple-like-button";
function ArticleLinkLoader({ pointer, ...props }: { pointer: nip19.AddressPointer } & Omit<LinkProps, "children">) {
const article = useReplaceableEvent(pointer);
@ -64,62 +70,33 @@ export function ListCardContent({ list, ...props }: Omit<CardProps, "children">
const references = getReferencesFromList(list);
return (
<>
<Text>
Updated: <Timestamp timestamp={list.created_at} />
</Text>
<SimpleGrid spacing="2" columns={4}>
{people.length > 0 && (
<>
<Text>People ({people.length}):</Text>
<AvatarGroup overflow="hidden" mb="2" max={16} size="sm">
{people.map(({ pubkey, relay }) => (
<UserAvatarLink key={pubkey} pubkey={pubkey} relay={relay} />
))}
</AvatarGroup>
</>
<Text>
<User01 boxSize={5} /> {people.length}
</Text>
)}
{notes.length > 0 && (
<Flex gap="2" overflow="hidden" wrap="wrap">
<Text>Notes ({notes.length}):</Text>
{notes.slice(0, 4).map(({ id, relay }) => (
<NoteLink key={id} noteId={id} />
))}
</Flex>
<Text>
<NotesIcon boxSize={5} /> {notes.length}
</Text>
)}
{references.length > 0 && (
<Flex gap="2" overflow="hidden" wrap="wrap">
<Text>References ({references.length})</Text>
{references.slice(0, 3).map(({ url, petname }) => (
<Link maxW="200" href={url} isExternal whiteSpace="pre" color="blue.500" isTruncated>
{petname || url}
</Link>
))}
</Flex>
)}
{communities.length > 0 && (
<Flex gap="2" overflow="hidden" wrap="wrap">
<Text>Communities ({communities.length}):</Text>
{communities.map((pointer) => (
<Link
key={JSON.stringify(pointer)}
as={RouterLink}
to={`/c/${pointer.identifier}/${nip19.npubEncode(pointer.pubkey)}`}
color="blue.500"
>
{pointer.identifier}
</Link>
))}
</Flex>
<Text>
<Link01 boxSize={5} /> {references.length}
</Text>
)}
{articles.length > 0 && (
<Flex overflow="hidden" direction="column" wrap="wrap">
<Text>Articles ({articles.length}):</Text>
{articles.slice(0, 4).map((pointer) => (
<ArticleLinkLoader key={JSON.stringify(pointer)} pointer={pointer} isTruncated />
))}
</Flex>
<Text>
<File02 /> {articles.length}
</Text>
)}
</>
{communities.length > 0 && (
<Text>
<CommunityIcon boxSize={5} /> {communities.length}
</Text>
)}
</SimpleGrid>
);
}
@ -135,12 +112,12 @@ function ListCardRender({
useRegisterIntersectionEntity(ref, getEventUID(list));
return (
<Card ref={ref} variant="outline" {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0">
<Card as={LinkBox} ref={ref} variant="outline" {...props}>
<CardHeader display="flex" gap="2" p="4" alignItems="center">
<Heading size="md" isTruncated>
<Link as={RouterLink} to={`/lists/${link}`}>
<HoverLinkOverlay as={RouterLink} to={`/lists/${link}`}>
{getListName(list)}
</Link>
</HoverLinkOverlay>
</Heading>
{!hideCreator && (
<>
@ -149,14 +126,19 @@ function ListCardRender({
<UserLink pubkey={list.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
</>
)}
<ButtonGroup size="xs" variant="ghost" ml="auto">
</CardHeader>
<CardBody py="0" px="4">
<ListCardContent list={list} />
</CardBody>
<CardFooter p="2">
<NoteZapButton event={list} size="sm" variant="ghost" />
{/* TODO: reactions are tagging every user in list */}
<SimpleLikeButton event={list} variant="ghost" size="sm" />
<ButtonGroup size="sm" variant="ghost" ml="auto">
<ListFavoriteButton list={list} />
<ListMenu list={list} aria-label="list menu" />
</ButtonGroup>
</CardHeader>
<CardBody p="2">
<ListCardContent list={list} />
</CardBody>
</CardFooter>
</Card>
);
}

View File

@ -1,9 +1,9 @@
import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { Image, MenuItem, useDisclosure } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { NostrEvent } from "../../../types/nostr-event";
import { NostrEvent, isPTag } from "../../../types/nostr-event";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { useCurrentAccount } from "../../../hooks/use-current-account";
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";
@ -21,6 +21,8 @@ export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit
const naddr = getSharableEventAddress(list);
const hasPeople = list.tags.some(isPTag);
return (
<>
<CustomMenuIconButton {...props}>
@ -39,6 +41,14 @@ export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit
Delete List
</MenuItem>
)}
{hasPeople && (
<MenuItem
icon={<Image w="4" h="4" src="https://www.makeprisms.com/favicon.ico" />}
onClick={() => window.open(`https://www.makeprisms.com/create/${naddr}`, "_blank")}
>
Create $prism
</MenuItem>
)}
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>

View File

@ -12,7 +12,7 @@ import { listRemovePerson } from "../../../helpers/nostr/lists";
import { useSigningContext } from "../../../providers/signing-provider";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import clientRelaysService from "../../../services/client-relays";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useCurrentAccount from "../../../hooks/use-current-account";
import { UserFollowButton } from "../../../components/user-follow-button";
export type UserCardProps = { pubkey: string; relay?: string; list: NostrEvent } & Omit<CardProps, "children">;

View File

@ -2,7 +2,7 @@ import { Button, Divider, Flex, Heading, Image, Link, SimpleGrid, Spacer, useDis
import { useNavigate, Link as RouterLink, Navigate } from "react-router-dom";
import { Kind } from "nostr-tools";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import { ExternalLinkIcon, PlusCircleIcon } from "../../components/icons";
import ListCard from "./components/list-card";
import { getEventUID } from "../../helpers/nostr/events";

View File

@ -4,7 +4,7 @@ import { Kind, nip19 } from "nostr-tools";
import { UserLink } from "../../components/user-link";
import { Button, Flex, Heading, SimpleGrid, Spacer } from "@chakra-ui/react";
import { ChevronLeftIcon } from "../../components/icons";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import { useDeleteEventContext } from "../../providers/delete-event-provider";
import { parseCoordinate } from "../../helpers/nostr/events";
import {
@ -18,16 +18,16 @@ import {
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import UserCard from "./components/user-card";
import OpenGraphCard from "../../components/open-graph-card";
import NoteCard from "./components/note-card";
import { TrustProvider } from "../../providers/trust";
import ListMenu from "./components/list-menu";
import ListFavoriteButton from "./components/list-favorite-button";
import ListFeedButton from "./components/list-feed-button";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
import { EmbedEventPointer } from "../../components/embed-event";
import { EmbedEvent, EmbedEventPointer } from "../../components/embed-event";
import { encodePointer } from "../../helpers/nip19";
import { DecodeResult } from "nostr-tools/lib/types/nip19";
import useSingleEvent from "../../hooks/use-single-event";
function useListCoordinate() {
const { addr } = useParams() as { addr: string };
@ -43,6 +43,12 @@ function useListCoordinate() {
return parsed.data;
}
function BookmarkedEvent({ id, relay }: { id: string; relay?: string }) {
const event = useSingleEvent(id, relay ? [relay] : undefined);
return event ? <EmbedEvent event={event} /> : <>Loading {id}</>;
}
export default function ListDetailsView() {
const navigate = useNavigate();
const coordinate = useListCoordinate();
@ -67,56 +73,54 @@ export default function ListDetailsView() {
const references = getReferencesFromList(list);
return (
<VerticalPageLayout overflow="hidden" h="full">
<Flex gap="2" alignItems="center">
<Button onClick={() => navigate(-1)} leftIcon={<ChevronLeftIcon />}>
Back
</Button>
<Heading size="md" isTruncated>
{getListName(list)}
</Heading>
<ListFavoriteButton list={list} size="sm" />
<Spacer />
<ListFeedButton list={list} />
{isAuthor && !isSpecialListKind(list.kind) && (
<Button colorScheme="red" onClick={() => deleteEvent(list).then(() => navigate("/lists"))}>
Delete
<TrustProvider trust>
<VerticalPageLayout overflow="hidden" h="full">
<Flex gap="2" alignItems="center">
<Button onClick={() => navigate(-1)} leftIcon={<ChevronLeftIcon />}>
Back
</Button>
<Heading size="md" isTruncated>
{getListName(list)}
</Heading>
<ListFavoriteButton list={list} size="sm" />
<Spacer />
<ListFeedButton list={list} />
{isAuthor && !isSpecialListKind(list.kind) && (
<Button colorScheme="red" onClick={() => deleteEvent(list).then(() => navigate("/lists"))}>
Delete
</Button>
)}
<ListMenu aria-label="More options" list={list} />
</Flex>
{people.length > 0 && (
<>
<Heading size="lg">People</Heading>
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
{people.map(({ pubkey, relay }) => (
<UserCard pubkey={pubkey} relay={relay} list={list} />
))}
</SimpleGrid>
</>
)}
<ListMenu aria-label="More options" list={list} />
</Flex>
{people.length > 0 && (
<>
<Heading size="lg">People</Heading>
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
{people.map(({ pubkey, relay }) => (
<UserCard pubkey={pubkey} relay={relay} list={list} />
))}
</SimpleGrid>
</>
)}
{notes.length > 0 && (
<>
<Heading size="lg">Notes</Heading>
<TrustProvider trust>
{notes.length > 0 && (
<>
<Heading size="lg">Notes</Heading>
<Flex gap="2" direction="column">
{notes.map(({ id, relay }) => (
<NoteCard id={id} relay={relay} />
<BookmarkedEvent id={id} relay={relay} />
))}
</Flex>
</TrustProvider>
</>
)}
</>
)}
{references.length > 0 && (
<>
<Heading size="lg">References</Heading>
<TrustProvider trust>
{references.length > 0 && (
<>
<Heading size="lg">References</Heading>
<Flex gap="2" direction="column">
{references.map(({ url, petname }) => (
<>
@ -125,32 +129,32 @@ export default function ListDetailsView() {
</>
))}
</Flex>
</TrustProvider>
</>
)}
</>
)}
{communities.length > 0 && (
<>
<Heading size="lg">Communities</Heading>
<SimpleGrid spacing="2" columns={{ base: 1, lg: 2 }}>
{communities.map((pointer) => (
<EmbedEventPointer key={nip19.naddrEncode(pointer)} pointer={{ type: "naddr", data: pointer }} />
))}
</SimpleGrid>
</>
)}
{communities.length > 0 && (
<>
<Heading size="lg">Communities</Heading>
<SimpleGrid spacing="2" columns={{ base: 1, lg: 2 }}>
{communities.map((pointer) => (
<EmbedEventPointer key={nip19.naddrEncode(pointer)} pointer={{ type: "naddr", data: pointer }} />
))}
</SimpleGrid>
</>
)}
{articles.length > 0 && (
<>
<Heading size="lg">Articles</Heading>
<Flex gap="2" direction="column">
{articles.map((pointer) => {
const decode: DecodeResult = { type: "naddr", data: pointer };
return <EmbedEventPointer key={encodePointer(decode)} pointer={decode} />;
})}
</Flex>
</>
)}
</VerticalPageLayout>
{articles.length > 0 && (
<>
<Heading size="lg">Articles</Heading>
<Flex gap="2" direction="column">
{articles.map((pointer) => {
const decode: DecodeResult = { type: "naddr", data: pointer };
return <EmbedEventPointer key={encodePointer(decode)} pointer={decode} />;
})}
</Flex>
</>
)}
</VerticalPageLayout>
</TrustProvider>
);
}

View File

@ -13,16 +13,15 @@ import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays";
import { DraftNostrEvent } from "../../types/nostr-event";
import RequireCurrentAccount from "../../providers/require-current-account";
import { Message } from "./message";
import Message from "./message";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import NostrPublishAction from "../../classes/nostr-publish-action";
import { LightboxProvider } from "../../components/lightbox-provider";
import VerticalPageLayout from "../../components/vertical-page-layout";
function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
const toast = useToast();

View File

@ -1,5 +1,6 @@
import { Button } from "@chakra-ui/react";
import { Alert, AlertDescription, AlertIcon, Button } from "@chakra-ui/react";
import { useState } from "react";
import { UnlockIcon } from "../../components/icons";
import { useSigningContext } from "../../providers/signing-provider";
@ -15,17 +16,30 @@ export default function DecryptPlaceholder({
const { requestDecrypt } = useSigningContext();
const [loading, setLoading] = useState(false);
const [decrypted, setDecrypted] = useState<string>();
const [error, setError] = useState<Error>();
const decrypt = async () => {
setLoading(true);
const decrypted = await requestDecrypt(data, pubkey);
if (decrypted) setDecrypted(decrypted);
try {
const decrypted = await requestDecrypt(data, pubkey);
if (decrypted) setDecrypted(decrypted);
} catch (e) {
if (e instanceof Error) setError(e);
}
setLoading(false);
};
if (decrypted) {
return children(decrypted);
}
if (error) {
return (
<Alert status="error">
<AlertIcon />
<AlertDescription>{error.message}</AlertDescription>
</Alert>
);
}
return (
<Button onClick={decrypt} isLoading={loading} leftIcon={<UnlockIcon />} width="full">
Decrypt

View File

@ -1,7 +1,7 @@
import { useRef } from "react";
import { Box, CardProps, Flex } from "@chakra-ui/react";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCurrentAccount from "../../hooks/use-current-account";
import { getMessageRecipient } from "../../services/direct-messages";
import { NostrEvent } from "../../types/nostr-event";
import DecryptPlaceholder from "./decrypt-placeholder";
@ -31,7 +31,7 @@ export function MessageContent({ event, text }: { event: NostrEvent; text: strin
return <Box whiteSpace="pre-wrap">{content}</Box>;
}
export function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
export default function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
const account = useCurrentAccount()!;
const isOwnMessage = account.pubkey === event.pubkey;

View File

@ -7,7 +7,7 @@ import dayjs from "dayjs";
import { NostrEvent } from "../../../types/nostr-event";
import { UserAvatarStack } from "../../../components/compact-user-stack";
import { ThreadItem, getThreadMembers } from "../../../helpers/thread";
import { NoteContents } from "../../../components/note/note-contents";
import { NoteContents } from "../../../components/note/text-note-contents";
import {
addReplyTags,
createEmojiTags,
@ -15,7 +15,7 @@ import {
finalizeNote,
getContentMentions,
} from "../../../helpers/nostr/post";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useCurrentAccount from "../../../hooks/use-current-account";
import { useSigningContext } from "../../../providers/signing-provider";
import { useWriteRelayUrls } from "../../../hooks/use-client-relays";
import NostrPublishAction from "../../../classes/nostr-publish-action";

View File

@ -1,22 +1,53 @@
import { useState } from "react";
import { Alert, AlertIcon, Button, ButtonGroup, Flex, useDisclosure } from "@chakra-ui/react";
import {
Alert,
AlertIcon,
Button,
ButtonGroup,
Flex,
IconButton,
Link,
useColorMode,
useDisclosure,
} from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import { Link as RouterLink } from "react-router-dom";
import { ChevronDownIcon, ChevronUpIcon, ReplyIcon } from "../../../components/icons";
import { Note } from "../../../components/note";
import { ReplyIcon } from "../../../components/icons";
import { countReplies, ThreadItem } from "../../../helpers/thread";
import { TrustProvider } from "../../../providers/trust";
import ReplyForm from "./reply-form";
import useClientSideMuteFilter from "../../../hooks/use-client-side-mute-filter";
import UserAvatarLink from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import Timestamp from "../../../components/timestamp";
import { NoteContents } from "../../../components/note/text-note-contents";
import Expand01 from "../../../components/icons/expand-01";
import Minus from "../../../components/icons/minus";
import NoteZapButton from "../../../components/note/note-zap-button";
import { QuoteRepostButton } from "../../../components/note/components/quote-repost-button";
import { RepostButton } from "../../../components/note/components/repost-button";
import NoteMenu from "../../../components/note/note-menu";
import useSubject from "../../../hooks/use-subject";
import appSettings from "../../../services/settings/app-settings";
import { useBreakpointValue } from "../../../providers/breakpoint-provider";
import NoteReactions from "../../../components/note/components/note-reactions";
import BookmarkButton from "../../../components/note/components/bookmark-button";
import NoteCommunityMetadata from "../../../components/note/note-community-metadata";
const LEVEL_COLORS = ["green", "blue", "red", "purple", "yellow", "cyan", "pink"];
export type ThreadItemProps = {
post: ThreadItem;
initShowReplies?: boolean;
focusId?: string;
level?: number;
};
export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps) => {
const [showReplies, setShowReplies] = useState(initShowReplies ?? post.replies.length === 1);
const toggle = () => setShowReplies((v) => !v);
export const ThreadPost = ({ post, initShowReplies, focusId, level = -1 }: ThreadItemProps) => {
const { showReactions } = useSubject(appSettings);
const [expanded, setExpanded] = useState(initShowReplies ?? (level < 2 || post.replies.length <= 1));
const toggle = () => setExpanded((v) => !v);
const showReplyForm = useDisclosure();
const muteFilter = useClientSideMuteFilter();
@ -38,44 +69,92 @@ export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps)
if (isMuted && replies.length === 0) return null;
return (
<Flex direction="column" gap="2">
{isMuted && !alwaysShow ? (
muteAlert
) : (
<TrustProvider trust={focusId === post.event.id ? true : undefined}>
<Note
event={post.event}
borderColor={focusId === post.event.id ? "blue.500" : undefined}
clickable={focusId !== post.event.id}
hideDrawerButton
/>
</TrustProvider>
)}
{showReplyForm.isOpen && (
<ReplyForm item={post} onCancel={showReplyForm.onClose} onSubmitted={showReplyForm.onClose} />
)}
<ButtonGroup variant="link" size="sm" alignSelf="flex-start">
{!showReplyForm.isOpen && (
<Button onClick={showReplyForm.onOpen} leftIcon={<ReplyIcon />}>
Write reply
</Button>
)}
const colorMode = useColorMode().colorMode;
const color = LEVEL_COLORS[level % LEVEL_COLORS.length];
const colorValue = colorMode === "light" ? 200 : 800;
const focusColor = colorMode === "light" ? "blue.300" : "blue.700";
{replies.length > 0 && (
<Button onClick={toggle}>
{numberOfReplies} {numberOfReplies > 1 ? "Replies" : "Reply"}
{showReplies ? <ChevronDownIcon /> : <ChevronUpIcon />}
</Button>
)}
</ButtonGroup>
{post.replies.length > 0 && showReplies && (
<Flex direction="column" gap="2" pl={[2, 2, 4]} borderLeftColor="gray.500" borderLeftWidth="1px">
{post.replies.map((child) => (
<ThreadPost key={child.event.id} post={child} focusId={focusId} />
))}
</Flex>
const header = (
<Flex gap="2" alignItems="center">
<UserAvatarLink pubkey={post.event.pubkey} size="sm" />
<UserLink pubkey={post.event.pubkey} fontWeight="bold" isTruncated />
<Link as={RouterLink} whiteSpace="nowrap" color="current" to={`/n/${nip19.noteEncode(post.event.id)}`}>
<Timestamp timestamp={post.event.created_at} />
</Link>
{replies.length > 0 ? (
<Button variant="ghost" onClick={toggle} rightIcon={expanded ? <Minus /> : <Expand01 />}>
({numberOfReplies})
</Button>
) : (
<IconButton
variant="ghost"
onClick={toggle}
icon={expanded ? <Minus /> : <Expand01 />}
aria-label={expanded ? "Collapse" : "Expand"}
title={expanded ? "Collapse" : "Expand"}
/>
)}
</Flex>
);
const renderContent = () => {
return isMuted && !alwaysShow ? (
muteAlert
) : (
<>
<NoteCommunityMetadata event={post.event} pl="2" />
<TrustProvider trust={focusId === post.event.id ? true : undefined} event={post.event}>
<NoteContents event={post.event} pl="2" />
</TrustProvider>
</>
);
};
const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false });
const reactionButtons = showReactions && (
<NoteReactions event={post.event} flexWrap="wrap" variant="ghost" size="sm" />
);
const footer = (
<Flex gap="2" alignItems="center">
<ButtonGroup variant="ghost" size="sm">
<IconButton aria-label="Reply" title="Reply" onClick={showReplyForm.onToggle} icon={<ReplyIcon />} />
<RepostButton event={post.event} />
<QuoteRepostButton event={post.event} />
<NoteZapButton event={post.event} />
</ButtonGroup>
{!showReactionsOnNewLine && reactionButtons}
<BookmarkButton event={post.event} variant="ghost" aria-label="Bookmark" size="sm" ml="auto" />
<NoteMenu event={post.event} variant="ghost" size="sm" aria-label="More Options" />
</Flex>
);
return (
<>
<Flex
direction="column"
gap="2"
p="2"
borderRadius="md"
borderWidth=".1rem .1rem .1rem .35rem"
borderColor={focusId === post.event.id ? focusColor : undefined}
borderLeftColor={color + "." + colorValue}
>
{header}
{expanded && renderContent()}
{expanded && showReactionsOnNewLine && reactionButtons}
{expanded && footer}
</Flex>
{showReplyForm.isOpen && (
<ReplyForm item={post} onCancel={showReplyForm.onClose} onSubmitted={showReplyForm.onClose} />
)}
{post.replies.length > 0 && expanded && (
<Flex direction="column" gap="2" pl={{ base: 2, md: 4 }}>
{post.replies.map((child) => (
<ThreadPost key={child.event.id} post={child} focusId={focusId} level={level + 1} />
))}
</Flex>
)}
</>
);
};

Some files were not shown because too many files have changed in this diff Show More