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:
parent
62c499c50f
commit
abd26c9c1d
|
@ -1,6 +1,24 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"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",
|
||||
"request": "launch",
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -51,6 +51,7 @@ export class Chat {
|
|||
#pinEvents: Map<string, Pick<ChatMessage, 'clock' | 'pinned'>>
|
||||
#reactEvents: Map<string, Pick<ChatMessage, 'clock' | 'reactions'>>
|
||||
#deleteEvents: Map<string, Pick<ChatMessage, 'clock' | 'signer'>>
|
||||
#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 = (
|
||||
|
|
|
@ -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<typeof setInterval>
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<HTMLAnchorElement>) => {
|
||||
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<HTMLAnchorElement>) => {
|
|||
state={muted ? 'muted' : unread ? 'unread' : undefined}
|
||||
>
|
||||
<Avatar size={24} name={displayName} color={color} />#{displayName}
|
||||
{count > 0 && <Badge>{count}</Badge>}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
@ -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',
|
||||
})
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue