mirror of
https://github.com/hcengineering/platform.git
synced 2024-12-22 11:01:54 +03:00
Add who is typing (#6373)
This commit is contained in:
parent
56ac8eb4f1
commit
23b5e35be7
@ -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]
|
||||
|
||||
|
@ -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}
|
||||
|
||||
|
@ -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..."
|
||||
}
|
||||
}
|
@ -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..."
|
||||
}
|
||||
}
|
@ -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..."
|
||||
}
|
||||
}
|
@ -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..."
|
||||
}
|
||||
}
|
@ -119,6 +119,8 @@
|
||||
"CreatedChannelOn": "Создал этот канал {date}",
|
||||
"ChannelMessages": "Сообщения каналов",
|
||||
"JoinChannel": "Приссоединение к каналу",
|
||||
"YouJoinedChannel": "Вы присоединились к каналу"
|
||||
"YouJoinedChannel": "Вы присоединились к каналу",
|
||||
"AndMore": "и еще {count}",
|
||||
"IsTyping": "{count, plural, =1 {печатает} other {печатают}}..."
|
||||
}
|
||||
}
|
@ -119,6 +119,8 @@
|
||||
"CreatedChannelOn": "于 {date} 创建此频道",
|
||||
"ChannelMessages": "频道消息",
|
||||
"JoinChannel": "加入频道",
|
||||
"YouJoinedChannel": "你已加入频道"
|
||||
"YouJoinedChannel": "你已加入频道",
|
||||
"AndMore": "和 {count} 人",
|
||||
"IsTyping": "正在输入..."
|
||||
}
|
||||
}
|
||||
|
@ -98,8 +98,6 @@
|
||||
|
||||
{#if dataProvider}
|
||||
<ChannelScrollView
|
||||
objectId={object._id}
|
||||
objectClass={object._class}
|
||||
{object}
|
||||
skipLabels={!isDocChannel}
|
||||
selectedFilters={filters}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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>
|
@ -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}
|
||||
|
@ -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} />
|
||||
|
@ -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>,
|
||||
|
Loading…
Reference in New Issue
Block a user