diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index a01a957b61..318cca4000 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -180,7 +180,7 @@ limitations under the License. border-top-left-radius: var(--cornerRadius); border-top-right-radius: var(--cornerRadius); - > a { + > a, .mx_MessageTimestamp { position: absolute; padding: 4px 8px; bottom: 0; @@ -375,7 +375,8 @@ limitations under the License. margin-left: 9px; } - .mx_EventTile_line > a { + .mx_EventTile_line > a, + .mx_EventTile_line .mx_MessageTimestamp { // Align timestamps with those of normal bubble tiles right: auto; top: -11px; diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 5abc86f3df..e90b3dd3d9 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -841,24 +841,6 @@ $left-gutter: 64px; display: flex; flex-direction: column; - .mx_EventTile_senderDetails { - display: flex; - align-items: center; - gap: calc(6px + $selected-message-border-width); - - a { - flex: 1; - min-width: none; - max-width: 100%; - display: flex; - align-items: center; - - .mx_SenderProfile { - flex: 1; - } - } - } - .mx_ThreadView_List { flex: 1; overflow: scroll; @@ -869,7 +851,6 @@ $left-gutter: 64px; } .mx_EventTile { - width: 100%; display: flex; flex-direction: column; padding-top: 0; @@ -880,11 +861,7 @@ $left-gutter: 64px; } .mx_MessageTimestamp { - left: auto; - right: 2px !important; - top: 1px !important; - font-size: 1rem; - text-align: right; + font-size: $font-10px; } .mx_ReactionsRow { @@ -896,16 +873,63 @@ $left-gutter: 64px; } } - .mx_EventTile_content, - .mx_RedactedBody, - .mx_ReplyChain_wrapper { + .mx_EventTile[data-layout=bubble] { margin-left: 36px; - margin-right: 50px; + margin-right: 36px; + + &[data-self=true] { + align-items: flex-end; + + .mx_EventTile_line.mx_EventTile_mediaLine { + margin: 0 -13px 0 0; // align with normal messages + padding: 0 !important; + + .mx_MFileBody ~ .mx_MessageTimestamp { + bottom: calc($font-14px + 4px); // above the Decrypt/Download text line + } + } + } + } + + .mx_EventTile[data-layout=group] { + width: 100%; .mx_EventTile_content, .mx_RedactedBody, - .mx_MImageBody { - margin: 0; + .mx_ReplyChain_wrapper { + margin-left: 36px; + margin-right: 50px; + + .mx_EventTile_content, + .mx_RedactedBody, + .mx_MImageBody { + margin: 0; + } + } + + .mx_MessageTimestamp { + left: auto; + right: 2px !important; + top: 1px !important; + text-align: right; + } + + .mx_EventTile_senderDetails { + display: flex; + align-items: center; + gap: calc(6px + $selected-message-border-width); + + a { + flex: 1; + min-width: none; + max-width: 100%; + display: flex; + align-items: center; + + .mx_SenderProfile { + flex: 1; + } + } } } diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 42cd3851e8..4f992c4f43 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -18,6 +18,7 @@ import React from 'react'; import { IEventRelation, MatrixEvent, Room } from 'matrix-js-sdk/src'; import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; import { RelationType } from 'matrix-js-sdk/src/@types/event'; +import classNames from "classnames"; import BaseCard from "../views/right_panel/BaseCard"; import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases"; @@ -40,6 +41,7 @@ import UploadBar from './UploadBar'; import { _t } from '../../languageHandler'; import ThreadListContextMenu from '../views/context_menus/ThreadListContextMenu'; import RightPanelStore from '../../stores/right-panel/RightPanelStore'; +import SettingsStore from "../../settings/SettingsStore"; interface IProps { room: Room; @@ -53,6 +55,7 @@ interface IProps { } interface IState { thread?: Thread; + layout: Layout; editState?: EditorStateTransfer; replyToEvent?: MatrixEvent; } @@ -63,10 +66,17 @@ export default class ThreadView extends React.Component { private dispatcherRef: string; private timelinePanelRef: React.RefObject = React.createRef(); + private readonly layoutWatcherRef: string; constructor(props: IProps) { super(props); - this.state = {}; + this.state = { + layout: SettingsStore.getValue("layout"), + }; + + this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, (...[,,, value]) => + this.setState({ layout: value as Layout }), + ); } public componentDidMount(): void { @@ -82,6 +92,7 @@ export default class ThreadView extends React.Component { dis.unregister(this.dispatcherRef); const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); room.removeListener(ThreadEvent.New, this.onNewThread); + SettingsStore.unwatchSetting(this.layoutWatcherRef); } public componentDidUpdate(prevProps) { @@ -192,6 +203,12 @@ export default class ThreadView extends React.Component { event_id: this.state.thread?.id, }; + const messagePanelClassNames = classNames( + "mx_RoomView_messagePanel", + { + "mx_GroupLayout": this.state.layout === Layout.Group, + }); + return ( { timelineSet={this.state?.thread?.timelineSet} showUrlPreview={true} tileShape={TileShape.Thread} - layout={Layout.Group} + // ThreadView doesn't support IRC layout at this time + layout={this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group} hideThreadedMessages={false} hidden={false} showReactions={true} - className="mx_RoomView_messagePanel mx_GroupLayout" + className={messagePanelClassNames} permalinkCreator={this.props.permalinkCreator} membersLoaded={true} editState={this.state.editState} diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 3ea0b7b4dc..467d0d4579 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -23,7 +23,7 @@ import { Relations } from "matrix-js-sdk/src/models/relations"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; import { logger } from "matrix-js-sdk/src/logger"; -import { NotificationCountType } from 'matrix-js-sdk/src/models/room'; +import { NotificationCountType, Room } from 'matrix-js-sdk/src/models/room'; import { POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; import ReplyChain from "../elements/ReplyChain"; @@ -129,16 +129,15 @@ for (const evType of ALL_RULE_TYPES) { stateEventTileTypes[evType] = 'messages.TextualEvent'; } -export function getHandlerTile(ev) { +export function getHandlerTile(ev: MatrixEvent): string { const type = ev.getType(); // don't show verification requests we're not involved in, // not even when showing hidden events - if (type === "m.room.message") { + if (type === EventType.RoomMessage) { const content = ev.getContent(); if (content && content.msgtype === MsgType.KeyVerificationRequest) { - const client = MatrixClientPeg.get(); - const me = client && client.getUserId(); + const me = MatrixClientPeg.get()?.getUserId(); if (ev.getSender() !== me && content.to !== me) { return undefined; } else { @@ -148,20 +147,19 @@ export function getHandlerTile(ev) { } // these events are sent by both parties during verification, but we only want to render one // tile once the verification concludes, so filter out the one from the other party. - if (type === "m.key.verification.done") { - const client = MatrixClientPeg.get(); - const me = client && client.getUserId(); + if (type === EventType.KeyVerificationDone) { + const me = MatrixClientPeg.get()?.getUserId(); if (ev.getSender() !== me) { return undefined; } } - // sometimes MKeyVerificationConclusion declines to render. Jankily decline to render and + // sometimes MKeyVerificationConclusion declines to render. Jankily decline to render and // fall back to showing hidden events, if we're viewing hidden events // XXX: This is extremely a hack. Possibly these components should have an interface for // declining to render? - if (type === "m.key.verification.cancel" || type === "m.key.verification.done") { - if (!MKeyVerificationConclusion.shouldRender(ev, ev.request)) { + if (type === EventType.KeyVerificationCancel || type === EventType.KeyVerificationDone) { + if (!MKeyVerificationConclusion.shouldRender(ev, ev.verificationRequest)) { return; } } @@ -375,8 +373,9 @@ export default class EventTile extends React.Component { }; static contextType = MatrixClientContext; + public context!: React.ContextType; - constructor(props, context) { + constructor(props: IProps, context: React.ContextType) { super(props, context); this.state = { @@ -424,7 +423,7 @@ export default class EventTile extends React.Component { // 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 = MatrixClientPeg.get().getUserId(); + const myUserId = this.context.getUserId(); if (this.props.mxEvent.getSender() !== myUserId) return false; // Finally, determine if the type is relevant to the user. This notably excludes state @@ -455,7 +454,7 @@ export default class EventTile extends React.Component { // If anyone has read the event besides us, we don't want to show a sent receipt. const receipts = this.props.readReceipts || []; - const myUserId = MatrixClientPeg.get().getUserId(); + const myUserId = this.context.getUserId(); if (receipts.some(r => r.userId !== myUserId)) return false; // Finally, we should show a receipt. @@ -511,7 +510,7 @@ export default class EventTile extends React.Component { room?.on(ThreadEvent.New, this.onNewThread); } - private setupNotificationListener = (thread): void => { + private setupNotificationListener = (thread: Thread): void => { const room = this.context.getRoom(this.props.mxEvent.getRoomId()); const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(room); @@ -537,7 +536,7 @@ export default class EventTile extends React.Component { }); }; - private updateThread = (thread) => { + private updateThread = (thread: Thread) => { if (thread !== this.state.thread) { if (this.threadState) { this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate); @@ -554,7 +553,7 @@ export default class EventTile extends React.Component { // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line - UNSAFE_componentWillReceiveProps(nextProps) { + UNSAFE_componentWillReceiveProps(nextProps: IProps) { // re-check the sender verification as outgoing events progress through // the send process. if (nextProps.eventSendStatus !== this.props.eventSendStatus) { @@ -562,7 +561,7 @@ export default class EventTile extends React.Component { } } - shouldComponentUpdate(nextProps, nextState, nextContext) { + shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean { if (objectHasDiff(this.state, nextState)) { return true; } @@ -592,7 +591,7 @@ export default class EventTile extends React.Component { } } - componentDidUpdate(prevProps, prevState, snapshot) { + componentDidUpdate(prevProps: IProps, prevState: IState, snapshot) { // If we're not listening for receipts and expect to be, register a listener. if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) { this.context.on("Room.receipt", this.onRoomReceipt); @@ -619,7 +618,7 @@ export default class EventTile extends React.Component { * We currently have no reliable way to discover than an event is a thread * when we are at the sync stage */ - const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + const room = this.context.getRoom(this.props.mxEvent.getRoomId()); const thread = room?.threads.get(this.props.mxEvent.getId()); if (!thread || thread.length === 0) { @@ -692,9 +691,9 @@ export default class EventTile extends React.Component { ); } - private onRoomReceipt = (ev, room) => { + private onRoomReceipt = (ev: MatrixEvent, room: Room): void => { // ignore events for other rooms - const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + const tileRoom = this.context.getRoom(this.props.mxEvent.getRoomId()); if (room !== tileRoom) return; if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt && !this.isListeningForReceipts) { @@ -722,19 +721,19 @@ export default class EventTile extends React.Component { this.forceUpdate(); }; - private onDeviceVerificationChanged = (userId, device) => { + private onDeviceVerificationChanged = (userId: string, device: string): void => { if (userId === this.props.mxEvent.getSender()) { this.verifyEvent(this.props.mxEvent); } }; - private onUserVerificationChanged = (userId, _trustStatus) => { + private onUserVerificationChanged = (userId: string, _trustStatus: string): void => { if (userId === this.props.mxEvent.getSender()) { this.verifyEvent(this.props.mxEvent); } }; - private async verifyEvent(mxEvent) { + private async verifyEvent(mxEvent: MatrixEvent): Promise { if (!mxEvent.isEncrypted()) { return; } @@ -788,7 +787,7 @@ export default class EventTile extends React.Component { }, this.props.onHeightChanged); // Decryption may have caused a change in size } - private propsEqual(objA, objB) { + private propsEqual(objA: IProps, objB: IProps): boolean { const keysA = Object.keys(objA); const keysB = Object.keys(objB); @@ -836,7 +835,7 @@ export default class EventTile extends React.Component { return true; } - shouldHighlight() { + private shouldHighlight(): boolean { if (this.props.forExport) return false; const actions = this.context.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent); if (!actions || !actions.tweaks) { return false; } @@ -849,13 +848,13 @@ export default class EventTile extends React.Component { return actions.tweaks.highlight; } - toggleAllReadAvatars = () => { + private toggleAllReadAvatars = () => { this.setState({ allReadAvatars: !this.state.allReadAvatars, }); }; - getReadAvatars() { + private getReadAvatars() { if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { return ; } @@ -928,7 +927,8 @@ export default class EventTile extends React.Component { />, ); } - let remText; + + let remText: JSX.Element; if (!this.state.allReadAvatars) { const remainder = receipts.length - MAX_READ_AVATARS; if (remainder > 0) { @@ -950,7 +950,7 @@ export default class EventTile extends React.Component { ); } - onSenderProfileClick = () => { + private onSenderProfileClick = () => { if (!this.props.timelineRenderingType) return; dis.dispatch({ action: Action.ComposerInsert, @@ -959,7 +959,7 @@ export default class EventTile extends React.Component { }); }; - onRequestKeysClick = () => { + private onRequestKeysClick = () => { this.setState({ // Indicate in the UI that the keys have been requested (this is expected to // be reset if the component is mounted in the future). @@ -972,7 +972,7 @@ export default class EventTile extends React.Component { this.context.cancelAndResendEventRoomKeyRequest(this.props.mxEvent); }; - onPermalinkClicked = e => { + private onPermalinkClicked = e => { // 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(); @@ -1027,17 +1027,16 @@ export default class EventTile extends React.Component { return null; } - onActionBarFocusChange = focused => { - this.setState({ - actionBarFocused: focused, - }); + private onActionBarFocusChange = (actionBarFocused: boolean) => { + this.setState({ actionBarFocused }); }; + // TODO: Types - getTile: () => any | null = () => this.tile.current; + private getTile: () => any | null = () => this.tile.current; - getReplyChain = () => this.replyChain.current; + private getReplyChain = () => this.replyChain.current; - getReactions = () => { + private getReactions = () => { if ( !this.props.showReactions || !this.props.getRelationsForEvent @@ -1063,6 +1062,7 @@ export default class EventTile extends React.Component { isQuoteExpanded: expanded, }); }; + render() { const msgtype = this.props.mxEvent.getContent().msgtype; const eventType = this.props.mxEvent.getType() as EventType; @@ -1099,6 +1099,11 @@ export default class EventTile extends React.Component { const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted; const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure(); + let isContinuation = this.props.continuation; + if (this.props.tileShape && this.props.layout !== Layout.Bubble) { + isContinuation = false; + } + const isEditing = !!this.props.editState; const classes = classNames({ mx_EventTile_bubbleContainer: isBubbleMessage, @@ -1111,10 +1116,7 @@ export default class EventTile extends React.Component { mx_EventTile_sending: !isEditing && isSending, mx_EventTile_highlight: this.props.tileShape === TileShape.Notif ? false : this.shouldHighlight(), mx_EventTile_selected: this.props.isSelectedEvent, - mx_EventTile_continuation: ( - (this.props.tileShape ? '' : this.props.continuation) || - eventType === EventType.CallInvite - ), + mx_EventTile_continuation: isContinuation || eventType === EventType.CallInvite, mx_EventTile_last: this.props.last, mx_EventTile_lastInSection: this.props.lastInSection, mx_EventTile_contextual: this.props.contextual, @@ -1226,7 +1228,7 @@ export default class EventTile extends React.Component { || this.state.hover || this.state.actionBarFocused); - const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + const room = this.context.getRoom(this.props.mxEvent.getRoomId()); const thread = room?.findThreadForEvent?.(this.props.mxEvent); // Thread panel shows the timestamp of the last reply in that thread @@ -1312,20 +1314,22 @@ export default class EventTile extends React.Component { msgOption = readAvatars; } - const replyChain = haveTileForEvent(this.props.mxEvent) && - ReplyChain.hasReply(this.props.mxEvent) ? ( - ) : null; + const replyChain = haveTileForEvent(this.props.mxEvent) && ReplyChain.hasReply(this.props.mxEvent) + ? + : null; + + const isOwnEvent = this.props.mxEvent?.sender?.userId === this.context.getUserId(); switch (this.props.tileShape) { case TileShape.Notif: { @@ -1372,6 +1376,8 @@ export default class EventTile extends React.Component { "aria-atomic": true, "data-scroll-tokens": scrollToken, "data-has-reply": !!replyChain, + "data-layout": this.props.layout, + "data-self": isOwnEvent, "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), }, [ @@ -1407,8 +1413,6 @@ export default class EventTile extends React.Component { ]); } case TileShape.ThreadPanel: { - const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId(); - // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers return ( React.createElement(this.props.as || "li", { @@ -1491,8 +1495,6 @@ export default class EventTile extends React.Component { } default: { - const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId(); - // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers return ( React.createElement(this.props.as || "li", { @@ -1554,7 +1556,7 @@ function isMessageEvent(ev: MatrixEvent): boolean { return (messageTypes.includes(ev.getType())); } -export function haveTileForEvent(e: MatrixEvent, showHiddenEvents?: boolean) { +export function haveTileForEvent(e: MatrixEvent, showHiddenEvents?: boolean): boolean { // Only messages have a tile (black-rectangle) if redacted if (e.isRedacted() && !isMessageEvent(e)) return false;