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
This commit is contained in:
Felicio Mununga 2022-06-13 16:33:57 +02:00 committed by GitHub
parent 29b0e23319
commit 062c29d6fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 598 additions and 573 deletions

View File

@ -46,7 +46,6 @@
"ethereum-cryptography": "^1.0.3", "ethereum-cryptography": "^1.0.3",
"js-sha3": "^0.8.0", "js-sha3": "^0.8.0",
"js-waku": "^0.23.0", "js-waku": "^0.23.0",
"lodash": "^4.17.21",
"long": "^5.2.0", "long": "^5.2.0",
"pbkdf2": "^3.1.2", "pbkdf2": "^3.1.2",
"protobufjs": "^6.11.3", "protobufjs": "^6.11.3",
@ -57,7 +56,6 @@
"devDependencies": { "devDependencies": {
"@types/bn.js": "^5.1.0", "@types/bn.js": "^5.1.0",
"@types/elliptic": "^6.4.14", "@types/elliptic": "^6.4.14",
"@types/lodash": "^4.14.182",
"@types/pbkdf2": "^3.1.0", "@types/pbkdf2": "^3.1.0",
"@types/secp256k1": "^4.0.3", "@types/secp256k1": "^4.0.3",
"@types/uuid": "^8.3.3", "@types/uuid": "^8.3.3",

View File

@ -117,6 +117,7 @@ export interface CommunityDescription {
members: CommunityMember members: CommunityMember
permissions: CommunityPermissions permissions: CommunityPermissions
identity: ChatIdentity identity: ChatIdentity
// fixme!: Map
chats: CommunityChat chats: CommunityChat
banList: string[] banList: string[]
categories: CommunityCategory categories: CommunityCategory

View File

@ -17,7 +17,7 @@ export interface ClientOptions {
} }
class Client { class Client {
private waku: Waku public waku: Waku
public readonly wakuMessages: Set<string> public readonly wakuMessages: Set<string>
public account?: Account public account?: Account
@ -29,7 +29,7 @@ class Client {
this.wakuMessages = new Set() this.wakuMessages = new Set()
// Community // Community
this.community = new Community(this, waku, options.publicKey) this.community = new Community(this, options.publicKey)
} }
static async start(options: ClientOptions) { static async start(options: ClientOptions) {
@ -72,7 +72,7 @@ class Client {
// this.account = undefined // this.account = undefined
// } // }
public sendMessage = async ( public sendWakuMessage = async (
type: keyof typeof ApplicationMetadataMessage.Type, type: keyof typeof ApplicationMetadataMessage.Type,
payload: Uint8Array, payload: Uint8Array,
contentTopic: string, contentTopic: string,

View File

@ -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<ChatMessage, 'responseToMessage'>
}
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
)
}
}

View File

@ -1,82 +1,69 @@
import { hexToBytes } from 'ethereum-cryptography/utils' import { waku_message } from 'js-waku'
import { PageDirection, waku_message } from 'js-waku'
import difference from 'lodash/difference'
import { ChatMessage } from '~/protos/chat-message' import { MessageType } from '~/protos/enums'
import { CommunityRequestToJoin } from '~/protos/communities' import { getDifferenceByKeys } from '~/src/helpers/get-difference-by-keys'
import { EmojiReaction } from '~/protos/emoji-reaction'
import { idToContentTopic } from '../../contentTopic' import { idToContentTopic } from '../../contentTopic'
import { createSymKeyFromPassword } from '../../encryption' import { createSymKeyFromPassword } from '../../encryption'
import { createChannelContentTopics } from './create-channel-content-topics' import { Chat } from '../chat'
import type { Client } from '../../client' import type { Client } from '../../client'
import type { CommunityDescription } from '../../wire/community_description' import type {
import type { Reactions } from './get-reactions' CommunityChat,
import type { ImageMessage } from '~/src/proto/communities/v1/chat_message' CommunityDescription,
import type { Waku } from 'js-waku' } from '~/src/proto/communities/v1/communities'
export type CommunityMetadataType = CommunityDescription['proto']
export type MessageType = ChatMessage & {
messageId: string
pinned: boolean
reactions: Reactions
channelId: string
responseToMessage?: Omit<MessageType, 'responseToMessage'>
}
export class Community { export class Community {
private client: Client 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<string, Chat>
public callback: ((description: CommunityDescription) => void) | undefined
constructor(client: Client, publicKey: string) {
this.client = client this.client = client
this.waku = waku this.publicKey = publicKey
this.communityPublicKey = publicKey
this.chats = new Map()
} }
public async start() { public async start() {
this.communityContentTopic = idToContentTopic(this.communityPublicKey) this.contentTopic = idToContentTopic(this.publicKey)
this.communityDecryptionKey = await createSymKeyFromPassword( this.symmetricKey = await createSymKeyFromPassword(this.publicKey)
this.communityPublicKey
)
// Waku // 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 // Community
const communityMetadata = await this.fetchCommunity() const description = await this.fetch()
if (!communityMetadata) { if (!description) {
throw new Error('Failed to intiliaze Community') throw new Error('Failed to intiliaze Community')
} }
this.communityMetadata = communityMetadata this.description = description
await this.observeCommunity() this.observe()
// Channels // Chats
await this.observeChannelMessages(Object.keys(this.communityMetadata.chats)) await this.observeChatMessages(this.description.chats)
} }
public fetchCommunity = async () => { // todo: rename this to chats when changing references in ui
let communityMetadata: CommunityMetadataType | undefined public get _chats() {
let shouldStop = false return [...this.chats.values()]
}
await this.waku.store.queryHistory([this.communityContentTopic], { public fetch = async () => {
decryptionKeys: [this.communityDecryptionKey], await this.client.waku.store.queryHistory([this.contentTopic], {
// oldest message first // oldest message first
callback: wakuMessages => { callback: wakuMessages => {
let index = wakuMessages.length let index = wakuMessages.length
@ -85,380 +72,117 @@ export class Community {
while (--index >= 0) { while (--index >= 0) {
this.client.handleWakuMessage(wakuMessages[index]) this.client.handleWakuMessage(wakuMessages[index])
if (!this.communityMetadata) { return this.description !== undefined
return shouldStop
}
communityMetadata = this.communityMetadata
shouldStop = true
return shouldStop
} }
}, },
}) })
return communityMetadata return this.description
} }
public createFetchChannelMessages = async ( private observe = () => {
channelId: string, this.client.waku.relay.addObserver(this.client.handleWakuMessage, [
callback: (messages: MessageType[]) => void this.contentTopic,
) => {
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 observeChannelMessages = async (chatsIds: string[]) => { private observeChatMessages = async (
const symKeyPromises = chatsIds.map(async (chatId: string) => { chatDescriptions: CommunityDescription['chats']
const id = `${this.communityPublicKey}${chatId}` ) => {
const channelContentTopic = idToContentTopic(id) 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, { this.client.waku.relay.addDecryptionKey(chat.symmetricKey, {
method: waku_message.DecryptionMethod.Symmetric, method: waku_message.DecryptionMethod.Symmetric,
contentTopics: [channelContentTopic], contentTopics: [contentTopic],
}) })
return channelContentTopic return contentTopic
})
const contentTopics = await Promise.all(symKeyPromises)
this.waku.relay.addObserver(this.client.handleWakuMessage, contentTopics)
} }
private unobserveChannelMessages = (chatIds: string[]) => {
const contentTopics = createChannelContentTopics(
chatIds,
this.communityPublicKey
) )
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 = ( private unobserveChatMessages = (
communityMetadata: CommunityMetadataType chatDescription: CommunityDescription['chats']
) => { ) => {
if (this.communityMetadata) { const contentTopics = Object.keys(chatDescription).map(chatUuid => {
if (this.communityMetadata.clock > communityMetadata.clock) { 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 return
} }
// Channels // Chats
const removedChats = difference( // observe
Object.keys(this.communityMetadata.chats), const removedChats = getDifferenceByKeys(
Object.keys(communityMetadata.chats) this.description.chats,
description.chats
) )
const addedChats = difference( if (Object.keys(removedChats).length) {
Object.keys(communityMetadata.chats), this.unobserveChatMessages(removedChats)
Object.keys(this.communityMetadata.chats)
)
if (removedChats.length) {
this.unobserveChannelMessages(removedChats)
} }
if (addedChats.length) { const addedChats = getDifferenceByKeys(
this.observeChannelMessages(addedChats) description.chats,
this.description.chats
)
if (Object.keys(addedChats).length) {
this.observeChatMessages(addedChats)
} }
} }
// Community // 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 // state
const channelId = _messages[0].channelId this.description = description
this.channelMessages[channelId] = _messages
// callback // 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[] { public onChange = (callback: (description: CommunityDescription) => void) => {
return this.channelMessages[channelId] ?? [] this.callback = callback
}
public onCommunityUpdate = (
callback: (community: CommunityMetadataType) => void
) => {
this.communityCallback = callback
return () => { 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
)
}
} }

View File

@ -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
}

View File

@ -1,3 +0,0 @@
export function getChannelId(chatId: string) {
return chatId.slice(68)
}

View File

@ -0,0 +1,3 @@
export function getChatUuid(chatId: string) {
return chatId.slice(68)
}

View File

@ -12,13 +12,12 @@ import { ProtocolMessage } from '../../../protos/protocol-message'
import { CommunityDescription } from '../../proto/communities/v1/communities' import { CommunityDescription } from '../../proto/communities/v1/communities'
import { payloadToId } from '../../utils/payload-to-id' import { payloadToId } from '../../utils/payload-to-id'
import { recoverPublicKey } from '../../utils/recover-public-key' import { recoverPublicKey } from '../../utils/recover-public-key'
import { getChannelId } from './get-channel-id' import { getChatUuid } from './get-chat-uuid'
import { getReactions } from './get-reactions'
import { mapChatMessage } from './map-chat-message' import { mapChatMessage } from './map-chat-message'
import type { Account } from '../../account' import type { Account } from '../../account'
import type { Client } from '../../client' import type { Client } from '../../client'
import type { Community /*, MessageType*/ } from './community' import type { Community } from './community'
import type { WakuMessage } from 'js-waku' import type { WakuMessage } from 'js-waku'
export function handleWakuMessage( export function handleWakuMessage(
@ -57,13 +56,13 @@ export function handleWakuMessage(
decodedMetadata.payload decodedMetadata.payload
) )
const wakuMessageId = payloadToId( const messageId = payloadToId(
decodedProtocol?.publicMessage || decodedMetadata.payload, decodedProtocol?.publicMessage || decodedMetadata.payload,
publicKey publicKey
) )
// already handled // already handled
if (client.wakuMessages.has(wakuMessageId)) { if (client.wakuMessages.has(messageId)) {
return return
} }
@ -76,7 +75,7 @@ export function handleWakuMessage(
const decodedPayload = CommunityDescription.decode(messageToDecode) const decodedPayload = CommunityDescription.decode(messageToDecode)
// handle (state and callback) // handle (state and callback)
community.handleCommunityMetadataEvent(decodedPayload) community.handleDescription(decodedPayload)
success = true success = true
@ -88,17 +87,16 @@ export function handleWakuMessage(
const decodedPayload = ChatMessage.decode(messageToDecode) const decodedPayload = ChatMessage.decode(messageToDecode)
// TODO?: ignore community.channelMessages which are messageType !== COMMUNITY_CHAT // TODO?: ignore community.channelMessages which are messageType !== COMMUNITY_CHAT
const chatUuid = getChatUuid(decodedPayload.chatId)
// map // map
const channelId = getChannelId(decodedPayload.chatId)
const chatMessage = mapChatMessage(decodedPayload, { const chatMessage = mapChatMessage(decodedPayload, {
messageId: wakuMessageId, messageId,
channelId, chatUuid,
}) })
// handle // handle
community.handleChannelChatMessageNewEvent(chatMessage) community.chats.get(chatUuid)?.handleNewMessage(chatMessage)
success = true success = true
@ -109,40 +107,11 @@ export function handleWakuMessage(
const decodedPayload = EditMessage.decode(messageToDecode) const decodedPayload = EditMessage.decode(messageToDecode)
const messageId = decodedPayload.messageId const messageId = decodedPayload.messageId
const channelId = getChannelId(decodedPayload.chatId) const chatUuid = getChatUuid(decodedPayload.chatId)
const _messages = community.channelMessages[channelId] || [] community.chats
.get(chatUuid)
let index = _messages.length ?.handleEditedMessage(messageId, decodedPayload.text)
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]!
)
success = true success = true
@ -153,33 +122,9 @@ export function handleWakuMessage(
const decodedPayload = DeleteMessage.decode(messageToDecode) const decodedPayload = DeleteMessage.decode(messageToDecode)
const messageId = decodedPayload.messageId const messageId = decodedPayload.messageId
const channelId = getChannelId(decodedPayload.chatId) const chatUuid = getChatUuid(decodedPayload.chatId)
const _messages = community.channelMessages[channelId] || [] community.chats.get(chatUuid)?.handleDeletedMessage(messageId)
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]!
)
success = true success = true
@ -190,33 +135,11 @@ export function handleWakuMessage(
const decodedPayload = PinMessage.decode(messageToDecode) const decodedPayload = PinMessage.decode(messageToDecode)
const messageId = decodedPayload.messageId const messageId = decodedPayload.messageId
const channelId = getChannelId(decodedPayload.chatId) const chatUuid = getChatUuid(decodedPayload.chatId)
const _messages = community.channelMessages[channelId] || [] community.chats
.get(chatUuid)
let index = _messages.length ?.handlePinnedMessage(messageId, decodedPayload.pinned)
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]!
)
success = true success = true
@ -227,41 +150,12 @@ export function handleWakuMessage(
const decodedPayload = EmojiReaction.decode(messageToDecode) const decodedPayload = EmojiReaction.decode(messageToDecode)
const messageId = decodedPayload.messageId 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] || [] community.chats
.get(chatUuid)
let index = _messages.length ?.handleEmojiReaction(messageId, decodedPayload, isMe)
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]!
)
success = true success = true
@ -269,11 +163,13 @@ export function handleWakuMessage(
} }
default: default:
success = true
break break
} }
if (success) { if (success) {
client.wakuMessages.add(wakuMessageId) client.wakuMessages.add(messageId)
} }
return return

View File

@ -1,22 +1,19 @@
import type { ChatMessage } from '../../../protos/chat-message' import type { ChatMessage } from '../chat'
import type { MessageType } from './community' import type { ChatMessage as ChatMessageProto } from '~/protos/chat-message'
// import type { Reactions } from './set-reactions'
export function mapChatMessage( export function mapChatMessage(
decodedMessage: ChatMessage, decodedMessage: ChatMessageProto,
props: { props: {
messageId: string messageId: string
channelId: string chatUuid: string
// pinned: boolean
// reactions: Reactions
} }
): MessageType { ): ChatMessage {
const { messageId, channelId } = props const { messageId, chatUuid } = props
const message = { const message = {
...decodedMessage, ...decodedMessage,
messageId, messageId,
channelId, chatUuid,
pinned: false, pinned: false,
reactions: { reactions: {
THUMBS_UP: { THUMBS_UP: {

View File

@ -0,0 +1,16 @@
export function getDifferenceByKeys<T extends Record<string, unknown>>(
a: T,
b: T
): T {
const initialValue: Record<string, unknown> = {}
const result = Object.entries(a).reduce((result, [key, value]) => {
if (!b[key]) {
result[key] = value
}
return result
}, initialValue)
return result as T
}

View File

@ -1,4 +1,5 @@
export type { Account } from './account' export type { Account } from './account'
export type { Client, ClientOptions } from './client' export type { Client, ClientOptions } from './client'
export { createClient } 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'

View File

@ -11,14 +11,14 @@ const Context = createContext<State | undefined>(undefined)
type State = { type State = {
loading: boolean loading: boolean
client: Client | undefined client: Client | undefined
community: Community['communityMetadata'] | undefined community: Community['description'] | undefined
account: Account | undefined account: Account | undefined
dispatch?: React.Dispatch<Action> dispatch?: React.Dispatch<Action>
} }
type Action = type Action =
| { type: 'INIT'; client: Client } | { type: 'INIT'; client: Client }
| { type: 'UPDATE_COMMUNITY'; community: Community['communityMetadata'] } | { type: 'UPDATE_COMMUNITY'; community: Community['description'] }
| { type: 'SET_ACCOUNT'; account: Account } | { type: 'SET_ACCOUNT'; account: Account }
| { type: 'REMOVE_ACCOUNT' } | { type: 'REMOVE_ACCOUNT' }
@ -35,7 +35,7 @@ const reducer = (state: State, action: Action): State => {
...state, ...state,
loading: false, loading: false,
client, client,
community: client.community.communityMetadata, community: client.community.description,
} }
} }
case 'UPDATE_COMMUNITY': { case 'UPDATE_COMMUNITY': {
@ -76,7 +76,7 @@ export const ProtocolProvider = (props: Props) => {
useEffect(() => { useEffect(() => {
if (client) { if (client) {
return client.community.onCommunityUpdate(community => { return client.community.onChange(community => {
dispatch({ type: 'UPDATE_COMMUNITY', community }) dispatch({ type: 'UPDATE_COMMUNITY', community })
}) })
} }
@ -103,7 +103,7 @@ export function useProtocol() {
// we enforce initialization of client before rendering children // we enforce initialization of client before rendering children
return context as State & { return context as State & {
client: Client client: Client
community: Community['communityMetadata'] community: Community['description']
dispatch: React.Dispatch<Action> dispatch: React.Dispatch<Action>
} }
} }

View File

@ -4,7 +4,7 @@ import { useProtocol } from './provider'
import type { Community } from '@status-im/js' 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 => { export const useChat = (id: string): Chat => {
const { community } = useProtocol() const { community } = useProtocol()

View File

@ -4,7 +4,7 @@ import { useProtocol } from './provider'
import type { Community } from '@status-im/js' import type { Community } from '@status-im/js'
export type Chat = Community['communityMetadata']['chats'][0] & { export type Chat = Community['description']['chats'][0] & {
id: string id: string
} }

View File

@ -2,7 +2,7 @@ import { useProtocol } from '~/src/protocol'
import type { Community } from '@status-im/js' 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[] => { export const useMembers = (): string[] => {
const { community } = useProtocol() const { community } = useProtocol()

View File

@ -91,20 +91,20 @@ export const useMessages = (channelId: string): Result => {
// const [state, dispatch] = useReducer<Result>((state,action) => {}, {}) // const [state, dispatch] = useReducer<Result>((state,action) => {}, {})
const [data, setData] = useState<any[]>(() => const [data, setData] = useState<any[]>(() =>
client.community.getMessages(channelId) client.community.chats.get(channelId).getMessages()
) )
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error>() const [error, setError] = useState<Error>()
useEffect(() => { useEffect(() => {
setData(client.community.getMessages(channelId)) setData(client.community.chats.get(channelId).getMessages())
const handleUpdate = (messages: Message[]) => { const handleUpdate = (messages: Message[]) => {
setLoading(false) setLoading(false)
setData(messages) setData(messages)
} }
return client.community.onChannelMessageUpdate(channelId, handleUpdate) return client.community.chats.get(channelId).onMessage(handleUpdate)
}, [channelId]) }, [channelId])
return { return {

View File

@ -77,15 +77,14 @@ export const ChatMessage = (props: Props) => {
const userProfileDialog = useDialog(UserProfileDialog) const userProfileDialog = useDialog(UserProfileDialog)
const handleMessageSubmit = (message: string) => { const handleMessageSubmit = (message: string) => {
client.community.sendTextMessage( client.community.get(chatId).sendTextMessage(
chatId,
message, message,
'0x0fa999097568d1fdcc39108a08d75340bd2cee5ec59c36799007150d0a9fc896' '0x0fa999097568d1fdcc39108a08d75340bd2cee5ec59c36799007150d0a9fc896'
) )
} }
const handleReaction = (reaction: Reaction) => { const handleReaction = (reaction: Reaction) => {
client.community.sendReaction(chatId, messageId, reaction) client.community.get(chatId).sendReaction(chatId, messageId, reaction)
} }
const handleReplyClick = () => { const handleReplyClick = () => {

View File

@ -82,8 +82,7 @@ export const Chat = () => {
const showMembers = enableMembers && state.showMembers const showMembers = enableMembers && state.showMembers
const handleMessageSubmit = (message: string) => { const handleMessageSubmit = (message: string) => {
client.community.sendTextMessage( client.community.get(chatId).sendTextMessage(
chatId,
message message
// '0x0fa999097568d1fdcc39108a08d75340bd2cee5ec59c36799007150d0a9fc896' // '0x0fa999097568d1fdcc39108a08d75340bd2cee5ec59c36799007150d0a9fc896'
) )