UBERF-7638: Add scroll to latest message button (#6119)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-07-23 14:15:58 +04:00 committed by GitHub
parent c443cc60bd
commit 4962367182
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 135 additions and 38 deletions

View File

@ -40,6 +40,7 @@
export let inheritFont: boolean = false export let inheritFont: boolean = false
export let tooltip: LabelAndProps | undefined = undefined export let tooltip: LabelAndProps | undefined = undefined
export let element: HTMLButtonElement | undefined = undefined export let element: HTMLButtonElement | undefined = undefined
export let shape: 'rectangle' | 'round' = 'rectangle'
export let id: string | undefined = undefined export let id: string | undefined = undefined
let actualIconSize: IconSize = 'small' let actualIconSize: IconSize = 'small'
@ -89,7 +90,7 @@
<button <button
{id} {id}
bind:this={element} bind:this={element}
class="font-medium-14 {kind} {size} {type}" class="font-medium-14 {kind} {size} {type} {shape}"
class:loading class:loading
class:pressed class:pressed
class:inheritColor class:inheritColor
@ -155,6 +156,10 @@
height: var(--global-large-Size); height: var(--global-large-Size);
border-radius: var(--medium-BorderRadius); border-radius: var(--medium-BorderRadius);
&.round {
border-radius: var(--large-BorderRadius);
}
&.type-button:not(.iconOnly) { &.type-button:not(.iconOnly) {
padding: 0 var(--spacing-2); padding: 0 var(--spacing-2);
} }
@ -167,6 +172,9 @@
height: var(--global-medium-Size); height: var(--global-medium-Size);
border-radius: var(--medium-BorderRadius); border-radius: var(--medium-BorderRadius);
&.round {
border-radius: var(--large-BorderRadius);
}
&.type-button:not(.iconOnly) { &.type-button:not(.iconOnly) {
padding: 0 var(--spacing-2); padding: 0 var(--spacing-2);
} }
@ -180,6 +188,9 @@
gap: var(--spacing-0_5); gap: var(--spacing-0_5);
border-radius: var(--small-BorderRadius); border-radius: var(--small-BorderRadius);
&.round {
border-radius: var(--large-BorderRadius);
}
&.type-button:not(.iconOnly) { &.type-button:not(.iconOnly) {
padding: 0 var(--spacing-1); padding: 0 var(--spacing-1);
} }
@ -192,6 +203,9 @@
height: var(--global-extra-small-Size); height: var(--global-extra-small-Size);
border-radius: var(--extra-small-BorderRadius); border-radius: var(--extra-small-BorderRadius);
&.round {
border-radius: var(--large-BorderRadius);
}
&.type-button:not(.iconOnly) { &.type-button:not(.iconOnly) {
padding: 0 var(--spacing-1); padding: 0 var(--spacing-1);
} }

View File

@ -14,6 +14,7 @@
export let labelParams: Record<string, any> = {} export let labelParams: Record<string, any> = {}
export let kind: 'primary' | 'secondary' | 'tertiary' | 'negative' = 'secondary' export let kind: 'primary' | 'secondary' | 'tertiary' | 'negative' = 'secondary'
export let size: ButtonBaseSize = 'large' export let size: ButtonBaseSize = 'large'
export let shape: 'rectangle' | 'round' = 'rectangle'
export let icon: Asset | AnySvelteComponent | ComponentType | undefined = undefined export let icon: Asset | AnySvelteComponent | ComponentType | undefined = undefined
export let iconSize: IconSize | undefined = undefined export let iconSize: IconSize | undefined = undefined
export let disabled: boolean = false export let disabled: boolean = false
@ -29,6 +30,7 @@
<ButtonBase <ButtonBase
type={'type-button'} type={'type-button'}
{title} {title}
{shape}
{label} {label}
{labelParams} {labelParams}
{kind} {kind}

View File

@ -110,6 +110,7 @@
"JoinChannelHeader": "Click \"Join\" to get started.", "JoinChannelHeader": "Click \"Join\" to get started.",
"JoinChannelText": "Once you've joined, you'll be able to read all messages and contribute to the discussion.", "JoinChannelText": "Once you've joined, you'll be able to read all messages and contribute to the discussion.",
"NoMessagesInChannel": "Currently there are no messages", "NoMessagesInChannel": "Currently there are no messages",
"SendMessagesInChannel": "Send the first message to start the conversation" "SendMessagesInChannel": "Send the first message to start the conversation",
"LatestMessages": "↓ Latest messages"
} }
} }

