Add who is typing (#6373)

This commit is contained in:
Kristina 2024-08-22 18:06:22 +04:00 committed by GitHub
parent 56ac8eb4f1
commit 23b5e35be7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 211 additions and 49 deletions

View File

@ -24,7 +24,8 @@ import {
type ObjectChatPanel,
type ThreadMessage,
type ChatInfo,
type ChannelInfo
type ChannelInfo,
type TypingInfo
} from '@hcengineering/chunter'
import presentation from '@hcengineering/model-presentation'
import contact, { type ChannelProvider as SocialChannelProvider, type Person } from '@hcengineering/contact'
@ -35,7 +36,8 @@ import {
DOMAIN_MODEL,
type Ref,
type Timestamp,
IndexKind
IndexKind,
DOMAIN_TRANSIENT
} from '@hcengineering/core'
import {
type Builder,
@ -56,7 +58,7 @@ import core, { TClass, TDoc, TSpace } from '@hcengineering/model-core'
import notification, { TDocNotifyContext } from '@hcengineering/model-notification'
import view from '@hcengineering/model-view'
import workbench from '@hcengineering/model-workbench'
import type { IntlString } from '@hcengineering/platform'
import { type IntlString } from '@hcengineering/platform'
import { TActivityMessage } from '@hcengineering/model-activity'
import { type DocNotifyContext } from '@hcengineering/notification'
@ -155,6 +157,14 @@ export class TChatInfo extends TDoc implements ChatInfo {
timestamp!: Timestamp
}
@Model(chunter.class.TypingInfo, core.class.Doc, DOMAIN_TRANSIENT)
export class TTypingInfo extends TDoc implements TypingInfo {
objectId!: Ref<Doc>
objectClass!: Ref<Class<Doc>>
person!: Ref<Person>
lastTyping!: Timestamp
}
export function createModel (builder: Builder): void {
builder.createModel(
TChunterSpace,
@ -165,7 +175,8 @@ export function createModel (builder: Builder): void {
TChatMessageViewlet,
TObjectChatPanel,
TChatInfo,
TChannelInfo
TChannelInfo,
TTypingInfo
)
const spaceClasses = [chunter.class.Channel, chunter.class.DirectMessage]

View File

@ -250,7 +250,11 @@
</div>
{#if isNewestFirst && showCommenInput}
<div class="ref-input newest-first">
<ActivityExtensionComponent kind="input" {extensions} props={{ object, boundary, focusIndex }} />
<ActivityExtensionComponent
kind="input"
{extensions}
props={{ object, boundary, focusIndex, withTypingInfo: true }}
/>
</div>
{/if}
<div
@ -288,7 +292,11 @@
</div>
{#if showCommenInput && !isNewestFirst}
<div class="ref-input oldest-first">
<ActivityExtensionComponent kind="input" {extensions} props={{ object, boundary, focusIndex }} />
<ActivityExtensionComponent
kind="input"
{extensions}
props={{ object, boundary, focusIndex, withTypingInfo: true }}
/>
</div>
{/if}

View File

@ -119,6 +119,8 @@
"CreatedChannelOn": "Created this channel on {date}",
"ChannelMessages": "Channel messages",
"JoinChannel": "Join channel",
"YouJoinedChannel": "You have been joined to channel"
"YouJoinedChannel": "You have been joined to channel",
"AndMore": "and {count} more",
"IsTyping": "{count, plural, =1 {is} other {are}} typing..."
}
}

View File

@ -119,6 +119,8 @@
"CreatedChannelOn": "Creó este canal el {date}",
"ChannelMessages": "Mensajes del canal",
"JoinChannel": "Unirse",
"YouJoinedChannel": "Te has unido al canal"
"YouJoinedChannel": "Te has unido al canal",
"AndMore": "y {count} más",
"IsTyping": "está escribiendo..."
}
}

View File

@ -119,6 +119,8 @@
"CreatedChannelOn": "A créé ce canal le {date}",
"ChannelMessages": "Messages du canal",
"JoinChannel": "Rejoindre",
"YouJoinedChannel": "Vous avez rejoint le canal"
"YouJoinedChannel": "Vous avez rejoint le canal",
"AndMore": "et {count} de plus",
"IsTyping": "est en train d'écrire..."
}
}

