diff --git a/res/css/_components.scss b/res/css/_components.scss index 56403ea190e..8a6b1c202a8 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -195,6 +195,7 @@ @import "./views/rooms/_EntityTile.scss"; @import "./views/rooms/_EventTile.scss"; @import "./views/rooms/_GroupLayout.scss"; +@import "./views/rooms/_BubbleLayout.scss"; @import "./views/rooms/_IRCLayout.scss"; @import "./views/rooms/_JumpToBottomButton.scss"; @import "./views/rooms/_LinkPreviewWidget.scss"; diff --git a/res/css/views/rooms/_BubbleLayout.scss b/res/css/views/rooms/_BubbleLayout.scss new file mode 100644 index 00000000000..b78dca37379 --- /dev/null +++ b/res/css/views/rooms/_BubbleLayout.scss @@ -0,0 +1,724 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020 Tobias Büttner +Copyright 2020-2021 Quirin Götz + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +$left-gutter: 54px; + +.mx_BubbleLayout { + // ---- Overrides ---- + + .mx_RoomView_MessageList { + padding-bottom: 8px; + padding-right: 10px; + } + + .mx_RoomTile { + .mx_RoomTile_nameContainer { + .mx_RoomTile_name, + .mx_RoomTile_messagePreview { + margin: 2px 2px; + } + } + } + + li > .mx_DateSeparator { + margin: 7px 0px 1px 0px; + } + + .mx_LinkPreviewWidget { + margin-bottom: 0px; + } + + .mx_EventTile { + // no reserved space for read receipts required. + padding-top: 6px; + + &.mx_EventTile_info { + padding-top: 0px; + + .mx_EventTile_avatar { + left: $left-gutter; + } + } + + > .mx_SenderProfile { + line-height: $font-17px; + padding-left: $left-gutter; + max-width: unset !important; + } + + > .mx_EventTile_line { + padding-left: $left-gutter !important; + border-left: none !important; + background: transparent !important; + margin-right: unset; + } + + > .mx_EventTile_avatar { + position: absolute; + } + + .mx_EventTile_line, .mx_EventTile_reply { + padding-top: 3px; + padding-bottom: 3px; + padding-left: $left-gutter; + line-height: $font-22px; + min-height: $font-22px; + } + } + + .mx_EventTile_line, .mx_EventTile_reply { + padding-left: $left-gutter; + } + + .mx_EventListSummary { + // For toggle + position: relative; + + // Consistence with other bubbles + + .mx_EventTile { + padding-top: 0; + } + + .mx_EventTile_bubbleLine_info { + margin-top: 6px; + + // ToDo: Find better way to not crash bubble with toggle on small width screens + // padding-left: 110px; + margin-right: 0; + padding-left: 0; + } + + .mx_EventTile_line { + padding-top: 3px; + padding-bottom: 3px; + } + + .mx_EventTile_bubbleArea_info { + // For toggle + max-width: 72rem; + } + } + + .mx_EventListSummary_toggle { + // ToDo: Find better way to not crash bubble on small width screens + float: unset; + position: absolute; + right: 10px; + margin-right: 0; + z-index: 1; + } + + .mx_EventTile_info .mx_EventTile_line { + padding-left: calc($left-gutter + 18px) !important; + max-width: max-content; + + .mx_MessageActionBar { + top: -21px; // counteract padding: 24-3 + } + } + + .mx_SenderProfile { + text-align: left; + } + + .mx_SenderProfile_name { + color: $accent-color !important; + } + + .mx_EventTile_selected > div > a > .mx_MessageTimestamp { + left: 0px; + width: 46px; + } + + .mx_EventTile_msgOption { + float: unset; + text-align: unset; + position: unset; + + margin-left: 8px; + margin-right: unset; + + /* Hack to stop the height of this pushing the messages apart. + Replaces margin-top: -6px. This interacts better with a read + marker being in between. Content overflows. */ + // SC: Reserve our space + height: 14px; + margin-top: 6px; + margin-bottom: 6px; + + // stop read avatars overflowing + width: 100%; + + &.mx_readReceipts_empty { + height: 0; + margin-top: 0; + margin-bottom: 0; + } + } + + .mx_EventTile_bigInfoContainer .mx_EventTile_msgOption { + grid-column: unset; // show read avatara like always + } + + .mx_EventTile_readAvatars { + // SC-TODO align left below msg area + //top: 29px; + top: 0; + + // stop read avatars overflowing + width: 100%; + height: 22px; + overflow-x: scroll; + overflow-y: hidden; + z-index: 0; // let message action bar not jump away + } + + .mx_EventTile_readAvatarRemainder { + height: 14px; + } + + .mx_EventTile_content { + // Handled by bubble + margin-right: unset; + } + + // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) + .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp, + .mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp, + .mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp { + left: 5px; + } + + .mx_EventTile_isEditing { + background-color: transparent; + } + + .mx_EditMessageComposer { + margin: 0; + padding: 0; + + .mx_EditMessageComposer_buttons { + position: unset; + margin: 0; + margin-left: auto; + padding: 0; + padding-top: 10px; + max-width: 100%; + background: transparent; + flex-wrap: wrap; + + .mx_AccessibleButton { + padding: 5px 0; + width: 11em; + max-width: 100%; + } + } + } + + .mx_BasicMessageComposer { + max-width: 100%; + } + + // blockquote.mx_ReplyThread { + // margin-bottom: 10px; + // } + + .mx_ReplyThread, .mx_ReplyPreview { + // padding-top: 2px; + + .mx_EventTile_avatar { + top: 4px !important; + } + + .mx_MessageTimestamp { + top: 4px !important; + } + + .mx_MImageBody_thumbnail, span.mx_MVideoBody video.mx_MVideoBody { + border-color: $event-timestamp-color !important; + } + } + + .mx_MNoticeBody { + opacity: unset; + } + + .mx_MessageActionBar { + left: unset; + right: 0px; + top: -36px; + } + + .mx_MFileBody { + display: flow-root; + } + + .mx_MFileBody_download { + display: inline; + } + + + .mx_MessageTimestamp { + position: absolute; + width: 46px; /* 8 + 30 (avatar) + 8 */ + } + + .mx_ReactionsRow { + margin-right: -6px; + display: flex; + justify-content: left; + flex-direction: row; + + .mx_ReactionsRow_addReactionButton { + margin-left: unset; + + &::before { + left: 0; + } + } + } + + a.mx_Pill { + max-width: 100%; + } + + .mx_MImageBody { + margin-right: 0; + position: relative; + + .mx_MFileBody_download { + display: none; + } + + .mx_PlaceholderTimestamp { + display: none; + } + + .mx_MessageTimestamp { + visibility: visible !important; + text-align: center; + background: rgba(0, 0, 0, 0.5); + color: white; + left: unset; + right: 1px; + top: unset; + bottom: 1px; + border-radius: 8px 0; + font-size: 0.85em; + padding: 6px 9px; + width: unset; + line-height: 1; + } + } + + .mx_MImageBody_thumbnail, span.mx_MVideoBody video.mx_MVideoBody { + box-sizing: border-box; + border: 1px solid $message-bubble-background; + border-radius: 8px; + } + + .mx_MStickerBody_wrapper { + padding: 0; + } + + span.mx_MVideoBody { + display: block; + max-width: max-content; + position: relative; + + video.mx_MVideoBody { + display: block; + width: auto; + max-height: 600px; + } + + .mx_MFileBody { + display: flex; + position: relative; + justify-content: space-between; + align-items: center; + } + + .mx_MFileBody_download { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .mx_PlaceholderTimestamp { + display: none; + } + + .mx_LinkedTimestamp { + margin-left: 1rem; + + .mx_MessageTimestamp { + visibility: visible !important; + position: unset !important; + width: unset !important; + font-size: 0.85em; + line-height: 1; + } + } + } + + // ---- Bubble specific ---- + + .mx_EventTile > .mx_EventTile_bubbleLine_info { + padding-left: 0 !important; + } + + .mx_EventTile .mx_EventTile_bubbleLine_info { + line-height: unset; + } + + .mx_EventTile_bubbleContainer { + > .mx_EventTile_avatar { + top: 9px; + } + } + + .mx_EventTile_e2eIcon { + display: none !important; + } + + // .mx_EventTile_bubbleLine { + // .mx_EventTile_e2eIcon { + // left: 16px; + + // .mx_EventTile_bubbleTailLeftContainer & { + // top: 35px; + // } + // } + // } + + .mx_EventTile_bubbleArea { + // No full width for both-side bubbles only + width: 84%; + max-width: 88rem; + padding: 0px; + margin-bottom: 0; + } + + .mx_EventTile_bubbleArea_left { + margin-left: 0px; + margin-right: auto; + text-align: left; + } + + .mx_EventTile_bubble { + background-color: $message-bubble-background; + padding: 7px 10px; + border-radius: 8px; + //margin: 10px auto; + max-width: max-content; + // Min width: respect/"hide" bubble tail + min-width: 20px; + position: relative; + //box-sizing: content-box; + //display: flex; + //flex-wrap: wrap; + + // Don't inherit bubbleArea alignment + text-align: start; + + // &.mx_EventTile_bubble_tail::before { + // content: ''; + // border: 16px solid transparent; + // border-top-color: $message-bubble-background; + // border-bottom: 0; + // position: absolute; + // top: 0; + // } + + > .mx_SenderProfile { + // Sender-profile within bubble + max-width: 100%; + margin-bottom: 4px; + display: block; + } + + .mx_ReplyThread_wrapper { + margin-top: 3px; + } + + > *:not(.mx_ReplyThread_wrapper) { + .markdown-body > blockquote { + margin-top: 3px !important; + } + + .mx_PlaceholderTimestamp { + display: inline-flex; + margin-inline-start: .7em; + opacity: 0; + line-height: inherit; // don't add additional height + + .mx_MessageTimestamp { + visibility: visible !important; + position: unset; + width: unset; + text-align: unset; + display: inline; + font-size: 0.85em; + } + + &:dir(rtl) { + display: block; + margin-inline-start: unset; + } + } + + .mx_LinkedTimestamp { + position: absolute; + inset-block-end: 4px; + inset-inline-end: 10px; + + // Get rid of underline with custom themes + text-decoration: none; + + .mx_MessageTimestamp { + visibility: visible !important; + position: unset; + text-align: unset; + display: inline; + font-size: 0.85em; + } + + &:dir(rtl) { + position: absolute; + display: block; + inset-inline-end: unset; + right: 10px; + } + } + } + } + + .mx_EventTile_bubble_left { + margin-left: 0px; + margin-right: auto; + + // &.mx_EventTile_bubble_tail::before { + // left: -8px; + // } + } + + .mx_EventTile_bubbleArea_right { + margin-right: 16px; + margin-left: auto; + text-align: right; + + .mx_ReactionsRow { + justify-content: right; + flex-direction: row-reverse; + + .mx_ReactionsRow_addReactionButton { + margin-left: unset; + margin-right: 6px; + } + } + } + + .mx_EventTile_bubble_right { + margin-right: 0px; + margin-left: auto; + + // &.mx_EventTile_bubble_tail::before { + // right: -8px; + // } + } + + .mx_EventTile_bubble_self { + &:not(.mx_EventTile_bubble_media) { + background-color: $message-bubble-background-self; + + // &.mx_EventTile_bubble_tail::before { + // border-top-color: $message-bubble-background-self; + // } + } + + .mx_MImageBody_thumbnail, span.mx_MVideoBody video.mx_MVideoBody { + border-color: $message-bubble-background-self; + } + } + + .mx_EventTile_bubbleArea_center { + margin-left: auto; + margin-right: auto; + } + + .mx_EventTile_bubble_center { + margin-left: auto; + margin-right: auto; + } + + .mx_EventTile_bubbleArea_info { + width: 76%; + } + + .mx_EventTile_bubble_info { + text-align: center; + background: transparent; + box-shadow: inset 0px 0px 0px 1px rgba($roomtopic-color, 0.15); + + .mx_EventTileBubble { + background-color: transparent; + padding: 0; + margin: 0; + max-width: unset; + text-align: start; + + &::before, &::after { + margin-top: 1px; + } + + .mx_EventTileBubble_title { + opacity: 0.8; + } + } + + .mx_EventTile_avatar { + display: inline; + position: unset; + + &::after { + content: "\00a0"; + } + } + + .mx_BaseAvatar { + display: inline-flex; + vertical-align: middle; + margin-top: -2px; + } + + .mx_EventListSummary_avatars { + padding-top: unset; + } + + .mx_TextualEvent, .mx_RoomAvatarEvent { + display: inline; + opacity: 0.8; + } + + .mx_RoomAvatarEvent_avatar { + top: unset; + } + } + + .mx_EventTile_bubble_notice { + opacity: 0.6; + } + + .mx_EventTile_bubble_media { + max-width: 600px; + position: relative; + + &.mx_EventTile_bubble_right { + margin-left: auto; + + > * { + margin-left: auto; + } + + .mx_MImageBody_thumbnail { + left: unset; + right: 0; + } + } + + // maybe: + // & + .mx_ReactionsRow { + // margin-top: 0; + // margin-bottom: 0; + // } + } + + .mx_EventTile_bubble_sticker { + .mx_MImageBody_thumbnail { + border: none; + } + } + + .mx_EventTile_selected { + .mx_EventTile_bubble { + background: $message-bubble-background-selected; + + // &.mx_EventTile_bubble_tail::before { + // border-top-color: $message-bubble-background-selected; + // } + } + + .mx_EventTile_bubble_media { + > .mx_MImageBody { + .mx_MImageBody_thumbnail_container { + background: $accent-color; + border-radius: 8px; + } + .mx_MImageBody_thumbnail { + opacity: 0.7; + } + } + + > span.mx_MVideoBody { + .mx_MVideoBody_video_container { + background: $accent-color; + border-radius: 8px; + display: block; + } + + video.mx_MVideoBody { + opacity: 0.7; + } + } + } + } + + // single side bubbles overrides + &.mx_BubbleLayout_singleSide { + .mx_EventTile_bubbleArea { + width: calc(100% - 16px); + max-width: unset; + } + + .mx_EventTile_bubbleArea_info { + width: 80%; + max-width: 72rem; + } + + .mx_EventTile_bubbleLine { + max-width: 104rem; + } + + .mx_EventTile_bubbleLine_info, .mx_DateSeparator, .mx_EventListSummary { + max-width: calc($left-gutter + 104rem); + } + } +} + +/* Compact layout overrides */ + +// .mx_MatrixChat_useCompactLayout { +// .mx_BubbleLayout { +// // nothing (yet?) +// // ToDo: figure out if there is a useful way to make it more compact +// // maybe reduce text line-height and some paddings and margins +// } +// } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 3af266caeef..db37946da76 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -116,7 +116,7 @@ $hover-select-border: 4px; } } -.mx_EventTile_bubbleContainer { +.mx_EventTile_bigInfoContainer { display: grid; grid-template-columns: 1fr 100px; diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss index ddee81a914b..ba76bdfc3dc 100644 --- a/res/css/views/rooms/_GroupLayout.scss +++ b/res/css/views/rooms/_GroupLayout.scss @@ -43,7 +43,7 @@ $left-gutter: 64px; /* Compact layout overrides */ -.mx_MatrixChat_useCompactLayout { +.mx_MatrixChat_useCompactLayout .mx_GroupLayout { .mx_EventTile { padding-top: 4px; diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 2d0e3d2a8b0..6e0c62df8f9 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -181,6 +181,9 @@ $visual-bell-bg-color: #800; $room-warning-bg-color: $header-panel-bg-color; $dark-panel-bg-color: $header-panel-bg-color; +$message-bubble-background: #424242; +$message-bubble-background-self: #303030; +$message-bubble-background-selected: #3F4931; $panel-gradient: rgba(34, 38, 46, 0), rgba(34, 38, 46, 1); $message-action-bar-bg-color: $header-panel-bg-color; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index a852ad94e95..04264a06952 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -175,6 +175,9 @@ $visual-bell-bg-color: #800; $room-warning-bg-color: $header-panel-bg-color; $dark-panel-bg-color: $header-panel-bg-color; +$message-bubble-background: #424242; +$message-bubble-background-self: #303030; +$message-bubble-background-selected: #3F4931; $panel-gradient: rgba(34, 38, 46, 0), rgba(34, 38, 46, 1); $message-action-bar-bg-color: $header-panel-bg-color; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 84666bc662c..2936f865269 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -298,6 +298,9 @@ $authpage-primary-color: #232f32; $authpage-secondary-color: #61708b; $dark-panel-bg-color: $secondary-accent-color; +$message-bubble-background: #eeeeee; +$message-bubble-background-self: #F1F8E9; +$message-bubble-background-selected: #DBEDC6; $panel-gradient: rgba(242, 245, 248, 0), rgba(242, 245, 248, 1); $message-action-bar-bg-color: $primary-bg-color; diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss index 1b9254d1005..91c9805c736 100644 --- a/res/themes/light-custom/css/_custom.scss +++ b/res/themes/light-custom/css/_custom.scss @@ -140,3 +140,8 @@ $event-highlight-bg-color: var(--timeline-highlights-color); // // redirect some variables away from their hardcoded values in the light theme $settings-grey-fg-color: $primary-fg-color; + +// bubble layout variables +$message-bubble-background: var(--timeline-bubble-background-incoming); +$message-bubble-background-self: var(--timeline-bubble-background-outgoing); +$message-bubble-background-selected: var(--timeline-bubble-background-selected); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index c889f43d0b9..d5bf37b5dda 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -296,6 +296,9 @@ $authpage-primary-color: #232f32; $authpage-secondary-color: #61708b; $dark-panel-bg-color: $secondary-accent-color; +$message-bubble-background: #eeeeee; +$message-bubble-background-self: #F1F8E9; +$message-bubble-background-selected: #DBEDC6; $panel-gradient: rgba(242, 245, 248, 0), rgba(242, 245, 248, 1); $message-action-bar-bg-color: $primary-bg-color; diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index eb9611a6fc6..a50847dbf41 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -148,6 +148,9 @@ export default class MessagePanel extends React.Component { // which layout to use layout: LayoutPropType, + // whether to use single side bubbles + singleSideBubbles: PropTypes.bool, + // whether or not to show flair at all enableFlair: PropTypes.bool, }; @@ -549,7 +552,8 @@ export default class MessagePanel extends React.Component { for (const Grouper of groupers) { if (Grouper.canStartGroup(this, mxEv)) { - grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent, nextEvent, nextTile); + grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent, nextEvent, nextTile, + this.props.layout); } } if (!grouper) { @@ -672,6 +676,7 @@ export default class MessagePanel extends React.Component { getRelationsForEvent={this.props.getRelationsForEvent} showReactions={this.props.showReactions} layout={this.props.layout} + singleSideBubbles={this.props.singleSideBubbles} enableFlair={this.props.enableFlair} showReadReceipts={this.props.showReadReceipts} /> @@ -915,7 +920,7 @@ class CreationGrouper { return ev.getType() === "m.room.create"; }; - constructor(panel, createEvent, prevEvent, lastShownEvent) { + constructor(panel, createEvent, prevEvent, lastShownEvent, nextEvent, nextEventTile, layout) { this.panel = panel; this.createEvent = createEvent; this.prevEvent = prevEvent; @@ -928,6 +933,7 @@ class CreationGrouper { createEvent.getId(), createEvent === lastShownEvent, ); + this.layout = layout; } shouldGroup(ev) { @@ -1026,6 +1032,7 @@ class CreationGrouper { onToggle={panel._onHeightChanged} // Update scroll state summaryMembers={[ev.sender]} summaryText={summaryText} + layout={this.layout} > { eventTiles } , @@ -1048,7 +1055,7 @@ class RedactionGrouper { return panel._shouldShowEvent(ev) && ev.isRedacted(); } - constructor(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile) { + constructor(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile, layout) { this.panel = panel; this.readMarker = panel._readMarkerForEvent( ev.getId(), @@ -1059,6 +1066,7 @@ class RedactionGrouper { this.lastShownEvent = lastShownEvent; this.nextEvent = nextEvent; this.nextEventTile = nextEventTile; + this.layout = layout; } shouldGroup(ev) { @@ -1124,6 +1132,7 @@ class RedactionGrouper { onToggle={panel._onHeightChanged} // Update scroll state summaryMembers={Array.from(senders)} summaryText={_t("%(count)s messages deleted.", { count: eventTiles.length })} + layout={this.layout} > { eventTiles } , @@ -1147,7 +1156,7 @@ class MemberGrouper { return panel._shouldShowEvent(ev) && isMembershipChange(ev); } - constructor(panel, ev, prevEvent, lastShownEvent) { + constructor(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile, layout) { this.panel = panel; this.readMarker = panel._readMarkerForEvent( ev.getId(), @@ -1156,6 +1165,7 @@ class MemberGrouper { this.events = [ev]; this.prevEvent = prevEvent; this.lastShownEvent = lastShownEvent; + this.layout = layout; } shouldGroup(ev) { @@ -1230,6 +1240,7 @@ class MemberGrouper { events={this.events} onToggle={panel._onHeightChanged} // Update scroll state startExpanded={highlightInMels} + layout={this.layout} > { eventTiles } , diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 1e3adcb5186..b8a2be879d3 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -82,6 +82,7 @@ import SpaceRoomView from "./SpaceRoomView"; import { IOpts } from "../../createRoom"; import { replaceableComponent } from "../../utils/replaceableComponent"; import UIStore from "../../stores/UIStore"; +import ReactDOM from 'react-dom'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -180,6 +181,8 @@ export interface IState { canReact: boolean; canReply: boolean; layout: Layout; + singleSideBubbles: boolean; + adaptiveSideBubbles: boolean; lowBandwidth: boolean; showReadReceipts: boolean; showRedactions: boolean; @@ -211,7 +214,7 @@ export default class RoomView extends React.Component { private roomView = createRef(); private searchResultsPanel = createRef(); - private messagePanel: TimelinePanel; + private messagePanel = createRef(); static contextType = MatrixClientContext; @@ -243,6 +246,8 @@ export default class RoomView extends React.Component { canReact: false, canReply: false, layout: SettingsStore.getValue("layout"), + singleSideBubbles: SettingsStore.getValue("singleSideBubbles"), + adaptiveSideBubbles: SettingsStore.getValue("adaptiveSideBubbles"), lowBandwidth: SettingsStore.getValue("lowBandwidth"), showReadReceipts: true, showRedactions: true, @@ -279,6 +284,15 @@ export default class RoomView extends React.Component { SettingsStore.watchSetting("layout", null, () => this.setState({ layout: SettingsStore.getValue("layout") }), ), + SettingsStore.watchSetting("singleSideBubbles", null, () => + this.setState({ singleSideBubbles: SettingsStore.getValue("singleSideBubbles") }), + ), + SettingsStore.watchSetting("adaptiveSideBubbles", null, () => + this.setState({ + adaptiveSideBubbles: SettingsStore.getValue("adaptiveSideBubbles"), + singleSideBubbles: SettingsStore.getValue("singleSideBubbles"), // restore default + }), + ), SettingsStore.watchSetting("lowBandwidth", null, () => this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }), ), @@ -598,12 +612,14 @@ export default class RoomView extends React.Component { // in render() prevents the ref from being set on first mount, so we try and // catch the messagePanel when it does mount. Because we only want the ref once, // we use a boolean flag to avoid duplicate work. - if (this.messagePanel && !this.state.atEndOfLiveTimelineInit) { + if (this.messagePanel.current && !this.state.atEndOfLiveTimelineInit) { this.setState({ atEndOfLiveTimelineInit: true, - atEndOfLiveTimeline: this.messagePanel.isAtEndOfLiveTimeline(), + atEndOfLiveTimeline: this.messagePanel.current.isAtEndOfLiveTimeline(), }); } + + this.onResize(); } componentWillUnmount() { @@ -724,7 +740,7 @@ export default class RoomView extends React.Component { const action = getKeyBindingsManager().getRoomAction(ev); switch (action) { case RoomAction.DismissReadMarker: - this.messagePanel.forgetReadMarker(); + this.messagePanel.current?.forgetReadMarker(); this.jumpToLiveTimeline(); handled = true; break; @@ -897,10 +913,10 @@ export default class RoomView extends React.Component { }; public canResetTimeline = () => { - if (!this.messagePanel) { + if (!this.messagePanel.current) { return true; } - return this.messagePanel.canResetTimeline(); + return this.messagePanel.current.canResetTimeline(); }; // called when state.room is first initialised (either at initial load, @@ -1031,15 +1047,6 @@ export default class RoomView extends React.Component { }); } - private updateTint() { - const room = this.state.room; - if (!room) return; - - console.log("Tinter.tint from updateTint"); - const colorScheme = SettingsStore.getValue("roomColor", room.roomId); - Tinter.tint(colorScheme.primary_color, colorScheme.secondary_color); - } - private onAccountData = (event: MatrixEvent) => { const type = event.getType(); if ((type === "org.matrix.preview_urls" || type === "im.vector.web.settings") && this.state.room) { @@ -1180,7 +1187,7 @@ export default class RoomView extends React.Component { }; private onMessageListScroll = ev => { - if (this.messagePanel.isAtEndOfLiveTimeline()) { + if (this.messagePanel.current?.isAtEndOfLiveTimeline()) { this.setState({ numUnreadMessages: 0, atEndOfLiveTimeline: true, @@ -1426,6 +1433,8 @@ export default class RoomView extends React.Component { resultLink={resultLink} permalinkCreator={this.getPermalinkCreatorForRoom(room)} onHeightChanged={onHeightChanged} + layout={this.state.layout} + singleSideBubbles={this.state.singleSideBubbles} />); } return ret; @@ -1557,29 +1566,29 @@ export default class RoomView extends React.Component { }); } else { // Otherwise we have to jump manually - this.messagePanel.jumpToLiveTimeline(); + this.messagePanel.current?.jumpToLiveTimeline(); dis.fire(Action.FocusComposer); } }; // jump up to wherever our read marker is private jumpToReadMarker = () => { - this.messagePanel.jumpToReadMarker(); + this.messagePanel.current?.jumpToReadMarker(); }; // update the read marker to match the read-receipt private forgetReadMarker = ev => { ev.stopPropagation(); - this.messagePanel.forgetReadMarker(); + this.messagePanel.current?.forgetReadMarker(); }; // decide whether or not the top 'unread messages' bar should be shown private updateTopUnreadMessagesBar = () => { - if (!this.messagePanel) { + if (!this.messagePanel.current) { return; } - const showBar = this.messagePanel.canJumpToReadMarker(); + const showBar = this.messagePanel.current?.canJumpToReadMarker(); if (this.state.showTopUnreadMessagesBar != showBar) { this.setState({showTopUnreadMessagesBar: showBar}); } @@ -1589,7 +1598,7 @@ export default class RoomView extends React.Component { // restored when we switch back to it. // private getScrollState() { - const messagePanel = this.messagePanel; + const messagePanel = this.messagePanel.current; if (!messagePanel) return null; // if we're following the live timeline, we want to return null; that @@ -1645,6 +1654,24 @@ export default class RoomView extends React.Component { if (this.state.auxPanelMaxHeight !== auxPanelMaxHeight) { this.setState({ auxPanelMaxHeight }); } + + // Let the bubble layout choose between single side and both sides by threshold + if (this.state.layout == Layout.Bubble && this.state.adaptiveSideBubbles && this.roomView.current) { + // ToDo: Find better way to get the current width (references, but which???) + const messageLists = [this.searchResultsPanel, this.messagePanel]; + let width = 0; + for (const messageList of messageLists) { + // @ts-ignore - findDOMNode() has return type of Element | Text, but we know better + const boundingBox = ReactDOM.findDOMNode(messageList.current)?.getBoundingClientRect(); + if (boundingBox?.width > width) width = boundingBox.width; + } + // ToDo: Make threshold configurable? + if (width < 1280) { + this.setState({ singleSideBubbles: false }); + } else { + this.setState({ singleSideBubbles: true }); + } + } }; private onStatusBarVisible = () => { @@ -1667,8 +1694,8 @@ export default class RoomView extends React.Component { let panel; if (this.searchResultsPanel.current) { panel = this.searchResultsPanel.current; - } else if (this.messagePanel) { - panel = this.messagePanel; + } else if (this.messagePanel.current) { + panel = this.messagePanel.current; } if (panel) { @@ -1686,16 +1713,6 @@ export default class RoomView extends React.Component { return CallHandler.sharedInstance().getCallForRoom(this.state.room.roomId); } - // this has to be a proper method rather than an unnamed function, - // otherwise react calls it with null on each update. - private gatherTimelinePanelRef = r => { - this.messagePanel = r; - if (r) { - console.log("updateTint from RoomView.gatherTimelinePanelRef"); - this.updateTint(); - } - }; - private getOldRoom() { const createEvent = this.state.room.currentState.getStateEvents("m.room.create", ""); if (!createEvent || !createEvent.getContent()['predecessor']) return null; @@ -1731,6 +1748,7 @@ export default class RoomView extends React.Component { loading={loading} joining={this.state.joining} oobData={this.props.oobData} + layout={this.state.layout} /> @@ -1968,6 +1986,7 @@ export default class RoomView extends React.Component { resizeNotifier={this.props.resizeNotifier} replyToEvent={this.state.replyToEvent} permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} + layout={this.state.layout} />; } @@ -1981,6 +2000,13 @@ export default class RoomView extends React.Component { }; } + const layout = { + "mx_IRCLayout": this.state.layout == Layout.IRC, + "mx_GroupLayout": this.state.layout == Layout.Group, + "mx_BubbleLayout": this.state.layout == Layout.Bubble, + "mx_BubbleLayout_singleSide": this.state.layout == Layout.Bubble && this.state.singleSideBubbles, + }; + // if we have search results, we keep the messagepanel (so that it preserves its // scroll state), but hide it. let searchResultsPanel; @@ -1993,10 +2019,16 @@ export default class RoomView extends React.Component {
); } else { + const searchResultsPanelClassNames = classNames( + "mx_RoomView_messagePanel", + "mx_RoomView_searchResultsPanel", + layout, + ); + searchResultsPanel = ( @@ -2015,15 +2047,13 @@ export default class RoomView extends React.Component { const messagePanelClassNames = classNames( "mx_RoomView_messagePanel", - { - "mx_IRCLayout": this.state.layout == Layout.IRC, - "mx_GroupLayout": this.state.layout == Layout.Group, - }); + layout, + ); // console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview); const messagePanel = ( { resizeNotifier={this.props.resizeNotifier} showReactions={true} layout={this.state.layout} + singleSideBubbles={this.state.singleSideBubbles} />); let topUnreadMessagesBar = null; diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 03d0b5c6d7e..e4f0e661669 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -123,6 +123,9 @@ class TimelinePanel extends React.Component { // which layout to use layout: LayoutPropType, + // whether to use single side bubbles + singleSideBubbles: PropTypes.bool, + // whether to always show timestamps for an event alwaysShowTimestamps: PropTypes.bool, } @@ -1476,6 +1479,7 @@ class TimelinePanel extends React.Component { editState={this.state.editState} showReactions={this.props.showReactions} layout={this.props.layout} + singleSideBubbles={this.props.singleSideBubbles} enableFlair={SettingsStore.getValue(UIFeature.Flair)} /> ); diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx index 86d3e082ad3..001d1e1649a 100644 --- a/src/components/views/elements/EventListSummary.tsx +++ b/src/components/views/elements/EventListSummary.tsx @@ -22,6 +22,7 @@ import MemberAvatar from '../avatars/MemberAvatar'; import { _t } from '../../../languageHandler'; import {useStateToggle} from "../../../hooks/useStateToggle"; import AccessibleButton from "./AccessibleButton"; +import { Layout } from '../../../settings/Layout'; interface IProps { // An array of member events to summarise @@ -37,7 +38,9 @@ interface IProps { // An array of EventTiles to render when expanded children: ReactChildren, // Called when the event list expansion is toggled - onToggle?(): void; + onToggle?(): void, + // which layout to use + layout: Layout, } const EventListSummary: React.FC = ({ @@ -48,6 +51,7 @@ const EventListSummary: React.FC = ({ startExpanded, summaryMembers = [], summaryText, + layout, }) => { const [expanded, toggleExpanded] = useStateToggle(startExpanded); @@ -77,18 +81,37 @@ const EventListSummary: React.FC = ({ ; } else { const avatars = summaryMembers.map((m) => ); - body = ( -
-
- - { avatars } - - - { summaryText } - + if (layout === Layout.Bubble) { + body = ( +
+
+
+ + { avatars } + + + { summaryText } + +
+
-
- ); + ); + } else { + body = ( +
+
+ + { avatars } + + + { summaryText } + +
+
+ ); + } } return ( diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index cf3b7a6e61a..467571fea56 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -121,6 +121,7 @@ export default class EventTilePreview extends React.Component { const className = classnames(this.props.className, { "mx_IRCLayout": this.props.layout == Layout.IRC, "mx_GroupLayout": this.props.layout == Layout.Group, + "mx_BubbleLayout": this.props.layout == Layout.Bubble, }); return
diff --git a/src/components/views/elements/MemberEventListSummary.tsx b/src/components/views/elements/MemberEventListSummary.tsx index f10884ce9d3..680b7abbcbc 100644 --- a/src/components/views/elements/MemberEventListSummary.tsx +++ b/src/components/views/elements/MemberEventListSummary.tsx @@ -24,6 +24,7 @@ import { _t } from '../../../languageHandler'; import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import { isValid3pidInvite } from "../../../RoomInvite"; import EventListSummary from "./EventListSummary"; +import { Layout } from '../../../settings/Layout'; import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { @@ -39,6 +40,8 @@ interface IProps { startExpanded?: boolean, // An array of EventTiles to render when expanded children: ReactChildren; + // which layout to use + layout: Layout, // Called when the MELS expansion is toggled onToggle?(): void, } @@ -448,6 +451,7 @@ export default class MemberEventListSummary extends React.Component { startExpanded={this.props.startExpanded} children={this.props.children} summaryMembers={[...latestUserAvatarMember.values()]} - summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} />; + summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} + layout={this.props.layout} />; } } diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index 0d5e449fc00..7ea88f547bf 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -15,6 +15,8 @@ */ import React from 'react'; +import PropTypes from 'prop-types'; + import MFileBody from './MFileBody'; import { decryptFile } from '../../../utils/DecryptFile'; @@ -25,6 +27,10 @@ import {mediaFromContent} from "../../../customisations/Media"; @replaceableComponent("views.messages.MAudioBody") export default class MAudioBody extends React.Component { + static propTypes = { + bubbleTimestamp: PropTypes.object, + }; + constructor(props) { super(props); this.state = { diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index 8f464e08bde..2adab0c8a85 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -102,6 +102,7 @@ export default class MFileBody extends React.Component { tileShape: PropTypes.string, /* whether or not to show the default placeholder for the file. Defaults to true. */ showGenericPlaceholder: PropTypes.bool, + bubbleTimestamp: PropTypes.object, }; static defaultProps = { @@ -214,6 +215,7 @@ export default class MFileBody extends React.Component { { _t("Decrypt %(text)s", { text: text }) }
+ { this.props.bubbleTimestamp } ); } @@ -256,6 +258,7 @@ export default class MFileBody extends React.Component { ref={this._iframe} sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
+ { this.props.bubbleTimestamp } ); } else if (contentUrl) { @@ -318,6 +321,7 @@ export default class MFileBody extends React.Component { { content.info && content.info.size ? filesize(content.info.size) : "" }
+ { this.props.bubbleTimestamp } ); } else { @@ -330,6 +334,7 @@ export default class MFileBody extends React.Component { { _t("Download %(text)s", { text: text }) } + { this.props.bubbleTimestamp } ); } @@ -338,6 +343,7 @@ export default class MFileBody extends React.Component { return {placeholder} { _t("Invalid file%(extra)s", { extra: extra }) } + { this.props.bubbleTimestamp } ; } } diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 6505b1d66ae..53cfacbb8c6 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -44,6 +44,10 @@ export default class MImageBody extends React.Component { /* the permalinkCreator */ permalinkCreator: PropTypes.object, + + bubbleEnabled: PropTypes.bool, + bubbleTimestamp: PropTypes.object, + bubbleActionBar: PropTypes.object, }; static contextType = MatrixClientContext; @@ -352,7 +356,7 @@ export default class MImageBody extends React.Component { /> ); } - return this.wrapImage(contentUrl, imageElement); + return { thumbnail: this.wrapImage(contentUrl, imageElement) }; } infoWidth = this.state.loadedImageDimensions.naturalWidth; infoHeight = this.state.loadedImageDimensions.naturalHeight; @@ -426,7 +430,7 @@ export default class MImageBody extends React.Component { ); - return this.wrapImage(contentUrl, thumbnail); + return { thumbnail: this.wrapImage(contentUrl, thumbnail), maxWidth: maxWidth }; } // Overidden by MStickerBody @@ -460,6 +464,7 @@ export default class MImageBody extends React.Component { { _t("Error decrypting image") } + { this.props.bubbleActionBar } ); } @@ -472,12 +477,13 @@ export default class MImageBody extends React.Component { thumbUrl = this._getThumbUrl(); } - const thumbnail = this._messageContent(contentUrl, thumbUrl, content); + const { thumbnail, maxWidth } = this._messageContent(contentUrl, thumbUrl, content); const fileBody = this.getFileBody(); - return + return { thumbnail } { fileBody } + { this.props.bubbleActionBar } ; } } diff --git a/src/components/views/messages/MStickerBody.js b/src/components/views/messages/MStickerBody.js index 54eb7649b42..0100accf56d 100644 --- a/src/components/views/messages/MStickerBody.js +++ b/src/components/views/messages/MStickerBody.js @@ -36,7 +36,8 @@ export default class MStickerBody extends MImageBody { if (!this.state.showImage) { onClick = this.onClick; } - return
{ children }
; + const wrapper =
{ children }
; + return [wrapper, this.props.bubbleTimestamp]; } // Placeholder to show in place of the sticker image if diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index 2efdce506e7..4d71bdb35c7 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -29,6 +29,8 @@ interface IProps { mxEvent: any; /* called when the video has loaded */ onHeightChanged: () => void; + + bubbleActionBar: any; } interface IState { @@ -193,6 +195,7 @@ export default class MVideoBody extends React.PureComponent { { _t("Error decrypting video") } + { this.props.bubbleActionBar } ); } @@ -207,6 +210,7 @@ export default class MVideoBody extends React.PureComponent {
+ { this.props.bubbleActionBar }
); } @@ -230,23 +234,26 @@ export default class MVideoBody extends React.PureComponent { } } return ( - - + + + + + { this.props.bubbleActionBar } ); } diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 78e0dc422d7..a0abcdfe3d3 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -49,6 +49,10 @@ export default class MessageEvent extends React.Component { /* the permalinkCreator */ permalinkCreator: PropTypes.object, + + bubbleEnabled: PropTypes.bool, + bubbleTimestamp: PropTypes.object, + bubbleActionBar: PropTypes.object, }; constructor(props) { @@ -126,6 +130,9 @@ export default class MessageEvent extends React.Component { onHeightChanged={this.props.onHeightChanged} onMessageAllowed={this.onTileUpdate} permalinkCreator={this.props.permalinkCreator} + bubbleEnabled={this.props.bubbleEnabled} + bubbleTimestamp={this.props.bubbleTimestamp} + bubbleActionBar={this.props.bubbleActionBar} />; } } diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index ebc4ce7ce8a..945685ae992 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -60,6 +60,8 @@ export default class TextualBody extends React.Component { /* the shape of the tile, used */ tileShape: PropTypes.string, + + bubbleTimestamp: PropTypes.object, }; constructor(props) { @@ -203,6 +205,7 @@ export default class TextualBody extends React.Component { _wrapInDiv(pre) { const div = document.createElement("div"); div.className = "mx_EventTile_pre_container"; + div.dir = "auto"; // Insert containing div in place of
 block
         pre.parentNode.replaceChild(div, pre);
@@ -491,7 +494,7 @@ export default class TextualBody extends React.Component {
     render() {
         if (this.props.editState) {
             const EditMessageComposer = sdk.getComponent('rooms.EditMessageComposer');
-            return ;
+            return ;
         }
         const mxEvent = this.props.mxEvent;
         const content = mxEvent.getContent();
@@ -536,7 +539,7 @@ export default class TextualBody extends React.Component {
         switch (content.msgtype) {
             case "m.emote":
                 return (
-                    
+                    
                 );
             case "m.notice":
                 return (
-                    
+                    
                         { body }
                         { widgets }
+                        { this.props.bubbleTimestamp }
                     
                 );
             default: // including "m.text"
                 return (
-                    
+                    
                         { body }
                         { widgets }
+                        { this.props.bubbleTimestamp }
                     
                 );
         }
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index 0099bf73fb6..4cad3d83e9d 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -1,6 +1,8 @@
 /*
 Copyright 2015-2021 The Matrix.org Foundation C.I.C.
 Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
+Copyright 2020 Tobias Büttner 
+Copyright 2020-2021 Quirin Götz 
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -263,6 +265,9 @@ interface IProps {
     // which layout to use
     layout: Layout;
 
+    // whether to use single side bubbles
+    singleSideBubbles?: boolean;
+
     // whether or not to show flair at all
     enableFlair?: boolean;
 
@@ -653,11 +658,7 @@ export default class EventTile extends React.Component {
             // animation will start from the top of the timeline (because it
             // lost its container).
             // See also https://github.com/vector-im/element-web/issues/17561
-            return (
-                
- -
- ); + return (); } const ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker'); @@ -679,7 +680,11 @@ export default class EventTile extends React.Component { // If hidden, set offset equal to the offset of the final visible avatar or // else set it proportional to index - left = (hidden ? MAX_READ_AVATARS - 1 : i) * -receiptOffset; + if (this.props.layout === Layout.Bubble) { + left = (hidden ? MAX_READ_AVATARS - 1 : i) * receiptOffset; + } else { + left = (hidden ? MAX_READ_AVATARS - 1 : i) * -receiptOffset; + } const userId = receipt.userId; let readReceiptInfo; @@ -709,22 +714,26 @@ export default class EventTile extends React.Component { let remText; if (!this.state.allReadAvatars) { const remainder = receipts.length - MAX_READ_AVATARS; + + let style; + if (this.props.layout === Layout.Bubble) { + style = { left: "calc(" + toRem(left) + " + " + receiptOffset + "px)" }; + } else { + style = { right: "calc(" + toRem(-left) + " + " + receiptOffset + "px)" }; + } + if (remainder > 0) { remText = { remainder }+ + style={style}>{ remainder }+ ; } } - return ( -
- - { remText } - { avatars } - -
- ) + return + { remText } + { avatars } + ; } onSenderProfileClick = event => { @@ -848,13 +857,13 @@ export default class EventTile extends React.Component { let tileHandler = getHandlerTile(this.props.mxEvent); // Info messages are basically information about commands processed on a room - const isBubbleMessage = eventType.startsWith("m.key.verification") || + const isBigInfoMessage = eventType.startsWith("m.key.verification") || (eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) || (eventType === EventType.RoomCreate) || (eventType === EventType.RoomEncryption) || (tileHandler === "messages.MJitsiWidgetEvent"); let isInfoMessage = ( - !isBubbleMessage && eventType !== EventType.RoomMessage && + !isBigInfoMessage && eventType !== EventType.RoomMessage && eventType !== EventType.Sticker && eventType !== EventType.RoomCreate ); @@ -884,27 +893,38 @@ export default class EventTile extends React.Component { const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted; const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure(); + const client = MatrixClientPeg.get(); + const me = client && client.getUserId(); + const bubbleEnabled = this.props.layout == Layout.Bubble + && this.props.tileShape !== 'reply_preview' && this.props.tileShape !== 'reply' + && this.props.tileShape !== 'notif' && this.props.tileShape !== 'file_grid'; + const sentByMe = me === this.props.mxEvent.getSender(); + const showRight = sentByMe && !this.props.singleSideBubbles; + const showLeft = !sentByMe || this.props.singleSideBubbles; + const isEditing = !!this.props.editState; const classes = classNames({ - mx_EventTile_bubbleContainer: isBubbleMessage, + mx_EventTile_bigInfoContainer: isBigInfoMessage && this.props.layout != Layout.Bubble, mx_EventTile: true, mx_EventTile_isEditing: isEditing, - mx_EventTile_info: isInfoMessage, + mx_EventTile_info: isInfoMessage && this.props.layout !== Layout.Bubble, mx_EventTile_12hr: this.props.isTwelveHour, // Note: we keep the `sending` state class for tests, not for our styles mx_EventTile_sending: !isEditing && isSending, mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(), mx_EventTile_selected: this.props.isSelectedEvent, - mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation, + mx_EventTile_continuation: !isInfoMessage && !isBigInfoMessage && + (this.props.tileShape ? '' : this.props.continuation), mx_EventTile_last: this.props.last, mx_EventTile_lastInSection: this.props.lastInSection, mx_EventTile_contextual: this.props.contextual, mx_EventTile_actionBarFocused: this.state.actionBarFocused, - mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2E_STATE.VERIFIED, - mx_EventTile_unverified: !isBubbleMessage && this.state.verified === E2E_STATE.WARNING, - mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2E_STATE.UNKNOWN, + mx_EventTile_verified: !isBigInfoMessage && this.state.verified === E2E_STATE.VERIFIED, + mx_EventTile_unverified: !isBigInfoMessage && this.state.verified === E2E_STATE.WARNING, + mx_EventTile_unknown: !isBigInfoMessage && this.state.verified === E2E_STATE.UNKNOWN, mx_EventTile_bad: isEncryptionFailure, mx_EventTile_emote: msgtype === 'm.emote', + mx_EventTile_bubbleContainer: bubbleEnabled, }); // If the tile is in the Sending state, don't speak the message. @@ -926,10 +946,13 @@ export default class EventTile extends React.Component { let avatarSize; let needsSenderProfile; - if (this.props.tileShape === "notif") { + if (!isInfoMessage && bubbleEnabled && showRight) { + avatarSize = 0; + needsSenderProfile = false; + } else if (this.props.tileShape === "notif") { avatarSize = 24; needsSenderProfile = true; - } else if (tileHandler === 'messages.RoomCreate' || isBubbleMessage) { + } else if (tileHandler === 'messages.RoomCreate' || isBigInfoMessage) { avatarSize = 0; needsSenderProfile = false; } else if (isInfoMessage) { @@ -991,7 +1014,8 @@ export default class EventTile extends React.Component { /> : undefined; const showTimestamp = this.props.mxEvent.getTs() && - (this.props.alwaysShowTimestamps || this.props.last || this.state.hover || this.state.actionBarFocused); + (bubbleEnabled || + this.props.alwaysShowTimestamps || this.props.last || this.state.hover || this.state.actionBarFocused); const timestamp = showTimestamp ? : null; @@ -1038,7 +1062,7 @@ export default class EventTile extends React.Component { />; } - const linkedTimestamp = { { timestamp } ; + const placeholderTimestamp = + { timestamp } + ; + + const bubbleTimestamp = <>{placeholderTimestamp}{linkedTimestamp}; + const useIRCLayout = this.props.layout == Layout.IRC; const groupTimestamp = !useIRCLayout ? linkedTimestamp : null; const ircTimestamp = useIRCLayout ? linkedTimestamp : null; - const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); - const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); - + const groupPadlock = !useIRCLayout && !isBigInfoMessage && this.renderE2EPadlock(); + const ircPadlock = useIRCLayout && !isBigInfoMessage && this.renderE2EPadlock(); + + const msgOptionClasses = classNames( + "mx_EventTile_msgOption", + { + "mx_readReceipts_empty": ( + // Don't reserve space below bubbles if there are no read receipts + (!this.props.readReceipts || this.props.readReceipts.length === 0) && + // ToDo: Maybe incorporate sent/sending state into bubble?!? + !(this.shouldShowSentReceipt || this.shouldShowSendingReceipt) + ), + }, + ); let msgOption; if (this.props.showReadReceipts) { const readAvatars = this.getReadAvatars(); - msgOption = readAvatars; + msgOption = ( +
+ { readAvatars } +
+ ); } switch (this.props.tileShape) { @@ -1170,44 +1215,141 @@ export default class EventTile extends React.Component { this.props.alwaysShowTimestamps || this.state.hover, ); - // 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", { - "ref": this.ref, - "className": classes, - "tabIndex": -1, - "aria-live": ariaLive, - "aria-atomic": "true", - "data-scroll-tokens": scrollToken, - "onMouseEnter": () => this.setState({ hover: true }), - "onMouseLeave": () => this.setState({ hover: false }), - }, [ - ircTimestamp, - sender, - ircPadlock, -
- { groupTimestamp } - { groupPadlock } - { thread } - - { keyRequestInfo } - { reactionsRow } - { actionBar } -
, - msgOption, - avatar, - - ]) - ) + if (bubbleEnabled) { + const infoBubble = isInfoMessage || isBigInfoMessage; + + const mediaBodyTypes = ['m.image', /* 'm.file', */ /* 'm.audio', */ 'm.video']; + const mediaEvTypes = ['m.sticker']; + let mediaBody = false; + let stickerBody = false; + let noticeBody = false; + + const content = this.props.mxEvent.getContent(); + const type = this.props.mxEvent.getType(); + const msgtype = content.msgtype; + + if (msgtype && mediaBodyTypes.indexOf(msgtype) != -1) { + mediaBody = true; + } + + if (type && mediaEvTypes.indexOf(type) != -1) { + mediaBody = true; + stickerBody = true; + } + + if (msgtype && msgtype == 'm.notice') noticeBody = true; + + const bubbleLineClasses = classNames( + "mx_EventTile_line", + "mx_EventTile_bubbleLine", + { + "mx_EventTile_bubbleLine_info": infoBubble, + }, + ); + const bubbleAreaClasses = classNames( + "mx_EventTile_bubbleArea", + { + "mx_EventTile_bubbleArea_right": !infoBubble && showRight, + "mx_EventTile_bubbleArea_left": !infoBubble && showLeft, + "mx_EventTile_bubbleArea_center": infoBubble, + "mx_EventTile_bubbleArea_info": infoBubble, + }, + ); + const bubbleClasses = classNames( + { + "mx_EventTile_bubble": !mediaBody, + "mx_EventTile_bubble_media": mediaBody, + "mx_EventTile_bubble_info": infoBubble, + "mx_EventTile_bubble_self": !infoBubble && sentByMe, + "mx_EventTile_bubble_right": !infoBubble && showRight, + "mx_EventTile_bubble_left": !infoBubble && showLeft, + "mx_EventTile_bubble_center": infoBubble, + "mx_EventTile_bubble_tail": !infoBubble && !this.props.continuation, + "mx_EventTile_bubble_notice": noticeBody, + "mx_EventTile_bubble_sticker": stickerBody, + }, + ); + + // 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", { + "ref": this.ref, + "className": classes, + "tabIndex": -1, + "aria-live": ariaLive, + "aria-atomic": "true", + "data-scroll-tokens": scrollToken, + "onMouseEnter": () => this.setState({ hover: true }), + "onMouseLeave": () => this.setState({ hover: false }), + }, [ +
+ { groupPadlock } +
+
+ { sender } + { thread } + { infoBubble ? avatar : null } + + { !mediaBody ? actionBar : null } +
+ { keyRequestInfo } + { reactionsRow } +
+
, + !infoBubble ? avatar : null, + msgOption, + ]) + ); + } else { + // 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", { + "ref": this.ref, + "className": classes, + "tabIndex": -1, + "aria-live": ariaLive, + "aria-atomic": "true", + "data-scroll-tokens": scrollToken, + "onMouseEnter": () => this.setState({ hover: true }), + "onMouseLeave": () => this.setState({ hover: false }), + }, [ + ircTimestamp, + sender, + ircPadlock, +
+ { groupTimestamp } + { groupPadlock } + { thread } + + { keyRequestInfo } + { reactionsRow } + { actionBar } +
, + msgOption, + avatar, + ]) + ); + } } } } @@ -1369,15 +1511,11 @@ class SentReceipt extends React.PureComponent; } - return ( -
- - - {nonCssBadge} - {tooltip} - - -
- ); + return + + {nonCssBadge} + {tooltip} + + ; } } diff --git a/src/components/views/rooms/LinkPreviewWidget.js b/src/components/views/rooms/LinkPreviewWidget.js index 904040a067d..c43199ea02e 100644 --- a/src/components/views/rooms/LinkPreviewWidget.js +++ b/src/components/views/rooms/LinkPreviewWidget.js @@ -138,7 +138,7 @@ export default class LinkPreviewWidget extends React.Component { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return ( -
+
{ img }
diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index f7d562fca0b..f7bda1b0b29 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -33,6 +33,7 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import ReplyPreview from "./ReplyPreview"; import { UIFeature } from "../../../settings/UIFeature"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import { Layout } from '../../../settings/Layout'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore"; @@ -185,6 +186,7 @@ interface IProps { permalinkCreator: RoomPermalinkCreator; replyToEvent?: MatrixEvent; e2eStatus?: E2EStatus; + layout: Layout; } interface IState { @@ -438,6 +440,19 @@ export default class MessageComposer extends React.Component { ); } + const msgComposerClassNames = classNames( + "mx_MessageComposer", + { + // When IRC layout gets something for the message composer we can use the following + // "mx_IRCLayout": this.props.layout == Layout.IRC, + // "mx_GroupLayout": this.props.layout == Layout.Group, + + // IRC layout has nothing for message composer so use group layout stuff + "mx_GroupLayout": this.props.layout == Layout.IRC || this.props.layout == Layout.Group, + "mx_BubbleLayout": this.props.layout == Layout.Bubble, + }, + ); + let recordingTooltip; const secondsLeft = Math.round(this.state.recordingTimeLeftSeconds); if (secondsLeft) { @@ -448,7 +463,7 @@ export default class MessageComposer extends React.Component { } return ( -
+
{recordingTooltip}
diff --git a/src/components/views/rooms/SearchResultTile.js b/src/components/views/rooms/SearchResultTile.js index 3b79aa62463..cfdbb177f7d 100644 --- a/src/components/views/rooms/SearchResultTile.js +++ b/src/components/views/rooms/SearchResultTile.js @@ -70,6 +70,8 @@ export default class SearchResultTile extends React.Component { isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} alwaysShowTimestamps={alwaysShowTimestamps} enableFlair={SettingsStore.getValue(UIFeature.Flair)} + layout={this.props.layout} + singleSideBubbles={this.props.singleSideBubbles} /> )); } diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index 9e27ed968e7..0911cf9a0a0 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -37,6 +37,8 @@ import StyledRadioGroup from "../../../elements/StyledRadioGroup"; import { SettingLevel } from "../../../../../settings/SettingLevel"; import { UIFeature } from "../../../../../settings/UIFeature"; import { Layout } from "../../../../../settings/Layout"; +import classNames from 'classnames'; +import StyledRadioButton from '../../../elements/StyledRadioButton'; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; import { compare } from "../../../../../utils/strings"; @@ -65,6 +67,7 @@ interface IState extends IThemeState { systemFont: string; showAdvanced: boolean; layout: Layout; + adaptiveSideBubbles: boolean; // User profile data for the message preview userId: string; displayName: string; @@ -90,6 +93,7 @@ export default class AppearanceUserSettingsTab extends React.Component): void => { + let layout; + switch (e.target.value) { + case "irc": layout = Layout.IRC; break; + case "group": layout = Layout.Group; break; + case "bubble": layout = Layout.Bubble; break; + } + + this.setState({ layout: layout }); + + SettingsStore.setValue("layout", null, SettingLevel.DEVICE, layout); + }; + private onIRCLayoutChange = (enabled: boolean) => { if (enabled) { this.setState({layout: Layout.IRC}); @@ -367,6 +384,77 @@ export default class AppearanceUserSettingsTab extends React.Component; } + private renderLayoutSection = () => { + return
+ { _t("Message layout") } + +
+
+ + + { "IRC" } + +
+
+
+ + + {_t("Modern")} + +
+
+
+ + + {_t("Message bubbles")} + +
+
+
; + }; + private renderAdvancedSection() { if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null; @@ -390,14 +478,33 @@ export default class AppearanceUserSettingsTab extends React.Component - this.onIRCLayoutChange(ev.target.checked)} - > - {_t("Enable experimental, compact IRC style layout")} - + { SettingsStore.getValue("feature_new_layout_switcher") ? + : null + } + { SettingsStore.getValue("feature_new_layout_switcher") ? + this.setState({ adaptiveSideBubbles: checked })} + disabled={this.state.layout !== Layout.Bubble} + /> : null + } + { !SettingsStore.getValue("feature_new_layout_switcher") ? + this.onIRCLayoutChange(ev.target.checked)} + > + {_t("Enable experimental, compact IRC style layout")} + : null + } {_t("Appearance Settings only affect this %(brand)s session.", { brand })}
- {this.renderThemeSection()} - {this.renderFontSection()} - {this.renderAdvancedSection()} + { this.renderThemeSection() } + { SettingsStore.getValue("feature_new_layout_switcher") ? this.renderLayoutSection() : null } + { this.renderFontSection() } + { this.renderAdvancedSection() }
); } diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 3464f952a65..1955daa086c 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -40,6 +40,8 @@ const RoomContext = createContext({ canReact: false, canReply: false, layout: Layout.Group, + singleSideBubbles: false, + adaptiveSideBubbles: false, lowBandwidth: false, showReadReceipts: true, showRedactions: true, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8c4262fe440..3b581bdc839 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -809,6 +809,7 @@ "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", "Enable advanced debugging for the room list": "Enable advanced debugging for the room list", "Show info about bridges in room settings": "Show info about bridges in room settings", + "Explore new ways switching layouts (including a new bubble layout)": "Explore new ways switching layouts (including a new bubble layout)", "Font size": "Font size", "Use custom size": "Use custom size", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", @@ -862,6 +863,8 @@ "How fast should messages be downloaded.": "How fast should messages be downloaded.", "Manually verify all remote sessions": "Manually verify all remote sessions", "IRC display name width": "IRC display name width", + "Show message bubbles on one side only": "Show message bubbles on one side only", + "Show message bubbles depending on the width either on both sides or only on one side": "Show message bubbles depending on the width either on both sides or only on one side", "Show chat effects (animations when receiving e.g. confetti)": "Show chat effects (animations when receiving e.g. confetti)", "Collecting app version information": "Collecting app version information", "Collecting logs": "Collecting logs", @@ -1223,6 +1226,9 @@ "Custom theme URL": "Custom theme URL", "Add theme": "Add theme", "Theme": "Theme", + "Message layout": "Message layout", + "Modern": "Modern", + "Message bubbles": "Message bubbles", "Hide advanced": "Hide advanced", "Show advanced": "Show advanced", "Set the name of a font installed on your system & %(brand)s will attempt to use it.": "Set the name of a font installed on your system & %(brand)s will attempt to use it.", diff --git a/src/settings/Layout.ts b/src/settings/Layout.ts index 3a42b2b5102..d4e1f06c0ab 100644 --- a/src/settings/Layout.ts +++ b/src/settings/Layout.ts @@ -1,5 +1,6 @@ /* Copyright 2021 Šimon Brandner +Copyright 2021 Quirin Götz Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,7 +20,8 @@ import PropTypes from 'prop-types'; /* TODO: This should be later reworked into something more generic */ export enum Layout { IRC = "irc", - Group = "group" + Group = "group", + Bubble = "bubble", } /* We need this because multiple components are still using JavaScript */ diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 155d0395728..3a051bd773d 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -41,6 +41,7 @@ import { Layout } from "./Layout"; import ReducedMotionController from './controllers/ReducedMotionController'; import IncompatibleController from "./controllers/IncompatibleController"; import SdkConfig from "../SdkConfig"; +import NewLayoutSwitcherController from './controllers/NewLayoutSwitcherController'; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = [ @@ -285,6 +286,13 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td("Show info about bridges in room settings"), default: false, }, + "feature_new_layout_switcher": { + isFeature: true, + supportedLevels: LEVELS_FEATURE, + displayName: _td("Explore new ways switching layouts (including a new bubble layout)"), + default: false, + controller: new NewLayoutSwitcherController(), + }, "RoomList.backgroundImage": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: null, @@ -719,6 +727,16 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: Layout.Group, }, + "singleSideBubbles": { + supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM, + displayName: _td("Show message bubbles on one side only"), + default: false, + }, + "adaptiveSideBubbles": { + supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM, + displayName: _td("Show message bubbles depending on the width either on both sides or only on one side"), + default: true, + }, "showChatEffects": { supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM, displayName: _td("Show chat effects (animations when receiving e.g. confetti)"), diff --git a/src/settings/controllers/NewLayoutSwitcherController.ts b/src/settings/controllers/NewLayoutSwitcherController.ts new file mode 100644 index 00000000000..1c623db7134 --- /dev/null +++ b/src/settings/controllers/NewLayoutSwitcherController.ts @@ -0,0 +1,29 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import SettingController from "./SettingController"; +import { SettingLevel } from "../SettingLevel"; +import SettingsStore from "../SettingsStore"; +import { Layout } from "../Layout"; + +export default class NewLayoutSwitcherController extends SettingController { + public onChange(level: SettingLevel, roomId: string, newValue: any) { + // On disabling switch back to Layout.Group if Layout.Bubble + if (!newValue && SettingsStore.getValue("layout") == Layout.Bubble) { + SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + } + } +}