mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-05 12:16:53 +02:00
WIP
This commit is contained in:
parent
5ad0dceae0
commit
fd9381d417
618
src/components/viewmodels/rooms/EventTileViewModel.tsx
Normal file
618
src/components/viewmodels/rooms/EventTileViewModel.tsx
Normal file
@ -0,0 +1,618 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector 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 {
|
||||
EventType,
|
||||
type Relations,
|
||||
type RelationType,
|
||||
type MatrixEvent,
|
||||
type RoomMember,
|
||||
type MatrixClient,
|
||||
EventStatus,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
|
||||
import { Layout } from "../../../settings/enums/Layout";
|
||||
import { ElementCall } from "../../../models/Call";
|
||||
import type { IReadReceiptPosition } from "../../views/rooms/ReadReceiptMarker";
|
||||
import type EditorStateTransfer from "../../../utils/EditorStateTransfer";
|
||||
import type { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import type LegacyCallEventGrouper from "../../structures/LegacyCallEventGrouper";
|
||||
import type { EventTileViewState } from "../../views/rooms/EventTileView";
|
||||
import type { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import type ReplyChain from "../../views/elements/ReplyChain";
|
||||
import { isEligibleForSpecialReceipt, type IEventTileType } from "../../views/rooms/EventTile";
|
||||
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
||||
import PinningUtils from "../../../utils/PinningUtils";
|
||||
import { isMessageEvent } from "../../../events/EventTileFactory";
|
||||
import { getSelectedText } from "../../../utils/strings";
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { formatTime } from "../../../DateUtils";
|
||||
|
||||
export interface IReadReceiptProps {
|
||||
userId: string;
|
||||
roomMember: RoomMember | null;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export type GetRelationsForEvent = (
|
||||
eventId: string,
|
||||
relationType: RelationType | string,
|
||||
eventType: EventType | string,
|
||||
) => Relations | null | undefined;
|
||||
|
||||
export interface EventTileViewModelProps {
|
||||
// the MatrixEvent to show
|
||||
mxEvent: MatrixEvent;
|
||||
|
||||
// true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted()
|
||||
// might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent
|
||||
// references the same this.props.mxEvent.
|
||||
isRedacted?: boolean;
|
||||
|
||||
// true if this is a continuation of the previous event (which has the
|
||||
// effect of not showing another avatar/displayname
|
||||
isContinuation?: boolean;
|
||||
|
||||
// true if this is the last event in the timeline (which has the effect
|
||||
// of always showing the timestamp)
|
||||
last?: boolean;
|
||||
|
||||
// true if the event is the last event in a section (adds a css class for
|
||||
// targeting)
|
||||
lastInSection?: boolean;
|
||||
|
||||
// True if the event is the last successful (sent) event.
|
||||
lastSuccessful?: boolean;
|
||||
|
||||
// true if this is search context (which has the effect of greying out
|
||||
// the text
|
||||
contextual?: boolean;
|
||||
|
||||
// a list of words to highlight, ordered by longest first
|
||||
highlights?: string[];
|
||||
|
||||
// link URL for the highlights
|
||||
highlightLink?: string;
|
||||
|
||||
// should show URL previews for this event
|
||||
showUrlPreview?: boolean;
|
||||
|
||||
// is this the focused event
|
||||
isSelectedEvent?: boolean;
|
||||
|
||||
resizeObserver?: ResizeObserver;
|
||||
|
||||
// a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'.
|
||||
readReceipts?: IReadReceiptProps[];
|
||||
|
||||
// opaque readreceipt info for each userId; used by ReadReceiptMarker
|
||||
// to manage its animations. Should be an empty object when the room
|
||||
// first loads
|
||||
readReceiptMap?: { [userId: string]: IReadReceiptPosition };
|
||||
|
||||
// A function which is used to check if the parent panel is being
|
||||
// unmounted, to avoid unnecessary work. Should return true if we
|
||||
// are being unmounted.
|
||||
checkUnmounting?: () => boolean;
|
||||
|
||||
// the status of this event - ie, mxEvent.status. Denormalised to here so
|
||||
// that we can tell when it changes.
|
||||
eventSendStatus?: string;
|
||||
|
||||
forExport?: boolean;
|
||||
|
||||
// show twelve hour timestamps
|
||||
isTwelveHour?: boolean;
|
||||
|
||||
// helper function to access relations for this event
|
||||
getRelationsForEvent?: GetRelationsForEvent;
|
||||
|
||||
// whether to show reactions for this event
|
||||
showReactions?: boolean;
|
||||
|
||||
// which layout to use
|
||||
layout?: Layout;
|
||||
|
||||
// whether or not to show read receipts
|
||||
showReadReceipts?: boolean;
|
||||
|
||||
// Used while editing, to pass the event, and to preserve editor state
|
||||
// from one editor instance to another when remounting the editor
|
||||
// upon receiving the remote echo for an unsent event.
|
||||
editState?: EditorStateTransfer;
|
||||
|
||||
// Event ID of the event replacing the content of this event, if any
|
||||
replacingEventId?: string;
|
||||
|
||||
// Helper to build permalinks for the room
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
|
||||
// LegacyCallEventGrouper for this event
|
||||
callEventGrouper?: LegacyCallEventGrouper;
|
||||
|
||||
// Symbol of the root node
|
||||
as?: string;
|
||||
|
||||
// whether or not to always show timestamps
|
||||
alwaysShowTimestamps?: boolean;
|
||||
|
||||
// whether or not to display the sender
|
||||
hideSender?: boolean;
|
||||
|
||||
// whether or not to display thread info
|
||||
showThreadInfo?: boolean;
|
||||
|
||||
// if specified and `true`, the message is being
|
||||
// hidden for moderation from other users but is
|
||||
// displayed to the current user either because they're
|
||||
// the author or they are a moderator
|
||||
isSeeingThroughMessageHiddenForModeration?: boolean;
|
||||
|
||||
// The following properties are used by EventTilePreview to disable tab indexes within the event tile
|
||||
hideTimestamp?: boolean;
|
||||
inhibitInteraction?: boolean;
|
||||
|
||||
// ref?: Ref<UnwrappedEventTile>;
|
||||
|
||||
timelineRenderingType: TimelineRenderingType;
|
||||
showHiddenEvents: boolean;
|
||||
cli: MatrixClient;
|
||||
}
|
||||
|
||||
function getMemberFromEvent(mxEvent: MatrixEvent): RoomMember | null {
|
||||
// set member to receiver (target) if it is a 3PID invite
|
||||
// so that the correct avatar is shown as the text is
|
||||
// `$target accepted the invitation for $email`
|
||||
if (mxEvent.getContent().third_party_invite) {
|
||||
return mxEvent.target;
|
||||
} else {
|
||||
return mxEvent.sender;
|
||||
}
|
||||
}
|
||||
|
||||
function calculateAvatarSize(
|
||||
mxEvent: MatrixEvent,
|
||||
info: ReturnType<typeof getEventDisplayInfo>,
|
||||
timelineRenderingType: TimelineRenderingType,
|
||||
showHiddenEvents: boolean,
|
||||
isContinuation: boolean | undefined,
|
||||
layout: Layout | undefined,
|
||||
): { avatarSize: string | null; needsSenderProfile: boolean } {
|
||||
const eventType = mxEvent.getType();
|
||||
const isRenderingNotification = timelineRenderingType === TimelineRenderingType.Notification;
|
||||
if (isRenderingNotification) {
|
||||
return { avatarSize: "24px", needsSenderProfile: true };
|
||||
} else if (info.isInfoMessage) {
|
||||
// a small avatar, with no sender profile, for
|
||||
// joins/parts/etc
|
||||
return { avatarSize: "14px", needsSenderProfile: false };
|
||||
} else if (
|
||||
timelineRenderingType === TimelineRenderingType.ThreadsList ||
|
||||
(timelineRenderingType === TimelineRenderingType.Thread && !isContinuation)
|
||||
) {
|
||||
return { avatarSize: "32px", needsSenderProfile: true };
|
||||
} else if (eventType === EventType.RoomCreate || info.isBubbleMessage) {
|
||||
return { avatarSize: null, needsSenderProfile: false };
|
||||
} else if (layout === Layout.IRC) {
|
||||
return { avatarSize: "14px", needsSenderProfile: true };
|
||||
} else if (
|
||||
(isContinuation && timelineRenderingType !== TimelineRenderingType.File) ||
|
||||
eventType === EventType.CallInvite ||
|
||||
ElementCall.CALL_EVENT_TYPE.matches(eventType)
|
||||
) {
|
||||
// no avatar or sender profile for continuation messages and call tiles
|
||||
return { avatarSize: null, needsSenderProfile: false };
|
||||
} else if (timelineRenderingType === TimelineRenderingType.File) {
|
||||
return { avatarSize: "20px", needsSenderProfile: true };
|
||||
} else {
|
||||
return { avatarSize: "30px", needsSenderProfile: true };
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class ViewModel<Props, ViewState> {
|
||||
private updates: CallableFunction[] = [];
|
||||
protected state: ViewState;
|
||||
|
||||
public constructor(protected props: Props) {
|
||||
this.state = this.generateInitialState();
|
||||
}
|
||||
|
||||
public getSnapshot = (): ViewState => {
|
||||
return this.state;
|
||||
};
|
||||
|
||||
public subscribe = (update: CallableFunction): (() => void) => {
|
||||
this.updates = [...this.updates, update];
|
||||
return () => {
|
||||
this.updates = this.updates.filter((u) => u !== update);
|
||||
this.destroy();
|
||||
};
|
||||
};
|
||||
|
||||
public get viewState(): ViewState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
protected setState(newState: ViewState): void {
|
||||
this.state = newState;
|
||||
for (const update of this.updates) {
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract generateInitialState(): ViewState;
|
||||
|
||||
// should be called by react hook
|
||||
public onComponentMounted(): void {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
|
||||
export class EventTileViewModel extends ViewModel<EventTileViewModelProps, EventTileViewState> {
|
||||
private tileRef = new TrackedRef<IEventTileType>();
|
||||
private replyChainRef = new TrackedRef<ReplyChain>();
|
||||
private actionBarFocused = false;
|
||||
private isQuoteExpanded = false;
|
||||
private suppressReadReceiptAnimation = true;
|
||||
private hover = false;
|
||||
private contextMenu: EventTileViewState["contextMenu"];
|
||||
private reactions?: Relations | null;
|
||||
|
||||
public constructor(protected props: EventTileViewModelProps) {
|
||||
super({ ...props, isContinuation: props.isContinuation ?? false });
|
||||
}
|
||||
|
||||
public onComponentMounted(): void {
|
||||
// todo: shouldn't this actually emit?
|
||||
this.suppressReadReceiptAnimation = false;
|
||||
}
|
||||
|
||||
protected generateInitialState(): EventTileViewState {
|
||||
return this.generateState();
|
||||
}
|
||||
|
||||
/**
|
||||
* In some cases we can't use shouldHideEvent() since whether or not we hide
|
||||
* an event depends on other things that the event itself
|
||||
* @returns {boolean} true if event should be hidden
|
||||
*/
|
||||
private shouldHideEvent(): boolean {
|
||||
// If the call was replaced we don't render anything since we render the other call
|
||||
if (this.props.callEventGrouper?.hangupReason === CallErrorCode.Replaced) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private onSenderProfileClick(): void {
|
||||
dis.dispatch<ComposerInsertPayload>({
|
||||
action: Action.ComposerInsert,
|
||||
userId: this.props.mxEvent.getSender()!,
|
||||
timelineRenderingType: this.props.timelineRenderingType,
|
||||
});
|
||||
}
|
||||
|
||||
private getTile(): IEventTileType | null {
|
||||
return this.tileRef.current;
|
||||
}
|
||||
|
||||
private getReplyChain(): ReplyChain | null {
|
||||
return this.replyChainRef.current;
|
||||
}
|
||||
|
||||
private onActionBarFocusChange(actionBarFocused: boolean): void {
|
||||
this.actionBarFocused = actionBarFocused;
|
||||
this.setState({ ...this.viewState, actionBarFocused: this.actionBarFocused });
|
||||
}
|
||||
|
||||
private setQuoteExpanded(expanded: boolean): void {
|
||||
this.isQuoteExpanded = expanded;
|
||||
this.setState({ ...this.viewState, isQuoteExpanded: this.isQuoteExpanded });
|
||||
}
|
||||
|
||||
private onTimestampContextMenu = (ev: React.MouseEvent): void => {
|
||||
this.showContextMenu(ev, this.props.permalinkCreator?.forEvent(this.props.mxEvent.getId()!));
|
||||
};
|
||||
|
||||
private showContextMenu(ev: React.MouseEvent, permalink?: string): void {
|
||||
const clickTarget = ev.target as HTMLElement;
|
||||
|
||||
// Try to find an anchor element
|
||||
const anchorElement = clickTarget instanceof HTMLAnchorElement ? clickTarget : clickTarget.closest("a");
|
||||
|
||||
// There is no way to copy non-PNG images into clipboard, so we can't
|
||||
// have our own handling for copying images, so we leave it to the
|
||||
// Electron layer (webcontents-handler.ts)
|
||||
if (clickTarget instanceof HTMLImageElement) return;
|
||||
|
||||
// Return if we're in a browser and click either an a tag or we have
|
||||
// selected text, as in those cases we want to use the native browser
|
||||
// menu
|
||||
if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && (getSelectedText() || anchorElement)) return;
|
||||
|
||||
// We don't want to show the menu when editing a message
|
||||
if (this.props.editState) return;
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.contextMenu = {
|
||||
position: {
|
||||
left: ev.clientX,
|
||||
top: ev.clientY,
|
||||
bottom: ev.clientY,
|
||||
},
|
||||
link: anchorElement?.href || permalink,
|
||||
};
|
||||
this.actionBarFocused = true;
|
||||
this.setState({
|
||||
...this.state,
|
||||
contextMenu: {
|
||||
position: {
|
||||
left: ev.clientX,
|
||||
top: ev.clientY,
|
||||
bottom: ev.clientY,
|
||||
},
|
||||
link: anchorElement?.href || permalink,
|
||||
},
|
||||
actionBarFocused: true,
|
||||
});
|
||||
}
|
||||
|
||||
private onPermalinkClicked = (e: React.MouseEvent): void => {
|
||||
// This allows the permalink to be opened in a new tab/window or copied as
|
||||
// matrix.to, but also for it to enable routing within Element when clicked.
|
||||
e.preventDefault();
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
event_id: this.props.mxEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.mxEvent.getRoomId(),
|
||||
metricsTrigger:
|
||||
this.props.timelineRenderingType === TimelineRenderingType.Search ? "MessageSearch" : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* When true, the tile qualifies for some sort of special read receipt. This could be a 'sending'
|
||||
* or 'sent' receipt, for example.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
private get isEligibleForSpecialReceipt(): boolean {
|
||||
// First, if there are other read receipts then just short-circuit this.
|
||||
if (this.props.readReceipts && this.props.readReceipts.length > 0) return false;
|
||||
if (!this.props.mxEvent) return false;
|
||||
|
||||
// Sanity check (should never happen, but we shouldn't explode if it does)
|
||||
const room = this.props.cli.getRoom(this.props.mxEvent.getRoomId());
|
||||
if (!room) return false;
|
||||
|
||||
// Quickly check to see if the event was sent by us. If it wasn't, it won't qualify for
|
||||
// special read receipts.
|
||||
const myUserId = this.props.cli.getSafeUserId();
|
||||
// Check to see if the event was sent by us. If it wasn't, it won't qualify for special read receipts.
|
||||
if (this.props.mxEvent.getSender() !== myUserId) return false;
|
||||
return isEligibleForSpecialReceipt(this.props.mxEvent);
|
||||
}
|
||||
|
||||
private get shouldShowSentReceipt(): boolean {
|
||||
// If we're not even eligible, don't show the receipt.
|
||||
if (!this.isEligibleForSpecialReceipt) return false;
|
||||
|
||||
// We only show the 'sent' receipt on the last successful event.
|
||||
if (!this.props.lastSuccessful) return false;
|
||||
|
||||
// Check to make sure the sending state is appropriate. A null/undefined send status means
|
||||
// that the message is 'sent', so we're just double checking that it's explicitly not sent.
|
||||
if (this.props.eventSendStatus && this.props.eventSendStatus !== EventStatus.SENT) return false;
|
||||
|
||||
// If anyone has read the event besides us, we don't want to show a sent receipt.
|
||||
const receipts = this.props.readReceipts || [];
|
||||
const myUserId = this.props.cli.getUserId();
|
||||
if (receipts.some((r) => r.userId !== myUserId)) return false;
|
||||
|
||||
// Finally, we should show a receipt.
|
||||
return true;
|
||||
}
|
||||
|
||||
private get shouldShowSendingReceipt(): boolean {
|
||||
// If we're not even eligible, don't show the receipt.
|
||||
if (!this.isEligibleForSpecialReceipt) return false;
|
||||
|
||||
// Check the event send status to see if we are pending. Null/undefined status means the
|
||||
// message was sent, so check for that and 'sent' explicitly.
|
||||
if (!this.props.eventSendStatus || this.props.eventSendStatus === EventStatus.SENT) return false;
|
||||
|
||||
// Default to showing - there's no other event properties/behaviours we care about at
|
||||
// this point.
|
||||
return true;
|
||||
}
|
||||
|
||||
private generateState(): EventTileViewState {
|
||||
const {
|
||||
timelineRenderingType,
|
||||
mxEvent,
|
||||
cli,
|
||||
showHiddenEvents,
|
||||
isContinuation,
|
||||
layout,
|
||||
inhibitInteraction,
|
||||
hideSender,
|
||||
} = this.props;
|
||||
|
||||
const member = getMemberFromEvent(mxEvent);
|
||||
|
||||
// In the ThreadsList view we use the entire EventTile as a click target to open the thread instead
|
||||
const viewUserOnClick =
|
||||
!inhibitInteraction &&
|
||||
![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes(timelineRenderingType);
|
||||
|
||||
const forceHistorical = mxEvent.getType() === EventType.RoomMember;
|
||||
|
||||
const info = getEventDisplayInfo(cli, mxEvent, showHiddenEvents, this.shouldHideEvent());
|
||||
const { avatarSize, needsSenderProfile } = calculateAvatarSize(
|
||||
mxEvent,
|
||||
info,
|
||||
timelineRenderingType,
|
||||
showHiddenEvents,
|
||||
isContinuation,
|
||||
layout,
|
||||
);
|
||||
|
||||
const eventType = mxEvent.getType();
|
||||
const hasNoRenderer = !info.hasRenderer;
|
||||
if (hasNoRenderer) {
|
||||
// This shouldn't happen: the caller should check we support this type
|
||||
// before trying to instantiate us
|
||||
logger.warn(`Event type not supported: type:${eventType} isState:${mxEvent.isState()}`);
|
||||
}
|
||||
|
||||
const shouldRender = needsSenderProfile && hideSender !== true;
|
||||
const senderProfileInfo: EventTileViewState["senderProfileInfo"] = {
|
||||
shouldRender,
|
||||
onClick: undefined,
|
||||
tooltip: undefined,
|
||||
};
|
||||
if (shouldRender) {
|
||||
if (
|
||||
[
|
||||
TimelineRenderingType.Room,
|
||||
TimelineRenderingType.Search,
|
||||
TimelineRenderingType.Pinned,
|
||||
TimelineRenderingType.Thread,
|
||||
].includes(timelineRenderingType)
|
||||
) {
|
||||
senderProfileInfo.onClick = () => this.onSenderProfileClick();
|
||||
} else if (timelineRenderingType === TimelineRenderingType.ThreadsList) {
|
||||
senderProfileInfo.tooltip = true;
|
||||
}
|
||||
}
|
||||
|
||||
const isEditing = !!this.props.editState;
|
||||
const showMessageActionBar = !isEditing && !this.props.forExport;
|
||||
const permalinkCreator = this.props.permalinkCreator;
|
||||
|
||||
// timestamp vm
|
||||
|
||||
// Thread panel shows the timestamp of the last reply in that thread
|
||||
let ts =
|
||||
this.props.timelineRenderingType !== TimelineRenderingType.ThreadsList
|
||||
? this.props.mxEvent.getTs()
|
||||
: this.state.thread?.replyToEvent?.getTs();
|
||||
if (typeof ts !== "number") {
|
||||
// Fall back to something we can use
|
||||
ts = this.props.mxEvent.getTs();
|
||||
}
|
||||
const showTimestamp = Boolean(
|
||||
this.props.mxEvent.getTs() &&
|
||||
!this.props.hideTimestamp &&
|
||||
(this.props.alwaysShowTimestamps ||
|
||||
this.props.last ||
|
||||
this.hover ||
|
||||
this.actionBarFocused ||
|
||||
Boolean(this.contextMenu)) &&
|
||||
ts,
|
||||
);
|
||||
|
||||
const needsPinnedMessageBadge = PinningUtils.isPinned(cli, mxEvent);
|
||||
|
||||
const isRedacted = Boolean(isMessageEvent(this.props.mxEvent) && this.props.isRedacted);
|
||||
|
||||
// If we have reactions or a pinned message badge, we need a footer
|
||||
const needsFooter = Boolean((!isRedacted && this.reactions) || needsPinnedMessageBadge);
|
||||
|
||||
let permalink = "#";
|
||||
if (this.props.permalinkCreator) {
|
||||
permalink = this.props.permalinkCreator.forEvent(mxEvent.getId()!);
|
||||
}
|
||||
|
||||
return {
|
||||
member,
|
||||
viewUserOnClick,
|
||||
forceHistorical,
|
||||
avatarSize,
|
||||
hasNoRenderer,
|
||||
senderProfileInfo,
|
||||
mxEvent,
|
||||
showMessageActionBar,
|
||||
|
||||
// todo: This should be a state
|
||||
reactions: this.reactions,
|
||||
hover: this.hover,
|
||||
contextMenu: this.contextMenu,
|
||||
thread: null,
|
||||
|
||||
permalinkCreator,
|
||||
getTile: () => this.getTile(),
|
||||
getReplyChain: () => this.getReplyChain(),
|
||||
getRelationsForEvent: this.props.getRelationsForEvent,
|
||||
|
||||
onFocusChange: (menuDisplayed) => this.onActionBarFocusChange(menuDisplayed),
|
||||
actionBarFocused: this.actionBarFocused,
|
||||
|
||||
isQuoteExpanded: this.isQuoteExpanded,
|
||||
toggleThreadExpanded: () => this.setQuoteExpanded(!this.isQuoteExpanded),
|
||||
|
||||
timestampViewModel: {
|
||||
showRelative: this.props.timelineRenderingType === TimelineRenderingType.ThreadsList,
|
||||
showTwelveHour: this.props.isTwelveHour,
|
||||
shouldRender: showTimestamp,
|
||||
ts,
|
||||
receivedTs: getLateEventInfo(this.props.mxEvent)?.received_ts,
|
||||
},
|
||||
|
||||
linkedTimestampViewModel: {
|
||||
hideTimestamp: this.props.hideTimestamp,
|
||||
permalink,
|
||||
ariaLabel: formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour),
|
||||
onContextMenu: (e) => {
|
||||
this.onTimestampContextMenu(e);
|
||||
},
|
||||
onClick: (e) => {
|
||||
this.onPermalinkClicked(e);
|
||||
},
|
||||
},
|
||||
|
||||
needsPinnedMessageBadge,
|
||||
isRedacted,
|
||||
needsFooter,
|
||||
suppressReadReceiptAnimation: this.suppressReadReceiptAnimation,
|
||||
|
||||
shouldShowSentReceipt: this.shouldShowSentReceipt,
|
||||
shouldShowSendingReceipt: this.shouldShowSendingReceipt,
|
||||
messageState: mxEvent.getAssociatedStatus(),
|
||||
|
||||
checkUnmounting: this.props.checkUnmounting,
|
||||
readReceiptMap: this.props.readReceiptMap,
|
||||
readReceipts: this.props.readReceipts,
|
||||
showReadReceipts: this.props.showReadReceipts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TrackedRef<T> {
|
||||
private node: T | null = null;
|
||||
|
||||
public ref = (node: T): (() => void) => {
|
||||
this.node = node;
|
||||
return () => {
|
||||
this.node = null;
|
||||
};
|
||||
};
|
||||
|
||||
public get current(): T | null {
|
||||
return this.node;
|
||||
}
|
||||
}
|
||||
@ -83,6 +83,9 @@ import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
||||
import PinningUtils from "../../../utils/PinningUtils";
|
||||
import { PinnedMessageBadge } from "../messages/PinnedMessageBadge";
|
||||
import { EventPreview } from "./EventPreview";
|
||||
import { EventTileNew } from "./EventTileNew";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext";
|
||||
|
||||
export type GetRelationsForEvent = (
|
||||
eventId: string,
|
||||
@ -1485,9 +1488,17 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
|
||||
// Wrap all event tiles with the tile error boundary so that any throws even during construction are captured
|
||||
const SafeEventTile = (props: EventTileProps): JSX.Element => {
|
||||
const cli = useMatrixClientContext();
|
||||
const context = useScopedRoomContext("timelineRenderingType", "showHiddenEvents");
|
||||
return (
|
||||
<TileErrorBoundary mxEvent={props.mxEvent} layout={props.layout ?? Layout.Group}>
|
||||
<UnwrappedEventTile {...props} />
|
||||
{/* <UnwrappedEventTile {...props} /> */}
|
||||
<EventTileNew
|
||||
{...props}
|
||||
cli={cli}
|
||||
timelineRenderingType={context.timelineRenderingType}
|
||||
showHiddenEvents={context.showHiddenEvents}
|
||||
/>
|
||||
</TileErrorBoundary>
|
||||
);
|
||||
};
|
||||
@ -1544,7 +1555,7 @@ interface ISentReceiptProps {
|
||||
messageState: EventStatus | null;
|
||||
}
|
||||
|
||||
function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element {
|
||||
export function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element {
|
||||
const isSent = !messageState || messageState === "sent";
|
||||
const isFailed = messageState === "not_sent";
|
||||
const receiptClasses = classNames({
|
||||
|
||||
18
src/components/views/rooms/EventTileNew.tsx
Normal file
18
src/components/views/rooms/EventTileNew.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2023 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
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 React, { useMemo } from "react";
|
||||
|
||||
import { EventTileViewModel, type EventTileViewModelProps } from "../../viewmodels/rooms/EventTileViewModel";
|
||||
import { EventTileView } from "./EventTileView";
|
||||
|
||||
export const EventTileNew: React.FC<EventTileViewModelProps> = (props) => {
|
||||
const vm = useMemo(() => new EventTileViewModel(props), []);
|
||||
return <EventTileView vm={vm} />;
|
||||
};
|
||||
254
src/components/views/rooms/EventTileView.tsx
Normal file
254
src/components/views/rooms/EventTileView.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector 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 React, { useEffect, useSyncExternalStore } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import type {
|
||||
EventStatus,
|
||||
EventType,
|
||||
MatrixEvent,
|
||||
Relations,
|
||||
RelationType,
|
||||
RoomMember,
|
||||
Thread,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import type { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { SentReceipt, type IEventTileType } from "./EventTile";
|
||||
import type ReplyChain from "../elements/ReplyChain";
|
||||
import type { IReadReceiptPosition } from "./ReadReceiptMarker";
|
||||
import type { EventTileViewModel, IReadReceiptProps, ViewModel } from "../../viewmodels/rooms/EventTileViewModel";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import SenderProfile from "../messages/SenderProfile";
|
||||
import MessageActionBar from "../messages/MessageActionBar";
|
||||
import MessageTimestamp from "../messages/MessageTimestamp";
|
||||
import { PinnedMessageBadge } from "../messages/PinnedMessageBadge";
|
||||
import ReactionsRow from "../messages/ReactionsRow";
|
||||
import { ReadReceiptGroup } from "./ReadReceiptGroup";
|
||||
|
||||
interface IProps {
|
||||
vm: EventTileViewModel;
|
||||
}
|
||||
|
||||
export type GetRelationsForEvent = (
|
||||
eventId: string,
|
||||
relationType: RelationType | string,
|
||||
eventType: EventType | string,
|
||||
) => Relations | null | undefined;
|
||||
|
||||
export interface EventTileViewState {
|
||||
avatarSize: string | null;
|
||||
member: RoomMember | null;
|
||||
viewUserOnClick: boolean;
|
||||
forceHistorical: boolean;
|
||||
senderProfileInfo: {
|
||||
shouldRender: boolean;
|
||||
onClick?: () => void;
|
||||
tooltip?: boolean;
|
||||
};
|
||||
hasNoRenderer: boolean;
|
||||
mxEvent: MatrixEvent;
|
||||
showMessageActionBar: boolean;
|
||||
// The Relations model from the JS SDK for reactions to `mxEvent`
|
||||
reactions?: Relations | null;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
getTile: () => IEventTileType | null;
|
||||
getReplyChain: () => ReplyChain | null;
|
||||
onFocusChange?: (menuDisplayed: boolean) => void;
|
||||
isQuoteExpanded?: boolean;
|
||||
toggleThreadExpanded: () => void;
|
||||
getRelationsForEvent?: GetRelationsForEvent;
|
||||
actionBarFocused: boolean;
|
||||
|
||||
timestampViewModel: {
|
||||
shouldRender: boolean;
|
||||
showRelative?: boolean;
|
||||
showTwelveHour?: boolean;
|
||||
ts: number;
|
||||
receivedTs?: number;
|
||||
};
|
||||
|
||||
hover: boolean;
|
||||
contextMenu?: {
|
||||
position: Pick<DOMRect, "top" | "left" | "bottom">;
|
||||
link?: string;
|
||||
};
|
||||
thread: Thread | null;
|
||||
|
||||
needsPinnedMessageBadge: boolean;
|
||||
isRedacted: boolean;
|
||||
needsFooter: boolean;
|
||||
|
||||
linkedTimestampViewModel: {
|
||||
hideTimestamp?: boolean;
|
||||
permalink: string;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
ariaLabel?: string;
|
||||
onContextMenu: (e: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
suppressReadReceiptAnimation: boolean;
|
||||
|
||||
shouldShowSentReceipt: boolean;
|
||||
shouldShowSendingReceipt: boolean;
|
||||
messageState: EventStatus | null;
|
||||
|
||||
// a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'.
|
||||
readReceipts?: IReadReceiptProps[];
|
||||
|
||||
// opaque readreceipt info for each userId; used by ReadReceiptMarker
|
||||
// to manage its animations. Should be an empty object when the room
|
||||
// first loads
|
||||
readReceiptMap?: { [userId: string]: IReadReceiptPosition };
|
||||
|
||||
// A function which is used to check if the parent panel is being
|
||||
// unmounted, to avoid unnecessary work. Should return true if we
|
||||
// are being unmounted.
|
||||
checkUnmounting?: () => boolean;
|
||||
showReadReceipts?: boolean;
|
||||
}
|
||||
|
||||
const NoRendererView: React.FC = () => {
|
||||
return (
|
||||
<div className="mx_EventTile mx_EventTile_info mx_MNoticeBody">
|
||||
<div className="mx_EventTile_line">{_t("timeline|error_no_renderer")}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EventTileAvatarView: React.FC<{ vs: EventTileViewState }> = ({ vs }) => {
|
||||
if (!vs.avatarSize || !vs.member) return null;
|
||||
return (
|
||||
<div className="mx_EventTile_avatar">
|
||||
<MemberAvatar
|
||||
member={vs.member}
|
||||
size={vs.avatarSize}
|
||||
viewUserOnClick={vs.viewUserOnClick}
|
||||
forceHistorical={vs.forceHistorical}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExtractViewState<V> = V extends ViewModel<unknown, infer S> ? S : never;
|
||||
|
||||
function useViewModel<V extends ViewModel<unknown, ExtractViewState<V>>>(vm: V): ExtractViewState<V> {
|
||||
const vs = useSyncExternalStore(vm.subscribe, vm.getSnapshot);
|
||||
|
||||
useEffect(() => {
|
||||
vm.onComponentMounted();
|
||||
}, [vm]);
|
||||
|
||||
return vs;
|
||||
}
|
||||
|
||||
export const EventTileView: React.FC<IProps> = ({ vm }) => {
|
||||
const vs = useViewModel(vm);
|
||||
|
||||
if (vs.hasNoRenderer) {
|
||||
// This shouldn't happen: the caller should check we support this type
|
||||
// before trying to instantiate us
|
||||
// todo: logger.warn should go to vm
|
||||
logger.warn(`Event type not supported: `);
|
||||
return <NoRendererView />;
|
||||
}
|
||||
|
||||
const avatar = <EventTileAvatarView vs={vs} />;
|
||||
|
||||
let sender: React.JSX.Element | null = null;
|
||||
const senderProfileInfo = vs.senderProfileInfo;
|
||||
if (senderProfileInfo.shouldRender) {
|
||||
sender = (
|
||||
<SenderProfile
|
||||
mxEvent={vs.mxEvent}
|
||||
onClick={vs.senderProfileInfo.onClick}
|
||||
withTooltip={vs.senderProfileInfo.tooltip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let actionBar: React.JSX.Element | null = null;
|
||||
if (vs.showMessageActionBar) {
|
||||
actionBar = (
|
||||
<MessageActionBar
|
||||
mxEvent={vs.mxEvent}
|
||||
reactions={vs.reactions}
|
||||
permalinkCreator={vs.permalinkCreator}
|
||||
getTile={vs.getTile}
|
||||
getReplyChain={vs.getReplyChain}
|
||||
onFocusChange={vs.onFocusChange}
|
||||
isQuoteExpanded={vs.isQuoteExpanded}
|
||||
toggleThreadExpanded={vs.toggleThreadExpanded}
|
||||
getRelationsForEvent={vs.getRelationsForEvent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let messageTimestamp: React.JSX.Element | null = null;
|
||||
const timestampVm = vs.timestampViewModel;
|
||||
if (timestampVm.shouldRender) {
|
||||
messageTimestamp = (
|
||||
<MessageTimestamp
|
||||
showRelative={timestampVm.showRelative}
|
||||
showTwelveHour={timestampVm.showTwelveHour}
|
||||
ts={timestampVm.ts}
|
||||
receivedTs={timestampVm.receivedTs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let pinnedMessageBadge: React.JSX.Element | null = null;
|
||||
if (vs.needsPinnedMessageBadge) {
|
||||
pinnedMessageBadge = <PinnedMessageBadge />;
|
||||
}
|
||||
|
||||
let reactionsRow: React.JSX.Element | null = null;
|
||||
if (!vs.isRedacted) {
|
||||
reactionsRow = <ReactionsRow mxEvent={vs.mxEvent} reactions={vs.reactions} key="mx_EventTile_reactionsRow" />;
|
||||
}
|
||||
|
||||
let linkedTimestamp: React.JSX.Element | null = null;
|
||||
const linkedTimestampVm = vs.linkedTimestampViewModel;
|
||||
if (!linkedTimestampVm.hideTimestamp) {
|
||||
linkedTimestamp = (
|
||||
<a
|
||||
href={linkedTimestampVm.permalink}
|
||||
onClick={linkedTimestampVm.onClick}
|
||||
aria-label={linkedTimestampVm.ariaLabel}
|
||||
onContextMenu={linkedTimestampVm.onContextMenu}
|
||||
>
|
||||
{messageTimestamp}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
let msgOption: React.JSX.Element | null = null;
|
||||
if (vs.shouldShowSentReceipt || vs.shouldShowSendingReceipt) {
|
||||
msgOption = <SentReceipt messageState={vs.messageState} />;
|
||||
} else if (vs.showReadReceipts) {
|
||||
msgOption = (
|
||||
<ReadReceiptGroup
|
||||
readReceipts={vs.readReceipts ?? []}
|
||||
readReceiptMap={vs.readReceiptMap ?? {}}
|
||||
checkUnmounting={vs.checkUnmounting}
|
||||
suppressAnimation={vs.suppressReadReceiptAnimation}
|
||||
isTwelveHour={timestampVm.showTwelveHour}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="EventTileView">
|
||||
{avatar}
|
||||
{sender}
|
||||
{actionBar}
|
||||
{pinnedMessageBadge}
|
||||
{reactionsRow}
|
||||
{msgOption}
|
||||
{linkedTimestamp}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -73,7 +73,7 @@ export const ScopedRoomContextProvider = memo(
|
||||
},
|
||||
);
|
||||
|
||||
type ScopedRoomContext<K extends Array<keyof ContextValue>> = { [key in K[number]]: ContextValue[key] };
|
||||
export type ScopedRoomContext<K extends Array<keyof ContextValue>> = { [key in K[number]]: ContextValue[key] };
|
||||
|
||||
export function useScopedRoomContext<K extends Array<keyof ContextValue>>(...keys: K): ScopedRoomContext<K> {
|
||||
const context = useContext(ScopedRoomContext);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user