add direct messages

This commit is contained in:
hzrd149 2023-02-25 15:01:21 -06:00
parent 5ba19391c1
commit 06e66c1b9d
12 changed files with 522 additions and 45 deletions

View File

@ -6,7 +6,7 @@
My goals for this project is to learn as much as I can about nostr (by implementing everything myself) and to have a client that works exactly how I like.
There are many features missing from this client and I wont get around to implementing everything (probably no DM support). but if you like the client you are welcome to use it.
There are many features missing from this client and I wont get around to implementing everything. but if you like the client you are welcome to use it.
Live Instance: [nostrudel.ninja](https://nostrudel.ninja)
@ -44,7 +44,7 @@ I would recomend you use a browser extension like [Alby](https://getalby.com/) o
- [x] [NIP-02](https://github.com/nostr-protocol/nips/blob/master/02.md): Contact List and Petnames
- [ ] [NIP-03](https://github.com/nostr-protocol/nips/blob/master/03.md): OpenTimestamps Attestations for Events
- [ ] [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md): Encrypted Direct Message
- [x] [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md): Encrypted Direct Message
- [x] [NIP-05](https://github.com/nostr-protocol/nips/blob/master/05.md): Mapping Nostr keys to DNS-based internet identifiers
- [ ] [NIP-06](https://github.com/nostr-protocol/nips/blob/master/06.md): Basic key derivation from mnemonic seed phrase
- [x] [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md): `window.nostr` capability for web browsers
@ -54,11 +54,11 @@ I would recomend you use a browser extension like [Alby](https://getalby.com/) o
- [ ] [NIP-12](https://github.com/nostr-protocol/nips/blob/master/12.md): Generic Tag Queries
- [ ] [NIP-13](https://github.com/nostr-protocol/nips/blob/master/13.md): Proof of Work
- [ ] [NIP-14](https://github.com/nostr-protocol/nips/blob/master/14.md): Subject tag in text events.
- [ ] [NIP-15](https://github.com/nostr-protocol/nips/blob/master/15.md): End of Stored Events Notice
- [x] [NIP-15](https://github.com/nostr-protocol/nips/blob/master/15.md): End of Stored Events Notice
- [x] [NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md): bech32-encoded entities
- [ ] [NIP-20](https://github.com/nostr-protocol/nips/blob/master/20.md): Command Results
- [ ] [NIP-21](https://github.com/nostr-protocol/nips/blob/master/21.md): `nostr:` URL scheme
- [ ] [NIP-25](https://github.com/nostr-protocol/nips/blob/master/25.md): Reactions
- [x] [NIP-25](https://github.com/nostr-protocol/nips/blob/master/25.md): Reactions
- [ ] [NIP-26](https://github.com/nostr-protocol/nips/blob/master/26.md): Delegated Event Signing
- [ ] [NIP-33](https://github.com/nostr-protocol/nips/blob/master/33.md): Parameterized Replaceable Events
- [ ] [NIP-36](https://github.com/nostr-protocol/nips/blob/master/36.md): Sensitive Content
@ -66,17 +66,14 @@ I would recomend you use a browser extension like [Alby](https://getalby.com/) o
- [ ] [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md): Authentication of clients to relays
- [ ] [NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md): Keywords filter
- [ ] [NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md): Reporting
- [ ] [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md): Lightning Zaps
- [x] [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md): Lightning Zaps
- [x] [NIP-65](https://github.com/nostr-protocol/nips/blob/master/65.md): Relay List Metadata
## TODO
- Show reactions and zaps on notes
- Create a "event posting" service that can show modals (for qr code scanning), warnings (signed by wrong pubkey), and results (what relays responded) when posting events.
- Create notifications service that keeps track of read notifications. (show unread count in sidenav)
- Rebuild relays view to show relay info and settings NIP-11
- filter list of followers by users the user has blocked/reported (stops bots/spammers from showing up at followers)
- Add preview tab to note modal
- Add mentions in notes (https://css-tricks.com/so-you-want-to-build-an-mention-autocomplete-feature/)
- add `client` tag to published events
- Save note drafts and let users manage them
@ -88,19 +85,6 @@ I would recomend you use a browser extension like [Alby](https://getalby.com/) o
- allow user to select relay or following list when fetching replies (default to my relays + following?)
- massive thread note1dapvuu8fl09yjtg2gyr2h6nypaffl2sq0aj5raz86463qk5kpyzqlxvtc3
User / Note Tip or Zap modal requirements.
- Custom user amounts
- add comment (added to either zap request or invoice description)
- default to zapping if lnurlp endpoint supports it
- optional eventId to make zaps target event
- show QR Code button
- handle LNURL / lightning address.
- pay with webln button
- pay with app (open lightning: url) button
- copy invoice button
- return a deffered promise or shared loading state so component can show loading
## Setup
```bash

View File

@ -28,6 +28,8 @@ import { deleteDatabase } from "./services/db";
import { LoginNsecView } from "./views/login/nsec";
import UserZapsTab from "./views/user/zaps";
import PopularTab from "./views/home/popular";
import DirectMessagesView from "./views/dm";
import DirectMessageChatView from "./views/dm/chat";
const RequireCurrentAccount = ({ children }: { children: JSX.Element }) => {
let location = useLocation();
@ -115,6 +117,14 @@ const router = createBrowserRouter([
path: "notifications",
element: <NotificationsView />,
},
{
path: "dm",
element: <DirectMessagesView />,
},
{
path: "dm/:key",
element: <DirectMessageChatView />,
},
{
path: "profile",
element: <ProfileView />,

View File

@ -187,19 +187,31 @@ export const UndoIcon = createIcon({
});
export const LikeIcon = createIcon({
displayName: "UndoIcon",
displayName: "LikeIcon",
d: "M14.6 8H21a2 2 0 0 1 2 2v2.104a2 2 0 0 1-.15.762l-3.095 7.515a1 1 0 0 1-.925.619H2a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1h3.482a1 1 0 0 0 .817-.423L11.752.85a.5.5 0 0 1 .632-.159l1.814.907a2.5 2.5 0 0 1 1.305 2.853L14.6 8zM7 10.588V19h11.16L21 12.104V10h-6.4a2 2 0 0 1-1.938-2.493l.903-3.548a.5.5 0 0 0-.261-.571l-.661-.33-4.71 6.672c-.25.354-.57.644-.933.858zM5 11H3v8h2v-8z",
defaultProps,
});
export const DislikeIcon = createIcon({
displayName: "UndoIcon",
displayName: "DislikeIcon",
d: "M9.4 16H3a2 2 0 0 1-2-2v-2.104a2 2 0 0 1 .15-.762L4.246 3.62A1 1 0 0 1 5.17 3H22a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-3.482a1 1 0 0 0-.817.423l-5.453 7.726a.5.5 0 0 1-.632.159L9.802 22.4a2.5 2.5 0 0 1-1.305-2.853L9.4 16zm7.6-2.588V5H5.84L3 11.896V14h6.4a2 2 0 0 1 1.938 2.493l-.903 3.548a.5.5 0 0 0 .261.571l.661.33 4.71-6.672c.25-.354.57-.644.933-.858zM19 13h2V5h-2v8z",
defaultProps,
});
export const QrCodeIcon = createIcon({
displayName: "UndoIcon",
displayName: "QrCodeIcon",
d: "M16 17v-1h-3v-3h3v2h2v2h-1v2h-2v2h-2v-3h2v-1h1zm5 4h-4v-2h2v-2h2v4zM3 3h8v8H3V3zm2 2v4h4V5H5zm8-2h8v8h-8V3zm2 2v4h4V5h-4zM3 13h8v8H3v-8zm2 2v4h4v-4H5zm13-2h3v2h-3v-2zM6 6h2v2H6V6zm0 10h2v2H6v-2zM16 6h2v2h-2V6z",
defaultProps,
});
export const ChatIcon = createIcon({
displayName: "ChatIcon",
d: "M10 3h4a8 8 0 1 1 0 16v3.5c-5-2-12-5-12-11.5a8 8 0 0 1 8-8zm2 14h2a6 6 0 1 0 0-12h-4a6 6 0 0 0-6 6c0 3.61 2.462 5.966 8 8.48V17z",
defaultProps,
});
export const UnlockIcon = createIcon({
displayName: "UnlockIcon",
d: "M7 10h13a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V11a1 1 0 0 1 1-1h1V9a7 7 0 0 1 13.262-3.131l-1.789.894A5 5 0 0 0 7 9v1zm-2 2v8h14v-8H5zm5 3h4v2h-4v-2z",
defaultProps,
});

View File

@ -4,7 +4,7 @@ import { Link, useNavigate } from "react-router-dom";
import { useCurrentAccount } from "../../hooks/use-current-account";
import accountService from "../../services/account";
import { ConnectedRelays } from "../connected-relays";
import { FeedIcon, LogoutIcon, NotificationIcon, ProfileIcon, RelayIcon } from "../icons";
import { ChatIcon, FeedIcon, LogoutIcon, NotificationIcon, ProfileIcon, RelayIcon } from "../icons";
import { ProfileButton } from "../profile-button";
import AccountSwitcher from "./account-switcher";
@ -27,6 +27,9 @@ export default function DesktopSideNav() {
<Button onClick={() => navigate("/notifications")} leftIcon={<NotificationIcon />}>
Notifications
</Button>
<Button onClick={() => navigate("/dm")} leftIcon={<ChatIcon />}>
Messages
</Button>
<Button onClick={() => navigate("/profile")} leftIcon={<ProfileIcon />}>
Profile
</Button>

View File

@ -1,6 +1,7 @@
import { ChatIcon } from "@chakra-ui/icons";
import { Flex, IconButton } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { FeedIcon, ProfileIcon, SettingsIcon } from "../icons";
import { FeedIcon, SettingsIcon } from "../icons";
export default function MobileBottomNav() {
const navigate = useNavigate();
@ -8,13 +9,7 @@ export default function MobileBottomNav() {
return (
<Flex flexShrink={0} gap="2" padding="2">
<IconButton icon={<FeedIcon />} aria-label="Home" onClick={() => navigate("/")} flexGrow="1" size="lg" />
<IconButton
icon={<ProfileIcon />}
aria-label="Profile"
onClick={() => navigate(`/profile`)}
flexGrow="1"
size="lg"
/>
<IconButton icon={<ChatIcon />} aria-label="Messages" onClick={() => navigate(`/dm`)} flexGrow="1" size="lg" />
<IconButton
icon={<SettingsIcon />}
aria-label="Settings"

View File

@ -7,12 +7,20 @@ import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
export type SigningContextType = {
requestSignature: (draft: DraftNostrEvent) => Promise<NostrEvent | undefined>;
requestDecrypt: (data: string, pubkey: string) => Promise<string | undefined>;
requestEncrypt: (data: string, pubkey: string) => Promise<string | undefined>;
};
export const SigningContext = React.createContext<SigningContextType>({
requestSignature: () => {
throw new Error("not setup yet");
},
requestDecrypt: () => {
throw new Error("not setup yet");
},
requestEncrypt: () => {
throw new Error("not setup yet");
},
});
export function useSigningContext() {
@ -26,7 +34,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
const requestSignature = useCallback(
async (draft: DraftNostrEvent) => {
try {
if (!current) throw new Error("no account");
if (!current) throw new Error("No account");
return await signingService.requestSignature(draft, current);
} catch (e) {
if (e instanceof Error) {
@ -37,9 +45,45 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
}
}
},
[toast]
[toast, current]
);
const requestDecrypt = useCallback(
async (data: string, pubkey: string) => {
try {
if (!current) throw new Error("No account");
return await signingService.requestDecrypt(data, pubkey, current);
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
}
},
[toast, current]
);
const requestEncrypt = useCallback(
async (data: string, pubkey: string) => {
try {
if (!current) throw new Error("No account");
return await signingService.requestEncrypt(data, pubkey, current);
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
}
},
[toast, current]
);
const context = useMemo(
() => ({ requestSignature, requestDecrypt, requestEncrypt }),
[requestSignature, requestDecrypt, requestEncrypt]
);
const context = useMemo(() => ({ requestSignature }), [requestSignature]);
return <SigningContext.Provider value={context}>{children}</SigningContext.Provider>;
};

View File

@ -0,0 +1,132 @@
import moment, { MomentInput } from "moment";
import { NostrMultiSubscription } from "../classes/nostr-multi-subscription";
import { NostrEvent } from "../types/nostr-event";
import clientRelaysService from "./client-relays";
import { insertEventIntoDescendingList } from "nostr-tools/utils";
import { SuperMap } from "../classes/super-map";
import { PersistentSubject } from "../classes/subject";
import accountService from "./account";
import { NostrQuery } from "../types/nostr-query";
import { convertTimestampToDate } from "../helpers/date";
import { Kind } from "nostr-tools";
import { getReferences } from "../helpers/nostr-event";
export function getMessageRecipient(event: NostrEvent): string | undefined {
return getReferences(event).pubkeys[0];
}
class DirectMessagesService {
incomingSub: NostrMultiSubscription;
outgoingSub: NostrMultiSubscription;
conversations = new PersistentSubject<string[]>([]);
messages = new SuperMap<string, PersistentSubject<NostrEvent[]>>(() => new PersistentSubject<NostrEvent[]>([]));
constructor() {
this.incomingSub = new NostrMultiSubscription(
clientRelaysService.getReadUrls(),
undefined,
"incoming-direct-messages"
);
this.incomingSub.onEvent.subscribe(this.receiveEvent, this);
this.outgoingSub = new NostrMultiSubscription(
clientRelaysService.getReadUrls(),
undefined,
"outgoing-direct-messages"
);
this.outgoingSub.onEvent.subscribe(this.receiveEvent, this);
// reset the messages when the account changes
accountService.current.subscribe((newAccount) => {
this.messages.clear();
this.conversations.next([]);
if (!newAccount) return;
// update subscriptions
if (this.incomingSub.query) {
this.incomingSub.setQuery({
...this.incomingSub.query,
"#p": [newAccount.pubkey],
since: moment().subtract(1, "day").unix(),
});
}
if (this.outgoingSub.query) {
this.outgoingSub.setQuery({
...this.outgoingSub.query,
authors: [newAccount.pubkey],
since: moment().subtract(1, "day").unix(),
});
}
});
// update relays when they change
clientRelaysService.readRelays.subscribe((relays) => {
const urls = relays.map((r) => r.url);
this.incomingSub.setRelays(urls);
this.outgoingSub.setRelays(urls);
});
}
receiveEvent(event: NostrEvent) {
const from = event.pubkey;
const to = getMessageRecipient(event);
if (!to) return;
const pubkey = accountService.current.value?.pubkey;
if (from !== pubkey && to !== pubkey) return;
const conversation = from === pubkey ? to : from;
const subject = this.messages.get(conversation);
subject.next(insertEventIntoDescendingList(subject.value, event));
if (!this.conversations.value.includes(conversation)) {
this.conversations.next([...this.conversations.value, conversation]);
}
}
getUserMessages(from: string) {
return this.messages.get(from);
}
getContactCount() {
return this.messages.size;
}
loadDateRange(from: MomentInput) {
const account = accountService.current.value;
if (!account) return;
if (this.incomingSub.query?.since && moment(convertTimestampToDate(this.incomingSub.query.since)).isBefore(from)) {
// "since" is already set on the subscription and its older than "from"
return;
}
const incomingQuery: NostrQuery = {
kinds: [Kind.EncryptedDirectMessage],
"#p": [account.pubkey],
since: moment(from).unix(),
};
this.incomingSub.setQuery(incomingQuery);
const outgoingQuery: NostrQuery = {
kinds: [Kind.EncryptedDirectMessage],
authors: [account.pubkey],
since: moment(from).unix(),
};
this.outgoingSub.setQuery(outgoingQuery);
this.incomingSub.setRelays(clientRelaysService.getReadUrls());
this.outgoingSub.setRelays(clientRelaysService.getReadUrls());
if (this.incomingSub.state !== NostrMultiSubscription.OPEN) {
this.incomingSub.open();
}
if (this.outgoingSub.state !== NostrMultiSubscription.OPEN) {
this.outgoingSub.open();
}
}
}
const directMessagesService = new DirectMessagesService();
export default directMessagesService;

View File

@ -3,6 +3,7 @@ import { Account } from "./account";
import { getPublicKey } from "nostr-tools/keys";
import { signEvent, getEventHash } from "nostr-tools/event";
import db from "./db";
import { decrypt, encrypt } from "nostr-tools/nip04";
class SigningService {
private async getSalt() {
@ -18,7 +19,7 @@ class SigningService {
private async getKeyMaterial() {
const password = window.prompt("Enter local encryption password");
if (!password) throw new Error("password required");
if (!password) throw new Error("Password required");
const enc = new TextEncoder();
return window.crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits", "deriveKey"]);
}
@ -53,7 +54,7 @@ class SigningService {
}
async decryptSecKey(account: Account) {
if (!account.secKey) throw new Error("account dose not have a secret key");
if (!account.secKey) throw new Error("Account dose not have a secret key");
const key = await this.getEncryptionKey();
const decode = new TextDecoder();
@ -61,18 +62,18 @@ class SigningService {
const decrypted = await window.crypto.subtle.decrypt({ name: "AES-GCM", iv: account.iv }, key, account.secKey);
return decode.decode(decrypted);
} catch (e) {
throw new Error("failed to decrypt secret key");
throw new Error("Failed to decrypt secret key");
}
}
async requestSignature(draft: DraftNostrEvent, account: Account) {
if (account?.readonly) throw new Error("cant sign in readonly mode");
if (account?.readonly) throw new Error("Cant sign in readonly mode");
if (account?.useExtension) {
if (window.nostr) {
const signed = await window.nostr.signEvent(draft);
if (signed.pubkey !== account.pubkey) throw new Error("signed with the wrong pubkey!");
if (signed.pubkey !== account.pubkey) throw new Error("Signed with the wrong pubkey!");
return signed;
} else throw new Error("missing nostr extension");
} else throw new Error("Missing nostr extension");
} else if (account?.secKey) {
const secKey = await this.decryptSecKey(account);
const tmpDraft = { ...draft, pubkey: getPublicKey(secKey) };
@ -84,7 +85,35 @@ class SigningService {
};
return event;
} else throw new Error("no signing method");
} else throw new Error("No signing method");
}
async requestDecrypt(data: string, pubkey: string, account: Account) {
if (account?.readonly) throw new Error("Cant decrypt in readonly mode");
if (account?.useExtension) {
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?.secKey) {
const secKey = await this.decryptSecKey(account);
return await decrypt(secKey, pubkey, data);
} else throw new Error("No decryption method");
}
async requestEncrypt(text: string, pubkey: string, account: Account) {
if (account?.readonly) throw new Error("Cant encrypt in readonly mode");
if (account?.useExtension) {
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?.secKey) {
const secKey = await this.decryptSecKey(account);
return await encrypt(secKey, pubkey, text);
} else throw new Error("No encryption method");
}
}

119
src/views/dm/chat.tsx Normal file
View File

@ -0,0 +1,119 @@
import { Button, Card, CardBody, CardProps, Flex, IconButton, Spacer, Text, Textarea } from "@chakra-ui/react";
import moment from "moment";
import { Kind } from "nostr-tools";
import { useEffect, useMemo, useState } from "react";
import { Link, Navigate, useParams } from "react-router-dom";
import { nostrPostAction } from "../../classes/nostr-post-action";
import { ArrowLeftSIcon } from "../../components/icons";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { convertTimestampToDate } from "../../helpers/date";
import { normalizeToHex } from "../../helpers/nip-19";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useIsMobile } from "../../hooks/use-is-mobile";
import useSubject from "../../hooks/use-subject";
import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays";
import directMessagesService, { getMessageRecipient } from "../../services/direct-messages";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import DecryptPlaceholder from "./decrypt-placeholder";
function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
const account = useCurrentAccount();
const isOwnMessage = account.pubkey === event.pubkey;
return (
<Flex direction="column">
<Text size="sm" textAlign={isOwnMessage ? "right" : "left"} px="2">
{moment(convertTimestampToDate(event.created_at)).fromNow()}
</Text>
<Card size="sm" mr={isOwnMessage ? 0 : "8"} ml={isOwnMessage ? "8" : 0}>
<CardBody position="relative">
<DecryptPlaceholder
data={event.content}
pubkey={isOwnMessage ? getMessageRecipient(event) ?? "" : event.pubkey}
>
{(text) => <Text>{text}</Text>}
</DecryptPlaceholder>
</CardBody>
</Card>
</Flex>
);
}
export default function DirectMessageChatView() {
const { key } = useParams();
if (!key) return <Navigate to="/" />;
const pubkey = normalizeToHex(key);
if (!pubkey) throw new Error("invalid pubkey");
const { requestEncrypt, requestSignature } = useSigningContext();
const isMobile = useIsMobile();
const [loading, setLoading] = useState(false);
const [from, setFrom] = useState(moment().subtract(1, "week"));
const [content, setContent] = useState<string>("");
useEffect(() => directMessagesService.loadDateRange(from), [from]);
const loadMore = () => {
setLoading(true);
setFrom((date) => moment(date).subtract(1, "week"));
setTimeout(() => {
setLoading(false);
}, 1000);
};
const subject = useMemo(() => directMessagesService.getUserMessages(pubkey), [pubkey]);
const messages = useSubject(subject);
const sendMessage = async () => {
if (!content) return;
const encrypted = await requestEncrypt(content, pubkey);
if (!encrypted) return;
const event: DraftNostrEvent = {
kind: Kind.EncryptedDirectMessage,
content: encrypted,
tags: [["p", pubkey]],
created_at: moment().unix(),
};
const signed = await requestSignature(event);
if (!signed) return;
const writeRelays = clientRelaysService.getWriteUrls();
nostrPostAction(writeRelays, signed);
setContent("");
};
return (
<Flex height="100%" overflow="hidden" direction="column">
<Card size="sm" flexShrink={0}>
<CardBody display="flex" gap="2" alignItems="center">
<IconButton
as={Link}
variant="ghost"
icon={<ArrowLeftSIcon />}
aria-label="Back"
to="/dm"
size={isMobile ? "sm" : "md"}
/>
<UserAvatar pubkey={pubkey} size={isMobile ? "sm" : "md"} />
<UserLink pubkey={pubkey} />
</CardBody>
</Card>
<Flex flex={1} overflowX="hidden" overflowY="scroll" direction="column" gap="4" py="4">
<Spacer height="100vh" />
<Button onClick={loadMore} mx="auto" flexShrink={0} isLoading={loading}>
Load More
</Button>
{[...messages].reverse().map((event) => (
<Message key={event.id} event={event} />
))}
</Flex>
<Flex shrink={0}>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} />
<Button isDisabled={!content} onClick={sendMessage}>
Send
</Button>
</Flex>
</Flex>
);
}

View File

@ -0,0 +1,34 @@
import { Button } from "@chakra-ui/react";
import { useState } from "react";
import { UnlockIcon } from "../../components/icons";
import { useSigningContext } from "../../providers/signing-provider";
export default function DecryptPlaceholder({
children,
data,
pubkey,
}: {
children: (decrypted: string) => JSX.Element;
data: string;
pubkey: string;
}): JSX.Element {
const { requestDecrypt } = useSigningContext();
const [loading, setLoading] = useState(false);
const [decrypted, setDecrypted] = useState<string>();
const decrypt = async () => {
setLoading(true);
const decrypted = await requestDecrypt(data, pubkey);
if (decrypted) setDecrypted(decrypted);
setLoading(false);
};
if (decrypted) {
return children(decrypted);
}
return (
<Button variant="text" onClick={decrypt} isLoading={loading} leftIcon={<UnlockIcon />} width="100%">
Decrypt
</Button>
);
}

106
src/views/dm/index.tsx Normal file
View File

@ -0,0 +1,106 @@
import { ChatIcon } from "@chakra-ui/icons";
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Button,
Card,
CardBody,
Flex,
LinkBox,
LinkOverlay,
Text,
} from "@chakra-ui/react";
import moment from "moment";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { UserAvatar } from "../../components/user-avatar";
import { convertTimestampToDate } from "../../helpers/date";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
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";
function ContactCard({ pubkey }: { pubkey: string }) {
const subject = useMemo(() => directMessagesService.getUserMessages(pubkey), [pubkey]);
const messages = useSubject(subject);
const metadata = useUserMetadata(pubkey);
const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey);
return (
<LinkBox as={Card} size="sm">
<CardBody display="flex" gap="2" overflow="hidden">
<UserAvatar pubkey={pubkey} />
<Flex direction="column" gap="1" overflow="hidden" flex={1}>
<Text flex={1}>{getUserDisplayName(metadata, pubkey)}</Text>
{messages[0] && (
<Text flexShrink={0}>{moment(convertTimestampToDate(messages[0].created_at)).fromNow()}</Text>
)}
</Flex>
</CardBody>
<LinkOverlay as={Link} to={`/dm/${npub ?? pubkey}`} />
</LinkBox>
);
}
function DirectMessagesView() {
const [from, setFrom] = useState(moment().subtract(2, "days"));
const conversations = useSubject(directMessagesService.conversations);
useEffect(() => directMessagesService.loadDateRange(from), [from]);
const [loading, setLoading] = useState(false);
const loadMore = () => {
setLoading(true);
setFrom((date) => moment(date).subtract(2, "days"));
setTimeout(() => {
setLoading(false);
}, 1000);
};
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]);
if (conversations.length === 0) {
return (
<Alert
status="info"
variant="subtle"
flexDirection="column"
alignItems="center"
justifyContent="center"
textAlign="center"
height="200px"
>
<AlertIcon boxSize="40px" mr={0} />
<AlertTitle mt={4} mb={1} fontSize="lg">
No direct messages yet :(
</AlertTitle>
<AlertDescription maxWidth="sm">
Click <ChatIcon /> on another users profile to start a conversation.
</AlertDescription>
</Alert>
);
}
return (
<Flex direction="column" gap="2" overflowX="hidden" overflowY="auto" height="100%" pt="2" pb="8">
{sortedConversations.map((pubkey) => (
<ContactCard key={pubkey} pubkey={pubkey} />
))}
<Button onClick={loadMore} isLoading={loading} flexShrink={0}>
Load More
</Button>
</Flex>
);
}
export default DirectMessagesView;

View File

@ -1,7 +1,7 @@
import { Flex, Heading, SkeletonText, Text, Link, IconButton } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import { CopyIconButton } from "../../../components/copy-icon-button";
import { ExternalLinkIcon, KeyIcon, SettingsIcon } from "../../../components/icons";
import { ChatIcon, ExternalLinkIcon, KeyIcon, SettingsIcon } from "../../../components/icons";
import { QrIconButton } from "../../../components/qr-icon-button";
import { UserAvatar } from "../../../components/user-avatar";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity";
@ -69,6 +69,15 @@ export default function Header({ pubkey }: { pubkey: string }) {
onClick={() => navigate("/settings")}
/>
)}
{!isSelf && (
<IconButton
as={RouterLink}
size="sm"
icon={<ChatIcon />}
aria-label="Message"
to={`/dm/${npub ?? pubkey}`}
/>
)}
{!isSelf && <UserFollowButton pubkey={pubkey} size="sm" />}
</Flex>
</Flex>