mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-10 04:39:19 +02:00
add direct messages
This commit is contained in:
parent
5ba19391c1
commit
06e66c1b9d
26
README.md
26
README.md
@ -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
|
||||
|
10
src/app.tsx
10
src/app.tsx
@ -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 />,
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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>;
|
||||
};
|
||||
|
132
src/services/direct-messages.ts
Normal file
132
src/services/direct-messages.ts
Normal 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;
|
@ -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
119
src/views/dm/chat.tsx
Normal 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>
|
||||
);
|
||||
}
|
34
src/views/dm/decrypt-placeholder.tsx
Normal file
34
src/views/dm/decrypt-placeholder.tsx
Normal 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
106
src/views/dm/index.tsx
Normal 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;
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user