View File

@ -110,6 +110,7 @@
"JoinChannelHeader": "Haga clic en \"Unirse\" para comenzar.", "JoinChannelHeader": "Haga clic en \"Unirse\" para comenzar.",
"JoinChannelText": "Una vez que se haya unido, podrá leer todos los mensajes y contribuir a la discusión.", "JoinChannelText": "Una vez que se haya unido, podrá leer todos los mensajes y contribuir a la discusión.",
"NoMessagesInChannel": "No hay mensajes en este canal todavía.", "NoMessagesInChannel": "No hay mensajes en este canal todavía.",
"SendMessagesInChannel": "Envíe mensajes en este canal para comenzar la conversación." "SendMessagesInChannel": "Envíe mensajes en este canal para comenzar la conversación.",
"LatestMessages": "↓ Últimos mensajes"
} }
} }

View File

@ -110,6 +110,7 @@
"JoinChannelHeader": "Cliquez sur \"Rejoindre\" pour commencer.", "JoinChannelHeader": "Cliquez sur \"Rejoindre\" pour commencer.",
"JoinChannelText": "Une fois que vous avez rejoint, vous pourrez lire tous les messages et participer à la discussion.", "JoinChannelText": "Une fois que vous avez rejoint, vous pourrez lire tous les messages et participer à la discussion.",
"NoMessagesInChannel": "Il n'y a pas encore de messages dans ce canal.", "NoMessagesInChannel": "Il n'y a pas encore de messages dans ce canal.",
"SendMessagesInChannel": "Envoyez des messages pour commencer la conversation." "SendMessagesInChannel": "Envoyez des messages pour commencer la conversation.",
"LatestMessages": "↓ Derniers messages"
} }
} }

View File

@ -110,6 +110,7 @@
"JoinChannelHeader": "Clique em \"Participar\" para começar.", "JoinChannelHeader": "Clique em \"Participar\" para começar.",
"JoinChannelText": "Depois de entrar, você poderá ler todas as mensagens e contribuir na discussão.", "JoinChannelText": "Depois de entrar, você poderá ler todas as mensagens e contribuir na discussão.",
"NoMessagesInChannel": "Ainda não existem mensagens neste canal.", "NoMessagesInChannel": "Ainda não existem mensagens neste canal.",
"SendMessagesInChannel": "Envie a sua primeira mensagem!" "SendMessagesInChannel": "Envie a sua primeira mensagem!",
"LatestMessages": "↓ Últimas mensagens"
} }
} }

View File

@ -110,6 +110,7 @@
"JoinChannelHeader": "Нажмите \"Присоединиться\", чтобы начать.", "JoinChannelHeader": "Нажмите \"Присоединиться\", чтобы начать.",
"JoinChannelText": "Присоединившись, вы сможете читать все сообщения и участвовать в обсуждении.", "JoinChannelText": "Присоединившись, вы сможете читать все сообщения и участвовать в обсуждении.",
"NoMessagesInChannel": "В этом канале пока нет сообщений", "NoMessagesInChannel": "В этом канале пока нет сообщений",
"SendMessagesInChannel": "Отправьте первое сообщение, чтобы начать общение" "SendMessagesInChannel": "Отправьте первое сообщение, чтобы начать общение",
"LatestMessages": "↓ Последние сообщения"
} }
} }

View File

