fix: use pulse for presence (#9896)

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
This commit is contained in:
Alexander Onnikov
2025-09-19 15:56:14 +03:00
committed by GitHub
parent 9d147f065b
commit e15e398e0c
9 changed files with 233 additions and 108 deletions
@@ -102,14 +102,14 @@
async function deleteTypingInfo (): Promise<void> {
if (!withTypingInfo) return
clearTyping(me, object._id)
void clearTyping(me, object._id)
}
async function updateTypingInfo (): Promise<void> {
if (!withTypingInfo) return
throttle.call(() => {
setTyping(me, object._id)
void setTyping(me, object._id)
})
}
@@ -139,7 +139,7 @@
await editMessage(message, markdown, blobsToLoad, linksToLoad, appletsToLoad)
}
clearTyping(me, card._id)
void clearTyping(me, card._id)
}
async function attachApplets (messageId: MessageID, appletDrafts: AppletDraft[]): Promise<void> {
@@ -14,26 +14,66 @@
-->
<script lang="ts">
import { notEmpty, type Doc } from '@hcengineering/core'
import { formatName } from '@hcengineering/contact'
import { Avatar, getPersonByPersonRefStore } from '@hcengineering/contact-resources'
import { type Class, type Doc, type Ref, notEmpty } from '@hcengineering/core'
import { type Person, formatName, getCurrentEmployee } from '@hcengineering/contact'
import { Avatar, getPersonsByPersonRefs } from '@hcengineering/contact-resources'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { IconSize, tooltip, deviceOptionsStore as deviceInfo, checkAdaptiveMatching } from '@hcengineering/ui'
import { onDestroy } from 'svelte'
import PresenceList from './PresenceList.svelte'
import { presenceByObjectId, followee, toggleFollowee } from '../store'
import { PresenceInfo, subscribePresence } from '../presence'
import { followee, toggleFollowee } from '../store'
export let object: Doc
export let size: IconSize = 'small'
export let limit: number = 4
$: presence = $presenceByObjectId?.get(object._id) ?? []
$: personByRefStore = getPersonByPersonRefStore(presence.map((p) => p.person))
$: persons = presence
.map((it) => it.person)
.map((p) => $personByRefStore.get(p))
.filter(notEmpty)
const me = getCurrentEmployee()
let presenceInfo = new Map<string, Ref<Person>>()
let persons: Person[] = []
$: overLimit = persons.length > limit
$: adaptive = checkAdaptiveMatching($deviceInfo.size, 'md') || overLimit
let unsubscribe: (() => Promise<boolean>) | undefined
async function updatePresence (presenceInfo: Map<string, Ref<Person>>): Promise<void> {
const personByRef = await getPersonsByPersonRefs(Array.from(presenceInfo.values()))
persons = presenceInfo
.values()
.map((p) => personByRef.get(p))
.filter(notEmpty)
.toArray()
}
function handlePresenceInfo (key: string, value: PresenceInfo | undefined): void {
if (value === undefined) {
presenceInfo.delete(key)
presenceInfo = presenceInfo
return
}
if (presenceInfo.has(key) || value.personId === me) {
return
}
presenceInfo.set(key, value.personId)
presenceInfo = presenceInfo
}
async function updatePresenceSub (objectClass: Ref<Class<Doc>>, objectId: Ref<Doc>): Promise<void> {
await unsubscribe?.()
presenceInfo = new Map<string, Ref<Person>>()
unsubscribe = await subscribePresence(objectClass, objectId, handlePresenceInfo)
}
$: void updatePresenceSub(object._class, object._id)
$: void updatePresence(presenceInfo)
onDestroy(() => {
void unsubscribe?.()
})
</script>
{#if persons.length > 0}
@@ -14,20 +14,43 @@
-->
<script lang="ts">
import { Doc } from '@hcengineering/core'
import { onDestroy, onMount } from 'svelte'
import { type Doc } from '@hcengineering/core'
import { getCurrentEmployee } from '@hcengineering/contact'
import { onMount } from 'svelte'
import { updateMyPresence, removeMyPresence } from '../store'
import { updatePresence, deletePresence } from '../presence'
export let object: Doc
export let presenceTtlSeconds: number = 5
const personId = getCurrentEmployee()
async function doUpdatePresence (): Promise<void> {
const presence = { personId, objectId: object._id, objectClass: object._class }
await updatePresence(presence, presenceTtlSeconds)
}
async function doDeletePresence (object: Doc): Promise<void> {
const presence = { personId, objectId: object._id, objectClass: object._class }
await deletePresence(presence)
}
onMount(() => {
updateMyPresence({ objectId: object._id, objectClass: object._class }, {})
void doUpdatePresence()
const interval = setInterval(doUpdatePresence, presenceTtlSeconds * 1000)
return () => {
clearInterval(interval)
void doDeletePresence(object)
}
})
onDestroy(() => {
removeMyPresence({ objectId: object._id, objectClass: object._class })
})
let previousObject: Doc = object
$: if (object !== undefined && (object._id !== previousObject._id || object._class !== previousObject._class)) {
void doDeletePresence(previousObject)
previousObject = object
void doUpdatePresence()
}
</script>
<slot />
+2 -1
View File
@@ -21,9 +21,10 @@ import WorkbenchExtension from './components/WorkbenchExtension.svelte'
import { getFollowee, publishData, followeeDataSubscribe, followeeDataUnsubscribe } from './store'
export { Presence, PresenceAvatars }
export { updateMyPresence, removeMyPresence, presenceByObjectId } from './store'
export * from './pulse'
export * from './presence'
export * from './typing'
export * from './types'
export default async (): Promise<Resources> => ({
@@ -0,0 +1,69 @@
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License
import { type Person } from '@hcengineering/contact'
import { type UnsubscribeCallback, type Callback } from '@hcengineering/hulypulse-client'
import { type Class, type Doc, type Ref } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { createPulseClient } from './pulse'
export interface PresenceInfo {
personId: Ref<Person>
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
}
export async function subscribePresence (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
callback: Callback<PresenceInfo | undefined>
): Promise<UnsubscribeCallback> {
const client = await createPulseClient()
if (client !== undefined) {
const workspace = getMetadata(presentation.metadata.WorkspaceUuid) ?? ''
return await client.subscribe(`${workspace}/presence/${objectId}/`, callback)
}
return async () => false
}
export async function updatePresence (presence: PresenceInfo, presenceTtlSeconds: number): Promise<void> {
const client = await createPulseClient()
if (client !== undefined) {
const workspace = getMetadata(presentation.metadata.WorkspaceUuid) ?? ''
const { personId, objectId } = presence
try {
await client.put(`${workspace}/presence/${objectId}/${personId}`, presence, presenceTtlSeconds)
} catch (error) {
console.warn('failed to put presence info:', error)
}
}
}
export async function deletePresence (presence: PresenceInfo): Promise<void> {
const client = await createPulseClient()
if (client !== undefined) {
const workspace = getMetadata(presentation.metadata.WorkspaceUuid) ?? ''
const { personId, objectId } = presence
try {
await client.delete(`${workspace}/presence/${objectId}/${personId}`)
} catch (error) {
console.warn('failed to delete presence info:', error)
}
}
}
+1 -40
View File
@@ -11,20 +11,12 @@
// See the License for the specific language governing permissions and
// limitations under the License
import { type Person, getCurrentEmployee } from '@hcengineering/contact'
import { HulypulseClient, type UnsubscribeCallback, type Callback } from '@hcengineering/hulypulse-client'
import { HulypulseClient } from '@hcengineering/hulypulse-client'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { type Doc, type Ref } from '@hcengineering/core'
const typingDelaySeconds = 2
let pulseclient: HulypulseClient | undefined
function getWorkspace (): string {
return getMetadata(presentation.metadata.WorkspaceUuid) ?? ''
}
export async function createPulseClient (): Promise<HulypulseClient | undefined> {
if (pulseclient == null) {
const wsPulseUrl = getMetadata(presentation.metadata.PulseUrl)
@@ -35,37 +27,6 @@ export async function createPulseClient (): Promise<HulypulseClient | undefined>
return pulseclient
}
export interface TypingInfo {
personId: Ref<Person>
objectId: Ref<Doc>
}
export async function subscribeTyping (
objectId: Ref<Doc>,
callback: Callback<TypingInfo | undefined>
): Promise<UnsubscribeCallback> {
const workspace = getWorkspace()
return (await pulseclient?.subscribe(`${workspace}/typing/${objectId}/`, callback)) ?? (async () => false)
}
export async function setTyping (me: string, objectId: Ref<Doc>): Promise<void> {
const workspace = getWorkspace()
const personId = getCurrentEmployee()
const typingInfo: TypingInfo = { personId, objectId }
try {
await pulseclient?.put(`${workspace}/typing/${objectId}/${me}`, typingInfo, typingDelaySeconds)
} catch (error) {
console.warn('failed to put typing info:', error)
}
}
export function clearTyping (me: string, objectId: string): void {
const workspace = getWorkspace()
void pulseclient?.delete(`${workspace}/typing/${objectId}/${me}`).catch((error) => {
console.warn('failed to delete typing info:', error)
})
}
export function closePulseClient (): void {
pulseclient?.close()
pulseclient = undefined
+4 -47
View File
@@ -13,13 +13,12 @@
// limitations under the License.
//
import { type Doc, type Ref } from '@hcengineering/core'
import { getCurrentEmployee, type Person } from '@hcengineering/contact'
import { type PresenceData } from '@hcengineering/presence'
import { type Ref } from '@hcengineering/core'
import { type Person } from '@hcengineering/contact'
import { getPersonByPersonRef } from '@hcengineering/contact-resources'
import { type Readable, derived, writable, get } from 'svelte/store'
import { writable, get } from 'svelte/store'
import type { PersonRoomPresence, Room, RoomPresence, MyDataItem } from './types'
import type { RoomPresence, MyDataItem } from './types'
type PersonPresenceMap = Map<Ref<Person>, RoomPresence[]>
@@ -31,48 +30,6 @@ export const followee = writable<Ref<Person> | undefined>(undefined)
const personDataMap = new Map<Ref<Person>, Map<string, any>>()
const followeeDataHandlers = new Map<string, Set<(data: any) => Promise<void>>>()
export const presenceByObjectId = derived<Readable<PersonPresenceMap>, Map<Ref<Doc>, PersonRoomPresence[]>>(
otherPresence,
($presence) => {
const map = new Map<Ref<Doc>, PersonRoomPresence[]>()
for (const [person, presences] of $presence.entries()) {
if (person === getCurrentEmployee()) continue
presences.forEach((presence) => {
const values = map.get(presence.room.objectId) ?? []
values.push({ person, ...presence })
map.set(presence.room.objectId, values)
})
}
return map
}
)
export function updateMyPresence (room: Room, presence: PresenceData): void {
// lleo console.log('@@@ key: ', room, presence)
myPresence.update((rooms) => {
const value = { room, presence, lastUpdated: Date.now() }
const index = rooms.findIndex((it) => it.room.objectId === room.objectId)
if (index >= 0) {
rooms[index] = value
} else {
rooms.push(value)
}
return rooms
})
}
export function removeMyPresence (room: Room): void {
myPresence.update((old) => {
return old.filter((it) => it.room.objectId !== room.objectId)
})
}
export function onPersonUpdate (person: Ref<Person>, presence: RoomPresence[]): void {
otherPresence.update((map) => {
map.set(person, [...presence])
+74
View File
@@ -0,0 +1,74 @@
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License
import { type Employee, type Person } from '@hcengineering/contact'
import { type UnsubscribeCallback, type Callback } from '@hcengineering/hulypulse-client'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { type Doc, type Ref } from '@hcengineering/core'
import { createPulseClient } from './pulse'
const typingDelaySeconds = 2
function getWorkspace (): string {
return getMetadata(presentation.metadata.WorkspaceUuid) ?? ''
}
export interface TypingInfo {
personId: Ref<Person>
objectId: Ref<Doc>
}
export async function subscribeTyping (
objectId: Ref<Doc>,
callback: Callback<TypingInfo | undefined>
): Promise<UnsubscribeCallback> {
const client = await createPulseClient()
if (client !== undefined) {
const workspace = getWorkspace()
try {
return await client.subscribe(`${workspace}/typing/${objectId}/`, callback)
} catch (error) {
console.warn('failed to subscribe typing info:', error)
}
}
return async () => false
}
export async function setTyping (personId: Ref<Employee>, objectId: Ref<Doc>): Promise<void> {
const client = await createPulseClient()
if (client !== undefined) {
const workspace = getWorkspace()
const typingInfo: TypingInfo = { personId, objectId }
try {
await client.put(`${workspace}/typing/${objectId}/${personId}`, typingInfo, typingDelaySeconds)
} catch (error) {
console.warn('failed to put typing info:', error)
}
}
}
export async function clearTyping (me: string, objectId: string): Promise<void> {
const client = await createPulseClient()
if (client !== undefined) {
const workspace = getWorkspace()
try {
await client.delete(`${workspace}/typing/${objectId}/${me}`)
} catch (error) {
console.warn('failed to delete typing info:', error)
}
}
}