Chat and inbox ui updates (#9580)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2025-07-22 13:02:06 +07:00
committed by GitHub
parent c81ea1a4da
commit ae8348ebb4
13 changed files with 437 additions and 71 deletions
+23 -7
View File
@@ -175,9 +175,11 @@ const listConfig: (BuildModelKey | string)[] = [
{ key: '', displayProps: { grow: true } },
{
key: '',
presenter: view.component.RolePresenter,
presenter: card.component.CardTagsColored,
label: card.string.Tags,
props: { fullSize: true },
props: {
showType: false
},
displayProps: { key: 'tags', fixed: 'right' }
},
{
@@ -230,10 +232,10 @@ const favoritesViewletConfig: (BuildModelKey | string)[] = [
},
key: '$lookup.attachedTo',
label: card.string.Tags,
presenter: view.component.RolePresenter,
props: {
fullSize: true
}
showType: false
},
presenter: card.component.CardTagsColored
},
{
key: '$lookup.attachedTo',
@@ -285,7 +287,14 @@ export function createSystemType (
config: [
{ key: '', props: { shrink: true } },
'_class',
{ key: '', presenter: view.component.RolePresenter, label: card.string.Tags, props: { fullSize: true } },
{
key: '',
presenter: card.component.CardTagsColored,
label: card.string.Tags,
props: {
showType: false
}
},
{
key: '',
presenter: card.component.LabelsPresenter,
@@ -519,7 +528,14 @@ export function createModel (builder: Builder): void {
config: [
'',
'_class',
{ key: '', presenter: view.component.RolePresenter, label: card.string.Tags, props: { fullSize: true } },
{
key: '',
presenter: card.component.CardTagsColored,
label: card.string.Tags,
props: {
showType: false
}
},
'modifiedOn'
]
},
@@ -18,6 +18,8 @@
import CardTagColored from './CardTagColored.svelte'
export let value: Card
export let showTags: boolean = true
export let showType: boolean = true
const client = getClient()
const hierarchy = client.getHierarchy()
@@ -37,9 +39,13 @@
</script>
<div class="tags-container">
<CardTagColored labelIntl={type.label} color={type.background} />
{#if tags.length > 0}
<div class="divider" />
{#if showType}
<CardTagColored labelIntl={type.label} color={type.background} />
{/if}
{#if tags.length > 0 && showTags}
{#if showType}
<div class="divider" />
{/if}
{#each tags as tag}
<CardTagColored labelIntl={tag.label} color={tag.background} />
{/each}
@@ -15,7 +15,7 @@
<script lang="ts">
import card, { Card, CardSection, CardViewDefaults } from '@hcengineering/card'
import { Component, Loading, Scroller } from '@hcengineering/ui'
import { Component, languageStore, Loading, ModernButton, Scroller } from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation'
import { NotificationContext } from '@hcengineering/communication-types'
import { Ref } from '@hcengineering/core'
@@ -24,7 +24,7 @@
import { Heading } from '@hcengineering/text-editor'
import { CardSectionAction } from '../types'
import { getResource } from '@hcengineering/platform'
import { getResource, translate } from '@hcengineering/platform'
export let doc: Card
export let context: NotificationContext | undefined = undefined
@@ -58,11 +58,18 @@
let timer: any
let hideBar: boolean = false
let bottomOffset: number = 0
function onNavigationClick (heading: Heading): void {
selectedToc = heading
navigate()
}
function getBottomOffset (): number {
if (scrollDiv == null) return 0
return Math.max(0, Math.floor(scrollDiv.scrollHeight - scrollDiv.scrollTop - scrollDiv.clientHeight))
}
function navigate (): void {
if (selectedToc === undefined) return
const sectionId = selectedToc.group as Ref<CardSection>
@@ -188,6 +195,8 @@
if (selectedSectionId != null && selectedSectionId !== selectedToc?.group) {
selectedToc = toc.find((it) => it.group === selectedSectionId)
}
bottomOffset = getBottomOffset()
}
async function filterSections (s: CardSection[], doc: Card): Promise<void> {
@@ -210,6 +219,33 @@
$: void filterSections(_sections, doc)
$: updateToc(sections, subTocBySection)
$: showOverlay = !isScrollInitialized || Object.values(sectionOverlays).some((it) => it)
function handleLasTocScroll (): void {
selectedToc = toc[toc.length - 1]
const sectionId = selectedToc.group as Ref<CardSection>
const ref = sectionRef[sectionId]
if (ref?.scrollDown) {
ref.scrollDown(selectedToc.id)
}
}
function canScrollDown (): boolean {
const lastSection = sections[sections.length - 1]
if (lastSection === undefined) return false
const ref = sectionRef[lastSection._id]
if (ref === undefined) return false
return ref.canScrollDown?.() ?? false
}
async function getDownButtonTitle (item: Heading, lang: string): Promise<string> {
if (item.titleIntl) {
const label = await translate(item.titleIntl, {}, lang)
return '↓ ' + label
}
return '↓ ' + (item.title ?? '')
}
</script>
<div class="hulyComponent-content__container columns relative">
@@ -274,6 +310,14 @@
{/each}
</div>
</Scroller>
{#if toc.length > 0 && (bottomOffset > 400 || canScrollDown())}
{@const lastToc = toc[toc.length - 1]}
{#await getDownButtonTitle(lastToc, $languageStore) then title}
<div class="down-button">
<ModernButton {title} shape="round" size="small" kind="primary" on:click={handleLasTocScroll} />
</div>
{/await}
{/if}
</div>
</div>
@@ -316,4 +360,26 @@
align-items: flex-start;
align-self: stretch;
}
.down-button {
position: absolute;
width: 100%;
display: flex;
justify-content: center;
bottom: 0.5rem;
animation: 0.5s fadeIn;
animation-fill-mode: forwards;
visibility: hidden;
left: 0;
right: 0;
}
@keyframes fadeIn {
99% {
visibility: hidden;
}
100% {
visibility: visible;
}
}
</style>
@@ -54,9 +54,9 @@
},
key: '',
label: card.string.Tags,
presenter: view.component.RolePresenter,
presenter: card.component.CardTagsColored,
props: {
fullSize: true
showType: false
}
},
{
@@ -33,6 +33,8 @@
const dispatch = createEventDispatcher()
let list: MessagesList | undefined = undefined
let position: MessagesNavigationAnchors =
navigation === MessagesNavigationAnchors.LatestMessages
? MessagesNavigationAnchors.LatestMessages
@@ -49,6 +51,16 @@
position = id
}
export function scrollDown (): void {
shouldScrollToStart = false
position = MessagesNavigationAnchors.LatestMessages
list?.scrollDown()
}
export function canScrollDown (): boolean {
return list?.canScrollDown() ?? false
}
onMount(() => {
if (active && navigation === MessagesNavigationAnchors.LatestMessages) {
position = MessagesNavigationAnchors.LatestMessages
@@ -60,6 +72,7 @@
{#if scrollDiv != null && isContextLoaded && contentDiv != null}
<div class="section-messages">
<MessagesList
bind:this={list}
card={doc}
{readonly}
context={notificationContext}
@@ -52,8 +52,10 @@
const scrollToNewThreshold = 50
const initialLastView = context?.lastView
const initialLastUpdate = context?.lastUpdate
let initialLastView = context?.lastView
let initialLastUpdate = context?.lastUpdate
let shouldScrollToEnd = false
let separatorDiv: HTMLDivElement | null | undefined = undefined
@@ -76,9 +78,18 @@
let topOffset: number = 0
const limit = $deviceInfo.isMobile ? 25 : 50
const unread = initialLastView != null && initialLastUpdate != null && initialLastUpdate > initialLastView
let queryDef = getBaseQuery()
export function scrollDown (): void {
shouldScrollToEnd = true
position = MessagesNavigationAnchors.LatestMessages
reinit(position, true)
}
export function canScrollDown (): boolean {
return window !== undefined && window.hasNextPage()
}
$: if (
(context?.lastView?.getTime() ?? 0) >= (context?.lastUpdate?.getTime() ?? 0) &&
(notifications?.length ?? 0) > 0 &&
@@ -156,8 +167,8 @@
}
}
function reinit (position: MessagesNavigationAnchors): void {
if (prevPosition === position) {
function reinit (position: MessagesNavigationAnchors, force = false): void {
if (prevPosition === position && !force) {
return
}
prevPosition = position
@@ -191,7 +202,10 @@
limit
}
}
const order = unread ? SortingOrder.Ascending : SortingOrder.Descending
initialLastView = context?.lastView
initialLastUpdate = context?.lastUpdate
const unread = initialLastView != null && initialLastUpdate != null && initialLastUpdate > initialLastView
const order = unread && !shouldScrollToEnd ? SortingOrder.Ascending : SortingOrder.Descending
return {
card: card._id,
replies: true,
@@ -200,7 +214,7 @@
links: true,
order,
limit,
from: unread && initialLastView != null ? initialLastView : undefined
from: unread && !shouldScrollToEnd && initialLastView != null ? initialLastView : undefined
}
}
@@ -231,12 +245,12 @@
return bottomOffset <= 200
}
function loadMore (): void {
function loadMore (direction: 'up' | 'down'): void {
if (window === undefined || !isScrollInitialized) return
if (shouldLoadPrevPage() && window.hasPrevPage()) {
if (shouldLoadPrevPage() && window.hasPrevPage() && direction === 'up') {
void loadPrevPage()
} else if (shouldLoadNextPage() && window.hasNextPage()) {
} else if (shouldLoadNextPage() && window.hasNextPage() && direction === 'down') {
void loadNextPage()
}
}
@@ -312,13 +326,19 @@
}
}
let rafId: any | null = null
let lastScrollTop: number = 0
function handleScroll (): void {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
const top = scrollDiv.scrollTop
const direction = top > lastScrollTop ? 'down' : 'up'
lastScrollTop = top
bottomOffset = getBottomOffset()
topOffset = getTopOffset()
updateShouldScrollToNew()
loadMore()
loadMore(direction)
checkPositionOnScroll()
void readAll()
rafId = null
@@ -531,11 +551,15 @@
const separatorIndex =
initialLastView !== undefined
? messages.findIndex(({ created, creator }) => !me.socialIds.includes(creator) && created > initialLastView)
? messages.findIndex(
({ created, creator }) =>
initialLastView != null && !me.socialIds.includes(creator) && created > initialLastView
)
: -1
if (separatorIndex === -1) {
if (separatorIndex === -1 || shouldScrollToEnd) {
await tick() // Wait for the DOM to update
shouldScrollToEnd = false
scrollToBottom(true)
shouldScrollToNew = true
atBottom = true
@@ -551,7 +575,9 @@
if (separatorDiv != null) {
await tick() // Wait for the DOM to update
scrollToWithOffset(scrollDiv, separatorDiv, 80)
isScrollInitialized = true
setTimeout(() => {
isScrollInitialized = true
}, 10)
updateShouldScrollToNew()
bottomOffset = getBottomOffset()
topOffset = getTopOffset()
@@ -23,25 +23,20 @@
Location,
restoreLocation,
Component,
Label,
ModernButton,
IconDelete,
closePanel,
getCurrentLocation
} from '@hcengineering/ui'
import { onDestroy } from 'svelte'
import { getClient, getCommunicationClient } from '@hcengineering/presentation'
import { getClient } from '@hcengineering/presentation'
import { inboxId } from '@hcengineering/inbox'
import view from '@hcengineering/view'
import { NotificationContext } from '@hcengineering/communication-types'
import { SortingOrder } from '@hcengineering/core'
import InboxNavigation from './InboxNavigation.svelte'
import { getCardIdFromLocation, navigateToCard } from '../location'
import inbox from '../plugin'
import InboxHeader from './InboxHeader.svelte'
const client = getClient()
const communicationClient = getCommunicationClient()
let replacedPanelElement: HTMLElement
let card: Card | undefined = undefined
@@ -95,20 +90,6 @@
$: $deviceInfo.replacedPanel = replacedPanelElement
onDestroy(() => ($deviceInfo.replacedPanel = undefined))
let clearing = false
async function clearInbox (): Promise<void> {
if (clearing) return
try {
clearing = true
const contexts = await communicationClient.findNotificationContexts({ order: SortingOrder.Ascending })
await Promise.all(contexts.map((context) => communicationClient.removeNotificationContext(context.id)))
clearing = false
} catch (e) {
clearing = false
console.error(e)
}
}
</script>
<div class="hulyPanels-container inbox">
@@ -120,19 +101,7 @@
class:fly={$deviceInfo.navigator.float}
>
<div class="antiPanel-wrap__content hulyNavPanel-container">
<div class="hulyNavPanel-header withButton small inbox-header">
<span class="overflow-label"><Label label={inbox.string.Inbox} /></span>
<div class="flex-row-center flex-gap-2">
<ModernButton
label={inbox.string.ClearAll}
icon={IconDelete}
size="small"
iconSize="small"
on:click={clearInbox}
loading={clearing}
/>
</div>
</div>
<InboxHeader />
<div class="antiPanel-wrap__content hulyNavPanel-container">
<InboxNavigation {card} on:select={selectCard} />
</div>
@@ -175,8 +144,4 @@
position: relative;
}
}
.inbox-header {
border-bottom: 1px solid var(--theme-navpanel-border);
}
</style>
@@ -84,7 +84,7 @@
flex-direction: column;
position: relative;
cursor: pointer;
padding: 1rem 0.5rem 0.5rem;
padding: 0.5rem;
border-bottom: 1px solid var(--global-ui-BorderColor);
min-height: 5.625rem;
@@ -0,0 +1,61 @@
<!-- 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 { SortingOrder } from '@hcengineering/core'
import { IconDelete, IconSettings, Label, ModernButton, showPopup, eventToHTMLElement } from '@hcengineering/ui'
import { getCommunicationClient } from '@hcengineering/presentation'
import inbox from '../plugin'
import InboxViewSettings from './InboxViewSettings.svelte'
const communicationClient = getCommunicationClient()
let clearing = false
async function clearInbox (): Promise<void> {
if (clearing) return
try {
clearing = true
const contexts = await communicationClient.findNotificationContexts({ order: SortingOrder.Ascending })
await Promise.all(contexts.map((context) => communicationClient.removeNotificationContext(context.id)))
clearing = false
} catch (e) {
clearing = false
console.error(e)
}
}
function click (e: MouseEvent): void {
showPopup(InboxViewSettings, {}, eventToHTMLElement(e))
}
</script>
<div class="hulyNavPanel-header withButton small inbox-header">
<span class="overflow-label"><Label label={inbox.string.Inbox} /></span>
<div class="flex-row-center flex-gap-2">
<ModernButton
label={inbox.string.ClearAll}
icon={IconDelete}
size="small"
iconSize="small"
on:click={clearInbox}
loading={clearing}
/>
<ModernButton icon={IconSettings} on:click={click} size="small" iconSize="small" />
</div>
</div>
<style lang="scss">
.inbox-header {
border-bottom: 1px solid var(--theme-navpanel-border);
}
</style>
@@ -0,0 +1,120 @@
<!--
// Copyright © 2023 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 { createEventDispatcher } from 'svelte'
import { createFocusManager, FocusHandler, Label, ListView, ModernToggle, resizeObserver } from '@hcengineering/ui'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { updateViewSetting, isViewSettingEnabled, viewSettingsStore, hideUserNamesSettingId } from '../settings'
const dispatch = createEventDispatcher()
let popupElement: HTMLDivElement | undefined = undefined
const items = [
{
id: hideUserNamesSettingId,
label: getEmbeddedLabel('Hide user names')
}
]
let selection = 0
let list: ListView
export function onKeydown (key: KeyboardEvent): boolean {
if (key.code === 'Tab') {
dispatch('close')
key.preventDefault()
key.stopPropagation()
return true
}
if (key.code === 'ArrowUp') {
key.stopPropagation()
key.preventDefault()
list.select(selection - 1)
return true
}
if (key.code === 'ArrowDown') {
key.stopPropagation()
key.preventDefault()
list.select(selection + 1)
return true
}
if (key.code === 'Enter') {
key.preventDefault()
key.stopPropagation()
const item = items[selection]
if (item != null) {
onToggle(item.id)
}
return true
}
return false
}
const manager = createFocusManager()
$: if (popupElement) {
popupElement.focus()
}
function onToggle (id: string): void {
updateViewSetting(id, !isViewSettingEnabled($viewSettingsStore, id))
}
</script>
<FocusHandler {manager} />
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="selectPopup"
bind:this={popupElement}
tabindex="0"
use:resizeObserver={() => {
dispatch('changeContent')
}}
on:keydown={onKeydown}
>
<div class="menu-space" />
<div class="scroll">
<div class="box">
<ListView bind:this={list} count={items.length} bind:selection on:changeContent={() => dispatch('changeContent')}>
<svelte:fragment slot="item" let:item={itemId}>
{@const item = items[itemId]}
<div class="menu-item withList w-full container">
<Label label={item.label} />
<ModernToggle
checked={isViewSettingEnabled($viewSettingsStore, item.id)}
size="small"
on:change={() => {
onToggle(item.id)
}}
/>
</div>
</svelte:fragment>
</ListView>
</div>
</div>
<div class="menu-space" />
</div>
<style lang="scss">
.container {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
@@ -44,7 +44,7 @@
{#if notification.message}
<div class="reaction-notification">
<PreviewTemplate socialId={content.creator} date={notification.created} color="secondary">
<PreviewTemplate socialId={content.creator} date={notification.created} color="secondary" showSeparator={false}>
<Label label={inbox.string.ReactedToYourMessage} />
</PreviewTemplate>
@@ -23,6 +23,7 @@
getPersonByPersonId
} from '@hcengineering/contact-resources'
import { SocialID } from '@hcengineering/communication-types'
import { isViewSettingEnabled, hideUserNamesSettingId, viewSettingsStore } from '../../settings'
export let tooltipLabel: IntlString | undefined = undefined
export let person: Person | undefined = undefined
@@ -32,10 +33,11 @@
export let kind: 'default' | 'column' = 'default'
export let padding: string | undefined = undefined
export let fixHeight: boolean = false
export let showSeparator: boolean = true
let clientWidth: number
$: showPersonName = clientWidth > 300
$: showPersonName = clientWidth > 300 && !isViewSettingEnabled($viewSettingsStore, hideUserNamesSettingId)
let _person: Person | undefined
$: void updatePerson(socialId, person)
@@ -95,9 +97,11 @@
{/if}
</span>
{#if showPersonName && _person}
<span class="message-preview-template__name overflow-label">{formatName(_person?.name ?? '')}</span>
<span class="message-preview-template__name overflow-label {color}">{formatName(_person?.name ?? '')}</span>
{/if}
{#if showSeparator}
<span class="message-preview-template__separator"></span>
{/if}
<span class="message-preview-template__separator"></span>
</span>
</PersonPreviewProvider>
@@ -188,6 +192,10 @@
&__name {
font-weight: 500;
flex: 0 0 auto;
&.secondary {
color: var(--global-secondary-TextColor);
}
}
&__time {
+85
View File
@@ -0,0 +1,85 @@
// 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 { get, writable } from 'svelte/store'
import { getCurrentAccount } from '@hcengineering/core'
import { location } from '@hcengineering/ui'
export interface ViewSetting {
id: string
on: boolean
}
export const hideUserNamesSettingId = 'view-settings.hide-user-names'
export const viewSettingsStore = writable<ViewSetting[]>([])
export function isViewSettingEnabled (settings: ViewSetting[], id: string): boolean {
const index = settings.findIndex((it) => it.id === id)
if (index === -1) return false
return settings[index].on
}
export function updateViewSetting (id: string, on: boolean): void {
const settings = get(viewSettingsStore)
const index = settings.findIndex((it) => it.id === id)
if (index === -1) {
viewSettingsStore.set([...settings, { id, on }])
} else {
viewSettingsStore.set(settings.map((it) => (it.id === id ? { ...it, on } : it)))
}
}
let prevWorkspace: string | undefined
location.subscribe((loc) => {
const workspace = loc.path[1]
if (prevWorkspace !== workspace) {
initViewSettings()
prevWorkspace = workspace
}
})
viewSettingsStore.subscribe((settings) => {
const key = geLocalStorageKey()
if (key === undefined) return
localStorage.setItem(key, JSON.stringify(settings))
})
function geLocalStorageKey (): string | undefined {
const me = getCurrentAccount()
const loc = get(location)
const workspace = loc.path[1] ?? ''
if (me == null || workspace === '') return undefined
return `${workspace}.${me.uuid}.inbox.view-settings.v1`
}
function initViewSettings (): void {
const key = geLocalStorageKey()
if (key === undefined) return
const stored = localStorage.getItem(key)
if (stored == null) {
viewSettingsStore.set([])
return
}
try {
const data = JSON.parse(stored)
Array.isArray(data) ? viewSettingsStore.set(data as ViewSetting[]) : viewSettingsStore.set([])
} catch (e) {
console.error(e)
viewSettingsStore.set([])
}
}