View File

@ -119,6 +119,8 @@
"CreatedChannelOn": "Criou este canal em {date}",
"ChannelMessages": "Mensagens do canal",
"JoinChannel": "Participar no canal",
"YouJoinedChannel": "Entrou no canal"
"YouJoinedChannel": "Entrou no canal",
"AndMore": "e mais {count}",
"IsTyping": "está a escrever..."
}
}

View File

@ -119,6 +119,8 @@
"CreatedChannelOn": "Создал этот канал {date}",
"ChannelMessages": "Сообщения каналов",
"JoinChannel": "Приссоединение к каналу",
"YouJoinedChannel": "Вы присоединились к каналу"
"YouJoinedChannel": "Вы присоединились к каналу",
"AndMore": "и еще {count}",
"IsTyping": "{count, plural, =1 {печатает} other {печатают}}..."
}
}

View File

@ -119,6 +119,8 @@
"CreatedChannelOn": "于 {date} 创建此频道",
"ChannelMessages": "频道消息",
"JoinChannel": "加入频道",
"YouJoinedChannel": "你已加入频道"
"YouJoinedChannel": "你已加入频道",
"AndMore": "和 {count} 人",
"IsTyping": "正在输入..."
}
}

View File

@ -98,8 +98,6 @@
{#if dataProvider}
<ChannelScrollView
objectId={object._id}
objectClass={object._class}
{object}
skipLabels={!isDocChannel}
selectedFilters={filters}

View File

@ -25,7 +25,7 @@
canGroupMessages,
messageInFocus
} from '@hcengineering/activity-resources'
import { Class, Doc, generateId, getDay, Ref, Timestamp } from '@hcengineering/core'
import { Doc, generateId, getDay, Ref, Timestamp } from '@hcengineering/core'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { getResource } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
@ -49,9 +49,7 @@
import chunter from '../plugin'
export let provider: ChannelDataProvider
export let object: Doc | undefined
export let objectClass: Ref<Class<Doc>>
export let objectId: Ref<Doc>
export let object: Doc
export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
export let scrollElement: HTMLDivElement | undefined | null = undefined
export let startFromBottom = false
@ -115,9 +113,9 @@
$: messages = $messagesStore
$: isLoading = $isLoadingStore
$: extensions = client.getModel().findAllSync(activity.class.ActivityExtension, { ofClass: objectClass })
$: extensions = client.getModel().findAllSync(activity.class.ActivityExtension, { ofClass: doc._class })
$: notifyContext = $contextByDocStore.get(objectId)
$: notifyContext = $contextByDocStore.get(doc._id)
void client
.getModel()
@ -129,7 +127,7 @@
}
})
$: displayMessages = filterChatMessages(messages, filters, filterResources, objectClass, selectedFilters)
$: displayMessages = filterChatMessages(messages, filters, filterResources, doc._class, selectedFilters)
const unsubscribe = inboxClient.inboxNotificationsByContext.subscribe(() => {
if (notifyContext !== undefined) {
@ -674,7 +672,7 @@
}
const op = client.apply(generateId(), 'chunter.scrollDown')
await inboxClient.readDoc(op, objectId)
await inboxClient.readDoc(op, doc._id)
await op.commit()
}
@ -693,7 +691,7 @@
if (unViewed.length === 0) {
forceRead = true
const op = client.apply(generateId(), 'chunter.forceReadContext')
await inboxClient.readDoc(op, objectId)
await inboxClient.readDoc(op, object._id)
await op.commit()
}
}
@ -790,7 +788,7 @@
<ActivityExtensionComponent
kind="input"
{extensions}
props={{ object, boundary: scrollElement, collection, autofocus: true }}
props={{ object, boundary: scrollElement, collection, autofocus: true, withTypingInfo: true }}
/>
</div>
{/if}
@ -805,6 +803,7 @@
.ref-input {
flex-shrink: 0;
margin: 1.25rem 1rem 1rem;
margin-bottom: 0;
max-height: 18.75rem;
}

