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",
|
"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",
|
||||||
|
|
|
@ -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'>>
|
#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 = (
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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',
|
||||||
|
})
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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