mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-10 04:39:19 +02:00
Merge branch 'next'
This commit is contained in:
commit
16e43a33f3
5
.changeset/eight-pots-sort.md
Normal file
5
.changeset/eight-pots-sort.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add reactions and zaps to DMs
|
5
.changeset/eleven-cows-fail.md
Normal file
5
.changeset/eleven-cows-fail.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for threads in DMs
|
5
.changeset/four-grapes-beg.md
Normal file
5
.changeset/four-grapes-beg.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for Amber signer
|
5
.changeset/long-oranges-type.md
Normal file
5
.changeset/long-oranges-type.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Make DMs view more readable
|
5
.changeset/smart-dryers-wave.md
Normal file
5
.changeset/smart-dryers-wave.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for NIP-46 signer
|
5
.changeset/tall-trains-kick.md
Normal file
5
.changeset/tall-trains-kick.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Support NIP-31 on unknown event kinds
|
7
.github/workflows/docker-image.yml
vendored
7
.github/workflows/docker-image.yml
vendored
@ -25,6 +25,12 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@ -55,5 +61,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -1,3 +1,3 @@
|
||||
{
|
||||
"cSpell.words": ["Bech", "Chakra", "lnurl", "Msat", "nostr", "noStrudel", "Npub", "pubkeys", "Sats", "webln"]
|
||||
"cSpell.words": ["Bech", "Chakra", "damus", "lnurl", "Msat", "nostr", "noStrudel", "Npub", "pubkeys", "Sats", "webln"]
|
||||
}
|
||||
|
@ -62,7 +62,6 @@
|
||||
"three": "^0.157.0",
|
||||
"three-spritetext": "^1.8.1",
|
||||
"webln": "^0.3.2",
|
||||
"webtorrent": "^2.1.29",
|
||||
"yet-another-react-lightbox": "^3.12.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -79,13 +78,12 @@
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/three": "^0.157.2",
|
||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
|
||||
"@types/webtorrent": "^0.109.7",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"camelcase": "^8.0.0",
|
||||
"prettier": "^3.0.2",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.2",
|
||||
"vite-plugin-pwa": "^0.17.2",
|
||||
"vite-plugin-pwa": "^0.17.4",
|
||||
"workbox-build": "^7.0.0",
|
||||
"workbox-window": "^7.0.0"
|
||||
},
|
||||
|
11
src/app.tsx
11
src/app.tsx
@ -16,8 +16,8 @@ import ProfileView from "./views/profile";
|
||||
import HashTagView from "./views/hashtag";
|
||||
import ThreadView from "./views/note";
|
||||
import NotificationsView from "./views/notifications";
|
||||
import DirectMessagesView from "./views/messages";
|
||||
import DirectMessageChatView from "./views/messages/chat";
|
||||
import DirectMessagesView from "./views/dms";
|
||||
import DirectMessageChatView from "./views/dms/chat";
|
||||
|
||||
import SigninView from "./views/signin";
|
||||
import SignupView from "./views/signup";
|
||||
@ -74,6 +74,7 @@ import UserDMsTab from "./views/user/dms";
|
||||
import DMFeedView from "./views/tools/dm-feed";
|
||||
import ContentDiscoveryView from "./views/tools/content-discovery";
|
||||
import ContentDiscoveryDVMView from "./views/tools/content-discovery/dvm";
|
||||
import LoginNostrConnectView from "./views/signin/nostr-connect";
|
||||
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
||||
|
||||
const ToolsHomeView = lazy(() => import("./views/tools"));
|
||||
@ -94,7 +95,6 @@ const ChannelView = lazy(() => import("./views/channels/channel"));
|
||||
|
||||
const TorrentsView = lazy(() => import("./views/torrents"));
|
||||
const TorrentDetailsView = lazy(() => import("./views/torrents/torrent"));
|
||||
const TorrentPreviewView = lazy(() => import("./views/torrents/preview"));
|
||||
const NewTorrentView = lazy(() => import("./views/torrents/new"));
|
||||
|
||||
const overrideReactTextareaAutocompleteStyles = css`
|
||||
@ -146,6 +146,7 @@ const router = createHashRouter([
|
||||
{ path: "npub", element: <LoginNpubView /> },
|
||||
{ path: "nip05", element: <LoginNip05View /> },
|
||||
{ path: "nsec", element: <LoginNsecView /> },
|
||||
{ path: "nostr-connect", element: <LoginNostrConnectView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -287,10 +288,6 @@ const router = createHashRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "torrents/:id/preview",
|
||||
element: <TorrentPreviewView />,
|
||||
},
|
||||
{
|
||||
path: "torrents",
|
||||
children: [
|
||||
|
@ -112,6 +112,12 @@ export default class NostrMultiSubscription {
|
||||
this.relayQueries.delete(relay);
|
||||
}
|
||||
|
||||
sendAll(event: NostrEvent) {
|
||||
for (const relay of this.relays) {
|
||||
relay.send(["EVENT", event]);
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this.state === NostrMultiSubscription.OPEN) return this;
|
||||
|
||||
|
@ -2,21 +2,21 @@ import { Badge, BadgeProps } from "@chakra-ui/react";
|
||||
import { Account } from "../services/account";
|
||||
|
||||
export default function AccountInfoBadge({ account, ...props }: BadgeProps & { account: Account }) {
|
||||
if (account.connectionType === "extension") {
|
||||
if (account.type === "extension") {
|
||||
return (
|
||||
<Badge {...props} variant="solid" colorScheme="green">
|
||||
extension
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (account.connectionType === "serial") {
|
||||
if (account.type === "serial") {
|
||||
return (
|
||||
<Badge {...props} variant="solid" colorScheme="teal">
|
||||
serial
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (account.secKey) {
|
||||
if (account.type === "local") {
|
||||
return (
|
||||
<Badge {...props} variant="solid" colorScheme="red">
|
||||
nsec
|
||||
|
@ -1,121 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
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").unix());
|
||||
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>
|
||||
);
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
@ -1,21 +1,19 @@
|
||||
import { Card, CardBody, CardHeader, CardProps, LinkBox, Spacer, Text } from "@chakra-ui/react";
|
||||
import { Card, CardBody, CardHeader, CardProps, LinkBox, 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 DecryptPlaceholder from "../../../views/dms/components/decrypt-placeholder";
|
||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||
import { getDMRecipient, getDMSender } from "../../../helpers/nostr/dms";
|
||||
import { MessageContent } from "../../../views/dms/components/message-bubble";
|
||||
|
||||
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);
|
||||
const sender = getDMSender(dm);
|
||||
const receiver = getDMRecipient(dm);
|
||||
|
||||
if (!receiver) return "Broken DM";
|
||||
|
||||
@ -30,11 +28,13 @@ export default function EmbeddedDM({ dm, ...props }: Omit<CardProps, "children">
|
||||
<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>
|
||||
{(sender === account?.pubkey || receiver === account?.pubkey) && (
|
||||
<CardBody px="2" pt="0" pb="2">
|
||||
<DecryptPlaceholder message={dm}>
|
||||
{(plaintext) => <MessageContent event={dm} text={plaintext} />}
|
||||
</DecryptPlaceholder>
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
</TrustProvider>
|
||||
);
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Button, Card, CardBody, CardHeader, CardProps, Flex, Link, Text, useDisclosure } from "@chakra-ui/react";
|
||||
|
||||
import { getSharableEventAddress } from "../../../helpers/nip19";
|
||||
@ -7,9 +8,16 @@ import UserLink from "../../user-link";
|
||||
import { truncatedId } from "../../../helpers/nostr/events";
|
||||
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
|
||||
import { UserDnsIdentityIcon } from "../../user-dns-identity-icon";
|
||||
import { useMemo } from "react";
|
||||
import { embedEmoji, embedNostrHashtags, embedNostrLinks, embedNostrMentions } from "../../embed-types";
|
||||
import { EmbedableContent } from "../../../helpers/embeds";
|
||||
import {
|
||||
embedEmoji,
|
||||
embedNostrHashtags,
|
||||
embedNostrLinks,
|
||||
embedNostrMentions,
|
||||
renderGenericUrl,
|
||||
renderImageUrl,
|
||||
renderVideoUrl,
|
||||
} from "../../embed-types";
|
||||
import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
|
||||
import Timestamp from "../../timestamp";
|
||||
import { CodeIcon } from "../../icons";
|
||||
import NoteDebugModal from "../../debug-modals/note-debug-modal";
|
||||
@ -18,15 +26,18 @@ export default function EmbeddedUnknown({ event, ...props }: Omit<CardProps, "ch
|
||||
const debugModal = useDisclosure();
|
||||
const address = getSharableEventAddress(event);
|
||||
|
||||
const alt = event.tags.find((t) => t[0] === "alt")?.[1];
|
||||
const content = useMemo(() => {
|
||||
let jsx: EmbedableContent = [event.content];
|
||||
let jsx: EmbedableContent = [alt || event.content];
|
||||
jsx = embedNostrLinks(jsx);
|
||||
jsx = embedNostrMentions(jsx, event);
|
||||
jsx = embedNostrHashtags(jsx, event);
|
||||
jsx = embedEmoji(jsx, event);
|
||||
|
||||
jsx = embedUrls(jsx, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
|
||||
|
||||
return jsx;
|
||||
}, [event.content]);
|
||||
}, [event.content, alt]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -234,3 +234,5 @@ export const DownloadIcon = Download01;
|
||||
export const TranslateIcon = Translate01;
|
||||
|
||||
export const ChannelsIcon = MessageChatSquare;
|
||||
|
||||
export const ThreadIcon = MessageChatSquare;
|
||||
|
@ -22,12 +22,10 @@ function AccountItem({ account, onClick }: { account: Account; onClick?: () => v
|
||||
|
||||
return (
|
||||
<Box display="flex" gap="2" alignItems="center" cursor="pointer">
|
||||
<Flex as="button" onClick={handleClick} flex={1} gap="2">
|
||||
<Flex as="button" onClick={handleClick} flex={1} gap="2" overflow="hidden" alignItems="center">
|
||||
<UserAvatar pubkey={pubkey} size="md" />
|
||||
<Flex overflow="hidden" direction="column" alignItems="flex-start">
|
||||
<Text isTruncated>{getUserDisplayName(metadata, pubkey)}</Text>
|
||||
<AccountInfoBadge fontSize="0.7em" account={account} />
|
||||
</Flex>
|
||||
<Text isTruncated>{getUserDisplayName(metadata, pubkey)}</Text>
|
||||
<AccountInfoBadge fontSize="0.7em" account={account} />
|
||||
</Flex>
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
|
@ -12,7 +12,6 @@ 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 });
|
||||
@ -66,7 +65,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
</Flex>
|
||||
{isGhost && <GhostToolbar />}
|
||||
{searchModal.isOpen && <SearchModal isOpen onClose={searchModal.onClose} />}
|
||||
{/* {!isMobile && <ChatWindows />} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerProps,
|
||||
Flex,
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Portal,
|
||||
useBoolean,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
@ -22,7 +23,11 @@ import { draftEventReaction } from "../../../helpers/nostr/reactions";
|
||||
import { getEventUID } from "../../../helpers/nostr/events";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ReactionButton({ event, ...props }: { event: NostrEvent } & Omit<ButtonProps, "children">) {
|
||||
export default function AddReactionButton({
|
||||
event,
|
||||
portal = false,
|
||||
...props
|
||||
}: { event: NostrEvent; portal?: boolean } & Omit<ButtonProps, "children">) {
|
||||
const toast = useToast();
|
||||
const { requestSignature } = useSigningContext();
|
||||
const reactions = useEventReactions(getEventUID(event)) ?? [];
|
||||
@ -47,6 +52,15 @@ export default function ReactionButton({ event, ...props }: { event: NostrEvent
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<ReactionPicker onSelect={addReaction} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover isLazy isOpen={popover} onOpen={setPopover.on} onClose={setPopover.off}>
|
||||
<PopoverTrigger>
|
||||
@ -60,12 +74,7 @@ export default function ReactionButton({ event, ...props }: { event: NostrEvent
|
||||
{reactions?.length ?? 0}
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<ReactionPicker onSelect={addReaction} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
{portal ? <Portal>{content}</Portal> : content}
|
||||
</Popover>
|
||||
);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { ButtonGroup, ButtonGroupProps, Divider } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import ReactionButton from "./reaction-button";
|
||||
import AddReactionButton from "./add-reaction-button";
|
||||
import EventReactionButtons from "../../event-reactions/event-reactions";
|
||||
import useEventReactions from "../../../hooks/use-event-reactions";
|
||||
import { useBreakpointValue } from "../../../providers/breakpoint-provider";
|
||||
@ -12,7 +12,7 @@ export default function NoteReactions({ event, ...props }: Omit<ButtonGroupProps
|
||||
|
||||
return (
|
||||
<ButtonGroup spacing="1" {...props}>
|
||||
<ReactionButton event={event} />
|
||||
<AddReactionButton event={event} />
|
||||
{reactions.length > 0 && (
|
||||
<>
|
||||
<Divider orientation="vertical" h="1.5rem" />
|
||||
|
@ -40,13 +40,13 @@ import {
|
||||
import { UserAvatarStack } from "../compact-user-stack";
|
||||
import MagicTextArea, { RefType } from "../magic-textarea";
|
||||
import { useContextEmojis } from "../../providers/emoji-provider";
|
||||
import { nostrBuildUploadImage as nostrBuildUpload } from "../../helpers/nostr-build";
|
||||
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 useCacheForm from "../../hooks/use-cache-form";
|
||||
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
|
||||
import { useTextAreaUploadFileWithForm } from "../../hooks/use-textarea-upload-file";
|
||||
|
||||
type FormValues = {
|
||||
subject: string;
|
||||
@ -100,34 +100,10 @@ export default function PostModal({
|
||||
// cache form to localStorage
|
||||
useCacheForm<FormValues>(cacheFormKey, getValues, setValue, formState);
|
||||
|
||||
const textAreaRef = useRef<RefType | null>(null);
|
||||
const imageUploadRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
if (!(file.type.includes("image") || file.type.includes("video") || file.type.includes("audio")))
|
||||
throw new Error("Unsupported file type");
|
||||
|
||||
setUploading(true);
|
||||
|
||||
const response = await nostrBuildUpload(file, requestSignature);
|
||||
const imageUrl = response.url;
|
||||
|
||||
const content = getValues().content;
|
||||
const position = textAreaRef.current?.getCaretPosition();
|
||||
if (position !== undefined) {
|
||||
setValue("content", content.slice(0, position) + imageUrl + " " + content.slice(position), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
} else setValue("content", content + imageUrl + " ", { shouldDirty: true });
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
setUploading(false);
|
||||
},
|
||||
[setValue, getValues],
|
||||
);
|
||||
const textAreaRef = useRef<RefType | null>(null);
|
||||
const { onPaste, onFileInputChange, uploading } = useTextAreaUploadFileWithForm(textAreaRef, getValues, setValue);
|
||||
|
||||
const getDraft = useCallback(() => {
|
||||
const { content, nsfw, nsfwReason, community, split, subject } = getValues();
|
||||
@ -139,8 +115,6 @@ export default function PostModal({
|
||||
created_at: dayjs().unix(),
|
||||
});
|
||||
|
||||
updatedDraft.content = correctContentMentions(updatedDraft.content);
|
||||
|
||||
if (nsfw) {
|
||||
updatedDraft.tags.push(nsfwReason ? ["content-warning", nsfwReason] : ["content-warning"]);
|
||||
}
|
||||
@ -184,6 +158,8 @@ export default function PostModal({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: wrap this in a form
|
||||
return (
|
||||
<>
|
||||
{requireSubject && <Input {...register("subject", { required: true })} isRequired placeholder="Subject" />}
|
||||
@ -191,13 +167,13 @@ export default function PostModal({
|
||||
autoFocus
|
||||
mb="2"
|
||||
value={getValues().content}
|
||||
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
|
||||
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true, shouldTouch: true })}
|
||||
rows={5}
|
||||
isRequired
|
||||
instanceRef={(inst) => (textAreaRef.current = inst)}
|
||||
onPaste={(e) => {
|
||||
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
|
||||
if (imageFile) uploadFile(imageFile);
|
||||
onPaste={onPaste}
|
||||
onKeyDown={(e) => {
|
||||
if (e.ctrlKey && e.key === "Enter") submit();
|
||||
}}
|
||||
/>
|
||||
{getValues().content.length > 0 && (
|
||||
@ -216,10 +192,7 @@ export default function PostModal({
|
||||
type="file"
|
||||
accept="image/*,audio/*,video/*"
|
||||
ref={imageUploadRef}
|
||||
onChange={(e) => {
|
||||
const img = e.target.files?.[0];
|
||||
if (img) uploadFile(img);
|
||||
}}
|
||||
onChange={onFileInputChange}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<UploadImageIcon />}
|
||||
|
@ -1,10 +1,15 @@
|
||||
import { bech32 } from "bech32";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
|
||||
import { NostrEvent, Tag, isATag, isDTag, isETag, isPTag } from "../types/nostr-event";
|
||||
import { isReplaceable } from "./nostr/events";
|
||||
import { DecodeResult } from "nostr-tools/lib/types/nip19";
|
||||
import relayHintService from "../services/event-relay-hint";
|
||||
|
||||
export function isHex(str?: string) {
|
||||
if (str?.match(/^[0-9a-f]+$/i)) return true;
|
||||
return false;
|
||||
}
|
||||
export function isHexKey(key?: string) {
|
||||
if (key?.toLowerCase()?.match(/^[0-9a-f]{64}$/)) return true;
|
||||
return false;
|
||||
|
35
src/helpers/nostr/dms.ts
Normal file
35
src/helpers/nostr/dms.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import dayjs from "dayjs";
|
||||
import { NostrEvent, isPTag } from "../../types/nostr-event";
|
||||
|
||||
export function getDMSender(event: NostrEvent) {
|
||||
return event.pubkey;
|
||||
}
|
||||
export function getDMRecipient(event: NostrEvent) {
|
||||
const pubkey = event.tags.find(isPTag)?.[1];
|
||||
if (!pubkey) throw new Error("Missing recipient pubkey");
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
export function groupMessages(messages: NostrEvent[], minutes = 5, ascending = false) {
|
||||
const sorted = messages.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
const groups: { id: string; pubkey: string; events: NostrEvent[] }[] = [];
|
||||
for (const message of sorted) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.pubkey === message.pubkey) {
|
||||
const lastEvent = last.events[last.events.length - 1];
|
||||
if (lastEvent && dayjs.unix(lastEvent.created_at).diff(dayjs.unix(message.created_at), "minute") < minutes) {
|
||||
last.events.push(message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const group = { id: message.id, pubkey: message.pubkey, events: [message] };
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
if (ascending) {
|
||||
for (const group of groups) group.events.reverse();
|
||||
return groups.reverse();
|
||||
} else return groups;
|
||||
}
|
72
src/hooks/use-textarea-upload-file.ts
Normal file
72
src/hooks/use-textarea-upload-file.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { ChangeEventHandler, ClipboardEventHandler, MutableRefObject, useCallback, useState } from "react";
|
||||
import { useToast } from "@chakra-ui/react";
|
||||
|
||||
import { nostrBuildUploadImage } from "../helpers/nostr-build";
|
||||
import { RefType } from "../components/magic-textarea";
|
||||
import { useSigningContext } from "../providers/signing-provider";
|
||||
import { UseFormGetValues, UseFormSetValue } from "react-hook-form";
|
||||
|
||||
export function useTextAreaUploadFileWithForm(
|
||||
ref: MutableRefObject<RefType | null>,
|
||||
getValues: UseFormGetValues<any>,
|
||||
setValue: UseFormSetValue<any>,
|
||||
) {
|
||||
const getText = useCallback(() => getValues().content, [getValues]);
|
||||
const setText = useCallback(
|
||||
(text: string) => setValue("content", text, { shouldDirty: true, shouldTouch: true }),
|
||||
[setValue],
|
||||
);
|
||||
return useTextAreaUploadFile(ref, getText, setText);
|
||||
}
|
||||
|
||||
export default function useTextAreaUploadFile(
|
||||
ref: MutableRefObject<RefType | null>,
|
||||
getText: () => string,
|
||||
setText: (text: string) => void,
|
||||
) {
|
||||
const toast = useToast();
|
||||
const { requestSignature } = useSigningContext();
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
if (!(file.type.includes("image") || file.type.includes("video") || file.type.includes("audio")))
|
||||
throw new Error("Unsupported file type");
|
||||
|
||||
setUploading(true);
|
||||
|
||||
const response = await nostrBuildUploadImage(file, requestSignature);
|
||||
const imageUrl = response.url;
|
||||
|
||||
const content = getText();
|
||||
const position = ref.current?.getCaretPosition();
|
||||
if (position !== undefined) {
|
||||
setText(content.slice(0, position) + imageUrl + " " + content.slice(position));
|
||||
} else setText(content + imageUrl + " ");
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
setUploading(false);
|
||||
},
|
||||
[setText, getText, toast, setUploading],
|
||||
);
|
||||
|
||||
const onFileInputChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||
(e) => {
|
||||
const img = e.target.files?.[0];
|
||||
if (img) uploadFile(img);
|
||||
},
|
||||
[uploadFile],
|
||||
);
|
||||
|
||||
const onPaste = useCallback<ClipboardEventHandler<HTMLTextAreaElement>>(
|
||||
(e) => {
|
||||
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
|
||||
if (imageFile) uploadFile(imageFile);
|
||||
},
|
||||
[uploadFile],
|
||||
);
|
||||
|
||||
return { uploadFile, uploading, onPaste, onFileInputChange };
|
||||
}
|
@ -2,7 +2,6 @@ import "./polyfill";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app";
|
||||
import { GlobalProviders } from "./providers";
|
||||
import "./services/local-cache-relay";
|
||||
|
||||
// setup dayjs
|
||||
import dayjs from "dayjs";
|
||||
|
@ -1,6 +0,0 @@
|
||||
// @ts-ignore
|
||||
import lib from "webtorrent/dist/webtorrent.min.js";
|
||||
import type { WebTorrent } from "webtorrent";
|
||||
|
||||
const WebTorrent = lib as WebTorrent;
|
||||
export default WebTorrent;
|
@ -112,6 +112,22 @@ export function useNavigateInDrawer() {
|
||||
|
||||
const log = logger.extend("DrawerRouter");
|
||||
|
||||
export function useRouterMarker(router: Router) {
|
||||
const index = useRef<number | null>(null);
|
||||
const set = useCallback((v=0) => (index.current = v), []);
|
||||
const reset = useCallback(() => (index.current = null), []);
|
||||
|
||||
useEffect(() => {
|
||||
return router.subscribe((event) => {
|
||||
if (index.current === null) return;
|
||||
if (event.historyAction === "PUSH") index.current++;
|
||||
else if (event.historyAction === "POP") index.current--;
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
return useMemo(() => ({ index, set, reset }), [index, set, reset]);
|
||||
}
|
||||
|
||||
export default function DrawerSubViewProvider({
|
||||
children,
|
||||
parentRouter,
|
||||
@ -121,16 +137,13 @@ export default function DrawerSubViewProvider({
|
||||
const openInParent = useCallback((to: To) => parentRouter.navigate(to), [parentRouter]);
|
||||
|
||||
const direction = useRef<"up" | "down">();
|
||||
const marker = useRef<number>(0);
|
||||
const marker = useRouterMarker(parentRouter);
|
||||
|
||||
useEffect(() => {
|
||||
return parentRouter.subscribe((event) => {
|
||||
const location = event.location as Location<{ subRouterPath?: To | null } | null>;
|
||||
const subRoute = location.state?.subRouterPath;
|
||||
|
||||
if (event.historyAction === "PUSH") marker.current++;
|
||||
else if (event.historyAction === "POP") marker.current--;
|
||||
|
||||
if (subRoute) {
|
||||
if (router) {
|
||||
if (router.state.location.pathname !== subRoute && direction.current !== "up") {
|
||||
@ -175,7 +188,7 @@ export default function DrawerSubViewProvider({
|
||||
|
||||
const openDrawer = useCallback(
|
||||
(to: To) => {
|
||||
marker.current = 0;
|
||||
marker.set();
|
||||
parentRouter.navigate(parentRouter.state.location, {
|
||||
preventScrollReset: true,
|
||||
state: { ...parentRouter.state.location.state, subRouterPath: to },
|
||||
@ -185,8 +198,8 @@ export default function DrawerSubViewProvider({
|
||||
);
|
||||
|
||||
const closeDrawer = useCallback(() => {
|
||||
const i = marker.current;
|
||||
if (i > 0) {
|
||||
const i = marker.index.current;
|
||||
if (i !== null && i > 0) {
|
||||
log(`Navigating back ${i} entries to the point the drawer was opened`);
|
||||
parentRouter.navigate(-i);
|
||||
} else {
|
||||
@ -198,7 +211,7 @@ export default function DrawerSubViewProvider({
|
||||
}
|
||||
|
||||
// reset marker
|
||||
marker.current = 0;
|
||||
marker.reset();
|
||||
}, [parentRouter]);
|
||||
|
||||
const context = useMemo(
|
||||
|
@ -2,15 +2,47 @@ import { PersistentSubject } from "../classes/subject";
|
||||
import db from "./db";
|
||||
import { AppSettings } from "./settings/migrations";
|
||||
|
||||
export type Account = {
|
||||
type CommonAccount = {
|
||||
pubkey: string;
|
||||
readonly: boolean;
|
||||
relays?: string[];
|
||||
secKey?: ArrayBuffer;
|
||||
iv?: Uint8Array;
|
||||
connectionType?: "extension" | "serial";
|
||||
localSettings?: AppSettings;
|
||||
};
|
||||
export type LocalAccount = CommonAccount & {
|
||||
type: "local";
|
||||
readonly: false;
|
||||
secKey: ArrayBuffer;
|
||||
iv: Uint8Array;
|
||||
};
|
||||
export type PubkeyAccount = CommonAccount & {
|
||||
type: "pubkey";
|
||||
readonly: true;
|
||||
};
|
||||
export type ExtensionAccount = CommonAccount & {
|
||||
type: "extension";
|
||||
readonly: false;
|
||||
};
|
||||
export type SerialAccount = CommonAccount & {
|
||||
type: "serial";
|
||||
readonly: false;
|
||||
};
|
||||
export type AmberAccount = CommonAccount & {
|
||||
type: "amber";
|
||||
readonly: false;
|
||||
};
|
||||
export type NostrConnectAccount = CommonAccount & {
|
||||
type: "nostr-connect";
|
||||
clientSecretKey: string;
|
||||
signerRelays: string[];
|
||||
readonly: false;
|
||||
};
|
||||
|
||||
export type Account =
|
||||
| ExtensionAccount
|
||||
| LocalAccount
|
||||
| NostrConnectAccount
|
||||
| SerialAccount
|
||||
| AmberAccount
|
||||
| PubkeyAccount;
|
||||
|
||||
class AccountService {
|
||||
loading = new PersistentSubject(true);
|
||||
@ -33,6 +65,7 @@ class AccountService {
|
||||
|
||||
startGhost(pubkey: string) {
|
||||
const ghostAccount: Account = {
|
||||
type: "pubkey",
|
||||
pubkey,
|
||||
readonly: true,
|
||||
};
|
||||
|
107
src/services/amber-signer.ts
Normal file
107
src/services/amber-signer.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { getEventHash, nip19, verifySignature } from "nostr-tools";
|
||||
|
||||
import createDefer, { Deferred } from "../classes/deferred";
|
||||
import { getPubkeyFromDecodeResult, isHex, isHexKey } from "../helpers/nip19";
|
||||
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
|
||||
|
||||
export function createGetPublicKeyIntent() {
|
||||
return `intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end`;
|
||||
}
|
||||
export function createSignEventIntent(draft: DraftNostrEvent) {
|
||||
return `intent:${encodeURIComponent(
|
||||
JSON.stringify(draft),
|
||||
)}#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=sign_event;end`;
|
||||
}
|
||||
export function createNip04EncryptIntent(pubkey: string, plainText: string) {
|
||||
return `intent:${encodeURIComponent(
|
||||
plainText,
|
||||
)}#Intent;scheme=nostrsigner;S.pubKey=${pubkey};S.compressionType=none;S.returnType=signature;S.type=nip04_encrypt;end`;
|
||||
}
|
||||
export function createNip04DecryptIntent(pubkey: string, data: string) {
|
||||
return `intent:${encodeURIComponent(
|
||||
data,
|
||||
)}#Intent;scheme=nostrsigner;S.pubKey=${pubkey};S.compressionType=none;S.returnType=signature;S.type=nip04_decrypt;end`;
|
||||
}
|
||||
|
||||
let pendingRequest: Deferred<string> | null = null;
|
||||
|
||||
function rejectPending() {
|
||||
if (pendingRequest) {
|
||||
pendingRequest.reject("Canceled");
|
||||
pendingRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === "visible") {
|
||||
if (!pendingRequest) return;
|
||||
|
||||
// read the result from the clipboard
|
||||
setTimeout(() => {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((result) => pendingRequest?.resolve(result))
|
||||
.catch((e) => pendingRequest?.reject(e));
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
async function intentRequest(intent: string) {
|
||||
rejectPending();
|
||||
const request = createDefer<string>();
|
||||
window.open(intent, "_blank");
|
||||
// NOTE: wait 500ms before setting the pending request since the visibilitychange event fires as soon as window.open is called
|
||||
setTimeout(() => {
|
||||
pendingRequest = request;
|
||||
}, 500);
|
||||
const result = await request;
|
||||
if (result.length === 0) throw new Error("Empty clipboard");
|
||||
return result;
|
||||
}
|
||||
|
||||
async function getPublicKey() {
|
||||
const result = await intentRequest(createGetPublicKeyIntent());
|
||||
if (isHexKey(result)) return result;
|
||||
else if (result.startsWith("npub") || result.startsWith("nprofile")) {
|
||||
const decode = nip19.decode(result);
|
||||
const pubkey = getPubkeyFromDecodeResult(decode);
|
||||
if (!pubkey) throw new Error("Expected npub from clipboard");
|
||||
return pubkey;
|
||||
}
|
||||
throw new Error("Expected clipboard to have pubkey");
|
||||
}
|
||||
|
||||
async function signEvent(draft: DraftNostrEvent & { pubkey: string }): Promise<NostrEvent> {
|
||||
const draftWithId = { ...draft, id: draft.id || getEventHash(draft) };
|
||||
const sig = await intentRequest(createSignEventIntent(draftWithId));
|
||||
if (!isHex(sig)) throw new Error("Expected hex signature");
|
||||
|
||||
const event: NostrEvent = { ...draftWithId, sig };
|
||||
if (!verifySignature(event)) throw new Error("Invalid signature");
|
||||
return event;
|
||||
}
|
||||
|
||||
async function nip04Encrypt(pubkey: string, plaintext: string): Promise<string> {
|
||||
const data = await intentRequest(createNip04EncryptIntent(pubkey, plaintext));
|
||||
return data;
|
||||
}
|
||||
async function nip04Decrypt(pubkey: string, data: string): Promise<string> {
|
||||
const plaintext = await intentRequest(createNip04DecryptIntent(pubkey, data));
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
const amberSignerService = {
|
||||
supported: navigator.userAgent.includes("Android"),
|
||||
getPublicKey,
|
||||
signEvent,
|
||||
nip04Encrypt,
|
||||
nip04Decrypt,
|
||||
};
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.amberSignerService = amberSignerService;
|
||||
}
|
||||
|
||||
export default amberSignerService;
|
@ -1,11 +1,11 @@
|
||||
import { openDB, deleteDB, IDBPDatabase } from "idb";
|
||||
import { SchemaV1, SchemaV2, SchemaV3, SchemaV4, SchemaV5, SchemaV6 } from "./schema";
|
||||
import { openDB, deleteDB, IDBPDatabase, IDBPTransaction } from "idb";
|
||||
import { SchemaV1, SchemaV2, SchemaV3, SchemaV4, SchemaV5, SchemaV6, SchemaV7 } from "./schema";
|
||||
import { logger } from "../../helpers/debug";
|
||||
|
||||
const log = logger.extend("Database");
|
||||
|
||||
const dbName = "storage";
|
||||
const version = 6;
|
||||
const version = 7;
|
||||
const db = await openDB<SchemaV6>(dbName, version, {
|
||||
upgrade(db, oldVersion, newVersion, transaction, event) {
|
||||
if (oldVersion < 1) {
|
||||
@ -44,11 +44,11 @@ const db = await openDB<SchemaV6>(dbName, version, {
|
||||
}
|
||||
|
||||
if (oldVersion < 2) {
|
||||
const v1 = db as unknown as IDBPDatabase<SchemaV1>;
|
||||
const trans = transaction as unknown as IDBPTransaction<SchemaV1, string[], "versionchange">;
|
||||
const v2 = db as unknown as IDBPDatabase<SchemaV2>;
|
||||
|
||||
// rename the old settings object store to misc
|
||||
const oldSettings = transaction.objectStore("settings");
|
||||
const oldSettings = trans.objectStore("settings");
|
||||
oldSettings.name = "misc";
|
||||
|
||||
// create new settings object store
|
||||
@ -63,10 +63,10 @@ const db = await openDB<SchemaV6>(dbName, version, {
|
||||
const v3 = db as unknown as IDBPDatabase<SchemaV3>;
|
||||
|
||||
// rename the old event caches
|
||||
v3.deleteObjectStore("userMetadata");
|
||||
v3.deleteObjectStore("userContacts");
|
||||
v3.deleteObjectStore("userRelays");
|
||||
v3.deleteObjectStore("settings");
|
||||
v2.deleteObjectStore("userMetadata");
|
||||
v2.deleteObjectStore("userContacts");
|
||||
v2.deleteObjectStore("userRelays");
|
||||
v2.deleteObjectStore("settings");
|
||||
|
||||
// create new replaceable event object store
|
||||
const settings = v3.createObjectStore("replaceableEvents", {
|
||||
@ -89,15 +89,14 @@ const db = await openDB<SchemaV6>(dbName, version, {
|
||||
}
|
||||
|
||||
if (oldVersion < 5) {
|
||||
const v4 = db as unknown as IDBPDatabase<SchemaV4>;
|
||||
const v5 = db as unknown as IDBPDatabase<SchemaV5>;
|
||||
const trans = transaction as unknown as IDBPTransaction<SchemaV5, string[], "versionchange">;
|
||||
|
||||
// migrate accounts table
|
||||
const objectStore = transaction.objectStore("accounts");
|
||||
const objectStore = trans.objectStore("accounts");
|
||||
|
||||
objectStore.getAll().then((accounts: SchemaV4["accounts"]["value"][]) => {
|
||||
for (const account of accounts) {
|
||||
const newAccount: SchemaV5["accounts"] = {
|
||||
const newAccount: SchemaV5["accounts"]["value"] = {
|
||||
...account,
|
||||
connectionType: account.useExtension ? "extension" : undefined,
|
||||
};
|
||||
@ -118,6 +117,52 @@ const db = await openDB<SchemaV6>(dbName, version, {
|
||||
});
|
||||
channelMetadata.createIndex("created", "created");
|
||||
}
|
||||
|
||||
if (oldVersion < 7) {
|
||||
const transV6 = transaction as unknown as IDBPTransaction<SchemaV6, string[], "versionchange">;
|
||||
const transV7 = transaction as unknown as IDBPTransaction<SchemaV7, string[], "versionchange">;
|
||||
|
||||
const accounts = transV7.objectStore("accounts");
|
||||
|
||||
transV6
|
||||
.objectStore("accounts")
|
||||
.getAll()
|
||||
.then((oldAccounts: SchemaV6["accounts"]["value"][]) => {
|
||||
for (const account of oldAccounts) {
|
||||
if (account.secKey && account.iv) {
|
||||
// migrate local accounts
|
||||
accounts.put({
|
||||
type: "local",
|
||||
pubkey: account.pubkey,
|
||||
secKey: account.secKey,
|
||||
iv: account.iv,
|
||||
readonly: false,
|
||||
relays: account.relays,
|
||||
} satisfies SchemaV7["accounts"]["value"]);
|
||||
} else if (account.readonly) {
|
||||
// migrate readonly accounts
|
||||
accounts.put({
|
||||
type: "pubkey",
|
||||
pubkey: account.pubkey,
|
||||
readonly: true,
|
||||
relays: account.relays,
|
||||
} satisfies SchemaV7["accounts"]["value"]);
|
||||
} else if (
|
||||
account.connectionType === "serial" ||
|
||||
account.connectionType === "amber" ||
|
||||
account.connectionType === "extension"
|
||||
) {
|
||||
// migrate extension, serial, amber accounts
|
||||
accounts.put({
|
||||
type: account.connectionType,
|
||||
pubkey: account.pubkey,
|
||||
readonly: false,
|
||||
relays: account.relays,
|
||||
} satisfies SchemaV7["accounts"]["value"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { DBSchema } from "idb";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { RelayInformationDocument } from "../relay-info";
|
||||
import { AppSettings } from "../settings/migrations";
|
||||
import { Account } from "../account";
|
||||
|
||||
export interface SchemaV1 extends DBSchema {
|
||||
export interface SchemaV1 {
|
||||
userMetadata: {
|
||||
key: string;
|
||||
value: NostrEvent;
|
||||
@ -58,8 +60,7 @@ export interface SchemaV1 extends DBSchema {
|
||||
};
|
||||
}
|
||||
|
||||
export interface SchemaV2 extends SchemaV1 {
|
||||
accounts: SchemaV1["accounts"];
|
||||
export interface SchemaV2 extends Omit<SchemaV1, "settings"> {
|
||||
settings: {
|
||||
key: string;
|
||||
value: NostrEvent;
|
||||
@ -71,8 +72,7 @@ export interface SchemaV2 extends SchemaV1 {
|
||||
};
|
||||
}
|
||||
|
||||
export interface SchemaV3 {
|
||||
accounts: SchemaV2["accounts"];
|
||||
export interface SchemaV3 extends Omit<SchemaV2, "settings" | "userMetadata" | "userContacts" | "userRelays"> {
|
||||
replaceableEvents: {
|
||||
key: string;
|
||||
value: {
|
||||
@ -80,20 +80,11 @@ export interface SchemaV3 {
|
||||
created: number;
|
||||
event: NostrEvent;
|
||||
};
|
||||
indexes: { created: number };
|
||||
};
|
||||
userFollows: SchemaV2["userFollows"];
|
||||
dnsIdentifiers: SchemaV2["dnsIdentifiers"];
|
||||
relayInfo: SchemaV2["relayInfo"];
|
||||
relayScoreboardStats: SchemaV2["relayScoreboardStats"];
|
||||
misc: SchemaV2["misc"];
|
||||
}
|
||||
|
||||
export interface SchemaV4 {
|
||||
accounts: SchemaV3["accounts"];
|
||||
replaceableEvents: SchemaV3["replaceableEvents"];
|
||||
dnsIdentifiers: SchemaV3["dnsIdentifiers"];
|
||||
relayInfo: SchemaV3["relayInfo"];
|
||||
relayScoreboardStats: SchemaV3["relayScoreboardStats"];
|
||||
export interface SchemaV4 extends Omit<SchemaV3, "userFollows"> {
|
||||
userSearch: {
|
||||
key: string;
|
||||
value: {
|
||||
@ -101,30 +92,24 @@ export interface SchemaV4 {
|
||||
names: string[];
|
||||
};
|
||||
};
|
||||
misc: SchemaV3["misc"];
|
||||
}
|
||||
|
||||
export interface SchemaV5 {
|
||||
export interface SchemaV5 extends Omit<SchemaV4, "accounts"> {
|
||||
accounts: {
|
||||
pubkey: string;
|
||||
readonly: boolean;
|
||||
relays?: string[];
|
||||
secKey?: ArrayBuffer;
|
||||
iv?: Uint8Array;
|
||||
connectionType?: "extension" | "serial";
|
||||
localSettings?: AppSettings;
|
||||
key: string;
|
||||
value: {
|
||||
pubkey: string;
|
||||
readonly: boolean;
|
||||
relays?: string[];
|
||||
secKey?: ArrayBuffer;
|
||||
iv?: Uint8Array;
|
||||
connectionType?: "extension" | "serial" | "amber";
|
||||
localSettings?: AppSettings;
|
||||
};
|
||||
};
|
||||
replaceableEvents: SchemaV4["replaceableEvents"];
|
||||
dnsIdentifiers: SchemaV4["dnsIdentifiers"];
|
||||
relayInfo: SchemaV4["relayInfo"];
|
||||
relayScoreboardStats: SchemaV4["relayScoreboardStats"];
|
||||
userSearch: SchemaV4["userSearch"];
|
||||
misc: SchemaV4["misc"];
|
||||
}
|
||||
|
||||
export interface SchemaV6 {
|
||||
accounts: SchemaV5["accounts"];
|
||||
replaceableEvents: SchemaV5["replaceableEvents"];
|
||||
export interface SchemaV6 extends SchemaV5 {
|
||||
channelMetadata: {
|
||||
key: string;
|
||||
value: {
|
||||
@ -133,9 +118,11 @@ export interface SchemaV6 {
|
||||
event: NostrEvent;
|
||||
};
|
||||
};
|
||||
dnsIdentifiers: SchemaV5["dnsIdentifiers"];
|
||||
relayInfo: SchemaV5["relayInfo"];
|
||||
relayScoreboardStats: SchemaV5["relayScoreboardStats"];
|
||||
userSearch: SchemaV5["userSearch"];
|
||||
misc: SchemaV5["misc"];
|
||||
}
|
||||
|
||||
export interface SchemaV7 extends Omit<SchemaV6, "account"> {
|
||||
accounts: {
|
||||
key: string;
|
||||
value: Account;
|
||||
};
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import { PersistentSubject } from "../classes/subject";
|
||||
import accountService from "./account";
|
||||
import { createSimpleQueryMap } from "../helpers/nostr/filter";
|
||||
|
||||
export function getMessageRecipient(event: NostrEvent): string | undefined {
|
||||
function getMessageRecipient(event: NostrEvent): string | undefined {
|
||||
return event.tags.find(isPTag)?.[1];
|
||||
}
|
||||
|
||||
|
@ -35,3 +35,15 @@ relayPoolService.onRelayCreated.subscribe((relay) => {
|
||||
handleEventFromRelay(relay, event);
|
||||
});
|
||||
});
|
||||
|
||||
const eventRelaysService = {
|
||||
getEventRelays,
|
||||
handleEventFromRelay,
|
||||
};
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
//@ts-ignore
|
||||
window.eventRelaysService = eventRelaysService;
|
||||
}
|
||||
|
||||
export default eventRelaysService;
|
||||
|
224
src/services/nostr-connect.ts
Normal file
224
src/services/nostr-connect.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import { finishEvent, generatePrivateKey, getPublicKey, nip04, nip19 } from "nostr-tools";
|
||||
import dayjs from "dayjs";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import NostrMultiSubscription from "../classes/nostr-multi-subscription";
|
||||
import { getPubkeyFromDecodeResult, isHexKey } from "../helpers/nip19";
|
||||
import { createSimpleQueryMap } from "../helpers/nostr/filter";
|
||||
import { logger } from "../helpers/debug";
|
||||
import { DraftNostrEvent, NostrEvent, isPTag } from "../types/nostr-event";
|
||||
import createDefer, { Deferred } from "../classes/deferred";
|
||||
import { truncatedId } from "../helpers/nostr/events";
|
||||
import { NostrConnectAccount } from "./account";
|
||||
|
||||
export enum NostrConnectMethod {
|
||||
Connect = "connect",
|
||||
Disconnect = "disconnect",
|
||||
GetPublicKey = "get_pubic_key",
|
||||
SignEvent = "sign_event",
|
||||
Nip04Encrypt = "nip04_encrypt",
|
||||
Nip04Decrypt = "nip04_decrypt",
|
||||
}
|
||||
type RequestParams = {
|
||||
[NostrConnectMethod.Connect]: [string] | [string, string];
|
||||
[NostrConnectMethod.Disconnect]: [];
|
||||
[NostrConnectMethod.GetPublicKey]: [];
|
||||
[NostrConnectMethod.SignEvent]: [string];
|
||||
[NostrConnectMethod.Nip04Encrypt]: [string, string];
|
||||
[NostrConnectMethod.Nip04Decrypt]: [string, string];
|
||||
};
|
||||
type ResponseResults = {
|
||||
[NostrConnectMethod.Connect]: "ack";
|
||||
[NostrConnectMethod.Disconnect]: "ack";
|
||||
[NostrConnectMethod.GetPublicKey]: string;
|
||||
[NostrConnectMethod.SignEvent]: string;
|
||||
[NostrConnectMethod.Nip04Encrypt]: string;
|
||||
[NostrConnectMethod.Nip04Decrypt]: string;
|
||||
};
|
||||
export type NostrConnectRequest<N extends NostrConnectMethod> = { id: string; method: N; params: RequestParams[N] };
|
||||
export type NostrConnectResponse<N extends NostrConnectMethod> = {
|
||||
id: string;
|
||||
result: ResponseResults[N];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export class NostrConnectClient {
|
||||
sub: NostrMultiSubscription;
|
||||
log = logger.extend("NostrConnectClient");
|
||||
|
||||
isConnected = false;
|
||||
pubkey: string;
|
||||
relays: string[];
|
||||
|
||||
secretKey: string;
|
||||
publicKey: string;
|
||||
|
||||
supportedMethods: NostrConnectMethod[] | undefined;
|
||||
|
||||
constructor(pubkey: string, relays: string[], secretKey?: string) {
|
||||
this.sub = new NostrMultiSubscription(`${truncatedId(pubkey)}-nostr-connect`);
|
||||
this.pubkey = pubkey;
|
||||
this.relays = relays;
|
||||
|
||||
this.secretKey = secretKey || generatePrivateKey();
|
||||
this.publicKey = getPublicKey(this.secretKey);
|
||||
|
||||
this.sub.onEvent.subscribe(this.handleEvent, this);
|
||||
this.sub.setQueryMap(createSimpleQueryMap(this.relays, { kinds: [24133], "#p": [this.publicKey] }));
|
||||
}
|
||||
|
||||
open() {
|
||||
this.sub.open();
|
||||
}
|
||||
close() {
|
||||
this.sub.close();
|
||||
}
|
||||
|
||||
private requests = new Map<string, Deferred<any>>();
|
||||
async handleEvent(event: NostrEvent) {
|
||||
if (event.kind !== 24133) return;
|
||||
|
||||
const to = event.tags.find(isPTag)?.[1];
|
||||
if (!to) return;
|
||||
|
||||
try {
|
||||
const responseStr = await nip04.decrypt(this.secretKey, this.pubkey, event.content);
|
||||
const response = JSON.parse(responseStr);
|
||||
if (response.id) {
|
||||
const p = this.requests.get(response.id);
|
||||
if (!p) return;
|
||||
if (response.error) {
|
||||
this.log(`ERROR: Got error for ${response.id}`, response);
|
||||
p.reject(new Error(response.error));
|
||||
} else if (response.result) {
|
||||
this.log(response.id, response);
|
||||
p.resolve(response.result);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
private createEvent(content: string) {
|
||||
return finishEvent(
|
||||
{
|
||||
kind: 24133,
|
||||
created_at: dayjs().unix(),
|
||||
tags: [["p", this.pubkey]],
|
||||
content,
|
||||
},
|
||||
this.secretKey,
|
||||
);
|
||||
}
|
||||
private async makeRequest<T extends NostrConnectMethod>(
|
||||
method: T,
|
||||
params: RequestParams[T],
|
||||
): Promise<ResponseResults[T]> {
|
||||
const id = nanoid();
|
||||
const request: NostrConnectRequest<T> = { method, id, params };
|
||||
const encrypted = await nip04.encrypt(this.secretKey, this.pubkey, JSON.stringify(request));
|
||||
this.log(`Sending request ${id} (${method}) ${JSON.stringify(params)}`);
|
||||
this.sub.sendAll(this.createEvent(encrypted));
|
||||
|
||||
const p = createDefer<ResponseResults[T]>();
|
||||
this.requests.set(id, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
connect(token?: string) {
|
||||
this.open();
|
||||
try {
|
||||
const result = this.makeRequest(NostrConnectMethod.Connect, token ? [this.publicKey, token] : [this.publicKey]);
|
||||
this.isConnected = true;
|
||||
return result;
|
||||
} catch (e) {
|
||||
this.isConnected = false;
|
||||
this.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
ensureConnected() {
|
||||
if (!this.isConnected) return this.connect();
|
||||
}
|
||||
disconnect() {
|
||||
return this.makeRequest(NostrConnectMethod.Disconnect, []);
|
||||
}
|
||||
getPublicKey() {
|
||||
return this.makeRequest(NostrConnectMethod.GetPublicKey, []);
|
||||
}
|
||||
async signEvent(draft: DraftNostrEvent) {
|
||||
const eventString = await this.makeRequest(NostrConnectMethod.SignEvent, [JSON.stringify(draft)]);
|
||||
return JSON.parse(eventString) as NostrEvent;
|
||||
}
|
||||
nip04Encrypt(pubkey: string, plaintext: string) {
|
||||
return this.makeRequest(NostrConnectMethod.Nip04Encrypt, [pubkey, plaintext]);
|
||||
}
|
||||
async nip04Decrypt(pubkey: string, data: string) {
|
||||
const plaintext = await this.makeRequest(NostrConnectMethod.Nip04Decrypt, [pubkey, data]);
|
||||
if (plaintext.startsWith('["') && plaintext.endsWith('"]')) return JSON.parse(plaintext)[0] as string;
|
||||
else return plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
class NostrConnectService {
|
||||
log = logger.extend("NostrConnect");
|
||||
clients: NostrConnectClient[] = [];
|
||||
|
||||
getClient(pubkey: string) {
|
||||
return this.clients.find((client) => client.pubkey === pubkey);
|
||||
}
|
||||
saveClient(client: NostrConnectClient) {
|
||||
if (!this.clients.includes(client)) this.clients.push(client);
|
||||
}
|
||||
|
||||
createClient(pubkey: string, relays: string[], secretKey?: string) {
|
||||
if (this.getClient(pubkey)) throw new Error("A client for that pubkey already exists");
|
||||
|
||||
const client = new NostrConnectClient(pubkey, relays, secretKey);
|
||||
client.log = this.log.extend(pubkey);
|
||||
|
||||
this.log(`Created client for ${pubkey} using ${relays.join(", ")}`);
|
||||
|
||||
return client;
|
||||
}
|
||||
fromBunkerURI(uri: string) {
|
||||
const url = new URL(uri);
|
||||
|
||||
const pubkey = url.pathname.replace(/^\/\//, "");
|
||||
if (!isHexKey(pubkey)) throw new Error("Invalid connection URI");
|
||||
const relays = url.searchParams.getAll("relay");
|
||||
if (relays.length === 0) throw new Error("Missing relays");
|
||||
|
||||
return this.getClient(pubkey) || this.createClient(pubkey, relays);
|
||||
}
|
||||
fromNsecBunkerToken(token: string) {
|
||||
const [npub, hexToken] = token.split("#");
|
||||
const decoded = nip19.decode(npub);
|
||||
const pubkey = getPubkeyFromDecodeResult(decoded);
|
||||
if (!pubkey) throw new Error("Cant find pubkey");
|
||||
const relays = ["wss://relay.nsecbunker.com", "wss://nos.lol"];
|
||||
if (relays.length === 0) throw new Error("Missing relays");
|
||||
|
||||
const client = this.getClient(pubkey) || this.createClient(pubkey, relays);
|
||||
return client;
|
||||
}
|
||||
fromAccount(account: NostrConnectAccount) {
|
||||
const existingClient = this.getClient(account.pubkey);
|
||||
if (existingClient) return existingClient;
|
||||
|
||||
const client = this.createClient(account.pubkey, account.signerRelays, account.clientSecretKey);
|
||||
|
||||
// presume the client has already connected
|
||||
this.saveClient(client);
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
const nostrConnectService = new NostrConnectService();
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.nostrConnectService = nostrConnectService;
|
||||
}
|
||||
|
||||
export default nostrConnectService;
|
@ -182,7 +182,7 @@ function parseResponse(value: string) {
|
||||
export const utf8Decoder = new TextDecoder("utf-8");
|
||||
export const utf8Encoder = new TextEncoder();
|
||||
|
||||
export async function encrypt(pubkey: string, text: string) {
|
||||
export async function nip04Encrypt(pubkey: string, text: string) {
|
||||
const sharedSecretStr = await callMethodOnDevice(METHOD_SHARED_SECRET, [xOnlyToXY(pubkey)]);
|
||||
const sharedSecret = hexToBytes(sharedSecretStr);
|
||||
|
||||
@ -196,7 +196,7 @@ export async function encrypt(pubkey: string, text: string) {
|
||||
return `${ctb64}?iv=${ivb64}`;
|
||||
}
|
||||
|
||||
export async function decrypt(pubkey: string, data: string) {
|
||||
export async function nip04Decrypt(pubkey: string, data: string) {
|
||||
let [ctb64, ivb64] = data.split("?iv=");
|
||||
|
||||
const sharedSecretStr = await callMethodOnDevice(METHOD_SHARED_SECRET, [xOnlyToXY(pubkey)]);
|
||||
@ -235,8 +235,8 @@ const serialPortService = {
|
||||
supported: !!navigator.serial,
|
||||
signEvent,
|
||||
getPublicKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
nip04Encrypt,
|
||||
nip04Decrypt,
|
||||
callMethodOnDevice,
|
||||
connectToDevice,
|
||||
};
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { nip04, getPublicKey, finishEvent } from "nostr-tools";
|
||||
|
||||
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
|
||||
import { Account } from "./account";
|
||||
import db from "./db";
|
||||
import serialPortService from "./serial-port";
|
||||
import amberSignerService from "./amber-signer";
|
||||
import nostrConnectService from "./nostr-connect";
|
||||
|
||||
const decryptedKeys = new Map<string, string>();
|
||||
|
||||
@ -60,7 +63,7 @@ class SigningService {
|
||||
}
|
||||
|
||||
async decryptSecKey(account: Account) {
|
||||
if (!account.secKey) throw new Error("Account dose not have a secret key");
|
||||
if (account.type !== "local") throw new Error("Account dose not have a secret key");
|
||||
|
||||
const cache = decryptedKeys.get(account.pubkey);
|
||||
if (cache) return cache;
|
||||
@ -79,64 +82,106 @@ class SigningService {
|
||||
}
|
||||
|
||||
async requestSignature(draft: DraftNostrEvent, account: Account) {
|
||||
const checkSig = (signed: NostrEvent) => {
|
||||
if (signed.pubkey !== account.pubkey) throw new Error("Signed with the wrong pubkey");
|
||||
};
|
||||
|
||||
if (account.readonly) throw new Error("Cant sign in readonly mode");
|
||||
if (account.connectionType) {
|
||||
if (account.connectionType === "extension") {
|
||||
|
||||
switch (account.type) {
|
||||
case "local": {
|
||||
const secKey = await this.decryptSecKey(account);
|
||||
const tmpDraft = { ...draft, pubkey: getPublicKey(secKey) };
|
||||
const event = finishEvent(tmpDraft, secKey) as NostrEvent;
|
||||
return event;
|
||||
}
|
||||
case "extension":
|
||||
if (window.nostr) {
|
||||
const signed = await window.nostr.signEvent(draft);
|
||||
if (signed.pubkey !== account.pubkey) throw new Error("Signed with the wrong pubkey!");
|
||||
checkSig(signed);
|
||||
return signed;
|
||||
} else throw new Error("Missing nostr extension");
|
||||
} else if (account.connectionType === "serial") {
|
||||
case "serial":
|
||||
if (serialPortService.supported) {
|
||||
const signed = await serialPortService.signEvent(draft);
|
||||
if (signed.pubkey !== account.pubkey) throw new Error("Signed with the wrong pubkey!");
|
||||
checkSig(signed);
|
||||
return signed;
|
||||
} else throw new Error("Serial devices are not supported");
|
||||
} else throw new Error("Unknown connection type " + account.connectionType);
|
||||
} else if (account?.secKey) {
|
||||
const secKey = await this.decryptSecKey(account);
|
||||
const tmpDraft = { ...draft, pubkey: getPublicKey(secKey) };
|
||||
const event = finishEvent(tmpDraft, secKey) as NostrEvent;
|
||||
|
||||
return event;
|
||||
} else throw new Error("No signing method");
|
||||
case "amber":
|
||||
if (amberSignerService.supported) {
|
||||
const signed = await amberSignerService.signEvent({ ...draft, pubkey: account.pubkey });
|
||||
checkSig(signed);
|
||||
return signed;
|
||||
} else throw new Error("Cant use Amber on non-Android device");
|
||||
case "nostr-connect":
|
||||
const client = nostrConnectService.fromAccount(account);
|
||||
await client.ensureConnected();
|
||||
const signed = await client.signEvent({ ...draft, pubkey: account.pubkey });
|
||||
checkSig(signed);
|
||||
return signed;
|
||||
default:
|
||||
throw new Error("Unknown account type");
|
||||
}
|
||||
}
|
||||
|
||||
async requestDecrypt(data: string, pubkey: string, account: Account) {
|
||||
if (account.readonly) throw new Error("Cant decrypt in readonly mode");
|
||||
if (account.connectionType) {
|
||||
if (account.connectionType === "extension") {
|
||||
|
||||
switch (account.type) {
|
||||
case "local":
|
||||
const secKey = await this.decryptSecKey(account);
|
||||
return await nip04.decrypt(secKey, pubkey, data);
|
||||
case "extension":
|
||||
if (window.nostr) {
|
||||
if (window.nostr.nip04) {
|
||||
return await window.nostr.nip04.decrypt(pubkey, data);
|
||||
} else throw new Error("Extension dose not support decryption");
|
||||
} else throw new Error("Missing nostr extension");
|
||||
} else if (account.connectionType === "serial") {
|
||||
return await serialPortService.decrypt(pubkey, data);
|
||||
} else throw new Error("Unknown connection type " + account.connectionType);
|
||||
} else if (account?.secKey) {
|
||||
const secKey = await this.decryptSecKey(account);
|
||||
return await nip04.decrypt(secKey, pubkey, data);
|
||||
} else throw new Error("No decryption method");
|
||||
case "serial":
|
||||
if (serialPortService.supported) {
|
||||
return await serialPortService.nip04Decrypt(pubkey, data);
|
||||
} else throw new Error("Serial devices are not supported");
|
||||
case "amber":
|
||||
if (amberSignerService.supported) {
|
||||
return await amberSignerService.nip04Decrypt(pubkey, data);
|
||||
} else throw new Error("Cant use Amber on non-Android device");
|
||||
case "nostr-connect":
|
||||
const client = nostrConnectService.fromAccount(account);
|
||||
await client.ensureConnected();
|
||||
return await client.nip04Decrypt(pubkey, data);
|
||||
default:
|
||||
throw new Error("Unknown account type");
|
||||
}
|
||||
}
|
||||
|
||||
async requestEncrypt(text: string, pubkey: string, account: Account) {
|
||||
if (account.readonly) throw new Error("Cant encrypt in readonly mode");
|
||||
if (account.connectionType) {
|
||||
if (account.connectionType === "extension") {
|
||||
|
||||
switch (account.type) {
|
||||
case "local":
|
||||
const secKey = await this.decryptSecKey(account);
|
||||
return await nip04.encrypt(secKey, pubkey, text);
|
||||
case "extension":
|
||||
if (window.nostr) {
|
||||
if (window.nostr.nip04) {
|
||||
return await window.nostr.nip04.encrypt(pubkey, text);
|
||||
} else throw new Error("Extension dose not support encryption");
|
||||
} else throw new Error("Missing nostr extension");
|
||||
} else if (account.connectionType === "serial") {
|
||||
return await serialPortService.encrypt(pubkey, text);
|
||||
} else throw new Error("Unknown connection type " + account.connectionType);
|
||||
} else if (account?.secKey) {
|
||||
const secKey = await this.decryptSecKey(account);
|
||||
return await nip04.encrypt(secKey, pubkey, text);
|
||||
} else throw new Error("No encryption method");
|
||||
case "serial":
|
||||
if (serialPortService.supported) {
|
||||
return await serialPortService.nip04Encrypt(pubkey, text);
|
||||
} else throw new Error("Serial devices are not supported");
|
||||
case "amber":
|
||||
if (amberSignerService.supported) {
|
||||
return await amberSignerService.nip04Encrypt(pubkey, text);
|
||||
} else throw new Error("Cant use Amber on non-Android device");
|
||||
case "nostr-connect":
|
||||
const client = nostrConnectService.fromAccount(account);
|
||||
await client.ensureConnected();
|
||||
return await client.nip04Encrypt(pubkey, text);
|
||||
default:
|
||||
throw new Error("Unknown connection type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,7 @@ export type CountResponse = {
|
||||
approximate?: boolean;
|
||||
};
|
||||
|
||||
export type DraftNostrEvent = Omit<NostrEvent, "pubkey" | "id" | "sig">;
|
||||
export type DraftNostrEvent = Omit<NostrEvent, "pubkey" | "id" | "sig"> & { pubkey?: string; id?: string };
|
||||
|
||||
export type RawIncomingEvent = ["EVENT", string, NostrEvent];
|
||||
export type RawIncomingNotice = ["NOTICE", string];
|
||||
|
@ -1,46 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card, Flex, IconButton, Textarea, useToast } from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Button, ButtonGroup, Card, Flex, IconButton, useDisclosure } from "@chakra-ui/react";
|
||||
import { Kind, nip19 } from "nostr-tools";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { UNSAFE_DataRouterContext, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { ChevronLeftIcon } from "../../components/icons";
|
||||
import { ChevronLeftIcon, ThreadIcon } from "../../components/icons";
|
||||
import UserAvatar from "../../components/user-avatar";
|
||||
import UserLink from "../../components/user-link";
|
||||
import { isHexKey } from "../../helpers/nip19";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
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 MessageBlock from "./components/message-block";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
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 { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
|
||||
import { useDecryptionContext } from "../../providers/dycryption-provider";
|
||||
import { useUserRelays } from "../../hooks/use-user-relays";
|
||||
import { RelayMode } from "../../classes/relay";
|
||||
import { unique } from "../../helpers/array";
|
||||
import SendMessageForm from "./components/send-message-form";
|
||||
import { groupMessages } from "../../helpers/nostr/dms";
|
||||
import ThreadDrawer from "./components/thread-drawer";
|
||||
import ThreadsProvider from "./components/thread-provider";
|
||||
import { useRouterMarker } from "../../providers/drawer-sub-view-provider";
|
||||
import TimelineLoader from "../../classes/timeline-loader";
|
||||
|
||||
/** This is broken out from DirectMessageChatPage for performance reasons. Don't use outside of file */
|
||||
const ChatLog = memo(({ timeline }: { timeline: TimelineLoader }) => {
|
||||
const messages = useSubject(timeline.timeline);
|
||||
const filteredMessages = useMemo(
|
||||
() => messages.filter((e) => !e.tags.some((t) => t[0] === "e" && t[3] === "root")),
|
||||
[messages.length],
|
||||
);
|
||||
const grouped = useMemo(() => groupMessages(filteredMessages), [filteredMessages]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{grouped.map((group) => (
|
||||
<MessageBlock key={group.id} messages={group.events} reverse />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const account = useCurrentAccount()!;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { getOrCreateContainer, addToQueue, startQueue } = useDecryptionContext();
|
||||
const { requestEncrypt, requestSignature } = useSigningContext();
|
||||
const [content, setContent] = useState<string>("");
|
||||
|
||||
const { router } = useContext(UNSAFE_DataRouterContext)!;
|
||||
const marker = useRouterMarker(router);
|
||||
useEffect(() => {
|
||||
if (location.state?.thread && marker.index.current === null) {
|
||||
// the drawer just open, set the marker
|
||||
marker.set(1);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const openDrawerList = useCallback(() => {
|
||||
marker.set(0);
|
||||
navigate(".", { state: { thread: "list" } });
|
||||
}, [marker, navigate]);
|
||||
|
||||
const closeDrawer = useCallback(() => {
|
||||
if (marker.index.current !== null && marker.index.current > 0) {
|
||||
navigate(-marker.index.current);
|
||||
} else navigate(".", { state: { thread: undefined } });
|
||||
marker.reset();
|
||||
}, [marker, navigate]);
|
||||
|
||||
const myInbox = useReadRelayUrls();
|
||||
const usersInbox = useUserRelays(pubkey)
|
||||
.filter((r) => r.mode & RelayMode.READ)
|
||||
.map((r) => r.url);
|
||||
|
||||
const timeline = useTimelineLoader(`${pubkey}-${account.pubkey}-messages`, myInbox, [
|
||||
{
|
||||
kinds: [Kind.EncryptedDirectMessage],
|
||||
@ -54,31 +84,9 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
},
|
||||
]);
|
||||
|
||||
const messages = useSubject(timeline.timeline);
|
||||
|
||||
const sendMessage = async () => {
|
||||
try {
|
||||
if (!content) return;
|
||||
const encrypted = await requestEncrypt(content, pubkey);
|
||||
const event: DraftNostrEvent = {
|
||||
kind: Kind.EncryptedDirectMessage,
|
||||
content: encrypted,
|
||||
tags: [["p", pubkey]],
|
||||
created_at: dayjs().unix(),
|
||||
};
|
||||
const signed = await requestSignature(event);
|
||||
const writeRelays = clientRelaysService.getWriteUrls();
|
||||
const relays = unique([...writeRelays, ...usersInbox]);
|
||||
new NostrPublishAction("Send DM", relays, signed);
|
||||
setContent("");
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ status: "error", description: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const decryptAll = async () => {
|
||||
const promises = messages
|
||||
const promises = timeline.timeline.value
|
||||
.map((message) => {
|
||||
const container = getOrCreateContainer(pubkey, message.content);
|
||||
if (container.plaintext.value === undefined) return addToQueue(container);
|
||||
@ -94,7 +102,7 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<LightboxProvider>
|
||||
<ThreadsProvider timeline={timeline}>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<Card size="sm" flexShrink={0} p="2" flexDirection="row">
|
||||
<Flex gap="2" alignItems="center">
|
||||
@ -109,24 +117,28 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
<UserLink pubkey={pubkey} fontWeight="bold" />
|
||||
<UserDnsIdentityIcon pubkey={pubkey} onlyIcon />
|
||||
</Flex>
|
||||
<Button onClick={decryptAll} isLoading={loading} ml="auto">
|
||||
Decrypt All
|
||||
</Button>
|
||||
<ButtonGroup ml="auto">
|
||||
<Button onClick={decryptAll} isLoading={loading}>
|
||||
Decrypt All
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label="Threads"
|
||||
title="Threads"
|
||||
icon={<ThreadIcon boxSize={5} />}
|
||||
onClick={openDrawerList}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</Card>
|
||||
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column-reverse" gap="2" py="4" px="2">
|
||||
{[...messages].map((event) => (
|
||||
<Message key={event.id} event={event} />
|
||||
))}
|
||||
<ChatLog timeline={timeline} />
|
||||
<TimelineActionAndStatus timeline={timeline} />
|
||||
</Flex>
|
||||
<Flex shrink={0}>
|
||||
<Textarea value={content} onChange={(e) => setContent(e.target.value)} />
|
||||
<Button isDisabled={!content} onClick={sendMessage}>
|
||||
Send
|
||||
</Button>
|
||||
</Flex>
|
||||
<SendMessageForm flexShrink={0} pubkey={pubkey} />
|
||||
{location.state?.thread && (
|
||||
<ThreadDrawer isOpen onClose={closeDrawer} threadId={location.state.thread} pubkey={pubkey} />
|
||||
)}
|
||||
</IntersectionObserverProvider>
|
||||
</LightboxProvider>
|
||||
</ThreadsProvider>
|
||||
);
|
||||
}
|
||||
|
@ -1,20 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription, AlertIcon, Button } from "@chakra-ui/react";
|
||||
import { Alert, AlertDescription, AlertIcon, Button, ButtonProps } from "@chakra-ui/react";
|
||||
|
||||
import { UnlockIcon } from "../../components/icons";
|
||||
import { useDecryptionContainer } from "../../providers/dycryption-provider";
|
||||
import { UnlockIcon } from "../../../components/icons";
|
||||
import { useDecryptionContainer } from "../../../providers/dycryption-provider";
|
||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||
import { getDMRecipient, getDMSender } from "../../../helpers/nostr/dms";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
|
||||
export default function DecryptPlaceholder({
|
||||
children,
|
||||
data,
|
||||
pubkey,
|
||||
message,
|
||||
...props
|
||||
}: {
|
||||
children: (decrypted: string) => JSX.Element;
|
||||
data: string;
|
||||
pubkey: string;
|
||||
}): JSX.Element {
|
||||
message: NostrEvent;
|
||||
} & Omit<ButtonProps, "children">): JSX.Element {
|
||||
const account = useCurrentAccount();
|
||||
const isOwn = account?.pubkey === message.pubkey;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { requestDecrypt, plaintext, error } = useDecryptionContainer(pubkey, data);
|
||||
const { requestDecrypt, plaintext, error } = useDecryptionContainer(
|
||||
isOwn ? getDMRecipient(message) : getDMSender(message),
|
||||
message.content,
|
||||
);
|
||||
|
||||
const decrypt = async () => {
|
||||
setLoading(true);
|
||||
@ -39,7 +46,7 @@ export default function DecryptPlaceholder({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button onClick={decrypt} isLoading={loading} leftIcon={<UnlockIcon />} width="full">
|
||||
<Button onClick={decrypt} isLoading={loading} leftIcon={<UnlockIcon />} width="full" {...props}>
|
||||
Decrypt
|
||||
</Button>
|
||||
);
|
66
src/views/dms/components/message-block.tsx
Normal file
66
src/views/dms/components/message-block.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { memo } from "react";
|
||||
import { CardProps, Flex } from "@chakra-ui/react";
|
||||
|
||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import UserAvatar from "../../../components/user-avatar";
|
||||
import MessageBubble, { MessageBubbleProps } from "./message-bubble";
|
||||
import { useThreadsContext } from "./thread-provider";
|
||||
import ThreadButton from "./thread-button";
|
||||
|
||||
function MessageBubbleWithThread({ message, showThreadButton = true, ...props }: MessageBubbleProps) {
|
||||
const { threads } = useThreadsContext();
|
||||
const thread = threads[message.id];
|
||||
|
||||
return (
|
||||
<>
|
||||
{showThreadButton && !!thread && <ThreadButton thread={thread} />}
|
||||
<MessageBubble message={message} showThreadButton={showThreadButton && !thread} {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBlock({
|
||||
messages,
|
||||
showThreadButton = true,
|
||||
reverse = false,
|
||||
}: { messages: NostrEvent[]; showThreadButton?: boolean; reverse?: boolean } & Omit<CardProps, "children">) {
|
||||
const lastEvent = messages[messages.length - 1];
|
||||
const account = useCurrentAccount()!;
|
||||
const isOwn = account.pubkey === lastEvent.pubkey;
|
||||
|
||||
const avatar = <UserAvatar pubkey={lastEvent.pubkey} size="sm" my="1" />;
|
||||
|
||||
const MessageBubbleComponent = showThreadButton ? MessageBubbleWithThread : MessageBubble;
|
||||
|
||||
return (
|
||||
<Flex direction="row" gap="2" alignItems="flex-end">
|
||||
{!isOwn && avatar}
|
||||
<Flex
|
||||
direction={reverse ? "column-reverse" : "column"}
|
||||
gap="1"
|
||||
ml={isOwn ? "auto" : 0}
|
||||
mr={isOwn ? 0 : "auto"}
|
||||
maxW="2xl"
|
||||
alignItems={isOwn ? "flex-end" : "flex-start"}
|
||||
overflowX="hidden"
|
||||
overflowY="visible"
|
||||
>
|
||||
{messages.map((message, i, arr) => (
|
||||
<MessageBubbleComponent
|
||||
key={message.id}
|
||||
message={message}
|
||||
showHeader={reverse ? i === arr.length - 1 : i === 0}
|
||||
minW={{ base: 0, sm: "sm", md: "md" }}
|
||||
maxW="full"
|
||||
overflow="hidden"
|
||||
showThreadButton={showThreadButton}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
{isOwn && avatar}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MessageBlock);
|
148
src/views/dms/components/message-bubble.tsx
Normal file
148
src/views/dms/components/message-bubble.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
BoxProps,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardProps,
|
||||
IconButton,
|
||||
IconButtonProps,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import DecryptPlaceholder from "./decrypt-placeholder";
|
||||
import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
|
||||
import {
|
||||
embedCashuTokens,
|
||||
embedNostrLinks,
|
||||
renderGenericUrl,
|
||||
renderImageUrl,
|
||||
renderVideoUrl,
|
||||
} from "../../../components/embed-types";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
|
||||
import { getEventUID } from "../../../helpers/nostr/events";
|
||||
import Timestamp from "../../../components/timestamp";
|
||||
import NoteZapButton from "../../../components/note/note-zap-button";
|
||||
import UserLink from "../../../components/user-link";
|
||||
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
|
||||
import useEventReactions from "../../../hooks/use-event-reactions";
|
||||
import AddReactionButton from "../../../components/note/components/add-reaction-button";
|
||||
import { TrustProvider } from "../../../providers/trust";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ThreadIcon } from "../../../components/icons";
|
||||
import EventReactionButtons from "../../../components/event-reactions/event-reactions";
|
||||
import { LightboxProvider } from "../../../components/lightbox-provider";
|
||||
|
||||
export function IconThreadButton({
|
||||
event,
|
||||
...props
|
||||
}: { event: NostrEvent } & Omit<IconButtonProps, "aria-label" | "icon">) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const onClick = () => {
|
||||
navigate(`.`, { state: { ...location.state, thread: event.id } });
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={<ThreadIcon />}
|
||||
onClick={onClick}
|
||||
aria-label="Reply in thread"
|
||||
title="Reply in thread"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageContent({ event, text, children, ...props }: { event: NostrEvent; text: string } & BoxProps) {
|
||||
let content: EmbedableContent = [text];
|
||||
|
||||
content = embedNostrLinks(content);
|
||||
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
|
||||
|
||||
// cashu
|
||||
content = embedCashuTokens(content);
|
||||
|
||||
return (
|
||||
<TrustProvider event={event}>
|
||||
<LightboxProvider>
|
||||
<Box whiteSpace="pre-wrap" {...props}>
|
||||
{content}
|
||||
{children}
|
||||
</Box>
|
||||
</LightboxProvider>
|
||||
</TrustProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageBubbleProps = { message: NostrEvent; showHeader?: boolean; showThreadButton?: boolean } & Omit<
|
||||
CardProps,
|
||||
"children"
|
||||
>;
|
||||
|
||||
export default function MessageBubble({
|
||||
message,
|
||||
showHeader = true,
|
||||
showThreadButton = true,
|
||||
...props
|
||||
}: MessageBubbleProps) {
|
||||
const reactions = useEventReactions(message.id) ?? [];
|
||||
const hasReactions = reactions.length > 0;
|
||||
|
||||
let actionPosition = showHeader ? "header" : "inline";
|
||||
if (hasReactions && actionPosition === "inline") actionPosition = "footer";
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, getEventUID(message));
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<NoteZapButton event={message} />
|
||||
<AddReactionButton event={message} portal />
|
||||
{showThreadButton && <IconThreadButton event={message} />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card {...props} borderRadius="lg" ref={ref}>
|
||||
{showHeader && (
|
||||
<CardHeader px="2" pt="2" pb="0" gap="2" display="flex" alignItems="center">
|
||||
<UserLink pubkey={message.pubkey} fontWeight="bold" />
|
||||
<UserDnsIdentityIcon pubkey={message.pubkey} onlyIcon />
|
||||
{actionPosition === "header" && (
|
||||
<ButtonGroup size="xs" variant="ghost" ml="auto">
|
||||
{actions}
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardBody px="2" py="2">
|
||||
<DecryptPlaceholder message={message} variant="link" py="4" px="6rem">
|
||||
{(plaintext) => (
|
||||
<MessageContent event={message} text={plaintext} display="inline">
|
||||
{!hasReactions && (
|
||||
<ButtonGroup size="xs" variant="ghost" float="right">
|
||||
{actionPosition === "inline" && actions}
|
||||
<Timestamp timestamp={message.created_at} ml="2" />
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</MessageContent>
|
||||
)}
|
||||
</DecryptPlaceholder>
|
||||
</CardBody>
|
||||
{hasReactions && (
|
||||
<CardFooter alignItems="center" display="flex" gap="2" px="2" pt="0" pb="2">
|
||||
<ButtonGroup size="xs" variant="ghost">
|
||||
{actionPosition === "footer" ? actions : <AddReactionButton event={message} portal />}
|
||||
<EventReactionButtons event={message} />
|
||||
</ButtonGroup>
|
||||
<Timestamp ml="auto" timestamp={message.created_at} />
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
101
src/views/dms/components/send-message-form.tsx
Normal file
101
src/views/dms/components/send-message-form.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import dayjs from "dayjs";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
import { Button, Flex, FlexProps, Heading, useToast } from "@chakra-ui/react";
|
||||
import { useSigningContext } from "../../../providers/signing-provider";
|
||||
import MagicTextArea, { RefType } from "../../../components/magic-textarea";
|
||||
import { useTextAreaUploadFileWithForm } from "../../../hooks/use-textarea-upload-file";
|
||||
import clientRelaysService from "../../../services/client-relays";
|
||||
import { unique } from "../../../helpers/array";
|
||||
import { DraftNostrEvent } from "../../../types/nostr-event";
|
||||
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||
import { useUserRelays } from "../../../hooks/use-user-relays";
|
||||
import { RelayMode } from "../../../classes/relay";
|
||||
import { useDecryptionContext } from "../../../providers/dycryption-provider";
|
||||
|
||||
export default function SendMessageForm({
|
||||
pubkey,
|
||||
rootId,
|
||||
...props
|
||||
}: { pubkey: string; rootId?: string } & Omit<FlexProps, "children">) {
|
||||
const toast = useToast();
|
||||
const { requestEncrypt, requestSignature } = useSigningContext();
|
||||
const { getOrCreateContainer } = useDecryptionContext();
|
||||
|
||||
const [loadingMessage, setLoadingMessage] = useState("");
|
||||
const { getValues, setValue, watch, handleSubmit, formState, reset } = useForm({
|
||||
defaultValues: {
|
||||
content: "",
|
||||
},
|
||||
mode: "all",
|
||||
});
|
||||
watch("content");
|
||||
|
||||
const textAreaRef = useRef<RefType | null>(null);
|
||||
const { onPaste } = useTextAreaUploadFileWithForm(textAreaRef, getValues, setValue);
|
||||
|
||||
const usersInbox = useUserRelays(pubkey)
|
||||
.filter((r) => r.mode & RelayMode.READ)
|
||||
.map((r) => r.url);
|
||||
const sendMessage = handleSubmit(async (values) => {
|
||||
try {
|
||||
if (!values.content) return;
|
||||
setLoadingMessage("Encrypting...");
|
||||
const encrypted = await requestEncrypt(values.content, pubkey);
|
||||
|
||||
const event: DraftNostrEvent = {
|
||||
kind: Kind.EncryptedDirectMessage,
|
||||
content: encrypted,
|
||||
tags: [["p", pubkey]],
|
||||
created_at: dayjs().unix(),
|
||||
};
|
||||
|
||||
if (rootId) {
|
||||
event.tags.push(["e", rootId, "", "root"]);
|
||||
}
|
||||
|
||||
setLoadingMessage("Signing...");
|
||||
const signed = await requestSignature(event);
|
||||
const writeRelays = clientRelaysService.getWriteUrls();
|
||||
const relays = unique([...writeRelays, ...usersInbox]);
|
||||
new NostrPublishAction("Send DM", relays, signed);
|
||||
reset();
|
||||
|
||||
// add plaintext to decryption context
|
||||
getOrCreateContainer(pubkey, encrypted).plaintext.next(values.content);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ status: "error", description: e.message });
|
||||
}
|
||||
setLoadingMessage("");
|
||||
});
|
||||
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
return (
|
||||
<Flex as="form" gap="2" onSubmit={sendMessage} ref={formRef} {...props}>
|
||||
{loadingMessage ? (
|
||||
<Heading size="md" mx="auto" my="4">
|
||||
{loadingMessage}
|
||||
</Heading>
|
||||
) : (
|
||||
<>
|
||||
<MagicTextArea
|
||||
mb="2"
|
||||
value={getValues().content}
|
||||
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
|
||||
rows={2}
|
||||
isRequired
|
||||
instanceRef={(inst) => (textAreaRef.current = inst)}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={(e) => {
|
||||
if (e.ctrlKey && e.key === "Enter" && formRef.current) formRef.current.requestSubmit();
|
||||
}}
|
||||
/>
|
||||
<Button type="submit">Send</Button>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
29
src/views/dms/components/thread-button.tsx
Normal file
29
src/views/dms/components/thread-button.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Button, IconButton } from "@chakra-ui/react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import UserAvatar from "../../../components/user-avatar";
|
||||
import { Thread } from "./thread-provider";
|
||||
import { ChevronRightIcon, ThreadIcon } from "../../../components/icons";
|
||||
import { IconButtonProps } from "yet-another-react-lightbox";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
|
||||
export default function ThreadButton({ thread }: { thread: Thread }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const onClick = () => {
|
||||
navigate(`.`, { state: { ...location.state, thread: thread.rootId } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<UserAvatar pubkey={thread.messages[thread.messages.length - 1].pubkey} size="xs" />}
|
||||
rightIcon={<ChevronRightIcon />}
|
||||
onClick={onClick}
|
||||
>
|
||||
{thread.messages.length} replies
|
||||
</Button>
|
||||
);
|
||||
}
|
151
src/views/dms/components/thread-drawer.tsx
Normal file
151
src/views/dms/components/thread-drawer.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerProps,
|
||||
Flex,
|
||||
Spinner,
|
||||
Text,
|
||||
TextProps,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import UserAvatar from "../../../components/user-avatar";
|
||||
import UserLink from "../../../components/user-link";
|
||||
import DecryptPlaceholder from "./decrypt-placeholder";
|
||||
import Timestamp from "../../../components/timestamp";
|
||||
import { Thread, useThreadsContext } from "./thread-provider";
|
||||
import ThreadButton from "./thread-button";
|
||||
import MessageBlock from "./message-block";
|
||||
import SendMessageForm from "./send-message-form";
|
||||
import { groupMessages } from "../../../helpers/nostr/dms";
|
||||
import { useDecryptionContext } from "../../../providers/dycryption-provider";
|
||||
|
||||
function MessagePreview({ message, ...props }: { message: NostrEvent } & Omit<TextProps, "children">) {
|
||||
return (
|
||||
<DecryptPlaceholder message={message} variant="link" py="4" px="6rem" zIndex={1}>
|
||||
{(plaintext) => <Text isTruncated>{plaintext}</Text>}
|
||||
</DecryptPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadCard({ thread }: { thread: Thread }) {
|
||||
const latestMessage = thread.messages[thread.messages.length - 1];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{thread.root && (
|
||||
<CardHeader px="2" pt="2" pb="1" gap="2" display="flex">
|
||||
<UserAvatar pubkey={thread.root.pubkey} size="xs" />
|
||||
<UserLink fontWeight="bold" pubkey={thread.root.pubkey} />
|
||||
<Timestamp timestamp={latestMessage.created_at} ml="auto" />
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardBody px="2" py="1">
|
||||
{thread.root ? <MessagePreview message={thread.root} /> : <Spinner />}
|
||||
</CardBody>
|
||||
<CardFooter px="2" pb="2" pt="0">
|
||||
<ThreadButton thread={thread} />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ListThreads() {
|
||||
const { threads } = useThreadsContext();
|
||||
|
||||
const latestThreads = Object.values(threads).sort(
|
||||
(a, b) => b.messages[b.messages.length - 1].created_at - a.messages[a.messages.length - 1].created_at,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{latestThreads.map((thread) => (
|
||||
<ThreadCard key={thread.rootId} thread={thread} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadMessages({ thread, pubkey }: { thread: Thread; pubkey: string }) {
|
||||
const grouped = groupMessages(thread.messages, 5, true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column" gap="2">
|
||||
{thread.root && <MessageBlock messages={[thread.root]} showThreadButton={false} />}
|
||||
{grouped.map((group) => (
|
||||
<MessageBlock key={group.id} messages={group.events} showThreadButton={false} />
|
||||
))}
|
||||
</Flex>
|
||||
<SendMessageForm flexShrink={0} pubkey={pubkey} rootId={thread.rootId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ThreadDrawer({
|
||||
threadId,
|
||||
pubkey,
|
||||
...props
|
||||
}: Omit<DrawerProps, "children"> & { threadId: string; pubkey: string }) {
|
||||
const { threads, getRoot } = useThreadsContext();
|
||||
const { startQueue, getOrCreateContainer, addToQueue } = useDecryptionContext();
|
||||
|
||||
const thread = threads[threadId];
|
||||
const [loading, setLoading] = useState(false);
|
||||
const decryptAll = async () => {
|
||||
if (!thread) return <Spinner />;
|
||||
|
||||
const promises = thread.messages
|
||||
.map((message) => {
|
||||
const container = getOrCreateContainer(pubkey, message.content);
|
||||
if (container.plaintext.value === undefined) return addToQueue(container);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (thread.root) {
|
||||
const rootContainer = getOrCreateContainer(pubkey, thread.root.content);
|
||||
if (rootContainer.plaintext.value === undefined) addToQueue(rootContainer);
|
||||
}
|
||||
|
||||
startQueue();
|
||||
|
||||
setLoading(true);
|
||||
Promise.all(promises).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (threadId === "list") return <ListThreads />;
|
||||
if (!thread) {
|
||||
return <ThreadMessages thread={{ rootId: threadId, messages: [], root: getRoot(threadId) }} pubkey={pubkey} />;
|
||||
} else return <ThreadMessages thread={thread} pubkey={pubkey} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer placement="right" size="lg" {...props}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent bgColor="var(--chakra-colors-chakra-body-bg)">
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader p="2" display="flex" gap="4">
|
||||
<Text>Threads</Text>
|
||||
<Button size="sm" onClick={decryptAll} isLoading={loading}>
|
||||
Decrypt All
|
||||
</Button>
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody px="2" pt="0" pb="2" gap="2" display="flex" flexDirection="column">
|
||||
{renderContent()}
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
58
src/views/dms/components/thread-provider.tsx
Normal file
58
src/views/dms/components/thread-provider.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
import TimelineLoader from "../../../classes/timeline-loader";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import useSubject from "../../../hooks/use-subject";
|
||||
|
||||
export type Thread = {
|
||||
root?: NostrEvent;
|
||||
rootId: string;
|
||||
messages: NostrEvent[];
|
||||
};
|
||||
type ThreadsContextType = {
|
||||
threads: Record<string, Thread>;
|
||||
getRoot: (id: string) => NostrEvent | undefined;
|
||||
};
|
||||
const ThreadsContext = createContext<ThreadsContextType>({
|
||||
threads: {},
|
||||
getRoot: (id: string) => {
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
export function useThreadsContext() {
|
||||
return useContext(ThreadsContext);
|
||||
}
|
||||
|
||||
export default function ThreadsProvider({ timeline, children }: { timeline: TimelineLoader } & PropsWithChildren) {
|
||||
const messages = useSubject(timeline.timeline);
|
||||
|
||||
const threads = useMemo(() => {
|
||||
const grouped: Record<string, Thread> = {};
|
||||
for (const message of messages) {
|
||||
const rootId = message.tags.find((t) => t[0] === "e" && t[3] === "root")?.[1];
|
||||
if (rootId) {
|
||||
if (!grouped[rootId]) {
|
||||
grouped[rootId] = {
|
||||
messages: [],
|
||||
rootId,
|
||||
root: timeline.events.getEvent(rootId),
|
||||
};
|
||||
}
|
||||
grouped[rootId].messages.push(message);
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}, [messages.length, timeline.events]);
|
||||
|
||||
const getRoot = useCallback(
|
||||
(id: string) => {
|
||||
return timeline.events.getEvent(id);
|
||||
},
|
||||
[timeline.events],
|
||||
);
|
||||
|
||||
const context = useMemo(() => ({ threads, getRoot }), [threads, getRoot]);
|
||||
|
||||
return <ThreadsContext.Provider value={context}>{children}</ThreadsContext.Provider>;
|
||||
}
|
@ -1,20 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChatIcon } from "@chakra-ui/icons";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
Flex,
|
||||
Input,
|
||||
Link,
|
||||
LinkBox,
|
||||
LinkOverlay,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { Button, Card, CardBody, Flex, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
import { Outlet, Link as RouterLink, useLocation, useParams } from "react-router-dom";
|
||||
import { nip19 } from "nostr-tools";
|
||||
@ -24,10 +9,8 @@ import { getUserDisplayName } from "../../helpers/user-metadata";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import directMessagesService from "../../services/direct-messages";
|
||||
import { ExternalLinkIcon } from "../../components/icons";
|
||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||
import Timestamp from "../../components/timestamp";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
|
||||
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
|
||||
|
||||
@ -36,6 +19,7 @@ function ContactCard({ pubkey }: { pubkey: string }) {
|
||||
const messages = useSubject(subject);
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
const location = useLocation();
|
||||
const latestMessage = messages[0];
|
||||
|
||||
return (
|
||||
<LinkBox as={Card} size="sm">
|
||||
@ -43,7 +27,7 @@ function ContactCard({ pubkey }: { pubkey: string }) {
|
||||
<UserAvatar pubkey={pubkey} />
|
||||
<Flex direction="column" gap="1" overflow="hidden" flex={1}>
|
||||
<Text flex={1}>{getUserDisplayName(metadata, pubkey)}</Text>
|
||||
{messages[0] && <Timestamp flexShrink={0} timestamp={messages[0].created_at} />}
|
||||
{latestMessage && <Timestamp flexShrink={0} timestamp={latestMessage.created_at} />}
|
||||
</Flex>
|
||||
</CardBody>
|
||||
<LinkOverlay as={RouterLink} to={`/dm/${nip19.npubEncode(pubkey)}` + location.search} />
|
||||
@ -82,7 +66,7 @@ function DirectMessagesPage() {
|
||||
const isChatOpen = !!params.pubkey;
|
||||
|
||||
return (
|
||||
<Flex gap="4" maxH={{ base: "calc(100vh - 3.5rem)", md: "100vh" }}>
|
||||
<Flex gap="4" h={{ base: "calc(100vh - 3.5rem)", md: "100vh" }} overflow="hidden">
|
||||
<Flex
|
||||
gap="2"
|
||||
direction="column"
|
||||
@ -104,7 +88,7 @@ function DirectMessagesPage() {
|
||||
Load More
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex gap="2" direction="column" flex={1} hideBelow={!isChatOpen ? "xl" : undefined}>
|
||||
<Flex gap="2" direction="column" flex={1} hideBelow={!isChatOpen ? "xl" : undefined} overflow="hidden">
|
||||
<Outlet />
|
||||
</Flex>
|
||||
</Flex>
|
@ -1,53 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { Box, CardProps, Flex } from "@chakra-ui/react";
|
||||
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import { getMessageRecipient } from "../../services/direct-messages";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import DecryptPlaceholder from "./decrypt-placeholder";
|
||||
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
|
||||
import {
|
||||
embedCashuTokens,
|
||||
embedNostrLinks,
|
||||
renderGenericUrl,
|
||||
renderImageUrl,
|
||||
renderVideoUrl,
|
||||
} from "../../components/embed-types";
|
||||
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
|
||||
import UserAvatar from "../../components/user-avatar";
|
||||
import UserLink from "../../components/user-link";
|
||||
import { getEventUID } from "../../helpers/nostr/events";
|
||||
import Timestamp from "../../components/timestamp";
|
||||
|
||||
export function MessageContent({ event, text }: { event: NostrEvent; text: string }) {
|
||||
let content: EmbedableContent = [text];
|
||||
|
||||
content = embedNostrLinks(content);
|
||||
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
|
||||
|
||||
// cashu
|
||||
content = embedCashuTokens(content);
|
||||
|
||||
return <Box whiteSpace="pre-wrap">{content}</Box>;
|
||||
}
|
||||
|
||||
export default function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
|
||||
const account = useCurrentAccount()!;
|
||||
const isOwnMessage = account.pubkey === event.pubkey;
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, getEventUID(event));
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" ref={ref}>
|
||||
<Flex gap="2" mr="2">
|
||||
<UserAvatar pubkey={event.pubkey} size="xs" />
|
||||
<UserLink pubkey={event.pubkey} fontWeight="bold" />
|
||||
<Timestamp ml="auto" timestamp={event.created_at} />
|
||||
</Flex>
|
||||
<DecryptPlaceholder data={event.content} pubkey={isOwnMessage ? getMessageRecipient(event) ?? "" : event.pubkey}>
|
||||
{(text) => <MessageContent event={event} text={text} />}
|
||||
</DecryptPlaceholder>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -95,8 +95,10 @@ export default function ReplyForm({ item, onCancel, onSubmitted, replyKind = Kin
|
||||
}
|
||||
});
|
||||
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
return (
|
||||
<Flex as="form" direction="column" gap="2" pb="4" onSubmit={submit}>
|
||||
<Flex as="form" direction="column" gap="2" pb="4" onSubmit={submit} ref={formRef}>
|
||||
<MagicTextArea
|
||||
placeholder="Reply"
|
||||
autoFocus
|
||||
@ -110,6 +112,9 @@ export default function ReplyForm({ item, onCancel, onSubmitted, replyKind = Kin
|
||||
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
|
||||
if (imageFile) uploadImage(imageFile);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.ctrlKey && e.key === "Enter" && formRef.current) formRef.current.requestSubmit();
|
||||
}}
|
||||
/>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<VisuallyHiddenInput
|
||||
|
@ -102,6 +102,9 @@ const ReactionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>((
|
||||
const expanded = useDisclosure({ defaultIsOpen: true });
|
||||
if (!pointer || (account?.pubkey && pointer.author !== account.pubkey)) return null;
|
||||
|
||||
const reactedEvent = useSingleEvent(pointer.id, pointer.relays);
|
||||
if (reactedEvent?.kind === Kind.EncryptedDirectMessage) return null;
|
||||
|
||||
return (
|
||||
<Flex gap="2" ref={ref}>
|
||||
<IconBox>
|
||||
|
@ -73,7 +73,7 @@ export default function LoginNip05View() {
|
||||
}
|
||||
}
|
||||
|
||||
accountService.addAccount({ pubkey, relays: Array.from(bootstrapRelays), readonly: true });
|
||||
accountService.addAccount({ type: "pubkey", pubkey, relays: Array.from(bootstrapRelays), readonly: true });
|
||||
}
|
||||
|
||||
accountService.switchAccount(pubkey);
|
||||
|
70
src/views/signin/nostr-connect.tsx
Normal file
70
src/views/signin/nostr-connect.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Flex, FormControl, FormHelperText, FormLabel, Input, Text, useToast } from "@chakra-ui/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import accountService from "../../services/account";
|
||||
import nostrConnectService, { NostrConnectClient } from "../../services/nostr-connect";
|
||||
|
||||
export default function LoginNostrConnectView() {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [uri, setUri] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState<string | undefined>();
|
||||
const handleSubmit: React.FormEventHandler<HTMLDivElement> = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
setLoading("Connecting...");
|
||||
let client: NostrConnectClient;
|
||||
if (uri.startsWith("bunker://")) {
|
||||
client = nostrConnectService.fromBunkerURI(uri);
|
||||
await client.connect();
|
||||
} else if (uri.startsWith("npub")) {
|
||||
client = nostrConnectService.fromNsecBunkerToken(uri);
|
||||
const [npub, hexToken] = uri.split("#");
|
||||
await client.connect(hexToken);
|
||||
} else throw new Error("Unknown format");
|
||||
|
||||
nostrConnectService.saveClient(client);
|
||||
accountService.addAccount({
|
||||
type: "nostr-connect",
|
||||
signerRelays: client.relays,
|
||||
clientSecretKey: client.secretKey,
|
||||
pubkey: client.pubkey,
|
||||
readonly: false,
|
||||
});
|
||||
accountService.switchAccount(client.pubkey);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ status: "error", description: e.message });
|
||||
}
|
||||
setLoading(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} minWidth="350" w="full">
|
||||
{loading && <Text fontSize="lg">{loading}</Text>}
|
||||
{!loading && (
|
||||
<FormControl>
|
||||
<FormLabel>Connect URI</FormLabel>
|
||||
<Input
|
||||
placeholder="bunker://<pubkey>?relay=wss://relay.example.com"
|
||||
isRequired
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FormHelperText>A bunker connect URI</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
<Flex justifyContent="space-between" gap="2">
|
||||
<Button variant="link" onClick={() => navigate("../")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button colorScheme="primary" ml="auto" type="submit" isLoading={!!loading}>
|
||||
Connect
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -22,7 +22,7 @@ export default function LoginNpubView() {
|
||||
}
|
||||
|
||||
if (!accountService.hasAccount(pubkey)) {
|
||||
accountService.addAccount({ pubkey, relays: [relayUrl], readonly: true });
|
||||
accountService.addAccount({ type: "pubkey", pubkey, relays: [relayUrl], readonly: true });
|
||||
}
|
||||
accountService.switchAccount(pubkey);
|
||||
};
|
||||
|
@ -73,7 +73,7 @@ export default function LoginNsecView() {
|
||||
const pubkey = getPublicKey(hexKey);
|
||||
|
||||
const encrypted = await signingService.encryptSecKey(hexKey);
|
||||
accountService.addAccount({ pubkey, relays: [relayUrl], ...encrypted, readonly: false });
|
||||
accountService.addAccount({ type: "local", pubkey, relays: [relayUrl], ...encrypted, readonly: false });
|
||||
accountService.switchAccount(pubkey);
|
||||
};
|
||||
|
||||
|
@ -13,14 +13,18 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||
|
||||
import accountService from "../../services/account";
|
||||
import Key01 from "../../components/icons/key-01";
|
||||
import Diamond01 from "../../components/icons/diamond-01";
|
||||
import ChevronDown from "../../components/icons/chevron-down";
|
||||
import ChevronUp from "../../components/icons/chevron-up";
|
||||
import serialPortService from "../../services/serial-port";
|
||||
import UsbFlashDrive from "../../components/icons/usb-flash-drive";
|
||||
import HelpCircle from "../../components/icons/help-circle";
|
||||
|
||||
import { COMMON_CONTACT_RELAY } from "../../const";
|
||||
import accountService from "../../services/account";
|
||||
import serialPortService from "../../services/serial-port";
|
||||
import amberSignerService from "../../services/amber-signer";
|
||||
|
||||
export default function LoginStartView() {
|
||||
const location = useLocation();
|
||||
const toast = useToast();
|
||||
@ -44,10 +48,10 @@ export default function LoginStartView() {
|
||||
}
|
||||
|
||||
if (relays.length === 0) {
|
||||
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine"];
|
||||
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine", COMMON_CONTACT_RELAY];
|
||||
}
|
||||
|
||||
accountService.addAccount({ pubkey, relays, connectionType: "extension", readonly: false });
|
||||
accountService.addAccount({ pubkey, relays, type: "extension", readonly: false });
|
||||
}
|
||||
|
||||
accountService.switchAccount(pubkey);
|
||||
@ -59,7 +63,7 @@ export default function LoginStartView() {
|
||||
toast({ status: "warning", title: "Cant find extension" });
|
||||
}
|
||||
};
|
||||
const loginWithSerial = async () => {
|
||||
const signinWithSerial = async () => {
|
||||
if (serialPortService.supported) {
|
||||
try {
|
||||
setLoading(true);
|
||||
@ -68,14 +72,11 @@ export default function LoginStartView() {
|
||||
|
||||
if (!accountService.hasAccount(pubkey)) {
|
||||
let relays: string[] = [];
|
||||
|
||||
// TODO: maybe get relays from device
|
||||
|
||||
if (relays.length === 0) {
|
||||
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine"];
|
||||
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine", COMMON_CONTACT_RELAY];
|
||||
}
|
||||
|
||||
accountService.addAccount({ pubkey, relays, connectionType: "serial", readonly: false });
|
||||
accountService.addAccount({ pubkey, relays, type: "serial", readonly: false });
|
||||
}
|
||||
|
||||
accountService.switchAccount(pubkey);
|
||||
@ -88,16 +89,38 @@ export default function LoginStartView() {
|
||||
}
|
||||
};
|
||||
|
||||
const signinWithAmber = async () => {
|
||||
try {
|
||||
const pubkey = await amberSignerService.getPublicKey();
|
||||
if (!accountService.hasAccount(pubkey)) {
|
||||
let relays: string[] = [];
|
||||
if (relays.length === 0) {
|
||||
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine", COMMON_CONTACT_RELAY];
|
||||
}
|
||||
|
||||
accountService.addAccount({ pubkey, relays, type: "amber", readonly: false });
|
||||
}
|
||||
accountService.switchAccount(pubkey);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" flexShrink={0} alignItems="center">
|
||||
<Button onClick={signinWithExtension} leftIcon={<Key01 boxSize={6} />} w="sm" colorScheme="primary">
|
||||
Sign in with extension
|
||||
{window.nostr && (
|
||||
<Button onClick={signinWithExtension} leftIcon={<Key01 boxSize={6} />} w="sm" colorScheme="primary">
|
||||
Sign in with extension
|
||||
</Button>
|
||||
)}
|
||||
<Button as={RouterLink} to="./nostr-connect" state={location.state} w="sm" colorScheme="blue">
|
||||
Nostr Connect (NIP-46)
|
||||
</Button>
|
||||
{serialPortService.supported && (
|
||||
<ButtonGroup colorScheme="purple">
|
||||
<Button onClick={loginWithSerial} leftIcon={<UsbFlashDrive boxSize={6} />} w="xs">
|
||||
<Button onClick={signinWithSerial} leftIcon={<UsbFlashDrive boxSize={6} />} w="xs">
|
||||
Use Signing Device
|
||||
</Button>
|
||||
<IconButton
|
||||
@ -110,6 +133,21 @@ export default function LoginStartView() {
|
||||
/>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
{amberSignerService.supported && (
|
||||
<ButtonGroup colorScheme="orange">
|
||||
<Button onClick={signinWithAmber} leftIcon={<Diamond01 boxSize={6} />} w="xs">
|
||||
Use Amber
|
||||
</Button>
|
||||
<IconButton
|
||||
as={Link}
|
||||
aria-label="What is Amber?"
|
||||
title="What is Amber?"
|
||||
isExternal
|
||||
href="https://github.com/greenart7c3/Amber"
|
||||
icon={<HelpCircle boxSize={5} />}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={advanced.onToggle}
|
||||
@ -122,19 +160,19 @@ export default function LoginStartView() {
|
||||
{advanced.isOpen && (
|
||||
<>
|
||||
<Button as={RouterLink} to="./nip05" state={location.state} w="sm">
|
||||
NIP05
|
||||
DNS ID
|
||||
<Badge ml="2" colorScheme="blue">
|
||||
read-only
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button as={RouterLink} to="./npub" state={location.state} w="sm">
|
||||
public key (npub)
|
||||
Public key (npub)
|
||||
<Badge ml="2" colorScheme="blue">
|
||||
read-only
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button as={RouterLink} to="./nsec" state={location.state} w="sm">
|
||||
secret key (nsec)
|
||||
Secret key (nsec)
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
@ -63,7 +63,7 @@ export default function CreateStep({
|
||||
// login
|
||||
const pubkey = getPublicKey(hex);
|
||||
const encrypted = await signingService.encryptSecKey(hex);
|
||||
accountService.addAccount({ pubkey, relays, ...encrypted, readonly: false });
|
||||
accountService.addAccount({ type: "local", pubkey, relays, ...encrypted, readonly: false });
|
||||
accountService.switchAccount(pubkey);
|
||||
|
||||
// set relays
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Button, Flex } from "@chakra-ui/react";
|
||||
import { memo, useRef } from "react";
|
||||
import { memo, useCallback, useRef } from "react";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
@ -14,6 +14,7 @@ import EmbeddedDM from "../../components/embed-event/event-types/embedded-dm";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { ChevronLeftIcon } from "../../components/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
|
||||
|
||||
const DirectMessage = memo(({ dm }: { dm: NostrEvent }) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
@ -30,6 +31,14 @@ export function DMFeedPage() {
|
||||
const navigate = useNavigate();
|
||||
const { listId, filter } = usePeopleListContext();
|
||||
|
||||
const clientMuteFilter = useClientSideMuteFilter();
|
||||
const eventFilter = useCallback(
|
||||
(e: NostrEvent) => {
|
||||
if (clientMuteFilter(e)) return false;
|
||||
return true;
|
||||
},
|
||||
[clientMuteFilter],
|
||||
);
|
||||
const readRelays = useReadRelayUrls();
|
||||
const timeline = useTimelineLoader(
|
||||
`${listId ?? "global"}-dm-feed`,
|
||||
@ -43,6 +52,7 @@ export function DMFeedPage() {
|
||||
{ "#p": filter.authors, kinds: [Kind.EncryptedDirectMessage] },
|
||||
]
|
||||
: { kinds: [Kind.EncryptedDirectMessage] },
|
||||
{ eventFilter },
|
||||
);
|
||||
|
||||
const dms = useSubject(timeline.timeline);
|
||||
|
@ -34,7 +34,7 @@ function Warning() {
|
||||
const secKey = generatePrivateKey();
|
||||
const encrypted = await signingService.encryptSecKey(secKey);
|
||||
const pubkey = getPublicKey(secKey);
|
||||
accountService.addAccount({ ...encrypted, pubkey, readonly: false });
|
||||
accountService.addAccount({ type: "local", ...encrypted, pubkey, readonly: false });
|
||||
accountService.switchAccount(pubkey);
|
||||
navigate("/relays");
|
||||
} catch (e) {
|
||||
|
@ -1,93 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button, Code, Flex, Spinner, useForceUpdate } from "@chakra-ui/react";
|
||||
import WebTorrent from "../../lib/webtorrent";
|
||||
import type { Torrent } from "webtorrent";
|
||||
|
||||
import { safeDecode } from "../../helpers/nip19";
|
||||
import useSingleEvent from "../../hooks/use-single-event";
|
||||
|
||||
import { ErrorBoundary } from "../../components/error-boundary";
|
||||
import { getTorrentMagnetLink } from "../../helpers/nostr/torrents";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import { ChevronLeftIcon } from "../../components/icons";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
|
||||
const client = new WebTorrent();
|
||||
|
||||
// @ts-ignore
|
||||
window.torrentClient = client;
|
||||
|
||||
function TorrentPreview({ torrent }: { torrent: Torrent; event: NostrEvent }) {
|
||||
const update = useForceUpdate();
|
||||
const preview = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
torrent.on("metadata", update);
|
||||
torrent.on("ready", update);
|
||||
torrent.on("done", update);
|
||||
return () => {
|
||||
// torrent.off("metadata", update);
|
||||
};
|
||||
}, [torrent]);
|
||||
|
||||
return (
|
||||
<Flex gap="4">
|
||||
<Flex direction="column">
|
||||
{torrent.files.map((file) => (
|
||||
<Button key={file.path}>{file.name}</Button>
|
||||
))}
|
||||
</Flex>
|
||||
<Code as="pre">{JSON.stringify({ ready: torrent.ready, name: torrent.name }, null, 2)}</Code>
|
||||
<div ref={preview} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function TorrentPreviewPage({ event }: { event: NostrEvent }) {
|
||||
const navigate = useNavigate();
|
||||
const magnet = getTorrentMagnetLink(event);
|
||||
|
||||
const [torrent, setTorrent] = useState<Torrent>();
|
||||
useEffect(() => {
|
||||
setTorrent(
|
||||
client.add(magnet, (t) => {
|
||||
console.log(t);
|
||||
}),
|
||||
);
|
||||
return () => {
|
||||
client.remove(magnet);
|
||||
setTorrent(undefined);
|
||||
};
|
||||
}, [magnet]);
|
||||
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="2">
|
||||
<Button leftIcon={<ChevronLeftIcon />} onClick={() => navigate(-1)}>
|
||||
Back
|
||||
</Button>
|
||||
</Flex>
|
||||
{torrent && <TorrentPreview torrent={torrent} event={event} />}
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TorrentPreviewView() {
|
||||
const { id } = useParams() as { id: string };
|
||||
const parsed = useMemo(() => {
|
||||
const result = safeDecode(id);
|
||||
if (!result) return;
|
||||
if (result.type === "note") return { id: result.data };
|
||||
if (result.type === "nevent") return result.data;
|
||||
}, [id]);
|
||||
const torrent = useSingleEvent(parsed?.id, parsed?.relays ?? []);
|
||||
|
||||
if (!torrent) return <Spinner />;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<TorrentPreviewPage event={torrent} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
@ -44,6 +44,7 @@ export const UserProfileMenu = ({
|
||||
const readRelays = userRelays.filter((r) => r.mode === RelayMode.READ).map((r) => r.url) ?? [];
|
||||
if (!accountService.hasAccount(pubkey)) {
|
||||
accountService.addAccount({
|
||||
type: 'pubkey',
|
||||
pubkey,
|
||||
relays: readRelays,
|
||||
readonly: true,
|
||||
|
@ -33,7 +33,7 @@ export default defineConfig({
|
||||
manifest: {
|
||||
name: "noStrudel",
|
||||
short_name: "noStrudel",
|
||||
description: "A simple PWA nostr client",
|
||||
description: "A sandbox for exploring nostr",
|
||||
orientation: "any",
|
||||
theme_color: "#8DB600",
|
||||
categories: ["nostr"],
|
||||
|
Loading…
x
Reference in New Issue
Block a user