View File

@ -0,0 +1,83 @@
<!--
// Copyright © 2024 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 { onMount } from 'svelte'
import chunter, { TypingInfo } from '@hcengineering/chunter'
import { getName, Person } from '@hcengineering/contact'
import { personByIdStore } from '@hcengineering/contact-resources'
import { getCurrentAccount, IdMap } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Label } from '@hcengineering/ui'
export let typingInfo: TypingInfo[] = []
const typingDelay = 2000
const maxTypingPersons = 3
const me = getCurrentAccount()
const hierarchy = getClient().getHierarchy()
let typingPersonsLabel: string = ''
let typingPersonsCount = 0
let moreCount: number = 0
$: updateTypingPersons($personByIdStore, typingInfo)
function updateTypingPersons (personById: IdMap<Person>, typingInfo: TypingInfo[]) {
const now = Date.now()
const personIds = new Set(
typingInfo
.filter((info) => info.person !== me.person && now - info.lastTyping < typingDelay)
.map((info) => info.person)
)
const names = Array.from(personIds)
.map((personId) => personById.get(personId))
.filter((person): person is Person => person !== undefined)
.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)
}
onMount(() => {
const interval = setInterval(() => {
updateTypingPersons($personByIdStore, typingInfo)
}, typingDelay)
return () => {
clearInterval(interval)
}
})
</script>
<span class="root h-4 mt-1 mb-1 ml-0-5 overflow-label">
{#if typingPersonsLabel !== ''}
<span class="fs-bold">
{typingPersonsLabel}
</span>
{#if moreCount > 0}
<span class="ml-1"><Label label={chunter.string.AndMore} params={{ count: moreCount }} /></span>
{/if}
<span class="ml-1"><Label label={chunter.string.IsTyping} params={{ count: typingPersonsCount }} /></span>
{/if}
</span>
<style>
.root {
display: flex;
align-items: center;
font-size: 0.75rem;
}
</style>

View File

@ -16,14 +16,18 @@
import activity, { ActivityMessage } from '@hcengineering/activity'
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 { createQuery, DraftController, draftsStore, getClient, isSpace } from '@hcengineering/presentation'
import { EmptyMarkup } from '@hcengineering/text'
import chunter, { ChatMessage, ChunterEvents, ThreadMessage, TypingInfo } from '@hcengineering/chunter'
import { Class, Doc, generateId, Ref, type CommitResult, getCurrentAccount } 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 } from '@hcengineering/activity-resources'
import { PersonAccount } from '@hcengineering/contact'
import { getChannelSpace } from '../../utils'
import ChannelTypingInfo from '../ChannelTypingInfo.svelte'
export let object: Doc
export let chatMessage: ChatMessage | undefined = undefined
@ -33,6 +37,7 @@
export let loading = false
export let collection: string = 'comments'
export let autofocus = false
export let withTypingInfo = false
type MessageDraft = Pick<ChatMessage, '_id' | 'message' | 'attachments'>
@ -60,20 +65,26 @@
let inputContent = currentMessage.message
$: if (currentDraft != null) {
createdMessageQuery.query(
_class,
{ _id, space: getChannelSpace(object._class, object._id, object.space) },
(result: ChatMessage[]) => {
if (result.length > 0 && _id !== chatMessage?._id) {
// Ouch we have got comment with same id created already.
clear()
}
createdMessageQuery.query(_class, { _id, space: getSpace(object) }, (result: ChatMessage[]) => {
if (result.length > 0 && _id !== chatMessage?._id) {
// Ouch we have got comment with same id created already.
clear()
}
)
})
} else {
createdMessageQuery.unsubscribe()
}
const typingInfoQuery = createQuery()
let typingInfo: TypingInfo[] = []
$: if (withTypingInfo) {
typingInfoQuery.query(chunter.class.TypingInfo, { objectId: object._id, space: getSpace(object) }, (res) => {
typingInfo = res
})
} else {
typingInfoQuery.unsubscribe()
}
function clear (): void {
currentMessage = getDefault()
_id = currentMessage._id
@ -95,7 +106,40 @@
}
}
const me = getCurrentAccount() as PersonAccount
const throttle = new ThrottledCaller(500)
async function deleteTypingInfo (): Promise<void> {
if (!withTypingInfo) return
const myTypingInfo = typingInfo.find((info) => info.person === me.person)
if (myTypingInfo === undefined) return
await client.remove(myTypingInfo)
}
async function updateTypingInfo (): Promise<void> {
if (!withTypingInfo) return
const myTypingInfo = typingInfo.find((info) => info.person === me.person)
if (myTypingInfo === undefined) {
await client.createDoc(chunter.class.TypingInfo, getSpace(object), {
objectId: object._id,
objectClass: object._class,
person: me.person,
lastTyping: Date.now()
})
} else {
throttle.call(() => {
void client.update(myTypingInfo, {
lastTyping: Date.now()
})
})
}
}
function onUpdate (event: CustomEvent): void {
if (!isEmptyMarkup(event.detail.message)) {
void updateTypingInfo()
}
if (!shouldSaveDraft) {
return
}
@ -141,6 +185,7 @@
await handleEdit(event)
} else {
void handleCreate(event, _id)
void deleteTypingInfo()
}
// Remove draft from Local Storage
@ -173,7 +218,7 @@
} else {
await operations.addCollection<Doc, ChatMessage>(
_class,
isSpace(object) ? object._id : object.space,
getSpace(object),
object._id,
object._class,
collection,
@ -213,3 +258,7 @@
on:blur
bind:loading
/>
{#if withTypingInfo}
<ChannelTypingInfo {typingInfo} />
{/if}

View File

@ -118,15 +118,7 @@
<div class="hulyComponent-content hulyComponent-content__container noShrink">
{#if message && dataProvider !== undefined}
<ChannelScrollView
bind:selectedMessageId
embedded
skipLabels
object={message}
objectId={message._id}
objectClass={message._class}
provider={dataProvider}
>
<ChannelScrollView bind:selectedMessageId embedded skipLabels object={message} provider={dataProvider}>
<svelte:fragment slot="header">
<div class="mt-3">
<ThreadParentMessage {message} />

View File

@ -80,6 +80,13 @@ export interface ChatInfo extends Doc {
timestamp: Timestamp
}
export interface TypingInfo extends Doc {
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
person: Ref<Person>
lastTyping: Timestamp
}
export interface ChannelInfo extends DocNotifyContext {
hidden: boolean
}
@ -124,7 +131,8 @@ export default plugin(chunterId, {
DirectMessage: '' as Ref<Class<DirectMessage>>,
ChatMessage: '' as Ref<Class<ChatMessage>>,
ChatMessageViewlet: '' as Ref<Class<ChatMessageViewlet>>,
ChatInfo: '' as Ref<Class<ChatInfo>>
ChatInfo: '' as Ref<Class<ChatInfo>>,
TypingInfo: '' as Ref<Class<TypingInfo>>
},
mixin: {
ObjectChatPanel: '' as Ref<Mixin<ObjectChatPanel>>,
@ -179,7 +187,9 @@ export default plugin(chunterId, {
Added: '' as IntlString,
Removed: '' as IntlString,
CreatedChannelOn: '' as IntlString,
YouJoinedChannel: '' as IntlString
YouJoinedChannel: '' as IntlString,
AndMore: '' as IntlString,
IsTyping: '' as IntlString
},
ids: {
DMNotification: '' as Ref<NotificationType>,