fix new note form

This commit is contained in:
hzrd149 2025-01-07 10:01:39 -06:00
parent e8b7ecafe3
commit b5a7f76d9a
12 changed files with 254 additions and 608 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Remove legacy satellite cdn view

View File

@ -50,7 +50,7 @@ import UserListsTab from "./views/user/lists";
import UserGoalsTab from "./views/user/goals";
import MutedByView from "./views/user/muted-by";
import UserArticlesTab from "./views/user/articles";
import UserDMsTab from "./views/user/dms";
import UserMessagesTab from "./views/user/messages";
const UserTorrentsTab = lazy(() => import("./views/user/torrents"));
import ListsHomeView from "./views/lists";
@ -124,7 +124,6 @@ const EventConsoleView = lazy(() => import("./views/tools/event-console"));
const EventPublisherView = lazy(() => import("./views/tools/event-publisher"));
const DMTimelineView = lazy(() => import("./views/tools/dm-timeline"));
const TransformNoteView = lazy(() => import("./views/tools/transform-note"));
const SatelliteCDNView = lazy(() => import("./views/tools/satellite-cdn"));
const CorrectionsFeedView = lazy(() => import("./views/tools/corrections"));
const NoStrudelUsersView = lazy(() => import("./views/tools/nostrudel-users/index"));
@ -258,6 +257,11 @@ const router = createHashRouter([
children: [
{
path: "new",
element: (
<RequireCurrentAccount>
<Outlet />
</RequireCurrentAccount>
),
children: [
{ path: "", element: <NewView /> },
{ path: "note", element: <NewNoteView /> },
@ -286,7 +290,7 @@ const router = createHashRouter([
{ path: "relays", element: <UserRelaysTab /> },
{ path: "reports", element: <UserReportsTab /> },
{ path: "muted-by", element: <MutedByView /> },
{ path: "dms", element: <UserDMsTab /> },
{ path: "dms", element: <UserMessagesTab /> },
{ path: "torrents", element: <UserTorrentsTab /> },
],
},
@ -433,7 +437,6 @@ const router = createHashRouter([
{ path: "network-dm-graph", element: <NetworkDMGraphView /> },
{ path: "dm-timeline", element: <DMTimelineView /> },
{ path: "transform/:id", element: <TransformNoteView /> },
{ path: "satellite-cdn", element: <SatelliteCDNView /> },
{ path: "unknown", element: <UnknownTimelineView /> },
{ path: "console", element: <EventConsoleView /> },
{ path: "corrections", element: <CorrectionsFeedView /> },

View File

@ -1,85 +0,0 @@
import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent, Tag } from "../types/nostr-event";
const ROOT_URL = "https://api.satellite.earth/v1/media";
type Signer = (draft: DraftNostrEvent) => Promise<NostrEvent>;
export type SatelliteCDNUpload = {
created: number;
sha256: string;
name: string;
url: string;
infohash: string;
magnet: string;
size: number;
type: string;
nip94: Tag[];
};
export type SatelliteCDNFile = {
created: number;
magnet: string;
type: string;
name?: string;
sha256: string;
size: number;
url: string;
};
export type SatelliteCDNAccount = {
timeRemaining: number;
paidThrough: number;
transactions: {
order: NostrEvent;
receipt: NostrEvent;
payment: NostrEvent;
}[];
storageTotal: number;
creditTotal: number;
usageTotal: number;
rateFiat: {
usd: number;
};
exchangeFiat: {
usd: number;
};
files: SatelliteCDNFile[];
};
export function getAccountAuthToken(signEvent: Signer) {
return signEvent({
created_at: dayjs().unix(),
kind: 22242,
content: "Authenticate User",
tags: [],
});
}
export async function getAccount(authToken: NostrEvent) {
return fetch(`${ROOT_URL}/account?auth=${encodeURIComponent(JSON.stringify(authToken))}`).then((res) =>
res.json(),
) as Promise<SatelliteCDNAccount>;
}
export async function deleteFile(sha256: string, signEvent: Signer) {
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: 22242,
content: "Delete Item",
tags: [["x", sha256]],
};
const signed = await signEvent(draft);
await fetch(`${ROOT_URL}/item?auth=${encodeURIComponent(JSON.stringify(signed))}`, { method: "DELETE" });
}
export async function uploadFile(file: File, signEvent: Signer) {
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: 22242,
content: "Authorize Upload",
tags: [["name", file.name]],
};
const signed = await signEvent(draft);
return (await fetch(`${ROOT_URL}/item?auth=${encodeURIComponent(JSON.stringify(signed))}`, {
method: "PUT",
body: file,
}).then((res) => res.json())) as Promise<SatelliteCDNUpload>;
}

View File

@ -1,8 +1,5 @@
import { Box, ButtonGroup, Flex, Heading, Spinner } from "@chakra-ui/react";
import { ButtonGroup, Flex, Heading, Spinner } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { COMMENT_KIND } from "applesauce-core/helpers";
import { useStoreQuery } from "applesauce-react/hooks";
import { CommentsQuery } from "applesauce-core/queries";
import { useReadRelays } from "../../hooks/use-client-relays";
import useParamsEventPointer from "../../hooks/use-params-event-pointer";
@ -16,9 +13,6 @@ import { TrustProvider } from "../../providers/local/trust-provider";
import DebugEventButton from "../../components/debug-modal/debug-event-button";
import RepostButton from "../../components/note/timeline-note/components/repost-button";
import QuoteEventButton from "../../components/note/quote-event-button";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
import EventZapIconButton from "../../components/zap/event-zap-icon-button";
import AddReactionButton from "../../components/note/timeline-note/components/add-reaction-button";

View File

@ -7,7 +7,6 @@ import {
useDisclosure,
Input,
Switch,
ModalProps,
FormLabel,
FormControl,
FormHelperText,
@ -16,7 +15,6 @@ import {
SliderTrack,
SliderFilledTrack,
SliderThumb,
ModalCloseButton,
Alert,
AlertIcon,
ButtonGroup,
@ -27,7 +25,7 @@ import { useForm } from "react-hook-form";
import { UnsignedEvent } from "nostr-tools";
import { useAsync, useThrottle } from "react-use";
import { useEventFactory, useObservable } from "applesauce-react/hooks";
import { Emoji, ZapSplit } from "applesauce-core/helpers";
import { Emoji } from "applesauce-core/helpers";
import { useFinalizeDraft, usePublishEvent } from "../../../providers/global/publish-provider";
import useCurrentAccount from "../../../hooks/use-current-account";
@ -148,163 +146,154 @@ export default function ShortTextNoteForm({
const canSubmit = getValues().content.length > 0;
const renderBody = () => {
if (publishAction) {
return (
<Flex direction="column" gap="2">
<PublishDetails pub={publishAction} />
</Flex>
);
}
if (miningTarget && draft) {
return (
<Flex direction="column" gap="2">
<MinePOW
draft={draft}
targetPOW={miningTarget}
onCancel={() => setMiningTarget(0)}
onSkip={publishPost}
onComplete={publishPost}
/>
</Flex>
);
}
const showAdvanced =
advanced.isOpen || formState.dirtyFields.difficulty || formState.dirtyFields.nsfw || formState.dirtyFields.split;
// TODO: wrap this in a form
if (publishAction) {
return (
<>
<Flex direction="column" gap="2">
<MagicTextArea
autoFocus
mb="2"
value={getValues().content}
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true, shouldTouch: true })}
rows={8}
isRequired
instanceRef={(inst) => (textAreaRef.current = inst)}
onPaste={onPaste}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") submit();
}}
/>
{preview && preview.content.length > 0 && (
<Box>
<Heading size="sm">Preview:</Heading>
<Box borderWidth={1} borderRadius="md" p="2">
<ErrorBoundary>
<TrustProvider trust>
<TextNoteContents event={preview} />
</TrustProvider>
</ErrorBoundary>
</Box>
</Box>
)}
<Flex gap="2" alignItems="center" justifyContent="flex-end">
<Flex mr="auto" gap="2">
<InsertImageButton onUploaded={insertText} aria-label="Upload image" />
<InsertGifButton onSelectURL={insertText} aria-label="Add gif" />
</Flex>
</Flex>
<Flex gap="2" alignItems="center" justifyContent="space-between">
<Button
variant="link"
rightIcon={advanced.isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
onClick={advanced.onToggle}
>
More Options
</Button>
{formState.isDirty && (
<Button variant="ghost" onClick={() => confirm("Clear draft?") && reset()} ms="auto">
Clear
</Button>
)}
<Button
colorScheme="primary"
type="submit"
isLoading={formState.isSubmitting}
onClick={submit}
isDisabled={!canSubmit}
>
Post
</Button>
</Flex>
{showAdvanced && (
<Flex direction={{ base: "column", lg: "row" }} gap="4">
<Flex direction="column" gap="2" flex={1}>
<FormControl>
<FormLabel>Post to community</FormLabel>
<CommunitySelect {...register("community")} />
</FormControl>
<Flex gap="2" direction="column">
<Switch {...register("nsfw")}>NSFW</Switch>
{getValues().nsfw && (
<Input {...register("nsfwReason", { required: true })} placeholder="Reason" isRequired />
)}
</Flex>
<FormControl>
<FormLabel>POW Difficulty ({getValues("difficulty")})</FormLabel>
<Slider
aria-label="difficulty"
value={getValues("difficulty")}
onChange={(v) => setValue("difficulty", v, { shouldDirty: true, shouldTouch: true })}
min={0}
max={40}
step={1}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
<FormHelperText>
The number of leading 0's in the event id. see{" "}
<Link href="https://github.com/nostr-protocol/nips/blob/master/13.md" isExternal>
NIP-13
</Link>
</FormHelperText>
</FormControl>
</Flex>
<Flex direction="column" gap="2" flex={1}>
<ZapSplitCreator
splits={getValues().split}
onChange={(splits) => setValue("split", splits, { shouldDirty: true, shouldTouch: true })}
authorPubkey={account?.pubkey}
/>
</Flex>
</Flex>
)}
</Flex>
{!addClientTag && promptAddClientTag.isOpen && (
<Alert status="info" whiteSpace="pre-wrap" flexDirection={{ base: "column", lg: "row" }}>
<AlertIcon hideBelow="lg" />
<Text>
Enable{" "}
<Link isExternal href="https://github.com/nostr-protocol/nips/blob/master/89.md#client-tag">
NIP-89
</Link>{" "}
client tags and let other users know what app you're using to write notes
</Text>
<ButtonGroup ml="auto" size="sm" variant="ghost">
<Button onClick={promptAddClientTag.onClose}>Close</Button>
<Button colorScheme="primary" onClick={() => localSettings.addClientTag.next(true)}>
Enable
</Button>
</ButtonGroup>
</Alert>
)}
</>
<Flex direction="column" gap="2">
<PublishDetails pub={publishAction} />
</Flex>
);
};
}
if (miningTarget && draft) {
return (
<Flex direction="column" gap="2">
<MinePOW
draft={draft}
targetPOW={miningTarget}
onCancel={() => setMiningTarget(0)}
onSkip={publishPost}
onComplete={publishPost}
/>
</Flex>
);
}
const showAdvanced =
advanced.isOpen || formState.dirtyFields.difficulty || formState.dirtyFields.nsfw || formState.dirtyFields.split;
// TODO: wrap this in a form
return (
<>
{publishAction && <ModalCloseButton />}
{renderBody()}
<Flex direction="column" gap="2">
<MagicTextArea
autoFocus
mb="2"
value={getValues().content}
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true, shouldTouch: true })}
rows={8}
isRequired
instanceRef={(inst) => (textAreaRef.current = inst)}
onPaste={onPaste}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") submit();
}}
/>
{preview && preview.content.length > 0 && (
<Box>
<Heading size="sm">Preview:</Heading>
<Box borderWidth={1} borderRadius="md" p="2">
<ErrorBoundary>
<TrustProvider trust>
<TextNoteContents event={preview} />
</TrustProvider>
</ErrorBoundary>
</Box>
</Box>
)}
<Flex gap="2" alignItems="center" justifyContent="flex-end">
<Flex mr="auto" gap="2">
<InsertImageButton onUploaded={insertText} aria-label="Upload image" />
<InsertGifButton onSelectURL={insertText} aria-label="Add gif" />
</Flex>
</Flex>
<Flex gap="2" alignItems="center" justifyContent="space-between">
<Button
variant="link"
rightIcon={advanced.isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
onClick={advanced.onToggle}
>
More Options
</Button>
{formState.isDirty && (
<Button variant="ghost" onClick={() => confirm("Clear draft?") && reset()} ms="auto">
Clear
</Button>
)}
<Button
colorScheme="primary"
type="submit"
isLoading={formState.isSubmitting}
onClick={submit}
isDisabled={!canSubmit}
>
Post
</Button>
</Flex>
{showAdvanced && (
<Flex direction={{ base: "column", lg: "row" }} gap="4">
<Flex direction="column" gap="2" flex={1}>
<FormControl>
<FormLabel>Post to community</FormLabel>
<CommunitySelect {...register("community")} />
</FormControl>
<Flex gap="2" direction="column">
<Switch {...register("nsfw")}>NSFW</Switch>
{getValues().nsfw && (
<Input {...register("nsfwReason", { required: true })} placeholder="Reason" isRequired />
)}
</Flex>
<FormControl>
<FormLabel>POW Difficulty ({getValues("difficulty")})</FormLabel>
<Slider
aria-label="difficulty"
value={getValues("difficulty")}
onChange={(v) => setValue("difficulty", v, { shouldDirty: true, shouldTouch: true })}
min={0}
max={40}
step={1}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
<FormHelperText>
The number of leading 0's in the event id. see{" "}
<Link href="https://github.com/nostr-protocol/nips/blob/master/13.md" isExternal>
NIP-13
</Link>
</FormHelperText>
</FormControl>
</Flex>
<Flex direction="column" gap="2" flex={1}>
<ZapSplitCreator
splits={getValues().split}
onChange={(splits) => setValue("split", splits, { shouldDirty: true, shouldTouch: true })}
authorPubkey={account?.pubkey}
/>
</Flex>
</Flex>
)}
</Flex>
{!addClientTag && promptAddClientTag.isOpen && (
<Alert status="info" whiteSpace="pre-wrap" flexDirection={{ base: "column", lg: "row" }}>
<AlertIcon hideBelow="lg" />
<Text>
Enable{" "}
<Link isExternal href="https://github.com/nostr-protocol/nips/blob/master/89.md#client-tag">
NIP-89
</Link>{" "}
client tags and let other users know what app you're using to write notes
</Text>
<ButtonGroup ml="auto" size="sm" variant="ghost">
<Button onClick={promptAddClientTag.onClose}>Close</Button>
<Button colorScheme="primary" onClick={() => localSettings.addClientTag.next(true)}>
Enable
</Button>
</ButtonGroup>
</Alert>
)}
</>
);
}

