aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/Menu.svelte1
-rw-r--r--src/lib/nip01.ts19
-rw-r--r--src/lib/nip07.ts42
-rw-r--r--src/lib/nip11.ts15
-rw-r--r--src/lib/nip17.ts223
-rw-r--r--src/lib/nip40.ts16
-rw-r--r--src/lib/nip44.ts135
-rw-r--r--src/lib/nip51.ts71
-rw-r--r--src/lib/nostr.ts11
-rw-r--r--src/locales/en.json27
-rw-r--r--src/locales/fr.json27
-rw-r--r--src/routes/(app)/[username]/(profile)/+layout.svelte9
-rw-r--r--src/routes/(app)/dm/+page.svelte508
-rw-r--r--src/routes/(app)/settings/+layout.svelte45
-rw-r--r--src/routes/(app)/settings/nostr/+page.svelte22
15 files changed, 1169 insertions, 2 deletions
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>