@ -110,6 +110,7 @@
"JoinChannelHeader": "点击“加入”开始。", "JoinChannelHeader": "点击“加入”开始。",
"JoinChannelText": "加入后,你将能够阅读所有消息并参与讨论。", "JoinChannelText": "加入后,你将能够阅读所有消息并参与讨论。",
"NoMessagesInChannel": "此频道中没有消息。", "NoMessagesInChannel": "此频道中没有消息。",
"SendMessagesInChannel": "在此频道中发送消息。" "SendMessagesInChannel": "在此频道中发送消息。",
"LatestMessages": "↓ 最新消息"
} }
} }

View File

@ -29,10 +29,10 @@ import { derived, get, type Readable, writable } from 'svelte/store'
import { type ActivityMessage } from '@hcengineering/activity' import { type ActivityMessage } from '@hcengineering/activity'
import attachment from '@hcengineering/attachment' import attachment from '@hcengineering/attachment'
import { combineActivityMessages } from '@hcengineering/activity-resources' import { combineActivityMessages } from '@hcengineering/activity-resources'
import { type ChatMessage } from '@hcengineering/chunter'
import notification, { type DocNotifyContext } from '@hcengineering/notification'
import chunter from './plugin' import chunter from './plugin'
import { type ChatMessage } from '@hcengineering/chunter'
import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification'
export type LoadMode = 'forward' | 'backward' export type LoadMode = 'forward' | 'backward'
@ -175,7 +175,11 @@ export class ChannelDataProvider implements IChannelDataProvider {
) )
} }
private async loadInitialMessages (selectedMsg?: Ref<ActivityMessage>, loadAll = false): Promise<void> { private async loadInitialMessages (
selectedMsg?: Ref<ActivityMessage>,
loadAll = false,
ignoreNew = false
): Promise<void> {
const isLoading = get(this.isInitialLoadingStore) const isLoading = get(this.isInitialLoadingStore)
const isLoaded = get(this.isInitialLoadedStore) const isLoaded = get(this.isInitialLoadedStore)
@ -183,28 +187,15 @@ export class ChannelDataProvider implements IChannelDataProvider {
return return
} }
const client = getClient()
this.isInitialLoadingStore.set(true) this.isInitialLoadingStore.set(true)
const firstNotification =
this.context !== undefined
? await client.findOne(
notification.class.InboxNotification,
{
_class: {
$in: [notification.class.MentionInboxNotification, notification.class.ActivityInboxNotification]
},
docNotifyContext: this.context._id,
isViewed: false
},
{ sort: { createdOn: SortingOrder.Ascending } }
)
: undefined
const metadata = get(this.metadataStore) const metadata = get(this.metadataStore)
const firstNewMsgIndex = this.getFirstNewMsgIndex(firstNotification) const firstNewMsgIndex = ignoreNew ? undefined : await this.getFirstNewMsgIndex()
if (get(this.newTimestampStore) === undefined) { if (get(this.newTimestampStore) === undefined) {
this.newTimestampStore.set(firstNewMsgIndex !== undefined ? metadata[firstNewMsgIndex]?.createdOn : undefined) this.newTimestampStore.set(firstNewMsgIndex !== undefined ? metadata[firstNewMsgIndex]?.createdOn : undefined)
} else if (ignoreNew) {
this.newTimestampStore.set(undefined)
} }
const startPosition = this.getStartPosition(selectedMsg ?? this.selectedMsgId, firstNewMsgIndex) const startPosition = this.getStartPosition(selectedMsg ?? this.selectedMsgId, firstNewMsgIndex)
@ -351,7 +342,7 @@ export class ChannelDataProvider implements IChannelDataProvider {
return firsNewMsgIndex return firsNewMsgIndex
} }
private getFirstNewMsgIndex (firstNotification: InboxNotification | undefined): number | undefined { private async getFirstNewMsgIndex (): Promise<number | undefined> {
const metadata = get(this.metadataStore) const metadata = get(this.metadataStore)
if (metadata.length === 0) { if (metadata.length === 0) {
@ -363,6 +354,18 @@ export class ChannelDataProvider implements IChannelDataProvider {
} }
const lastViewedTimestamp = this.context.lastViewedTimestamp const lastViewedTimestamp = this.context.lastViewedTimestamp
const client = getClient()
const firstNotification = await client.findOne(
notification.class.InboxNotification,
{
_class: {
$in: [notification.class.MentionInboxNotification, notification.class.ActivityInboxNotification]
},
docNotifyContext: this.context._id,
isViewed: false
},
{ sort: { createdOn: SortingOrder.Ascending } }
)
if (lastViewedTimestamp === undefined && firstNotification === undefined) { if (lastViewedTimestamp === undefined && firstNotification === undefined) {
return -1 return -1
@ -436,7 +439,7 @@ export class ChannelDataProvider implements IChannelDataProvider {
await this.loadInitialMessages(msg._id) await this.loadInitialMessages(msg._id)
} }
public jumpToMessage (message: ActivityMessage): boolean { public jumpToMessage (message: MessageMetadata): boolean {
const metadata = get(this.metadataStore).find(({ _id }) => _id === message._id) const metadata = get(this.metadataStore).find(({ _id }) => _id === message._id)
if (metadata === undefined) { if (metadata === undefined) {
@ -455,7 +458,7 @@ export class ChannelDataProvider implements IChannelDataProvider {
return true return true
} }
public jumpToEnd (): boolean { public jumpToEnd (ignoreNew = false): boolean {
const last = get(this.metadataStore)[get(this.metadataStore).length - 1] const last = get(this.metadataStore)[get(this.metadataStore).length - 1]
if (last === undefined) { if (last === undefined) {
@ -468,8 +471,9 @@ export class ChannelDataProvider implements IChannelDataProvider {
return false return false
} }
this.selectedMsgId = undefined
this.clearMessages() this.clearMessages()
void this.loadInitialMessages() void this.loadInitialMessages(this.selectedMsgId, false, ignoreNew)
return true return true
} }

View File

@ -22,13 +22,14 @@
import { import {
ActivityExtension as ActivityExtensionComponent, ActivityExtension as ActivityExtensionComponent,
ActivityMessagePresenter, ActivityMessagePresenter,
canGroupMessages canGroupMessages,
messageInFocus
} from '@hcengineering/activity-resources' } from '@hcengineering/activity-resources'
import { Class, Doc, getDay, Ref, Timestamp } from '@hcengineering/core' import { Class, Doc, getDay, Ref, Timestamp } from '@hcengineering/core'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources' import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { getResource } from '@hcengineering/platform' import { getResource } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import { Loading, Scroller, ScrollParams } from '@hcengineering/ui' import { Loading, ModernButton, Scroller, ScrollParams } from '@hcengineering/ui'
import { afterUpdate, beforeUpdate, onDestroy, onMount, tick } from 'svelte' import { afterUpdate, beforeUpdate, onDestroy, onMount, tick } from 'svelte'
import { get } from 'svelte/store' import { get } from 'svelte/store'
@ -101,6 +102,9 @@
let selectedDate: Timestamp | undefined = undefined let selectedDate: Timestamp | undefined = undefined
let dateToJump: Timestamp | undefined = undefined let dateToJump: Timestamp | undefined = undefined
let prevScrollHeight = 0
let isScrollAtBottom = false
let messagesCount = 0 let messagesCount = 0
let wasAsideOpened = isAsideOpened let wasAsideOpened = isAsideOpened
@ -277,6 +281,7 @@
function handleScroll ({ autoScrolling }: ScrollParams): void { function handleScroll ({ autoScrolling }: ScrollParams): void {
saveScrollPosition() saveScrollPosition()
updateDownButtonVisibility($metadataStore, displayMessages, scrollElement)
if (autoScrolling) { if (autoScrolling) {
return return
} }
@ -450,6 +455,8 @@
isScrollInitialized = true isScrollInitialized = true
isInitialScrolling = false isInitialScrolling = false
} }
updateDownButtonVisibility($metadataStore, displayMessages, scrollElement)
} }
function reinitializeScroll (): void { function reinitializeScroll (): void {
@ -557,9 +564,6 @@
loadMore() loadMore()
} }
let prevScrollHeight = 0
let isScrollAtBottom = false
function saveScrollPosition (): void { function saveScrollPosition (): void {
if (!scrollElement) { if (!scrollElement) {
return return
@ -588,7 +592,7 @@
} }
}) })
async function compensateAside (isOpened: boolean) { async function compensateAside (isOpened: boolean): Promise<void> {
if (!isInitialScrolling && isScrollAtBottom && !wasAsideOpened && isOpened) { if (!isInitialScrolling && isScrollAtBottom && !wasAsideOpened && isOpened) {
await wait() await wait()
scrollToBottom() scrollToBottom()
@ -599,7 +603,7 @@
$: void compensateAside(isAsideOpened) $: void compensateAside(isAsideOpened)
function canGroupChatMessages (message: ActivityMessage, prevMessage?: ActivityMessage) { function canGroupChatMessages (message: ActivityMessage, prevMessage?: ActivityMessage): boolean {
let prevMetadata: MessageMetadata | undefined = undefined let prevMetadata: MessageMetadata | undefined = undefined
if (prevMessage === undefined) { if (prevMessage === undefined) {
@ -617,6 +621,53 @@
onDestroy(() => { onDestroy(() => {
unsubscribe() unsubscribe()
}) })
let showScrollDownButton = false
$: updateDownButtonVisibility($metadataStore, displayMessages, scrollElement)
function updateDownButtonVisibility (
metadata: MessageMetadata[],
displayMessages: DisplayActivityMessage[],
element?: HTMLDivElement
): void {
if (metadata.length === 0 || displayMessages.length === 0) {
showScrollDownButton = false
return
}
const lastMetadata = metadata[metadata.length - 1]
const lastMessage = displayMessages[displayMessages.length - 1]
if (lastMetadata._id !== lastMessage._id) {
showScrollDownButton = true
} else if (element != null) {
const { scrollHeight, scrollTop, offsetHeight } = element
showScrollDownButton = scrollHeight > offsetHeight + scrollTop + 300
} else {
showScrollDownButton = false
}
}
function handleScrollDown (): void {
selectedMessageId = undefined
messageInFocus.set(undefined)
const metadata = $metadataStore
const lastMetadata = metadata[metadata.length - 1]
const lastMessage = displayMessages[displayMessages.length - 1]
void inboxClient.readDoc(client, objectId)
if (lastMetadata._id !== lastMessage._id) {
separatorIndex = -1
provider.jumpToEnd(true)
reinitializeScroll()
} else {
scrollToBottom()
}
}
</script> </script>
{#if isLoading} {#if isLoading}
@ -689,6 +740,18 @@
<HistoryLoading isLoading={$isLoadingMoreStore} /> <HistoryLoading isLoading={$isLoadingMoreStore} />
{/if} {/if}
</Scroller> </Scroller>
{#if showScrollDownButton}
<div class="down-button absolute">
<ModernButton
label={chunter.string.LatestMessages}
shape="round"
size="small"
kind="primary"
on:click={handleScrollDown}
/>
</div>
{/if}
</div> </div>
{#if object} {#if object}
<div class="ref-input"> <div class="ref-input">
@ -728,4 +791,11 @@
left: 0; left: 0;
right: 0; right: 0;
} }
.down-button {
width: 100%;
display: flex;
justify-content: center;
bottom: -0.75rem;
}
</style> </style>

View File

@ -108,6 +108,7 @@ export default mergeIds(chunterId, chunter, {
ArchiveActivityConfirmationTitle: '' as IntlString, ArchiveActivityConfirmationTitle: '' as IntlString,
ArchiveActivityConfirmationMessage: '' as IntlString, ArchiveActivityConfirmationMessage: '' as IntlString,
JoinChannelHeader: '' as IntlString, JoinChannelHeader: '' as IntlString,
JoinChannelText: '' as IntlString JoinChannelText: '' as IntlString,
LatestMessages: '' as IntlString
} }
}) })