diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index 2609ddff7fb..00c7cd05b45 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -108,26 +108,25 @@ limitations under the License. } .mx_Toast_title { + display: flex; + align-items: center; + column-gap: 8px; width: 100%; box-sizing: border-box; h2 { - grid-column: 1 / 3; - grid-row: 1; margin: 0; font-size: $font-15px; font-weight: 600; display: inline; width: auto; - vertical-align: middle; } - span { - padding-left: 8px; - float: right; + .mx_Toast_title_countIndicator { font-size: $font-12px; line-height: $font-22px; color: $secondary-content; + margin-inline-start: auto; // on the end side of the div } } @@ -137,17 +136,14 @@ limitations under the License. } .mx_Toast_buttons { - float: right; display: flex; + justify-content: flex-end; + column-gap: 5px; .mx_AccessibleButton { min-width: 96px; box-sizing: border-box; } - - .mx_AccessibleButton + .mx_AccessibleButton { - margin-left: 5px; - } } .mx_Toast_description { diff --git a/res/css/views/messages/_CallEvent.scss b/res/css/views/messages/_CallEvent.scss index b5ef5b0bc17..b0b117efded 100644 --- a/res/css/views/messages/_CallEvent.scss +++ b/res/css/views/messages/_CallEvent.scss @@ -19,22 +19,24 @@ limitations under the License. width: 100%; .mx_CallEvent { - position: relative; display: flex; flex-direction: row; + flex-wrap: wrap; align-items: center; justify-content: space-between; + gap: $spacing-4 0; + position: relative; + margin: $spacing-4 0; + padding: $spacing-12 $spacing-24; + box-sizing: border-box; background-color: $dark-panel-bg-color; border-radius: 8px; width: 65%; - box-sizing: border-box; - height: 60px; - margin: 4px 0; + height: fit-content; .mx_CallEvent_iconButton { display: inline-flex; - margin-right: 8px; &::before { content: ''; @@ -62,6 +64,13 @@ limitations under the License. .mx_CallEvent_content_button_answer span::before { mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); } + + &.mx_CallEvent_rejected, + &.mx_CallEvent_noAnswer { + .mx_CallEvent_type_icon::before { + mask-image: url('$(res)/img/voip/declined-voice.svg'); + } + } } &.mx_CallEvent_video { @@ -70,44 +79,49 @@ limitations under the License. .mx_CallEvent_content_button_answer span::before { mask-image: url('$(res)/img/element-icons/call/video-call.svg'); } - } - &.mx_CallEvent_voice.mx_CallEvent_missed .mx_CallEvent_type_icon::before { - mask-image: url('$(res)/img/voip/missed-voice.svg'); - } - - &.mx_CallEvent_video.mx_CallEvent_missed .mx_CallEvent_type_icon::before { - mask-image: url('$(res)/img/voip/missed-video.svg'); + &.mx_CallEvent_rejected, + &.mx_CallEvent_noAnswer { + .mx_CallEvent_type_icon::before { + mask-image: url('$(res)/img/voip/declined-video.svg'); + } + } } - &.mx_CallEvent_voice.mx_CallEvent_rejected .mx_CallEvent_type_icon::before, - &.mx_CallEvent_voice.mx_CallEvent_noAnswer .mx_CallEvent_type_icon::before { - mask-image: url('$(res)/img/voip/declined-voice.svg'); - } + &.mx_CallEvent_missed { + &.mx_CallEvent_voice { + .mx_CallEvent_type_icon::before { + mask-image: url('$(res)/img/voip/missed-voice.svg'); + } + } - &.mx_CallEvent_video.mx_CallEvent_rejected .mx_CallEvent_type_icon::before, - &.mx_CallEvent_video.mx_CallEvent_noAnswer .mx_CallEvent_type_icon::before { - mask-image: url('$(res)/img/voip/declined-video.svg'); + &.mx_CallEvent_video { + .mx_CallEvent_type_icon::before { + mask-image: url('$(res)/img/voip/missed-video.svg'); + } + } } .mx_CallEvent_info { display: flex; flex-direction: row; align-items: center; - margin-left: 12px; - min-width: 0; + width: fit-content; + max-width: 100%; .mx_CallEvent_info_basic { display: flex; flex-direction: column; + gap: $spacing-4; margin-left: 10px; // To match mx_CallEvent + margin-right: 10px; min-width: 0; .mx_CallEvent_sender { font-weight: 600; font-size: 1.5rem; line-height: 1.8rem; - margin-bottom: 3px; + margin-bottom: $spacing-4; overflow: hidden; white-space: nowrap; @@ -115,12 +129,12 @@ limitations under the License. } .mx_CallEvent_type { + display: flex; + align-items: center; font-weight: 400; color: $secondary-content; font-size: 1.2rem; line-height: $font-13px; - display: flex; - align-items: center; .mx_CallEvent_type_icon { height: 13px; @@ -143,16 +157,17 @@ limitations under the License. .mx_CallEvent_content { display: flex; - flex-direction: row; + flex-wrap: wrap; align-items: center; color: $secondary-content; - margin-right: 16px; - gap: 12px; // See mx_IncomingCallToast_buttons - min-width: max-content; + gap: $spacing-12; // See mx_IncomingCallToast_buttons + margin-inline-start: 42px; // avatar (32px) + mx_CallEvent_info_basic margin (10px) + word-break: break-word; + max-width: fit-content; .mx_CallEvent_content_button { @mixin CallButton; - padding: 0 12px; + padding: 0 $spacing-12; span::before { mask-size: 16px; @@ -162,8 +177,10 @@ limitations under the License. } } - .mx_CallEvent_content_button_reject span::before { - mask-image: url('$(res)/img/element-icons/call/hangup.svg'); + .mx_CallEvent_content_button_reject { + span::before { + mask-image: url('$(res)/img/element-icons/call/hangup.svg'); + } } .mx_CallEvent_content_tooltip { @@ -171,16 +188,12 @@ limitations under the License. } } - .mx_MessageTimestamp { - margin-left: 16px; - } - &.mx_CallEvent_narrow { - height: unset; - width: 290px; flex-direction: column; align-items: unset; - gap: 16px; + gap: $spacing-4 $spacing-16; + height: unset; + min-width: 290px; .mx_CallEvent_iconButton { position: absolute; @@ -194,18 +207,36 @@ limitations under the License. .mx_CallEvent_info { align-items: unset; - margin-top: 12px; - margin-right: 12px; - - .mx_CallEvent_sender { - margin-bottom: 8px; - } } + } + } +} + +.mx_EventTile[data-layout="bubble"] { + .mx_EventTile_e2eIcon + .mx_CallEvent_wrapper { + .mx_CallEvent { + position: relative; + + // 5px (gap) + 14px (e2e icon size * mask-size) + 9px (margin-left of e2e icon) + right: calc(5px + 14px + 9px); + } + } +} - .mx_CallEvent_content { - margin-left: 54px; // mx_CallEvent margin (12px) + avatar (32px) + mx_CallEvent_info_basic margin (10px) - margin-bottom: 16px; +.mx_EventTile_leftAlignedBubble { + .mx_CallEvent_wrapper { + .mx_CallEvent { + &.mx_CallEvent_narrow { + gap: $spacing-8 $spacing-4; } } } } + +.mx_IRCLayout { + .mx_CallEvent_wrapper { + .mx_CallEvent { + margin-inline-start: $spacing-4; // display green line + } + } +} diff --git a/res/css/views/right_panel/_TimelineCard.scss b/res/css/views/right_panel/_TimelineCard.scss index 887fdd12fbf..447933a39f9 100644 --- a/res/css/views/right_panel/_TimelineCard.scss +++ b/res/css/views/right_panel/_TimelineCard.scss @@ -55,29 +55,61 @@ limitations under the License. margin-right: 0; } - .mx_EventTile:not([data-layout="bubble"]) .mx_EventTile_line { - padding-left: 36px; - padding-right: 36px; - } + .mx_EventTile:not([data-layout="bubble"]) { + .mx_EventTile_line { + padding-left: 36px; + padding-right: 36px; + } - .mx_EventTile:not([data-layout="bubble"]) .mx_ReactionsRow { - padding-left: 36px; - padding-right: 36px; - } + .mx_ReactionsRow { + padding: 0; - .mx_EventTile:not([data-layout="bubble"]) .mx_ThreadInfo { - margin-left: 36px; - margin-right: 0; - max-width: min(calc(100% - 36px), 600px); - } + // See margin setting of ReactionsRow on _EventTile.scss + margin-left: 36px; + margin-right: 8px; + } - .mx_GroupLayout .mx_EventTile > .mx_DisambiguatedProfile { - margin-left: 36px; + .mx_ThreadInfo { + margin-left: 36px; + margin-right: 0; + max-width: min(calc(100% - 36px), 600px); + } + + .mx_EventTile_avatar { + top: 12px; + left: -3px; + } + + .mx_MessageTimestamp { + right: -4px; + left: auto; + } + + .mx_EventTile_msgOption { + margin-right: 2px; + } + + &.mx_EventTile_info { + .mx_EventTile_line { + padding-left: 36px; + } + + .mx_EventTile_avatar { + left: 18px; + } + } } - .mx_EventTile:not([data-layout="bubble"]) .mx_EventTile_avatar { - top: 12px; - left: -3px; + .mx_GroupLayout { + .mx_EventTile { + > .mx_DisambiguatedProfile { + margin-left: 36px; + } + + .mx_EventTile_line { + padding-bottom: 8px; + } + } } .mx_CallEvent_wrapper { @@ -88,36 +120,15 @@ limitations under the License. } } - .mx_EventTile:not([data-layout="bubble"]) .mx_MessageTimestamp { - right: -4px; - left: auto; - } - - .mx_EventTile:not([data-layout="bubble"]) .mx_EventTile_msgOption { - margin-right: 2px; - } - .mx_GenericEventListSummary:not([data-layout=bubble]) .mx_EventTile_line, .mx_GenericEventListSummary:not([data-layout=bubble]) > .mx_GenericEventListSummary_unstyledList > .mx_EventTile_info .mx_EventTile_avatar ~ .mx_EventTile_line { padding-left: 36px; } - .mx_GroupLayout .mx_EventTile .mx_EventTile_line { - padding-bottom: 8px; - } - .mx_EventTile_readAvatars { top: -10px; } - .mx_EventTile:not([data-layout="bubble"]).mx_EventTile_info .mx_EventTile_line { - padding-left: 36px; - } - - .mx_EventTile:not([data-layout="bubble"]).mx_EventTile_info .mx_EventTile_avatar { - left: 18px; - } - .mx_WhoIsTypingTile { margin-left: -12px; // undo padding on the message list } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index ddecd526aba..7d2fb2817d5 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -317,6 +317,7 @@ $left-gutter: 64px; .mx_EventTile_line { /* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */ margin-right: 110px; + min-height: $font-14px; } .mx_ThreadInfo { @@ -1005,6 +1006,7 @@ $threadInfoLineHeight: calc(2 * $font-12px); } .mx_EventTile[data-layout=group] { + $spacing-start: 48px; width: 100%; .mx_EventTile_content, @@ -1012,8 +1014,9 @@ $threadInfoLineHeight: calc(2 * $font-12px); .mx_RedactedBody, .mx_UnknownBody, .mx_MPollBody, - .mx_ReplyChain_wrapper { - margin-left: 48px; + .mx_ReplyChain_wrapper, + .mx_ReactionsRow { + margin-left: $spacing-start; margin-right: 8px; .mx_EventTile_content, @@ -1024,11 +1027,6 @@ $threadInfoLineHeight: calc(2 * $font-12px); } } - .mx_ReactionsRow { - margin-left: 36px; - margin-right: 8px; - } - .mx_MessageTimestamp { top: 2px !important; width: auto; @@ -1052,10 +1050,14 @@ $threadInfoLineHeight: calc(2 * $font-12px); } } } + + .mx_EventTile_mediaLine { + padding-inline-start: $spacing-start; + } } .mx_EventTile_mediaLine { - padding-left: 36px !important; + padding-left: 36px; padding-right: 50px; .mx_MImageBody { diff --git a/src/Unread.ts b/src/Unread.ts index e596bdbe50e..7deafef9678 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -48,7 +48,7 @@ export function eventTriggersUnreadCount(ev: MatrixEvent): boolean { } if (ev.isRedacted()) return false; - return haveRendererForEvent(ev); + return haveRendererForEvent(ev, false /* hidden messages should never trigger unread counts anyways */); } export function doesRoomHaveUnreadMessages(room: Room): boolean { diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 55d58a89536..4c051a71269 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -3,6 +3,7 @@ Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd Copyright 2017, 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2022 Ryan Browne Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -62,6 +63,13 @@ function score(query, space) { } } +function colonsTrimmed(str: string): string { + // Trim off leading and potentially trailing `:` to correctly match the emoji data as they exist in emojibase. + // Notes: The regex is pinned to the start and end of the string so that we can use the lazy-capturing `*?` matcher. + // It needs to be lazy so that the trailing `:` is not captured in the replacement group, if it exists. + return str.replace(/^:(.*?):?$/, "$1"); +} + export default class EmojiProvider extends AutocompleteProvider { matcher: QueryMatcher; nameMatcher: QueryMatcher; @@ -108,8 +116,9 @@ export default class EmojiProvider extends AutocompleteProvider { // then sort by score (Infinity if matchedString not in shortcode) sorters.push(c => score(matchedString, c.emoji.shortcodes[0])); // then sort by max score of all shortcodes, trim off the `:` + const trimmedMatch = colonsTrimmed(matchedString); sorters.push(c => Math.min( - ...c.emoji.shortcodes.map(s => score(matchedString.substring(1), s)), + ...c.emoji.shortcodes.map(s => score(trimmedMatch, s)), )); // If the matchedString is not empty, sort by length of shortcode. Example: // matchedString = ":bookmark" diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index a4791a1ec57..ff9bc826528 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -242,7 +242,7 @@ export default class MessagePanel extends React.Component { // displayed event in the current render cycle. private readReceiptsByUserId: Record = {}; - private readonly showHiddenEventsInTimeline: boolean; + private readonly _showHiddenEvents: boolean; private readonly threadsEnabled: boolean; private isMounted = false; @@ -270,7 +270,7 @@ export default class MessagePanel extends React.Component { // Cache these settings on mount since Settings is expensive to query, // and we check this in a hot code path. This is also cached in our // RoomContext, however we still need a fallback for roomless MessagePanels. - this.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline"); + this._showHiddenEvents = SettingsStore.getValue("showHiddenEventsInTimeline"); this.threadsEnabled = SettingsStore.getValue("feature_thread"); this.showTypingNotificationsWatcherRef = @@ -465,7 +465,7 @@ export default class MessagePanel extends React.Component { }; public get showHiddenEvents(): boolean { - return this.context?.showHiddenEventsInTimeline ?? this.showHiddenEventsInTimeline; + return this.context?.showHiddenEvents ?? this._showHiddenEvents; } // TODO: Implement granular (per-room) hide options @@ -748,7 +748,7 @@ export default class MessagePanel extends React.Component { const willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEv.getDate() || new Date()); lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEv.getSender() || - getEventDisplayInfo(nextEv).isInfoMessage || + getEventDisplayInfo(nextEv, this.showHiddenEvents).isInfoMessage || !shouldFormContinuation( mxEv, nextEv, this.showHiddenEvents, this.threadsEnabled, this.context.timelineRenderingType, ); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 321c598ea6b..f53e75db0be 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -199,7 +199,7 @@ export interface IRoomState { showTwelveHourTimestamps: boolean; readMarkerInViewThresholdMs: number; readMarkerOutOfViewThresholdMs: number; - showHiddenEventsInTimeline: boolean; + showHiddenEvents: boolean; showReadReceipts: boolean; showRedactions: boolean; showJoinLeaves: boolean; @@ -271,7 +271,7 @@ export class RoomView extends React.Component { showTwelveHourTimestamps: SettingsStore.getValue("showTwelveHourTimestamps"), readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"), readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"), - showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"), + showHiddenEvents: SettingsStore.getValue("showHiddenEventsInTimeline"), showReadReceipts: true, showRedactions: true, showJoinLeaves: true, @@ -328,7 +328,7 @@ export class RoomView extends React.Component { this.setState({ readMarkerOutOfViewThresholdMs: value as number }), ), SettingsStore.watchSetting("showHiddenEventsInTimeline", null, (...[,,, value]) => - this.setState({ showHiddenEventsInTimeline: value as boolean }), + this.setState({ showHiddenEvents: value as boolean }), ), ]; } @@ -1480,7 +1480,7 @@ export class RoomView extends React.Component { continue; } - if (!haveRendererForEvent(mxEv, this.state.showHiddenEventsInTimeline)) { + if (!haveRendererForEvent(mxEv, this.state.showHiddenEvents)) { // XXX: can this ever happen? It will make the result count // not match the displayed count. continue; diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 03798198adc..1dd37a8c412 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -250,7 +250,7 @@ const ThreadPanel: React.FC = ({ { const shouldIgnore = !!ev.status || // local echo (ignoreOwn && ev.getSender() === myUserId); // own message - const isWithoutTile = !haveRendererForEvent(ev, this.context?.showHiddenEventsInTimeline) || + const isWithoutTile = !haveRendererForEvent(ev, this.context?.showHiddenEvents) || shouldHideEvent(ev, this.context); if (isWithoutTile || !node) { diff --git a/src/components/structures/ToastContainer.tsx b/src/components/structures/ToastContainer.tsx index 7f2969e5af5..4cd1e654af2 100644 --- a/src/components/structures/ToastContainer.tsx +++ b/src/components/structures/ToastContainer.tsx @@ -79,7 +79,7 @@ export default class ToastContainer extends React.Component<{}, IState> { titleElement = (

{ title }

- { countIndicator } + { countIndicator }
); } diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index 840acd456a5..e9e8c9474a5 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -49,6 +49,7 @@ import WidgetUtils from '../../../utils/WidgetUtils'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { ActionPayload } from "../../../dispatcher/payloads"; import { Action } from '../../../dispatcher/actions'; +import { ElementWidgetCapabilities } from '../../../stores/widgets/ElementWidgetCapabilities'; interface IProps { app: IApp; @@ -430,7 +431,7 @@ export default class AppTile extends React.Component { private onWidgetCapabilitiesNotified = (): void => { this.setState({ - requiresClient: this.sgWidget.widgetApi.hasCapability(MatrixCapabilities.RequiresClient), + requiresClient: this.sgWidget.widgetApi.hasCapability(ElementWidgetCapabilities.RequiresClient), }); }; diff --git a/src/components/views/messages/TextualEvent.tsx b/src/components/views/messages/TextualEvent.tsx index c92d7b55c7d..fb217607fef 100644 --- a/src/components/views/messages/TextualEvent.tsx +++ b/src/components/views/messages/TextualEvent.tsx @@ -28,7 +28,7 @@ export default class TextualEvent extends React.Component { static contextType = RoomContext; public render() { - const text = TextForEvent.textForEvent(this.props.mxEvent, true, this.context?.showHiddenEventsInTimeline); + const text = TextForEvent.textForEvent(this.props.mxEvent, true, this.context?.showHiddenEvents); if (!text) return null; return
{ text }
; } diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index a96a6c2961d..934f88b0a74 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -178,7 +178,7 @@ function useHasCrossSigningKeys(cli: MatrixClient, member: User, canVerify: bool }, [cli, member, canVerify], undefined); } -function DeviceItem({ userId, device }: {userId: string, device: IDevice}) { +function DeviceItem({ userId, device }: { userId: string, device: IDevice }) { const cli = useContext(MatrixClientContext); const isMe = userId === cli.getUserId(); const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId); @@ -239,7 +239,7 @@ function DeviceItem({ userId, device }: {userId: string, device: IDevice}) { } } -function DevicesSection({ devices, userId, loading }: {devices: IDevice[], userId: string, loading: boolean}) { +function DevicesSection({ devices, userId, loading }: { devices: IDevice[], userId: string, loading: boolean }) { const cli = useContext(MatrixClientContext); const userTrust = cli.checkUserTrust(userId); @@ -653,7 +653,9 @@ const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit { msgOption = readAvatars; } - const replyChain = - (haveRendererForEvent(this.props.mxEvent) && shouldDisplayReply(this.props.mxEvent)) - ? - : null; + let replyChain; + if ( + haveRendererForEvent(this.props.mxEvent, this.context.showHiddenEvents) && + shouldDisplayReply(this.props.mxEvent) + ) { + replyChain = ; + } const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId(); @@ -1267,7 +1270,7 @@ export class UnwrappedEventTile extends React.Component { highlightLink: this.props.highlightLink, onHeightChanged: this.props.onHeightChanged, permalinkCreator: this.props.permalinkCreator, - }) } + }, this.context.showHiddenEvents) } , ]); } @@ -1309,7 +1312,7 @@ export class UnwrappedEventTile extends React.Component { highlightLink: this.props.highlightLink, onHeightChanged: this.props.onHeightChanged, permalinkCreator: this.props.permalinkCreator, - }) } + }, this.context.showHiddenEvents) } { actionBar } { timestamp } @@ -1395,7 +1398,7 @@ export class UnwrappedEventTile extends React.Component { highlightLink: this.props.highlightLink, onHeightChanged: this.props.onHeightChanged, permalinkCreator: this.props.permalinkCreator, - }) } + }, this.context.showHiddenEvents) } , { highlightLink: this.props.highlightLink, onHeightChanged: this.props.onHeightChanged, permalinkCreator: this.props.permalinkCreator, - }) } + }, this.context.showHiddenEvents) } { keyRequestInfo } { actionBar } { this.props.layout === Layout.IRC && <> diff --git a/src/components/views/rooms/ReplyTile.tsx b/src/components/views/rooms/ReplyTile.tsx index bfd592f6985..2b973abfca5 100644 --- a/src/components/views/rooms/ReplyTile.tsx +++ b/src/components/views/rooms/ReplyTile.tsx @@ -110,7 +110,9 @@ export default class ReplyTile extends React.PureComponent { const msgType = mxEvent.getContent().msgtype; const evType = mxEvent.getType() as EventType; - const { hasRenderer, isInfoMessage, isSeeingThroughMessageHiddenForModeration } = getEventDisplayInfo(mxEvent); + const { + hasRenderer, isInfoMessage, isSeeingThroughMessageHiddenForModeration, + } = getEventDisplayInfo(mxEvent, false /* Replies are never hidden, so this should be fine */); // This shouldn't happen: the caller should check we support this type // before trying to instantiate us if (!hasRenderer) { @@ -177,7 +179,7 @@ export default class ReplyTile extends React.PureComponent { highlightLink: this.props.highlightLink, onHeightChanged: this.props.onHeightChanged, permalinkCreator: this.props.permalinkCreator, - }) } + }, false /* showHiddenEvents shouldn't be relevant */) } ); diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx index a3d646aed3e..d4bb791aa86 100644 --- a/src/components/views/rooms/SearchResultTile.tsx +++ b/src/components/views/rooms/SearchResultTile.tsx @@ -78,7 +78,7 @@ export default class SearchResultTile extends React.Component { highlights = this.props.searchHighlights; } - if (haveRendererForEvent(mxEv, this.context?.showHiddenEventsInTimeline)) { + if (haveRendererForEvent(mxEv, this.context?.showHiddenEvents)) { // do we need a date separator since the last event? const prevEv = timeline[j - 1]; // is this a continuation of the previous message? @@ -87,7 +87,7 @@ export default class SearchResultTile extends React.Component { shouldFormContinuation( prevEv, mxEv, - this.context?.showHiddenEventsInTimeline, + this.context?.showHiddenEvents, threadsEnabled, TimelineRenderingType.Search, ); @@ -102,7 +102,7 @@ export default class SearchResultTile extends React.Component { !shouldFormContinuation( mxEv, nextEv, - this.context?.showHiddenEventsInTimeline, + this.context?.showHiddenEvents, threadsEnabled, TimelineRenderingType.Search, ) diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 631b849e013..9c44553a983 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -54,7 +54,7 @@ const RoomContext = createContext({ showTwelveHourTimestamps: false, readMarkerInViewThresholdMs: 3000, readMarkerOutOfViewThresholdMs: 30000, - showHiddenEventsInTimeline: false, + showHiddenEvents: false, showReadReceipts: true, showRedactions: true, showJoinLeaves: true, diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index 3f470a19b61..7beda8cc305 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -141,19 +141,25 @@ const SINGULAR_STATE_EVENTS = new Set([ * Find an event tile factory for the given conditions. * @param mxEvent The event. * @param cli The matrix client to reference when needed. + * @param showHiddenEvents Whether hidden events should be shown. * @param asHiddenEv When true, treat the event as always hidden. * @returns The factory, or falsy if not possible. */ -export function pickFactory(mxEvent: MatrixEvent, cli: MatrixClient, asHiddenEv?: boolean): Optional { +export function pickFactory( + mxEvent: MatrixEvent, + cli: MatrixClient, + showHiddenEvents: boolean, + asHiddenEv?: boolean, +): Optional { const evType = mxEvent.getType(); // cache this to reduce call stack execution hits // Note: we avoid calling SettingsStore unless absolutely necessary - this code is on the critical path. - if (asHiddenEv && SettingsStore.getValue("showHiddenEventsInTimeline")) { + if (asHiddenEv && showHiddenEvents) { return JSONEventFactory; } - const noEventFactoryFactory: (() => Optional) = () => SettingsStore.getValue("showHiddenEventsInTimeline") + const noEventFactoryFactory: (() => Optional) = () => showHiddenEvents ? JSONEventFactory : undefined; // just don't render things that we shouldn't render @@ -242,17 +248,19 @@ export function pickFactory(mxEvent: MatrixEvent, cli: MatrixClient, asHiddenEv? * Render an event as a tile * @param renderType The render type. Used to inform properties given to the eventual component. * @param props The properties to provide to the eventual component. + * @param showHiddenEvents Whether hidden events should be shown. * @param cli Optional client instance to use, otherwise the default MatrixClientPeg will be used. * @returns The tile as JSX, or falsy if unable to render. */ export function renderTile( renderType: TimelineRenderingType, props: EventTileTypeProps, + showHiddenEvents: boolean, cli?: MatrixClient, ): Optional { cli = cli ?? MatrixClientPeg.get(); // because param defaults don't do the correct thing - const factory = pickFactory(props.mxEvent, cli); + const factory = pickFactory(props.mxEvent, cli, showHiddenEvents); if (!factory) return undefined; // Note that we split off the ones we actually care about here just to be sure that we're @@ -316,16 +324,18 @@ export function renderTile( /** * A version of renderTile() specifically for replies. * @param props The properties to specify on the eventual object. + * @param showHiddenEvents Whether hidden events should be shown. * @param cli Optional client instance to use, otherwise the default MatrixClientPeg will be used. * @returns The tile as JSX, or falsy if unable to render. */ export function renderReplyTile( props: EventTileTypeProps, + showHiddenEvents: boolean, cli?: MatrixClient, ): Optional { cli = cli ?? MatrixClientPeg.get(); // because param defaults don't do the correct thing - const factory = pickFactory(props.mxEvent, cli); + const factory = pickFactory(props.mxEvent, cli, showHiddenEvents); if (!factory) return undefined; // See renderTile() for why we split off so much @@ -367,7 +377,7 @@ export function isMessageEvent(ev: MatrixEvent): boolean { return (messageTypes.includes(ev.getType() as EventType)) || M_POLL_START.matches(ev.getType()); } -export function haveRendererForEvent(mxEvent: MatrixEvent, showHiddenEvents?: boolean): boolean { +export function haveRendererForEvent(mxEvent: MatrixEvent, showHiddenEvents: boolean): boolean { // Only show "Message deleted" tile for plain message events, encrypted events, // and state events as they'll likely still contain enough keys to be relevant. if (mxEvent.isRedacted() && !mxEvent.isEncrypted() && !isMessageEvent(mxEvent) && !mxEvent.isState()) { @@ -377,7 +387,7 @@ export function haveRendererForEvent(mxEvent: MatrixEvent, showHiddenEvents?: bo // No tile for replacement events since they update the original tile if (mxEvent.isRelation(RelationType.Replace)) return false; - const handler = pickFactory(mxEvent, MatrixClientPeg.get()); + const handler = pickFactory(mxEvent, MatrixClientPeg.get(), showHiddenEvents); if (!handler) return false; if (handler === TextualEventFactory) { return hasText(mxEvent, showHiddenEvents); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3195ed38b17..081a2806873 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1992,7 +1992,10 @@ "Failed to remove user": "Failed to remove user", "Remove from room": "Remove from room", "Remove recent messages": "Remove recent messages", - "Ban": "Ban", + "Unban from space": "Unban from space", + "Ban from space": "Ban from space", + "Unban from room": "Unban from room", + "Ban from room": "Ban from room", "Unban from %(roomName)s": "Unban from %(roomName)s", "Ban from %(roomName)s": "Ban from %(roomName)s", "Unban them from everything I'm able to": "Unban them from everything I'm able to", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index bd34297161b..2de76013636 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -187,6 +187,8 @@ export const SETTINGS: {[setting: string]: ISetting} = { "feature_msc3531_hide_messages_pending_moderation": { isFeature: true, labsGroup: LabGroup.Moderation, + // Requires a reload since this setting is cached in EventUtils + controller: new ReloadOnChangeController(), displayName: _td("Let moderators hide messages pending moderation."), supportedLevels: LEVELS_FEATURE, default: false, diff --git a/src/stores/widgets/ElementWidgetCapabilities.ts b/src/stores/widgets/ElementWidgetCapabilities.ts index e493d5618f6..fd6653294c5 100644 --- a/src/stores/widgets/ElementWidgetCapabilities.ts +++ b/src/stores/widgets/ElementWidgetCapabilities.ts @@ -19,4 +19,9 @@ export enum ElementWidgetCapabilities { * @deprecated Use MSC2931 instead. */ CanChangeViewedRoom = "io.element.view_room", + /** + * Ask Element to not give the option to move the widget into a separate tab. + * This replaces RequiresClient in MatrixCapabilities. + */ + RequiresClient = "io.element.requires_client", } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index c33bbe06cff..6591a2c793f 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -49,6 +49,7 @@ import dis from "../../dispatcher/dispatcher"; import { tryTransformPermalinkToLocalHref } from "../../utils/permalinks/Permalinks"; import SettingsStore from "../../settings/SettingsStore"; import { RoomViewStore } from "../RoomViewStore"; +import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; // TODO: Purge this from the universe @@ -77,7 +78,7 @@ export class StopGapWidgetDriver extends WidgetDriver { // button if the widget says it supports screenshots. this.allowedCapabilities = new Set([...allowedCapabilities, MatrixCapabilities.Screenshots, - MatrixCapabilities.RequiresClient]); + ElementWidgetCapabilities.RequiresClient]); // Grant the permissions that are specific to given widget types if (WidgetType.JITSI.matches(this.forWidget.type) && forWidgetKind === WidgetKind.Room) { diff --git a/src/utils/EventRenderingUtils.ts b/src/utils/EventRenderingUtils.ts index fd6e518c4f2..a44db854fe2 100644 --- a/src/utils/EventRenderingUtils.ts +++ b/src/utils/EventRenderingUtils.ts @@ -25,7 +25,7 @@ import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory import { MatrixClientPeg } from "../MatrixClientPeg"; import { getMessageModerationState, MessageModerationState } from "./EventUtils"; -export function getEventDisplayInfo(mxEvent: MatrixEvent, hideEvent?: boolean): { +export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: boolean, hideEvent?: boolean): { isInfoMessage: boolean; hasRenderer: boolean; isBubbleMessage: boolean; @@ -52,7 +52,7 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent, hideEvent?: boolean): } // TODO: Thread a MatrixClient through to here - let factory = pickFactory(mxEvent, MatrixClientPeg.get()); + let factory = pickFactory(mxEvent, MatrixClientPeg.get(), showHiddenEvents); // Info messages are basically information about commands processed on a room let isBubbleMessage = ( @@ -92,11 +92,11 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent, hideEvent?: boolean): // source tile when there's no regular tile for an event and also for // replace relations (which otherwise would display as a confusing // duplicate of the thing they are replacing). - if (hideEvent || !haveRendererForEvent(mxEvent)) { + if (hideEvent || !haveRendererForEvent(mxEvent, showHiddenEvents)) { // forcefully ask for a factory for a hidden event (hidden event // setting is checked internally) // TODO: Thread a MatrixClient through to here - factory = pickFactory(mxEvent, MatrixClientPeg.get(), true); + factory = pickFactory(mxEvent, MatrixClientPeg.get(), showHiddenEvents, true); if (factory === JSONEventFactory) { isBubbleMessage = false; // Reuse info message avatar and sender profile styling diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index afcfc411398..4b626397f93 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -151,6 +151,16 @@ export enum MessageModerationState { SEE_THROUGH_FOR_CURRENT_USER = "SEE_THROUGH_FOR_CURRENT_USER", } +// This is lazily initialized and cached since getMessageModerationState needs it, +// and is called on timeline rendering hot-paths +let msc3531Enabled: boolean | null = null; +const getMsc3531Enabled = (): boolean => { + if (msc3531Enabled === null) { + msc3531Enabled = SettingsStore.getValue("feature_msc3531_hide_messages_pending_moderation"); + } + return msc3531Enabled; +}; + /** * Determine whether a message should be displayed as hidden pending moderation. * @@ -160,7 +170,7 @@ export enum MessageModerationState { export function getMessageModerationState(mxEvent: MatrixEvent, client?: MatrixClient): MessageModerationState { client = client ?? MatrixClientPeg.get(); // because param defaults don't do the correct thing - if (!SettingsStore.getValue("feature_msc3531_hide_messages_pending_moderation")) { + if (!getMsc3531Enabled()) { return MessageModerationState.VISIBLE_FOR_ALL; } const visibility = mxEvent.messageVisibility(); diff --git a/src/utils/VideoChannelUtils.ts b/src/utils/VideoChannelUtils.ts index d989324ed40..0387f81b8eb 100644 --- a/src/utils/VideoChannelUtils.ts +++ b/src/utils/VideoChannelUtils.ts @@ -42,6 +42,6 @@ export const addVideoChannel = async (roomId: string, roomName: string) => { export const getConnectedMembers = (state: RoomState): RoomMember[] => state.getStateEvents(VIDEO_CHANNEL_MEMBER) // Must have a device connected and still be joined to the room - .filter(e => e.getContent().devices?.length) + .filter(e => e.getContent()?.devices?.length) .map(e => state.getMember(e.getStateKey())) - .filter(member => member.membership === "join"); + .filter(member => member?.membership === "join"); diff --git a/src/utils/exportUtils/HtmlExport.tsx b/src/utils/exportUtils/HtmlExport.tsx index 89ac6146ca3..59d864b6c72 100644 --- a/src/utils/exportUtils/HtmlExport.tsx +++ b/src/utils/exportUtils/HtmlExport.tsx @@ -407,7 +407,7 @@ export default class HTMLExporter extends Exporter { total: events.length, }), false, true); if (this.cancelled) return this.cleanUp(); - if (!haveRendererForEvent(event)) continue; + if (!haveRendererForEvent(event, false)) continue; content += this.needsDateSeparator(event, prevEvent) ? this.getDateSeparator(event) : ""; const shouldBeJoined = !this.needsDateSeparator(event, prevEvent) && diff --git a/src/utils/exportUtils/JSONExport.ts b/src/utils/exportUtils/JSONExport.ts index 673420327e7..b0b4b330b06 100644 --- a/src/utils/exportUtils/JSONExport.ts +++ b/src/utils/exportUtils/JSONExport.ts @@ -85,7 +85,7 @@ export default class JSONExporter extends Exporter { total: events.length, }), false, true); if (this.cancelled) return this.cleanUp(); - if (!haveRendererForEvent(event)) continue; + if (!haveRendererForEvent(event, false)) continue; this.messages.push(await this.getJSONString(event)); } return this.createJSONString(); diff --git a/src/utils/exportUtils/PlainTextExport.ts b/src/utils/exportUtils/PlainTextExport.ts index d41d06d35c5..e86c56e9f4c 100644 --- a/src/utils/exportUtils/PlainTextExport.ts +++ b/src/utils/exportUtils/PlainTextExport.ts @@ -112,7 +112,7 @@ export default class PlainTextExporter extends Exporter { total: events.length, }), false, true); if (this.cancelled) return this.cleanUp(); - if (!haveRendererForEvent(event)) continue; + if (!haveRendererForEvent(event, false)) continue; const textForEvent = await this.plainTextForEvent(event); content += textForEvent && `${new Date(event.getTs()).toLocaleString()} - ${textForEvent}\n`; } diff --git a/test/autocomplete/EmojiProvider-test.ts b/test/autocomplete/EmojiProvider-test.ts new file mode 100644 index 00000000000..a64312fa297 --- /dev/null +++ b/test/autocomplete/EmojiProvider-test.ts @@ -0,0 +1,67 @@ +/* +Copyright 2022 Ryan Browne + +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 EmojiProvider from '../../src/autocomplete/EmojiProvider'; +import { mkStubRoom } from '../test-utils/test-utils'; + +const EMOJI_SHORTCODES = [ + ":+1", + ":heart", + ":grinning", + ":hand", + ":man", + ":sweat", + ":monkey", + ":boat", + ":mailbox", + ":cop", + ":bow", + ":kiss", + ":golf", +]; + +// Some emoji shortcodes are too short and do not actually trigger autocompletion until the ending `:`. +// This means that we cannot compare their autocompletion before and after the ending `:` and have +// to simply assert that the final completion with the colon is the exact emoji. +const TOO_SHORT_EMOJI_SHORTCODE = [ + { emojiShortcode: ":o", expectedEmoji: "⭕️" }, +]; + +describe('EmojiProvider', function() { + const testRoom = mkStubRoom(undefined, undefined, undefined); + + it.each(EMOJI_SHORTCODES)('Returns consistent results after final colon %s', async function(emojiShortcode) { + const ep = new EmojiProvider(testRoom); + const range = { "beginning": true, "start": 0, "end": 3 }; + const completionsBeforeColon = await ep.getCompletions(emojiShortcode, range); + const completionsAfterColon = await ep.getCompletions(emojiShortcode + ':', range); + + const firstCompletionWithoutColon = completionsBeforeColon[0].completion; + const firstCompletionWithColon = completionsAfterColon[0].completion; + + expect(firstCompletionWithoutColon).toEqual(firstCompletionWithColon); + }); + + it.each( + TOO_SHORT_EMOJI_SHORTCODE, + )('Returns correct results after final colon $emojiShortcode', async ({ emojiShortcode, expectedEmoji }) => { + const ep = new EmojiProvider(testRoom); + const range = { "beginning": true, "start": 0, "end": 3 }; + const completions = await ep.getCompletions(emojiShortcode + ':', range); + + expect(completions[0].completion).toEqual(expectedEmoji); + }); +}); diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index 1471c52eb87..3c774290005 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -276,6 +276,30 @@ describe('MessagePanel', function() { }), ]; } + + function mkMixedHiddenAndShownEvents() { + const roomId = "!room:id"; + const userId = "@alice:example.org"; + const ts0 = Date.now(); + + return [ + TestUtilsMatrix.mkMessage({ + event: true, + room: roomId, + user: userId, + ts: ts0, + }), + TestUtilsMatrix.mkEvent({ + event: true, + type: "org.example.a_hidden_event", + room: roomId, + user: userId, + content: {}, + ts: ts0 + 1, + }), + ]; + } + function isReadMarkerVisible(rmContainer) { return rmContainer && rmContainer.children.length > 0; } @@ -594,6 +618,21 @@ describe('MessagePanel', function() { expect(els.first().prop("events").length).toEqual(5); expect(els.last().prop("events").length).toEqual(5); }); + + // We test this because setting lookups can be *slow*, and we don't want + // them to happen in this code path + it("doesn't lookup showHiddenEventsInTimeline while rendering", () => { + // We're only interested in the setting lookups that happen on every render, + // rather than those happening on first mount, so let's get those out of the way + const res = mount(); + + // Set up our spy and re-render with new events + const settingsSpy = jest.spyOn(SettingsStore, "getValue").mockClear(); + res.setProps({ events: mkMixedHiddenAndShownEvents() }); + + expect(settingsSpy).not.toHaveBeenCalledWith("showHiddenEventsInTimeline"); + settingsSpy.mockRestore(); + }); }); describe("shouldFormContinuation", () => { diff --git a/test/components/views/rooms/MessageComposerButtons-test.tsx b/test/components/views/rooms/MessageComposerButtons-test.tsx index c94323e1c33..4abe3e36373 100644 --- a/test/components/views/rooms/MessageComposerButtons-test.tsx +++ b/test/components/views/rooms/MessageComposerButtons-test.tsx @@ -227,7 +227,7 @@ function createRoomState(room: Room, narrow: boolean): IRoomState { showTwelveHourTimestamps: false, readMarkerInViewThresholdMs: 3000, readMarkerOutOfViewThresholdMs: 30000, - showHiddenEventsInTimeline: false, + showHiddenEvents: false, showReadReceipts: true, showRedactions: true, showJoinLeaves: true, diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index 30ecd31586b..5ab7a1705e5 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -73,7 +73,7 @@ describe('', () => { showTwelveHourTimestamps: false, readMarkerInViewThresholdMs: 3000, readMarkerOutOfViewThresholdMs: 30000, - showHiddenEventsInTimeline: false, + showHiddenEvents: false, showReadReceipts: true, showRedactions: true, showJoinLeaves: true,