Indicate unread chats and mentions (#303)

* add activityCenter.ts

* use `process.env.VITEST`

* add `isMention` and `isReply`

* use activityCenter.ts

* fix initial `count`

* add `Badge` component

* add "Launch via Vite Node"

* remove comments

* remove `console.log`

* add comments

* type hook

* reverse order of notifications

* remove `activityCenter` from `provider.tsx`

* set `count`'s default value

* ref `ChatMessage` by id instead of object reference

* Revert "ref `ChatMessage` by id instead of object reference"

This reverts commit 1284386d22.

* use `isAuthor`
This commit is contained in:
Felicio Mununga 2022-09-16 15:49:20 +02:00 committed by GitHub
parent 62c499c50f
commit abd26c9c1d
No known key found for this signature in database
GPG Key ID: 0EB8D75C775AB6F1
9 changed files with 238 additions and 14 deletions

18
.vscode/launch.json vendored
View File

@ -1,6 +1,24 @@
{ {
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch via Vite Node",
"runtimeExecutable": "node",
"skipFiles": ["<node_internals>/**"],
// 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", "type": "node",
"request": "launch", "request": "launch",

View File

@ -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<string, { count: number }>
}
export class ActivityCenter {
#client: Client
#notifications: Set<Notification>
#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<string, { count: number }> = 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)
}
}
}

View File

@ -51,6 +51,7 @@ export class Chat {
#pinEvents: Map<string, Pick<ChatMessage, 'clock' | 'pinned'>> #pinEvents: Map<string, Pick<ChatMessage, 'clock' | 'pinned'>>
#reactEvents: Map<string, Pick<ChatMessage, 'clock' | 'reactions'>> #reactEvents: Map<string, Pick<ChatMessage, 'clock' | 'reactions'>>
#deleteEvents: Map<string, Pick<ChatMessage, 'clock' | 'signer'>> #deleteEvents: Map<string, Pick<ChatMessage, 'clock' | 'signer'>>
#isActive: boolean
#fetchingMessages?: boolean #fetchingMessages?: boolean
#previousFetchedStartTime?: Date #previousFetchedStartTime?: Date
#oldestFetchedMessage?: FetchedMessage #oldestFetchedMessage?: FetchedMessage
@ -81,6 +82,7 @@ export class Chat {
this.#pinEvents = new Map() this.#pinEvents = new Map()
this.#reactEvents = new Map() this.#reactEvents = new Map()
this.#deleteEvents = new Map() this.#deleteEvents = new Map()
this.#isActive = false
this.messageCallbacks = new Set() this.messageCallbacks = new Set()
} }
@ -158,9 +160,16 @@ export class Chat {
callback: (messages: ChatMessage[]) => void callback: (messages: ChatMessage[]) => void
): (() => void) => { ): (() => void) => {
this.messageCallbacks.add(callback) 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 () => { return () => {
this.messageCallbacks.delete(callback) this.messageCallbacks.delete(callback)
this.#isActive = false
} }
} }
@ -305,6 +314,18 @@ export class Chat {
// callback // callback
this.emitMessages() 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 = ( public handleEditedMessage = (

View File

@ -12,6 +12,7 @@ import {
import { ApplicationMetadataMessage } from '../protos/application-metadata-message' import { ApplicationMetadataMessage } from '../protos/application-metadata-message'
import { Account } from './account' import { Account } from './account'
import { ActivityCenter } from './activityCenter'
import { Community } from './community/community' import { Community } from './community/community'
import { handleWakuMessage } from './community/handle-waku-message' import { handleWakuMessage } from './community/handle-waku-message'
@ -37,6 +38,7 @@ class Client {
*/ */
#wakuDisconnectionTimer: ReturnType<typeof setInterval> #wakuDisconnectionTimer: ReturnType<typeof setInterval>
public activityCenter: ActivityCenter
public account?: Account public account?: Account
public community: Community public community: Community
@ -50,6 +52,9 @@ class Client {
this.wakuMessages = new Set() this.wakuMessages = new Set()
this.#wakuDisconnectionTimer = wakuDisconnectionTimer this.#wakuDisconnectionTimer = wakuDisconnectionTimer
// Activity Center
this.activityCenter = new ActivityCenter(this)
// Community // Community
this.community = new Community(this, options.publicKey) this.community = new Community(this, options.publicKey)
} }

View File

@ -1,4 +1,8 @@
export type { Account } from './client/account' export type { Account } from './client/account'
export type {
ActivityCenter,
ActivityCenterLatest,
} from './client/activityCenter'
export type { ChatMessage as Message } from './client/chat' export type { ChatMessage as Message } from './client/chat'
export type { Client, ClientOptions } from './client/client' export type { Client, ClientOptions } from './client/client'
export { createClient } from './client/client' export { createClient } from './client/client'

View File

@ -14,7 +14,7 @@ const external = [
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const alias: Alias[] = [] const alias: Alias[] = []
if (mode === 'test') { if (process.env.VITEST === 'true' || mode === 'test') {
alias.push({ alias.push({
/** /**
* Note: `happy-dom` nor `jsdom` have Crypto implemented (@see https://github.com/jsdom/jsdom/issues/1612) * Note: `happy-dom` nor `jsdom` have Crypto implemented (@see https://github.com/jsdom/jsdom/issues/1612)

View File

@ -2,6 +2,7 @@ import React, { forwardRef } from 'react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { useActivityCenter } from '../../../../protocol'
import { styled } from '../../../../styles/config' import { styled } from '../../../../styles/config'
import { Avatar } from '../../../../system' import { Avatar } from '../../../../system'
@ -15,8 +16,11 @@ interface Props {
const ChatItem = (props: Props, ref: Ref<HTMLAnchorElement>) => { const ChatItem = (props: Props, ref: Ref<HTMLAnchorElement>) => {
const { chat } = props const { chat } = props
const { unreadChats } = useActivityCenter()
const muted = false 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! const { color, displayName } = chat.identity!
@ -27,6 +31,7 @@ const ChatItem = (props: Props, ref: Ref<HTMLAnchorElement>) => {
state={muted ? 'muted' : unread ? 'unread' : undefined} state={muted ? 'muted' : unread ? 'unread' : undefined}
> >
<Avatar size={24} name={displayName} color={color} />#{displayName} <Avatar size={24} name={displayName} color={color} />#{displayName}
{count > 0 && <Badge>{count}</Badge>}
</Link> </Link>
) )
} }
@ -66,19 +71,19 @@ const Link = styled(NavLink, {
unread: { unread: {
color: '$accent-1', color: '$accent-1',
fontWeight: '$600', 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',
})

View File

@ -1,6 +1,7 @@
export { ProtocolProvider, useProtocol } from './provider' export { ProtocolProvider, useProtocol } from './provider'
export type { Account } from './use-account' export type { Account } from './use-account'
export { useAccount } from './use-account' export { useAccount } from './use-account'
export { useActivityCenter } from './use-activity-center'
export type { Chat } from './use-chat' export type { Chat } from './use-chat'
export { useChat } from './use-chat' export { useChat } from './use-chat'
export type { Member } from './use-members' export type { Member } from './use-members'

View File

@ -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<ActivityCenterLatest>(() =>
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,
}
}