From 062c29d6fafecfbc2917c9cce1513e8c469a0728 Mon Sep 17 00:00:00 2001 From: Felicio Mununga Date: Mon, 13 Jun 2022 16:33:57 +0200 Subject: [PATCH] Add Chat module (#270) * add packages/status-js/src/client/chat.ts * cache received but skipped waku messages * comment missing Map type in generate proto type * handle removed and added chats * remove lodash * use this.client.waku instead of this.waku in community.ts * rename community props * fix waku reference in Community * fix community reference in chat * handle chat.description changes * fix references in ui * revert use of contentTopic * fix callback on fetch * use clock to limit fetch * fix use of clock for community description * remove notes * remove await * use Object.keys() for diff in chats * fix typo * add decryption key in start() * set content topics when adding decryption keys * add getter with tmp name * rename community handler to handleDescription * return this.description !== undefined * add options to constructor --- packages/status-js/package.json | 2 - packages/status-js/protos/communities.ts | 1 + packages/status-js/src/client.ts | 6 +- packages/status-js/src/client/chat.ts | 409 ++++++++++++++ .../src/client/community/community.ts | 508 ++++-------------- .../create-channel-content-topics.ts | 15 - .../src/client/community/get-channel-id.ts | 3 - .../src/client/community/get-chat-uuid.ts | 3 + .../client/community/handle-waku-message.ts | 158 +----- .../src/client/community/map-chat-message.ts | 17 +- .../src/helpers/get-difference-by-keys.ts | 16 + packages/status-js/src/index.ts | 3 +- .../status-react/src/protocol/provider.tsx | 10 +- .../status-react/src/protocol/use-chat.tsx | 2 +- .../status-react/src/protocol/use-chats.tsx | 2 +- .../status-react/src/protocol/use-members.tsx | 2 +- .../src/protocol/use-messages.tsx | 6 +- .../chat/components/chat-message/index.tsx | 5 +- .../status-react/src/routes/chat/index.tsx | 3 +- 19 files changed, 598 insertions(+), 573 deletions(-) create mode 100644 packages/status-js/src/client/chat.ts delete mode 100644 packages/status-js/src/client/community/create-channel-content-topics.ts delete mode 100644 packages/status-js/src/client/community/get-channel-id.ts create mode 100644 packages/status-js/src/client/community/get-chat-uuid.ts create mode 100644 packages/status-js/src/helpers/get-difference-by-keys.ts diff --git a/packages/status-js/package.json b/packages/status-js/package.json index d6c66a11..927ab816 100644 --- a/packages/status-js/package.json +++ b/packages/status-js/package.json @@ -46,7 +46,6 @@ "ethereum-cryptography": "^1.0.3", "js-sha3": "^0.8.0", "js-waku": "^0.23.0", - "lodash": "^4.17.21", "long": "^5.2.0", "pbkdf2": "^3.1.2", "protobufjs": "^6.11.3", @@ -57,7 +56,6 @@ "devDependencies": { "@types/bn.js": "^5.1.0", "@types/elliptic": "^6.4.14", - "@types/lodash": "^4.14.182", "@types/pbkdf2": "^3.1.0", "@types/secp256k1": "^4.0.3", "@types/uuid": "^8.3.3", diff --git a/packages/status-js/protos/communities.ts b/packages/status-js/protos/communities.ts index a857f4d1..2053888b 100644 --- a/packages/status-js/protos/communities.ts +++ b/packages/status-js/protos/communities.ts @@ -117,6 +117,7 @@ export interface CommunityDescription { members: CommunityMember permissions: CommunityPermissions identity: ChatIdentity + // fixme!: Map chats: CommunityChat banList: string[] categories: CommunityCategory diff --git a/packages/status-js/src/client.ts b/packages/status-js/src/client.ts index 13492bce..4f25c1ae 100644 --- a/packages/status-js/src/client.ts +++ b/packages/status-js/src/client.ts @@ -17,7 +17,7 @@ export interface ClientOptions { } class Client { - private waku: Waku + public waku: Waku public readonly wakuMessages: Set public account?: Account @@ -29,7 +29,7 @@ class Client { this.wakuMessages = new Set() // Community - this.community = new Community(this, waku, options.publicKey) + this.community = new Community(this, options.publicKey) } static async start(options: ClientOptions) { @@ -72,7 +72,7 @@ class Client { // this.account = undefined // } - public sendMessage = async ( + public sendWakuMessage = async ( type: keyof typeof ApplicationMetadataMessage.Type, payload: Uint8Array, contentTopic: string, diff --git a/packages/status-js/src/client/chat.ts b/packages/status-js/src/client/chat.ts new file mode 100644 index 00000000..7e294f09 --- /dev/null +++ b/packages/status-js/src/client/chat.ts @@ -0,0 +1,409 @@ +import { hexToBytes } from 'ethereum-cryptography/utils' +import { PageDirection } from 'js-waku' + +import { ChatMessage as ChatMessageProto } from '~/protos/chat-message' +import { CommunityRequestToJoin } from '~/protos/communities' +import { EmojiReaction } from '~/protos/emoji-reaction' + +import { idToContentTopic } from '../contentTopic' +import { createSymKeyFromPassword } from '../encryption' +import { getReactions } from './community/get-reactions' + +import type { MessageType } from '../../protos/enums' +import type { Client } from '../client' +import type { Community } from './community/community' +import type { Reactions } from './community/get-reactions' +import type { ImageMessage } from '~/src/proto/communities/v1/chat_message' +import type { CommunityChat } from '~/src/proto/communities/v1/communities' +import type { WakuMessage } from 'js-waku' + +export type ChatMessage = ChatMessageProto & { + messageId: string + pinned: boolean + reactions: Reactions + chatUuid: string + responseToMessage?: Omit +} + +export class Chat { + private readonly community: Community + private readonly client: Client + + public readonly uuid: string + public readonly id: string + public readonly contentTopic: string + public readonly type: MessageType.COMMUNITY_CHAT + public readonly symmetricKey: Uint8Array + public description: CommunityChat + public readonly chatCallbacks: Set<(description: CommunityChat) => void> + public messages: ChatMessage[] + public readonly messageCallbacks: Set<(messages: ChatMessage[]) => void> + + constructor(options: { + community: Community + client: Client + uuid: string + id: string + contentTopic: string + type: MessageType.COMMUNITY_CHAT + symmetricKey: Uint8Array + description: CommunityChat + }) { + this.client = options.client + this.community = options.community + + this.uuid = options.uuid + this.id = options.id + this.contentTopic = options.contentTopic + this.type = options.type + this.symmetricKey = options.symmetricKey + this.description = options.description + + this.chatCallbacks = new Set() + this.messages = [] + this.messageCallbacks = new Set() + } + + public static create = async ( + community: Community, + client: Client, + uuid: string, + type: MessageType.COMMUNITY_CHAT, + description: CommunityChat + ) => { + const id = `${community.publicKey}${uuid}` + const contentTopic = idToContentTopic(id) + const symmetricKey = await createSymKeyFromPassword(id) + + return new Chat({ + community, + client, + uuid, + id, + contentTopic, + type, + symmetricKey, + description, + }) + } + + public getMessages = () => { + return this.messages + } + + public onChange = (callback: (description: CommunityChat) => void) => { + this.chatCallbacks.add(callback) + + return () => { + this.chatCallbacks.delete(callback) + } + } + + public emitChange = (description: CommunityChat) => { + this.chatCallbacks.forEach(callback => callback(description)) + } + + public onMessage = ( + callback: (messages: ChatMessage[]) => void + ): (() => void) => { + this.messageCallbacks.add(callback) + + return () => { + this.messageCallbacks.delete(callback) + } + } + + public fetchMessages = async ( + options: { start: Date }, + callback: (messages: ChatMessage[]) => void + ) => { + const startTime = options.start + const endTime = new Date() + + let _oldestClock: BigInt | undefined + let _oldestMessageTime: Date | undefined + + if (this.messages.length) { + _oldestClock = this.messages[0].clock + _oldestMessageTime = new Date(Number(this.messages[0].timestamp)) + + // already handled + if (_oldestMessageTime <= options.start) { + callback(this.messages) + + return + } + } + + await this.client.waku.store.queryHistory([this.contentTopic], { + timeFilter: { + startTime: startTime, + endTime: endTime, + }, + pageSize: 50, + // most recent page first + pageDirection: PageDirection.BACKWARD, + decryptionKeys: [this.symmetricKey], + callback: (wakuMessages: WakuMessage[]) => { + // oldest message first + for (const wakuMessage of wakuMessages) { + this.client.handleWakuMessage(wakuMessage) + } + }, + }) + + // callback + // more not found + if ( + _oldestClock && + this.messages.length && + _oldestClock >= this.messages[0].clock + ) { + callback([]) + + return + } + + callback(this.messages) + } + + public emitMessages = (messages: ChatMessage[]) => { + // fixme!: don't emit on backfill + this.messageCallbacks.forEach(callback => callback(messages)) + } + + public handleChange = (description: CommunityChat) => { + // state + this.description = description + + // callback + this.emitChange(description) + } + + public handleNewMessage = (message: ChatMessage) => { + let messageIndex = this.messages.length + while (messageIndex > 0) { + const _message = this.messages[messageIndex - 1] + + if (_message.clock <= message.clock) { + break + } + + messageIndex-- + } + + let responseToMessageIndex = this.messages.length + while (--responseToMessageIndex >= 0) { + const _message = this.messages[responseToMessageIndex] + + if (_message.messageId === message.responseTo) { + break + } + } + + if (responseToMessageIndex >= 0) { + message.responseToMessage = this.messages[responseToMessageIndex] + } + + // state + this.messages.splice(messageIndex, 0, message) + + // callback + this.emitMessages(this.messages) + } + + public handleEditedMessage = (messageId: string, text: string) => { + let messageIndex = this.messages.length + while (--messageIndex >= 0) { + const _message = this.messages[messageIndex] + + if (_message.messageId === messageId) { + break + } + } + + // original not found + if (messageIndex < 0) { + return + } + + this.messages[messageIndex] = { + ...this.messages[messageIndex], + text, + } + + // callback + this.emitMessages(this.messages) + } + + public handleDeletedMessage = (messageId: string) => { + let messageIndex = this.messages.length + while (--messageIndex >= 0) { + const _message = this.messages[messageIndex] + + if (_message.messageId === messageId) { + break + } + } + + if (messageIndex < 0) { + return + } + + this.messages.splice(messageIndex, 1) + + this.emitMessages(this.messages) + } + + public handlePinnedMessage = (messageId: string, pinned?: boolean) => { + let messageIndex = this.messages.length + while (--messageIndex >= 0) { + const _message = this.messages[messageIndex] + + if (_message.messageId === messageId) { + break + } + } + + if (messageIndex < 0) { + return + } + + this.messages[messageIndex].pinned = Boolean(pinned) + + this.emitMessages(this.messages) + } + + public handleEmojiReaction = ( + messageId: string, + reaction: EmojiReaction, + isMe: boolean + ) => { + let messageIndex = this.messages.length + while (--messageIndex >= 0) { + const _message = this.messages[messageIndex] + + if (_message.messageId === messageId) { + break + } + } + + if (messageIndex < 0) { + return + } + + this.messages[messageIndex].reactions = getReactions( + reaction, + this.messages[messageIndex].reactions, + isMe + ) + + this.emitMessages(this.messages) + } + + public sendTextMessage = async (text: string, responseTo?: string) => { + // TODO: protos does not support optional fields :-( + const payload = ChatMessageProto.encode({ + clock: BigInt(Date.now()), + timestamp: BigInt(Date.now()), + text, + responseTo: responseTo ?? '', + ensName: '', + chatId: this.id, + messageType: 'COMMUNITY_CHAT', + contentType: ChatMessageProto.ContentType.TEXT_PLAIN, + sticker: { hash: '', pack: 0 }, + image: { + type: 'JPEG', + payload: new Uint8Array([]), + }, + audio: { + type: 'AAC', + payload: new Uint8Array([]), + durationMs: BigInt(0), + }, + community: new Uint8Array([]), + grant: new Uint8Array([]), + displayName: '', + }) + + await this.client.sendWakuMessage( + 'TYPE_CHAT_MESSAGE', + payload, + this.contentTopic, + this.symmetricKey + ) + } + + public sendImageMessage = async (image: ImageMessage) => { + const payload = ChatMessageProto.encode({ + clock: BigInt(Date.now()), + timestamp: BigInt(Date.now()), + text: '', + responseTo: '', + ensName: '', + chatId: this.id, + messageType: 'COMMUNITY_CHAT', + contentType: ChatMessageProto.ContentType.TEXT_PLAIN, + sticker: { hash: '', pack: 0 }, + image: { + type: image.type, + payload: image.payload, + }, + audio: { + type: 'AAC', + payload: new Uint8Array([]), + durationMs: BigInt(0), + }, + community: new Uint8Array([]), + grant: new Uint8Array([]), + displayName: '', + }) + + await this.client.sendWakuMessage( + 'TYPE_CHAT_MESSAGE', + payload, + this.contentTopic, + this.symmetricKey + ) + } + + public sendReaction = async ( + chatId: string, + messageId: string, + reaction: EmojiReaction.Type + ) => { + const payload = EmojiReaction.encode({ + clock: BigInt(Date.now()), + chatId: chatId, + messageType: 'COMMUNITY_CHAT', + grant: new Uint8Array([]), + messageId, + retracted: false, + type: reaction, + }) + + await this.client.sendWakuMessage( + 'TYPE_EMOJI_REACTION', + payload, + this.contentTopic, + this.symmetricKey + ) + } + + public requestToJoin = async () => { + const payload = CommunityRequestToJoin.encode({ + clock: BigInt(Date.now()), + chatId: this.id, + communityId: hexToBytes(this.community.publicKey.replace(/^0[xX]/, '')), + ensName: '', + }) + + await this.client.sendWakuMessage( + 'TYPE_COMMUNITY_REQUEST_TO_JOIN', + payload, + this.contentTopic, + this.symmetricKey + ) + } +} diff --git a/packages/status-js/src/client/community/community.ts b/packages/status-js/src/client/community/community.ts index 3bb24ec6..706b7d43 100644 --- a/packages/status-js/src/client/community/community.ts +++ b/packages/status-js/src/client/community/community.ts @@ -1,82 +1,69 @@ -import { hexToBytes } from 'ethereum-cryptography/utils' -import { PageDirection, waku_message } from 'js-waku' -import difference from 'lodash/difference' +import { waku_message } from 'js-waku' -import { ChatMessage } from '~/protos/chat-message' -import { CommunityRequestToJoin } from '~/protos/communities' -import { EmojiReaction } from '~/protos/emoji-reaction' +import { MessageType } from '~/protos/enums' +import { getDifferenceByKeys } from '~/src/helpers/get-difference-by-keys' import { idToContentTopic } from '../../contentTopic' import { createSymKeyFromPassword } from '../../encryption' -import { createChannelContentTopics } from './create-channel-content-topics' +import { Chat } from '../chat' import type { Client } from '../../client' -import type { CommunityDescription } from '../../wire/community_description' -import type { Reactions } from './get-reactions' -import type { ImageMessage } from '~/src/proto/communities/v1/chat_message' -import type { Waku } from 'js-waku' - -export type CommunityMetadataType = CommunityDescription['proto'] - -export type MessageType = ChatMessage & { - messageId: string - pinned: boolean - reactions: Reactions - channelId: string - responseToMessage?: Omit -} +import type { + CommunityChat, + CommunityDescription, +} from '~/src/proto/communities/v1/communities' export class Community { private client: Client - private waku: Waku - public communityPublicKey: string - private communityContentTopic!: string - private communityDecryptionKey!: Uint8Array - public communityMetadata!: CommunityMetadataType // state - public channelMessages: Partial<{ [key: string]: MessageType[] }> = {} // state - public channelMessagesCallbacks: { - [key: string]: (messages: MessageType[]) => void - } = {} - public communityCallback: - | ((community: CommunityMetadataType) => void) - | undefined - constructor(client: Client, waku: Waku, publicKey: string) { + public publicKey: string + private contentTopic!: string + private symmetricKey!: Uint8Array + public description!: CommunityDescription + public chats: Map + public callback: ((description: CommunityDescription) => void) | undefined + + constructor(client: Client, publicKey: string) { this.client = client - this.waku = waku - this.communityPublicKey = publicKey + this.publicKey = publicKey + + this.chats = new Map() } public async start() { - this.communityContentTopic = idToContentTopic(this.communityPublicKey) - this.communityDecryptionKey = await createSymKeyFromPassword( - this.communityPublicKey - ) + this.contentTopic = idToContentTopic(this.publicKey) + this.symmetricKey = await createSymKeyFromPassword(this.publicKey) // Waku - this.waku.store.addDecryptionKey(this.communityDecryptionKey) + this.client.waku.store.addDecryptionKey(this.symmetricKey, { + contentTopics: [this.contentTopic], + }) + this.client.waku.relay.addDecryptionKey(this.symmetricKey, { + contentTopics: [this.contentTopic], + }) // Community - const communityMetadata = await this.fetchCommunity() + const description = await this.fetch() - if (!communityMetadata) { + if (!description) { throw new Error('Failed to intiliaze Community') } - this.communityMetadata = communityMetadata + this.description = description - await this.observeCommunity() + this.observe() - // Channels - await this.observeChannelMessages(Object.keys(this.communityMetadata.chats)) + // Chats + await this.observeChatMessages(this.description.chats) } - public fetchCommunity = async () => { - let communityMetadata: CommunityMetadataType | undefined - let shouldStop = false + // todo: rename this to chats when changing references in ui + public get _chats() { + return [...this.chats.values()] + } - await this.waku.store.queryHistory([this.communityContentTopic], { - decryptionKeys: [this.communityDecryptionKey], + public fetch = async () => { + await this.client.waku.store.queryHistory([this.contentTopic], { // oldest message first callback: wakuMessages => { let index = wakuMessages.length @@ -85,380 +72,117 @@ export class Community { while (--index >= 0) { this.client.handleWakuMessage(wakuMessages[index]) - if (!this.communityMetadata) { - return shouldStop - } - - communityMetadata = this.communityMetadata - shouldStop = true - - return shouldStop + return this.description !== undefined } }, }) - return communityMetadata + return this.description } - public createFetchChannelMessages = async ( - channelId: string, - callback: (messages: MessageType[]) => void - ) => { - const id = `${this.communityPublicKey}${channelId}` - const channelContentTopic = idToContentTopic(id) - const symKey = await createSymKeyFromPassword(id) - - return async (options: { start: Date }) => { - const startTime = options.start - const endTime = new Date() - - const _messages = this.channelMessages[channelId] || [] - let _oldestMessageTime: Date | undefined = undefined - - if (_messages.length) { - _oldestMessageTime = new Date(Number(_messages[0].timestamp)) - - if (_oldestMessageTime <= options.start) { - callback(_messages) - - return - } - } - - await this.waku.store.queryHistory([channelContentTopic], { - timeFilter: { - startTime: startTime, - endTime: endTime, - }, - pageSize: 50, - // most recent page first - pageDirection: PageDirection.BACKWARD, - decryptionKeys: [symKey], - callback: wakuMessages => { - // oldest message first - for (const wakuMessage of wakuMessages) { - this.client.handleWakuMessage(wakuMessage) - } - }, - }) - - // callback - if ( - _oldestMessageTime && - this.channelMessages[channelId]?.length && - _oldestMessageTime >= - new Date(Number(this.channelMessages[channelId]![0].timestamp)) - ) { - callback([]) - - return - } - - callback(this.channelMessages[channelId] ?? []) - } - } - - private observeCommunity = () => { - this.waku.relay.addDecryptionKey(this.communityDecryptionKey) - this.waku.relay.addObserver(this.client.handleWakuMessage, [ - this.communityContentTopic, + private observe = () => { + this.client.waku.relay.addObserver(this.client.handleWakuMessage, [ + this.contentTopic, ]) } - private observeChannelMessages = async (chatsIds: string[]) => { - const symKeyPromises = chatsIds.map(async (chatId: string) => { - const id = `${this.communityPublicKey}${chatId}` - const channelContentTopic = idToContentTopic(id) + private observeChatMessages = async ( + chatDescriptions: CommunityDescription['chats'] + ) => { + const chatPromises = Object.entries(chatDescriptions).map( + async ([chatUuid, chatDescription]: [string, CommunityChat]) => { + const chat = await Chat.create( + this, + this.client, + chatUuid, + MessageType.COMMUNITY_CHAT, + chatDescription + ) + const contentTopic = chat.contentTopic - const symKey = await createSymKeyFromPassword(id) + this.chats.set(chatUuid, chat) - this.waku.relay.addDecryptionKey(symKey, { - method: waku_message.DecryptionMethod.Symmetric, - contentTopics: [channelContentTopic], - }) + this.client.waku.relay.addDecryptionKey(chat.symmetricKey, { + method: waku_message.DecryptionMethod.Symmetric, + contentTopics: [contentTopic], + }) - return channelContentTopic - }) - const contentTopics = await Promise.all(symKeyPromises) - - this.waku.relay.addObserver(this.client.handleWakuMessage, contentTopics) - } - - private unobserveChannelMessages = (chatIds: string[]) => { - const contentTopics = createChannelContentTopics( - chatIds, - this.communityPublicKey + return contentTopic + } ) - this.waku.relay.deleteObserver(this.client.handleWakuMessage, contentTopics) + const contentTopics = await Promise.all(chatPromises) + + this.client.waku.relay.addObserver( + this.client.handleWakuMessage, + contentTopics + ) } - public handleCommunityMetadataEvent = ( - communityMetadata: CommunityMetadataType + private unobserveChatMessages = ( + chatDescription: CommunityDescription['chats'] ) => { - if (this.communityMetadata) { - if (this.communityMetadata.clock > communityMetadata.clock) { + const contentTopics = Object.keys(chatDescription).map(chatUuid => { + const chat = this.chats.get(chatUuid) + const contentTopic = chat!.contentTopic + + this.chats.delete(chatUuid) + + return contentTopic + }) + + this.client.waku.relay.deleteObserver( + this.client.handleWakuMessage, + contentTopics + ) + } + + public handleDescription = (description: CommunityDescription) => { + if (this.description) { + // already handled + if (this.description.clock >= description.clock) { return } - // Channels - const removedChats = difference( - Object.keys(this.communityMetadata.chats), - Object.keys(communityMetadata.chats) + // Chats + // observe + const removedChats = getDifferenceByKeys( + this.description.chats, + description.chats ) - const addedChats = difference( - Object.keys(communityMetadata.chats), - Object.keys(this.communityMetadata.chats) - ) - - if (removedChats.length) { - this.unobserveChannelMessages(removedChats) + if (Object.keys(removedChats).length) { + this.unobserveChatMessages(removedChats) } - if (addedChats.length) { - this.observeChannelMessages(addedChats) + const addedChats = getDifferenceByKeys( + description.chats, + this.description.chats + ) + if (Object.keys(addedChats).length) { + this.observeChatMessages(addedChats) } } // Community - this.communityMetadata = communityMetadata - this.communityCallback?.(communityMetadata) - } - - public handleChannelChatMessageNewEvent = (chatMessage: MessageType) => { - const _messages = this.channelMessages[chatMessage.channelId] || [] - - // findIndexLeft - // const index = _messages.findIndex(({ timestamp }) => { - // new Date(Number(timestamp)) > new Date(Number(message.timestamp)) - // }) - // findIndexRight - let messageIndex = _messages.length - while (messageIndex > 0) { - const _message = _messages[messageIndex - 1] - - // if (_message.messageId === chatMessage.messageId) { - // messageIndex = -1 - - // break - // } - - if (_message.clock <= chatMessage.clock) { - break - } - - messageIndex-- - } - - // // already received - // if (messageIndex < 0) { - // return - // } - - // replied - let responsedToMessageIndex = _messages.length - while (--responsedToMessageIndex >= 0) { - const _message = _messages[responsedToMessageIndex] - - if (_message.messageId === chatMessage.responseTo) { - break - } - } - - if (responsedToMessageIndex >= 0) { - chatMessage.responseToMessage = _messages[responsedToMessageIndex] - } - - _messages.splice(messageIndex, 0, chatMessage) - // state - const channelId = _messages[0].channelId - - this.channelMessages[channelId] = _messages + this.description = description // callback - this.channelMessagesCallbacks[channelId]?.(this.channelMessages[channelId]!) + this.callback?.(this.description) + + // Chats + // handle + Object.entries(this.description.chats).forEach( + ([chatUuid, chatDescription]) => + this.chats.get(chatUuid)?.handleChange(chatDescription) + ) } - public getMessages(channelId: string): MessageType[] { - return this.channelMessages[channelId] ?? [] - } - - public onCommunityUpdate = ( - callback: (community: CommunityMetadataType) => void - ) => { - this.communityCallback = callback + public onChange = (callback: (description: CommunityDescription) => void) => { + this.callback = callback return () => { - this.communityCallback = undefined + this.callback = undefined } } - - public onChannelMessageUpdate = ( - channelId: string, - callback: (messages: MessageType[]) => void - ) => { - this.channelMessagesCallbacks[channelId] = callback - - return () => { - delete this.channelMessagesCallbacks[channelId] - } - } - - public sendTextMessage = async ( - chatUuid: string, - text: string, - responseTo?: string - ) => { - const chat = this.communityMetadata.chats[chatUuid] - - if (!chat) { - throw new Error('Chat not found') - } - - // TODO: move to chat instance - const chatId = `${this.communityPublicKey}${chatUuid}` - const channelContentTopic = idToContentTopic(chatId) - const symKey = await createSymKeyFromPassword(chatId) - - // TODO: protos does not support optional fields :-( - const payload = ChatMessage.encode({ - clock: BigInt(Date.now()), - timestamp: BigInt(Date.now()), - text, - responseTo: responseTo ?? '', - ensName: '', - chatId, - messageType: 'COMMUNITY_CHAT', - contentType: ChatMessage.ContentType.TEXT_PLAIN, - sticker: { hash: '', pack: 0 }, - image: { - type: 'JPEG', - payload: new Uint8Array([]), - }, - audio: { - type: 'AAC', - payload: new Uint8Array([]), - durationMs: BigInt(0), - }, - community: new Uint8Array([]), - grant: new Uint8Array([]), - displayName: '', - }) - - await this.client.sendMessage( - 'TYPE_CHAT_MESSAGE', - payload, - channelContentTopic, - symKey - ) - } - - public sendImageMessage = async (chatUuid: string, image: ImageMessage) => { - const chat = this.communityMetadata.chats[chatUuid] - - if (!chat) { - throw new Error('Chat not found') - } - - // TODO: move to chat instance - const chatId = `${this.communityPublicKey}${chatUuid}` - const channelContentTopic = idToContentTopic(chatId) - const symKey = await createSymKeyFromPassword(chatId) - - const payload = ChatMessage.encode({ - clock: BigInt(Date.now()), - timestamp: BigInt(Date.now()), - text: '', - responseTo: responseTo ?? '', - ensName: '', - chatId, - messageType: 'COMMUNITY_CHAT', - contentType: ChatMessage.ContentType.TEXT_PLAIN, - sticker: { hash: '', pack: 0 }, - image: { - type: image.type, - payload: image.payload, - }, - audio: { - type: 'AAC', - payload: new Uint8Array([]), - durationMs: BigInt(0), - }, - community: new Uint8Array([]), - grant: new Uint8Array([]), - displayName: '', - }) - - await this.client.sendMessage( - 'TYPE_CHAT_MESSAGE', - payload, - channelContentTopic, - symKey - ) - } - - public sendReaction = async ( - chatId: string, - messageId: string, - reaction: EmojiReaction.Type - ) => { - // const chat = this.communityMetadata.chats[chatId] - - // if (!chat) { - // throw new Error('Chat not found') - // } - - // TODO: move to chat instance - // const chatId = `${this.communityPublicKey}${chatUuid}` - const channelContentTopic = idToContentTopic(chatId) - const symKey = await createSymKeyFromPassword(chatId) - - // TODO: protos does not support optional fields :-( - const payload = EmojiReaction.encode({ - clock: BigInt(Date.now()), - chatId: chatId, - messageType: 'COMMUNITY_CHAT', - grant: new Uint8Array([]), - messageId, - retracted: false, - type: reaction, - }) - - await this.client.sendMessage( - 'TYPE_EMOJI_REACTION', - payload, - channelContentTopic, - symKey - ) - } - - public requestToJoin = async (chatUuid: string) => { - if (!this.client.account) { - throw new Error('Account not found') - } - - const chat = this.communityMetadata.chats[chatUuid] - - if (!chat) { - throw new Error('Chat not found') - } - - // TODO: move to chat instance - const chatId = `${this.communityPublicKey}${chatUuid}` - - const payload = CommunityRequestToJoin.encode({ - clock: BigInt(Date.now()), - chatId, - communityId: hexToBytes(this.communityPublicKey.replace(/^0[xX]/, '')), - ensName: '', - }) - - await this.client.sendMessage( - 'TYPE_COMMUNITY_REQUEST_TO_JOIN', - payload, - this.communityContentTopic, - this.communityDecryptionKey - ) - } } diff --git a/packages/status-js/src/client/community/create-channel-content-topics.ts b/packages/status-js/src/client/community/create-channel-content-topics.ts deleted file mode 100644 index f427251f..00000000 --- a/packages/status-js/src/client/community/create-channel-content-topics.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { idToContentTopic } from '../../contentTopic' - -export function createChannelContentTopics( - channelIds: string[], - communityPublicKey: string -) { - const channelTopics = channelIds.map(channelId => { - const id = `${communityPublicKey}${channelId}` - const channelContentTopic = idToContentTopic(id) - - return channelContentTopic - }) - - return channelTopics -} diff --git a/packages/status-js/src/client/community/get-channel-id.ts b/packages/status-js/src/client/community/get-channel-id.ts deleted file mode 100644 index 562c374e..00000000 --- a/packages/status-js/src/client/community/get-channel-id.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function getChannelId(chatId: string) { - return chatId.slice(68) -} diff --git a/packages/status-js/src/client/community/get-chat-uuid.ts b/packages/status-js/src/client/community/get-chat-uuid.ts new file mode 100644 index 00000000..78ff7600 --- /dev/null +++ b/packages/status-js/src/client/community/get-chat-uuid.ts @@ -0,0 +1,3 @@ +export function getChatUuid(chatId: string) { + return chatId.slice(68) +} diff --git a/packages/status-js/src/client/community/handle-waku-message.ts b/packages/status-js/src/client/community/handle-waku-message.ts index 8053fc16..cfefc2e9 100644 --- a/packages/status-js/src/client/community/handle-waku-message.ts +++ b/packages/status-js/src/client/community/handle-waku-message.ts @@ -12,13 +12,12 @@ import { ProtocolMessage } from '../../../protos/protocol-message' import { CommunityDescription } from '../../proto/communities/v1/communities' import { payloadToId } from '../../utils/payload-to-id' import { recoverPublicKey } from '../../utils/recover-public-key' -import { getChannelId } from './get-channel-id' -import { getReactions } from './get-reactions' +import { getChatUuid } from './get-chat-uuid' import { mapChatMessage } from './map-chat-message' import type { Account } from '../../account' import type { Client } from '../../client' -import type { Community /*, MessageType*/ } from './community' +import type { Community } from './community' import type { WakuMessage } from 'js-waku' export function handleWakuMessage( @@ -57,13 +56,13 @@ export function handleWakuMessage( decodedMetadata.payload ) - const wakuMessageId = payloadToId( + const messageId = payloadToId( decodedProtocol?.publicMessage || decodedMetadata.payload, publicKey ) // already handled - if (client.wakuMessages.has(wakuMessageId)) { + if (client.wakuMessages.has(messageId)) { return } @@ -76,7 +75,7 @@ export function handleWakuMessage( const decodedPayload = CommunityDescription.decode(messageToDecode) // handle (state and callback) - community.handleCommunityMetadataEvent(decodedPayload) + community.handleDescription(decodedPayload) success = true @@ -88,17 +87,16 @@ export function handleWakuMessage( const decodedPayload = ChatMessage.decode(messageToDecode) // TODO?: ignore community.channelMessages which are messageType !== COMMUNITY_CHAT + const chatUuid = getChatUuid(decodedPayload.chatId) // map - const channelId = getChannelId(decodedPayload.chatId) - const chatMessage = mapChatMessage(decodedPayload, { - messageId: wakuMessageId, - channelId, + messageId, + chatUuid, }) // handle - community.handleChannelChatMessageNewEvent(chatMessage) + community.chats.get(chatUuid)?.handleNewMessage(chatMessage) success = true @@ -109,40 +107,11 @@ export function handleWakuMessage( const decodedPayload = EditMessage.decode(messageToDecode) const messageId = decodedPayload.messageId - const channelId = getChannelId(decodedPayload.chatId) + const chatUuid = getChatUuid(decodedPayload.chatId) - const _messages = community.channelMessages[channelId] || [] - - let index = _messages.length - while (--index >= 0) { - const _message = _messages[index] - - if (_message.messageId === messageId) { - break - } - } - - // original not found - if (index < 0) { - break - } - - const _message = _messages[index] - - const message = { - ..._message, - text: decodedPayload.text, - } - - _messages[index] = message - - // state - community.channelMessages[channelId] = _messages - - // callback - community.channelMessagesCallbacks[channelId]?.( - community.channelMessages[channelId]! - ) + community.chats + .get(chatUuid) + ?.handleEditedMessage(messageId, decodedPayload.text) success = true @@ -153,33 +122,9 @@ export function handleWakuMessage( const decodedPayload = DeleteMessage.decode(messageToDecode) const messageId = decodedPayload.messageId - const channelId = getChannelId(decodedPayload.chatId) + const chatUuid = getChatUuid(decodedPayload.chatId) - const _messages = community.channelMessages[channelId] || [] - - let index = _messages.length - while (--index >= 0) { - const _message = _messages[index] - - if (_message.messageId === messageId) { - break - } - } - - // original not found - if (index < 0) { - break - } - - _messages.splice(index, 1) - - // state - community.channelMessages[channelId] = _messages - - // callback - community.channelMessagesCallbacks[channelId]?.( - community.channelMessages[channelId]! - ) + community.chats.get(chatUuid)?.handleDeletedMessage(messageId) success = true @@ -190,33 +135,11 @@ export function handleWakuMessage( const decodedPayload = PinMessage.decode(messageToDecode) const messageId = decodedPayload.messageId - const channelId = getChannelId(decodedPayload.chatId) + const chatUuid = getChatUuid(decodedPayload.chatId) - const _messages = community.channelMessages[channelId] || [] - - let index = _messages.length - while (--index >= 0) { - const _message = _messages[index] - - if (_message.messageId === messageId) { - break - } - } - - // original not found - if (index < 0) { - break - } - - _messages[index].pinned = Boolean(decodedPayload.pinned) - - // state - community.channelMessages[channelId] = _messages - - // callback - community.channelMessagesCallbacks[channelId]?.( - community.channelMessages[channelId]! - ) + community.chats + .get(chatUuid) + ?.handlePinnedMessage(messageId, decodedPayload.pinned) success = true @@ -227,41 +150,12 @@ export function handleWakuMessage( const decodedPayload = EmojiReaction.decode(messageToDecode) const messageId = decodedPayload.messageId - const channelId = getChannelId(decodedPayload.chatId) + const chatUuid = getChatUuid(decodedPayload.chatId) + const isMe = account?.publicKey === `0x${bytesToHex(publicKey)}` - const _messages = community.channelMessages[channelId] || [] - - let index = _messages.length - while (--index >= 0) { - const _message = _messages[index] - - if (_message.messageId === messageId) { - break - } - } - - // original not found - if (index < 0) { - break - } - - const _message = _messages[index] - const isMe = - account?.publicKey === `0x${bytesToHex(wakuMessage.signaturePublicKey)}` - - _messages[index].reactions = getReactions( - decodedPayload, - _message.reactions, - isMe - ) - - // state - community.channelMessages[channelId] = _messages - - // callback - community.channelMessagesCallbacks[channelId]?.( - community.channelMessages[channelId]! - ) + community.chats + .get(chatUuid) + ?.handleEmojiReaction(messageId, decodedPayload, isMe) success = true @@ -269,11 +163,13 @@ export function handleWakuMessage( } default: + success = true + break } if (success) { - client.wakuMessages.add(wakuMessageId) + client.wakuMessages.add(messageId) } return diff --git a/packages/status-js/src/client/community/map-chat-message.ts b/packages/status-js/src/client/community/map-chat-message.ts index 3819e10c..58479820 100644 --- a/packages/status-js/src/client/community/map-chat-message.ts +++ b/packages/status-js/src/client/community/map-chat-message.ts @@ -1,22 +1,19 @@ -import type { ChatMessage } from '../../../protos/chat-message' -import type { MessageType } from './community' -// import type { Reactions } from './set-reactions' +import type { ChatMessage } from '../chat' +import type { ChatMessage as ChatMessageProto } from '~/protos/chat-message' export function mapChatMessage( - decodedMessage: ChatMessage, + decodedMessage: ChatMessageProto, props: { messageId: string - channelId: string - // pinned: boolean - // reactions: Reactions + chatUuid: string } -): MessageType { - const { messageId, channelId } = props +): ChatMessage { + const { messageId, chatUuid } = props const message = { ...decodedMessage, messageId, - channelId, + chatUuid, pinned: false, reactions: { THUMBS_UP: { diff --git a/packages/status-js/src/helpers/get-difference-by-keys.ts b/packages/status-js/src/helpers/get-difference-by-keys.ts new file mode 100644 index 00000000..ebbc8ca5 --- /dev/null +++ b/packages/status-js/src/helpers/get-difference-by-keys.ts @@ -0,0 +1,16 @@ +export function getDifferenceByKeys>( + a: T, + b: T +): T { + const initialValue: Record = {} + + const result = Object.entries(a).reduce((result, [key, value]) => { + if (!b[key]) { + result[key] = value + } + + return result + }, initialValue) + + return result as T +} diff --git a/packages/status-js/src/index.ts b/packages/status-js/src/index.ts index 8f5f6e0d..19e0eee6 100644 --- a/packages/status-js/src/index.ts +++ b/packages/status-js/src/index.ts @@ -1,4 +1,5 @@ export type { Account } from './account' export type { Client, ClientOptions } from './client' export { createClient } from './client' -export type { Community, MessageType } from './client/community/community' +export type { ChatMessage as Message } from './client/chat' +export type { Community } from './client/community/community' diff --git a/packages/status-react/src/protocol/provider.tsx b/packages/status-react/src/protocol/provider.tsx index ea44ec51..629f798f 100644 --- a/packages/status-react/src/protocol/provider.tsx +++ b/packages/status-react/src/protocol/provider.tsx @@ -11,14 +11,14 @@ const Context = createContext(undefined) type State = { loading: boolean client: Client | undefined - community: Community['communityMetadata'] | undefined + community: Community['description'] | undefined account: Account | undefined dispatch?: React.Dispatch } type Action = | { type: 'INIT'; client: Client } - | { type: 'UPDATE_COMMUNITY'; community: Community['communityMetadata'] } + | { type: 'UPDATE_COMMUNITY'; community: Community['description'] } | { type: 'SET_ACCOUNT'; account: Account } | { type: 'REMOVE_ACCOUNT' } @@ -35,7 +35,7 @@ const reducer = (state: State, action: Action): State => { ...state, loading: false, client, - community: client.community.communityMetadata, + community: client.community.description, } } case 'UPDATE_COMMUNITY': { @@ -76,7 +76,7 @@ export const ProtocolProvider = (props: Props) => { useEffect(() => { if (client) { - return client.community.onCommunityUpdate(community => { + return client.community.onChange(community => { dispatch({ type: 'UPDATE_COMMUNITY', community }) }) } @@ -103,7 +103,7 @@ export function useProtocol() { // we enforce initialization of client before rendering children return context as State & { client: Client - community: Community['communityMetadata'] + community: Community['description'] dispatch: React.Dispatch } } diff --git a/packages/status-react/src/protocol/use-chat.tsx b/packages/status-react/src/protocol/use-chat.tsx index 7ee40230..05df3cf1 100644 --- a/packages/status-react/src/protocol/use-chat.tsx +++ b/packages/status-react/src/protocol/use-chat.tsx @@ -4,7 +4,7 @@ import { useProtocol } from './provider' import type { Community } from '@status-im/js' -export type Chat = Community['communityMetadata']['chats'][0] +export type Chat = Community['description']['chats'][0] export const useChat = (id: string): Chat => { const { community } = useProtocol() diff --git a/packages/status-react/src/protocol/use-chats.tsx b/packages/status-react/src/protocol/use-chats.tsx index 29905607..9c4dd1de 100644 --- a/packages/status-react/src/protocol/use-chats.tsx +++ b/packages/status-react/src/protocol/use-chats.tsx @@ -4,7 +4,7 @@ import { useProtocol } from './provider' import type { Community } from '@status-im/js' -export type Chat = Community['communityMetadata']['chats'][0] & { +export type Chat = Community['description']['chats'][0] & { id: string } diff --git a/packages/status-react/src/protocol/use-members.tsx b/packages/status-react/src/protocol/use-members.tsx index db241948..d4e1d35b 100644 --- a/packages/status-react/src/protocol/use-members.tsx +++ b/packages/status-react/src/protocol/use-members.tsx @@ -2,7 +2,7 @@ import { useProtocol } from '~/src/protocol' import type { Community } from '@status-im/js' -export type Member = Community['communityMetadata']['members'][0] +export type Member = Community['description']['members'][0] export const useMembers = (): string[] => { const { community } = useProtocol() diff --git a/packages/status-react/src/protocol/use-messages.tsx b/packages/status-react/src/protocol/use-messages.tsx index efd673db..9b178945 100644 --- a/packages/status-react/src/protocol/use-messages.tsx +++ b/packages/status-react/src/protocol/use-messages.tsx @@ -91,20 +91,20 @@ export const useMessages = (channelId: string): Result => { // const [state, dispatch] = useReducer((state,action) => {}, {}) const [data, setData] = useState(() => - client.community.getMessages(channelId) + client.community.chats.get(channelId).getMessages() ) const [loading, setLoading] = useState(true) const [error, setError] = useState() useEffect(() => { - setData(client.community.getMessages(channelId)) + setData(client.community.chats.get(channelId).getMessages()) const handleUpdate = (messages: Message[]) => { setLoading(false) setData(messages) } - return client.community.onChannelMessageUpdate(channelId, handleUpdate) + return client.community.chats.get(channelId).onMessage(handleUpdate) }, [channelId]) return { diff --git a/packages/status-react/src/routes/chat/components/chat-message/index.tsx b/packages/status-react/src/routes/chat/components/chat-message/index.tsx index 97d79703..3b451a47 100644 --- a/packages/status-react/src/routes/chat/components/chat-message/index.tsx +++ b/packages/status-react/src/routes/chat/components/chat-message/index.tsx @@ -77,15 +77,14 @@ export const ChatMessage = (props: Props) => { const userProfileDialog = useDialog(UserProfileDialog) const handleMessageSubmit = (message: string) => { - client.community.sendTextMessage( - chatId, + client.community.get(chatId).sendTextMessage( message, '0x0fa999097568d1fdcc39108a08d75340bd2cee5ec59c36799007150d0a9fc896' ) } const handleReaction = (reaction: Reaction) => { - client.community.sendReaction(chatId, messageId, reaction) + client.community.get(chatId).sendReaction(chatId, messageId, reaction) } const handleReplyClick = () => { diff --git a/packages/status-react/src/routes/chat/index.tsx b/packages/status-react/src/routes/chat/index.tsx index d3fa05c3..0aae6be3 100644 --- a/packages/status-react/src/routes/chat/index.tsx +++ b/packages/status-react/src/routes/chat/index.tsx @@ -82,8 +82,7 @@ export const Chat = () => { const showMembers = enableMembers && state.showMembers const handleMessageSubmit = (message: string) => { - client.community.sendTextMessage( - chatId, + client.community.get(chatId).sendTextMessage( message // '0x0fa999097568d1fdcc39108a08d75340bd2cee5ec59c36799007150d0a9fc896' )