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. 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) 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 - [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-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 - [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 - [ ] [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 - [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-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-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-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 - [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-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-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-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-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 - [ ] [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-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-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-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 - [x] [NIP-65](https://github.com/nostr-protocol/nips/blob/master/65.md): Relay List Metadata
## TODO ## 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) - 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 - 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) - 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 mentions in notes (https://css-tricks.com/so-you-want-to-build-an-mention-autocomplete-feature/)
- add `client` tag to published events - add `client` tag to published events
- Save note drafts and let users manage them - 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?) - allow user to select relay or following list when fetching replies (default to my relays + following?)
- massive thread note1dapvuu8fl09yjtg2gyr2h6nypaffl2sq0aj5raz86463qk5kpyzqlxvtc3 - 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 ## Setup
```bash ```bash

View File

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

View File

@@ -187,19 +187,31 @@ export const UndoIcon = createIcon({
}); });
export const LikeIcon = 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", 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, defaultProps,
}); });
export const DislikeIcon = createIcon({ 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", 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, defaultProps,
}); });
export const QrCodeIcon = createIcon({ 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", 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, 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 { useCurrentAccount } from "../../hooks/use-current-account";
import accountService from "../../services/account"; import accountService from "../../services/account";
import { ConnectedRelays } from "../connected-relays"; 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 { ProfileButton } from "../profile-button";
import AccountSwitcher from "./account-switcher"; import AccountSwitcher from "./account-switcher";
@@ -27,6 +27,9 @@ export default function DesktopSideNav() {
<Button onClick={() => navigate("/notifications")} leftIcon={<NotificationIcon />}> <Button onClick={() => navigate("/notifications")} leftIcon={<NotificationIcon />}>
Notifications Notifications
</Button> </Button>
<Button onClick={() => navigate("/dm")} leftIcon={<ChatIcon />}>
Messages
</Button>
<Button onClick={() => navigate("/profile")} leftIcon={<ProfileIcon />}> <Button onClick={() => navigate("/profile")} leftIcon={<ProfileIcon />}>
Profile Profile
</Button> </Button>

View File

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

View File

@@ -7,12 +7,20 @@ import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
export type SigningContextType = { export type SigningContextType = {
requestSignature: (draft: DraftNostrEvent) => Promise<NostrEvent | undefined>; 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>({ export const SigningContext = React.createContext<SigningContextType>({
requestSignature: () => { requestSignature: () => {
throw new Error("not setup yet"); throw new Error("not setup yet");
}, },
requestDecrypt: () => {
throw new Error("not setup yet");
},
requestEncrypt: () => {
throw new Error("not setup yet");
},
}); });
export function useSigningContext() { export function useSigningContext() {
@@ -26,7 +34,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
const requestSignature = useCallback( const requestSignature = useCallback(
async (draft: DraftNostrEvent) => { async (draft: DraftNostrEvent) => {
try { try {
if (!current) throw new Error("no account"); if (!current) throw new Error("No account");
return await signingService.requestSignature(draft, current); return await signingService.requestSignature(draft, current);
} catch (e) { } catch (e) {
if (e instanceof Error) { 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>; 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 { getPublicKey } from "nostr-tools/keys";
import { signEvent, getEventHash } from "nostr-tools/event"; import { signEvent, getEventHash } from "nostr-tools/event";
import db from "./db"; import db from "./db";
import { decrypt, encrypt } from "nostr-tools/nip04";
class SigningService { class SigningService {
private async getSalt() { private async getSalt() {
@@ -18,7 +19,7 @@ class SigningService {
private async getKeyMaterial() { private async getKeyMaterial() {
const password = window.prompt("Enter local encryption password"); 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(); const enc = new TextEncoder();
return window.crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits", "deriveKey"]); return window.crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits", "deriveKey"]);
} }
@@ -53,7 +54,7 @@ class SigningService {
} }
async decryptSecKey(account: Account) { 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 key = await this.getEncryptionKey();
const decode = new TextDecoder(); 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); const decrypted = await window.crypto.subtle.decrypt({ name: "AES-GCM", iv: account.iv }, key, account.secKey);
return decode.decode(decrypted); return decode.decode(decrypted);
} catch (e) { } catch (e) {
throw new Error("failed to decrypt secret key"); throw new Error("Failed to decrypt secret key");
} }
} }
async requestSignature(draft: DraftNostrEvent, account: Account) { 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 (account?.useExtension) {
if (window.nostr) { if (window.nostr) {
const signed = await window.nostr.signEvent(draft); 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; return signed;
} else throw new Error("missing nostr extension"); } else throw new Error("Missing nostr extension");
} else if (account?.secKey) { } else if (account?.secKey) {
const secKey = await this.decryptSecKey(account); const secKey = await this.decryptSecKey(account);
const tmpDraft = { ...draft, pubkey: getPublicKey(secKey) }; const tmpDraft = { ...draft, pubkey: getPublicKey(secKey) };
@@ -84,7 +85,35 @@ class SigningService {
}; };
return event; 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 { 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 { 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 { QrIconButton } from "../../../components/qr-icon-button";
import { UserAvatar } from "../../../components/user-avatar"; import { UserAvatar } from "../../../components/user-avatar";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity"; import { UserDnsIdentityIcon } from "../../../components/user-dns-identity";
@@ -69,6 +69,15 @@ export default function Header({ pubkey }: { pubkey: string }) {
onClick={() => navigate("/settings")} onClick={() => navigate("/settings")}
/> />
)} )}
{!isSelf && (
<IconButton
as={RouterLink}
size="sm"
icon={<ChatIcon />}
aria-label="Message"
to={`/dm/${npub ?? pubkey}`}
/>
)}
{!isSelf && <UserFollowButton pubkey={pubkey} size="sm" />} {!isSelf && <UserFollowButton pubkey={pubkey} size="sm" />}
</Flex> </Flex>
</Flex> </Flex>