UBERF-8547: Inbox cleanup and other (#7058)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-10-29 17:06:16 +04:00 committed by GitHub
parent 400772b792
commit f258c6755a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 397 additions and 1120 deletions

View File

@ -33,7 +33,8 @@ import {
type Space, type Space,
type Timestamp, type Timestamp,
type Tx, type Tx,
type TxCUD type TxCUD,
DOMAIN_TRANSIENT
} from '@hcengineering/core' } from '@hcengineering/core'
import { import {
ArrOf, ArrOf,
@ -75,7 +76,6 @@ import {
type NotificationProvider, type NotificationProvider,
type NotificationProviderDefaults, type NotificationProviderDefaults,
type NotificationProviderSetting, type NotificationProviderSetting,
type NotificationStatus,
type NotificationTemplate, type NotificationTemplate,
type NotificationType, type NotificationType,
type NotificationTypeSetting, type NotificationTypeSetting,
@ -92,7 +92,7 @@ export { notificationId, DOMAIN_USER_NOTIFY, DOMAIN_NOTIFICATION, DOMAIN_DOC_NOT
export { notificationOperation } from './migration' export { notificationOperation } from './migration'
export { notification as default } export { notification as default }
@Model(notification.class.BrowserNotification, core.class.Doc, DOMAIN_USER_NOTIFY) @Model(notification.class.BrowserNotification, core.class.Doc, DOMAIN_TRANSIENT)
export class TBrowserNotification extends TDoc implements BrowserNotification { export class TBrowserNotification extends TDoc implements BrowserNotification {
senderId?: Ref<Account> | undefined senderId?: Ref<Account> | undefined
tag!: Ref<Doc<Space>> tag!: Ref<Doc<Space>>
@ -100,7 +100,6 @@ export class TBrowserNotification extends TDoc implements BrowserNotification {
body!: string body!: string
onClickLocation?: Location | undefined onClickLocation?: Location | undefined
user!: Ref<Account> user!: Ref<Account>
status!: NotificationStatus
messageId?: Ref<ActivityMessage> messageId?: Ref<ActivityMessage>
messageClass?: Ref<Class<ActivityMessage>> messageClass?: Ref<Class<ActivityMessage>>
objectId!: Ref<Doc> objectId!: Ref<Doc>
@ -368,6 +367,10 @@ export function createModel (builder: Builder): void {
TNotificationProviderDefaults TNotificationProviderDefaults
) )
builder.mixin(notification.class.BrowserNotification, core.class.Class, core.mixin.TransientConfiguration, {
broadcastOnly: true
})
builder.createDoc( builder.createDoc(
setting.class.SettingsCategory, setting.class.SettingsCategory,
core.space.Model, core.space.Model,
@ -389,6 +392,7 @@ export function createModel (builder: Builder): void {
{ {
label: notification.string.Inbox, label: notification.string.Inbox,
icon: notification.icon.Notifications, icon: notification.icon.Notifications,
locationDataResolver: notification.function.LocationDataResolver,
alias: notificationId, alias: notificationId,
hidden: true, hidden: true,
locationResolver: notification.resolver.Location, locationResolver: notification.resolver.Location,

View File

@ -23,7 +23,6 @@ import {
} from '@hcengineering/model' } from '@hcengineering/model'
import notification, { import notification, {
notificationId, notificationId,
NotificationStatus,
type BrowserNotification, type BrowserNotification,
type DocNotifyContext, type DocNotifyContext,
type InboxNotification type InboxNotification
@ -401,7 +400,7 @@ export const notificationOperation: MigrateOperation = {
} }
}, },
{ {
state: 'remove-update-txes-docnotify-ctx', state: 'remove-update-txes-docnotify-ctx-v2',
func: async (client) => { func: async (client) => {
await client.deleteMany(DOMAIN_TX, { await client.deleteMany(DOMAIN_TX, {
_class: core.class.TxUpdateDoc, _class: core.class.TxUpdateDoc,
@ -410,14 +409,28 @@ export const notificationOperation: MigrateOperation = {
$exists: true $exists: true
} }
}) })
await client.deleteMany(DOMAIN_TX, {
_class: core.class.TxUpdateDoc,
objectClass: notification.class.DocNotifyContext,
'operations.lastUpdateTimestamp': {
$exists: true
}
})
}
},
{
state: 'remove-browser-notification-v2',
func: async (client) => {
await client.deleteMany<BrowserNotification>(DOMAIN_USER_NOTIFY, {
_class: notification.class.BrowserNotification
})
await client.deleteMany(DOMAIN_TX, {
objectClass: notification.class.BrowserNotification
})
} }
} }
]) ])
await client.deleteMany<BrowserNotification>(DOMAIN_USER_NOTIFY, {
_class: notification.class.BrowserNotification,
status: NotificationStatus.Notified
})
}, },
async upgrade (state: Map<string, Set<string>>, client: () => Promise<MigrationUpgradeClient>): Promise<void> {} async upgrade (state: Map<string, Set<string>>, client: () => Promise<MigrationUpgradeClient>): Promise<void> {}
} }

View File

@ -17,9 +17,9 @@
import { type Doc, type Ref } from '@hcengineering/core' import { type Doc, type Ref } from '@hcengineering/core'
import notification, { notificationId } from '@hcengineering/notification' import notification, { notificationId } from '@hcengineering/notification'
import { type IntlString, type Resource, mergeIds } from '@hcengineering/platform' import { type IntlString, type Resource, mergeIds } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui/src/types' import { type AnyComponent, type Location } from '@hcengineering/ui/src/types'
import { type Action, type ActionCategory, type ViewAction } from '@hcengineering/view' import { type Action, type ActionCategory, type ViewAction } from '@hcengineering/view'
import { type Application } from '@hcengineering/workbench' import { type Application, type LocationData } from '@hcengineering/workbench'
import { type DocUpdateMessageViewlet } from '@hcengineering/activity' import { type DocUpdateMessageViewlet } from '@hcengineering/activity'
export default mergeIds(notificationId, notification, { export default mergeIds(notificationId, notification, {
@ -53,7 +53,8 @@ export default mergeIds(notificationId, notification, {
HasDocNotifyContextPinAction: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, HasDocNotifyContextPinAction: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
HasDocNotifyContextUnpinAction: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, HasDocNotifyContextUnpinAction: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanReadNotifyContext: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, CanReadNotifyContext: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanUnReadNotifyContext: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>> CanUnReadNotifyContext: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
LocationDataResolver: '' as Resource<(loc: Location) => Promise<LocationData>>
}, },
category: { category: {
Notification: '' as Ref<ActionCategory> Notification: '' as Ref<ActionCategory>

View File

@ -20,6 +20,7 @@ import {
} from '@hcengineering/model' } from '@hcengineering/model'
import { DOMAIN_PREFERENCE } from '@hcengineering/preference' import { DOMAIN_PREFERENCE } from '@hcengineering/preference'
import workbench from '@hcengineering/workbench' import workbench from '@hcengineering/workbench'
import core, { DOMAIN_TX } from '@hcengineering/core'
import { workbenchId } from '.' import { workbenchId } from '.'
@ -33,6 +34,15 @@ export const workbenchOperation: MigrateOperation = {
{ {
state: 'remove-wrong-tabs-v1', state: 'remove-wrong-tabs-v1',
func: removeTabs func: removeTabs
},
{
state: 'remove-txes-update-tabs-v1',
func: async () => {
await client.deleteMany(DOMAIN_TX, {
objectClass: workbench.class.WorkbenchTab,
_class: core.class.TxUpdateDoc
})
}
} }
]) ])
}, },

View File

@ -475,7 +475,6 @@ describe('query', () => {
message: 'child' message: 'child'
} }
) )
let attempt = 0
const pp = new Promise((resolve) => { const pp = new Promise((resolve) => {
liveQuery.query<AttachedComment>( liveQuery.query<AttachedComment>(
test.class.TestComment, test.class.TestComment,
@ -483,14 +482,10 @@ describe('query', () => {
(result) => { (result) => {
const comment = result[0] const comment = result[0]
if (comment !== undefined) { if (comment !== undefined) {
if (attempt++ > 0) { expect((comment.$lookup?.attachedTo as WithLookup<AttachedComment>)?.$lookup?.space?._id).toEqual(
expect((comment.$lookup?.attachedTo as WithLookup<AttachedComment>)?.$lookup?.space?._id).toEqual( futureSpace._id
futureSpace._id )
) resolve(null)
resolve(null)
} else {
expect((comment.$lookup?.attachedTo as WithLookup<AttachedComment>)?.$lookup?.space).toBeUndefined()
}
} }
}, },
{ lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } } { lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } }
@ -521,7 +516,6 @@ describe('query', () => {
message: 'test' message: 'test'
} }
) )
let attempt = 0
const childLength = 3 const childLength = 3
const pp = new Promise((resolve) => { const pp = new Promise((resolve) => {
liveQuery.query<AttachedComment>( liveQuery.query<AttachedComment>(
@ -529,10 +523,13 @@ describe('query', () => {
{ _id: parentComment }, { _id: parentComment },
(result) => { (result) => {
const comment = result[0] const comment = result[0]
if (comment !== undefined) { const res = (comment.$lookup as any)?.comments?.length
expect((comment.$lookup as any)?.comments).toHaveLength(attempt++)
if (res !== undefined) {
expect(res).toBeGreaterThanOrEqual(1)
expect(res).toBeLessThanOrEqual(childLength)
} }
if (attempt === childLength) { if ((res ?? 0) === childLength) {
resolve(null) resolve(null)
} }
}, },
@ -628,23 +625,15 @@ describe('query', () => {
message: 'child' message: 'child'
} }
) )
let attempt = -1
const pp = new Promise((resolve) => { const pp = new Promise((resolve) => {
liveQuery.query<AttachedComment>( liveQuery.query<AttachedComment>(
test.class.TestComment, test.class.TestComment,
{ _id: childComment }, { _id: childComment },
(result) => { (result) => {
attempt++
const comment = result[0] const comment = result[0]
if (comment !== undefined) { if (comment !== undefined) {
if (attempt > 0) { expect((comment.$lookup?.attachedTo as WithLookup<AttachedComment>)?.$lookup?.space).toBeUndefined()
expect((comment.$lookup?.attachedTo as WithLookup<AttachedComment>)?.$lookup?.space).toBeUndefined() resolve(null)
resolve(null)
} else {
expect(
((comment.$lookup?.attachedTo as WithLookup<AttachedComment>)?.$lookup?.space as Doc)?._id
).toEqual(futureSpace)
}
} }
}, },
{ lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } } { lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } }

View File

@ -95,6 +95,8 @@ export class LiveQuery implements WithTx, Client {
private queryCounter: number = 0 private queryCounter: number = 0
private closed: boolean = false private closed: boolean = false
private readonly queriesToUpdate = new Map<number, [Query, Doc[]]>()
// A map of _class to documents. // A map of _class to documents.
private readonly documentRefs = new Map<string, Map<Ref<Doc>, DocumentRef>>() private readonly documentRefs = new Map<string, Map<Ref<Doc>, DocumentRef>>()
@ -773,7 +775,7 @@ export class LiveQuery implements WithTx, Client {
if (q.options?.sort !== undefined) { if (q.options?.sort !== undefined) {
await resultSort(q.result, q.options?.sort, q._class, this.getHierarchy(), this.client.getModel()) await resultSort(q.result, q.options?.sort, q._class, this.getHierarchy(), this.client.getModel())
} }
await this.callback(q) await this.callback(q, true)
} }
} }
@ -846,6 +848,7 @@ export class LiveQuery implements WithTx, Client {
} }
private async refresh (q: Query): Promise<void> { private async refresh (q: Query): Promise<void> {
this.queriesToUpdate.delete(q.id)
await q.refresh() await q.refresh()
} }
@ -1040,10 +1043,10 @@ export class LiveQuery implements WithTx, Client {
if (q.options?.limit !== undefined && q.result.length > q.options.limit) { if (q.options?.limit !== undefined && q.result.length > q.options.limit) {
if (q.result.pop()?._id !== doc._id || q.options?.total === true) { if (q.result.pop()?._id !== doc._id || q.options?.total === true) {
await this.callback(q) await this.callback(q, true)
} }
} else { } else {
await this.callback(q) await this.callback(q, true)
} }
} }
} }
@ -1051,7 +1054,7 @@ export class LiveQuery implements WithTx, Client {
await this.handleDocAddLookup(q, doc) await this.handleDocAddLookup(q, doc)
} }
private async callback (q: Query): Promise<void> { private async callback (q: Query, bulkUpdate = false): Promise<void> {
if (q.result instanceof Promise) { if (q.result instanceof Promise) {
q.result = await q.result q.result = await q.result
} }
@ -1059,9 +1062,15 @@ export class LiveQuery implements WithTx, Client {
this.updateDocuments(q, q.result) this.updateDocuments(q, q.result)
const result = q.result const result = q.result
Array.from(q.callbacks.values()).forEach((callback) => {
callback(toFindResult(this.clone(result), q.total)) if (bulkUpdate) {
}) this.queriesToUpdate.set(q.id, [q, result])
} else {
this.queriesToUpdate.delete(q.id)
Array.from(q.callbacks.values()).forEach((callback) => {
callback(toFindResult(this.clone(result), q.total))
})
}
} }
private updateDocuments (q: Query, docs: Doc[], clean: boolean = false): void { private updateDocuments (q: Query, docs: Doc[], clean: boolean = false): void {
@ -1106,7 +1115,7 @@ export class LiveQuery implements WithTx, Client {
if (q.options?.sort !== undefined) { if (q.options?.sort !== undefined) {
await resultSort(q.result, q.options?.sort, q._class, this.getHierarchy(), this.getModel()) await resultSort(q.result, q.options?.sort, q._class, this.getHierarchy(), this.getModel())
} }
await this.callback(q) await this.callback(q, true)
} }
} }
@ -1181,7 +1190,7 @@ export class LiveQuery implements WithTx, Client {
if (q.options?.total === true) { if (q.options?.total === true) {
q.total-- q.total--
} }
await this.callback(q) await this.callback(q, true)
} }
await this.handleDocRemoveLookup(q, tx) await this.handleDocRemoveLookup(q, tx)
} }
@ -1220,7 +1229,7 @@ export class LiveQuery implements WithTx, Client {
if (q.options?.sort !== undefined) { if (q.options?.sort !== undefined) {
await resultSort(q.result, q.options?.sort, q._class, this.getHierarchy(), this.getModel()) await resultSort(q.result, q.options?.sort, q._class, this.getHierarchy(), this.getModel())
} }
await this.callback(q) await this.callback(q, true)
} }
} }
@ -1294,6 +1303,17 @@ export class LiveQuery implements WithTx, Client {
} }
result.push(await this._tx(tx, docCache)) result.push(await this._tx(tx, docCache))
} }
if (this.queriesToUpdate.size > 0) {
const copy = new Map(this.queriesToUpdate)
this.queriesToUpdate.clear()
for (const [q, res] of copy.values()) {
Array.from(q.callbacks.values()).forEach((callback) => {
callback(toFindResult(this.clone(res), q.total))
})
}
}
return result return result
} }
@ -1533,10 +1553,10 @@ export class LiveQuery implements WithTx, Client {
return return
} }
if (q.result.pop()?._id !== updatedDoc._id) { if (q.result.pop()?._id !== updatedDoc._id) {
await this.callback(q) await this.callback(q, true)
} }
} else { } else {
await this.callback(q) await this.callback(q, true)
} }
} }
} }

