4315038346
* Refactor MessageActionBar into MVVM ActionBarView * Adding tooltips for menu items and correct i18n strings * Layout changes * Renaming some properties * Rename property * Create a first version of the view model and refactor media visibility logic * Refactor view to take options and rections menu as optional properties * Cleaner interface between view and view model * Refactor view properties and replace Menu and MenuItem * Bugfixes and switching to ActionBarView instead of MessageActionBar in element-web * Avoid creating view models and render toolbar until it is actually shown * Added unit and playwright tests and documented the view * Added view model unit tests and updated snapshots of dependant tests * Remove unused components and unnecessary css * Remove unused language tags * Fix for handling join-rules correctly * Prettier * Add handling of stale view model in async calls * Prettier * Split the element-web css into two different. One for legacy components and one for the ActionBarView * Missing variables used for linting * Fix for showing ActionBarView when using keyboard for navigation * Handle visibility on context menu closing * ThreadPanel uses the ActionBarView so restore css rule * Fix for visibility of the ActionBarView in Thread panel * Fix for ActionBarVuew visibility when closing right-click context menu and not still hovering * Add roving index to function as a toolbar * Adjust the RoomView test to send hover to the EventTile instead of the message text * Fix SonarCloud issues * Fix for SonarCloud issue * Merge fix * Rename mx_LegacyActionBar to mx_ThreadActionBar * Added documentation and simplified join rules * Generalize the ActionBarView and move logic to view model * Add the four new buttons to the ActionBarView * Update view model and tests to use the updated ActionBarView * Refactor element-web to use ActionBarView * Clean up styling in element-web * Clean up and updating snaps and screenshots * Added unit-tests for better coverage * Moving ActionBarView to the correct folder in shared components * Update snaps in element-web * Better documentation in stories * Merge fixes * Updates after review comments * Review comment fixes * Added documentation to view models and updated snaps * Hide button had the wrong label * Replace createRef with useRef
505 lines
20 KiB
TypeScript
505 lines
20 KiB
TypeScript
/*
|
|
* Copyright 2026 Element Creations Ltd.
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
* Please see LICENSE files in the repository root for full details.
|
|
*/
|
|
|
|
import {
|
|
EventStatus,
|
|
EventTimeline,
|
|
EventType,
|
|
MatrixEventEvent,
|
|
M_BEACON_INFO,
|
|
MsgType,
|
|
RelationType,
|
|
RoomStateEvent,
|
|
type MatrixEvent,
|
|
} from "matrix-js-sdk/src/matrix";
|
|
import { logger } from "matrix-js-sdk/src/logger";
|
|
import {
|
|
ActionBarAction,
|
|
BaseViewModel,
|
|
type ActionBarViewActions,
|
|
type ActionBarViewSnapshot,
|
|
} from "@element-hq/web-shared-components";
|
|
|
|
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
|
import defaultDispatcher from "../../dispatcher/dispatcher";
|
|
import { Action } from "../../dispatcher/actions";
|
|
import { type ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
|
|
import { type GetRelationsForEvent } from "../../components/views/rooms/EventTile";
|
|
import { canCancel, canEditContent, editEvent, isContentActionable } from "../../utils/EventUtils";
|
|
import { TimelineRenderingType } from "../../contexts/RoomContext";
|
|
import Resend from "../../Resend";
|
|
import PinningUtils from "../../utils/PinningUtils";
|
|
import PosthogTrackers from "../../PosthogTrackers";
|
|
import { shouldDisplayReply } from "../../utils/Reply";
|
|
import { MediaEventHelper } from "../../utils/MediaEventHelper";
|
|
import SettingsStore from "../../settings/SettingsStore";
|
|
import { type SettingKey } from "../../settings/Settings";
|
|
import { getMediaVisibility, setMediaVisibility } from "../../utils/media/mediaVisibility";
|
|
import { FileDownloader } from "../../utils/FileDownloader";
|
|
import { _t } from "../../languageHandler";
|
|
import Modal from "../../Modal";
|
|
import ErrorDialog from "../../components/views/dialogs/ErrorDialog";
|
|
import { ModuleApi } from "../../modules/Api";
|
|
|
|
/** Props for the event-tile action bar view model. */
|
|
export interface EventTileActionBarViewModelProps {
|
|
/** The event whose available actions are being resolved. */
|
|
mxEvent: MatrixEvent;
|
|
/** The timeline context the event is rendered within. */
|
|
timelineRenderingType: TimelineRenderingType;
|
|
/** Whether the current user can send message-based actions such as reply. */
|
|
canSendMessages: boolean;
|
|
/** Whether the current user can react to the event. */
|
|
canReact: boolean;
|
|
/** Whether the tile is being rendered in search results. */
|
|
isSearch?: boolean;
|
|
/** Whether the tile is being rendered inside a card-style surface. */
|
|
isCard?: boolean;
|
|
/** Whether the quoted reply chain is currently expanded. */
|
|
isQuoteExpanded?: boolean;
|
|
/** Called when the overflow options action is activated. */
|
|
onOptionsClick?: (anchor: HTMLElement | null) => void;
|
|
/** Called when the reactions action is activated. */
|
|
onReactionsClick?: (anchor: HTMLElement | null) => void;
|
|
/** Provides relations needed for editing when available. */
|
|
getRelationsForEvent?: GetRelationsForEvent;
|
|
/** Called when the expand or collapse thread action is activated. */
|
|
onToggleThreadExpanded?: (anchor: HTMLElement | null) => void;
|
|
}
|
|
|
|
interface LocalActionBarState {
|
|
canDownload: boolean;
|
|
isDownloadLoading: boolean;
|
|
}
|
|
|
|
interface DerivedEventState {
|
|
showCancel: boolean;
|
|
showEdit: boolean;
|
|
showPinOrUnpin: boolean;
|
|
showReact: boolean;
|
|
showReply: boolean;
|
|
showExpandCollapse: boolean;
|
|
showReplyInThread: boolean;
|
|
showThreadForDeletedMessage: boolean;
|
|
isFailed: boolean;
|
|
isPinned: boolean;
|
|
isQuoteExpanded: boolean;
|
|
isThreadReplyAllowed: boolean;
|
|
}
|
|
|
|
interface DerivedMediaState {
|
|
showHide: boolean;
|
|
showDownload: boolean;
|
|
isDownloadEncrypted: boolean;
|
|
isDownloadLoading: boolean;
|
|
}
|
|
|
|
/** View model for the timeline event action bar shown on event tiles. */
|
|
export class EventTileActionBarViewModel
|
|
extends BaseViewModel<ActionBarViewSnapshot, EventTileActionBarViewModelProps>
|
|
implements ActionBarViewActions
|
|
{
|
|
private listenerCleanups: Array<() => void> = [];
|
|
private downloadPermissionRequestId = 0;
|
|
private downloadRequestId = 0;
|
|
private canDownload = true;
|
|
private isDownloadLoading = false;
|
|
private readonly downloader = new FileDownloader();
|
|
private downloadedBlob?: Blob;
|
|
|
|
public constructor(props: EventTileActionBarViewModelProps) {
|
|
super(
|
|
props,
|
|
EventTileActionBarViewModel.buildSnapshot(props, {
|
|
canDownload: true,
|
|
isDownloadLoading: false,
|
|
}),
|
|
);
|
|
this.setupListeners();
|
|
}
|
|
|
|
private static buildSnapshot(
|
|
props: EventTileActionBarViewModelProps,
|
|
localState: LocalActionBarState,
|
|
): ActionBarViewSnapshot {
|
|
const client = MatrixClientPeg.safeGet();
|
|
const eventState = EventTileActionBarViewModel.getDerivedEventState(props, client);
|
|
const mediaState = EventTileActionBarViewModel.getDerivedMediaState(props.mxEvent, client, localState);
|
|
|
|
return {
|
|
actions: EventTileActionBarViewModel.resolveActions(eventState, mediaState),
|
|
presentation: "icon",
|
|
isDownloadEncrypted: mediaState.isDownloadEncrypted,
|
|
isDownloadLoading: mediaState.isDownloadLoading,
|
|
isPinned: eventState.isPinned,
|
|
isQuoteExpanded: eventState.isQuoteExpanded,
|
|
isThreadReplyAllowed: eventState.isThreadReplyAllowed,
|
|
};
|
|
}
|
|
|
|
private static resolveActions(eventState: DerivedEventState, mediaState: DerivedMediaState): ActionBarAction[] {
|
|
const actions: ActionBarAction[] = [];
|
|
|
|
if (eventState.showCancel && eventState.isFailed) {
|
|
return [ActionBarAction.Resend, ActionBarAction.Cancel];
|
|
}
|
|
|
|
if (mediaState.showHide) {
|
|
actions.push(ActionBarAction.Hide);
|
|
}
|
|
if (mediaState.showDownload) {
|
|
actions.push(ActionBarAction.Download);
|
|
}
|
|
if (eventState.showReact) {
|
|
actions.push(ActionBarAction.React);
|
|
}
|
|
if (!eventState.showReply && eventState.showThreadForDeletedMessage) {
|
|
actions.push(ActionBarAction.ReplyInThread);
|
|
}
|
|
if (eventState.showReply) {
|
|
actions.push(ActionBarAction.Reply);
|
|
}
|
|
if (eventState.showReply && eventState.showReplyInThread) {
|
|
actions.push(ActionBarAction.ReplyInThread);
|
|
}
|
|
if (eventState.showEdit) {
|
|
actions.push(ActionBarAction.Edit);
|
|
}
|
|
if (eventState.showPinOrUnpin) {
|
|
actions.push(ActionBarAction.Pin);
|
|
}
|
|
if (eventState.showCancel) {
|
|
actions.push(ActionBarAction.Cancel);
|
|
}
|
|
if (eventState.showExpandCollapse) {
|
|
actions.push(ActionBarAction.Expand);
|
|
}
|
|
|
|
actions.push(ActionBarAction.Options);
|
|
|
|
return actions;
|
|
}
|
|
|
|
private static getDerivedEventState(
|
|
props: EventTileActionBarViewModelProps,
|
|
client: ReturnType<typeof MatrixClientPeg.safeGet>,
|
|
): DerivedEventState {
|
|
const { mxEvent } = props;
|
|
const contentActionable = isContentActionable(mxEvent);
|
|
const editStatus = mxEvent.replacingEvent()?.status;
|
|
const redactStatus = mxEvent.localRedactionEvent()?.status;
|
|
const relationType = mxEvent.getRelation()?.rel_type;
|
|
|
|
return {
|
|
showCancel: canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus),
|
|
showEdit: canEditContent(client, mxEvent),
|
|
showPinOrUnpin: PinningUtils.canPin(client, mxEvent) || PinningUtils.canUnpin(client, mxEvent),
|
|
showReact: contentActionable && props.canReact && !props.isSearch,
|
|
showReply: contentActionable && props.canSendMessages,
|
|
isThreadReplyAllowed: !(!!relationType && relationType !== RelationType.Thread),
|
|
showExpandCollapse: props.isQuoteExpanded !== undefined && shouldDisplayReply(mxEvent),
|
|
showReplyInThread: contentActionable && EventTileActionBarViewModel.canShowReplyInThreadAction(props),
|
|
showThreadForDeletedMessage:
|
|
!contentActionable &&
|
|
props.timelineRenderingType === TimelineRenderingType.Room &&
|
|
Boolean(mxEvent.getThread()),
|
|
isFailed: [mxEvent.status, editStatus, redactStatus].includes(EventStatus.NOT_SENT),
|
|
isPinned: PinningUtils.isPinned(client, mxEvent),
|
|
isQuoteExpanded: props.isQuoteExpanded ?? false,
|
|
};
|
|
}
|
|
|
|
private static getDerivedMediaState(
|
|
mxEvent: MatrixEvent,
|
|
client: ReturnType<typeof MatrixClientPeg.safeGet>,
|
|
localState: LocalActionBarState,
|
|
): DerivedMediaState {
|
|
const contentActionable = isContentActionable(mxEvent);
|
|
const mediaHelper = MediaEventHelper.isEligible(mxEvent) ? new MediaEventHelper(mxEvent) : undefined;
|
|
|
|
return {
|
|
showDownload: contentActionable && Boolean(mediaHelper) && localState.canDownload,
|
|
showHide: contentActionable && MediaEventHelper.canHide(mxEvent) && getMediaVisibility(mxEvent, client),
|
|
isDownloadEncrypted: mediaHelper?.media.isEncrypted ?? false,
|
|
isDownloadLoading: localState.isDownloadLoading,
|
|
};
|
|
}
|
|
|
|
private computeSnapshot(): ActionBarViewSnapshot {
|
|
return EventTileActionBarViewModel.buildSnapshot(this.props, {
|
|
canDownload: this.canDownload,
|
|
isDownloadLoading: this.isDownloadLoading,
|
|
});
|
|
}
|
|
|
|
private static canShowReplyInThreadAction(props: EventTileActionBarViewModelProps): boolean {
|
|
const inNotThreadTimeline = props.timelineRenderingType !== TimelineRenderingType.Thread;
|
|
const content = props.mxEvent.getContent();
|
|
const isAllowedMessageType =
|
|
![MsgType.KeyVerificationRequest].includes(content.msgtype as MsgType) &&
|
|
!M_BEACON_INFO.matches(props.mxEvent.getType());
|
|
|
|
return inNotThreadTimeline && isAllowedMessageType;
|
|
}
|
|
|
|
private setupListeners(): void {
|
|
this.teardownListeners();
|
|
|
|
const { mxEvent } = this.props;
|
|
const roomId = mxEvent.getRoomId();
|
|
this.trackEvent(mxEvent, MatrixEventEvent.Status, this.refreshSnapshot);
|
|
this.trackEvent(mxEvent, MatrixEventEvent.Decrypted, this.refreshSnapshot);
|
|
this.trackEvent(mxEvent, MatrixEventEvent.BeforeRedaction, this.refreshSnapshot);
|
|
this.watchSetting("mediaPreviewConfig", roomId ?? null);
|
|
this.watchSetting("showMediaEventIds", null);
|
|
|
|
const roomState = roomId
|
|
? MatrixClientPeg.safeGet().getRoom(roomId)?.getLiveTimeline().getState(EventTimeline.FORWARDS)
|
|
: undefined;
|
|
if (roomState) {
|
|
roomState.on(RoomStateEvent.Events, this.onRoomEvent);
|
|
this.addListenerCleanup(() => roomState.off(RoomStateEvent.Events, this.onRoomEvent));
|
|
}
|
|
|
|
MatrixClientPeg.safeGet().decryptEventIfNeeded(mxEvent);
|
|
void this.updateDownloadPermission(++this.downloadPermissionRequestId);
|
|
}
|
|
|
|
private teardownListeners(): void {
|
|
for (const cleanup of this.listenerCleanups) {
|
|
cleanup();
|
|
}
|
|
this.listenerCleanups = [];
|
|
}
|
|
|
|
private addListenerCleanup(cleanup: () => void): void {
|
|
this.listenerCleanups.push(cleanup);
|
|
}
|
|
|
|
private trackEvent(event: MatrixEvent, eventName: MatrixEventEvent, callback: (...args: unknown[]) => void): void {
|
|
event.on(eventName, callback);
|
|
this.addListenerCleanup(() => event.off(eventName, callback));
|
|
}
|
|
|
|
private watchSetting(settingName: SettingKey, roomId: string | null): void {
|
|
const watcherRef = SettingsStore.watchSetting(settingName, roomId, this.refreshSnapshot);
|
|
this.addListenerCleanup(() => SettingsStore.unwatchSetting(watcherRef));
|
|
}
|
|
|
|
private readonly refreshSnapshot = (): void => {
|
|
this.snapshot.merge(this.computeSnapshot());
|
|
};
|
|
|
|
private resetEventState(): void {
|
|
this.downloadedBlob = undefined;
|
|
this.canDownload = true;
|
|
this.isDownloadLoading = false;
|
|
}
|
|
|
|
private isCurrentDownloadPermissionRequest(requestId: number, mxEvent: MatrixEvent): boolean {
|
|
return !this.isDisposed && requestId === this.downloadPermissionRequestId && this.props.mxEvent === mxEvent;
|
|
}
|
|
|
|
private updateDownloadPermissionState(requestId: number, mxEvent: MatrixEvent, canDownload: boolean): boolean {
|
|
if (!this.isCurrentDownloadPermissionRequest(requestId, mxEvent)) return false;
|
|
this.canDownload = canDownload;
|
|
this.refreshSnapshot();
|
|
return true;
|
|
}
|
|
|
|
private async updateDownloadPermission(requestId: number): Promise<void> {
|
|
const { mxEvent } = this.props;
|
|
const hints = ModuleApi.instance.customComponents.getHintsForMessage(mxEvent);
|
|
|
|
if (!hints?.allowDownloadingMedia) {
|
|
this.updateDownloadPermissionState(requestId, mxEvent, true);
|
|
return;
|
|
}
|
|
|
|
if (!this.updateDownloadPermissionState(requestId, mxEvent, false)) return;
|
|
|
|
try {
|
|
const canDownload = await hints.allowDownloadingMedia();
|
|
this.updateDownloadPermissionState(requestId, mxEvent, canDownload);
|
|
} catch (err) {
|
|
logger.error(`Failed to check media download permission for ${mxEvent.getId()}`, err);
|
|
this.updateDownloadPermissionState(requestId, mxEvent, false);
|
|
}
|
|
}
|
|
|
|
private isCurrentDownloadRequest(requestId: number, mxEvent: MatrixEvent): boolean {
|
|
return !this.isDisposed && requestId === this.downloadRequestId && this.props.mxEvent === mxEvent;
|
|
}
|
|
|
|
private setDownloadLoading(requestId: number, mxEvent: MatrixEvent, isDownloadLoading: boolean): boolean {
|
|
if (!this.isCurrentDownloadRequest(requestId, mxEvent)) return false;
|
|
this.isDownloadLoading = isDownloadLoading;
|
|
this.refreshSnapshot();
|
|
return true;
|
|
}
|
|
|
|
private readonly onRoomEvent = (event?: MatrixEvent): void => {
|
|
if (!event) return;
|
|
if (event.getType() !== EventType.RoomPinnedEvents && event.getType() !== EventType.RoomJoinRules) return;
|
|
this.refreshSnapshot();
|
|
};
|
|
|
|
/**
|
|
* Runs an action against the failed event variant that is still actionable.
|
|
*/
|
|
private runActionOnFailedEv(fn: (ev: MatrixEvent) => void, checkFn?: (ev: MatrixEvent) => boolean): void {
|
|
const shouldUseEvent = checkFn ?? (() => true);
|
|
const { mxEvent } = this.props;
|
|
const tryOrder = [mxEvent.localRedactionEvent(), mxEvent.replacingEvent(), mxEvent];
|
|
|
|
for (const event of tryOrder) {
|
|
if (event && shouldUseEvent(event)) {
|
|
fn(event);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Updates props, refreshes listeners when the event changes, and rebuilds the snapshot. */
|
|
public setProps(newProps: Partial<EventTileActionBarViewModelProps>): void {
|
|
const prevEvent = this.props.mxEvent;
|
|
const prevRoomId = prevEvent.getRoomId();
|
|
|
|
this.props = {
|
|
...this.props,
|
|
...newProps,
|
|
};
|
|
|
|
if (this.props.mxEvent !== prevEvent || this.props.mxEvent.getRoomId() !== prevRoomId) {
|
|
this.resetEventState();
|
|
this.setupListeners();
|
|
}
|
|
|
|
this.refreshSnapshot();
|
|
}
|
|
|
|
/** Removes listeners and releases resources owned by the view model. */
|
|
public override dispose(): void {
|
|
this.teardownListeners();
|
|
super.dispose();
|
|
}
|
|
|
|
/** Starts a reply to the current event. */
|
|
public onReplyClick = (_anchor: HTMLElement | null): void => {
|
|
defaultDispatcher.dispatch({
|
|
action: "reply_to_event",
|
|
event: this.props.mxEvent,
|
|
context: this.props.timelineRenderingType,
|
|
});
|
|
};
|
|
|
|
/** Opens the edit composer for the current event. */
|
|
public onEditClick = (_anchor: HTMLElement | null): void => {
|
|
editEvent(
|
|
MatrixClientPeg.safeGet(),
|
|
this.props.mxEvent,
|
|
this.props.timelineRenderingType,
|
|
this.props.getRelationsForEvent,
|
|
);
|
|
};
|
|
|
|
/** Retries sending the failed event variant that is still actionable. */
|
|
public onResendClick = (_anchor: HTMLElement | null): void => {
|
|
this.runActionOnFailedEv((event) => Resend.resend(MatrixClientPeg.safeGet(), event));
|
|
};
|
|
|
|
/** Cancels the failed event variant that is still cancellable. */
|
|
public onCancelClick = (_anchor: HTMLElement | null): void => {
|
|
this.runActionOnFailedEv(
|
|
(event) => Resend.removeFromQueue(MatrixClientPeg.safeGet(), event),
|
|
(event) => canCancel(event.status),
|
|
);
|
|
};
|
|
|
|
/** Pins or unpins the current event. */
|
|
public onPinClick = async (_anchor: HTMLElement | null): Promise<void> => {
|
|
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
|
await PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
|
PosthogTrackers.trackPinUnpinMessage(isPinned ? "Pin" : "Unpin", "Timeline");
|
|
};
|
|
|
|
/** Downloads the media content for the current event when available. */
|
|
public onDownloadClick = async (_anchor: HTMLElement | null): Promise<void> => {
|
|
if (this.isDownloadLoading || !this.canDownload) return;
|
|
const requestId = ++this.downloadRequestId;
|
|
const { mxEvent } = this.props;
|
|
|
|
try {
|
|
if (!this.setDownloadLoading(requestId, mxEvent, true)) return;
|
|
const mediaEventHelper = new MediaEventHelper(mxEvent);
|
|
|
|
if (!this.downloadedBlob) {
|
|
const downloadedBlob = await mediaEventHelper.sourceBlob.value;
|
|
if (!this.isCurrentDownloadRequest(requestId, mxEvent)) return;
|
|
this.downloadedBlob = downloadedBlob;
|
|
}
|
|
|
|
await this.downloader.download({
|
|
blob: this.downloadedBlob,
|
|
name: mediaEventHelper.fileName ?? _t("common|image"),
|
|
});
|
|
} catch (e) {
|
|
if (!this.isCurrentDownloadRequest(requestId, mxEvent)) return;
|
|
Modal.createDialog(ErrorDialog, {
|
|
title: _t("timeline|download_failed"),
|
|
description: `${_t("timeline|download_failed_description")}\n\n${String(e)}`,
|
|
});
|
|
} finally {
|
|
this.setDownloadLoading(requestId, mxEvent, false);
|
|
}
|
|
};
|
|
|
|
/** Hides the media preview for the current event. */
|
|
public onHideClick = (_anchor: HTMLElement | null): void => {
|
|
void setMediaVisibility(this.props.mxEvent, false);
|
|
};
|
|
|
|
/** Forwards the expand or collapse thread action using the triggering button as the anchor. */
|
|
public onToggleThreadExpanded = (anchor: HTMLElement | null): void => {
|
|
this.props.onToggleThreadExpanded?.(anchor);
|
|
};
|
|
|
|
/** Forwards the overflow options action using the triggering button as the anchor. */
|
|
public onOptionsClick = (anchor: HTMLElement | null): void => {
|
|
this.props.onOptionsClick?.(anchor);
|
|
};
|
|
|
|
/** Forwards the reactions action using the triggering button as the anchor. */
|
|
public onReactionsClick = (anchor: HTMLElement | null): void => {
|
|
this.props.onReactionsClick?.(anchor);
|
|
};
|
|
|
|
/** Opens or starts the thread associated with the current event. */
|
|
public onReplyInThreadClick = (_anchor: HTMLElement | null): void => {
|
|
const { mxEvent, isCard } = this.props;
|
|
const thread = mxEvent.getThread();
|
|
|
|
if (thread?.rootEvent && !mxEvent.isThreadRoot) {
|
|
defaultDispatcher.dispatch<ShowThreadPayload>({
|
|
action: Action.ShowThread,
|
|
rootEvent: thread.rootEvent,
|
|
initialEvent: mxEvent,
|
|
scroll_into_view: true,
|
|
highlighted: true,
|
|
push: isCard,
|
|
});
|
|
return;
|
|
}
|
|
|
|
defaultDispatcher.dispatch<ShowThreadPayload>({
|
|
action: Action.ShowThread,
|
|
rootEvent: mxEvent,
|
|
push: isCard,
|
|
});
|
|
};
|
|
}
|