Allow bubble layout in Thread View (#7478)

This commit is contained in:
Michael Telatynski 2022-01-12 09:02:30 +00:00 committed by GitHub
parent 038a6bc204
commit f5465b37a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 144 additions and 99 deletions

View file

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

View file

@ -841,6 +841,79 @@ $left-gutter: 64px;
display: flex;
flex-direction: column;
.mx_ThreadView_List {
flex: 1;
overflow: scroll;
}
.mx_EventTile_roomName {
display: none;
}
.mx_EventTile {
display: flex;
flex-direction: column;
padding-top: 0;
.mx_EventTile_line {
padding-left: 0;
order: 10 !important;
}
.mx_MessageTimestamp {
font-size: $font-10px;
}
.mx_ReactionsRow {
order: 999;
padding-left: 0;
padding-right: 0;
margin-left: 36px;
margin-right: 50px;
}
}
.mx_EventTile[data-layout=bubble] {
margin-left: 36px;
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_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;
@ -858,55 +931,6 @@ $left-gutter: 64px;
}
}
}
.mx_ThreadView_List {
flex: 1;
overflow: scroll;
}
.mx_EventTile_roomName {
display: none;
}
.mx_EventTile {
width: 100%;
display: flex;
flex-direction: column;
padding-top: 0;
.mx_EventTile_line {
padding-left: 0;
order: 10 !important;
}
.mx_MessageTimestamp {
left: auto;
right: 2px !important;
top: 1px !important;
font-size: 1rem;
text-align: right;
}
.mx_ReactionsRow {
order: 999;
padding-left: 0;
padding-right: 0;
margin-left: 36px;
margin-right: 50px;
}
}
.mx_EventTile_content,
.mx_RedactedBody,
.mx_ReplyChain_wrapper {
margin-left: 36px;
margin-right: 50px;
.mx_EventTile_content,
.mx_RedactedBody,
.mx_MImageBody {
margin: 0;
}
}
.mx_EventTile_mediaLine {

View file

@ -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<IProps, IState> {
private dispatcherRef: string;
private timelinePanelRef: React.RefObject<TimelinePanel> = 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<IProps, IState> {
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<IProps, IState> {
event_id: this.state.thread?.id,
};
const messagePanelClassNames = classNames(
"mx_RoomView_messagePanel",
{
"mx_GroupLayout": this.state.layout === Layout.Group,
});
return (
<RoomContext.Provider value={{
...this.context,
@ -216,11 +233,12 @@ export default class ThreadView extends React.Component<IProps, IState> {
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}

View file

@ -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,9 +147,8 @@ 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;
}
@ -160,8 +158,8 @@ export function getHandlerTile(ev) {
// 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<IProps, IState> {
};
static contextType = MatrixClientContext;
public context!: React.ContextType<typeof MatrixClientContext>;
constructor(props, context) {
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
@ -424,7 +423,7 @@ export default class EventTile extends React.Component<IProps, IState> {
// 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<IProps, IState> {
// 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<IProps, IState> {
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<IProps, IState> {
});
};
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<IProps, IState> {
// 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<IProps, IState> {
}
}
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<IProps, IState> {
}
}
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<IProps, IState> {
* 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<IProps, IState> {
);
}
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<IProps, IState> {
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<void> {
if (!mxEvent.isEncrypted()) {
return;
}
@ -788,7 +787,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}, 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<IProps, IState> {
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<IProps, IState> {
return actions.tweaks.highlight;
}
toggleAllReadAvatars = () => {
private toggleAllReadAvatars = () => {
this.setState({
allReadAvatars: !this.state.allReadAvatars,
});
};
getReadAvatars() {
private getReadAvatars() {
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
return <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
}
@ -928,7 +927,8 @@ export default class EventTile extends React.Component<IProps, IState> {
/>,
);
}
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<IProps, IState> {
);
}
onSenderProfileClick = () => {
private onSenderProfileClick = () => {
if (!this.props.timelineRenderingType) return;
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
@ -959,7 +959,7 @@ export default class EventTile extends React.Component<IProps, IState> {
});
};
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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
|| 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,9 +1314,8 @@ export default class EventTile extends React.Component<IProps, IState> {
msgOption = readAvatars;
}
const replyChain = haveTileForEvent(this.props.mxEvent) &&
ReplyChain.hasReply(this.props.mxEvent) ? (
<ReplyChain
const replyChain = haveTileForEvent(this.props.mxEvent) && ReplyChain.hasReply(this.props.mxEvent)
? <ReplyChain
parentEv={this.props.mxEvent}
onHeightChanged={this.props.onHeightChanged}
ref={this.replyChain}
@ -1325,7 +1326,10 @@ export default class EventTile extends React.Component<IProps, IState> {
isQuoteExpanded={isQuoteExpanded}
setQuoteExpanded={this.setQuoteExpanded}
getRelationsForEvent={this.props.getRelationsForEvent}
/>) : null;
/>
: 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<IProps, IState> {
"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<IProps, IState> {
]);
}
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<IProps, IState> {
}
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;