This commit is contained in:
R Midhun Suresh 2025-07-07 12:23:51 +05:30
parent 5ad0dceae0
commit fd9381d417
No known key found for this signature in database
5 changed files with 904 additions and 3 deletions

View 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;
}
}

View File

@ -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({

View 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} />;
};

View 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>
);
};

View File

@ -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);