View File

@ -59,11 +59,11 @@
margin: 0; margin: 0;
} }
&.default.highlighted:hover { &.default.highlighted:hover:not(.highlighted) {
background-color: var(--theme-popup-divider); background-color: var(--theme-popup-divider);
} }
&.lumia.highlighted:hover { &.lumia.highlighted:hover:not(.highlighted) {
background-color: var(--global-ui-highlight-BackgroundColor); background-color: var(--global-ui-highlight-BackgroundColor);
} }

View File

@ -5,6 +5,7 @@ import { type AnyComponent, type AnySvelteComponent } from '../../types'
export interface Notification { export interface Notification {
id: string id: string
title: string title: string
group?: string
component: AnyComponent | AnySvelteComponent component: AnyComponent | AnySvelteComponent
subTitle?: string subTitle?: string
subTitlePostfix?: string subTitlePostfix?: string

View File

@ -18,11 +18,19 @@ export const addNotification = (notification: Notification, store: Writable<Noti
id: generateId() id: generateId()
} }
update((notifications: Notification[]) => update((notifications: Notification[]) => {
[NotificationPosition.TopRight, NotificationPosition.TopLeft].includes(newNotification.position) if (
notification.group != null &&
notification.group !== '' &&
notifications.some(({ group }) => group === notification.group)
) {
return notifications.map((n) => (n.group === notification.group ? newNotification : n))
}
return [NotificationPosition.TopRight, NotificationPosition.TopLeft].includes(newNotification.position)
? [newNotification, ...notifications] ? [newNotification, ...notifications]
: [...notifications, newNotification] : [...notifications, newNotification]
) })
} }
export const removeNotification = (notificationId: string, { update }: Writable<Notification[]>): void => { export const removeNotification = (notificationId: string, { update }: Writable<Notification[]>): void => {

View File

@ -103,7 +103,8 @@ export function addNotification (
subTitle: string, subTitle: string,
component: AnyComponent | AnySvelteComponent, component: AnyComponent | AnySvelteComponent,
params?: Record<string, any>, params?: Record<string, any>,
severity: NotificationSeverity = NotificationSeverity.Success severity: NotificationSeverity = NotificationSeverity.Success,
group?: string
): void { ): void {
const closeTimeout = parseInt(localStorage.getItem('#platform.notification.timeout') ?? '10000') const closeTimeout = parseInt(localStorage.getItem('#platform.notification.timeout') ?? '10000')
const notification: Notification = { const notification: Notification = {
@ -111,6 +112,7 @@ export function addNotification (
title, title,
subTitle, subTitle,
severity, severity,
group,
position: NotificationPosition.BottomLeft, position: NotificationPosition.BottomLeft,
component, component,
closeTimeout, closeTimeout,

View File

@ -30,6 +30,7 @@
export let filters: Ref<ActivityMessagesFilter>[] = [] export let filters: Ref<ActivityMessagesFilter>[] = []
export let isAsideOpened = false export let isAsideOpened = false
export let syncLocation = true export let syncLocation = true
export let autofocus = true
export let freeze = false export let freeze = false
export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
@ -108,6 +109,7 @@
{collection} {collection}
provider={dataProvider} provider={dataProvider}
{freeze} {freeze}
{autofocus}
loadMoreAllowed={!isDocChannel} loadMoreAllowed={!isDocChannel}
/> />
{/if} {/if}

View File

@ -29,6 +29,7 @@
export let boundary: HTMLElement | undefined | null = undefined export let boundary: HTMLElement | undefined | null = undefined
export let collection: string | undefined export let collection: string | undefined
export let isThread = false export let isThread = false
export let autofocus = true
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
@ -68,7 +69,7 @@
<ActivityExtensionComponent <ActivityExtensionComponent
kind="input" kind="input"
{extensions} {extensions}
props={{ object, boundary, collection, autofocus: true, withTypingInfo: true }} props={{ object, boundary, collection, autofocus, withTypingInfo: true }}
/> />
</div> </div>
{:else} {:else}

View File

@ -22,6 +22,7 @@
export let _id: Ref<ChunterSpace> export let _id: Ref<ChunterSpace>
export let _class: Ref<Class<ChunterSpace>> export let _class: Ref<Class<ChunterSpace>>
export let autofocus = true
const objectQuery = createQuery() const objectQuery = createQuery()
const inboxClient = InboxNotificationsClientImpl.getClient() const inboxClient = InboxNotificationsClientImpl.getClient()
@ -37,5 +38,5 @@
</script> </script>
{#if object} {#if object}
<ChannelView {object} {context} on:close /> <ChannelView {object} {context} {autofocus} on:close />
{/if} {/if}

View File

@ -1,917 +0,0 @@
<!--
// 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 activity, { ActivityMessage, ActivityMessagesFilter, DisplayActivityMessage } from '@hcengineering/activity'
import {
ActivityMessagePresenter,
canGroupMessages,
messageInFocus,
sortActivityMessages
} from '@hcengineering/activity-resources'
import core, { Doc, getCurrentAccount, getDay, Ref, Space, Timestamp } from '@hcengineering/core'
import { DocNotifyContext } from '@hcengineering/notification'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { getResource } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { Loading, ModernButton, Scroller, ScrollParams } from '@hcengineering/ui'
import { afterUpdate, beforeUpdate, onDestroy, onMount, tick } from 'svelte'
import { get } from 'svelte/store'
import { ChannelDataProvider, MessageMetadata } from '../channelDataProvider'
import chunter from '../plugin'
import {
chatReadMessagesStore,
filterChatMessages,
getClosestDate,
readChannelMessages,
recheckNotifications
} from '../utils'
import BlankView from './BlankView.svelte'
import ActivityMessagesSeparator from './ChannelMessagesSeparator.svelte'
import JumpToDateSelector from './JumpToDateSelector.svelte'
import HistoryLoading from './LoadingHistory.svelte'
import ChannelInput from './ChannelInput.svelte'
import { messageInView } from '../scroll'
export let provider: ChannelDataProvider
export let object: Doc
export let channel: Doc
export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
export let scrollElement: HTMLDivElement | undefined | null = undefined
export let startFromBottom = false
export let selectedFilters: Ref<ActivityMessagesFilter>[] = []
export let embedded = false
export let collection: string | undefined = undefined
export let showEmbedded = false
export let skipLabels = false
export let loadMoreAllowed = true
export let isAsideOpened = false
export let fullHeight = true
export let fixedInput = true
export let freeze = false
const doc = object
const dateSelectorHeight = 30
const headerHeight = 52
const minMsgHeightRem = 2
const loadMoreThreshold = 40
const me = getCurrentAccount()
const client = getClient()
const hierarchy = client.getHierarchy()
const inboxClient = InboxNotificationsClientImpl.getClient()
const contextByDocStore = inboxClient.contextByDoc
const notificationsByContextStore = inboxClient.inboxNotificationsByContext
let filters: ActivityMessagesFilter[] = []
const filterResources = new Map<
Ref<ActivityMessagesFilter>,
(message: ActivityMessage, _class?: Ref<Doc>) => boolean
>()
const messagesStore = provider.messagesStore
const isLoadingStore = provider.isLoadingStore
const isLoadingMoreStore = provider.isLoadingMoreStore
const isTailLoadedStore = provider.isTailLoaded
const newTimestampStore = provider.newTimestampStore
const datesStore = provider.datesStore
const metadataStore = provider.metadataStore
let messages: ActivityMessage[] = []
let displayMessages: DisplayActivityMessage[] = []
let scroller: Scroller | undefined | null = undefined
let separatorElement: HTMLDivElement | undefined = undefined
let scrollContentBox: HTMLDivElement | undefined = undefined
let autoscroll = false
let shouldScrollToNew = false
let shouldWaitAndRead = false
let isScrollInitialized = false
let selectedDate: Timestamp | undefined = undefined
let dateToJump: Timestamp | undefined = undefined
let prevScrollHeight = 0
let isScrollAtBottom = false
let messagesCount = 0
let wasAsideOpened = isAsideOpened
$: messages = $messagesStore
$: isLoading = $isLoadingStore
$: notifyContext = $contextByDocStore.get(doc._id)
$: readonly = hierarchy.isDerived(channel._class, core.class.Space) ? (channel as Space).archived : false
void client
.getModel()
.findAll(activity.class.ActivityMessagesFilter, {})
.then(async (res) => {
filters = res
for (const filter of filters) {
filterResources.set(filter._id, await getResource(filter.filter))
}
})
let isPageHidden = false
let lastMsgBeforeFreeze: Ref<ActivityMessage> | undefined = undefined
function handleVisibilityChange (): void {
if (document.hidden) {
isPageHidden = true
lastMsgBeforeFreeze = shouldScrollToNew ? displayMessages[displayMessages.length - 1]?._id : undefined
} else {
if (isPageHidden) {
isPageHidden = false
void provider.updateNewTimestamp(notifyContext)
}
}
}
function isFreeze (): boolean {
return freeze || isPageHidden
}
$: displayMessages = filterChatMessages(messages, filters, filterResources, doc._class, selectedFilters)
const unsubscribe = inboxClient.inboxNotificationsByContext.subscribe(() => {
if (notifyContext !== undefined && !isFreeze()) {
recheckNotifications(notifyContext)
readViewportMessages()
}
})
function scrollToBottom (afterScrollFn?: () => void): void {
if (scroller != null && scrollElement != null && !isFreeze()) {
scroller.scrollBy(scrollElement.scrollHeight)
updateSelectedDate()
afterScrollFn?.()
}
}
function scrollToSeparator (): void {
if (separatorElement && scrollElement) {
const messagesElements = scrollContentBox?.getElementsByClassName('activityMessage')
const messagesHeight = displayMessages
.slice(separatorIndex)
.reduce((res, msg) => res + (messagesElements?.[msg._id as any]?.clientHeight ?? 0), 0)
separatorElement.scrollIntoView()
if (messagesHeight >= scrollElement.clientHeight) {
scroller?.scrollBy(-50)
}
updateShouldScrollToNew()
readViewportMessages()
}
}
function scrollToMessage () {
if (!selectedMessageId) {
return
}
if (!scrollElement || !scrollContentBox) {
setTimeout(scrollToMessage, 50)
}
const messagesElements = scrollContentBox?.getElementsByClassName('activityMessage')
const msgElement = messagesElements?.[selectedMessageId as any]
if (!msgElement) {
if (displayMessages.some(({ _id }) => _id === selectedMessageId)) {
setTimeout(scrollToMessage, 50)
}
return
}
msgElement.scrollIntoView()
readViewportMessages()
}
function isDateRendered (date: Timestamp): boolean {
const day = getDay(date)
return document.getElementById(day.toString()) != null
}
function jumpToDate (e: CustomEvent): void {
const date = e.detail.date
if (!date || !scrollElement) {
return
}
const closestDate = getClosestDate(date, get(provider.datesStore))
if (closestDate === undefined) {
return
}
if (isDateRendered(closestDate)) {
scrollToDate(closestDate)
} else {
void provider.jumpToDate(closestDate)
dateToJump = closestDate
}
}
function scrollToDate (date: Timestamp): void {
autoscroll = false
dateToJump = undefined
shouldWaitAndRead = false
const day = getDay(date)
const element = document.getElementById(day.toString())
let offset = element?.offsetTop
if (!offset || !scroller) {
return
}
offset = offset - headerHeight - dateSelectorHeight / 2
scroller?.scroll(offset)
}
function updateShouldScrollToNew (): void {
if (scrollElement) {
const { offsetHeight, scrollHeight, scrollTop } = scrollElement
const offset = 100
shouldScrollToNew = scrollHeight <= scrollTop + offsetHeight + offset
}
}
function shouldLoadMoreUp (): boolean {
if (!scrollElement) {
return false
}
return scrollElement.scrollTop <= loadMoreThreshold
}
function shouldLoadMoreDown (): boolean {
if (!scrollElement) {
return false
}
const { scrollHeight, scrollTop, clientHeight } = scrollElement
return scrollHeight - Math.ceil(scrollTop + clientHeight) <= loadMoreThreshold
}
let scrollToRestore = 0
let backwardRequested = false
function loadMore (): void {
if (!loadMoreAllowed || $isLoadingMoreStore || !scrollElement || isInitialScrolling) {
return
}
const minMsgHeightPx = minMsgHeightRem * parseFloat(getComputedStyle(document.documentElement).fontSize)
const maxMsgPerScreen = Math.ceil(scrollElement.clientHeight / minMsgHeightPx)
const limit = Math.max(maxMsgPerScreen, provider.limit)
if (!shouldLoadMoreUp()) {
backwardRequested = false
}
if (shouldLoadMoreUp() && !backwardRequested) {
shouldScrollToNew = false
scrollToRestore = scrollElement?.scrollHeight ?? 0
provider.addNextChunk('backward', messages[0]?.createdOn, limit)
backwardRequested = true
} else if (shouldLoadMoreDown() && !$isTailLoadedStore) {
scrollToRestore = 0
shouldScrollToNew = false
isScrollAtBottom = false
provider.addNextChunk('forward', messages[messages.length - 1]?.createdOn, limit)
}
}
function scrollToStartOfNew (): void {
if (!scrollElement || !lastMsgBeforeFreeze) {
return
}
const lastIndex = displayMessages.findIndex(({ _id }) => _id === lastMsgBeforeFreeze)
if (lastIndex === -1) return
const firstNewMessage = displayMessages.find(({ createdBy }, index) => index > lastIndex && createdBy !== me._id)
if (firstNewMessage === undefined) {
scrollToBottom()
return
}
const messagesElements = scrollContentBox?.getElementsByClassName('activityMessage')
const msgElement = messagesElements?.[firstNewMessage._id as any]
if (!msgElement) {
return
}
const messageRect = msgElement.getBoundingClientRect()
const topOffset = messageRect.top - 150
if (topOffset < 0) {
scroller?.scrollBy(topOffset)
} else if (topOffset > 0) {
scroller?.scrollBy(topOffset)
}
}
async function handleScroll ({ autoScrolling }: ScrollParams): Promise<void> {
saveScrollPosition()
updateDownButtonVisibility($metadataStore, displayMessages, scrollElement)
if (autoScrolling) {
return
}
shouldWaitAndRead = false
autoscroll = false
updateShouldScrollToNew()
loadMore()
updateSelectedDate()
readViewportMessages()
}
function isLastMessageViewed (): boolean {
if (!scrollElement) {
return false
}
const last = displayMessages[displayMessages.length - 1]
if (last === undefined) {
return false
}
const containerRect = scrollElement.getBoundingClientRect()
const messagesElements = scrollContentBox?.getElementsByClassName('activityMessage')
const msgElement = messagesElements?.[last._id as any]
if (!msgElement) {
return false
}
return messageInView(msgElement, containerRect)
}
const messagesToReadAccumulator: Set<DisplayActivityMessage> = new Set<DisplayActivityMessage>()
let messagesToReadAccumulatorTimer: any
function readViewportMessages (): void {
if (!scrollElement || !scrollContentBox || isFreeze()) {
return
}
const containerRect = scrollElement.getBoundingClientRect()
const messagesElements = scrollContentBox?.getElementsByClassName('activityMessage')
for (const message of displayMessages) {
const msgElement = messagesElements?.[message._id as any]
if (!msgElement) {
continue
}
if (messageInView(msgElement, containerRect)) {
messagesToReadAccumulator.add(message)
}
}
clearTimeout(messagesToReadAccumulatorTimer)
messagesToReadAccumulatorTimer = setTimeout(() => {
const messagesToRead = [...messagesToReadAccumulator]
messagesToReadAccumulator.clear()
void readChannelMessages(sortActivityMessages(messagesToRead), notifyContext)
}, 500)
}
function updateSelectedDate (): void {
if (embedded) {
return
}
if (!scrollContentBox || !scrollElement) {
return
}
const containerRect = scrollElement.getBoundingClientRect()
const messagesElements = scrollContentBox?.getElementsByClassName('activityMessage')
if (messagesElements === undefined) {
return
}
const reversedDates = [...get(datesStore)].reverse()
for (const message of displayMessages) {
const msgElement = messagesElements?.[message._id as any]
if (!msgElement) {
continue
}
const createdOn = message.createdOn
if (createdOn === undefined) {
continue
}
const messageRect = msgElement.getBoundingClientRect()
const isInView =
messageRect.top > 0 &&
messageRect.top < containerRect.bottom &&
messageRect.bottom - headerHeight - 2 * dateSelectorHeight > 0 &&
messageRect.bottom <= containerRect.bottom
if (isInView) {
selectedDate = reversedDates.find((date) => date <= createdOn)
break
}
}
if (selectedDate) {
const day = getDay(selectedDate)
const dateElement = document.getElementById(day.toString())
let isElementVisible = false
if (dateElement) {
const elementRect = dateElement.getBoundingClientRect()
isElementVisible =
elementRect.top + dateSelectorHeight / 2 >= containerRect.top && elementRect.bottom <= containerRect.bottom
}
if (isElementVisible) {
selectedDate = undefined
}
}
}
$: newTimestamp = $newTimestampStore
$: separatorIndex =
newTimestamp !== undefined
? displayMessages.findIndex((message) => (message.createdOn ?? 0) >= (newTimestamp ?? 0))
: -1
$: void initializeScroll(isLoading, separatorElement, separatorIndex)
let isInitialScrolling = true
async function initializeScroll (isLoading: boolean, separatorElement?: HTMLDivElement, separatorIndex?: number) {
if (isLoading || isScrollInitialized) {
return
}
updateSelectedDate()
if (selectedMessageId !== undefined && messages.some(({ _id }) => _id === selectedMessageId)) {
await wait()
scrollToMessage()
isScrollInitialized = true
isInitialScrolling = false
} else if (separatorIndex === -1) {
await wait()
isScrollInitialized = true
shouldWaitAndRead = true
autoscroll = true
shouldScrollToNew = true
isInitialScrolling = false
waitLastMessageRenderAndRead(() => {
autoscroll = false
})
} else if (separatorElement) {
await wait()
scrollToSeparator()
isScrollInitialized = true
isInitialScrolling = false
}
updateDownButtonVisibility($metadataStore, displayMessages, scrollElement)
}
function reinitializeScroll (): void {
isScrollInitialized = false
void initializeScroll(isLoading, separatorElement, separatorIndex)
}
function adjustScrollPosition (selectedMessageId: Ref<ActivityMessage> | undefined): void {
if (isLoading || !isScrollInitialized || isInitialScrolling) {
return
}
const msg = $metadataStore.find(({ _id }) => _id === selectedMessageId)
if (msg !== undefined) {
const isReload = provider.jumpToMessage(msg)
if (isReload) {
reinitializeScroll()
}
} else if (selectedMessageId === undefined) {
provider.jumpToEnd()
reinitializeScroll()
}
}
$: adjustScrollPosition(selectedMessageId)
function waitLastMessageRenderAndRead (onComplete?: () => void) {
if (isLastMessageViewed()) {
readViewportMessages()
shouldScrollToNew = true
shouldWaitAndRead = false
onComplete?.()
} else if (shouldWaitAndRead && messages.length > 0) {
shouldWaitAndRead = false
setTimeout(() => {
waitLastMessageRenderAndRead(onComplete)
}, 500)
} else {
onComplete?.()
}
}
function scrollToNewMessages (): void {
if (!scrollElement || !shouldScrollToNew) {
readViewportMessages()
return
}
scrollToBottom()
readViewportMessages()
}
async function wait (): Promise<void> {
// One tick is not enough for messages to be rendered,
// I think this is due to the fact that we are using a Component, which takes some time to load,
// because after one tick I see spinners from Component
await tick() // wait until the DOM is updated
await tick() // wait until the DOM is updated
}
async function restoreScroll () {
await wait()
if (!scrollElement || !scroller || scrollToRestore === 0) {
scrollToRestore = 0
return
}
const delta = scrollElement.scrollHeight - scrollToRestore
scroller.scrollBy(delta)
scrollToRestore = 0
dateToJump = 0
autoscroll = false
shouldWaitAndRead = false
}
async function handleMessagesUpdated (newCount: number): Promise<void> {
if (newCount === messagesCount) {
return
}
const prevCount = messagesCount
messagesCount = newCount
if (isFreeze()) {
await wait()
scrollToStartOfNew()
return
}
if (scrollToRestore > 0) {
void restoreScroll()
} else if (dateToJump !== undefined) {
await wait()
scrollToDate(dateToJump)
} else if (shouldScrollToNew && prevCount > 0 && newCount > prevCount) {
await wait()
scrollToNewMessages()
} else {
await wait()
readViewportMessages()
}
}
$: void handleMessagesUpdated(displayMessages.length)
function handleResize (): void {
if (isInitialScrolling || !isScrollInitialized) {
return
}
if (shouldScrollToNew) {
scrollToBottom()
}
loadMore()
}
let timer: any
function saveScrollPosition (): void {
if (!scrollElement) {
return
}
prevScrollHeight = scrollElement.scrollHeight
clearTimeout(timer)
setTimeout(() => {
if (!scrollElement) {
return
}
const { offsetHeight, scrollHeight, scrollTop } = scrollElement
isScrollAtBottom = scrollHeight <= Math.ceil(scrollTop + offsetHeight)
}, 15)
}
beforeUpdate(() => {
if (!scrollElement) return
if (isScrollInitialized && scrollElement.scrollHeight === scrollElement.clientHeight) {
isScrollAtBottom = true
}
})
afterUpdate(() => {
if (!scrollElement) return
const { offsetHeight, scrollHeight, scrollTop } = scrollElement
if (!isInitialScrolling && !isFreeze() && prevScrollHeight < scrollHeight && isScrollAtBottom) {
scrollToBottom()
} else if (isFreeze()) {
isScrollAtBottom = scrollHeight <= Math.ceil(scrollTop + offsetHeight)
}
})
async function compensateAside (isOpened: boolean): Promise<void> {
if (!isInitialScrolling && isScrollAtBottom && !wasAsideOpened && isOpened) {
await wait()
scrollToBottom()
}
wasAsideOpened = isOpened
}
$: void compensateAside(isAsideOpened)
function canGroupChatMessages (message: ActivityMessage, prevMessage?: ActivityMessage): boolean {
let prevMetadata: MessageMetadata | undefined = undefined
if (prevMessage === undefined) {
const metadata = $metadataStore
prevMetadata = metadata.find((_, index) => metadata[index + 1]?._id === message._id)
}
return canGroupMessages(message, prevMessage ?? prevMetadata)
}
onMount(() => {
chatReadMessagesStore.update(() => new Set())
document.addEventListener('visibilitychange', handleVisibilityChange)
})
onDestroy(() => {
unsubscribe()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
let showScrollDownButton = false
$: updateDownButtonVisibility($metadataStore, displayMessages, scrollElement)
function updateDownButtonVisibility (
metadata: MessageMetadata[],
displayMessages: DisplayActivityMessage[],
element?: HTMLDivElement | null
): void {
if (metadata.length === 0 || displayMessages.length === 0) {
showScrollDownButton = false
return
}
if (!$isTailLoadedStore) {
showScrollDownButton = true
} else if (element != null) {
const { scrollHeight, scrollTop, offsetHeight } = element
showScrollDownButton = scrollHeight > offsetHeight + scrollTop + 50
} else {
showScrollDownButton = false
}
}
async function handleScrollDown (): Promise<void> {
selectedMessageId = undefined
messageInFocus.set(undefined)
const metadata = $metadataStore
const lastMetadata = metadata[metadata.length - 1]
const lastMessage = displayMessages[displayMessages.length - 1]
if (lastMetadata._id !== lastMessage._id) {
separatorIndex = -1
provider.jumpToEnd(true)
reinitializeScroll()
} else {
scrollToBottom()
}
const op = client.apply(undefined, 'chunter.scrollDown')
await inboxClient.readDoc(op, doc._id)
await op.commit()
}
let forceRead = false
$: void forceReadContext(isScrollAtBottom, notifyContext)
async function forceReadContext (isScrollAtBottom: boolean, context?: DocNotifyContext): Promise<void> {
if (context === undefined || !isScrollAtBottom || forceRead || isFreeze()) return
const { lastUpdateTimestamp = 0, lastViewedTimestamp = 0 } = context
if (lastViewedTimestamp >= lastUpdateTimestamp) return
const notifications = $notificationsByContextStore.get(context._id) ?? []
const unViewed = notifications.filter(({ isViewed }) => !isViewed)
if (unViewed.length === 0) {
forceRead = true
const op = client.apply(undefined, 'chunter.forceReadContext', true)
await inboxClient.readDoc(op, object._id)
await op.commit()
}
}
const canLoadNextForwardStore = provider.canLoadNextForwardStore
$: if (!freeze && !isPageHidden && isScrollInitialized) {
readViewportMessages()
}
</script>
{#if isLoading}
<Loading />
{:else}
<div class="flex-col relative" class:h-full={fullHeight}>
{#if startFromBottom}
<div class="grower" />
{/if}
{#if !embedded && displayMessages.length > 0 && selectedDate}
<div class="selectedDate">
<JumpToDateSelector {selectedDate} fixed on:jumpToDate={jumpToDate} />
</div>
{/if}
{#if isInitialScrolling}
<div class="overlay">
<Loading />
</div>
{/if}
<Scroller
{autoscroll}
bottomStart={startFromBottom}
bind:this={scroller}
bind:divScroll={scrollElement}
bind:divBox={scrollContentBox}
noStretch={false}
disableOverscroll
horizontal={false}
onScroll={handleScroll}
onResize={handleResize}
>
{#if loadMoreAllowed && !embedded}
<HistoryLoading isLoading={$isLoadingMoreStore} />
{/if}
<slot name="header" />
{#if displayMessages.length === 0 && !embedded && !readonly}
<BlankView
icon={chunter.icon.Thread}
header={chunter.string.NoMessagesInChannel}
label={chunter.string.SendMessagesInChannel}
/>
{/if}
{#each displayMessages as message, index (message._id)}
{@const isSelected = message._id === selectedMessageId}
{@const canGroup = canGroupChatMessages(message, displayMessages[index - 1])}
{#if separatorIndex === index}
<ActivityMessagesSeparator bind:element={separatorElement} label={activity.string.New} />
{/if}
{#if !embedded && message.createdOn && $datesStore.includes(message.createdOn)}
<JumpToDateSelector selectedDate={message.createdOn} on:jumpToDate={jumpToDate} />
{/if}
<ActivityMessagePresenter
{doc}
value={message}
skipLabel={skipLabels}
{showEmbedded}
hoverStyles="filledHover"
isHighlighted={isSelected}
shouldScroll={isSelected}
withShowMore={false}
attachmentImageSize="x-large"
type={canGroup ? 'short' : 'default'}
hideLink
{readonly}
/>
{/each}
{#if !fixedInput}
<ChannelInput {object} {readonly} boundary={scrollElement} {collection} isThread={embedded} />
{/if}
{#if loadMoreAllowed && $canLoadNextForwardStore}
<HistoryLoading isLoading={$isLoadingMoreStore} />
{/if}
</Scroller>
{#if !embedded && showScrollDownButton}
<div class="down-button absolute" class:readonly>
<ModernButton
label={chunter.string.LatestMessages}
shape="round"
size="small"
kind="primary"
on:click={handleScrollDown}
/>
</div>
{/if}
</div>
{#if fixedInput}
<ChannelInput {object} {readonly} boundary={scrollElement} {collection} isThread={embedded} />
{/if}
{/if}
<style lang="scss">
.grower {
flex-grow: 10;
flex-shrink: 5;
}
.overlay {
width: 100%;
height: 100%;
position: absolute;
background: var(--theme-panel-color);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.selectedDate {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.down-button {
width: 100%;
display: flex;
justify-content: center;
bottom: -0.75rem;
animation: 0.5s fadeIn;
animation-fill-mode: forwards;
visibility: hidden;
&.readonly {
bottom: 0.25rem;
}
}
@keyframes fadeIn {
99% {
visibility: hidden;
}
100% {
visibility: visible;
}
}
</style>

View File

@ -97,6 +97,7 @@
{#if threadId && visible} {#if threadId && visible}
<div class="thread" style:height style:width> <div class="thread" style:height style:width>
<ThreadView <ThreadView
{...tab.data.props}
_id={threadId} _id={threadId}
{selectedMessageId} {selectedMessageId}
syncLocation={false} syncLocation={false}

View File

@ -41,6 +41,7 @@
export let object: Doc export let object: Doc
export let context: DocNotifyContext | undefined export let context: DocNotifyContext | undefined
export let autofocus = true
export let embedded: boolean = false export let embedded: boolean = false
const client = getClient() const client = getClient()
@ -143,6 +144,7 @@
{context} {context}
{object} {object}
{filters} {filters}
{autofocus}
isAsideOpened={(withAside && isAsideShown) || isThreadOpened} isAsideOpened={(withAside && isAsideShown) || isThreadOpened}
/> />
{/if} {/if}

View File

@ -52,6 +52,7 @@
export let fullHeight = true export let fullHeight = true
export let freeze = false export let freeze = false
export let loadMoreAllowed = true export let loadMoreAllowed = true
export let autofocus = true
const minMsgHeightRem = 2 const minMsgHeightRem = 2
const loadMoreThreshold = 200 const loadMoreThreshold = 200
@ -400,9 +401,7 @@
scrollToBottom() scrollToBottom()
} }
const op = client.apply(undefined, 'chunter.scrollDown') await inboxClient.readDoc(doc._id)
await inboxClient.readDoc(op, doc._id)
await op.commit()
} }
let forceRead = false let forceRead = false
@ -419,9 +418,7 @@
if (unViewed.length === 0) { if (unViewed.length === 0) {
forceRead = true forceRead = true
const op = client.apply(undefined, 'chunter.forceReadContext', true) await inboxClient.readDoc(object._id)
await inboxClient.readDoc(op, object._id)
await op.commit()
} }
} }
@ -642,7 +639,7 @@
<HistoryLoading isLoading={$isLoadingMoreStore} /> <HistoryLoading isLoading={$isLoadingMoreStore} />
{/if} {/if}
{#if !fixedInput} {#if !fixedInput}
<ChannelInput {object} {readonly} boundary={scrollDiv} {collection} {isThread} /> <ChannelInput {object} {readonly} boundary={scrollDiv} {collection} {isThread} {autofocus} />
{/if} {/if}
</BaseChatScroller> </BaseChatScroller>
{#if !isThread && isLatestMessageButtonVisible} {#if !isThread && isLatestMessageButtonVisible}
@ -659,7 +656,7 @@
</div> </div>
{#if fixedInput} {#if fixedInput}
<ChannelInput {object} {readonly} boundary={scrollDiv} {collection} {isThread} /> <ChannelInput {object} {readonly} boundary={scrollDiv} {collection} {isThread} {autofocus} />
{/if} {/if}
<style lang="scss"> <style lang="scss">

View File

@ -13,6 +13,7 @@
export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
export let message: ActivityMessage export let message: ActivityMessage
export let autofocus = true
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
@ -65,6 +66,7 @@
object={message} object={message}
{channel} {channel}
provider={dataProvider} provider={dataProvider}
{autofocus}
fullHeight={false} fullHeight={false}
fixedInput={false} fixedInput={false}
> >

View File

@ -31,6 +31,7 @@
export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
export let showHeader: boolean = true export let showHeader: boolean = true
export let syncLocation = true export let syncLocation = true
export let autofocus = true
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
@ -143,7 +144,7 @@
{#if message} {#if message}
{#key _id} {#key _id}
<ThreadContent bind:selectedMessageId {message} /> <ThreadContent bind:selectedMessageId {message} {autofocus} />
{/key} {/key}
{:else if isLoading} {:else if isLoading}
<Loading /> <Loading />

View File

@ -269,6 +269,8 @@ export async function openChannelInSidebar (
const tab: ChatWidgetTab = { const tab: ChatWidgetTab = {
id: `chunter_${_id}`, id: `chunter_${_id}`,
objectId: object._id,
objectClass: object._class,
name, name,
icon: getChannelClassIcon(object), icon: getChannelClassIcon(object),
iconComponent: isChannel ? undefined : iconMixin?.component, iconComponent: isChannel ? undefined : iconMixin?.component,
@ -304,6 +306,8 @@ export async function openThreadInSidebarChannel (
): Promise<void> { ): Promise<void> {
const newTab: ChatWidgetTab = { const newTab: ChatWidgetTab = {
...tab, ...tab,
objectId: message._id,
objectClass: message._class,
name: await translate(chunter.string.ThreadIn, { name: tab.data.channelName }), name: await translate(chunter.string.ThreadIn, { name: tab.data.channelName }),
data: { ...tab.data, thread: message._id } data: { ...tab.data, thread: message._id }
} }
@ -314,10 +318,12 @@ export async function closeThreadInSidebarChannel (widget: Widget, tab: ChatWidg
const thread = tab.allowedPath !== undefined ? tab.data.thread : undefined const thread = tab.allowedPath !== undefined ? tab.data.thread : undefined
const newTab: ChatWidgetTab = { const newTab: ChatWidgetTab = {
...tab, ...tab,
objectId: tab.data._id,
objectClass: tab.data._class,
id: tab.id.startsWith('thread_') ? generateId() : tab.id, id: tab.id.startsWith('thread_') ? generateId() : tab.id,
name: tab.data.channelName, name: tab.data.channelName,
allowedPath: undefined, allowedPath: undefined,
data: { ...tab.data, thread: undefined } data: { ...tab.data, thread: undefined, props: undefined }
} }
createWidgetTab(widget, newTab) createWidgetTab(widget, newTab)
@ -330,7 +336,8 @@ export async function openThreadInSidebar (
_id: Ref<ActivityMessage>, _id: Ref<ActivityMessage>,
msg?: ActivityMessage, msg?: ActivityMessage,
doc?: Doc, doc?: Doc,
selectedMessageId?: Ref<ActivityMessage> selectedMessageId?: Ref<ActivityMessage>,
props?: Record<string, any>
): Promise<void> { ): Promise<void> {
const client = getClient() const client = getClient()
@ -371,13 +378,16 @@ export async function openThreadInSidebar (
id: 'thread_' + _id, id: 'thread_' + _id,
name: tabName, name: tabName,
icon: chunter.icon.Thread, icon: chunter.icon.Thread,
objectId: message._id,
objectClass: message._class,
allowedPath, allowedPath,
data: { data: {
_id: object?._id, _id: object?._id,
_class: object?._class, _class: object?._class,
thread: message._id, thread: message._id,
selectedMessageId, selectedMessageId,
channelName: name channelName: name,
props
} }
} }
createWidgetTab(widget, tab, true) createWidgetTab(widget, tab, true)
@ -461,6 +471,8 @@ export async function locationDataResolver (loc: Location): Promise<LocationData
const name = (await getChannelName(_id, _class, object)) ?? (await translate(titleIntl, {})) const name = (await getChannelName(_id, _class, object)) ?? (await translate(titleIntl, {}))
return { return {
objectId: object._id,
objectClass: object._class,
name, name,
icon: chunter.icon.Chunter, icon: chunter.icon.Chunter,
iconComponent: isChunterSpace ? iconMixin?.component : undefined, iconComponent: isChunterSpace ? iconMixin?.component : undefined,

View File

@ -420,7 +420,7 @@ export function recheckNotifications (context: DocNotifyContext): void {
const toReadData = Array.from(toRead) const toReadData = Array.from(toRead)
toRead.clear() toRead.clear()
void (async () => { void (async () => {
const _client = client.apply(undefined, 'recheckNotifications') const _client = client.apply(undefined, 'recheckNotifications', true)
await inboxClient.readNotifications(_client, toReadData) await inboxClient.readNotifications(_client, toReadData)
await _client.commit() await _client.commit()
})() })()
@ -436,7 +436,7 @@ export async function readChannelMessages (
} }
const inboxClient = InboxNotificationsClientImpl.getClient() const inboxClient = InboxNotificationsClientImpl.getClient()
const client = getClient().apply(undefined, 'readViewportMessages') const op = getClient().apply(undefined, 'readViewportMessages', true)
try { try {
const allIds = getAllIds(messages) const allIds = getAllIds(messages)
@ -469,11 +469,11 @@ export async function readChannelMessages (
store.set(context._id, newTimestamp) store.set(context._id, newTimestamp)
return store return store
}) })
await client.update(context, { lastViewedTimestamp: newTimestamp }) await op.update(context, { lastViewedTimestamp: newTimestamp })
} }
await inboxClient.readNotifications(client, [...notifications, ...relatedMentions]) await inboxClient.readNotifications(op, [...notifications, ...relatedMentions])
} finally { } finally {
await client.commit() await op.commit()
} }
} }

View File

@ -107,6 +107,7 @@ export interface ChatWidgetTab extends WidgetTab {
thread?: Ref<ActivityMessage> thread?: Ref<ActivityMessage>
channelName: string channelName: string
selectedMessageId?: Ref<ActivityMessage> selectedMessageId?: Ref<ActivityMessage>
props?: Record<string, any>
} }
} }
@ -252,7 +253,13 @@ export default plugin(chunterId, {
function: { function: {
CanTranslateMessage: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, CanTranslateMessage: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
OpenThreadInSidebar: '' as Resource< OpenThreadInSidebar: '' as Resource<
(_id: Ref<ActivityMessage>, msg?: ActivityMessage, doc?: Doc, selectedId?: Ref<ActivityMessage>) => Promise<void> (
_id: Ref<ActivityMessage>,
msg?: ActivityMessage,
doc?: Doc,
selectedId?: Ref<ActivityMessage>,
props?: Record<string, any>
) => Promise<void>
>, >,
OpenChannelInSidebar: '' as Resource< OpenChannelInSidebar: '' as Resource<
( (

View File

@ -73,7 +73,7 @@
const prev = lastId const prev = lastId
lastId = _id lastId = _id
if (prev !== undefined) { if (prev !== undefined) {
void inboxClient.then((client) => client.readDoc(getClient(), prev)) void inboxClient.then((client) => client.readDoc(prev))
} }
query.query(contact.class.Organization, { _id }, (result) => { query.query(contact.class.Organization, { _id }, (result) => {
object = result[0] object = result[0]
@ -82,7 +82,7 @@
} }
onDestroy(async () => { onDestroy(async () => {
void inboxClient.then((client) => client.readDoc(getClient(), _id)) void inboxClient.then((client) => client.readDoc(_id))
}) })
</script> </script>

View File

@ -105,13 +105,13 @@
if (lastId !== _id) { if (lastId !== _id) {
const prev = lastId const prev = lastId
lastId = _id lastId = _id
void notificationClient.then((client) => client.readDoc(getClient(), prev)) void notificationClient.then((client) => client.readDoc(prev))
} }
} }
onDestroy(async () => { onDestroy(async () => {
controlledDocumentClosed() controlledDocumentClosed()
void notificationClient.then((client) => client.readDoc(getClient(), _id)) void notificationClient.then((client) => client.readDoc(_id))
}) })
$: if (_id && _class && project) { $: if (_id && _class && project) {

View File

@ -92,12 +92,12 @@
if (lastId !== _id) { if (lastId !== _id) {
const prev = lastId const prev = lastId
lastId = _id lastId = _id
void notificationClient.then((client) => client.readDoc(getClient(), prev)) void notificationClient.then((client) => client.readDoc(prev))
} }
} }
onDestroy(async () => { onDestroy(async () => {
void notificationClient.then((client) => client.readDoc(getClient(), _id)) void notificationClient.then((client) => client.readDoc(_id))
}) })
const starredQuery = createQuery() const starredQuery = createQuery()

View File

@ -64,7 +64,7 @@
{ attachedTo: channelId }, { attachedTo: channelId },
(res) => { (res) => {
plainMessages = res plainMessages = res
inboxClient.readDoc(getClient(), channelId) inboxClient.readDoc(channelId)
}, },
{ sort: { sendOn: SortingOrder.Descending } } { sort: { sendOn: SortingOrder.Descending } }
) )
@ -93,7 +93,7 @@
) )
} }
) )
await inboxClient.readDoc(getClient(), channel._id) await inboxClient.readDoc(channel._id)
clear() clear()
} }

View File

@ -52,7 +52,7 @@
let integrations: Integration[] = [] let integrations: Integration[] = []
let selectedIntegration: Integration | undefined = undefined let selectedIntegration: Integration | undefined = undefined
channel && inboxClient.forceReadDoc(getClient(), channel._id, channel._class) channel && inboxClient.forceReadDoc(channel._id, channel._class)
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()

View File

@ -82,7 +82,7 @@
objectId objectId
) )
Analytics.handleEvent(GmailEvents.SentEmail, { to: channel.value }) Analytics.handleEvent(GmailEvents.SentEmail, { to: channel.value })
await inboxClient.forceReadDoc(getClient(), channel._id, channel._class) await inboxClient.forceReadDoc(channel._id, channel._class)
objectId = generateId() objectId = generateId()
dispatch('close') dispatch('close')
} }

View File

@ -96,7 +96,7 @@
.filter((m) => m.length) .filter((m) => m.length)
}) })
Analytics.handleEvent(GmailEvents.SentEmail, { to: channel.value }) Analytics.handleEvent(GmailEvents.SentEmail, { to: channel.value })
await inboxClient.forceReadDoc(getClient(), channel._id, channel._class) await inboxClient.forceReadDoc(channel._id, channel._class)
for (const attachment of attachments) { for (const attachment of attachments) {
await client.addCollection( await client.addCollection(
attachmentP.class.Attachment, attachmentP.class.Attachment,

View File

@ -55,6 +55,7 @@
"@hcengineering/ui": "^0.6.15", "@hcengineering/ui": "^0.6.15",
"@hcengineering/view": "^0.6.13", "@hcengineering/view": "^0.6.13",
"@hcengineering/view-resources": "^0.6.0", "@hcengineering/view-resources": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"svelte": "^4.2.12" "svelte": "^4.2.12"
} }
} }

View File

@ -13,14 +13,20 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { getCurrentAccount } from '@hcengineering/core' import { Class, Doc, getCurrentAccount, Ref } from '@hcengineering/core'
import notification, { BrowserNotification, NotificationStatus } from '@hcengineering/notification' import notification, { BrowserNotification } from '@hcengineering/notification'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery, getClient } from '@hcengineering/presentation'
import { addNotification, getCurrentResolvedLocation, Location, NotificationSeverity } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { parseLinkId } from '@hcengineering/view-resources'
import { Analytics } from '@hcengineering/analytics'
import workbench, { Application } from '@hcengineering/workbench'
import { getResource } from '@hcengineering/platform'
import { checkPermission, pushAllowed, subscribePush } from '../utils' import { checkPermission, pushAllowed, subscribePush } from '../utils'
import { NotificationSeverity, addNotification } from '@hcengineering/ui'
import Notification from './Notification.svelte' import Notification from './Notification.svelte'
async function check (allowed: boolean) { async function check (allowed: boolean): Promise<void> {
if (allowed) { if (allowed) {
query.unsubscribe() query.unsubscribe()
return return
@ -38,26 +44,69 @@
query.query( query.query(
notification.class.BrowserNotification, notification.class.BrowserNotification,
{ {
user: getCurrentAccount()._id, user: getCurrentAccount()._id
status: NotificationStatus.New,
createdOn: { $gt: Date.now() }
}, },
(res) => { (res) => {
if (res.length > 0) { if (res.length > 0) {
notify(res[0]) void notify(res[0])
} }
} }
) )
} }
const client = getClient() const client = getClient()
const linkProviders = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
async function getObjectIdFromLocation (loc: Location): Promise<string | undefined> {
const appAlias = loc.path[2]
const application = client.getModel().findAllSync<Application>(workbench.class.Application, { alias: appAlias })[0]
if (application?.locationDataResolver != null) {
const resolver = await getResource(application.locationDataResolver)
const data = await resolver(loc)
return data.objectId
} else {
if (loc.fragment == null) return
const [, id, _class] = decodeURIComponent(loc.fragment).split('|')
if (_class == null) return
try {
return await parseLinkId(linkProviders, id, _class as Ref<Class<Doc>>)
} catch (err: any) {
Analytics.handleError(err)
console.error(err)
}
}
}
async function notify (value: BrowserNotification): Promise<void> { async function notify (value: BrowserNotification): Promise<void> {
addNotification(value.title, value.body, Notification, { value }, NotificationSeverity.Info) const _id: Ref<Doc> | undefined = value.objectId
await client.update(value, { status: NotificationStatus.Notified })
const getSidebarObject = await getResource(workbench.function.GetSidebarObject)
const sidebarObjectId = getSidebarObject()?._id
if (_id && _id === sidebarObjectId) {
await client.remove(value)
return
}
const locObjectId = await getObjectIdFromLocation(getCurrentResolvedLocation())
if (_id && _id === locObjectId) {
await client.remove(value)
return
}
addNotification(
value.title,
value.body,
Notification,
{ value },
NotificationSeverity.Info,
`notification-${value.objectId}`
)
await client.remove(value)
} }
const query = createQuery() const query = createQuery()
$: check($pushAllowed) $: void check($pushAllowed)
</script> </script>

View File

@ -30,16 +30,12 @@
import InboxNotificationPresenter from './inbox/InboxNotificationPresenter.svelte' import InboxNotificationPresenter from './inbox/InboxNotificationPresenter.svelte'
import NotifyContextIcon from './NotifyContextIcon.svelte' import NotifyContextIcon from './NotifyContextIcon.svelte'
import { import { isActivityNotification, isMentionNotification } from '../utils'
archiveContextNotifications,
isActivityNotification,
isMentionNotification,
unarchiveContextNotifications
} from '../utils'
export let value: DocNotifyContext export let value: DocNotifyContext
export let notifications: WithLookup<DisplayInboxNotification>[] export let notifications: WithLookup<DisplayInboxNotification>[]
export let viewlets: ActivityNotificationViewlet[] = [] export let viewlets: ActivityNotificationViewlet[] = []
export let isArchiving = false
export let archived = false export let archived = false
const maxNotifications = 3 const maxNotifications = 3
@ -174,13 +170,8 @@
isActionMenuOpened = false isActionMenuOpened = false
} }
let archivingPromise: Promise<any> | undefined = undefined
async function checkContext (): Promise<void> { async function checkContext (): Promise<void> {
await archivingPromise dispatch('archive')
archivingPromise = archived ? unarchiveContextNotifications(value) : archiveContextNotifications(value)
await archivingPromise
archivingPromise = undefined
} }
// function canShowTooltip (group: InboxNotification[]): boolean { // function canShowTooltip (group: InboxNotification[]): boolean {
@ -231,7 +222,7 @@
<div class="actions clear-mins"> <div class="actions clear-mins">
<div class="flex-center min-w-6"> <div class="flex-center min-w-6">
{#if archivingPromise !== undefined} {#if isArchiving}
<Spinner size="small" /> <Spinner size="small" />
{:else} {:else}
<CheckBox checked={archived} kind="todo" size="medium" on:value={checkContext} /> <CheckBox checked={archived} kind="todo" size="medium" on:value={checkContext} />
@ -284,7 +275,7 @@
<div class="actions clear-mins"> <div class="actions clear-mins">
<div class="flex-center"> <div class="flex-center">
{#if archivingPromise !== undefined} {#if isArchiving}
<Spinner size="small" /> <Spinner size="small" />
{:else} {:else}
<CheckBox checked={archived} kind="todo" size="medium" on:value={checkContext} /> <CheckBox checked={archived} kind="todo" size="medium" on:value={checkContext} />

View File

@ -15,14 +15,16 @@
<script lang="ts"> <script lang="ts">
import activity, { ActivityMessage } from '@hcengineering/activity' import activity, { ActivityMessage } from '@hcengineering/activity'
import chunter from '@hcengineering/chunter' import chunter from '@hcengineering/chunter'
import { Class, Doc, getCurrentAccount, groupByArray, IdMap, Ref, SortingOrder } from '@hcengineering/core' import { Class, Doc, getCurrentAccount, groupByArray, Ref, SortingOrder } from '@hcengineering/core'
import { DocNotifyContext, InboxNotification, notificationId } from '@hcengineering/notification' import { DocNotifyContext, InboxNotification, notificationId } from '@hcengineering/notification'
import { ActionContext, createQuery, getClient } from '@hcengineering/presentation' import { ActionContext, createQuery, getClient } from '@hcengineering/presentation'
import { import {
AnyComponent, AnyComponent,
closePanel,
Component, Component,
defineSeparators, defineSeparators,
deviceOptionsStore as deviceInfo, deviceOptionsStore as deviceInfo,
getCurrentLocation,
Label, Label,
Location, Location,
location as locationStore, location as locationStore,
@ -30,9 +32,7 @@
Scroller, Scroller,
Separator, Separator,
TabItem, TabItem,
TabList, TabList
closePanel,
getCurrentLocation
} from '@hcengineering/ui' } from '@hcengineering/ui'
import view, { decodeObjectURI } from '@hcengineering/view' import view, { decodeObjectURI } from '@hcengineering/view'
import { parseLinkId } from '@hcengineering/view-resources' import { parseLinkId } from '@hcengineering/view-resources'
@ -142,7 +142,7 @@
} }
} }
$: filteredData = filterData(filter, selectedTabId, inboxData, $contextByIdStore) $: filteredData = filterData(filter, selectedTabId, inboxData)
const unsubscribeLoc = locationStore.subscribe((newLocation) => { const unsubscribeLoc = locationStore.subscribe((newLocation) => {
void syncLocation(newLocation) void syncLocation(newLocation)
@ -192,7 +192,7 @@
if (thread !== undefined) { if (thread !== undefined) {
const fn = await getResource(chunter.function.OpenThreadInSidebar) const fn = await getResource(chunter.function.OpenThreadInSidebar)
void fn(thread, undefined, undefined, selectedMessageId) void fn(thread, undefined, undefined, selectedMessageId, { autofocus: false })
} }
if (selectedMessageId !== undefined) { if (selectedMessageId !== undefined) {
@ -308,23 +308,10 @@
} }
} }
function filterNotifications (
filter: InboxNotificationsFilter,
notifications: InboxNotification[]
): InboxNotification[] {
switch (filter) {
case 'unread':
return notifications.filter(({ isViewed }) => !isViewed)
case 'all':
return notifications
}
}
function filterData ( function filterData (
filter: InboxNotificationsFilter, filter: InboxNotificationsFilter,
selectedTabId: string | number, selectedTabId: string | number,
inboxData: InboxData, inboxData: InboxData
contextById: IdMap<DocNotifyContext>
): InboxData { ): InboxData {
if (selectedTabId === allTab.id && filter === 'all') { if (selectedTabId === allTab.id && filter === 'all') {
return inboxData return inboxData
@ -333,18 +320,20 @@
const result = new Map() const result = new Map()
for (const [key, notifications] of inboxData) { for (const [key, notifications] of inboxData) {
const resNotifications = filterNotifications(filter, notifications) if (filter === 'unread' && key !== selectedContext?._id && !notifications.some(({ isViewed }) => !isViewed)) {
continue
}
if (resNotifications.length === 0) { if (notifications.length === 0) {
continue continue
} }
if (selectedTabId === allTab.id) { if (selectedTabId === allTab.id) {
result.set(key, resNotifications) result.set(key, notifications)
continue continue
} }
const context = contextById.get(key) const context = $contextByIdStore.get(key)
if (context === undefined) { if (context === undefined) {
continue continue
@ -354,9 +343,9 @@
selectedTabId === activity.class.ActivityMessage && selectedTabId === activity.class.ActivityMessage &&
hierarchy.isDerived(context.objectClass, activity.class.ActivityMessage) hierarchy.isDerived(context.objectClass, activity.class.ActivityMessage)
) { ) {
result.set(key, resNotifications) result.set(key, notifications)
} else if (context.objectClass === selectedTabId) { } else if (context.objectClass === selectedTabId) {
result.set(key, resNotifications) result.set(key, notifications)
} }
} }
@ -372,11 +361,13 @@
function onArchiveToggled (): void { function onArchiveToggled (): void {
showArchive = !showArchive showArchive = !showArchive
selectedTabId = allTab.id selectedTabId = allTab.id
void selectContext(undefined)
} }
function onUnreadsToggled (): void { function onUnreadsToggled (): void {
filter = filter === 'unread' ? 'all' : 'unread' filter = filter === 'unread' ? 'all' : 'unread'
localStorage.setItem('inbox-filter', filter) localStorage.setItem('inbox-filter', filter)
void selectContext(undefined)
} }
$: items = [ $: items = [
@ -459,9 +450,10 @@
_class: isChunterChannel(selectedContext, urlObjectClass) _class: isChunterChannel(selectedContext, urlObjectClass)
? urlObjectClass ?? selectedContext.objectClass ? urlObjectClass ?? selectedContext.objectClass
: selectedContext.objectClass, : selectedContext.objectClass,
autofocus: false,
context: selectedContext, context: selectedContext,
activityMessage: selectedMessage, activityMessage: selectedMessage,
props: { context: selectedContext } props: { context: selectedContext, autofocus: false }
}} }}
on:close={() => selectContext(undefined)} on:close={() => selectContext(undefined)}
/> />

View File

@ -18,7 +18,7 @@
DisplayInboxNotification, DisplayInboxNotification,
DocNotifyContext DocNotifyContext
} from '@hcengineering/notification' } from '@hcengineering/notification'
import { Ref } from '@hcengineering/core' import { Ref, Timestamp } from '@hcengineering/core'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { ListView } from '@hcengineering/ui' import { ListView } from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
@ -40,6 +40,10 @@
let list: ListView let list: ListView
let listSelection = 0 let listSelection = 0
let element: HTMLDivElement | undefined let element: HTMLDivElement | undefined
let prevArchived = false
let archivingContexts = new Set<Ref<DocNotifyContext>>()
let archivedContexts = new Map<Ref<DocNotifyContext>, Timestamp>()
let displayData: [Ref<DocNotifyContext>, DisplayInboxNotification[]][] = [] let displayData: [Ref<DocNotifyContext>, DisplayInboxNotification[]][] = []
let viewlets: ActivityNotificationViewlet[] = [] let viewlets: ActivityNotificationViewlet[] = []
@ -48,15 +52,61 @@
viewlets = res viewlets = res
}) })
$: if (prevArchived !== archived) {
prevArchived = archived
archivedContexts.clear()
}
$: updateDisplayData(data) $: updateDisplayData(data)
function updateDisplayData (data: InboxData): void { function updateDisplayData (data: InboxData): void {
displayData = Array.from(data.entries()).sort(([, notifications1], [, notifications2]) => let result: [Ref<DocNotifyContext>, DisplayInboxNotification[]][] = Array.from(data.entries())
if (archivedContexts.size > 0) {
result = result.filter(([contextId]) => {
const context = $contextByIdStore.get(contextId)
return (
!archivedContexts.has(contextId) ||
(context?.lastUpdateTimestamp ?? 0) > (archivedContexts.get(contextId) ?? 0)
)
})
}
displayData = result.sort(([, notifications1], [, notifications2]) =>
notificationsComparator(notifications1[0], notifications2[0]) notificationsComparator(notifications1[0], notifications2[0])
) )
} }
function onKeydown (key: KeyboardEvent): void { async function archiveContext (listSelection: number): Promise<void> {
const contextId = displayData[listSelection]?.[0]
const context = $contextByIdStore.get(contextId)
if (contextId === undefined || context === undefined) {
return
}
archivingContexts = archivingContexts.add(contextId)
try {
const nextContextId = displayData[listSelection + 1]?.[0] ?? displayData[listSelection - 1]?.[0]
const nextContext = $contextByIdStore.get(nextContextId)
if (archived) {
void unarchiveContextNotifications(context)
} else {
void archiveContextNotifications(context)
}
list.select(Math.min(listSelection, displayData.length - 2))
archivedContexts = archivedContexts.set(contextId, context.lastUpdateTimestamp ?? 0)
displayData = displayData.filter(([id]) => id !== contextId)
if (selectedContext === contextId || selectedContext === undefined) {
dispatch('click', { context: nextContext })
}
} catch (e) {}
archivingContexts.delete(contextId)
archivingContexts = archivingContexts
}
async function onKeydown (key: KeyboardEvent): Promise<void> {
if (key.code === 'ArrowUp') { if (key.code === 'ArrowUp') {
key.stopPropagation() key.stopPropagation()
key.preventDefault() key.preventDefault()
@ -71,14 +121,7 @@
key.preventDefault() key.preventDefault()
key.stopPropagation() key.stopPropagation()
const contextId = displayData[listSelection]?.[0] await archiveContext(listSelection)
const context = $contextByIdStore.get(contextId)
if (archived) {
void unarchiveContextNotifications(context)
} else {
void archiveContextNotifications(context)
}
} }
if (key.code === 'Enter') { if (key.code === 'Enter') {
key.preventDefault() key.preventDefault()
@ -126,6 +169,8 @@
notifications={contextNotifications} notifications={contextNotifications}
{archived} {archived}
{viewlets} {viewlets}
isArchiving={archivingContexts.has(contextId)}
on:archive={() => archiveContext(itemIndex)}
on:click={(event) => { on:click={(event) => {
dispatch('click', event.detail) dispatch('click', event.detail)
listSelection = itemIndex listSelection = itemIndex

View File

@ -148,13 +148,15 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
return InboxNotificationsClientImpl._instance return InboxNotificationsClientImpl._instance
} }
async readDoc (client: TxOperations, _id: Ref<Doc>): Promise<void> { async readDoc (_id: Ref<Doc>): Promise<void> {
const docNotifyContext = this._contextByDoc.get(_id) const docNotifyContext = this._contextByDoc.get(_id)
if (docNotifyContext === undefined) { if (docNotifyContext === undefined) {
return return
} }
const client = getClient()
const op = client.apply(undefined, 'readDoc', true)
const inboxNotifications = await client.findAll( const inboxNotifications = await client.findAll(
notification.class.InboxNotification, notification.class.InboxNotification,
{ docNotifyContext: docNotifyContext._id, isViewed: false }, { docNotifyContext: docNotifyContext._id, isViewed: false },
@ -162,19 +164,20 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
) )
for (const notification of inboxNotifications) { for (const notification of inboxNotifications) {
await client.updateDoc(notification._class, notification.space, notification._id, { isViewed: true }) await op.updateDoc(notification._class, notification.space, notification._id, { isViewed: true })
} }
await client.update(docNotifyContext, { lastViewedTimestamp: Date.now() }) await op.update(docNotifyContext, { lastViewedTimestamp: Date.now() })
} }
async forceReadDoc (client: TxOperations, _id: Ref<Doc>, _class: Ref<Class<Doc>>): Promise<void> { async forceReadDoc (_id: Ref<Doc>, _class: Ref<Class<Doc>>): Promise<void> {
const context = this._contextByDoc.get(_id) const context = this._contextByDoc.get(_id)
if (context !== undefined) { if (context !== undefined) {
await this.readDoc(client, _id) await this.readDoc(_id)
return return
} }
const client = getClient()
const doc = await client.findOne(_class, { _id }) const doc = await client.findOne(_class, { _id })
if (doc === undefined) { if (doc === undefined) {
@ -230,7 +233,7 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
async archiveNotifications (client: TxOperations, ids: Array<Ref<InboxNotification>>): Promise<void> { async archiveNotifications (client: TxOperations, ids: Array<Ref<InboxNotification>>): Promise<void> {
const inboxNotifications = (get(this.inboxNotifications) ?? []).filter(({ _id }) => ids.includes(_id)) const inboxNotifications = (get(this.inboxNotifications) ?? []).filter(({ _id }) => ids.includes(_id))
for (const notification of inboxNotifications) { for (const notification of inboxNotifications) {
await client.update(notification, { archived: true }) await client.update(notification, { archived: true, isViewed: true })
} }
} }
@ -248,7 +251,10 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
) )
const contexts = get(this.contexts) ?? [] const contexts = get(this.contexts) ?? []
for (const notification of inboxNotifications) { for (const notification of inboxNotifications) {
await ops.updateDoc(notification._class, notification.space, notification._id, { archived: true }) await ops.updateDoc(notification._class, notification.space, notification._id, {
archived: true,
isViewed: true
})
} }
for (const context of contexts) { for (const context of contexts) {

View File

@ -44,7 +44,8 @@ import {
unreadAll, unreadAll,
checkPermission, checkPermission,
unarchiveContextNotifications, unarchiveContextNotifications,
isNotificationAllowed isNotificationAllowed,
locationDataResolver
} from './utils' } from './utils'
import { InboxNotificationsClientImpl } from './inboxNotificationsClient' import { InboxNotificationsClientImpl } from './inboxNotificationsClient'
@ -77,7 +78,8 @@ export default async (): Promise<Resources> => ({
CanUnReadNotifyContext: canUnReadNotifyContext, CanUnReadNotifyContext: canUnReadNotifyContext,
HasInboxNotifications: hasInboxNotifications, HasInboxNotifications: hasInboxNotifications,
CheckPushPermission: checkPermission, CheckPushPermission: checkPermission,
IsNotificationAllowed: isNotificationAllowed IsNotificationAllowed: isNotificationAllowed,
LocationDataResolver: locationDataResolver
}, },
actionImpl: { actionImpl: {
Unsubscribe: unsubscribe, Unsubscribe: unsubscribe,

View File

@ -38,7 +38,6 @@ import core, {
type WithLookup type WithLookup
} from '@hcengineering/core' } from '@hcengineering/core'
import notification, { import notification, {
NotificationStatus,
notificationId, notificationId,
type ActivityInboxNotification, type ActivityInboxNotification,
type BaseNotificationType, type BaseNotificationType,
@ -63,9 +62,10 @@ import {
type Location, type Location,
type ResolvedLocation type ResolvedLocation
} from '@hcengineering/ui' } from '@hcengineering/ui'
import { decodeObjectURI, encodeObjectURI, type LinkIdProvider } from '@hcengineering/view' import view, { decodeObjectURI, encodeObjectURI, type LinkIdProvider } from '@hcengineering/view'
import { getObjectLinkId } from '@hcengineering/view-resources' import { getObjectLinkId, parseLinkId } from '@hcengineering/view-resources'
import { get, writable } from 'svelte/store' import { get, writable } from 'svelte/store'
import type { LocationData } from '@hcengineering/workbench'
import { InboxNotificationsClientImpl } from './inboxNotificationsClient' import { InboxNotificationsClientImpl } from './inboxNotificationsClient'
import { type InboxData, type InboxNotificationsFilter } from './types' import { type InboxData, type InboxNotificationsFilter } from './types'
@ -183,7 +183,7 @@ export async function archiveContextNotifications (doc?: DocNotifyContext): Prom
return return
} }
const ops = getClient().apply(undefined, 'archiveContextNotifications') const ops = getClient().apply(undefined, 'archiveContextNotifications', true)
try { try {
const notifications = await ops.findAll( const notifications = await ops.findAll(
@ -209,7 +209,7 @@ export async function unarchiveContextNotifications (doc?: DocNotifyContext): Pr
return return
} }
const ops = getClient().apply(undefined, 'unarchiveContextNotifications') const ops = getClient().apply(undefined, 'unarchiveContextNotifications', true)
try { try {
const notifications = await ops.findAll( const notifications = await ops.findAll(
@ -790,11 +790,10 @@ export async function subscribePush (): Promise<boolean> {
async function cleanTag (_id: Ref<Doc>): Promise<void> { async function cleanTag (_id: Ref<Doc>): Promise<void> {
const client = getClient() const client = getClient()
const notifications = await client.findAll(notification.class.BrowserNotification, { const notifications = await client.findAll(notification.class.BrowserNotification, {
tag: _id, tag: _id
status: NotificationStatus.New
}) })
for (const notification of notifications) { for (const notification of notifications) {
await client.update(notification, { status: NotificationStatus.Notified }) await client.remove(notification)
} }
} }
@ -858,3 +857,20 @@ export function isNotificationAllowed (type: BaseNotificationType, providerId: R
return type.defaultEnabled return type.defaultEnabled
} }
export async function locationDataResolver (loc: Location): Promise<LocationData> {
const client = getClient()
try {
const [id, _class] = decodeObjectURI(loc.path[3])
const linkProviders = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
const _id: Ref<Doc> | undefined = await parseLinkId(linkProviders, id, _class)
return {
objectId: _id,
objectClass: _class
}
} catch (e) {
return {}
}
}

View File

@ -52,7 +52,6 @@ export const DOMAIN_USER_NOTIFY = 'notification-user' as Domain
*/ */
export interface BrowserNotification extends Doc { export interface BrowserNotification extends Doc {
user: Ref<Account> user: Ref<Account>
status: NotificationStatus
title: string title: string
body: string body: string
onClickLocation?: Location onClickLocation?: Location
@ -84,14 +83,6 @@ export interface PushSubscription extends Doc {
keys: PushSubscriptionKeys keys: PushSubscriptionKeys
} }
/**
* @public
*/
export enum NotificationStatus {
New,
Notified
}
/** /**
* @public * @public
*/ */
@ -314,8 +305,8 @@ export interface InboxNotificationsClient {
activityInboxNotifications: Writable<ActivityInboxNotification[]> activityInboxNotifications: Writable<ActivityInboxNotification[]>
inboxNotificationsByContext: Readable<Map<Ref<DocNotifyContext>, InboxNotification[]>> inboxNotificationsByContext: Readable<Map<Ref<DocNotifyContext>, InboxNotification[]>>
readDoc: (client: TxOperations, _id: Ref<Doc>) => Promise<void> readDoc: (_id: Ref<Doc>) => Promise<void>
forceReadDoc: (client: TxOperations, _id: Ref<Doc>, _class: Ref<Class<Doc>>) => Promise<void> forceReadDoc: (_id: Ref<Doc>, _class: Ref<Class<Doc>>) => Promise<void>
readNotifications: (client: TxOperations, ids: Array<Ref<InboxNotification>>) => Promise<void> readNotifications: (client: TxOperations, ids: Array<Ref<InboxNotification>>) => Promise<void>
unreadNotifications: (client: TxOperations, ids: Array<Ref<InboxNotification>>) => Promise<void> unreadNotifications: (client: TxOperations, ids: Array<Ref<InboxNotification>>) => Promise<void>
archiveNotifications: (client: TxOperations, ids: Array<Ref<InboxNotification>>) => Promise<void> archiveNotifications: (client: TxOperations, ids: Array<Ref<InboxNotification>>) => Promise<void>

View File

@ -55,12 +55,12 @@
if (lastId !== _id) { if (lastId !== _id) {
const prev = lastId const prev = lastId
lastId = _id lastId = _id
void notificationClient.then((client) => client.readDoc(getClient(), prev)) void notificationClient.then((client) => client.readDoc(prev))
} }
} }
onDestroy(async () => { onDestroy(async () => {
void notificationClient.then((client) => client.readDoc(getClient(), _id)) void notificationClient.then((client) => client.readDoc(_id))
}) })
const query = createQuery() const query = createQuery()

View File

@ -68,12 +68,12 @@
if (lastId !== _id) { if (lastId !== _id) {
const prev = lastId const prev = lastId
lastId = _id lastId = _id
void notificationClient.then((client) => client.readDoc(getClient(), prev)) void notificationClient.then((client) => client.readDoc(prev))
} }
} }
onDestroy(async () => { onDestroy(async () => {
void notificationClient.then((client) => client.readDoc(getClient(), _id)) void notificationClient.then((client) => client.readDoc(_id))
}) })
const query = createQuery() const query = createQuery()

View File

@ -44,7 +44,7 @@
const inboxClient = getResource(notification.function.GetInboxNotificationsClient).then((res) => res()) const inboxClient = getResource(notification.function.GetInboxNotificationsClient).then((res) => res())
onDestroy(async () => { onDestroy(async () => {
void inboxClient.then((client) => client.readDoc(getClient(), _id)) void inboxClient.then((client) => client.readDoc(_id))
}) })
const client = getClient() const client = getClient()
@ -57,7 +57,7 @@
const prev = lastId const prev = lastId
lastId = _id lastId = _id
if (prev !== undefined) { if (prev !== undefined) {
void inboxClient.then((client) => client.readDoc(getClient(), prev)) void inboxClient.then((client) => client.readDoc(prev))
} }
query.query(recruit.class.Vacancy, { _id }, (result) => { query.query(recruit.class.Vacancy, { _id }, (result) => {
object = result[0] as Required<Vacancy> object = result[0] as Required<Vacancy>

View File

@ -89,7 +89,7 @@
(res) => { (res) => {
messages = res.reverse() messages = res.reverse()
if (channel !== undefined) { if (channel !== undefined) {
inboxClient.forceReadDoc(client, channel._id, channel._class) inboxClient.forceReadDoc(channel._id, channel._class)
} }
}, },
{ {
@ -150,7 +150,7 @@
} }
) )
if (channel !== undefined) { if (channel !== undefined) {
await inboxClient.forceReadDoc(client, channel._id, channel._class) await inboxClient.forceReadDoc(channel._id, channel._class)
} }
clear() clear()
} }

View File

@ -89,13 +89,13 @@
if (_id && lastId && lastId !== _id) { if (_id && lastId && lastId !== _id) {
const prev = lastId const prev = lastId
lastId = _id lastId = _id
void inboxClient.readDoc(getClient(), prev) void inboxClient.readDoc(prev)
} }
} }
onDestroy(async () => { onDestroy(async () => {
if (issueId === undefined) return if (issueId === undefined) return
void inboxClient.readDoc(getClient(), issueId) void inboxClient.readDoc(issueId)
}) })
$: if (issueId !== undefined && _class !== undefined) { $: if (issueId !== undefined && _class !== undefined) {

View File

@ -54,12 +54,12 @@
if (lastId !== _id) { if (lastId !== _id) {
const prev = lastId const prev = lastId
lastId = _id lastId = _id
void inboxClient.then((client) => client.readDoc(getClient(), prev)) void inboxClient.then((client) => client.readDoc(prev))
} }
} }
onDestroy(async () => { onDestroy(async () => {
void inboxClient.then((client) => client.readDoc(getClient(), _id)) void inboxClient.then((client) => client.readDoc(_id))
}) })
$: _id !== undefined && $: _id !== undefined &&

View File

@ -65,7 +65,7 @@
const prev = lastId const prev = lastId
lastId = _id lastId = _id
void inboxClient.then(async (client) => { void inboxClient.then(async (client) => {
await client.readDoc(pClient, prev) await client.readDoc(prev)
}) })
} }
} }
@ -73,7 +73,7 @@
onDestroy(async () => { onDestroy(async () => {
await inboxClient.then(async (client) => { await inboxClient.then(async (client) => {
if (objectId === undefined) return if (objectId === undefined) return
await client.readDoc(pClient, objectId) await client.readDoc(objectId)
}) })
}) })

View File

@ -215,9 +215,11 @@
} else { } else {
const tabToReplace = tabs.findLast((t) => !t.isPinned) const tabToReplace = tabs.findLast((t) => !t.isPinned)
if (tabToReplace !== undefined) { if (tabToReplace !== undefined) {
await client.update(tabToReplace, { const op = client.apply(undefined, undefined, true)
await op.update(tabToReplace, {
location: url location: url
}) })
await op.commit()
selectTab(tabToReplace._id) selectTab(tabToReplace._id)
prevTabIdStore.set(tabToReplace._id) prevTabIdStore.set(tabToReplace._id)
} else { } else {

View File

@ -26,7 +26,7 @@ import ServerManager from './components/ServerManager.svelte'
import WorkbenchTabs from './components/WorkbenchTabs.svelte' import WorkbenchTabs from './components/WorkbenchTabs.svelte'
import { isAdminUser } from '@hcengineering/presentation' import { isAdminUser } from '@hcengineering/presentation'
import { canCloseTab, closeTab, pinTab, unpinTab } from './workbench' import { canCloseTab, closeTab, pinTab, unpinTab } from './workbench'
import { closeWidgetTab, createWidgetTab } from './sidebar' import { closeWidgetTab, createWidgetTab, getSidebarObject } from './sidebar'
async function hasArchiveSpaces (spaces: Space[]): Promise<boolean> { async function hasArchiveSpaces (spaces: Space[]): Promise<boolean> {
return spaces.find((sp) => sp.archived) !== undefined return spaces.find((sp) => sp.archived) !== undefined
@ -57,7 +57,8 @@ export default async (): Promise<Resources> => ({
IsOwner: async (docs: Space[]) => getCurrentAccount().role === AccountRole.Owner || isAdminUser(), IsOwner: async (docs: Space[]) => getCurrentAccount().role === AccountRole.Owner || isAdminUser(),
CanCloseTab: canCloseTab, CanCloseTab: canCloseTab,
CreateWidgetTab: createWidgetTab, CreateWidgetTab: createWidgetTab,
CloseWidgetTab: closeWidgetTab CloseWidgetTab: closeWidgetTab,
GetSidebarObject: getSidebarObject
}, },
actionImpl: { actionImpl: {
Navigate: doNavigate, Navigate: doNavigate,

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
// //
import { WorkbenchEvents, type Widget, type WidgetTab } from '@hcengineering/workbench' import { WorkbenchEvents, type Widget, type WidgetTab } from '@hcengineering/workbench'
import { getCurrentAccount, type Ref } from '@hcengineering/core' import { type Class, type Doc, getCurrentAccount, type Ref } from '@hcengineering/core'
import { get, writable } from 'svelte/store' import { get, writable } from 'svelte/store'
import { getCurrentLocation } from '@hcengineering/ui' import { getCurrentLocation } from '@hcengineering/ui'
import { getResource } from '@hcengineering/platform' import { getResource } from '@hcengineering/platform'
@ -31,6 +31,8 @@ export interface WidgetState {
data?: Record<string, any> data?: Record<string, any>
tabs: WidgetTab[] tabs: WidgetTab[]
tab?: string tab?: string
objectId?: Ref<Doc>
objectClass?: Ref<Class<Doc>>
closedByUser?: boolean closedByUser?: boolean
openedByUser?: boolean openedByUser?: boolean
} }
@ -347,3 +349,22 @@ export function updateTabData (widget: Ref<Widget>, tabId: string, data: Record<
widgetsState widgetsState
}) })
} }
export function getSidebarObject (): Partial<Pick<Doc, '_id' | '_class'>> {
const state = get(sidebarStore)
if (state.variant !== SidebarVariant.EXPANDED || state.widget == null) {
return {}
}
const { widgetsState } = state
const widgetState = widgetsState.get(state.widget)
if (widgetState == null) {
return {}
}
const tab = widgetState.tabs.find((it) => it.id === widgetState.tab)
return {
_id: tab?.objectId ?? widgetState.objectId,
_class: tab?.objectClass ?? widgetState.objectClass
}
}

View File

@ -121,7 +121,9 @@ const syncTabLoc = reduceCalls(async (): Promise<void> => {
// return // return
// } // }
await getClient().diffUpdate(tab, { location: url, name }) const op = getClient().apply(undefined, undefined, true)
await op.diffUpdate(tab, { location: url, name })
await op.commit()
} }
}) })

View File

@ -32,6 +32,8 @@ import { ViewAction } from '@hcengineering/view'
*/ */
export interface LocationData { export interface LocationData {
objectId?: Ref<Doc>
objectClass?: Ref<Class<Doc>>
name?: string name?: string
nameIntl?: IntlString nameIntl?: IntlString
icon?: Asset icon?: Asset
@ -93,6 +95,8 @@ export interface WidgetTab {
widget?: Ref<Widget> widget?: Ref<Widget>
isPinned?: boolean isPinned?: boolean
allowedPath?: string allowedPath?: string
objectId?: Ref<Doc>
objectClass?: Ref<Class<Doc>>
data?: Record<string, any> data?: Record<string, any>
} }
@ -258,7 +262,8 @@ export default plugin(workbenchId, {
}, },
function: { function: {
CreateWidgetTab: '' as Resource<(widget: Widget, tab: WidgetTab, newTab: boolean) => Promise<void>>, CreateWidgetTab: '' as Resource<(widget: Widget, tab: WidgetTab, newTab: boolean) => Promise<void>>,
CloseWidgetTab: '' as Resource<(widget: Widget, tab: string) => Promise<void>> CloseWidgetTab: '' as Resource<(widget: Widget, tab: string) => Promise<void>>,
GetSidebarObject: '' as Resource<() => Partial<Pick<Doc, '_id' | '_class'>>>
}, },
actionImpl: { actionImpl: {
Navigate: '' as ViewAction<{ Navigate: '' as ViewAction<{

View File

@ -63,7 +63,6 @@ import notification, {
InboxNotification, InboxNotification,
MentionInboxNotification, MentionInboxNotification,
notificationId, notificationId,
NotificationStatus,
NotificationType, NotificationType,
PushData, PushData,
PushSubscription PushSubscription
@ -545,7 +544,6 @@ export async function createPushFromInbox (
) )
return control.txFactory.createTxCreateDoc(notification.class.BrowserNotification, receiver.space, { return control.txFactory.createTxCreateDoc(notification.class.BrowserNotification, receiver.space, {
user: receiver._id, user: receiver._id,
status: NotificationStatus.New,
title, title,
body, body,
senderId: sender._id, senderId: sender._id,

View File

@ -210,7 +210,6 @@ test.describe('Channel tests', () => {
await channelPageSecond.sendMessage('One two') await channelPageSecond.sendMessage('One two')
await channelPageSecond.checkMessageExist('One two', true, 'One two') await channelPageSecond.checkMessageExist('One two', true, 'One two')
await channelPage.clickChannel('random') await channelPage.clickChannel('random')
await channelPage.clickOnClosePopupButton()
await channelPage.clickChannel('general') await channelPage.clickChannel('general')
await channelPage.checkMessageExist('One two', true, 'One two') await channelPage.checkMessageExist('One two', true, 'One two')
}) })
@ -236,7 +235,6 @@ test.describe('Channel tests', () => {
await channelPageSecond.sendMessage('One two') await channelPageSecond.sendMessage('One two')
await channelPageSecond.checkMessageExist('One two', true, 'One two') await channelPageSecond.checkMessageExist('One two', true, 'One two')
await channelPage.clickChannel('general') await channelPage.clickChannel('general')
await channelPage.clickOnClosePopupButton()
await channelPage.clickChannel('random') await channelPage.clickChannel('random')
await channelPage.checkMessageExist('One two', true, 'One two') await channelPage.checkMessageExist('One two', true, 'One two')
}) })

View File

@ -111,7 +111,7 @@ test.describe('candidate/talents tests', () => {
mergeLocation: true, mergeLocation: true,
location: firstLocation, location: firstLocation,
mergeTitle: true, mergeTitle: true,
title: titleTalent1, title: titleTalent2,
mergeSource: true, mergeSource: true,
source: sourceTalent1 source: sourceTalent1
}) })
@ -121,7 +121,7 @@ test.describe('candidate/talents tests', () => {
await talentsPage.openTalentByTalentName(talentNameFirst) await talentsPage.openTalentByTalentName(talentNameFirst)
await talentDetailsPage.checkSocialLinks('Phone', '123123213213') await talentDetailsPage.checkSocialLinks('Phone', '123123213213')
await talentDetailsPage.checkSocialLinks('Email', 'test-merge-2@gmail.com') await talentDetailsPage.checkSocialLinks('Email', 'test-merge-2@gmail.com')
await talentDetailsPage.checkMergeContacts(firstLocation, titleTalent2, sourceTalent2) await talentDetailsPage.checkMergeContacts(firstLocation, titleTalent2, sourceTalent1)
}) })
test('Match to vacancy', async () => { test('Match to vacancy', async () => {