From d1cb1e6bb767223538078ef011af865eae67d941 Mon Sep 17 00:00:00 2001 From: Attila Gazso <230163+agazso@users.noreply.github.com> Date: Thu, 16 Nov 2023 14:29:14 +0100 Subject: [PATCH] feat: babbles logic proof-of-concept (#495) --- src/lib/adapters/index.ts | 2 +- src/lib/adapters/waku/codec.ts | 29 +- src/lib/adapters/waku/crypto.test.ts | 28 +- src/lib/adapters/waku/crypto.ts | 31 +- src/lib/adapters/waku/index.ts | 234 +++++++++---- src/lib/adapters/waku/message-hash.test.ts | 48 +++ src/lib/adapters/waku/message-hash.ts | 52 +++ src/lib/adapters/waku/safe-waku.ts | 64 ++-- src/lib/adapters/waku/wakustore.ts | 20 +- src/lib/components/chat-message.svelte | 9 + src/lib/components/icons/babbles.svelte | 29 ++ src/lib/routes.ts | 4 + src/lib/stores/chat.ts | 29 +- src/routes/+page.svelte | 30 +- .../chat/[id]/[[thread_id]]/+page.svelte | 321 ++++++++++++++++++ .../babbles/chat/[id]/edit/+page.svelte | 177 ++++++++++ src/routes/babbles/new/+page.svelte | 113 ++++++ src/routes/invite/[address]/+page.svelte | 13 + 18 files changed, 1109 insertions(+), 124 deletions(-) create mode 100644 src/lib/adapters/waku/message-hash.test.ts create mode 100644 src/lib/adapters/waku/message-hash.ts create mode 100644 src/lib/components/icons/babbles.svelte create mode 100644 src/routes/babbles/chat/[id]/[[thread_id]]/+page.svelte create mode 100644 src/routes/babbles/chat/[id]/edit/+page.svelte create mode 100644 src/routes/babbles/new/+page.svelte diff --git a/src/lib/adapters/index.ts b/src/lib/adapters/index.ts index f463911b..b6325018 100644 --- a/src/lib/adapters/index.ts +++ b/src/lib/adapters/index.ts @@ -22,7 +22,7 @@ export interface Adapter { name: string, avatar?: string, ): Promise - + startBabbles(wallet: BaseWallet, chatId: string, name: string, avatar?: string): Promise addMemberToGroupChat(chatId: string, users: string[]): Promise removeFromGroupChat(chatId: string, address: string): Promise saveGroupChatProfile(chatId: string): Promise diff --git a/src/lib/adapters/waku/codec.ts b/src/lib/adapters/waku/codec.ts index a5a385d7..ff63d4e2 100644 --- a/src/lib/adapters/waku/codec.ts +++ b/src/lib/adapters/waku/codec.ts @@ -2,9 +2,13 @@ import { createEncoder as createWakuSymmetricEncoder, createDecoder as createWakuSymmetricDecoder, } from '@waku/message-encryption/symmetric' +import { + createEncoder as createWakuEciesEncoder, + createDecoder as createWakuEciesDecoder, +} from '@waku/message-encryption/ecies' import { getTopic, type ContentTopic } from './waku' import { bytesToHex, hexToBytes } from '@waku/utils/bytes' -import { type Hex, fixHex } from './crypto' +import { type Hex, fixHex, uncompressPublicKey, privateKeyToPublicKey } from './crypto' function toHex(value: Uint8Array | Hex): Hex { return typeof value === 'string' ? fixHex(value) : bytesToHex(value) @@ -31,3 +35,26 @@ export function createSymmetricDecoder(options: { const contentTopic = getTopic(options.contentTopic, toHex(options.symKey)) return createWakuSymmetricDecoder(contentTopic, hexToBytes(options.symKey)) } + +export function createEciesEncoder(options: { + contentTopic: ContentTopic + publicKey: Uint8Array | Hex + sigPrivKey?: Uint8Array | Hex +}) { + const contentTopic = getTopic(options.contentTopic, toHex(options.publicKey)) + return createWakuEciesEncoder({ + ...options, + contentTopic, + publicKey: hexToBytes(uncompressPublicKey(options.publicKey)), + sigPrivKey: options.sigPrivKey ? hexToBytes(options.sigPrivKey) : undefined, + }) +} + +export function createEciesDecoder(options: { + contentTopic: ContentTopic + privateKey: Uint8Array | Hex +}) { + const publicKey = privateKeyToPublicKey(options.privateKey) + const contentTopic = getTopic(options.contentTopic, toHex(publicKey)) + return createWakuEciesDecoder(contentTopic, hexToBytes(options.privateKey)) +} diff --git a/src/lib/adapters/waku/crypto.test.ts b/src/lib/adapters/waku/crypto.test.ts index 0ddc7791..c8b8295c 100644 --- a/src/lib/adapters/waku/crypto.test.ts +++ b/src/lib/adapters/waku/crypto.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect } from 'vitest' -import { publicKeyToAddress } from './crypto' +import { + compressPublicKey, + privateKeyToPublicKey, + publicKeyToAddress, + uncompressPublicKey, +} from './crypto' -// const testPrivateKey = 'd195918969e09d9394c768e25b621eafc4c360117a9e1eebb0a68bfd53119ba4' +const testPrivateKey = 'd195918969e09d9394c768e25b621eafc4c360117a9e1eebb0a68bfd53119ba4' const testCompressedPublicKey = '0374a6b1cea74a7a755396d8c62a3be4eb9098c7bb286dcdfc02ab93e7683c93f9' const testUncompressedPublicKey = '0474a6b1cea74a7a755396d8c62a3be4eb9098c7bb286dcdfc02ab93e7683c93f99515f1cf8980e0cb25b6078113813d90d99303aaea1aa34c12805f8355768e21' @@ -18,3 +23,22 @@ describe('publicKeyToAddress', () => { expect(address).toEqual(testAddress) }) }) + +describe('public key compression', () => { + it('calculates compressed public key', () => { + const result = compressPublicKey(testUncompressedPublicKey) + expect(result).toEqual(testCompressedPublicKey) + }) + + it('calculates uncompressed public key', () => { + const result = uncompressPublicKey(testCompressedPublicKey) + expect(result).toEqual(testUncompressedPublicKey) + }) +}) + +describe('privateKeyToPublicKey', () => { + it('returns the public key', () => { + const publicKey = privateKeyToPublicKey(testPrivateKey) + expect(publicKey).toEqual(testCompressedPublicKey) + }) +}) diff --git a/src/lib/adapters/waku/crypto.ts b/src/lib/adapters/waku/crypto.ts index 24268e77..6816c1ad 100644 --- a/src/lib/adapters/waku/crypto.ts +++ b/src/lib/adapters/waku/crypto.ts @@ -1,5 +1,10 @@ -import { getSharedSecret as nobleGetSharedSecret, ProjectivePoint } from '@noble/secp256k1' +import { + getPublicKey, + getSharedSecret as nobleGetSharedSecret, + ProjectivePoint, +} from '@noble/secp256k1' import { keccak_256 } from '@noble/hashes/sha3' +import { sha256 as nobleSha256 } from '@noble/hashes/sha256' import { bytesToHex, hexToBytes } from '@waku/utils/bytes' import { gcm } from '@noble/ciphers/aes' import { randomBytes } from '@noble/ciphers/webcrypto/utils' @@ -13,6 +18,16 @@ export function fixHex(h: Hex): Hex { return h } +export function keccak256(data: Uint8Array | Hex): Hex { + const hashBytes = keccak_256(data) + return bytesToHex(hashBytes) +} + +export function sha256(data: Uint8Array | Hex): Hex { + const hashBytes = nobleSha256(data) + return bytesToHex(hashBytes) +} + export function getSharedSecret(privateKey: Hex, publicKey: Hex): Hex { const secretBytes = nobleGetSharedSecret(fixHex(privateKey), fixHex(publicKey), true) return hash(secretBytes.slice(1)) @@ -20,8 +35,7 @@ export function getSharedSecret(privateKey: Hex, publicKey: Hex): Hex { export function hash(data: Uint8Array | Hex): Hex { const bytes = typeof data === 'string' ? hexToBytes(data) : data - const hashBytes = keccak_256(bytes) - return bytesToHex(hashBytes) + return keccak256(bytes) } export function encrypt( @@ -52,3 +66,14 @@ export function compressPublicKey(publicKey: Hex | Uint8Array): Hex { publicKey = typeof publicKey === 'string' ? fixHex(publicKey) : bytesToHex(publicKey) return ProjectivePoint.fromHex(publicKey).toHex(true) } + +export function uncompressPublicKey(publicKey: Hex | Uint8Array): Hex { + publicKey = typeof publicKey === 'string' ? fixHex(publicKey) : bytesToHex(publicKey) + return ProjectivePoint.fromHex(publicKey).toHex(false) +} + +export function privateKeyToPublicKey(privateKey: Hex | Uint8Array): Hex { + privateKey = typeof privateKey === 'string' ? fixHex(privateKey) : bytesToHex(privateKey) + const publicKeyBytes = getPublicKey(privateKey) + return bytesToHex(publicKeyBytes) +} diff --git a/src/lib/adapters/waku/index.ts b/src/lib/adapters/waku/index.ts index 94c24afa..7bc173c6 100644 --- a/src/lib/adapters/waku/index.ts +++ b/src/lib/adapters/waku/index.ts @@ -9,6 +9,10 @@ import { type ChatData, getLastMessageTime, type InviteMessage, + type UserMessage, + type WithoutMeta, + type ChatMessage, + type BabbleMessage, } from '$lib/stores/chat' import type { User } from '$lib/types' import type { TimeFilter } from '@waku/interfaces' @@ -50,8 +54,14 @@ import { errorStore } from '$lib/stores/error' import { compressPublicKey, fixHex, getSharedSecret, hash } from './crypto' import { bytesToHex, hexToBytes } from '@waku/utils/bytes' import { encrypt, decrypt } from './crypto' -import { createSymmetricDecoder, createSymmetricEncoder } from './codec' +import { + createEciesDecoder, + createEciesEncoder, + createSymmetricDecoder, + createSymmetricEncoder, +} from './codec' import type { DecodedMessage } from '@waku/message-encryption' +import { utils } from '@noble/secp256k1' const MAX_MESSAGES = 100 @@ -105,18 +115,40 @@ function createGroupChat( return chatId } +function createBabbles( + chatId: string, + name: string, + avatar: string | undefined = undefined, + joined: boolean | undefined = undefined, +): string { + const groupChat: Chat = { + chatId, + type: 'babbles', + messages: [], + users: [], + name, + avatar, + unread: 0, + joined, + inviter: undefined, + } + chats.createChat(groupChat) + + return chatId +} + async function addMessageToChat( ownPublicKey: string, blockchainAdapter: WakuObjectAdapter, chatId: string, - message: Message, + message: ChatMessage, send?: (data: JSONValue) => Promise, ) { if (message.type === 'data' && send) { await executeOnDataMessage(ownPublicKey, blockchainAdapter, chatId, message, send) } - const unread = message.senderPublicKey !== ownPublicKey && message.type === 'user' ? 1 : 0 + const unread = message.type === 'user' && message.senderPublicKey !== ownPublicKey ? 1 : 0 chats.updateChat(chatId, (chat) => ({ ...chat, messages: [...chat.messages.slice(-MAX_MESSAGES), message], @@ -281,38 +313,7 @@ export default class WakuAdapter implements Adapter { chats.update((state) => ({ ...state, chats: new Map(storageChatEntries), loading: false })) // subscribe to invites - const decoder = createSymmetricDecoder({ - contentTopic: 'invites', - symKey: ownPublicEncryptionKey, - }) - await this.safeWaku.subscribeEncrypted( - ownPublicKey, - decoder, - async (message, decodedMessage) => { - if (!this.checkMessageSignature(message, decodedMessage)) { - return - } - - if (message.type !== 'invite') { - return - } - - const chatEncryptionKey = getSharedSecret(ownPrivateKey, message.senderPublicKey) - - const chatsMap = get(chats).chats - if (!chatsMap.has(chatEncryptionKey)) { - let user = await this.storageProfileToUser(message.senderPublicKey) - if (!user) { - user = { - publicKey: message.senderPublicKey, - } - } - - createPrivateChat(chatEncryptionKey, user, ownPublicKey) - } - await this.subscribeToPrivateChat(ownPublicKey, chatEncryptionKey, wakuObjectAdapter) - }, - ) + await this.subscribeToInvites(ownPrivateKey, ownPublicKey, wakuObjectAdapter) // subscribe to chats const allChats = Array.from(get(chats).chats) @@ -437,6 +438,22 @@ export default class WakuAdapter implements Adapter { return this.storageProfileToUser(address) } + async startBabbles( + wallet: BaseWallet, + chatId: string, + name: string, + avatar?: string, + ): Promise { + const wakuObjectAdapter = makeWakuObjectAdapter(this, wallet) + + createBabbles(chatId, name, avatar, true) + + const ownPublicKey = wallet.signingKey.compressedPublicKey + await this.subscribeToBabbles(ownPublicKey, chatId, wakuObjectAdapter) + + return chatId + } + async startChat(wallet: BaseWallet, peerPublicKey: string): Promise { const storageProfile = await this.fetchStorageProfile(peerPublicKey) if (!storageProfile) { @@ -447,18 +464,15 @@ export default class WakuAdapter implements Adapter { const ownPublicKey = wallet.signingKey.compressedPublicKey // send invite - const inviteMessage: InviteMessage = { + const inviteMessage: WithoutMeta = { type: 'invite', - timestamp: Date.now(), - senderPublicKey: wallet.signingKey.compressedPublicKey, chatId: ownPublicKey, } - const inviteEncryptionKey = hexToBytes(hash(peerPublicKey)) const ws = await this.makeWakustore() - const encoder = createSymmetricEncoder({ + const encoder = createEciesEncoder({ contentTopic: 'invites', - symKey: inviteEncryptionKey, + publicKey: peerPublicKey, sigPrivKey: ownPrivateKey, }) await ws.setDoc(encoder, inviteMessage) @@ -601,20 +615,29 @@ export default class WakuAdapter implements Adapter { } async sendChatMessage(wallet: BaseWallet, chatId: string, text: string): Promise { - const senderPublicKey = wallet.signingKey.compressedPublicKey const senderPrivateKey = wallet.privateKey - const message: Message = { + const message: WithoutMeta = { type: 'user', - timestamp: Date.now(), text, - senderPublicKey, } - const encryptionKey = hexToBytes(chatId) await this.safeWaku.sendMessage(message, encryptionKey, hexToBytes(senderPrivateKey)) } + async sendBabblesMessage(chatId: string, text: string, parentId?: string): Promise { + const message: WithoutMeta = { + type: 'babble', + text, + parentId, + } + const encryptionKey = hexToBytes(chatId) + // TODO find a better value + const senderPrivateKey = utils.randomPrivateKey() + + await this.safeWaku.sendMessage(message, encryptionKey, senderPrivateKey) + } + async sendData( wallet: BaseWallet, chatId: string, @@ -622,12 +645,9 @@ export default class WakuAdapter implements Adapter { instanceId: string, data: JSONSerializable, ): Promise { - const senderPublicKey = wallet.signingKey.compressedPublicKey const senderPrivateKey = wallet.privateKey - const message: Message = { + const message: WithoutMeta> = { type: 'data', - timestamp: Date.now(), - senderPublicKey, objectId, instanceId, data, @@ -643,11 +663,8 @@ export default class WakuAdapter implements Adapter { chatId: string, userPublicKeys: string[], ): Promise { - const senderPublicKey = wallet.signingKey.compressedPublicKey - const message: Message = { + const message: WithoutMeta = { type: 'invite', - timestamp: Date.now(), - senderPublicKey, chatId, } @@ -727,7 +744,7 @@ export default class WakuAdapter implements Adapter { const decodedMessage = await ws.getDecodedMessage(decoder) if (!decodedMessage) { - console.error('missing document') + console.error('missing profile', { profilePublicKey }) return } @@ -787,10 +804,28 @@ export default class WakuAdapter implements Adapter { endTime: now, } - if (isGroupChat(chat)) { - await this.subscribeToGroupChat(ownPublicKey, chat.chatId, wakuObjectAdapter, timeFilter) - } else { - await this.subscribeToPrivateChat(ownPublicKey, chat.chatId, wakuObjectAdapter, timeFilter) + switch (chat.type) { + case 'private': + return await this.subscribeToPrivateChat( + ownPublicKey, + chat.chatId, + wakuObjectAdapter, + timeFilter, + ) + case 'group': + return await this.subscribeToGroupChat( + ownPublicKey, + chat.chatId, + wakuObjectAdapter, + timeFilter, + ) + case 'babbles': + return await this.subscribeToBabbles( + ownPublicKey, + chat.chatId, + wakuObjectAdapter, + timeFilter, + ) } } @@ -880,18 +915,23 @@ export default class WakuAdapter implements Adapter { if (!this.checkMessageSignature(message, decodedMessage)) { return } - await this.handleMessage(ownPublicKey, message, encryptionKey, wakuObjectAdapter) + await this.handleChatMessage(ownPublicKey, message, encryptionKey, wakuObjectAdapter) }, timeFilter, ) } - private async handleMessage( + private async handleChatMessage( ownPublicKey: string, - message: Message, + message: ChatMessage, encryptionKey: Uint8Array, adapter: WakuObjectAdapter, ) { + // only handle certain types of messages + if (!(message.type === 'invite' || message.type === 'data' || message.type === 'user')) { + return + } + const chatId = bytesToHex(encryptionKey) const chatsMap = get(chats).chats @@ -939,13 +979,75 @@ export default class WakuAdapter implements Adapter { await addMessageToChat(ownPublicKey, adapter, chatId, message, send) } + private async subscribeToBabbles( + ownPublicKey: string, + chatId: string, + wakuObjectAdapter: WakuObjectAdapter, + timeFilter?: TimeFilter, + ) { + const encryptionKey = hexToBytes(chatId) + const decoder = createSymmetricDecoder({ + contentTopic: 'private-message', + symKey: encryptionKey, + }) + + this.safeWaku.subscribeEncrypted( + chatId, + decoder, + async (message) => { + if (message.type === 'babble') { + await addMessageToChat(ownPublicKey, wakuObjectAdapter, chatId, message) + } + }, + timeFilter, + ) + } + + private async subscribeToInvites( + ownPrivateKey: string, + ownPublicKey: string, + wakuObjectAdapter: WakuObjectAdapter, + ) { + const decoder = createEciesDecoder({ + contentTopic: 'invites', + privateKey: ownPrivateKey, + }) + await this.safeWaku.subscribeEncrypted( + ownPublicKey, + decoder, + async (message, decodedMessage) => { + if (!this.checkMessageSignature(message, decodedMessage)) { + return + } + + if (message.type !== 'invite') { + return + } + + const chatEncryptionKey = getSharedSecret(ownPrivateKey, message.senderPublicKey) + + const chatsMap = get(chats).chats + if (!chatsMap.has(chatEncryptionKey)) { + let user = await this.storageProfileToUser(message.senderPublicKey) + if (!user) { + user = { + publicKey: message.senderPublicKey, + } + } + + createPrivateChat(chatEncryptionKey, user, ownPublicKey) + } + await this.subscribeToPrivateChat(ownPublicKey, chatEncryptionKey, wakuObjectAdapter) + }, + ) + } + private checkMessageSignature(message: Message, decodedMessage: DecodedMessage): boolean { - if (!decodedMessage.signaturePublicKey) { - return false + if (message.type === 'babble') { + return true } - if (compressPublicKey(decodedMessage.signaturePublicKey) !== fixHex(message.senderPublicKey)) { - console.error('invalid signature', { decodedMessage, message }) + if (!decodedMessage.signaturePublicKey) { return false } diff --git a/src/lib/adapters/waku/message-hash.test.ts b/src/lib/adapters/waku/message-hash.test.ts new file mode 100644 index 00000000..4d97f275 --- /dev/null +++ b/src/lib/adapters/waku/message-hash.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { hashMessage } from './message-hash' + +/* + * https://rfc.vac.dev/spec/14/#test-vectors + */ +describe('test vectors', () => { + const pubsubTopic = '/waku/2/default-waku/proto' + const messagePayload = '0x010203045445535405060708' + const messageContentTopic = '/waku/2/default-content/proto' + + it('waku message hash computation (meta size of 12 bytes)', () => { + const messageMeta = '0x73757065722d736563726574' + const exptectedHash = '0x4fdde1099c9f77f6dae8147b6b3179aba1fc8e14a7bf35203fc253ee479f135f' + + const hash = hashMessage(messagePayload, messageContentTopic, messageMeta, pubsubTopic) + + expect(hash).toEqual(exptectedHash) + }) + + it('waku message hash computation (meta size of 64 bytes)', () => { + const messageMeta = + '0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f' + const exptectedHash = '0xc32ed3b51f0c432be1c7f50880110e1a1a60f6067cd8193ca946909efe1b26ad' + + const hash = hashMessage(messagePayload, messageContentTopic, messageMeta, pubsubTopic) + + expect(hash).toEqual(exptectedHash) + }) + + it('waku message hash computation (meta attribute not present)', () => { + const exptectedHash = '0x87619d05e563521d9126749b45bd4cc2430df0607e77e23572d874ed9c1aaa62' + + const hash = hashMessage(messagePayload, messageContentTopic) + + expect(hash).toEqual(exptectedHash) + }) + + it('waku message hash computation (payload length 0)', () => { + const messagePayload = '' + const messageMeta = '0x73757065722d736563726574' + const exptectedHash = '0xe1a9596237dbe2cc8aaf4b838c46a7052df6bc0d42ba214b998a8bfdbe8487d6' + + const hash = hashMessage(messagePayload, messageContentTopic, messageMeta) + + expect(hash).toEqual(exptectedHash) + }) +}) diff --git a/src/lib/adapters/waku/message-hash.ts b/src/lib/adapters/waku/message-hash.ts new file mode 100644 index 00000000..374415c4 --- /dev/null +++ b/src/lib/adapters/waku/message-hash.ts @@ -0,0 +1,52 @@ +import type { DecodedMessage } from '@waku/sdk' +import { sha256, type Hex } from './crypto' +import { hexToBytes } from '@waku/utils/bytes' + +export const DEFAULT_PUBSUB_TOPIC = '/waku/2/default-waku/proto' + +/** + * Message hashing according to https://rfc.vac.dev/spec/14/#version1 + * + * @param decodedMessage the decoded message + * @param pubsubTopic the pubsub topic + * @returns the hash as hex string with 0x prefix + */ +export function hashDecodedMessage( + decodedMessage: DecodedMessage, + pubsubTopic: string = DEFAULT_PUBSUB_TOPIC, +): Hex { + return hashMessage( + decodedMessage.payload, + decodedMessage.contentTopic, + decodedMessage.meta, + pubsubTopic, + ) +} + +/** + * Message hashing according to https://rfc.vac.dev/spec/14/#version1 + * + * @param payload payload of the message + * @param contentTopic contentTopic of the message + * @param meta meta of the message + * @param pubsubTopic the pubsub topic + * @returns the hash as hex string with 0x prefix + */ +export function hashMessage( + payload: Hex | Uint8Array, + contentTopic: string, + meta?: Hex | Uint8Array | undefined, + pubsubTopic: string = DEFAULT_PUBSUB_TOPIC, +) { + const payloadBytes = hexToBytes(payload) + const contentTopicBytes = new TextEncoder().encode(contentTopic) + const metaBytes = meta ? hexToBytes(meta) : new Uint8Array() + const pubsubTopicBytes = new TextEncoder().encode(pubsubTopic) + + return ( + '0x' + + sha256( + new Uint8Array([...pubsubTopicBytes, ...payloadBytes, ...contentTopicBytes, ...metaBytes]), + ) + ) +} diff --git a/src/lib/adapters/waku/safe-waku.ts b/src/lib/adapters/waku/safe-waku.ts index db02257a..f1f30601 100644 --- a/src/lib/adapters/waku/safe-waku.ts +++ b/src/lib/adapters/waku/safe-waku.ts @@ -1,15 +1,17 @@ import { connectWaku, storeDocument, type ConnectWakuOptions } from '$lib/adapters/waku/waku' -import type { Message } from '$lib/stores/chat' +import type { ChatMessage, Message, WithoutMeta } from '$lib/stores/chat' import type { IDecoder, IEncoder, LightNode, TimeFilter, Unsubscribe } from '@waku/interfaces' import { PageDirection } from '@waku/interfaces' import { makeWakustore } from './wakustore' import { createSymmetricEncoder } from './codec' import type { DecodedMessage } from '@waku/message-encryption' +import { hashDecodedMessage } from './message-hash' +import { compressPublicKey } from './crypto' -type Callback = (message: Message, decodedMessage: DecodedMessage) => Promise +type Callback = (message: ChatMessage, decodedMessage: DecodedMessage) => Promise interface QueuedMessage { - chatMessage: Message + chatMessage: ChatMessage decodedMessage: DecodedMessage chatId: string callback: Callback @@ -29,8 +31,7 @@ async function sleep(msec: number) { export class SafeWaku { public lightNode: LightNode | undefined = undefined private subscriptions = new Map() - private lastMessages = new Map() - private lastSentTimestamp = 0 + private lastMessages = new Map() private isReSubscribing = false public readonly errors = { numDisconnect: 0, @@ -82,7 +83,8 @@ export class SafeWaku { pageSize: 1000, }), (message, decodedMessage) => { - this.queueMessage(id, callback, message, decodedMessage) + const chatMessage = this.augmentMessageWithMetadata(message, decodedMessage) + this.queueMessage(id, callback, chatMessage, decodedMessage) }, ) @@ -104,7 +106,11 @@ export class SafeWaku { this.subscriptions = new Map() } - async sendMessage(message: Message, encryptionKey: Uint8Array, sigPrivKey: Uint8Array) { + async sendMessage( + message: WithoutMeta, + encryptionKey: Uint8Array, + sigPrivKey: Uint8Array, + ) { const encoder = createSymmetricEncoder({ contentTopic: 'private-message', symKey: encryptionKey, @@ -113,7 +119,7 @@ export class SafeWaku { return await this.storeEncrypted(encoder, message) } - async storeEncrypted(encoder: IEncoder, message: { timestamp: number }) { + async storeEncrypted(encoder: IEncoder, message: unknown) { if (!this.lightNode) { this.lightNode = await this.safeConnectWaku() } @@ -123,11 +129,6 @@ export class SafeWaku { const start = Date.now() - if (message.timestamp === this.lastSentTimestamp) { - message.timestamp++ - } - this.lastSentTimestamp = message.timestamp - do { try { error = await storeDocument(this.lightNode, encoder, message) @@ -153,6 +154,20 @@ export class SafeWaku { } } + private augmentMessageWithMetadata( + message: Message, + decodedMessage: DecodedMessage, + ): ChatMessage { + return { + ...message, + id: hashDecodedMessage(decodedMessage), + timestamp: Number(decodedMessage.timestamp), + senderPublicKey: decodedMessage.signaturePublicKey + ? '0x' + compressPublicKey(decodedMessage.signaturePublicKey) + : '', + } + } + private async safeConnectWaku() { if (this.isConnecting) { while (!this.lightNode) { @@ -275,10 +290,24 @@ export class SafeWaku { this.subscriptions.set(chatId, subscription) } + private areMessagesEqual(a: ChatMessage, b: ChatMessage): boolean { + if (a.timestamp !== b.timestamp) { + return false + } + if (a.senderPublicKey !== b.senderPublicKey) { + return false + } + if (a.id !== b.id) { + return false + } + + return true + } + private async queueMessage( chatId: string, callback: Callback, - chatMessage: Message, + chatMessage: ChatMessage, decodedMessage: DecodedMessage, ) { this.queuedMessages.push({ @@ -300,12 +329,7 @@ export class SafeWaku { // deduplicate already seen messages const message = queuedMessage.chatMessage const lastMessage = this.lastMessages.get(chatId) - if ( - lastMessage && - lastMessage.timestamp === message.timestamp && - lastMessage.type === message.type && - lastMessage.senderPublicKey === message.senderPublicKey - ) { + if (lastMessage && this.areMessagesEqual(lastMessage, message)) { this.log('🙈 ignoring duplicate message', { message, lastMessage }) continue } diff --git a/src/lib/adapters/waku/wakustore.ts b/src/lib/adapters/waku/wakustore.ts index 0493dc27..8758cc86 100644 --- a/src/lib/adapters/waku/wakustore.ts +++ b/src/lib/adapters/waku/wakustore.ts @@ -54,23 +54,9 @@ export function makeWakustore(waku: LightNode): Wakustore { function decodeDoc(message: DecodedMessage): T { const decodedPayload = decodeMessagePayload(message) - const typedPayload = JSON.parse(decodedPayload) as T & { timestamp?: number } - - // HACK to use waku timestamp instead of the type T's - if ( - typedPayload && - typeof typedPayload === 'object' && - !Array.isArray(typedPayload) && - typedPayload.timestamp - ) { - return { - ...typedPayload, - timestamp: Number(message.timestamp), - origTimestamp: typedPayload.timestamp, - } - } else { - return typedPayload - } + const typedPayload = JSON.parse(decodedPayload) as T + + return typedPayload } async function getQueryResults( diff --git a/src/lib/components/chat-message.svelte b/src/lib/components/chat-message.svelte index 53074327..935184b0 100644 --- a/src/lib/components/chat-message.svelte +++ b/src/lib/components/chat-message.svelte @@ -20,16 +20,25 @@ export let timestamp: string | undefined = undefined + export let onClick: (() => void) | undefined = undefined + + export let leftPadding: number | undefined = undefined + const isFF = () => { let browserInfo = navigator.userAgent return browserInfo.includes('Firefox') } + + +
(onClick ? onClick() : {})} class={`message ${myMessage ? 'my-message' : 'their-message'} ${isFF() ? 'ff' : ''} ${ object ? 'object' : '' } ${group ? 'group' : ''} ${sameSender ? 'same' : ''} ${noText ? 'no-text' : ''}`} + style={`${leftPadding ? `margin-left: ${leftPadding * 24}px` : ''}`} >
diff --git a/src/lib/components/icons/babbles.svelte b/src/lib/components/icons/babbles.svelte new file mode 100644 index 00000000..a716b2b3 --- /dev/null +++ b/src/lib/components/icons/babbles.svelte @@ -0,0 +1,29 @@ + + + + + + + + + + + + + diff --git a/src/lib/routes.ts b/src/lib/routes.ts index 9d684761..689eb3bb 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -16,4 +16,8 @@ export default { GROUP_NEW: `/group/new`, GROUP_CHAT: (id: string) => `/group/chat/${id}`, GROUP_EDIT: (id: string) => `/group/chat/${id}/edit`, + BABBLES_NEW: `/babbles/new`, + BABBLES_CHAT: (id: string, threadId?: string) => + `/babbles/chat/${id}${threadId ? `/${threadId}` : ''}`, + BABBLES_EDIT: (id: string) => `/babbles/chat/${id}/edit`, } diff --git a/src/lib/stores/chat.ts b/src/lib/stores/chat.ts index a8213cf9..a7c1ce62 100644 --- a/src/lib/stores/chat.ts +++ b/src/lib/stores/chat.ts @@ -2,6 +2,12 @@ import type { JSONSerializable } from '$lib/objects' import type { User } from '$lib/types' import { writable, type Writable } from 'svelte/store' +export interface MessageMetadata { + timestamp: number + senderPublicKey: string + id: string +} + export interface UserMessage { type: 'user' timestamp: number @@ -25,14 +31,27 @@ export interface InviteMessage { chatId: string } -export type Message = UserMessage | DataMessage | InviteMessage +export interface BabbleMessage { + type: 'babble' + timestamp: number + senderPublicKey: string + text: string + id: string + parentId?: string +} + +export type Message = UserMessage | DataMessage | InviteMessage | BabbleMessage -export type ChatType = 'private' | 'group' +export type WithoutMeta = Omit +export type WithMeta = T & MessageMetadata +export type ChatMessage = WithMeta + +export type ChatType = 'private' | 'group' | 'babbles' export interface Chat { chatId: string type: ChatType - messages: Message[] + messages: ChatMessage[] unread: number users: User[] name?: string @@ -57,6 +76,10 @@ export function isGroupChat(chat: Chat) { return chat.type === 'group' } +export function isBabbles(chat: Chat) { + return chat.type === 'babbles' +} + export function getLastMessageTime(chat?: Chat) { const lastMessage = chat?.messages.slice(-1)[0] return lastMessage ? lastMessage.timestamp : 0 diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e3738da7..2ed941dc 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -15,7 +15,7 @@ // Stores import { profile } from '$lib/stores/profile' - import { chats, isGroupChat, type Chat, type Message } from '$lib/stores/chat' + import { chats, isGroupChat, type Chat, type Message, isBabbles } from '$lib/stores/chat' import ROUTES from '$lib/routes' import AuthenticatedOnly from '$lib/components/authenticated-only.svelte' @@ -24,11 +24,11 @@ import { formatTimestamp } from '$lib/utils/format' import { userDisplayName } from '$lib/utils/user' import { publicKeyToAddress } from '$lib/adapters/waku/crypto' + import Babbles from '$lib/components/icons/babbles.svelte' $: orderedChats = Array.from($chats.chats) .map(([, chat]) => chat) .sort(compareChats) - .filter((chat) => chat.chatId) // HACK to remove early version broken group chats function compareChats(a: Chat, b: Chat) { // put not joined chats at the top @@ -67,6 +67,17 @@ return s } + function gotoChat(chat: Chat) { + switch (chat.type) { + case 'private': + return goto(ROUTES.CHAT(chat.chatId)) + case 'group': + return goto(ROUTES.GROUP_CHAT(chat.chatId)) + case 'babbles': + return goto(ROUTES.BABBLES_CHAT(chat.chatId)) + } + } + $: loading = $profile.loading || $chats.loading @@ -150,20 +161,14 @@
  • - isGroupChat(chat) - ? goto(ROUTES.GROUP_CHAT(chat.chatId)) - : goto(ROUTES.CHAT(chat.chatId))} - on:keypress={() => - isGroupChat(chat) - ? goto(ROUTES.GROUP_CHAT(chat.chatId)) - : goto(ROUTES.CHAT(chat.chatId))} + on:click={() => gotoChat(chat)} + on:keypress={() => gotoChat(chat)} role="button" tabindex="0" >
    - {#if isGroupChat(chat)} + {#if isGroupChat(chat) || isBabbles(chat)} {:else} @@ -176,6 +181,9 @@ {chat?.name} + {:else if isBabbles(chat)} + Babbles + {:else} {userDisplayName(otherUser)} diff --git a/src/routes/babbles/chat/[id]/[[thread_id]]/+page.svelte b/src/routes/babbles/chat/[id]/[[thread_id]]/+page.svelte new file mode 100644 index 00000000..bb4cff6a --- /dev/null +++ b/src/routes/babbles/chat/[id]/[[thread_id]]/+page.svelte @@ -0,0 +1,321 @@ + + + + {#if !chat} + + +

    Could not find group chat.

    +
    +
    + {:else} + + +
    + + + + + {chat?.name} + + + + + + + {#if chat.joined} + + {/if} + +
    +
    + {#if !chat.joined} + + + +

    Join "{chat?.name}"?

    + + + + + +
    + {:else} +
    + +
    +
    + + {#each displayMessages as message, i} + {#if message.text?.length > 0} + {@const sameSender = + displayMessages[i].senderPublicKey === + displayMessages[i - 1]?.senderPublicKey} + {@const lastMessage = + i + 1 === displayMessages.length || + displayMessages[i].senderPublicKey !== + displayMessages[i + 1]?.senderPublicKey || + displayMessages[i + 1]?.type !== 'babble'} + + + chat?.chatId && goto(ROUTES.BABBLES_CHAT(chat?.chatId, message.id))} + leftPadding={message.level} + myMessage={false} + bubble + group + {sameSender} + senderName={undefined} + timestamp={lastMessage + ? formatTimestamp(lastMessage ? message.timestamp : 0) + : undefined} + > + {@html textToHTML(htmlize(message.text))} + + {/if} + {/each} +
    +
    +
    +
    +
    + +
    +