diff --git a/.vscode/launch.json b/.vscode/launch.json index e7ff8ee4..460cdd6e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,24 @@ { "version": "0.2.0", "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch via Vite Node", + "runtimeExecutable": "node", + "skipFiles": ["/**"], + // todo?: make relative to ${file} + "cwd": "${workspaceFolder}/packages/status-js", + "program": "${workspaceRoot}/node_modules/vite-node/dist/cli.mjs", + "args": ["${file}"], + "smartStep": true, + "sourceMaps": true, + "env": { + "DEBUG": "*", + "NODE_ENV": "test", + "VITEST": "true" + } + }, { "type": "node", "request": "launch", diff --git a/packages/status-js/src/client/activityCenter.ts b/packages/status-js/src/client/activityCenter.ts new file mode 100644 index 00000000..5302d4a3 --- /dev/null +++ b/packages/status-js/src/client/activityCenter.ts @@ -0,0 +1,143 @@ +// todo?: rename to notifications (center?), inbox, or keep same as other platforms + +import type { ChatMessage } from './chat' +import type { Client } from './client' + +// todo?: rename to Activity +type Notification = { + type: 'message' + value: ChatMessage + isMention?: boolean + isReply?: boolean +} + +export type ActivityCenterLatest = { + notifications: Notification[] + // todo?: rename count to mentionsAndRepliesCount + unreadChats: Map +} + +export class ActivityCenter { + #client: Client + + #notifications: Set + #callbacks: Set<(latest: ActivityCenterLatest) => void> + + constructor(client: Client) { + this.#client = client + + this.#notifications = new Set() + this.#callbacks = new Set() + } + + public getLatest = (): ActivityCenterLatest => { + const notifications: Notification[] = [] + const unreadChats: Map = new Map() + + for (const notification of this.#notifications.values()) { + if (notification.type === 'message') { + const chatUuid = notification.value.chatUuid + + const chat = unreadChats.get(chatUuid) + let count = chat?.count ?? 0 + + if (notification.isMention || notification.isReply) { + count++ + } + + if (chat) { + chat.count = count + } else { + unreadChats.set(chatUuid, { count }) + } + } + + notifications.push(notification) + } + + notifications.sort((a, b) => { + if (a.value.clock > b.value.clock) { + return -1 + } + + if (a.value.clock < b.value.clock) { + return 1 + } + + return 0 + }) + + // fixme!?: do not display regular messages, only mentions and replies + // todo?: group notifications (all, unreads, mentions, replies, _chats.{id,count}) + return { notifications, unreadChats } + } + + // todo: pass ids instead of values and resolve within + public addMessageNotification = ( + newMessage: ChatMessage, + referencedMessage?: ChatMessage + ) => { + let isMention: boolean | undefined + let isReply: boolean | undefined + + if (this.#client.account) { + const publicKey = `0x${this.#client.account.publicKey}` + + isMention = newMessage.text.includes(publicKey) + isReply = referencedMessage?.signer === publicKey + } + + // todo?: getLatest on login + this.#notifications.add({ + type: 'message', + value: newMessage, + isMention, + isReply, + }) + + this.emitLatest() + } + + /** + * Removes all notifications. + */ + removeNotifications = () => { + this.#notifications.clear() + + this.emitLatest() + } + + /** + * Removes chat message notifications from the Activity Center. For example, + * on only opening or after scrolling to the end. + */ + public removeChatNotifications = (chatUuid: string) => { + // todo?: add chatUuid to "readChats" Set instead and resolve in getNotifications + // triggered by following emit, and clear the set afterwards + for (const notification of this.#notifications) { + if (notification.type !== 'message') { + continue + } + + if (notification.value.chatUuid === chatUuid) { + this.#notifications.delete(notification) + } + } + + this.emitLatest() + } + + private emitLatest = () => { + const latest = this.getLatest() + + this.#callbacks.forEach(callback => callback(latest)) + } + + public onChange = (callback: (latest: ActivityCenterLatest) => void) => { + this.#callbacks.add(callback) + + return () => { + this.#callbacks.delete(callback) + } + } +} diff --git a/packages/status-js/src/client/chat.ts b/packages/status-js/src/client/chat.ts index 36fc3971..61f990bd 100644 --- a/packages/status-js/src/client/chat.ts +++ b/packages/status-js/src/client/chat.ts @@ -51,6 +51,7 @@ export class Chat { #pinEvents: Map> #reactEvents: Map> #deleteEvents: Map> + #isActive: boolean #fetchingMessages?: boolean #previousFetchedStartTime?: Date #oldestFetchedMessage?: FetchedMessage @@ -81,6 +82,7 @@ export class Chat { this.#pinEvents = new Map() this.#reactEvents = new Map() this.#deleteEvents = new Map() + this.#isActive = false this.messageCallbacks = new Set() } @@ -158,9 +160,16 @@ export class Chat { callback: (messages: ChatMessage[]) => void ): (() => void) => { this.messageCallbacks.add(callback) + // todo?: set from ui, think use case without an ui + this.#isActive = true + // todo?!: only if in `unreadChats`, keep "unreads" separate from `notifications` + // todo?: only if at the bottom and all unread messages are in view + // todo?: call from ui + this.client.activityCenter.removeChatNotifications(this.uuid) return () => { this.messageCallbacks.delete(callback) + this.#isActive = false } } @@ -305,6 +314,18 @@ export class Chat { // callback this.emitMessages() + + // notifications + const isAuthor = + this.client.account !== undefined && + this.isAuthor(newMessage, `0x${this.client.account.publicKey}`) + + if (!this.#isActive && !isAuthor) { + this.client.activityCenter.addMessageNotification( + newMessage, + this.#messages.get(newMessage.responseTo) + ) + } } public handleEditedMessage = ( diff --git a/packages/status-js/src/client/client.ts b/packages/status-js/src/client/client.ts index 97d53d03..30a3d830 100644 --- a/packages/status-js/src/client/client.ts +++ b/packages/status-js/src/client/client.ts @@ -12,6 +12,7 @@ import { import { ApplicationMetadataMessage } from '../protos/application-metadata-message' import { Account } from './account' +import { ActivityCenter } from './activityCenter' import { Community } from './community/community' import { handleWakuMessage } from './community/handle-waku-message' @@ -37,6 +38,7 @@ class Client { */ #wakuDisconnectionTimer: ReturnType + public activityCenter: ActivityCenter public account?: Account public community: Community @@ -50,6 +52,9 @@ class Client { this.wakuMessages = new Set() this.#wakuDisconnectionTimer = wakuDisconnectionTimer + // Activity Center + this.activityCenter = new ActivityCenter(this) + // Community this.community = new Community(this, options.publicKey) } diff --git a/packages/status-js/src/index.ts b/packages/status-js/src/index.ts index db0ab148..2599977d 100644 --- a/packages/status-js/src/index.ts +++ b/packages/status-js/src/index.ts @@ -1,4 +1,8 @@ export type { Account } from './client/account' +export type { + ActivityCenter, + ActivityCenterLatest, +} from './client/activityCenter' export type { ChatMessage as Message } from './client/chat' export type { Client, ClientOptions } from './client/client' export { createClient } from './client/client' diff --git a/packages/status-js/vite.config.ts b/packages/status-js/vite.config.ts index 01a77a86..f97ac9eb 100644 --- a/packages/status-js/vite.config.ts +++ b/packages/status-js/vite.config.ts @@ -14,7 +14,7 @@ const external = [ export default defineConfig(({ mode }) => { const alias: Alias[] = [] - if (mode === 'test') { + if (process.env.VITEST === 'true' || mode === 'test') { alias.push({ /** * Note: `happy-dom` nor `jsdom` have Crypto implemented (@see https://github.com/jsdom/jsdom/issues/1612) diff --git a/packages/status-react/src/components/main-sidebar/components/chats/chat-item.tsx b/packages/status-react/src/components/main-sidebar/components/chats/chat-item.tsx index fa71d6a4..271ee500 100644 --- a/packages/status-react/src/components/main-sidebar/components/chats/chat-item.tsx +++ b/packages/status-react/src/components/main-sidebar/components/chats/chat-item.tsx @@ -2,6 +2,7 @@ import React, { forwardRef } from 'react' import { NavLink } from 'react-router-dom' +import { useActivityCenter } from '../../../../protocol' import { styled } from '../../../../styles/config' import { Avatar } from '../../../../system' @@ -15,8 +16,11 @@ interface Props { const ChatItem = (props: Props, ref: Ref) => { const { chat } = props + const { unreadChats } = useActivityCenter() + const muted = false - const unread = false + const unread = unreadChats.has(chat.id) + const count = unreadChats.get(chat.id)?.count ?? 0 const { color, displayName } = chat.identity! @@ -27,6 +31,7 @@ const ChatItem = (props: Props, ref: Ref) => { state={muted ? 'muted' : unread ? 'unread' : undefined} > #{displayName} + {count > 0 && {count}} ) } @@ -66,19 +71,19 @@ const Link = styled(NavLink, { unread: { color: '$accent-1', fontWeight: '$600', - '&::after': { - content: '"1"', - textAlign: 'center', - position: 'absolute', - right: 8, - width: 22, - height: 22, - background: '$primary-1', - borderRadius: '$full', - fontSize: 12, - color: '$accent-11', - }, }, }, }, }) + +const Badge = styled('div', { + textAlign: 'center', + position: 'absolute', + right: 8, + width: 22, + height: 22, + background: '$primary-1', + borderRadius: '$full', + fontSize: 12, + color: '$accent-11', +}) diff --git a/packages/status-react/src/protocol/index.tsx b/packages/status-react/src/protocol/index.tsx index 54f76465..666c0f45 100644 --- a/packages/status-react/src/protocol/index.tsx +++ b/packages/status-react/src/protocol/index.tsx @@ -1,6 +1,7 @@ export { ProtocolProvider, useProtocol } from './provider' export type { Account } from './use-account' export { useAccount } from './use-account' +export { useActivityCenter } from './use-activity-center' export type { Chat } from './use-chat' export { useChat } from './use-chat' export type { Member } from './use-members' diff --git a/packages/status-react/src/protocol/use-activity-center.tsx b/packages/status-react/src/protocol/use-activity-center.tsx new file mode 100644 index 00000000..d237db45 --- /dev/null +++ b/packages/status-react/src/protocol/use-activity-center.tsx @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react' + +import { useProtocol } from './provider' + +import type { ActivityCenterLatest } from '@status-im/js' + +export const useActivityCenter = () => { + const { client } = useProtocol() + + const [latest, setData] = useState(() => + client.activityCenter.getLatest() + ) + + useEffect(() => { + setData(client.activityCenter.getLatest()) + + const handleUpdate = (latest: ActivityCenterLatest) => { + setData(latest) + } + + return client.activityCenter.onChange(handleUpdate) + }, [client.activityCenter]) + + return { + unreadChats: latest.unreadChats, + } +}