diff options
| author | Adam Soltys <[email protected]> | 2026-01-28 22:51:34 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-01-28 22:51:34 -0800 |
| commit | 568a936e08586b7ac07b4c7adaf5bf9b8ef6ba2f (patch) | |
| tree | 56a45c92cd37c8ba1d1d794d300af0462c86dbf3 | |
| parent | 685d085596a1a20d2b5680cf8000313173bbe6fd (diff) | |
| parent | 838f74effb5b45e545bf68a4659e1e36c14da81f (diff) | |
| download | coinos-ui-nip17dm-fixes.tar.gz coinos-ui-nip17dm-fixes.zip | |
Merge remote-tracking branch 'origin/nip17dm'nip17dm-fixes
# Conflicts:
# .env.sample
| -rw-r--r-- | .env.sample | 2 | ||||
| -rw-r--r-- | doc/nostr-messages.md | 37 | ||||
| -rw-r--r-- | src/components/Menu.svelte | 1 | ||||
| -rw-r--r-- | src/lib/nip01.ts | 19 | ||||
| -rw-r--r-- | src/lib/nip07.ts | 42 | ||||
| -rw-r--r-- | src/lib/nip11.ts | 15 | ||||
| -rw-r--r-- | src/lib/nip17.ts | 223 | ||||
| -rw-r--r-- | src/lib/nip40.ts | 16 | ||||
| -rw-r--r-- | src/lib/nip44.ts | 135 | ||||
| -rw-r--r-- | src/lib/nip51.ts | 71 | ||||
| -rw-r--r-- | src/lib/nostr.ts | 11 | ||||
| -rw-r--r-- | src/locales/en.json | 27 | ||||
| -rw-r--r-- | src/locales/fr.json | 27 | ||||
| -rw-r--r-- | src/routes/(app)/[username]/(profile)/+layout.svelte | 9 | ||||
| -rw-r--r-- | src/routes/(app)/dm/+page.svelte | 508 | ||||
| -rw-r--r-- | src/routes/(app)/settings/+layout.svelte | 45 | ||||
| -rw-r--r-- | src/routes/(app)/settings/nostr/+page.svelte | 22 |
17 files changed, 1208 insertions, 2 deletions
diff --git a/.env.sample b/.env.sample index 72dfb652..f6582721 100644 --- a/.env.sample +++ b/.env.sample @@ -9,3 +9,5 @@ PUBLIC_COINOS_PUBKEY=ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a PUBLIC_COINOS_RELAY=wss://relay.coinos.io PUBLIC_COINOS_NETWORK=regtest PUBLIC_RECAPTCHA_SITE_KEY= +PUBLIC_VAPID_PUBKEY=BEfgMDTeAn1F8ZQUGC6v5iKTD86zYXaa3QVr1E-ylKXC5c24JplcfSAN9PDlXAsVtUd3cU6j4-LsX_TKQzz_yDQ +PUBLIC_DM_RELAYS=wss://relay.coinos.io diff --git a/doc/nostr-messages.md b/doc/nostr-messages.md new file mode 100644 index 00000000..a90f9a4a --- /dev/null +++ b/doc/nostr-messages.md @@ -0,0 +1,37 @@ +# coinos Nostr Messages - Technical Information + +Last Updated: 2026-01-28 + +CoinOS uses [Nostr](https://github.com/nostr-protocol/nostr)'s [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) for its direct message system. A CoinOS account is not necessary to send or receive messages using this system, and CoinOS users can interact with non-CoinOS users. + +## Relay Usage + +CoinOS will search the relays in the environment variable `PUBLIC_DM_RELAYS` for kind-10050 events. `PUBLIC_DM_RELAYS` is a comma-separated list of URLs. Any message sent to a user will go to the relays in their kind-10050 event, as per NIP-17. + +## Features + +- Send & receive messages +- Can use either browser extensions ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) or user accounts' saved nostr keys. +- Can chat with any nostr user, not just coinos accounts +- Can configure your DM relays within the settings page +- Automatically disappearing messages ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)) +- Can add users to nostr's mute list, hiding them from the menu +- UI shows users' nostr information if they're not a coinos account + +## Limitations + +- There is no way to delete messages, aside from setting them to be deleted automatically, due to [an inherent limitation of the protocol](https://github.com/nostr-protocol/nips/issues/1735). + +## Dependencies + +coinos's nostr messages depends on the `npm` package [`nostr-tools`](https://www.npmjs.com/package/nostr-tools). + +## Relevant NIPs + +- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) - the main protocol +- [NIP-05](https://github.com/nostr-protocol/nips/blob/master/05.md) - user identity verification via domain names +- [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md) - Nostr browser extensions +- **[NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md)** - Private Direct Messages +- [NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md) - Message expiration +- [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md) - Message encryption +- [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) - User lists (used for muting) diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte index e433fefb..49e477a1 100644 --- a/src/components/Menu.svelte +++ b/src/components/Menu.svelte @@ -16,6 +16,7 @@ // icon: "ph:storefront-bold", // href: `https://coinosmerch.com`, // }, + { key: "nav.dm", icon: "ph:chat-bold", href: `/dm` }, { key: "nav.dark", icon: "ph:moon-stars-bold", href: `/dark` }, { key: "nav.signOut", icon: "ph:sign-out-bold", href: `/logout` }, ]; diff --git a/src/lib/nip01.ts b/src/lib/nip01.ts new file mode 100644 index 00000000..a5d3f4b5 --- /dev/null +++ b/src/lib/nip01.ts @@ -0,0 +1,19 @@ +import { PUBLIC_DM_RELAYS, PUBLIC_COINOS_RELAY } from '$env/static/public'; +const DM_RELAYS_LIST = PUBLIC_DM_RELAYS.split(','); +const USERINFO_RELAYS = [ ...DM_RELAYS_LIST, PUBLIC_COINOS_RELAY ]; + +import { SimplePool } from 'nostr-tools/pool'; +const pool = new SimplePool(); + +// Returns the content of the kind-0 event of the provided pubkey. +export const getNostrUserInfo = async (pubkey: string) => { + const events = await pool.querySync( + USERINFO_RELAYS, { kinds: [0], limit: 1, authors: [pubkey] } + ); + + if (events.length == 0) { + return null; + } + + return JSON.parse(events[0].content); +} diff --git a/src/lib/nip07.ts b/src/lib/nip07.ts new file mode 100644 index 00000000..9f675950 --- /dev/null +++ b/src/lib/nip07.ts @@ -0,0 +1,42 @@ +import { bytesToHex } from '@noble/hashes/utils'; +import { finalizeEvent } from 'nostr-tools/pure'; +import { unwrapEvent } from 'nostr-tools/nip17'; +import { getPrivateKey } from '$lib/nostr'; +import * as libnip44 from '$lib/nip44'; +import { u } from '$lib/nip44'; + +// Signs an event using NIP-07 if available, otherwise getPrivateKey() +export const signEvent = async (event: object, user: object): object => { + if (await window.nostr.getPublicKey() === user.pubkey) { + return window.nostr.signEvent(event); + } else { + const sk = await getPrivateKey(); + return finalizeEvent(event, sk); + } +} + +// Encrypts an event in NIP-44 using NIP-07 if available, otherwise getPrivateKey() +export const encrypt = async (json: object, user: object, receiverPK: string): string => { + if (await window.nostr.getPublicKey() === user.pubkey) { + return window.nostr.nip44.encrypt( + receiverPK, JSON.stringify(json)); + } else { + const senderSK = await getPrivateKey(); + const senderSKString = bytesToHex(senderSK); + const ckey = u.getConversationKey(senderSKString, receiverPK); + return libnip44.encrypt(JSON.stringify(json), ckey); + } +} + +// Decrypts an event in NIP-44 using NIP-07 if available, otherwise getPrivateKey() +export const decrypt = async (encrypted: string, user: object, receiverPK: string): object => { + if (await window.nostr.getPublicKey() === user.pubkey) { + return JSON.parse(await window.nostr.nip44.decrypt( + receiverPK, encrypted)); + } else { + const senderSK = await getPrivateKey(); + const senderSKString = bytesToHex(senderSK); + const ckey = u.getConversationKey(senderSKString, receiverPK); + return JSON.parse(libnip44.decrypt(encrypted, ckey)); + } +} diff --git a/src/lib/nip11.ts b/src/lib/nip11.ts new file mode 100644 index 00000000..af254b7e --- /dev/null +++ b/src/lib/nip11.ts @@ -0,0 +1,15 @@ +import { fetchRelayInformation } from 'nostr-tools/nip11'; + +// Accepts a list of relays, and returns a list of the relays that support all the provided NIPs. +export const relaysSupporting = async (relays: string[], nips: number[]) => { + let supportingRelays = []; + + for (const relay of relays) { + const relayInfo = await fetchRelayInformation(relay); + if (nips.every(relay => relayInfo.supported_nips.includes(relay))) { + supportingRelays.push(relay); + } + } + + return supportingRelays; +} diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts new file mode 100644 index 00000000..b128dab9 --- /dev/null +++ b/src/lib/nip17.ts @@ -0,0 +1,223 @@ +import { bytesToHex } from '@noble/hashes/utils'; + +import { finalizeEvent, getEventHash, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { SimplePool } from 'nostr-tools/pool'; +import { unwrapEvent } from 'nostr-tools/nip17'; + +import { getPrivateKey } from '$lib/nostr'; +import { relaysSupporting } from '$lib/nip11'; +import { expired, expiration } from '$lib/nip40'; +import { encrypt, u } from '$lib/nip44'; + +const TWO_DAYS = 2 * 24 * 60 * 60; +const randomTimestamp = () => Math.floor( + Date.now() / 1000 - Math.random() * TWO_DAYS); +const pool = new SimplePool(); +let relayListCache = new Map(); + +import { PUBLIC_DM_RELAYS } from '$env/static/public'; +const DM_RELAYS_LIST = PUBLIC_DM_RELAYS.split(','); +const DM_FETCH_LIMIT = 256; + +// Create a NIP-17 message using a provided sender secret key. +// If wrapPK is specified and different than receiverPK, +// the message will be gift-wrapped to wrapPK +// but the rumour will be sent to receiverPK. +// If expiryDays is specified, the message will expire in that many days, +// plus a random amount from 0 to 2 for security. +// (expiration time = random creation time + expiryDays + 2 days) +export const createNIP17MessageSK = (text: string, senderSK: Uint8Array, receiverPK: string, wrapPK: string = null, expiryDays: number = null) => { + const rumour = createRumour(text, getPublicKey(senderSK), receiverPK); + const sealed = sealRumourSK(rumour, senderSK, wrapPK || receiverPK, expiryDays); + return giftWrap(sealed, wrapPK || receiverPK, expiryDays); +} + +// Create a NIP-17 message using NIP-07, requiring no sender secret key. +// If wrapPK is specified and different than receiverPK, +// the message will be gift-wrapped to wrapPK +// but the rumour will be sent to receiverPK. +// If expiryDays is specified, the message will expire in that many days, +// plus a random amount from 0 to 2 for security. +// (expiration time = random creation time + expiryDays + 2 days) +export const createNIP17MessageNIP07 = async (text: string, senderPK: string, receiverPK: string, wrapPK: string = null, expiryDays: number = null) => { + const rumour = createRumour(text, senderPK, receiverPK); + const sealed = await sealRumourNip07(rumour, wrapPK || receiverPK, expiryDays); + return giftWrap(sealed, wrapPK || receiverPK, expiryDays); +} + +const createRumour = (text: string, senderPK: string, receiverPK: string) => { + const rumour = { + kind: 14, // rumour + created_at: Math.floor(Date.now() / 1000), + tags: [["p", receiverPK]], + pubkey: senderPK, + content: text + }; + rumour.id = getEventHash(rumour); // do NOT sign rumours! + return rumour; +} + +const sealRumourSK = (rumour: object, senderSK: Uint8Array, receiverPK: string, expiryDays: number = null) => { + const senderSKString = bytesToHex(senderSK); + const conversationKey = u.getConversationKey(senderSKString, receiverPK); + const encryptedRumour = encrypt(JSON.stringify(rumour), conversationKey); + + const created = randomTimestamp(); + const sealEvent = { + kind: 13, // seal + created_at: created, + tags: [], + pubkey: getPublicKey(senderSK), + content: encryptedRumour + } + if (expiryDays != null) { + const expiration = created + (expiryDays + 2) * 86400; + sealEvent.tags.push(["expiration", expiration.toString()]); + } + + return finalizeEvent(sealEvent, senderSK); +} + +const sealRumourNip07 = async (rumour: object, receiverPK: string, expiryDays: number = null) => { + const encryptedRumour = await window.nostr.nip44.encrypt( + receiverPK, JSON.stringify(rumour)); + + const created = randomTimestamp(); + const sealEvent = { + kind: 13, // seal + created_at: created, + tags: [], + content: encryptedRumour + } + if (expiryDays != null) { + const expiration = created + (expiryDays + 2) * 86400; + sealEvent.tags.push(["expiration", expiration.toString()]); + } + + return window.nostr.signEvent(sealEvent); +} + +const giftWrap = (event: object, receiverPK: string, expiryDays: number = null) => { + const secretKey = generateSecretKey(); + const SKString = bytesToHex(secretKey); + const conversationKey = u.getConversationKey(secretKey, receiverPK); + const encryptedEvent = encrypt(JSON.stringify(event), conversationKey); + const created = randomTimestamp(); + + const giftWrapEvent = { + kind: 1059, // gift wrap + created_at: created, + tags: [["p", receiverPK]], + pubkey: getPublicKey(secretKey), + content: encryptedEvent + } + if (expiryDays != null) { + const expiration = created + (expiryDays + 2) * 86400; + giftWrapEvent.tags.push(["expiration", expiration.toString()]); + } + + return finalizeEvent(giftWrapEvent, secretKey); +} + +const decryptNIP17MessageNIP07 = async (wrapped: object): object => { + const sealedText = await window.nostr.nip44.decrypt( + wrapped.pubkey, wrapped.content); + const sealed = JSON.parse(sealedText); + const rumourText = await window.nostr.nip44.decrypt( + sealed.pubkey, sealed.content); + return JSON.parse(rumourText); +} + +// Decrypts the provided message, addressed to the user. +// If NIP-07 is available, it uses that, otherwise it uses getPrivateKey(). +export const decrypt = async (wrapped: object, user: object): object => { + if (await window.nostr.getPublicKey() === user.pubkey) { + return decryptNIP17MessageNIP07(wrapped); + } else { + const sk = await getPrivateKey(user); + return unwrapEvent(wrapped, sk); + } + } + +// Returns the rumours of the messages the provided user has sent or received. +// If the events have an expiration timestamp, +// that timestamp is added to its rumour object as the property `expiration`. +export const getMessageRumours = async (user: object): object[] => { + const relays = await getPreferredRelays(user.pubkey); + const wrapped = await pool.querySync( + relays, { kinds: [1059], "#p": [user.pubkey], limit: DM_FETCH_LIMIT } + ); + + let rumours = []; + // intentionally decrypting sequentially to avoid having a bunch of popups + for (event of wrapped) { + if (expired(event)) { + console.log("relay(s) contain expired event", event.id); + continue; + } + + const rumour = await decrypt(event, user); + + const expires = expiration(event); + if (expires) { + rumour.expiration = expires; + } + + rumours.push(rumour); + } + + return rumours; +} + +// Returns the preferred relays of the user with the provided pubkey, +// according to their kind-10050 event. +export const getPreferredRelays = async (pubkey: string): string[] => { + if (relayListCache.has(pubkey)) + return relayListCache.get(pubkey); + + const events = await pool.querySync( + DM_RELAYS_LIST, { kinds: [10050], limit: 1, authors: [pubkey] } + ); + + let relays = []; + for (const event of events) { + for (const tag of event.tags) { + if (tag.length >= 2 && tag[0] == "relay") { + relays.push(tag[1]); + } + } + } + + relayListCache.set(pubkey, relays); + return relays; +} + +const publishToPreferred = async (event: object, pubkey: string, expiryEnabled: boolean): Promise<string> => { + let preferredRelays = await getPreferredRelays(pubkey); + if (expiryEnabled) { + preferredRelays = await relaysSupporting(preferredRelays, [40]); + } + return Promise.any(pool.publish(preferredRelays, event)); +} + +// Sends a NIP-17 message from `user` to `recipient`. +// If expiryDays is set, messages will expire after that many days. +export const send = async (message: string, user: object, recipient: object, expiryDays?: number) => { + let event1, event2; + if (await window.nostr.getPublicKey() === user.pubkey) { + event1 = await createNIP17MessageNIP07( + message, user.pubkey, recipient.pubkey, recipient.pubkey, expiryDays); + event2 = await createNIP17MessageNIP07( + message, user.pubkey, recipient.pubkey, user.pubkey, expiryDays); + } else { + const sk = await getPrivateKey(user); + event1 = createNIP17MessageSK( + message, sk, recipient.pubkey, recipient.pubkey, expiryDays); + event2 = createNIP17MessageSK( + message, sk, recipient.pubkey, user.pubkey, expiryDays); + } + + const p1 = publishToPreferred(event1, recipient.pubkey, expiryDays != null); + const p2 = publishToPreferred(event2, user.pubkey, expiryDays != null); + return Promise.all([p1, p2]); +} diff --git a/src/lib/nip40.ts b/src/lib/nip40.ts new file mode 100644 index 00000000..030fe0df --- /dev/null +++ b/src/lib/nip40.ts @@ -0,0 +1,16 @@ +// Returns true if the message has an expiration in the past, false otherwise. +export const expired = (event: object): boolean => { + const expiryTime = expiration(event); + return expiryTime !== null && Date.now() / 1000 >= expiryTime; +} + +// Returns the provided event's expiration time, as a Unix ms integer. +// If it has none, returns null. +export const expiration = (event: object): number | null => { + for (const tag of event.tags) { + if (tag[0] == "expiration") { + return parseInt(tag[1]); + } + } + return null; +} diff --git a/src/lib/nip44.ts b/src/lib/nip44.ts new file mode 100644 index 00000000..313d6f6b --- /dev/null +++ b/src/lib/nip44.ts @@ -0,0 +1,135 @@ +// Copied from this public-domain code:
+// https://github.com/paulmillr/nip44/blob/main/javascript/index.ts
+// Modified slightly to deal with a dependency issue
+
+import { chacha20 } from '@noble/ciphers/chacha';
+import { equalBytes } from '@noble/ciphers/utils';
+import { secp256k1 } from '@noble/curves/secp256k1';
+import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf';
+import { hmac } from '@noble/hashes/hmac';
+import { sha256 } from '@noble/hashes/sha256';
+import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils';
+import { base64 } from '@scure/base';
+
+declare const TextDecoder: any;
+
+const decoder = new TextDecoder();
+export const u = {
+ minPlaintextSize: 0x0001, // 1b msg => padded to 32b
+ maxPlaintextSize: 0xffff, // 65535 (64kb-1) => padded to 64kb
+
+ utf8Encode: utf8ToBytes,
+ utf8Decode(bytes: Uint8Array) {
+ return decoder.decode(bytes);
+ },
+
+ getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
+ const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33);
+ return hkdf_extract(sha256, sharedX, 'nip44-v2');
+ },
+
+ getMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array) {
+ // ensureBytes(conversationKey, 32);
+ // ensureBytes(nonce, 32);
+ const keys = hkdf_expand(sha256, conversationKey, nonce, 76);
+ return {
+ chacha_key: keys.subarray(0, 32),
+ chacha_nonce: keys.subarray(32, 44),
+ hmac_key: keys.subarray(44, 76),
+ };
+ },
+
+ calcPaddedLen(len: number): number {
+ if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer');
+ if (len <= 32) return 32;
+ const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1);
+ const chunk = nextPower <= 256 ? 32 : nextPower / 8;
+ return chunk * (Math.floor((len - 1) / chunk) + 1);
+ },
+
+ writeU16BE(num: number) {
+ if (!Number.isSafeInteger(num) || num < u.minPlaintextSize || num > u.maxPlaintextSize)
+ throw new Error('invalid plaintext size: must be between 1 and 65535 bytes');
+ const arr = new Uint8Array(2);
+ new DataView(arr.buffer).setUint16(0, num, false);
+ return arr;
+ },
+
+ pad(plaintext: string): Uint8Array {
+ const unpadded = u.utf8Encode(plaintext);
+ const unpaddedLen = unpadded.length;
+ const prefix = u.writeU16BE(unpaddedLen);
+ const suffix = new Uint8Array(u.calcPaddedLen(unpaddedLen) - unpaddedLen);
+ return concatBytes(prefix, unpadded, suffix);
+ },
+
+ unpad(padded: Uint8Array): string {
+ const unpaddedLen = new DataView(padded.buffer).getUint16(0);
+ const unpadded = padded.subarray(2, 2 + unpaddedLen);
+ if (
+ unpaddedLen < u.minPlaintextSize ||
+ unpaddedLen > u.maxPlaintextSize ||
+ unpadded.length !== unpaddedLen ||
+ padded.length !== 2 + u.calcPaddedLen(unpaddedLen)
+ )
+ throw new Error('invalid padding');
+ return u.utf8Decode(unpadded);
+ },
+
+ hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array) {
+ if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes');
+ const combined = concatBytes(aad, message);
+ return hmac(sha256, key, combined);
+ },
+
+ // metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
+ // plaintext: 1b to 0xffff
+ // padded plaintext: 32b to 0xffff
+ // ciphertext: 32b+2 to 0xffff+2
+ // raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
+ // compressed payload (base64): 132b to 87472b
+ decodePayload(payload: string) {
+ if (typeof payload !== 'string') throw new Error('payload must be a valid string');
+ const plen = payload.length;
+ if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen);
+ if (payload[0] === '#') throw new Error('unknown encryption version');
+ let data: Uint8Array;
+ try {
+ data = base64.decode(payload);
+ } catch (error) {
+ throw new Error('invalid base64: ' + (error as any).message);
+ }
+ const dlen = data.length;
+ if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen);
+ const vers = data[0];
+ if (vers !== 2) throw new Error('unknown encryption version ' + vers);
+ return {
+ nonce: data.subarray(1, 33),
+ ciphertext: data.subarray(33, -32),
+ mac: data.subarray(-32),
+ };
+ },
+};
+
+export function encrypt(plaintext: string, conversationKey: Uint8Array, nonce = randomBytes(32)): string {
+ const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce);
+ const padded = u.pad(plaintext);
+ const ciphertext = chacha20(chacha_key, chacha_nonce, padded);
+ const mac = u.hmacAad(hmac_key, ciphertext, nonce);
+ return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac));
+}
+
+export function decrypt(payload: string, conversationKey: Uint8Array): string {
+ const { nonce, ciphertext, mac } = u.decodePayload(payload);
+ const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce);
+ const calculatedMac = u.hmacAad(hmac_key, ciphertext, nonce);
+ if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC');
+ const padded = chacha20(chacha_key, chacha_nonce, ciphertext);
+ return u.unpad(padded);
+}
+
+export const v2 = {
+ utils: u,
+ encrypt,
+ decrypt,
+};
diff --git a/src/lib/nip51.ts b/src/lib/nip51.ts new file mode 100644 index 00000000..b1b0b613 --- /dev/null +++ b/src/lib/nip51.ts @@ -0,0 +1,71 @@ +import { SimplePool } from 'nostr-tools/pool'; +import { verifyEvent } from 'nostr-tools/pure'; +import { signEvent, encrypt, decrypt } from '$lib/nip07'; +const pool = new SimplePool(); + +import { PUBLIC_DM_RELAYS } from '$env/static/public'; +const DM_RELAYS_LIST = PUBLIC_DM_RELAYS.split(','); + +// Mute the provided pubkey, as the provided user. +export const mute = async (user: object, pubkey: string, hide: bool) => { + let { shown, hidden } = await getMuteLists(user); + if (hide) { + hidden.push(["p", pubkey]); + } else { + shown.push(["p", pubkey]); + } + await publishMuteList(user, shown, hidden); +} + +const tagMatches = (tag: string[], pubkey: string): bool => + tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey; + +// Unmute the provided pubkey, as the provided user. +export const unmute = async (user: object, pubkey: string) => { + let { shown, hidden } = await getMuteLists(user); + shown = shown.filter(tag => !tagMatches(tag, pubkey)); + hidden = hidden.filter(tag => !tagMatches(tag, pubkey)); + await publishMuteList(user, shown, hidden); +} + +// Returns a set of the provided user's muted accounts. +export const mutedAccounts = async (user: object): Set<string> => { + const { shown, hidden } = await getMuteLists(user); + let muted = new Set<string>(); + for (const tag of shown) { + if (tag.length >= 2 && tag[0] === "p") { + muted.add(tag[1]); + } + } + for (const tag of hidden) { + if (tag.length >= 2 && tag[0] === "p") { + muted.add(tag[1]); + } + } + return muted; +} + +const getMuteLists = async (user: object): object => { + const listEvents = await pool.querySync( + DM_RELAYS_LIST, { kinds: [10000], "authors": [user.pubkey], limit: 1 } + ); + if (listEvents.length == 0) { + return { shown: [], hidden: [] }; + } + + return { + shown: listEvents[0].tags, + hidden: listEvents[0].content == "" ? [] : + await decrypt(listEvents[0].content, user, user.pubkey) + }; +} + +const publishMuteList = async (user: object, shown: string[], hidden: string[]) => { + const event = await signEvent({ + kind: 10000, + created_at: Math.floor(Date.now() / 1000), + tags: shown, + content: await encrypt(hidden, user, user.pubkey) + }, user); + return Promise.any(pool.publish(DM_RELAYS_LIST, event)); +} diff --git a/src/lib/nostr.ts b/src/lib/nostr.ts index a22882bc..beebba02 100644 --- a/src/lib/nostr.ts +++ b/src/lib/nostr.ts @@ -210,3 +210,14 @@ const signingMethods = { return signedEvent; }, }; + +// Returns all the keys mentioned in the provided event's 'p' tags. +export const pTagKeys = (event: object): string[] => { + let keys = []; + for (const tag of event.tags) { + if (tag[0] === "p") { + keys.push(tag[1]); + } + } + return keys; +} diff --git a/src/locales/en.json b/src/locales/en.json index 2d5ef646..229c107c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -92,6 +92,29 @@ "description": "Over 500 businesses worldwide are accepting bitcoin with Coinos. We hope that yours will be next!" } }, + "dm": { + "chatHeader": "Chats", + "newChatHeader": "New Chat", + "header": "Direct Messages", + "searchPrompt": "username, nip-05 or pubkey", + "findByUsername": "Find by username", + "findByNIP05": "Find by NIP-05", + "findByPubkey": "Find by pubkey", + "noChats": "You don't have any chats yet.", + "selectChat": "Select a chat from the list to the left.", + "recipientMsg": "You are sending nostr messages to [R].", + "sendMessage": "Send Message", + "mute": "Mute", + "unmute": "Unmute", + "expiry": "Enable message expiry", + "cantSendExpiring": "None of [R]'s DM relays support message expiry. You can't send expiring messages to them.", + "fadedWarning": "To avoid metadata leaks, messages expire at a random time up to 2 days after you specify; each message has its own random expiry time. Messages which appear faded are close to expiring; nearby messages may be missing.", + "days": "days", + "relaysNotFound": "Could not find [R]’s DM relays.", + "noRelaysSet": "WARNING: You haven't set any preferred relays. You won't be able to receive any messages or see the messages you've sent.", + "nostrSettingsLink": "Go to your Nostr settings to set your relays.", + "anonymous": "Anonymous" + }, "error": { "header": "Error", "message": "Please try again or contact support.", @@ -215,6 +238,7 @@ "light": "Light", "about": "About", "docs": "Docs", + "dm": "Nostr Messages", "messages": "Messages", "funds": "Funds", "map": "Map", @@ -337,6 +361,9 @@ "revealNsec": "Reveal secret key", "hideSeed": "Hide seed phrase", "revealSeed": "Reveal seed phrase", + "preferredDM": "Preferred DM Relays", + "preferredDMDescription": "Direct Messages meant for you will be published to these relays. To get DMs on multiple relays, put each relay URL on its own line.", + "noDMURLsWarning": "You haven't set any valid relay URLs. You will not be able to use DMs.", "saveSettings": "Save settings", "bannerImage": "Banner Image", "profileImage": "Profile Image", diff --git a/src/locales/fr.json b/src/locales/fr.json index 7ae6896b..9b2c767b 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -24,6 +24,29 @@ } } }, + "dm": { + "chatHeader": "Discussions", + "newChatHeader": "Nouveau Discussion", + "header": "Messages directs", + "searchPrompt": "nom, nip-05 ou clé", + "findByUsername": "Trouvez par nom d'utilisateur", + "findByNIP05": "Trouvez par NIP-05", + "findByPubkey": "Trouvez par clé", + "noChats": "Vous avez aucun discussion.", + "selectChat": "Chossisez un discussion de la liste à la gauche.", + "recipientMsg": "Vous envoyez messages nostr à [R].", + "sendMessage": "Envoyez Message", + "mute": "Silencez", + "unmute": "Réactivez", + "expiry": "Expirez messages", + "cantSendExpiring": "Aucun des relais de [R] expirent les messages expirants. Vous ne pouvez pas leur envoyer des messages expirants.", + "fadedWarning": "Pour protéger vos métadonées, vos messages expirent à un temps randomée au maximum 2 jours après la date que vous spécifiez; chaque message a son propre temps d’expiration randomée. Les messages qui semble faibles sont proche à expirer; les autres messages autours pouvait avoir déjà disparu.", + "days": "jours", + "relaysNotFound": "Ne pouvait pas trouvez les relais MD de [R].", + "noRelaysSet": "AVERTISSEMENT: Vous n’avez pas specifié vos relais préferés pour messages directs. Vous ne pouvez pas reçevoir des messages ou voir les messages que vous avez envoyé.", + "nostrSettingsLink": "Spécifiez vos relais en vos préférences Nostr.", + "anonymous": "Anonyme" + }, "error": { "header": "Erreur", "message": "S'il vous plait réessayez ou contactez l'assistance.", @@ -138,6 +161,7 @@ "merch": "Marchandise", "about": "À Propos", "docs": "Documents", + "dm": "Messages Nostr", "messages": "Messages", "funds": "Fonds", "dark": "Sombre", @@ -236,6 +260,9 @@ "hours": "heures", "hideSeed": "Cacher la phrase mnémotechnique", "revealSeed": "Montrer la phrase mnémotechnique", + "preferredDM": "Relais MD Préferés", + "preferredDMDescription": "Messages directs destinés à vous seront publiés à ces relais. Pour obtiendre des messages sur plusieurs relais, met chaque URL sur son propre ligne.", + "noDMURLsWarning": "Vous n'avez pas specifié aucun URL valide. Vous ne pourrez pas utiliser les messages directs.", "notifications": "Notifications de paiement", "notificationsDesc": "Reçevez un email quand un paiement arrive à votre compte", "seedPhrase": "Phrase mnémotechnique", diff --git a/src/routes/(app)/[username]/(profile)/+layout.svelte b/src/routes/(app)/[username]/(profile)/+layout.svelte index b384344e..515dba5d 100644 --- a/src/routes/(app)/[username]/(profile)/+layout.svelte +++ b/src/routes/(app)/[username]/(profile)/+layout.svelte @@ -140,6 +140,15 @@ <iconify-icon noobserver icon="ph:pencil-bold" width="32" ></iconify-icon> </a> + {:else} + <a + href="/dm?username={subject.username}" + class="btn contents" + aria-label="Direct Message" + > + <iconify-icon noobserver icon="ph:chat-bold" width="32" + ></iconify-icon> + </a> {/if} <!-- <a href={`/${subject.pubkey}/notes`}> --> <!-- <iconify-icon noobserver icon="ph:note-bold" width="32"></iconify-icon> --> diff --git a/src/routes/(app)/dm/+page.svelte b/src/routes/(app)/dm/+page.svelte new file mode 100644 index 00000000..5fe04aa8 --- /dev/null +++ b/src/routes/(app)/dm/+page.svelte @@ -0,0 +1,508 @@ +<script lang="ts"> + import { tick, onMount } from "svelte"; + import Icon from "$comp/Icon.svelte"; + + import { isValid, queryProfile } from "nostr-tools/nip05"; + import * as toolsnip17 from 'nostr-tools/nip17'; + + import { getNostrUserInfo } from '$lib/nip01'; + import { relaysSupporting } from '$lib/nip11'; + import { getMessageRumours } from '$lib/nip17'; + import * as libnip17 from '$lib/nip17'; + import { npubEncode, decode } from 'nostr-tools/nip19'; + import { mute, unmute, mutedAccounts } from '$lib/nip51'; + import { sign, getPrivateKey, pTagKeys } from '$lib/nostr'; + import { t } from "$lib/translations"; + import { theme } from "$lib/store"; + + let { data } = $props(); + const { user } = data; + + /// chat selection + const usernameFromPubkey = async (pubkey: string): string => { + const response = await fetch(`/api/users/${pubkey}`); + if (response.ok) { + const userInfo = await response.json(); + return userInfo.anon ? null : userInfo.username; + } else { + return null; + } + } + + const userInfo = async (pubkey: string) => { + if (pubkey.slice(0, 4) === "npub") { + return userInfo(decode(pubkey).data); + } + + const nostrUserInfo = await getNostrUserInfo(pubkey) || {}; + const valid = 'nip05' in nostrUserInfo && await isValid(pubkey, nostrUserInfo.nip05); + const username = await usernameFromPubkey(pubkey); + return { + coinosUsername: username, + nostrName: nostrUserInfo?.name, + nip05: nostrUserInfo?.nip05, + nip05Valid: valid, pubkey }; + } + + const name = (userInfo: object): string => { + return userInfo.coinosUsername || userInfo.nostrName || $t("dm.anonymous"); + } + + const id = (userInfo: object): string => { + if (userInfo.nip05 && userInfo.nip05.slice(0, 2) == "_@") { + return userInfo.nip05.slice(2); + } + + return userInfo.nip05 || (npubEncode(userInfo.pubkey).slice(0, 16) + "..."); + } + + const idValid = (userInfo: object): string => { + return !userInfo.nip05 || userInfo.nip05Valid; + } + + const rumourInvolves = (rumour: object, pubkey: string) => { + if (rumour.pubkey === pubkey) { + return true; + } else { + for (const tagPK of pTagKeys(rumour)) { + if (tagPK === pubkey) { + return true; + } + } + return false; + } + } + + const mostRecentCommunication = (rumours: object[], pubkey: string) => { + let mostRecentTime = null; + for (const rumour of rumours.filter((r) => rumourInvolves(r, pubkey))) { + if (!mostRecentTime || rumour.created_at > mostRecentTime) { + mostRecentTime = rumour.created_at; + } + } + return mostRecentTime; + } + + const updateSendersRecipients = async (rumours: object[]) => { + const senders = new Set<object>(); + const recipients = new Set<object>(); + for (const rumour of rumours) { + if (rumour.pubkey !== user.pubkey) { + senders.add(await userInfo(rumour.pubkey)); + } else { + for (const pubkey of pTagKeys(rumour)) { + recipients.add(await userInfo(pubkey)); + } + } + } + messageSenders = Array.from(senders); + messageRecipients = Array.from(recipients); + + const chatMap = new Map<string, object>(); + for (const sender of senders) { + chatMap.set(sender.pubkey, sender); + } + for (const recipient of recipients) { + chatMap.set(recipient.pubkey, recipient); + } + chats = Array.from(chatMap.values()); + chats.sort((a, b) => mostRecentCommunication(rumours, b.pubkey) - mostRecentCommunication(rumours, a.pubkey)); + }; + + let messageSenders = $state([]); + let messageRecipients = $state([]); + let chats = $state([]); + getMessageRumours(user).then(updateSendersRecipients); + + let selectedChat = $state(null); + let creatingNewChat = $state(false); + let searchQuery = $state(""); + + /// individual chat + let text = $state(""); + let allRumours = $state([]); + let messageRumours = $state([]); + let dates = $state([]); + let relayWarningShown = $state(false); + let expiryEnabled = $state(false); + let expiryDays = $state(7); + let canSend = $state(false); + let canSendExpiring = $state(false); + let nostrUserInfo = $state({}); + + const includesPubkey = (chats: object[], pubkey: string) => { + for (const c of chats) { + if (c.pubkey === pubkey) { + return true; + } + } + return false; + } + + const selectChat = (c: object) => { + selectedChat = c; + if (!includesPubkey(chats, c.pubkey)) { + chats.unshift(c); + } + userInfo(selectedChat.pubkey).then(info => nostrUserInfo = info); + libnip17.getPreferredRelays(selectedChat.pubkey).then((relays) => { + canSend = relays && relays.length > 0; + relaysSupporting(relays, [40]) + .then((supporting) => canSendExpiring = supporting.length > 0); + }); + updateEvents(); + } + + const selectChatUsername = async (username: string) => { + const response = await fetch(`/api/users/${username}`); + if (!response.ok) { + alert("Invalid username"); + return; + } + const coinosUserInfo = await response.json(); + selectChatPubkey(coinosUserInfo.pubkey); + } + + const selectChatNip05 = async (nip05: string) => { + const response = await queryProfile(nip05); + if (response == null) { + alert("Invalid nip-05"); + return; + } + selectChatPubkey(response.pubkey); + } + + const selectChatPubkey = async (pubkey: string) => { + selectChat(await userInfo(pubkey)); + creatingNewChat = false; + } + + onMount(() => { + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('username')) { + selectChatUsername(urlParams.get('username')); + } else if (urlParams.get('nip05')) { + selectChatNip05(urlParams.get('nip05')); + } else if (urlParams.get('pubkey')) { + selectChatPubkey(urlParams.get('pubkey')); + } + }); + + const appendMultimap = (map: Map, key: string, value: object) => { + if (map.has(key)) { + map.get(key).push(value); + } else { + map.set(key, [value]); + } + } + + const dateMap = (events: object[]): Map<number, object> => { + let eventsMap = new Map(); + for (const event of events) { + const created = Math.floor(event.created_at / 86400); + appendMultimap(eventsMap, created, event); + } + for (const [date, dateEvents] of eventsMap) { + dateEvents.sort((ev1, ev2) => ev1.created_at - ev2.created_at); + } + return eventsMap; + } + + const updateEvents = async () => { + allRumours = await libnip17.getMessageRumours(user); + const rumours = allRumours.filter((r) => + (r.pubkey === user.pubkey && pTagKeys(r).includes(selectedChat.pubkey)) || + (r.pubkey === selectedChat.pubkey && pTagKeys(r).includes(user.pubkey))); + messageRumours = dateMap(rumours); + + dates = Array.from(messageRumours.keys()); + dates.sort((d1, d2) => d1 - d2); + + await tick(); + const messages = document.getElementById("messages"); + if (messages) { + messages.scrollTop = messages.scrollHeight; + } + } + + const TIME = new Intl.DateTimeFormat( + undefined, { hour: "numeric", hour12: false, minute: "numeric" }); + const NEAR_DATE = new Intl.DateTimeFormat( + undefined, { month: "short", day: "numeric", weekday: "short" }); + const FAR_DATE = new Intl.DateTimeFormat( + undefined, { year: "numeric", month: "short", day: "numeric", weekday: "short" }); + const NEAR_DISTANCE = 182 * 24 * 60 * 60 * 1000; // around 6 months + const formatDate = (date: Date): string => { + if (date.getTime() >= Date.now() - NEAR_DISTANCE) { + return NEAR_DATE.format(date); + } else { + return FAR_DATE.format(date); + } + } + + const expirationClose = (expiration: number) => { + return expiration <= (Date.now() / 1000 + 2 * 86400); + } + + const sendMessage = async (message: string) => { + const expiry = expiryEnabled ? expiryDays : null; + await libnip17.send(message, user, selectedChat, expiry); + updateEvents(); + } + + libnip17.getPreferredRelays(user.pubkey).then((relays) => { + if (!relays || relays.length == 0) { + relayWarningShown = true; + return; + } + relayWarningShown = false; + }); + + let muted = $state(new Set<string>()); + mutedAccounts(user).then((m) => muted = m); + + const toggleMute = async (pubkey: string) => { + if (muted && muted.has(pubkey)) { + await unmute(user, pubkey); + } else { + await mute(user, pubkey, true); + } + muted = await mutedAccounts(user); + } +</script> + +<style> + .warning { + color: #ff7f00; + } + + .secondary { + color: #7f7f7f; + } + + .link { + text-decoration-line: underline; + } + + .inline { + white-space: nowrap; + } + + .short { + height: 1.5em; + margin-bottom: 10px; + } + + .small-width { + width: 6em; + } + + .tiny { + width: 2em; + } + + .invalid { + text-decoration: line-through; + } + + .super-container { + display: flex; + } + + .sidebar { + max-width: 300px; + padding: 0px 10px 5px; + margin: 0px 5px; + height: 100%; + border-radius: 10px; + } + + .light-sidebar { + background-color: #eeeeee; + } + + .dark-sidebar { + background-color: #222222; + } + + .chat-btn { + margin: 2px 0px; + padding: 0 25px; + border-radius: 10px; + min-width: 30px; + text-align: left; + } + + .tall-btn { + min-height: 3em; + width: 100%; + } + + .small-btn { + padding: 0 15px; + border-radius: 5px; + text-align: left; + } + + .push-right { + margin-left: auto; + } + + .chat-header { + margin: 1em 15px 0.5em; + } + + .light-chat-btn { + background-color: #eeeeee; + } + + .dark-chat-btn { + background-color: #222222; + } + + .light-chat-btn:hover { + background-color: #dddddd; + } + + .dark-chat-btn:hover { + background-color: #333333; + } + + .light-selected { + background-color: #dddddd; + } + + .dark-selected { + background-color: #333333; + } + + .main { + padding-left: 0px; + margin-left: 30px; + } + + .date-header { + text-align: center; + margin: 1.5em 0em; + } + + .timestamp { + margin-left: 0.5em; + text-align: right; + } + + .message { + padding: 0.25em 0.75em; + width: fit-content; + max-width: 75%; + border-radius: 10px; + } + + .light-message { + background-color: #DDD; + } + + .dark-message { + background-color: #222; + } + + .by-user { + margin: 3px 0px 3px auto; + } + + .by-other { + margin: 3px auto 3px 0px; + } + + .vcenter { + margin: auto 0px; + } +</style> + +<div class="super-container"> + <div class={"sidebar " + ($theme === "light" ? "light-sidebar" : "dark-sidebar")}> + <h2 class="text-xl chat-header"> + {creatingNewChat ? $t("dm.newChatHeader") : $t("dm.chatHeader")} + <button class={"chat-btn push-right " + ($theme === "light" ? "light-chat-btn" : "dark-chat-btn") + (creatingNewChat ? ($theme === "light" ? " light-selected" : " dark-selected") : "")} on:click={() => {creatingNewChat = !creatingNewChat; selectedChat = null}}>+</button> + </h2> + {#if creatingNewChat} + <input type="text" class="short" bind:value={searchQuery} placeholder="{$t("dm.searchPrompt")}"> + {/if} + {#if !creatingNewChat} + {#each chats as c} + {#if (selectedChat && selectedChat.pubkey === c.pubkey) || !(muted && muted.has(c.pubkey))} + <button class={"chat-btn tall-btn " + ($theme === "light" ? "light-chat-btn" : "dark-chat-btn") + (selectedChat && selectedChat.pubkey == c.pubkey ? ($theme === "light" ? " light-selected" : " dark-selected") : "")} on:click={() => selectChat(c)}> + <span class="{"text-xl" + (muted && muted.has(c.pubkey) ? " secondary" : "")}">{name(c)}</span> <span class={(idValid(c) ? "" : "invalid ") + "secondary text-xs"}>{id(c)}</span> + </button> + {/if} + {/each} + {:else if searchQuery !== ""} + <button class={"chat-btn tall-btn " + ($theme === "light" ? "light-chat-btn" : "dark-chat-btn")} on:click={() => selectChatUsername(searchQuery)}> + <span class="text-xl">{$t("dm.findByUsername")}</span> + </button> + {#if searchQuery.includes(".")} + <button class={"chat-btn tall-btn " + ($theme === "light" ? "light-chat-btn" : "dark-chat-btn")} on:click={() => selectChatNip05(searchQuery)}> + <span class="text-xl">{$t("dm.findByNIP05")}</span> + </button> + {/if} + {#if searchQuery.length === 64 || searchQuery.slice(0, 4) === "npub"} + <button class={"chat-btn tall-btn " + ($theme === "light" ? "light-chat-btn" : "dark-chat-btn")} on:click={() => selectChatPubkey(searchQuery)}> + <span class="text-xl">{$t("dm.findByPubkey")}</span> + </button> + {/if} + {/if} + </div> + <div class="main container space-y-2 mx-auto space-y-5 lg:max-w-xl xl:max-w-2xl lg:pl-10 mt-5 lg:mt-0"> + <h1 + class="text-5xl font-medium text-left w-full mx-auto lg:mx-0 md:w-[500px]" + >{$t("dm.header")}</h1> + + {#if selectedChat} + <h1> + {#if selectedChat.coinosUsername} + <a class="text-3xl" href="/{selectedChat.coinosUsername}">{name(selectedChat)}</a> + {:else} + <span class="text-3xl">{name(selectedChat)}</span> + {/if} + <span class={"secondary timestamp text-small" + (idValid(selectedChat) ? "" : " invalid")} title="{npubEncode(selectedChat.pubkey)}">{id(selectedChat)}</span> + <button class={"small-btn push-right " + ($theme === "light" ? "light-chat-btn" : "dark-chat-btn")} on:click={() => toggleMute(selectedChat.pubkey)}>{$t(muted && muted.has(selectedChat.pubkey) ? "dm.unmute" : "dm.mute")}</button> + </h1> + + {#if !(muted && muted.has(selectedChat.pubkey))} + <div id="messages" style="overflow-y: scroll; max-height: 600px;"> + {#each dates as day} + <p class="date-header secondary">{formatDate(new Date(day * 86400 * 1000))}</p> + <ul> + {#each messageRumours.get(day) as rumour} + {#if rumour.pubkey === user.pubkey && pTagKeys(rumour).includes(selectedChat.pubkey)} + <li class={(rumour.expiration && expirationClose(rumour.expiration) ? "secondary " : "") + ($theme === "light" ? "light-message " : "dark-message ") + "message by-user"}>{rumour.content} <span class="timestamp secondary text-xs">{TIME.format(new Date(rumour.created_at * 1000))}</span></li> + {:else if rumour.pubkey === selectedChat.pubkey && pTagKeys(rumour).includes(user.pubkey)} + <li class={(rumour.expiration && expirationClose(rumour.expiration) ? "secondary " : "") + ($theme === "light" ? "light-message " : "dark-message ") + "message by-other"}>{rumour.content} <span class="timestamp secondary text-xs">{TIME.format(new Date(rumour.created_at * 1000))}</span></li> + {/if} + {/each} + </ul> + {/each} + </div> + + <textarea id="message-contents" bind:value={text}></textarea> + <input id="send-message" type="button" class="btn btn-accent" disabled={!canSend} value={canSend ? $t("dm.sendMessage") : $t("dm.relaysNotFound").replace("[R]", name(selectedChat))} on:click={async () => sendMessage(text)}> + {/if} + + {#if !(muted && muted.has(selectedChat.pubkey))} + <input type="checkbox" id="expiryCheckbox" class="tiny" bind:checked={expiryEnabled} disabled={!canSendExpiring}> + <label for="expiryCheckbox">{$t("dm.expiry")}</label>: <input type="number" class="short small-width vcenter" bind:value={expiryDays} disabled={!expiryEnabled} min="1" step="1" max="99999"> <label for="expiryCheckbox">{$t("dm.days")}</label>. + <p class="secondary">{$t("dm.fadedWarning")}</p> + {#if canSend && !canSendExpiring} + <p class="warning"><em>{name(selectedChat)}</em></p> + {/if} + {/if} + {:else if chats.length == 0} + <p>{$t("dm.noChats")}</p> + {:else} + <p>{$t("dm.selectChat")}</p> + {/if} + {#if relayWarningShown} + <p class="warning"><em>{$t("dm.noRelaysSet")} <a class="link" href="/settings/nostr#dm-relays">{$t("dm.nostrSettingsLink")}</a></em></p> + {/if} + </div> +</div> diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte index d0d1a256..e603cbf4 100644 --- a/src/routes/(app)/settings/+layout.svelte +++ b/src/routes/(app)/settings/+layout.svelte @@ -1,4 +1,4 @@ -<script> +<script lang="ts"> import { browser } from "$app/environment"; import { onMount, tick } from "svelte"; import { fly } from "svelte/transition"; @@ -17,6 +17,9 @@ import { getPublicKey } from "nostr-tools"; import { bytesToHex } from "@noble/hashes/utils"; + import { SimplePool } from 'nostr-tools/pool'; + import { finalizeEvent } from 'nostr-tools/pure'; + let { children, data, form } = $props(); let formElement = $state(); @@ -45,7 +48,47 @@ let { about, id, username } = $derived(user); let submitting = $state(); + const pool = new SimplePool(); + import { PUBLIC_DM_RELAYS } from '$env/static/public'; + const DM_RELAYS_LIST = PUBLIC_DM_RELAYS.split(','); + const updateRelaysIfAvailable = async () => { + const relayEntry = document.getElementById('dmRelays'); + if (!relayEntry || relayEntry.value.length === 0) return; + + const newRelays = relayEntry.value.split(/[ \r\n\t]+/) + .filter(isValidURL); + if (newRelays.length === 0) { + warning($t("user.settings.noDMURLsWarning")); + return; + } + + const event = { + kind: 10050, + created_at: Math.floor(Date.now() / 1000), + tags: newRelays.map(r => ["relay", r]), + content: "" + }; + let signed; + if (await window.nostr.getPublicKey() === user.pubkey) { + signed = await window.nostr.signEvent(event); + } else { + const sk = await getPrivateKey(user); + signed = finalizeEvent(event, sk); + } + await Promise.any(pool.publish(DM_RELAYS_LIST, signed)); + } + + const isValidURL = (url: string) => { + try { + new URL(url); + return true; + } catch (err) { + return false; + } + } + async function handleSubmit(e) { + updateRelaysIfAvailable(); e.preventDefault(); try { submitting = true; diff --git a/src/routes/(app)/settings/nostr/+page.svelte b/src/routes/(app)/settings/nostr/+page.svelte index 7168f4f7..367f04f5 100644 --- a/src/routes/(app)/settings/nostr/+page.svelte +++ b/src/routes/(app)/settings/nostr/+page.svelte @@ -1,4 +1,4 @@ -<script> +<script lang="ts"> import { untrack } from "svelte"; import { browser } from "$app/environment"; import { getNsec } from "$lib/nostr"; @@ -11,6 +11,7 @@ PUBLIC_COINOS_RELAY as relay, } from "$env/static/public"; import { signer, save } from "$lib/store"; + import { getPreferredRelays } from "$lib/nip17"; let { data } = $props(); let { apps, challenge, user } = $derived(data); @@ -18,6 +19,8 @@ let extensionAvailable = $derived(browser && window.nostr); let { locale } = $derived(user); + import { SimplePool } from 'nostr-tools/pool'; + let newNsec = $state(), nsec = $state(), pin = $state(""), @@ -41,6 +44,14 @@ await tick(); $save.click(); }; + + import { PUBLIC_DM_RELAYS } from '$env/static/public'; + const DM_RELAYS_LIST = PUBLIC_DM_RELAYS.split(','); + const pool = new SimplePool(); + getPreferredRelays(user.pubkey).then(relays => { + const relayEntry = document.getElementById('dmRelays'); + relayEntry.value = relays.join("\n"); + }); </script> <input type="hidden" name="challenge" value={challenge} /> @@ -231,3 +242,12 @@ {/if} </div> {/if} + +<span class="font-bold" id="dm-relays"> + {$t("user.settings.preferredDM")} +</span> +<p class="text-secondary mb-1"> + {$t("user.settings.preferredDMDescription")} +</p> + +<textarea id="dmRelays" name="dmRelays" rows={3}></textarea> |
