feat:allow to customize typing (#10111)

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
This commit is contained in:
Alexander Onnikov
2025-10-20 11:05:46 +07:00
committed by GitHub
parent 84cbd4e12a
commit e14b322a03
17 changed files with 177 additions and 97 deletions
@@ -14,37 +14,73 @@
-->
<script lang="ts">
import chunter from '@hcengineering/chunter'
import { getName, getCurrentEmployee, Person } from '@hcengineering/contact'
import { type Doc, type PersonId, getCurrentAccount } from '@hcengineering/core'
import { getName, getPersonRefsBySocialIds } from '@hcengineering/contact'
import { getPersonsByPersonRefs } from '@hcengineering/contact-resources'
import { IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { Label } from '@hcengineering/ui'
import { typing } from '@hcengineering/presence-resources'
import { Doc, type Ref } from '@hcengineering/core'
const maxTypingPersons = 3
const me = getCurrentEmployee()
const hierarchy = getClient().getHierarchy()
import { type TypingInfo, typing } from '@hcengineering/presence-resources'
export let object: Doc
let typingInfo = new Map<string, Ref<Person>>()
let typingPersonsLabel: string = ''
let typingPersonsCount = 0
let moreCount: number = 0
const maxTypingPersons = 3
const acc = getCurrentAccount()
const client = getClient()
const hierarchy = getClient().getHierarchy()
interface TypingGroup {
status: IntlString
names: string
count: number
moreCount: number
}
let typingInfo = new Map<string, TypingInfo>()
let typingGroups: TypingGroup[] = []
$: void updateTypingPersons(typingInfo)
async function updateTypingPersons (typingInfo: Map<string, Ref<Person>>): Promise<void> {
const persons = await getPersonsByPersonRefs(Array.from(typingInfo.values()))
const names = Array.from(persons.values())
.map((person) => getName(hierarchy, person))
.sort((name1, name2) => name1.localeCompare(name2))
typingPersonsCount = names.length
typingPersonsLabel = names.slice(0, maxTypingPersons).join(', ')
moreCount = Math.max(names.length - maxTypingPersons, 0)
async function updateTypingPersons (typingInfo: Map<string, TypingInfo>): Promise<void> {
if (typingInfo.size === 0) {
typingGroups = []
return
}
const groupedByStatus = new Map<IntlString, PersonId[]>()
for (const info of typingInfo.values()) {
const status = info.status ?? chunter.string.IsTyping
const existing = groupedByStatus.get(status) ?? []
existing.push(info.socialId)
groupedByStatus.set(status, existing)
}
const groups: TypingGroup[] = []
for (const [status, personIds] of groupedByStatus.entries()) {
const personRefs = await getPersonRefsBySocialIds(client, personIds)
const persons = await getPersonsByPersonRefs(Object.values(personRefs))
const names = Array.from(persons.values())
.map((person) => getName(hierarchy, person))
.sort((name1, name2) => name1.localeCompare(name2))
const displayNames = names.slice(0, maxTypingPersons).join(', ')
const moreCount = Math.max(names.length - maxTypingPersons, 0)
groups.push({
status,
names: displayNames,
count: names.length,
moreCount
})
}
groups.sort((a, b) => a.status.localeCompare(b.status))
typingGroups = groups
}
function handleTyping (typing: Map<string, Ref<Person>>): void {
function handleTyping (typing: Map<string, TypingInfo>): void {
typingInfo = typing
}
</script>
@@ -52,20 +88,18 @@
<span
class="root h-4 mt-1 mb-1 ml-0-5 overflow-label"
use:typing={{
personId: me,
socialId: acc.primarySocialId,
objectId: object._id,
onTyping: handleTyping
}}
>
{#if typingPersonsLabel !== ''}
<span class="fs-bold">
{typingPersonsLabel}
</span>
{#if moreCount > 0}
<span class="ml-1"><Label label={chunter.string.AndMore} params={{ count: moreCount }} /></span>
{#each typingGroups as group, index}
<span class="fs-bold" class:ml-1={index > 0}>{group.names}</span>
{#if group.moreCount > 0}
<span class="ml-1"><Label label={chunter.string.AndMore} params={{ count: group.moreCount }} /></span>
{/if}
<span class="ml-1"><Label label={chunter.string.IsTyping} params={{ count: typingPersonsCount }} /></span>
{/if}
<span class="ml-1"><Label label={group.status} params={{ count: group.count }} /></span>
{/each}
</span>
<style>
@@ -17,14 +17,13 @@
import { Analytics } from '@hcengineering/analytics'
import { AttachmentRefInput } from '@hcengineering/attachment-resources'
import chunter, { ChatMessage, ChunterEvents, ThreadMessage } from '@hcengineering/chunter'
import { Class, Doc, generateId, Ref, type CommitResult } from '@hcengineering/core'
import { Class, Doc, generateId, getCurrentAccount, Ref, type CommitResult } from '@hcengineering/core'
import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation'
import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text'
import { createEventDispatcher } from 'svelte'
import { getObjectId } from '@hcengineering/view-resources'
import { ThrottledCaller } from '@hcengineering/ui'
import { getSpace, editingMessageStore } from '@hcengineering/activity-resources'
import { getCurrentEmployee } from '@hcengineering/contact'
import { getChannelSpace } from '../../utils'
import ChannelTypingInfo from '../ChannelTypingInfo.svelte'
@@ -99,19 +98,19 @@
}
}
const me = getCurrentEmployee()
const acc = getCurrentAccount()
const throttle = new ThrottledCaller(500)
async function deleteTypingInfo (): Promise<void> {
if (!withTypingInfo) return
void clearTyping(me, object._id)
void clearTyping(acc.primarySocialId, object._id)
}
async function updateTypingInfo (): Promise<void> {
if (!withTypingInfo) return
throttle.call(() => {
void setTyping(me, object._id)
void setTyping(acc.primarySocialId, object._id)
})
}
@@ -36,6 +36,7 @@
"YesterdayAt": "Včera v {time}",
"AndMore": "a {count} dalších",
"IsTyping": "{count, plural, =1 {píše} other {píší}}",
"IsThinking": "{count, plural, =1 {přemýšlí} other {přemýšlejí}}",
"Loading": "Načítání...",
"MessageIn": "Zpráva #{title}",
"ThreadWasDeleted": "Tato vlákno byla smazána.",
@@ -36,6 +36,7 @@
"YesterdayAt": "Gestern um {time}",
"AndMore": "und {count} weitere",
"IsTyping": "{count, plural, =1 {schreibt} other {schreiben}}",
"IsThinking": "{count, plural, =1 {denkt nach} other {denken nach}}",
"Loading": "Laden...",
"MessageIn": "Nachricht #{title}",
"ThreadWasDeleted": "Dieser Thread wurde gelöscht.",
@@ -36,6 +36,7 @@
"YesterdayAt": "Yesterday at {time}",
"AndMore": "and {count} more",
"IsTyping": "{count, plural, =1 {is typing} other {are typing}}...",
"IsThinking": "{count, plural, =1 {is thinking} other {are thinking}}...",
"Loading": "Loading...",
"MessageIn": "Message #{title}",
"ThreadWasRemoved": "This thread was removed.",
@@ -36,6 +36,7 @@
"YesterdayAt": "Ayer a las {time}",
"AndMore": "y {count} más",
"IsTyping": "{count, plural, =1 {está escribiendo} other {están escribiendo}}",
"IsThinking": "{count, plural, =1 {está pensando} other {están pensando}}",
"Loading": "Cargando...",
"MessageIn": "Mensaje #{title}",
"ThreadWasDeleted": "Este hilo fue eliminado.",
@@ -36,6 +36,7 @@
"YesterdayAt": "Hier à {time}",
"AndMore": "et {count} autres",
"IsTyping": "{count, plural, =1 {est en train d'écrire} other {écrivent}}",
"IsThinking": "{count, plural, =1 {est en train de réfléchir} other {réfléchissent}}",
"Loading": "Chargement...",
"MessageIn": "Message #{title}",
"ThreadWasDeleted": "Ce fil de discussion a été supprimé.",
@@ -36,6 +36,7 @@
"YesterdayAt": "Ieri alle {time}",
"AndMore": "e {count} altri",
"IsTyping": "{count, plural, =1 {sta scrivendo} other {stanno scrivendo}}",
"IsThinking": "{count, plural, =1 {sta pensando} other {stanno pensando}}",
"Loading": "Caricamento...",
"MessageIn": "Messaggio #{title}",
"ThreadWasDeleted": "Questo thread è stato eliminato.",
@@ -36,6 +36,7 @@
"YesterdayAt": "昨日・{time}",
"AndMore": "および{count}件の他",
"IsTyping": "{count, plural, =1 {入力中} other {入力中}}",
"IsThinking": "{count, plural, =1 {考え中} other {考え中}}",
"Loading": "読み込み中...",
"MessageIn": "メッセージ #{title}",
"ThreadWasDeleted": "このスレッドは削除されました。",
@@ -36,6 +36,7 @@
"YesterdayAt": "Ontem às {time}",
"AndMore": "e mais {count}",
"IsTyping": "{count, plural, =1 {está digitando} other {estão digitando}}",
"IsThinking": "{count, plural, =1 {está pensando} other {estão pensando}}",
"Loading": "Carregando...",
"MessageIn": "Mensagem #{title}",
"ThreadWasDeleted": "Este thread foi excluído.",
@@ -36,6 +36,7 @@
"YesterdayAt": "Вчера в {time}",
"AndMore": "и еще {count}",
"IsTyping": "{count, plural, =1 {печатает} other {печатают}}...",
"IsThinking": "{count, plural, =1 {думает} other {думают}}...",
"Loading": "Загрузка...",
"MessageIn": "Сообщение #{title}",
"ThreadWasRemoved": "Этот поток был удален.",
@@ -36,6 +36,7 @@
"YesterdayAt": "Dün - {time}",
"AndMore": "ve {count} daha",
"IsTyping": "{count, plural, =1 {yazıyor} other {yazıyorlar}}...",
"IsThinking": "{count, plural, =1 {düşünüyor} other {düşünüyorlar}}...",
"Loading": "Yükleniyor...",
"MessageIn": "#{title} içinde mesaj",
"ThreadWasRemoved": "Bu başlık kaldırıldı.",
@@ -36,6 +36,7 @@
"YesterdayAt": "昨天 于 {time}",
"AndMore": "和其他 {count} 条",
"IsTyping": "{count, plural, =1 {正在输入} other {正在输入}}",
"IsThinking": "{count, plural, =1 {正在思考} other {正在思考}}",
"Loading": "加载中...",
"MessageIn": "消息 #{title}",
"ThreadWasDeleted": "此线程已被删除。",
@@ -1,50 +1,88 @@
<!-- 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. -->
<!--
// 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.
-->
<script lang="ts">
import { getName, getCurrentEmployee, Person } from '@hcengineering/contact'
import { type PersonId, getCurrentAccount } from '@hcengineering/core'
import { getName, getPersonRefsBySocialIds } from '@hcengineering/contact'
import { getPersonsByPersonRefs } from '@hcengineering/contact-resources'
import { Ref } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { Label } from '@hcengineering/ui'
import { typing } from '@hcengineering/presence-resources'
import { type TypingInfo, typing } from '@hcengineering/presence-resources'
import { CardID } from '@hcengineering/communication-types'
import communication from '../plugin'
export let cardId: CardID
const maxTypingPersons = 3
const me = getCurrentEmployee()
const acc = getCurrentAccount()
const client = getClient()
const hierarchy = getClient().getHierarchy()
let typingInfo = new Map<string, Ref<Person>>()
let typingPersonsLabel: string = ''
let typingPersonsCount = 0
let moreCount: number = 0
interface TypingGroup {
status: IntlString
names: string
count: number
moreCount: number
}
let typingInfo = new Map<string, TypingInfo>()
let typingGroups: TypingGroup[] = []
$: void updateTypingPersons(typingInfo)
async function updateTypingPersons (typingInfo: Map<string, Ref<Person>>): Promise<void> {
const persons = await getPersonsByPersonRefs(Array.from(typingInfo.values()))
const names = Array.from(persons.values())
.map((person) => getName(hierarchy, person))
.sort((name1, name2) => name1.localeCompare(name2))
async function updateTypingPersons (typingInfo: Map<string, TypingInfo>): Promise<void> {
if (typingInfo.size === 0) {
typingGroups = []
return
}
typingPersonsCount = names.length
typingPersonsLabel = names.slice(0, maxTypingPersons).join(', ')
moreCount = Math.max(names.length - maxTypingPersons, 0)
const groupedByStatus = new Map<IntlString, PersonId[]>()
for (const info of typingInfo.values()) {
const status = info.status ?? communication.string.IsTyping
const existing = groupedByStatus.get(status) ?? []
existing.push(info.socialId)
groupedByStatus.set(status, existing)
}
const groups: TypingGroup[] = []
for (const [status, personIds] of groupedByStatus.entries()) {
const personRefs = await getPersonRefsBySocialIds(client, personIds)
const persons = await getPersonsByPersonRefs(Object.values(personRefs))
const names = Array.from(persons.values())
.map((person) => getName(hierarchy, person))
.sort((name1, name2) => name1.localeCompare(name2))
const displayNames = names.slice(0, maxTypingPersons).join(', ')
const moreCount = Math.max(names.length - maxTypingPersons, 0)
groups.push({
status,
names: displayNames,
count: names.length,
moreCount
})
}
groups.sort((a, b) => a.status.localeCompare(b.status))
typingGroups = groups
}
function handleTyping (typing: Map<string, Ref<Person>>): void {
function handleTyping (typing: Map<string, TypingInfo>): void {
typingInfo = typing
}
</script>
@@ -52,20 +90,18 @@
<span
class="root h-4 mt-1 mb-1 ml-0-5 overflow-label"
use:typing={{
personId: me,
socialId: acc.primarySocialId,
objectId: cardId,
onTyping: handleTyping
}}
>
{#if typingPersonsLabel !== ''}
<span class="fs-bold">
{typingPersonsLabel}
</span>
{#if moreCount > 0}
<span class="ml-1"><Label label={communication.string.AndMore} params={{ count: moreCount }} /></span>
{#each typingGroups as group, index}
<span class="fs-bold" class:ml-1={index > 0}>{group.names}</span>
{#if group.moreCount > 0}
<span class="ml-1"><Label label={communication.string.AndMore} params={{ count: group.moreCount }} /></span>
{/if}
<span class="ml-1"><Label label={communication.string.IsTyping} params={{ count: typingPersonsCount }} /></span>
{/if}
<span class="ml-1"><Label label={group.status} params={{ count: group.count }} /></span>
{/each}
</span>
<style>
@@ -27,8 +27,7 @@
Message,
MessageID
} from '@hcengineering/communication-types'
import { getCurrentEmployee } from '@hcengineering/contact'
import { generateId, Markup, RateLimiter, Ref } from '@hcengineering/core'
import { generateId, getCurrentAccount, Markup, RateLimiter, Ref } from '@hcengineering/core'
import { getResource, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { clearTyping, setTyping } from '@hcengineering/presence-resources'
import {
@@ -69,7 +68,7 @@
const dispatch = createEventDispatcher()
const communicationClient = getCommunicationClient()
const client = getClient()
const me = getCurrentEmployee()
const acc = getCurrentAccount()
const maxLinkPreviewCount = 4
const previewUrls = new Map<string, boolean>()
@@ -140,7 +139,7 @@
await editMessage(message, markdown, blobsToLoad, linksToLoad, appletsToLoad)
}
void clearTyping(me, card._id)
void clearTyping(acc.primarySocialId, card._id)
}
async function attachApplets (messageId: MessageID, appletDrafts: AppletDraft[]): Promise<void> {
@@ -462,7 +461,7 @@
if (message !== undefined) return
if (!isEmptyMarkup(draft.content)) {
throttle.call(() => {
void setTyping(me, card._id)
void setTyping(acc.primarySocialId, card._id)
})
}
}
@@ -69,6 +69,7 @@ export default mergeIds(communicationId, communication, {
YesterdayAt: '' as IntlString,
AndMore: '' as IntlString,
IsTyping: '' as IntlString,
IsThinking: '' as IntlString,
Loading: '' as IntlString,
MessageIn: '' as IntlString,
ThreadWasRemoved: '' as IntlString,
+20 -20
View File
@@ -11,11 +11,10 @@
// 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 { type IntlString, getMetadata } from '@hcengineering/platform'
import presentation, { createPulseClient } from '@hcengineering/presentation'
import { type Doc, type Ref } from '@hcengineering/core'
import { type Doc, type Ref, type PersonId } from '@hcengineering/core'
const typingDelaySeconds = 2
@@ -24,36 +23,37 @@ function getWorkspace (): string {
}
export interface TypingInfo {
personId: Ref<Person>
socialId: PersonId
objectId: Ref<Doc>
status?: IntlString
}
export interface TypingActionParams {
personId: Ref<Employee>
socialId: PersonId
objectId: Ref<Doc>
onTyping: (presence: Map<string, Ref<Person>>) => void
onTyping: (presence: Map<string, TypingInfo>) => void
}
export function typing (node: HTMLElement, params: TypingActionParams): any {
let unsubscribe: Promise<UnsubscribeCallback> | undefined
let presence = new Map<string, Ref<Person>>()
let typing = new Map<string, TypingInfo>()
let personId = params.personId
let socialId = params.socialId
let objectId = params.objectId
let onTyping = params.onTyping
function handleTypingInfo (key: string, value: TypingInfo | undefined): void {
if (value?.personId === personId) {
if (value?.socialId === socialId) {
return
}
if (value === undefined) {
presence.delete(key)
typing.delete(key)
} else {
presence.set(key, value.personId)
typing.set(key, value)
}
onTyping(presence)
onTyping(typing)
}
unsubscribe = subscribeTyping(params.objectId, handleTypingInfo)
@@ -61,7 +61,7 @@ export function typing (node: HTMLElement, params: TypingActionParams): any {
return {
update: (params: TypingActionParams) => {
if (objectId !== params.objectId) {
personId = params.personId
socialId = params.socialId
objectId = params.objectId
onTyping = params.onTyping
@@ -69,10 +69,10 @@ export function typing (node: HTMLElement, params: TypingActionParams): any {
void unsub()
})
presence = new Map<string, Ref<Person>>()
typing = new Map<string, TypingInfo>()
unsubscribe = subscribeTyping(params.objectId, handleTypingInfo)
onTyping(presence)
onTyping(typing)
}
},
destroy: () => {
@@ -100,27 +100,27 @@ export async function subscribeTyping (
return async () => false
}
export async function setTyping (personId: Ref<Employee>, objectId: Ref<Doc>): Promise<void> {
export async function setTyping (socialId: PersonId, objectId: Ref<Doc>, status?: IntlString): Promise<void> {
const client = await createPulseClient()
if (client !== undefined) {
const workspace = getWorkspace()
const typingInfo: TypingInfo = { personId, objectId }
const typingInfo: TypingInfo = { socialId, objectId, status }
try {
await client.put(`${workspace}/typing/${objectId}/${personId}`, typingInfo, typingDelaySeconds)
await client.put(`${workspace}/typing/${objectId}/${socialId}`, typingInfo, typingDelaySeconds)
} catch (error) {
console.warn('failed to put typing info:', error)
}
}
}
export async function clearTyping (me: string, objectId: string): Promise<void> {
export async function clearTyping (socialId: PersonId, objectId: string): Promise<void> {
const client = await createPulseClient()
if (client !== undefined) {
const workspace = getWorkspace()
try {
await client.delete(`${workspace}/typing/${objectId}/${me}`)
await client.delete(`${workspace}/typing/${objectId}/${socialId}`)
} catch (error) {
console.warn('failed to delete typing info:', error)
}