diff --git a/models/chunter/package.json b/models/chunter/package.json index 364127792a..8b0654b107 100644 --- a/models/chunter/package.json +++ b/models/chunter/package.json @@ -41,6 +41,7 @@ "@hcengineering/model-notification": "^0.6.0", "@hcengineering/model-view": "^0.6.0", "@hcengineering/model-workbench": "^0.6.1", + "@hcengineering/model-presentation": "^0.6.0", "@hcengineering/notification": "^0.6.23", "@hcengineering/platform": "^0.6.11", "@hcengineering/ui": "^0.6.15", diff --git a/models/chunter/src/index.ts b/models/chunter/src/index.ts index 50ca4f942e..32319cac47 100644 --- a/models/chunter/src/index.ts +++ b/models/chunter/src/index.ts @@ -24,6 +24,7 @@ import { type ObjectChatPanel, type ThreadMessage } from '@hcengineering/chunter' +import presentation from '@hcengineering/model-presentation' import contact from '@hcengineering/contact' import { type Class, @@ -457,6 +458,10 @@ export function createModel (builder: Builder): void { presenter: chunter.component.ChatMessagePresenter }) + builder.mixin(chunter.class.ChatMessage, core.class.Class, presentation.mixin.InstantTransactions, { + txClasses: [core.class.TxCreateDoc] + }) + builder.mixin(chunter.class.ThreadMessage, core.class.Class, view.mixin.ObjectPresenter, { presenter: chunter.component.ThreadMessagePresenter }) diff --git a/models/presentation/src/index.ts b/models/presentation/src/index.ts index c047798f6f..f5a0d26c8c 100644 --- a/models/presentation/src/index.ts +++ b/models/presentation/src/index.ts @@ -13,9 +13,9 @@ // limitations under the License. // -import { DOMAIN_MODEL, type Blob, type Class, type Doc, type Ref } from '@hcengineering/core' -import { Model, Prop, TypeRef, TypeString, type Builder } from '@hcengineering/model' -import core, { TDoc } from '@hcengineering/model-core' +import { DOMAIN_MODEL, type Tx, type Blob, type Class, type Doc, type Ref } from '@hcengineering/core' +import { Mixin, Model, Prop, TypeRef, TypeString, type Builder } from '@hcengineering/model' +import core, { TClass, TDoc } from '@hcengineering/model-core' import { type Asset, type IntlString, type Resource } from '@hcengineering/platform' // Import types to prevent .svelte components to being exposed to type typescript. import { @@ -33,7 +33,8 @@ import { type FilePreviewExtension, type ObjectSearchCategory, type ObjectSearchContext, - type ObjectSearchFactory + type ObjectSearchFactory, + type InstantTransactions } from '@hcengineering/presentation/src/types' import { type AnyComponent, type ComponentExtensionId } from '@hcengineering/ui/src/types' import presentation from './plugin' @@ -94,6 +95,11 @@ export class TFilePreviewExtension extends TComponentPointExtension implements F availabilityChecker?: Resource<() => Promise> } +@Mixin(presentation.mixin.InstantTransactions, core.class.Class) +export class TInstantTransactions extends TClass implements InstantTransactions { + txClasses!: Array>> +} + export function createModel (builder: Builder): void { builder.createModel( TObjectSearchCategory, @@ -101,6 +107,7 @@ export function createModel (builder: Builder): void { TComponentPointExtension, TDocCreateExtension, TDocRules, - TFilePreviewExtension + TFilePreviewExtension, + TInstantTransactions ) } diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index c04ac4ecf2..3dd6d63552 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -14,7 +14,7 @@ // limitations under the License. // -import { type Class, type Ref } from '@hcengineering/core' +import { type Mixin, type Class, type Ref } from '@hcengineering/core' import type { Asset, IntlString, Metadata, Plugin, StatusCode } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' import { type ComponentExtensionId } from '@hcengineering/ui' @@ -24,7 +24,8 @@ import { type DocRules, type DocCreateExtension, type FilePreviewExtension, - type ObjectSearchCategory + type ObjectSearchCategory, + type InstantTransactions } from './types' import type { PreviewConfig } from './preview' @@ -42,6 +43,9 @@ export default plugin(presentationId, { DocRules: '' as Ref>, FilePreviewExtension: '' as Ref> }, + mixin: { + InstantTransactions: '' as Ref> + }, string: { Create: '' as IntlString, Cancel: '' as IntlString, diff --git a/packages/presentation/src/types.ts b/packages/presentation/src/types.ts index 45889c2110..5f04e43f36 100644 --- a/packages/presentation/src/types.ts +++ b/packages/presentation/src/types.ts @@ -1,4 +1,5 @@ import { + type Tx, type Blob, type Class, type Client, @@ -183,3 +184,10 @@ export interface FilePreviewExtension extends ComponentPointExtension { // Extension is only available if this checker returns true availabilityChecker?: Resource<() => Promise> } + +/** + * @public + */ +export interface InstantTransactions extends Class { + txClasses: Array>> +} diff --git a/packages/presentation/src/utils.ts b/packages/presentation/src/utils.ts index be1825b75e..28001a69a0 100644 --- a/packages/presentation/src/utils.ts +++ b/packages/presentation/src/utils.ts @@ -17,9 +17,11 @@ import { Analytics } from '@hcengineering/analytics' import core, { TxOperations, + TxProcessor, concatLink, getCurrentAccount, reduceCalls, + type TxApplyIf, type AnyAttribute, type ArrOf, type AttachedDoc, @@ -46,7 +48,8 @@ import core, { type Tx, type TxResult, type TypeAny, - type WithLookup + type WithLookup, + type TxCUD } from '@hcengineering/core' import { getMetadata, getResource } from '@hcengineering/platform' import { LiveQuery as LQ } from '@hcengineering/query' @@ -54,14 +57,14 @@ import { getRawCurrentLocation, workspaceId, type AnyComponent, type AnySvelteCo import view, { type AttributeCategory, type AttributeEditor } from '@hcengineering/view' import { deepEqual } from 'fast-equals' import { onDestroy } from 'svelte' -import { get } from 'svelte/store' +import { type Writable, get, writable } from 'svelte/store' import { type KeyedAttribute } from '..' import { OptimizeQueryMiddleware, PresentationPipelineImpl, type PresentationPipeline } from './pipeline' import plugin from './plugin' export { reduceCalls } from '@hcengineering/core' let liveQuery: LQ -let client: TxOperations & MeasureClient +let client: TxOperations & MeasureClient & OptimisticTxes let pipeline: PresentationPipeline const txListeners: Array<(...tx: Tx[]) => void> = [] @@ -83,7 +86,11 @@ export function removeTxListener (l: (tx: Tx) => void): void { } } -class UIClient extends TxOperations implements Client, MeasureClient { +export interface OptimisticTxes { + pendingCreatedDocs: Writable, boolean>> +} + +class UIClient extends TxOperations implements Client, MeasureClient, OptimisticTxes { constructor ( client: MeasureClient, private readonly liveQuery: Client @@ -93,23 +100,56 @@ class UIClient extends TxOperations implements Client, MeasureClient { afterMeasure: Tx[] = [] measureOp?: MeasureDoneOperation + protected pendingTxes = new Set>() + protected _pendingCreatedDocs = writable, boolean>>({}) + + get pendingCreatedDocs (): typeof this._pendingCreatedDocs { + return this._pendingCreatedDocs + } async doNotify (...tx: Tx[]): Promise { if (this.measureOp !== undefined) { this.afterMeasure.push(...tx) } else { - try { - await pipeline.notifyTx(...tx) + const pending = get(this._pendingCreatedDocs) + let pendingUpdated = false + tx.forEach((t) => { + if (this.pendingTxes.has(t._id)) { + this.pendingTxes.delete(t._id) - await liveQuery.tx(...tx) + // Only CUD tx can be pending now + const innerTx = TxProcessor.extractTx(t) as TxCUD - txListeners.forEach((it) => { - it(...tx) - }) - } catch (err: any) { - Analytics.handleError(err) - console.log(err) + if (innerTx._class === core.class.TxCreateDoc) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete pending[innerTx.objectId] + pendingUpdated = true + } + } + }) + if (pendingUpdated) { + this._pendingCreatedDocs.set(pending) } + + // We still want to notify about all transactions because there might be queries created after + // the early applied transaction + // For old queries there's a check anyway that prevents the same document from being added twice + await this.provideNotify(...tx) + } + } + + private async provideNotify (...tx: Tx[]): Promise { + try { + await pipeline.notifyTx(...tx) + + await liveQuery.tx(...tx) + + txListeners.forEach((it) => { + it(...tx) + }) + } catch (err: any) { + Analytics.handleError(err) + console.log(err) } } @@ -130,9 +170,49 @@ class UIClient extends TxOperations implements Client, MeasureClient { } override async tx (tx: Tx): Promise { + void this.notifyEarly(tx) + return await this.client.tx(tx) } + private async notifyEarly (tx: Tx): Promise { + if (tx._class === core.class.TxApplyIf) { + const applyTx = tx as TxApplyIf + + if (applyTx.match.length !== 0 || applyTx.notMatch.length !== 0) { + // Cannot early apply conditional transactions + return + } + + await Promise.all( + applyTx.txes.map(async (atx) => { + await this.notifyEarly(atx) + }) + ) + return + } + + if (!this.getHierarchy().isDerived(tx._class, core.class.TxCUD)) { + return + } + + const innerTx = TxProcessor.extractTx(tx) as TxCUD + // Can pre-build some configuration later from the model if this will be too slow. + const instantTxes = this.getHierarchy().classHierarchyMixin(innerTx.objectClass, plugin.mixin.InstantTransactions) + if (instantTxes?.txClasses.includes(innerTx._class) !== true) { + return + } + + if (innerTx._class === core.class.TxCreateDoc) { + const pending = get(this._pendingCreatedDocs) + pending[innerTx.objectId] = true + this._pendingCreatedDocs.set(pending) + } + + this.pendingTxes.add(tx._id) + await this.provideNotify(tx) + } + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { return await this.client.searchFulltext(query, options) } @@ -159,7 +239,7 @@ class UIClient extends TxOperations implements Client, MeasureClient { /** * @public */ -export function getClient (): TxOperations & MeasureClient { +export function getClient (): TxOperations & MeasureClient & OptimisticTxes { return client } diff --git a/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte b/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte index 4c3931157e..fb77e490c8 100644 --- a/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte +++ b/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte @@ -49,6 +49,8 @@ export let hideFooter = false export let skipLabel = false export let hoverable = true + export let pending = false + export let stale = false export let hoverStyles: 'borderedHover' | 'filledHover' = 'borderedHover' export let showDatePreposition = false export let type: ActivityMessageViewType = 'default' @@ -158,6 +160,7 @@ class:actionsOpened={isActionsOpened} class:borderedHover={hoverStyles === 'borderedHover'} class:filledHover={hoverStyles === 'filledHover'} + class:stale on:click={onClick} on:contextmenu={handleContextMenu} > @@ -226,7 +229,7 @@ {#if withActions && !readonly} -
+
.actions { - visibility: visible; + &:not(.pending) { + visibility: visible; + } } &:hover > .time { @@ -324,6 +329,10 @@ } } } + + &.stale { + opacity: 0.5; + } } .header { diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte index 46cd5774a4..c6e0ebc752 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -100,8 +100,11 @@ } async function onMessage (event: CustomEvent) { - loading = true - const doneOp = await getClient().measure(`chunter.create.${_class} ${object._class}`) + if (chatMessage) { + loading = true + } // for new messages we use instant txes + + const doneOp = getClient().measure(`chunter.create.${_class} ${object._class}`) try { draftController.remove() inputRef.removeDraft(false) @@ -116,11 +119,11 @@ currentMessage = getDefault() _id = currentMessage._id const d1 = Date.now() - void doneOp().then((res) => { + void (await doneOp)().then((res) => { console.log(`create.${_class} measure`, res, Date.now() - d1) }) } catch (err: any) { - void doneOp() + void (await doneOp)() Analytics.handleError(err) console.error(err) } diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte index 9978500574..af641c4925 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte @@ -54,8 +54,9 @@ export let onClick: (() => void) | undefined = undefined const client = getClient() + const { pendingCreatedDocs } = client const hierarchy = client.getHierarchy() - + const STALE_TIMEOUT_MS = 5000 const userQuery = createQuery() const currentAccount = getCurrentAccount() @@ -103,6 +104,21 @@ $: links = showLinksPreview ? getLinks(value?.message) : [] + let stale = false + let markStaleId: NodeJS.Timeout | undefined + $: pending = value?._id !== undefined && $pendingCreatedDocs[value._id] + $: if (pending) { + markStaleId = setTimeout(() => { + stale = true + }, STALE_TIMEOUT_MS) + } else { + if (markStaleId !== undefined) { + clearTimeout(markStaleId) + markStaleId = undefined + } + stale = false + } + function getLinks (content?: string): HTMLLinkElement[] { if (!content) { return [] @@ -185,6 +201,8 @@ {hoverable} {hoverStyles} {skipLabel} + {pending} + {stale} showDatePreposition={hideLink} {type} {onClick}