View File

@ -1,33 +0,0 @@
import { useCallback, useState } from "react";
import { IconButton, useToast } from "@chakra-ui/react";
import { SatelliteCDNFile, deleteFile } from "../../../helpers/satellite-cdn";
import { useSigningContext } from "../../../providers/global/signing-provider";
import { TrashIcon } from "../../../components/icons";
export default function FileDeleteButton({ file }: { file: SatelliteCDNFile }) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const [loading, setLoading] = useState(false);
const handleClick = useCallback(async () => {
setLoading(true);
try {
await deleteFile(file.sha256, requestSignature);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
}, [requestSignature, file]);
return (
<IconButton
icon={<TrashIcon />}
aria-label="Delete File"
title="Delete File"
colorScheme="red"
onClick={handleClick}
isLoading={loading}
/>
);
}

View File

@ -1,206 +0,0 @@
import { useCallback, useRef, useState } from "react";
import {
Button,
ButtonGroup,
Flex,
Heading,
IconButton,
Image,
Input,
Link,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
useToast,
} from "@chakra-ui/react";
import { useAsync } from "react-use";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import { useSigningContext } from "../../../providers/global/signing-provider";
import { NostrEvent } from "../../../types/nostr-event";
import { formatBytes } from "../../../helpers/number";
import { CopyIconButton } from "../../../components/copy-icon-button";
import Timestamp from "../../../components/timestamp";
import { SatelliteCDNFile, getAccount, getAccountAuthToken, uploadFile } from "../../../helpers/satellite-cdn";
import FileDeleteButton from "./delete-file-button";
import { matchSorter } from "match-sorter";
import ShareFileButton from "./share-file-button";
import { DownloadIcon, TorrentIcon } from "../../../components/icons";
function FileUploadButton() {
const toast = useToast();
const { requestSignature } = useSigningContext();
const inputRef = useRef<HTMLInputElement | null>(null);
const [loading, setLoading] = useState(false);
const handleInputChange = useCallback(
async (file: File) => {
setLoading(true);
try {
const upload = await uploadFile(file, requestSignature);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
},
[requestSignature],
);
return (
<>
<Input
type="file"
hidden
ref={inputRef}
onChange={(e) => {
if (e.target.files?.[0]) handleInputChange(e.target.files[0]);
}}
/>
<Button colorScheme="primary" onClick={() => inputRef.current?.click()} isLoading={loading}>
Upload File
</Button>
</>
);
}
function FileRow({ file }: { file: SatelliteCDNFile }) {
return (
<>
<Tr>
<Td>
<Link isExternal href={file.url}>
{file.name}
</Link>
<CopyIconButton value={file.url} aria-label="Copy URL" title="Copy URL" size="xs" variant="ghost" ml="2" />
</Td>
<Td isNumeric>{formatBytes(file.size)}</Td>
<Td>{file.type}</Td>
<Td>
<Timestamp timestamp={file.created} />
</Td>
<Td isNumeric>
<ButtonGroup size="sm" variant="ghost">
<ShareFileButton file={file} />
<IconButton
as={Link}
href={file.url}
icon={<DownloadIcon />}
aria-label="Download"
download={file.name}
isExternal
/>
<IconButton as={Link} href={file.magnet} icon={<TorrentIcon />} aria-label="Open Magnet" isExternal />
<FileDeleteButton file={file} />
</ButtonGroup>
</Td>
</Tr>
</>
);
}
function FilesTable({ files }: { files: SatelliteCDNFile[] }) {
return (
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>Name</Th>
<Th isNumeric>Size</Th>
<Th>Type</Th>
<Th>Created</Th>
<Th isNumeric />
</Tr>
</Thead>
<Tbody>
{files.map((file) => (
<FileRow key={file.sha256} file={file} />
))}
</Tbody>
</Table>
</TableContainer>
);
}
export default function SatelliteCDNView() {
const toast = useToast();
const { requestSignature } = useSigningContext();
const [authToken, setAuthToken] = useState<NostrEvent>();
const { value: account, loading: isLoadingAccount } = useAsync(
async () => authToken && (await getAccount(authToken)),
[authToken],
);
const [loading, setLoading] = useState(false);
const handleAuthClick = useCallback(async () => {
setLoading(true);
try {
setAuthToken(await getAccountAuthToken(requestSignature));
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
}, [requestSignature]);
const [search, setSearch] = useState("");
const renderContent = () => {
if (!account)
return (
<Button
onClick={handleAuthClick}
mx="auto"
px="10"
my="8"
colorScheme="primary"
isLoading={loading || isLoadingAccount}
autoFocus
>
Unlock Account
</Button>
);
if (search) {
const filteredFiles = account.files.filter((f) => f.name?.toLowerCase().includes(search.toLowerCase().trim()));
const sortedFiles = matchSorter(filteredFiles, search.toLowerCase().trim(), { keys: ["name"] });
return <FilesTable files={sortedFiles} />;
} else return <FilesTable files={account.files} />;
};
return (
<VerticalPageLayout>
<Flex gap="2" alignItems="center" wrap="wrap">
<Image src="https://satellite.earth/image.png" w="12" />
<Heading>Satellite CDN</Heading>
<ButtonGroup ml="auto">
<Button
as={Link}
href="https://github.com/lovvtide/satellite-web/blob/master/docs/cdn.md"
isExternal
variant="ghost"
>
View Docs
</Button>
<FileUploadButton />
</ButtonGroup>
</Flex>
{account && (
<Flex gap="2">
<Input
type="Search"
placeholder="Search files"
w={{ base: "full", md: "md" }}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</Flex>
)}
{renderContent()}
</VerticalPageLayout>
);
}

View File

@ -1,32 +0,0 @@
import { useContext } from "react";
import { SatelliteCDNFile } from "../../../helpers/satellite-cdn";
import { PostModalContext } from "../../../providers/route/post-modal-provider";
import { ButtonProps, IconButton } from "@chakra-ui/react";
import { QuoteEventIcon } from "../../../components/icons";
export type ShareFileButtonProps = Omit<ButtonProps, "children" | "onClick"> & {
file: SatelliteCDNFile;
};
export default function ShareFileButton({
file,
"aria-label": ariaLabel,
title = "Share File",
...props
}: ShareFileButtonProps) {
const { openModal } = useContext(PostModalContext);
const handleClick = () => {
openModal({ cacheFormKey: null, initContent: "\n" + file.url });
};
return (
<IconButton
icon={<QuoteEventIcon />}
onClick={handleClick}
aria-label={ariaLabel || title}
title={title}
{...props}
/>
);
}

View File

@ -218,8 +218,9 @@ export default function UserAboutTab() {
</Flex>
<UserProfileBadges pubkey={pubkey} px="2" />
<UserPinnedEvents pubkey={pubkey} />
<Box px="2">
<Heading size="md">Recent activity:</Heading>
<Heading size="md">Recent events:</Heading>
<UserRecentEvents pubkey={pubkey} />
</Box>
<UserStatsAccordion pubkey={pubkey} />
@ -253,8 +254,6 @@ export default function UserAboutTab() {
Nostree page
</Button>
</Flex>
<UserPinnedEvents pubkey={pubkey} />
<UserJoinedCommunities pubkey={pubkey} />
<UserJoinedChanneled pubkey={pubkey} />

View File

@ -184,9 +184,9 @@ export default function UserRecentEvents({ pubkey }: { pubkey: string }) {
authors: [pubkey],
limit: 100,
});
const all = useDisclosure();
// const recent = useStoreQuery(TimelineQuery, [{ authors: [pubkey], limit: 100 }]);
const all = useDisclosure();
const byKind = recent?.reduce(
(dir, event) => {
@ -206,11 +206,16 @@ export default function UserRecentEvents({ pubkey }: { pubkey: string }) {
<Flex gap="2" wrap="wrap">
{byKind &&
Object.entries(byKind)
.filter(([_, { known }]) => (known ? known.hidden !== true : true))
.filter(([_, { known }]) => (!!known || all.isOpen) && (known ? known.hidden !== true : true))
.sort((a, b) => parseInt(a[0]) - parseInt(b[0]))
.map(([kind, { events, known }]) => (
<EventKindButton key={kind} kind={parseInt(kind)} events={events} pubkey={pubkey} known={known} />
))}
{!all.isOpen && (
<Button variant="link" p="4" onClick={all.onOpen}>
Show more ({Object.entries(byKind).filter(([_, { known }]) => !!known).length})
</Button>
)}
</Flex>
);
}

View File

@ -1,81 +0,0 @@
import { Flex, Text } from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import { useOutletContext } from "react-router-dom";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import VerticalPageLayout from "../../components/vertical-page-layout";
import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status";
import { NostrEvent, isPTag } from "../../types/nostr-event";
import UserAvatarLink from "../../components/user/user-avatar-link";
import UserLink from "../../components/user/user-link";
import ArrowRight from "../../components/icons/arrow-right";
import { AtIcon } from "../../components/icons";
import Timestamp from "../../components/timestamp";
import ArrowLeft from "../../components/icons/arrow-left";
import useEventIntersectionRef from "../../hooks/use-event-intersection-ref";
function DirectMessage({ dm, pubkey }: { dm: NostrEvent; pubkey: string }) {
const sender = dm.pubkey;
const receiver = dm.tags.find(isPTag)?.[1];
const ref = useEventIntersectionRef(dm);
if (sender === pubkey) {
if (!receiver) return null;
return (
<Flex gap="2" alignItems="center" ref={ref}>
<ArrowRight boxSize={6} />
<Timestamp timestamp={dm.created_at} />
<Text>Sent: </Text>
<UserAvatarLink pubkey={receiver} size="sm" />
<UserLink pubkey={receiver} fontWeight="bold" fontSize="lg" />
</Flex>
);
} else if (receiver === pubkey) {
return (
<Flex gap="2" alignItems="center" ref={ref}>
<ArrowLeft boxSize={6} />
<Timestamp timestamp={dm.created_at} />
<Text>Received: </Text>
<UserAvatarLink pubkey={sender} size="sm" />
<UserLink pubkey={sender} fontWeight="bold" fontSize="lg" />
</Flex>
);
} else {
return (
<Flex gap="2" alignItems="center" ref={ref}>
<AtIcon boxSize={6} />
<Timestamp timestamp={dm.created_at} />
<Text>Mentioned: </Text>
<UserAvatarLink pubkey={pubkey} size="sm" />
<UserLink pubkey={pubkey} fontWeight="bold" fontSize="lg" />
</Flex>
);
}
}
export default function UserDMsTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const readRelays = useAdditionalRelayContext();
const { loader, timeline: dms } = useTimelineLoader(pubkey + "-articles", readRelays, [
{
authors: [pubkey],
kinds: [kinds.EncryptedDirectMessage],
},
{ "#p": [pubkey], kinds: [kinds.EncryptedDirectMessage] },
]);
const callback = useTimelineCurserIntersectionCallback(loader);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
{dms?.map((dm) => <DirectMessage key={dm.id} dm={dm} pubkey={pubkey} />)}
<TimelineActionAndStatus timeline={loader} />
</VerticalPageLayout>
</IntersectionObserverProvider>
);
}

View File

@ -0,0 +1,88 @@
import { Flex, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import { useOutletContext } from "react-router-dom";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import VerticalPageLayout from "../../components/vertical-page-layout";
import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status";
import { NostrEvent } from "../../types/nostr-event";
import UserAvatarLink from "../../components/user/user-avatar-link";
import UserLink from "../../components/user/user-link";
import Timestamp from "../../components/timestamp";
import useEventIntersectionRef from "../../hooks/use-event-intersection-ref";
import SuperMap from "../../classes/super-map";
import { getDMRecipient, getDMSender } from "../../helpers/nostr/dms";
import UserDnsIdentityIcon from "../../components/user/user-dns-identity-icon";
function DirectMessageRow({ messages, pubkey, self }: { messages: NostrEvent[]; pubkey: string; self: string }) {
const ref = useEventIntersectionRef<HTMLTableRowElement>(messages[0]);
return (
<Tr ref={ref}>
<Td>
<Flex gap="2" alignItems="center">
<UserAvatarLink pubkey={pubkey} size="xs" />
<UserLink pubkey={pubkey} />
<UserDnsIdentityIcon pubkey={pubkey} />
</Flex>
</Td>
<Td isNumeric>
<Timestamp timestamp={messages[0].created_at} />
</Td>
<Td isNumeric>{messages.filter((dm) => getDMSender(dm) === self).length}</Td>
<Td isNumeric>{messages.filter((dm) => getDMRecipient(dm) === self).length}</Td>
<Td isNumeric>{messages.length}</Td>
</Tr>
);
}
export default function UserMessagesTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const readRelays = useAdditionalRelayContext();
const { loader, timeline: messages } = useTimelineLoader(pubkey + "-articles", readRelays, [
{
authors: [pubkey],
kinds: [kinds.EncryptedDirectMessage],
},
{ "#p": [pubkey], kinds: [kinds.EncryptedDirectMessage] },
]);
const callback = useTimelineCurserIntersectionCallback(loader);
const byCounterParty = new SuperMap<string, NostrEvent[]>(() => []);
for (const message of messages) {
const sender = getDMSender(message);
const receiver = getDMRecipient(message);
if (sender !== pubkey) byCounterParty.get(sender).push(message);
else if (receiver !== pubkey) byCounterParty.get(receiver).push(message);
}
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>User</Th>
<Th isNumeric>Recent</Th>
<Th isNumeric>Sent</Th>
<Th isNumeric>Received</Th>
<Th isNumeric>Total</Th>
</Tr>
</Thead>
<Tbody>
{Array.from(byCounterParty).map(([user, messages]) => (
<DirectMessageRow key={user} self={pubkey} pubkey={user} messages={messages} />
))}
</Tbody>
</Table>
</TableContainer>
<TimelineActionAndStatus timeline={loader} />
</VerticalPageLayout>
</IntersectionObserverProvider>
);
}