diff --git a/res/css/_common.scss b/res/css/_common.scss index e079292bdc..14ea4630b2 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -428,6 +428,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { border-radius: 8px; padding: 0px; box-shadow: none; + + /* Don't show scroll-bars on spinner dialogs */ + overflow-x: hidden; + overflow-y: hidden; } // TODO: Review mx_GeneralButton usage to see if it can use a different class @@ -596,14 +600,14 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { } &:last-child { - padding-bottom: 20px; + padding-bottom: 16px; } } .mx_IconizedContextMenu_optionList { // the notFirst class is for cases where the optionList might be under a header of sorts. &:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst { - margin-top: 20px; + margin-top: 12px; // This is a bit of a hack when we could just use a simple border-top property, // however we have a (kinda) good reason for doing it this way: we need opacity. @@ -634,7 +638,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { li { margin: 0; - padding: 20px 0 0; + padding: 12px 0 0; .mx_AccessibleButton { text-decoration: none; diff --git a/res/css/_components.scss b/res/css/_components.scss index 66eb98ea9d..afc40ca0d6 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -30,7 +30,7 @@ @import "./structures/_ToastContainer.scss"; @import "./structures/_TopLeftMenuButton.scss"; @import "./structures/_UploadBar.scss"; -@import "./structures/_UserMenuButton.scss"; +@import "./structures/_UserMenu.scss"; @import "./structures/_ViewSource.scss"; @import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_Login.scss"; diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index dd28a3107c..67fa9ba557 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -38,6 +38,12 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations // TagPanel handles its own CSS } + &:not(.mx_LeftPanel2_hasTagPanel) { + .mx_LeftPanel2_roomListContainer { + width: 100%; + } + } + // Note: The 'room list' in this context is actually everything that isn't the tag // panel, such as the menu options, breadcrumbs, filtering, etc .mx_LeftPanel2_roomListContainer { @@ -48,13 +54,13 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations flex-direction: column; .mx_LeftPanel2_userHeader { - padding: 14px 12px 20px; // 14px top, 12px sides, 20px bottom + padding: 12px 12px 20px; // 12px top, 12px sides, 20px bottom // Create another flexbox column for the rows to stack within display: flex; flex-direction: column; - // There's 2 rows when breadcrumbs are present: the top bit and the breadcrumbs + // This is basically just breadcrumbs. The row above that is handled by the UserMenu .mx_LeftPanel2_headerRow { // Create yet another flexbox, this time within the row, to ensure items stay // aligned correctly. This is also a row-based flexbox. @@ -62,31 +68,6 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations align-items: center; } - .mx_LeftPanel2_userAvatarContainer { - position: relative; // to make default avatars work - margin-right: 8px; - - .mx_LeftPanel2_userAvatar { - border-radius: 32px; // should match avatar size - } - } - - .mx_LeftPanel2_userName { - font-weight: 600; - font-size: $font-15px; - line-height: $font-20px; - flex: 1; - - // Ellipsize any text overflow - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - - .mx_LeftPanel2_headerButtons { - // No special styles: the rest of the layout happens to make it work. - } - .mx_LeftPanel2_breadcrumbsContainer { width: 100%; overflow: hidden; @@ -152,21 +133,16 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations min-width: unset; // We have to forcefully set the width to override the resizer's style attribute. - width: calc(68px + $tagPanelWidth) !important; + &.mx_LeftPanel2_hasTagPanel { + width: calc(68px + $tagPanelWidth) !important; + } + &:not(.mx_LeftPanel2_hasTagPanel) { + width: 68px !important; + } .mx_LeftPanel2_roomListContainer { width: 68px; - .mx_LeftPanel2_userHeader { - .mx_LeftPanel2_headerRow { - justify-content: center; - } - - .mx_LeftPanel2_userAvatarContainer { - margin-right: 0; - } - } - .mx_LeftPanel2_filterContainer { // Organize the flexbox into a centered column layout flex-direction: column; diff --git a/res/css/structures/_UserMenuButton.scss b/res/css/structures/_UserMenu.scss similarity index 62% rename from res/css/structures/_UserMenuButton.scss rename to res/css/structures/_UserMenu.scss index c2bfe5b916..bbb1e1cc7b 100644 --- a/res/css/structures/_UserMenuButton.scss +++ b/res/css/structures/_UserMenu.scss @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_UserMenuButton { - > span { +.mx_UserMenu { + .mx_UserMenu_headerButtons { width: 16px; height: 16px; position: relative; @@ -35,22 +35,71 @@ limitations under the License. mask-image: url('$(res)/img/feather-customised/more-horizontal.svg'); } } + + .mx_UserMenu_row { + // Create a row-based flexbox to ensure items stay aligned correctly. + display: flex; + align-items: center; + + .mx_UserMenu_userAvatarContainer { + position: relative; // to make default avatars work + margin-right: 8px; + height: 32px; // to remove the unknown 4px gap the browser puts below it + + .mx_UserMenu_userAvatar { + border-radius: 32px; // should match avatar size + } + } + + .mx_UserMenu_userName { + font-weight: 600; + font-size: $font-15px; + line-height: $font-20px; + flex: 1; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .mx_UserMenu_headerButtons { + // No special styles: the rest of the layout happens to make it work. + } + } + + &.mx_UserMenu_minimized { + .mx_UserMenu_userHeader { + .mx_UserMenu_row { + justify-content: center; + } + + .mx_UserMenu_userAvatarContainer { + margin-right: 0; + } + } + } } -.mx_UserMenuButton_contextMenu { +.mx_UserMenu_contextMenu { width: 247px; - .mx_UserMenuButton_contextMenu_header { + .mx_UserMenu_contextMenu_redRow { + .mx_AccessibleButton { + color: $warning-color !important; // !important to override styles from context menu + } + + .mx_IconizedContextMenu_icon::before { + background-color: $warning-color; + } + } + + .mx_UserMenu_contextMenu_header { // Create a flexbox to organize the header a bit easier display: flex; align-items: center; - &:nth-child(n + 1) { - // The first header will have appropriate padding, subsequent ones need a margin. - margin-top: 10px; - } - - .mx_UserMenuButton_contextMenu_name { + .mx_UserMenu_contextMenu_name { // Create another flexbox of columns to handle large user IDs display: flex; flex-direction: column; @@ -67,19 +116,19 @@ limitations under the License. white-space: nowrap; } - .mx_UserMenuButton_contextMenu_displayName { + .mx_UserMenu_contextMenu_displayName { font-weight: bold; font-size: $font-15px; line-height: $font-20px; } - .mx_UserMenuButton_contextMenu_userId { + .mx_UserMenu_contextMenu_userId { font-size: $font-15px; line-height: $font-24px; } } - .mx_UserMenuButton_contextMenu_themeButton { + .mx_UserMenu_contextMenu_themeButton { min-width: 32px; max-width: 32px; width: 32px; @@ -113,31 +162,31 @@ limitations under the License. } } - .mx_UserMenuButton_iconHome::before { + .mx_UserMenu_iconHome::before { mask-image: url('$(res)/img/feather-customised/home.svg'); } - .mx_UserMenuButton_iconBell::before { + .mx_UserMenu_iconBell::before { mask-image: url('$(res)/img/feather-customised/notifications.svg'); } - .mx_UserMenuButton_iconLock::before { + .mx_UserMenu_iconLock::before { mask-image: url('$(res)/img/feather-customised/lock.svg'); } - .mx_UserMenuButton_iconSettings::before { + .mx_UserMenu_iconSettings::before { mask-image: url('$(res)/img/feather-customised/settings.svg'); } - .mx_UserMenuButton_iconArchive::before { + .mx_UserMenu_iconArchive::before { mask-image: url('$(res)/img/feather-customised/archive.svg'); } - .mx_UserMenuButton_iconMessage::before { + .mx_UserMenu_iconMessage::before { mask-image: url('$(res)/img/feather-customised/message-circle.svg'); } - .mx_UserMenuButton_iconSignOut::before { + .mx_UserMenu_iconSignOut::before { mask-image: url('$(res)/img/feather-customised/sign-out.svg'); } } diff --git a/res/css/views/elements/_InlineSpinner.scss b/res/css/views/elements/_InlineSpinner.scss index 612b6209c6..6b91e45923 100644 --- a/res/css/views/elements/_InlineSpinner.scss +++ b/res/css/views/elements/_InlineSpinner.scss @@ -18,7 +18,7 @@ limitations under the License. display: inline; } -.mx_InlineSpinner img { +.mx_InlineSpinner_spin img { margin: 0px 6px; vertical-align: -3px; } diff --git a/res/css/views/elements/_Spinner.scss b/res/css/views/elements/_Spinner.scss index 01b4f23c2c..6966a60e52 100644 --- a/res/css/views/elements/_Spinner.scss +++ b/res/css/views/elements/_Spinner.scss @@ -23,6 +23,16 @@ limitations under the License. flex: 1; } +.mx_Spinner_spin img { + animation: spin 1s linear infinite; +} + +@keyframes spin { + 100% { + transform: rotate(360deg); + } +} + .mx_MatrixChat_middlePanel .mx_Spinner { height: auto; } diff --git a/res/css/views/elements/_StyledCheckbox.scss b/res/css/views/elements/_StyledCheckbox.scss index aab448605c..60f1bf0277 100644 --- a/res/css/views/elements/_StyledCheckbox.scss +++ b/res/css/views/elements/_StyledCheckbox.scss @@ -77,8 +77,8 @@ limitations under the License. } &:checked:disabled + label > .mx_Checkbox_background { - background-color: $muted-fg-color; - border-color: rgba($muted-fg-color, 0.5); + background-color: $accent-color; + border-color: $accent-color; } } } diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index c7dae56353..f859aba623 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -85,23 +85,30 @@ limitations under the License. // *************************** .mx_RoomSublist2_badgeContainer { - opacity: 0.8; - width: 16px; - margin-right: 5px; // aligns with the room tile's badge - // Create another flexbox row because it's super easy to position the badge this way. display: flex; align-items: center; justify-content: center; + + // Apply the width and margin to the badge so the container doesn't occupy dead space + .mx_NotificationBadge { + width: 16px; + margin-left: 8px; // same as menu+aux buttons + } + } + + &:not(.mx_RoomSublist2_headerContainer_withAux) { + .mx_NotificationBadge { + margin-right: 4px; // just to push it over a bit, aligning it with the other elements + } } - // Both of these buttons are hidden by default until the list is hovered .mx_RoomSublist2_auxButton, .mx_RoomSublist2_menuButton { - width: 0; - margin: 0; - visibility: hidden; + margin-left: 8px; // should be the same as the notification badge position: relative; + width: 24px; + height: 24px; border-radius: 32px; &::before { @@ -118,6 +125,13 @@ limitations under the License. } } + // Hide the menu button by default + .mx_RoomSublist2_menuButton { + visibility: hidden; + width: 0; + margin: 0; + } + .mx_RoomSublist2_auxButton::before { mask-image: url('$(res)/img/feather-customised/plus.svg'); } @@ -130,9 +144,9 @@ limitations under the License. flex: 1; max-width: calc(100% - 16px); // 16px is the badge width text-transform: uppercase; - opacity: 0.5; line-height: $font-16px; font-size: $font-12px; + font-weight: 600; // Ellipsize any text overflow text-overflow: ellipsis; @@ -142,11 +156,9 @@ limitations under the License. .mx_RoomSublist2_collapseBtn { display: inline-block; position: relative; - - // Default hidden - visibility: hidden; - width: 0; - height: 0; + width: 12px; + height: 12px; + margin-right: 8px; &::before { content: ''; @@ -158,7 +170,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $primary-fg-color; + background-color: $primary-fg-color; mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); } @@ -226,6 +238,16 @@ limitations under the License. .mx_RoomSublist2_showLessButtonChevron { mask-image: url('$(res)/img/feather-customised/chevron-up.svg'); } + + &.mx_RoomSublist2_isCutting::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + box-shadow: 0px -2px 3px rgba(46, 47, 50, 0.08); + } } // Class name comes from the ResizableBox component @@ -233,69 +255,34 @@ limitations under the License. // so that selector is below and one level higher. .react-resizable-handle { cursor: ns-resize; - border-radius: 2px; + border-radius: 3px; + + // Update RESIZE_HANDLE_HEIGHT if this changes + height: 4px; // This is positioned directly below the 'show more' button. position: absolute; bottom: 0; - left: 0; - right: 0; - // This is to visually align the bar in the list. Should be 12px from - // either side of the list. We define this after the positioning to - // trick the browser. - margin-left: 4px; - margin-right: 4px; + // Together, these make the bar 64px wide + left: calc(50% - 32px); + right: calc(50% - 32px); + } + + &:hover, &.mx_RoomSublist2_hasMenuOpen { + .react-resizable-handle { + opacity: 0.8; + background-color: $primary-fg-color; + } } } - // The aforementioned selector for the hover state. - &:hover, &.mx_RoomSublist2_hasMenuOpen { - .react-resizable-handle { - opacity: 0.2; - - // Update the render() function for RoomSublist2 if this changes - border: 2px solid $primary-fg-color; - } - - &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer { - // If the header doesn't have an aux button we still need to hide the badge for - // the menu button. - .mx_RoomSublist2_badgeContainer { - // Completely hide the badge - width: 0; - margin: 0; - visibility: hidden; - } - - &:not(.mx_RoomSublist2_headerContainer_withAux) { - // The menu button will be the rightmost button, so make it correctly aligned. - .mx_RoomSublist2_menuButton { - margin-right: 1px; // line it up with the badges on the room tiles - } - } - - // Both of these buttons have circled backgrounds and are visible at this point, - // so make them so. - .mx_RoomSublist2_auxButton, - .mx_RoomSublist2_menuButton { - width: 24px; - height: 24px; - margin-left: 16px; - visibility: visible; - background-color: $roomlist2-button-bg-color; - } - } - - .mx_RoomSublist2_headerContainer { - .mx_RoomSublist2_headerText { - .mx_RoomSublist2_collapseBtn { - visibility: visible; - width: 12px; - height: 12px; - margin-right: 4px; - } - } + &.mx_RoomSublist2_hasMenuOpen, + &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover { + .mx_RoomSublist2_menuButton { + visibility: visible; + width: 24px; + margin-left: 8px; } } @@ -344,7 +331,12 @@ limitations under the License. } } - &:hover, &.mx_RoomSublist2_hasMenuOpen { + .mx_RoomSublist2_menuButton { + height: 16px; + } + + &.mx_RoomSublist2_hasMenuOpen, + & > .mx_RoomSublist2_headerContainer:hover { .mx_RoomSublist2_menuButton { visibility: visible; position: absolute; @@ -365,7 +357,7 @@ limitations under the License. } } - .mx_RoomSublist2_headerContainer:not(.mx_RoomSublist2_headerContainer_withAux) { + &.mx_RoomSublist2_headerContainer:not(.mx_RoomSublist2_headerContainer_withAux) { .mx_RoomSublist2_menuButton { bottom: 8px; // align to the middle of name, 40px less than the `bottom` above. } @@ -374,27 +366,6 @@ limitations under the License. } } -// We have a hover style on the room list with no specific list hovered, so account for that -.mx_RoomList2:hover .mx_RoomSublist2:not(.mx_RoomSublist2_minimized), -.mx_RoomSublist2_hasMenuOpen:not(.mx_RoomSublist2_minimized) { - .mx_RoomSublist2_headerContainer_withAux { - .mx_RoomSublist2_badgeContainer { - // Completely hide the badge - width: 0; - margin: 0; - visibility: hidden; - } - - .mx_RoomSublist2_auxButton { - // Show the aux button, but not the list button - width: 24px; - height: 24px; - margin-right: 1px; // line it up with the badges on the room tiles - visibility: visible; - } - } -} - .mx_RoomSublist2_contextMenu { padding: 20px 16px; width: 250px; @@ -404,6 +375,7 @@ limitations under the License. margin-bottom: 16px; margin-right: 16px; // additional 16px border: 1px solid $roomsublist2-divider-color; + opacity: 0.1; } .mx_RoomSublist2_contextMenu_title { diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss index b24f548d60..d724b164e5 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss @@ -209,9 +209,15 @@ limitations under the License. } .mx_AppearanceUserSettingsTab_Advanced { + color: $primary-fg-color; + + > * { + margin-bottom: 16px; + } + .mx_AppearanceUserSettingsTab_AdvancedToggle { color: $accent-color; - margin-bottom: 16px; + cursor: pointer; } .mx_AppearanceUserSettingsTab_systemFont { diff --git a/res/img/spinner.svg b/res/img/spinner.svg new file mode 100644 index 0000000000..a18140c7e2 --- /dev/null +++ b/res/img/spinner.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 69fc91f222..1546e7a400 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -113,7 +113,7 @@ $theme-button-bg-color: #e3e8f0; $roomlist2-button-bg-color: #1A1D23; // Buttons include the filter box, explore button, and sublist buttons $roomlist2-bg-color: $header-panel-bg-color; -$roomsublist2-divider-color: #e9eaeb; +$roomsublist2-divider-color: $primary-fg-color; $roomtile2-preview-color: #9e9e9e; $roomtile2-default-badge-bg-color: #61708b; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 57dc1fa5e0..c4b4262642 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -180,7 +180,7 @@ $theme-button-bg-color: #e3e8f0; $roomlist2-button-bg-color: #fff; // Buttons include the filter box, explore button, and sublist buttons $roomlist2-bg-color: $header-panel-bg-color; -$roomsublist2-divider-color: #e9eaeb; +$roomsublist2-divider-color: $primary-fg-color; $roomtile2-preview-color: #9e9e9e; $roomtile2-default-badge-bg-color: #61708b; diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index d54dc7dd23..1d11495e61 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -25,8 +25,8 @@ import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload"; import {Action} from "./dispatcher/actions"; import {hideToast as hideUpdateToast} from "./toasts/UpdateToast"; -export const HOMESERVER_URL_KEY = "mx_hs_url"; -export const ID_SERVER_URL_KEY = "mx_is_url"; +export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url"; +export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url"; export enum UpdateCheckStatus { Checking = "CHECKING", @@ -221,7 +221,7 @@ export default abstract class BasePlatform { setLanguage(preferredLangs: string[]) {} - getSSOCallbackUrl(fragmentAfterLogin: string): URL { + protected getSSOCallbackUrl(fragmentAfterLogin: string): URL { const url = new URL(window.location.href); url.hash = fragmentAfterLogin || ""; return url; @@ -235,9 +235,9 @@ export default abstract class BasePlatform { */ startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string) { // persist hs url and is url for when the user is returned to the app with the login token - localStorage.setItem(HOMESERVER_URL_KEY, mxClient.getHomeserverUrl()); + localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl()); if (mxClient.getIdentityServerUrl()) { - localStorage.setItem(ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl()); + localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl()); } const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin); window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 96cefaf593..9ae4ae7e03 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -41,7 +41,10 @@ import {IntegrationManagers} from "./integrations/IntegrationManagers"; import {Mjolnir} from "./mjolnir/Mjolnir"; import DeviceListener from "./DeviceListener"; import {Jitsi} from "./widgets/Jitsi"; -import {HOMESERVER_URL_KEY, ID_SERVER_URL_KEY} from "./BasePlatform"; +import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; + +const HOMESERVER_URL_KEY = "mx_hs_url"; +const ID_SERVER_URL_KEY = "mx_is_url"; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -164,8 +167,8 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { return Promise.resolve(false); } - const homeserver = localStorage.getItem(HOMESERVER_URL_KEY); - const identityServer = localStorage.getItem(ID_SERVER_URL_KEY); + const homeserver = localStorage.getItem(SSO_HOMESERVER_URL_KEY); + const identityServer = localStorage.getItem(SSO_ID_SERVER_URL_KEY); if (!homeserver) { console.warn("Cannot log in with token: can't determine HS URL to use"); return Promise.resolve(false); diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 09cfb67de7..3607d7a676 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -265,22 +265,13 @@ function textForServerACLEvent(ev) { return text + changes.join(" "); } -function textForMessageEvent(ev, skipUserPrefix) { +function textForMessageEvent(ev) { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); let message = senderDisplayName + ': ' + ev.getContent().body; - if (skipUserPrefix) { - message = ev.getContent().body; - if (ev.getContent().msgtype === "m.emote") { - message = senderDisplayName + " " + message; - } else if (ev.getContent().msgtype === "m.image") { - message = _t('sent an image.'); - } - } else { - if (ev.getContent().msgtype === "m.emote") { - message = "* " + senderDisplayName + " " + message; - } else if (ev.getContent().msgtype === "m.image") { - message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName}); - } + if (ev.getContent().msgtype === "m.emote") { + message = "* " + senderDisplayName + " " + message; + } else if (ev.getContent().msgtype === "m.image") { + message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName}); } return message; } @@ -621,8 +612,8 @@ for (const evType of ALL_RULE_TYPES) { stateHandlers[evType] = textForMjolnirEvent; } -export function textForEvent(ev, skipUserPrefix) { +export function textForEvent(ev) { const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; - if (handler) return handler(ev, skipUserPrefix); + if (handler) return handler(ev); return ''; } diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 27583f26ee..6e0faff57f 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -22,18 +22,14 @@ import dis from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import RoomList2 from "../views/rooms/RoomList2"; import { Action } from "../../dispatcher/actions"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import BaseAvatar from '../views/avatars/BaseAvatar'; -import UserMenuButton from "./UserMenuButton"; +import UserMenu from "./UserMenu"; import RoomSearch from "./RoomSearch"; import AccessibleButton from "../views/elements/AccessibleButton"; import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2"; import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import ResizeNotifier from "../../utils/ResizeNotifier"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { throttle } from 'lodash'; -import { OwnProfileStore } from "../../stores/OwnProfileStore"; +import SettingsStore from "../../settings/SettingsStore"; /******************************************************************* * CAUTION * @@ -51,10 +47,12 @@ interface IProps { interface IState { searchFilter: string; // TODO: Move search into room list? showBreadcrumbs: boolean; + showTagPanel: boolean; } export default class LeftPanel2 extends React.Component { private listContainerRef: React.RefObject = createRef(); + private tagPanelWatcherRef: string; // TODO: Properly support TagPanel // TODO: Properly support searching/filtering @@ -69,39 +67,25 @@ export default class LeftPanel2 extends React.Component { this.state = { searchFilter: "", showBreadcrumbs: BreadcrumbsStore.instance.visible, + showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), }; BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); + this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { + this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); + }); // We watch the middle panel because we don't actually get resized, the middle panel does. // We listen to the noisy channel to avoid choppy reaction times. this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); - - OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); } public componentWillUnmount() { + SettingsStore.unwatchSetting(this.tagPanelWatcherRef); BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize); - OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); } - // TSLint wants this to be a member, but we don't want that. - // tslint:disable-next-line - private onRoomStateUpdate = throttle((ev: MatrixEvent) => { - const myUserId = MatrixClientPeg.get().getUserId(); - if (ev.getType() === 'm.room.member' && ev.getSender() === myUserId && ev.getStateKey() === myUserId) { - // noinspection JSIgnoredPromiseFromCall - this.onProfileUpdate(); - } - }, 200, {trailing: true, leading: true}); - - private onProfileUpdate = async () => { - // the store triggered an update, so force a layout update. We don't - // have any state to store here for that to magically happen. - this.forceUpdate(); - }; - private onSearch = (term: string): void => { this.setState({searchFilter: term}); }; @@ -170,7 +154,6 @@ export default class LeftPanel2 extends React.Component { // TODO: Presence // TODO: Breadcrumbs toggle // TODO: Menu button - const avatarSize = 32; // should match border-radius of the avatar let breadcrumbs; if (this.state.showBreadcrumbs) { @@ -181,34 +164,9 @@ export default class LeftPanel2 extends React.Component { ); } - let name = {OwnProfileStore.instance.displayName}; - let buttons = ( - - - - ); - if (this.props.isMinimized) { - name = null; - buttons = null; - } - return (
-
- - - - {name} - {buttons} -
+ {breadcrumbs}
); @@ -231,7 +189,7 @@ export default class LeftPanel2 extends React.Component { } public render(): React.ReactNode { - const tagPanel = ( + const tagPanel = !this.state.showTagPanel ? null : (
@@ -252,6 +210,7 @@ export default class LeftPanel2 extends React.Component { const containerClasses = classNames({ "mx_LeftPanel2": true, + "mx_LeftPanel2_hasTagPanel": !!tagPanel, "mx_LeftPanel2_minimized": this.props.isMinimized, }); diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 1bc656e6a3..9c01480df2 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -123,7 +123,7 @@ interface IState { * * Components mounted below us can access the matrix client via the react context. */ -class LoggedInView extends React.PureComponent { +class LoggedInView extends React.Component { static displayName = 'LoggedInView'; static propTypes = { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 63d2ec6fd9..79bdf743ce 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -18,6 +18,8 @@ limitations under the License. */ import React, { createRef } from 'react'; +// @ts-ignore - XXX: no idea why this import fails +import * as Matrix from "matrix-js-sdk"; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; @@ -1612,6 +1614,19 @@ export default class MatrixChat extends React.PureComponent { }); } else if (screen === 'directory') { dis.fire(Action.ViewRoomDirectory); + } else if (screen === "start_sso" || screen === "start_cas") { + // TODO if logged in, skip SSO + let cli = MatrixClientPeg.get(); + if (!cli) { + const {hsUrl, isUrl} = this.props.serverConfig; + cli = Matrix.createClient({ + baseUrl: hsUrl, + idBaseUrl: isUrl, + }); + } + + const type = screen === "start_sso" ? "sso" : "cas"; + PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin()); } else if (screen === 'groups') { dis.dispatch({ action: 'view_my_groups', @@ -1915,9 +1930,7 @@ export default class MatrixChat extends React.PureComponent { this.onLoggedIn(); }; - render() { - // console.log(`Rendering MatrixChat with view ${this.state.view}`); - + getFragmentAfterLogin() { let fragmentAfterLogin = ""; if (this.props.initialScreenAfterLogin && // XXX: workaround for https://github.com/vector-im/riot-web/issues/11643 causing a login-loop @@ -1925,7 +1938,11 @@ export default class MatrixChat extends React.PureComponent { ) { fragmentAfterLogin = `/${this.props.initialScreenAfterLogin.screen}`; } + return fragmentAfterLogin; + } + render() { + const fragmentAfterLogin = this.getFragmentAfterLogin(); let view; if (this.state.view === Views.LOADING) { @@ -2004,7 +2021,7 @@ export default class MatrixChat extends React.PureComponent { } } else if (this.state.view === Views.WELCOME) { const Welcome = sdk.getComponent('auth.Welcome'); - view = ; + view = ; } else if (this.state.view === Views.REGISTER) { const Registration = sdk.getComponent('structures.auth.Registration'); view = ( diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx new file mode 100644 index 0000000000..19e57ac51b --- /dev/null +++ b/src/components/structures/UserMenu.tsx @@ -0,0 +1,332 @@ +/* +Copyright 2020 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 * as React from "react"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { ActionPayload } from "../../dispatcher/payloads"; +import { Action } from "../../dispatcher/actions"; +import { createRef } from "react"; +import { _t } from "../../languageHandler"; +import {ContextMenu, ContextMenuButton} from "./ContextMenu"; +import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; +import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; +import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog"; +import Modal from "../../Modal"; +import LogoutDialog from "../views/dialogs/LogoutDialog"; +import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; +import {getCustomTheme} from "../../theme"; +import {getHostingLink} from "../../utils/HostingLink"; +import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; +import SdkConfig from "../../SdkConfig"; +import {getHomePageUrl} from "../../utils/pages"; +import { OwnProfileStore } from "../../stores/OwnProfileStore"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import BaseAvatar from '../views/avatars/BaseAvatar'; +import classNames from "classnames"; + +interface IProps { + isMinimized: boolean; +} + +interface IState { + menuDisplayed: boolean; + isDarkTheme: boolean; +} + +export default class UserMenu extends React.Component { + private dispatcherRef: string; + private themeWatcherRef: string; + private buttonRef: React.RefObject = createRef(); + + constructor(props: IProps) { + super(props); + + this.state = { + menuDisplayed: false, + isDarkTheme: this.isUserOnDarkTheme(), + }; + + OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); + } + + private get hasHomePage(): boolean { + return !!getHomePageUrl(SdkConfig.get()); + } + + public componentDidMount() { + this.dispatcherRef = defaultDispatcher.register(this.onAction); + this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); + } + + public componentWillUnmount() { + if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); + if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); + OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); + } + + private isUserOnDarkTheme(): boolean { + const theme = SettingsStore.getValue("theme"); + if (theme.startsWith("custom-")) { + return getCustomTheme(theme.substring("custom-".length)).is_dark; + } + return theme === "dark"; + } + + private onProfileUpdate = async () => { + // the store triggered an update, so force a layout update. We don't + // have any state to store here for that to magically happen. + this.forceUpdate(); + }; + + private onThemeChanged = () => { + this.setState({isDarkTheme: this.isUserOnDarkTheme()}); + }; + + private onAction = (ev: ActionPayload) => { + if (ev.action !== Action.ToggleUserMenu) return; // not interested + + // For accessibility + if (this.buttonRef.current) this.buttonRef.current.click(); + }; + + private onOpenMenuClick = (ev: InputEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + this.setState({menuDisplayed: true}); + }; + + private onCloseMenu = (ev: InputEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + this.setState({menuDisplayed: false}); + }; + + private onSwitchThemeClick = () => { + // Disable system theme matching if the user hits this button + SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false); + + const newTheme = this.state.isDarkTheme ? "light" : "dark"; + SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab + }; + + private onSettingsOpen = (ev: ButtonEvent, tabId: string) => { + ev.preventDefault(); + ev.stopPropagation(); + + const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId}; + defaultDispatcher.dispatch(payload); + this.setState({menuDisplayed: false}); // also close the menu + }; + + private onShowArchived = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + // TODO: Archived room view (deferred) + console.log("TODO: Show archived rooms"); + }; + + private onProvideFeedback = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog); + this.setState({menuDisplayed: false}); // also close the menu + }; + + private onSignOutClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); + this.setState({menuDisplayed: false}); // also close the menu + }; + + private onHomeClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + defaultDispatcher.dispatch({action: 'view_home_page'}); + }; + + private renderContextMenu = (): React.ReactNode => { + if (!this.state.menuDisplayed) return null; + + let hostingLink; + const signupLink = getHostingLink("user-context-menu"); + if (signupLink) { + hostingLink = ( +
+ {_t( + "Upgrade to your own domain", {}, + { + a: sub => ( + {sub} + ), + }, + )} +
+ ); + } + + let homeButton = null; + if (this.hasHomePage) { + homeButton = ( +
  • + + + {_t("Home")} + +
  • + ); + } + + const elementRect = this.buttonRef.current.getBoundingClientRect(); + return ( + +
    +
    +
    + + {OwnProfileStore.instance.displayName} + + + {MatrixClientPeg.get().getUserId()} + +
    +
    + {_t("Switch +
    +
    + {hostingLink} +
    +
      + {homeButton} +
    • + this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}> + + {_t("Notification settings")} + +
    • +
    • + this.onSettingsOpen(e, USER_SECURITY_TAB)}> + + {_t("Security & privacy")} + +
    • +
    • + this.onSettingsOpen(e, null)}> + + {_t("All settings")} + +
    • +
    • + + + {_t("Archived rooms")} + +
    • +
    • + + + {_t("Feedback")} + +
    • +
    +
    +
    +
      +
    • + + + {_t("Sign out")} + +
    • +
    +
    +
    +
    + ); + }; + + public render() { + const avatarSize = 32; // should match border-radius of the avatar + + let name = {OwnProfileStore.instance.displayName}; + let buttons = ( + + {/* masked image in CSS */} + + ); + if (this.props.isMinimized) { + name = null; + buttons = null; + } + + const classes = classNames({ + 'mx_UserMenu': true, + 'mx_UserMenu_minimized': this.props.isMinimized, + }); + + return ( + + +
    + + + + {name} + {buttons} +
    + {this.renderContextMenu()} +
    +
    + ); + } +} diff --git a/src/components/structures/UserMenuButton.tsx b/src/components/structures/UserMenuButton.tsx deleted file mode 100644 index 27dfdac5a1..0000000000 --- a/src/components/structures/UserMenuButton.tsx +++ /dev/null @@ -1,294 +0,0 @@ -/* -Copyright 2020 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 * as React from "react"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import defaultDispatcher from "../../dispatcher/dispatcher"; -import { ActionPayload } from "../../dispatcher/payloads"; -import { Action } from "../../dispatcher/actions"; -import { createRef } from "react"; -import { _t } from "../../languageHandler"; -import {ContextMenu, ContextMenuButton} from "./ContextMenu"; -import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; -import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; -import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog"; -import Modal from "../../Modal"; -import LogoutDialog from "../views/dialogs/LogoutDialog"; -import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; -import {getCustomTheme} from "../../theme"; -import {getHostingLink} from "../../utils/HostingLink"; -import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; -import SdkConfig from "../../SdkConfig"; -import {getHomePageUrl} from "../../utils/pages"; -import { OwnProfileStore } from "../../stores/OwnProfileStore"; -import { UPDATE_EVENT } from "../../stores/AsyncStore"; - -interface IProps { -} - -interface IState { - menuDisplayed: boolean; - isDarkTheme: boolean; -} - -export default class UserMenuButton extends React.Component { - private dispatcherRef: string; - private themeWatcherRef: string; - private buttonRef: React.RefObject = createRef(); - - constructor(props: IProps) { - super(props); - - this.state = { - menuDisplayed: false, - isDarkTheme: this.isUserOnDarkTheme(), - }; - - OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); - } - - private get hasHomePage(): boolean { - return !!getHomePageUrl(SdkConfig.get()); - } - - public componentDidMount() { - this.dispatcherRef = defaultDispatcher.register(this.onAction); - this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); - } - - public componentWillUnmount() { - if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); - if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); - OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); - } - - private isUserOnDarkTheme(): boolean { - const theme = SettingsStore.getValue("theme"); - if (theme.startsWith("custom-")) { - return getCustomTheme(theme.substring("custom-".length)).is_dark; - } - return theme === "dark"; - } - - private onProfileUpdate = async () => { - // the store triggered an update, so force a layout update. We don't - // have any state to store here for that to magically happen. - this.forceUpdate(); - }; - - private onThemeChanged = () => { - this.setState({isDarkTheme: this.isUserOnDarkTheme()}); - }; - - private onAction = (ev: ActionPayload) => { - if (ev.action !== Action.ToggleUserMenu) return; // not interested - - // For accessibility - if (this.buttonRef.current) this.buttonRef.current.click(); - }; - - private onOpenMenuClick = (ev: InputEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({menuDisplayed: true}); - }; - - private onCloseMenu = () => { - this.setState({menuDisplayed: false}); - }; - - private onSwitchThemeClick = () => { - // Disable system theme matching if the user hits this button - SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false); - - const newTheme = this.state.isDarkTheme ? "light" : "dark"; - SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab - }; - - private onSettingsOpen = (ev: ButtonEvent, tabId: string) => { - ev.preventDefault(); - ev.stopPropagation(); - - const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId}; - defaultDispatcher.dispatch(payload); - this.setState({menuDisplayed: false}); // also close the menu - }; - - private onShowArchived = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - // TODO: Archived room view (deferred) - console.log("TODO: Show archived rooms"); - }; - - private onProvideFeedback = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog); - this.setState({menuDisplayed: false}); // also close the menu - }; - - private onSignOutClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); - this.setState({menuDisplayed: false}); // also close the menu - }; - - private onHomeClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - defaultDispatcher.dispatch({action: 'view_home_page'}); - }; - - public render() { - let contextMenu; - if (this.state.menuDisplayed) { - let hostingLink; - const signupLink = getHostingLink("user-context-menu"); - if (signupLink) { - hostingLink = ( -
    - {_t( - "Upgrade to your own domain", {}, - { - a: sub => ( - {sub} - ), - }, - )} -
    - ); - } - - let homeButton = null; - if (this.hasHomePage) { - homeButton = ( -
  • - - - {_t("Home")} - -
  • - ); - } - - const elementRect = this.buttonRef.current.getBoundingClientRect(); - contextMenu = ( - -
    -
    -
    - - {OwnProfileStore.instance.displayName} - - - {MatrixClientPeg.get().getUserId()} - -
    -
    - {_t("Switch -
    -
    - {hostingLink} -
    -
      - {homeButton} -
    • - this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}> - - {_t("Notification settings")} - -
    • -
    • - this.onSettingsOpen(e, USER_SECURITY_TAB)}> - - {_t("Security & privacy")} - -
    • -
    • - this.onSettingsOpen(e, null)}> - - {_t("All settings")} - -
    • -
    • - - - {_t("Archived rooms")} - -
    • -
    • - - - {_t("Feedback")} - -
    • -
    -
    -
    -
      -
    • - - - {_t("Sign out")} - -
    • -
    -
    -
    -
    - ); - } - - return ( - - - {/* masked image in CSS */} - - {contextMenu} - - ); - } -} diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.js index a2824b63a3..6577386fae 100644 --- a/src/components/structures/auth/SoftLogout.js +++ b/src/components/structures/auth/SoftLogout.js @@ -25,7 +25,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {sendLoginRequest} from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; import SSOButton from "../../views/elements/SSOButton"; -import {HOMESERVER_URL_KEY, ID_SERVER_URL_KEY} from "../../../BasePlatform"; +import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform"; const LOGIN_VIEW = { LOADING: 1, @@ -158,8 +158,8 @@ export default class SoftLogout extends React.Component { async trySsoLogin() { this.setState({busy: true}); - const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY); - const isUrl = localStorage.getItem(ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl(); + const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY); + const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl(); const loginType = "m.login.token"; const loginParams = { token: this.props.realQueryParams['loginToken'], diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.js index 91ba368f70..5a30a02490 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.js @@ -18,9 +18,7 @@ import React from 'react'; import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import AuthPage from "./AuthPage"; -import * as Matrix from "matrix-js-sdk"; import {_td} from "../../../languageHandler"; -import PlatformPeg from "../../../PlatformPeg"; // translatable strings for Welcome pages _td("Sign in with SSO"); @@ -39,15 +37,6 @@ export default class Welcome extends React.PureComponent { pageUrl = 'welcome.html'; } - const {hsUrl, isUrl} = this.props.serverConfig; - const tmpClient = Matrix.createClient({ - baseUrl: hsUrl, - idBaseUrl: isUrl, - }); - const plaf = PlatformPeg.get(); - const callbackUrl = plaf.getSSOCallbackUrl(tmpClient.getHomeserverUrl(), tmpClient.getIdentityServerUrl(), - this.props.fragmentAfterLogin); - return (
    @@ -55,8 +44,8 @@ export default class Welcome extends React.PureComponent { className="mx_WelcomePage" url={pageUrl} replaceMap={{ - "$riot:ssoUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "sso"), - "$riot:casUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "cas"), + "$riot:ssoUrl": "#/start_sso", + "$riot:casUrl": "#/start_cas", }} /> diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 9129b8fe48..60cd1a2eba 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -29,7 +29,7 @@ import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import AppPermission from './AppPermission'; import AppWarning from './AppWarning'; -import MessageSpinner from './MessageSpinner'; +import Spinner from './Spinner'; import WidgetUtils from '../../../utils/WidgetUtils'; import dis from '../../../dispatcher/dispatcher'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; @@ -740,7 +740,7 @@ export default class AppTile extends React.Component { if (this.props.show) { const loadingElement = (
    - +
    ); if (!this.state.hasPermissionToLoad) { diff --git a/src/components/views/elements/InlineSpinner.js b/src/components/views/elements/InlineSpinner.js index ad70471d89..ad88868790 100644 --- a/src/components/views/elements/InlineSpinner.js +++ b/src/components/views/elements/InlineSpinner.js @@ -16,6 +16,8 @@ limitations under the License. import React from "react"; import createReactClass from 'create-react-class'; +import {_t} from "../../../languageHandler"; +import SettingsStore from "../../../settings/SettingsStore"; export default createReactClass({ displayName: 'InlineSpinner', @@ -25,9 +27,25 @@ export default createReactClass({ const h = this.props.h || 16; const imgClass = this.props.imgClassName || ""; + let divClass; + let imageSource; + if (SettingsStore.isFeatureEnabled('feature_new_spinner')) { + divClass = "mx_InlineSpinner mx_Spinner_spin"; + imageSource = require("../../../../res/img/spinner.svg"); + } else { + divClass = "mx_InlineSpinner"; + imageSource = require("../../../../res/img/spinner.gif"); + } + return ( -
    - +
    +
    ); }, diff --git a/src/components/views/elements/MessageSpinner.js b/src/components/views/elements/MessageSpinner.js deleted file mode 100644 index 1775fdd4d7..0000000000 --- a/src/components/views/elements/MessageSpinner.js +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd - -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 React from 'react'; -import createReactClass from 'create-react-class'; - -export default createReactClass({ - displayName: 'MessageSpinner', - - render: function() { - const w = this.props.w || 32; - const h = this.props.h || 32; - const imgClass = this.props.imgClassName || ""; - const msg = this.props.msg || "Loading..."; - return ( -
    -
    { msg }
      - -
    - ); - }, -}); diff --git a/src/components/views/elements/SettingsFlag.tsx b/src/components/views/elements/SettingsFlag.tsx index 9bdd04d803..4f41db51e2 100644 --- a/src/components/views/elements/SettingsFlag.tsx +++ b/src/components/views/elements/SettingsFlag.tsx @@ -30,6 +30,7 @@ interface IProps { isExplicit?: boolean; // XXX: once design replaces all toggles make this the default useCheckbox?: boolean; + disabled?: boolean; onChange?(checked: boolean): void; } @@ -78,14 +79,23 @@ export default class SettingsFlag extends React.Component { else label = _t(label); if (this.props.useCheckbox) { - return + return {label} ; } else { return (
    {label} - +
    ); } diff --git a/src/components/views/elements/Spinner.js b/src/components/views/elements/Spinner.js index b1fe97d5d2..08ba0cf921 100644 --- a/src/components/views/elements/Spinner.js +++ b/src/components/views/elements/Spinner.js @@ -16,19 +16,39 @@ limitations under the License. */ import React from "react"; -import createReactClass from 'create-react-class'; +import PropTypes from "prop-types"; +import {_t} from "../../../languageHandler"; +import SettingsStore from "../../../settings/SettingsStore"; -export default createReactClass({ - displayName: 'Spinner', +const Spinner = ({w = 32, h = 32, imgClassName, message}) => { + let divClass; + let imageSource; + if (SettingsStore.isFeatureEnabled('feature_new_spinner')) { + divClass = "mx_Spinner mx_Spinner_spin"; + imageSource = require("../../../../res/img/spinner.svg"); + } else { + divClass = "mx_Spinner"; + imageSource = require("../../../../res/img/spinner.gif"); + } - render: function() { - const w = this.props.w || 32; - const h = this.props.h || 32; - const imgClass = this.props.imgClassName || ""; - return ( -
    - -
    - ); - }, -}); + return ( +
    + { message &&
    { message}
     
    } + +
    + ); +}; +Spinner.propTypes = { + w: PropTypes.number, + h: PropTypes.number, + imgClassName: PropTypes.string, + message: PropTypes.node, +}; + +export default Spinner; diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index a642936fec..37f85a108f 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -22,6 +22,7 @@ import MFileBody from './MFileBody'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; +import InlineSpinner from '../elements/InlineSpinner'; export default class MAudioBody extends React.Component { constructor(props) { @@ -94,7 +95,7 @@ export default class MAudioBody extends React.Component { // Not sure how tall the audio player is so not sure how tall it should actually be. return ( - {content.body} + ); } diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index ad238a728e..c92ae475bf 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -26,6 +26,7 @@ import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import InlineSpinner from '../elements/InlineSpinner'; export default class MImageBody extends React.Component { static propTypes = { @@ -365,12 +366,7 @@ export default class MImageBody extends React.Component { // e2e image hasn't been decrypted yet if (content.file !== undefined && this.state.decryptedUrl === null) { - placeholder = {content.body}; + placeholder = ; } else if (!this.state.imgLoaded) { // Deliberately, getSpinner is left unimplemented here, MStickerBody overides placeholder = this.getPlaceholder(); diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index 03f345e042..fdc04deffc 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -23,6 +23,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; +import InlineSpinner from '../elements/InlineSpinner'; export default createReactClass({ displayName: 'MVideoBody', @@ -147,7 +148,7 @@ export default createReactClass({ return (
    - {content.body} +
    ); diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 6e3e90f805..79efa565b7 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -70,6 +70,7 @@ interface IProps { interface IState { notificationState: ListNotificationState; menuDisplayed: boolean; + isResizing: boolean; } export default class RoomSublist2 extends React.Component { @@ -82,6 +83,7 @@ export default class RoomSublist2 extends React.Component { this.state = { notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId), menuDisplayed: false, + isResizing: false, }; this.state.notificationState.setRooms(this.props.rooms); } @@ -111,13 +113,21 @@ export default class RoomSublist2 extends React.Component { this.forceUpdate(); // because the layout doesn't trigger a re-render }; + private onResizeStart = () => { + this.setState({isResizing: true}); + }; + + private onResizeStop = () => { + this.setState({isResizing: false}); + }; + private onShowAllClick = () => { this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT); this.forceUpdate(); // because the layout doesn't trigger a re-render }; private onShowLessClick = () => { - this.props.layout.visibleTiles = this.props.layout.minVisibleTiles; + this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles; this.forceUpdate(); // because the layout doesn't trigger a re-render }; @@ -320,8 +330,8 @@ export default class RoomSublist2 extends React.Component { {this.props.label} {this.renderMenu()} - {this.props.isMinimized ? null : addRoomButton} {this.props.isMinimized ? null : badgeContainer} + {this.props.isMinimized ? null : addRoomButton}
    {this.props.isMinimized ? badgeContainer : null} {this.props.isMinimized ? addRoomButton : null} @@ -356,6 +366,12 @@ export default class RoomSublist2 extends React.Component { const nVisible = Math.floor(layout.visibleTiles); const visibleTiles = tiles.slice(0, nVisible); + const maxTilesFactored = layout.tilesWithResizerBoxFactor(tiles.length); + const showMoreBtnClasses = classNames({ + 'mx_RoomSublist2_showNButton': true, + 'mx_RoomSublist2_isCutting': this.state.isResizing && layout.visibleTiles < maxTilesFactored, + }); + // If we're hiding rooms, show a 'show more' button to the user. This button // floats above the resize handle, if we have one present. If the user has all // tiles visible, it becomes 'show less'. @@ -370,7 +386,7 @@ export default class RoomSublist2 extends React.Component { ); if (this.props.isMinimized) showMoreText = null; showNButton = ( -
    +
    {/* set by CSS masking */} @@ -386,7 +402,7 @@ export default class RoomSublist2 extends React.Component { ); if (this.props.isMinimized) showLessText = null; showNButton = ( -
    +
    {/* set by CSS masking */} @@ -432,6 +448,8 @@ export default class RoomSublist2 extends React.Component { resizeHandles={handles} onResize={this.onResize} className="mx_RoomSublist2_resizeBox" + onResizeStart={this.onResizeStart} + onResizeStop={this.onResizeStop} > {visibleTiles} {showNButton} diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index 18b4ee8185..3d0a555877 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -34,7 +34,7 @@ import NotificationBadge, { import { _t } from "../../../languageHandler"; import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; -import { MessagePreviewStore } from "../../../stores/MessagePreviewStore"; +import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import RoomTileIcon from "./RoomTileIcon"; /******************************************************************* @@ -271,7 +271,7 @@ export default class RoomTile2 extends React.Component { let messagePreview = null; if (this.props.showMessagePreview && !this.props.isMinimized) { // The preview store heavily caches this info, so should be safe to hammer. - const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room); + const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag); // Only show the preview if there is one to show. if (text) { diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index 42f8cb01de..f02147608d 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -390,7 +390,13 @@ export default class AppearanceUserSettingsTab extends React.Component + advanced = <> + -
    ; + ; } return
    {toggle} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 74e747726a..d721979329 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -247,7 +247,6 @@ "%(senderDisplayName)s enabled flair for %(groups)s in this room.": "%(senderDisplayName)s enabled flair for %(groups)s in this room.", "%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s disabled flair for %(groups)s in this room.", "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.", - "sent an image.": "sent an image.", "%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.", "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.", "%(senderName)s removed the main address for this room.": "%(senderName)s removed the main address for this room.", @@ -421,12 +420,66 @@ "Restart": "Restart", "Upgrade your Riot": "Upgrade your Riot", "A new version of Riot is available!": "A new version of Riot is available!", - "You: %(message)s": "You: %(message)s", "Guest": "Guest", "There was an error joining the room": "There was an error joining the room", "Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.", "Please contact your homeserver administrator.": "Please contact your homeserver administrator.", "Failed to join room": "Failed to join room", + "You joined the call": "You joined the call", + "%(senderName)s joined the call": "%(senderName)s joined the call", + "Call in progress": "Call in progress", + "You left the call": "You left the call", + "%(senderName)s left the call": "%(senderName)s left the call", + "Call ended": "Call ended", + "You started a call": "You started a call", + "%(senderName)s started a call": "%(senderName)s started a call", + "Waiting for answer": "Waiting for answer", + "%(senderName)s is calling": "%(senderName)s is calling", + "You created the room": "You created the room", + "%(senderName)s created the room": "%(senderName)s created the room", + "You made the chat encrypted": "You made the chat encrypted", + "%(senderName)s made the chat encrypted": "%(senderName)s made the chat encrypted", + "You made history visible to new members": "You made history visible to new members", + "%(senderName)s made history visible to new members": "%(senderName)s made history visible to new members", + "You made history visible to anyone": "You made history visible to anyone", + "%(senderName)s made history visible to anyone": "%(senderName)s made history visible to anyone", + "You made history visible to future members": "You made history visible to future members", + "%(senderName)s made history visible to future members": "%(senderName)s made history visible to future members", + "You were invited": "You were invited", + "%(targetName)s was invited": "%(targetName)s was invited", + "You left": "You left", + "%(targetName)s left": "%(targetName)s left", + "You were kicked (%(reason)s)": "You were kicked (%(reason)s)", + "%(targetName)s was kicked (%(reason)s)": "%(targetName)s was kicked (%(reason)s)", + "You were kicked": "You were kicked", + "%(targetName)s was kicked": "%(targetName)s was kicked", + "You rejected the invite": "You rejected the invite", + "%(targetName)s rejected the invite": "%(targetName)s rejected the invite", + "You were uninvited": "You were uninvited", + "%(targetName)s was uninvited": "%(targetName)s was uninvited", + "You were banned (%(reason)s)": "You were banned (%(reason)s)", + "%(targetName)s was banned (%(reason)s)": "%(targetName)s was banned (%(reason)s)", + "You were banned": "You were banned", + "%(targetName)s was banned": "%(targetName)s was banned", + "You joined": "You joined", + "%(targetName)s joined": "%(targetName)s joined", + "You changed your name": "You changed your name", + "%(targetName)s changed their name": "%(targetName)s changed their name", + "You changed your avatar": "You changed your avatar", + "%(targetName)s changed their avatar": "%(targetName)s changed their avatar", + "%(senderName)s %(emote)s": "%(senderName)s %(emote)s", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "You changed the room name": "You changed the room name", + "%(senderName)s changed the room name": "%(senderName)s changed the room name", + "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "You uninvited %(targetName)s": "You uninvited %(targetName)s", + "%(senderName)s uninvited %(targetName)s": "%(senderName)s uninvited %(targetName)s", + "You invited %(targetName)s": "You invited %(targetName)s", + "%(senderName)s invited %(targetName)s": "%(senderName)s invited %(targetName)s", + "You changed the room topic": "You changed the room topic", + "%(senderName)s changed the room topic": "%(senderName)s changed the room topic", + "New spinner design": "New spinner design", "Font scaling": "Font scaling", "Message Pinning": "Message Pinning", "Custom user status messages": "Custom user status messages", @@ -441,7 +494,7 @@ "Font size": "Font size", "Use custom size": "Use custom size", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", - "Use compact timeline layout": "Use compact timeline layout", + "Use a more compact ‘Modern’ layout": "Use a more compact ‘Modern’ layout", "Show a placeholder for removed messages": "Show a placeholder for removed messages", "Show join/leave messages (invites/kicks/bans unaffected)": "Show join/leave messages (invites/kicks/bans unaffected)", "Show avatar changes": "Show avatar changes", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 5948a4ec9e..820329f6c6 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -97,6 +97,12 @@ export const SETTINGS = { // // not use this for new settings. // invertedSettingName: "my-negative-setting", // }, + "feature_new_spinner": { + isFeature: true, + displayName: _td("New spinner design"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_font_scaling": { isFeature: true, displayName: _td("Font scaling"), @@ -197,7 +203,7 @@ export const SETTINGS = { }, "useCompactLayout": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: _td('Use compact timeline layout'), + displayName: _td('Use a more compact ‘Modern’ layout'), default: false, }, "showRedactions": { diff --git a/src/stores/MessagePreviewStore.ts b/src/stores/MessagePreviewStore.ts deleted file mode 100644 index 9e225d1709..0000000000 --- a/src/stores/MessagePreviewStore.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* -Copyright 2020 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 { Room } from "matrix-js-sdk/src/models/room"; -import { ActionPayload } from "../dispatcher/payloads"; -import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; -import defaultDispatcher from "../dispatcher/dispatcher"; -import { RoomListStoreTempProxy } from "./room-list/RoomListStoreTempProxy"; -import { textForEvent } from "../TextForEvent"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { _t } from "../languageHandler"; - -const PREVIEWABLE_EVENTS = [ - // This is the same list from RiotX - {type: "m.room.message", isState: false}, - {type: "m.room.name", isState: true}, - {type: "m.room.topic", isState: true}, - {type: "m.room.member", isState: true}, - {type: "m.room.history_visibility", isState: true}, - {type: "m.call.invite", isState: false}, - {type: "m.call.hangup", isState: false}, - {type: "m.call.answer", isState: false}, - {type: "m.room.encrypted", isState: false}, - {type: "m.room.encryption", isState: true}, - {type: "m.room.third_party_invite", isState: true}, - {type: "m.sticker", isState: false}, - {type: "m.room.create", isState: true}, -]; - -// The maximum number of events we're willing to look back on to get a preview. -const MAX_EVENTS_BACKWARDS = 50; - -interface IState { - [roomId: string]: string | null; // null indicates the preview is empty -} - -export class MessagePreviewStore extends AsyncStoreWithClient { - private static internalInstance = new MessagePreviewStore(); - - private constructor() { - super(defaultDispatcher, {}); - } - - public static get instance(): MessagePreviewStore { - return MessagePreviewStore.internalInstance; - } - - /** - * Gets the pre-translated preview for a given room - * @param room The room to get the preview for. - * @returns The preview, or null if none present. - */ - public getPreviewForRoom(room: Room): string { - if (!room) return null; // invalid room, just return nothing - - // It's faster to do a lookup this way than it is to use Object.keys().includes() - // We only want to generate a preview if there's one actually missing and not explicitly - // set as 'none'. - const val = this.state[room.roomId]; - if (val !== null && typeof(val) !== "string") { - this.generatePreview(room); - } - - return this.state[room.roomId]; - } - - private generatePreview(room: Room) { - const events = room.timeline; - if (!events) return; // should only happen in tests - - for (let i = events.length - 1; i >= 0; i--) { - if (i === events.length - MAX_EVENTS_BACKWARDS) return; // limit reached - - const event = events[i]; - const preview = this.generatePreviewForEvent(event); - if (preview.isPreviewable) { - // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls - this.updateState({[room.roomId]: preview.preview}); - return; // break - we found some text - } - } - - // if we didn't find anything, subscribe ourselves to an update - // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls - this.updateState({[room.roomId]: null}); - } - - protected async onAction(payload: ActionPayload) { - if (!this.matrixClient) return; - - // TODO: Remove when new room list is made the default - if (!RoomListStoreTempProxy.isUsingNewStore()) return; - - if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { - const event = payload.event; // TODO: Type out the dispatcher - if (!Object.keys(this.state).includes(event.getRoomId())) return; // not important - - const preview = this.generatePreviewForEvent(event); - if (preview.isPreviewable) { - await this.updateState({[event.getRoomId()]: preview.preview}); - return; // break - we found some text - } - } - } - - private generatePreviewForEvent(event: MatrixEvent): { isPreviewable: boolean, preview: string } { - if (PREVIEWABLE_EVENTS.some(p => p.type === event.getType() && p.isState === event.isState())) { - const isSelf = event.getSender() === this.matrixClient.getUserId(); - let text = textForEvent(event, /*skipUserPrefix=*/isSelf); - if (!text || text.trim().length === 0) text = null; // force null if useless to us - if (text && isSelf) { - // XXX: i18n doesn't really work here if the language doesn't support prefixing. - // We'd ideally somehow route the `You:` bit to the textForEvent call, however - // threading that through is non-trivial. - text = _t("You: %(message)s", {message: text}); - } - return {isPreviewable: true, preview: text}; - } - return {isPreviewable: false, preview: null}; - } -} diff --git a/src/stores/room-list/ListLayout.ts b/src/stores/room-list/ListLayout.ts index ebc7b95854..8ca8ad637b 100644 --- a/src/stores/room-list/ListLayout.ts +++ b/src/stores/room-list/ListLayout.ts @@ -18,6 +18,10 @@ import { TagID } from "./models"; const TILE_HEIGHT_PX = 44; +// the .65 comes from the CSS where the show more button is +// mathematically 65% of a tile when floating. +const RESIZER_BOX_FACTOR = 0.65; + interface ISerializedListLayout { numTiles: number; showPreviews: boolean; @@ -67,6 +71,7 @@ export class ListLayout { } public get visibleTiles(): number { + if (this._n === 0) return this.defaultVisibleTiles; return Math.max(this._n, this.minVisibleTiles); } @@ -76,9 +81,13 @@ export class ListLayout { } public get minVisibleTiles(): number { - // the .65 comes from the CSS where the show more button is - // mathematically 65% of a tile when floating. - return 4.65; + return 1 + RESIZER_BOX_FACTOR; + } + + public get defaultVisibleTiles(): number { + // TODO: Remove dogfood flag + const val = Number(localStorage.getItem("mx_dogfood_rl_defTiles") || 4); + return val + RESIZER_BOX_FACTOR; } public calculateTilesToPixelsMin(maxTiles: number, n: number, possiblePadding: number): number { @@ -92,6 +101,10 @@ export class ListLayout { return this.tilesToPixels(Math.min(maxTiles, n)) + padding; } + public tilesWithResizerBoxFactor(n: number): number { + return n + RESIZER_BOX_FACTOR; + } + public tilesWithPadding(n: number, paddingPx: number): number { return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx)); } diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts new file mode 100644 index 0000000000..b727069f9f --- /dev/null +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -0,0 +1,204 @@ +/* +Copyright 2020 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 { Room } from "matrix-js-sdk/src/models/room"; +import { ActionPayload } from "../../dispatcher/payloads"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { RoomListStoreTempProxy } from "./RoomListStoreTempProxy"; +import { MessageEventPreview } from "./previews/MessageEventPreview"; +import { NameEventPreview } from "./previews/NameEventPreview"; +import { TagID } from "./models"; +import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; +import { TopicEventPreview } from "./previews/TopicEventPreview"; +import { MembershipEventPreview } from "./previews/MembershipEventPreview"; +import { HistoryVisibilityEventPreview } from "./previews/HistoryVisibilityEventPreview"; +import { CallInviteEventPreview } from "./previews/CallInviteEventPreview"; +import { CallAnswerEventPreview } from "./previews/CallAnswerEventPreview"; +import { CallHangupEvent } from "./previews/CallHangupEvent"; +import { EncryptionEventPreview } from "./previews/EncryptionEventPreview"; +import { ThirdPartyInviteEventPreview } from "./previews/ThirdPartyInviteEventPreview"; +import { StickerEventPreview } from "./previews/StickerEventPreview"; +import { ReactionEventPreview } from "./previews/ReactionEventPreview"; +import { CreationEventPreview } from "./previews/CreationEventPreview"; + +const PREVIEWS = { + 'm.room.message': { + isState: false, + previewer: new MessageEventPreview(), + }, + 'm.room.name': { + isState: true, + previewer: new NameEventPreview(), + }, + 'm.room.topic': { + isState: true, + previewer: new TopicEventPreview(), + }, + 'm.room.member': { + isState: true, + previewer: new MembershipEventPreview(), + }, + 'm.room.history_visibility': { + isState: true, + previewer: new HistoryVisibilityEventPreview(), + }, + 'm.call.invite': { + isState: false, + previewer: new CallInviteEventPreview(), + }, + 'm.call.answer': { + isState: false, + previewer: new CallAnswerEventPreview(), + }, + 'm.call.hangup': { + isState: false, + previewer: new CallHangupEvent(), + }, + 'm.room.encryption': { + isState: true, + previewer: new EncryptionEventPreview(), + }, + 'm.room.third_party_invite': { + isState: true, + previewer: new ThirdPartyInviteEventPreview(), + }, + 'm.sticker': { + isState: false, + previewer: new StickerEventPreview(), + }, + 'm.reaction': { + isState: false, + previewer: new ReactionEventPreview(), + }, + 'm.room.create': { + isState: true, + previewer: new CreationEventPreview(), + }, +}; + +// The maximum number of events we're willing to look back on to get a preview. +const MAX_EVENTS_BACKWARDS = 50; + +// type merging ftw +type TAG_ANY = "im.vector.any"; +const TAG_ANY: TAG_ANY = "im.vector.any"; + +interface IState { + [roomId: string]: Map; // null indicates the preview is empty / irrelevant +} + +export class MessagePreviewStore extends AsyncStoreWithClient { + private static internalInstance = new MessagePreviewStore(); + + private constructor() { + super(defaultDispatcher, {}); + } + + public static get instance(): MessagePreviewStore { + return MessagePreviewStore.internalInstance; + } + + /** + * Gets the pre-translated preview for a given room + * @param room The room to get the preview for. + * @param inTagId The tag ID in which the room resides + * @returns The preview, or null if none present. + */ + public getPreviewForRoom(room: Room, inTagId: TagID): string { + if (!room) return null; // invalid room, just return nothing + + const val = this.state[room.roomId]; + if (!val) this.generatePreview(room, inTagId); + + const previews = this.state[room.roomId]; + if (!previews) return null; + + if (!previews.has(inTagId)) { + return previews.get(TAG_ANY); + } + return previews.get(inTagId); + } + + private generatePreview(room: Room, tagId?: TagID) { + const events = room.timeline; + if (!events) return; // should only happen in tests + + let map = this.state[room.roomId]; + if (!map) { + map = new Map(); + + // We set the state later with the map, so no need to send an update now + } + + // Set the tags so we know what to generate + if (!map.has(TAG_ANY)) map.set(TAG_ANY, null); + if (tagId && !map.has(tagId)) map.set(tagId, null); + + let changed = false; + for (let i = events.length - 1; i >= 0; i--) { + if (i === events.length - MAX_EVENTS_BACKWARDS) return; // limit reached + + const event = events[i]; + const previewDef = PREVIEWS[event.getType()]; + if (!previewDef) continue; + if (previewDef.isState && isNullOrUndefined(event.getStateKey())) continue; + + const anyPreview = previewDef.previewer.getTextFor(event, null); + if (!anyPreview) continue; // not previewable for some reason + + changed = changed || anyPreview !== map.get(TAG_ANY); + map.set(TAG_ANY, anyPreview); + + const tagsToGenerate = Array.from(map.keys()).filter(t => t !== TAG_ANY); // we did the any tag above + for (const genTagId of tagsToGenerate) { + const realTagId: TagID = genTagId === TAG_ANY ? null : genTagId; + const preview = previewDef.previewer.getTextFor(event, realTagId); + if (preview === anyPreview) { + changed = changed || anyPreview !== map.get(genTagId); + map.delete(genTagId); + } else { + changed = changed || preview !== map.get(genTagId); + map.set(genTagId, preview); + } + } + + if (changed) { + // Update state for good measure - causes emit for update + // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls + this.updateState({[room.roomId]: map}); + } + return; // we're done + } + + // At this point, we didn't generate a preview so clear it + // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls + this.updateState({[room.roomId]: null}); + } + + protected async onAction(payload: ActionPayload) { + if (!this.matrixClient) return; + + // TODO: Remove when new room list is made the default + if (!RoomListStoreTempProxy.isUsingNewStore()) return; + + if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { + const event = payload.event; // TODO: Type out the dispatcher + if (!Object.keys(this.state).includes(event.getRoomId())) return; // not important + this.generatePreview(this.matrixClient.getRoom(event.getRoomId()), TAG_ANY); + } + } +} diff --git a/src/stores/room-list/previews/CallAnswerEventPreview.ts b/src/stores/room-list/previews/CallAnswerEventPreview.ts new file mode 100644 index 0000000000..b7207307e2 --- /dev/null +++ b/src/stores/room-list/previews/CallAnswerEventPreview.ts @@ -0,0 +1,35 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class CallAnswerEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + if (isSelf(event)) { + return _t("You joined the call"); + } else { + return _t("%(senderName)s joined the call", {senderName: getSenderName(event)}); + } + } else { + return _t("Call in progress"); + } + } +} diff --git a/src/stores/room-list/previews/CallHangupEvent.ts b/src/stores/room-list/previews/CallHangupEvent.ts new file mode 100644 index 0000000000..adc7d1aac8 --- /dev/null +++ b/src/stores/room-list/previews/CallHangupEvent.ts @@ -0,0 +1,35 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class CallHangupEvent implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + if (isSelf(event)) { + return _t("You left the call"); + } else { + return _t("%(senderName)s left the call", {senderName: getSenderName(event)}); + } + } else { + return _t("Call ended"); + } + } +} diff --git a/src/stores/room-list/previews/CallInviteEventPreview.ts b/src/stores/room-list/previews/CallInviteEventPreview.ts new file mode 100644 index 0000000000..47486e3701 --- /dev/null +++ b/src/stores/room-list/previews/CallInviteEventPreview.ts @@ -0,0 +1,39 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class CallInviteEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + if (isSelf(event)) { + return _t("You started a call"); + } else { + return _t("%(senderName)s started a call", {senderName: getSenderName(event)}); + } + } else { + if (isSelf(event)) { + return _t("Waiting for answer"); + } else { + return _t("%(senderName)s is calling", {senderName: getSenderName(event)}); + } + } + } +} diff --git a/src/stores/room-list/previews/CreationEventPreview.ts b/src/stores/room-list/previews/CreationEventPreview.ts new file mode 100644 index 0000000000..62bb5fe53a --- /dev/null +++ b/src/stores/room-list/previews/CreationEventPreview.ts @@ -0,0 +1,31 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class CreationEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (isSelf(event)) { + return _t("You created the room"); + } else { + return _t("%(senderName)s created the room", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/EncryptionEventPreview.ts b/src/stores/room-list/previews/EncryptionEventPreview.ts new file mode 100644 index 0000000000..d00fd7e7f9 --- /dev/null +++ b/src/stores/room-list/previews/EncryptionEventPreview.ts @@ -0,0 +1,31 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class EncryptionEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (isSelf(event)) { + return _t("You made the chat encrypted"); + } else { + return _t("%(senderName)s made the chat encrypted", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/HistoryVisibilityEventPreview.ts b/src/stores/room-list/previews/HistoryVisibilityEventPreview.ts new file mode 100644 index 0000000000..ac77a181f8 --- /dev/null +++ b/src/stores/room-list/previews/HistoryVisibilityEventPreview.ts @@ -0,0 +1,42 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class HistoryVisibilityEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + const visibility = event.getContent()['history_visibility']; + const isUs = isSelf(event); + + if (visibility === 'invited' || visibility === 'joined') { + return isUs + ? _t("You made history visible to new members") + : _t("%(senderName)s made history visible to new members", {senderName: getSenderName(event)}); + } else if (visibility === 'world_readable') { + return isUs + ? _t("You made history visible to anyone") + : _t("%(senderName)s made history visible to anyone", {senderName: getSenderName(event)}); + } else { // shared, default + return isUs + ? _t("You made history visible to future members") + : _t("%(senderName)s made history visible to future members", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/IPreview.ts b/src/stores/room-list/previews/IPreview.ts new file mode 100644 index 0000000000..9beb92bfbf --- /dev/null +++ b/src/stores/room-list/previews/IPreview.ts @@ -0,0 +1,31 @@ +/* +Copyright 2020 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 { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { TagID } from "../models"; + +/** + * Represents an event preview. + */ +export interface IPreview { + /** + * Gets the text which represents the event as a preview. + * @param event The event to preview. + * @param tagId Optional. The tag where the room the event was sent in resides. + * @returns The preview. + */ + getTextFor(event: MatrixEvent, tagId?: TagID): string; +} diff --git a/src/stores/room-list/previews/MembershipEventPreview.ts b/src/stores/room-list/previews/MembershipEventPreview.ts new file mode 100644 index 0000000000..44339aab5f --- /dev/null +++ b/src/stores/room-list/previews/MembershipEventPreview.ts @@ -0,0 +1,90 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getTargetName, isSelfTarget } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class MembershipEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + const newMembership = event.getContent()['membership']; + const oldMembership = event.getPrevContent()['membership']; + const reason = event.getContent()['reason']; + const isUs = isSelfTarget(event); + + if (newMembership === 'invite') { + return isUs + ? _t("You were invited") + : _t("%(targetName)s was invited", {targetName: getTargetName(event)}); + } else if (newMembership === 'leave' && oldMembership !== 'invite') { + if (event.getSender() === event.getStateKey()) { + return isUs + ? _t("You left") + : _t("%(targetName)s left", {targetName: getTargetName(event)}); + } else { + if (reason) { + return isUs + ? _t("You were kicked (%(reason)s)", {reason}) + : _t("%(targetName)s was kicked (%(reason)s)", {targetName: getTargetName(event), reason}); + } else { + return isUs + ? _t("You were kicked") + : _t("%(targetName)s was kicked", {targetName: getTargetName(event)}); + } + } + } else if (newMembership === 'leave' && oldMembership === 'invite') { + if (event.getSender() === event.getStateKey()) { + return isUs + ? _t("You rejected the invite") + : _t("%(targetName)s rejected the invite", {targetName: getTargetName(event)}); + } else { + return isUs + ? _t("You were uninvited") + : _t("%(targetName)s was uninvited", {targetName: getTargetName(event)}); + } + } else if (newMembership === 'ban') { + if (reason) { + return isUs + ? _t("You were banned (%(reason)s)", {reason}) + : _t("%(targetName)s was banned (%(reason)s)", {targetName: getTargetName(event), reason}); + } else { + return isUs + ? _t("You were banned") + : _t("%(targetName)s was banned", {targetName: getTargetName(event)}); + } + } else if (newMembership === 'join' && oldMembership !== 'join') { + return isUs + ? _t("You joined") + : _t("%(targetName)s joined", {targetName: getTargetName(event)}); + } else { + const isDisplayNameChange = event.getContent()['displayname'] !== event.getPrevContent()['displayname']; + const isAvatarChange = event.getContent()['avatar_url'] !== event.getPrevContent()['avatar_url']; + if (isDisplayNameChange) { + return isUs + ? _t("You changed your name") + : _t("%(targetName)s changed their name", {targetName: getTargetName(event)}); + } else if (isAvatarChange) { + return isUs + ? _t("You changed your avatar") + : _t("%(targetName)s changed their avatar", {targetName: getTargetName(event)}); + } else { + return null; // no change + } + } + } +} diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts new file mode 100644 index 0000000000..6f0dc14a58 --- /dev/null +++ b/src/stores/room-list/previews/MessageEventPreview.ts @@ -0,0 +1,55 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { _t } from "../../../languageHandler"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import ReplyThread from "../../../components/views/elements/ReplyThread"; + +export class MessageEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + let eventContent = event.getContent(); + + if (event.isRelation("m.replace")) { + // It's an edit, generate the preview on the new text + eventContent = event.getContent()['m.new_content']; + } + + let body = (eventContent['body'] || '').trim(); + const msgtype = eventContent['msgtype']; + if (!body || !msgtype) return null; // invalid event, no preview + + // XXX: Newer relations have a getRelation() function which is not compatible with replies. + const mRelatesTo = event.getWireContent()['m.relates_to']; + if (mRelatesTo && mRelatesTo['m.in_reply_to']) { + // If this is a reply, get the real reply and use that + body = (ReplyThread.stripPlainReply(body) || '').trim(); + if (!body) return null; // invalid event, no preview + } + + if (msgtype === 'm.emote') { + return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body}); + } + + if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + return body; + } else { + return _t("%(senderName)s: %(message)s", {senderName: getSenderName(event), message: body}); + } + } +} diff --git a/src/stores/room-list/previews/NameEventPreview.ts b/src/stores/room-list/previews/NameEventPreview.ts new file mode 100644 index 0000000000..4197abacfb --- /dev/null +++ b/src/stores/room-list/previews/NameEventPreview.ts @@ -0,0 +1,31 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class NameEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (isSelf(event)) { + return _t("You changed the room name"); + } else { + return _t("%(senderName)s changed the room name", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/ReactionEventPreview.ts b/src/stores/room-list/previews/ReactionEventPreview.ts new file mode 100644 index 0000000000..d58f592feb --- /dev/null +++ b/src/stores/room-list/previews/ReactionEventPreview.ts @@ -0,0 +1,34 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class ReactionEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + const reaction = event.getRelation().key; + if (!reaction) return; + + if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + return reaction; + } else { + return _t("%(senderName)s: %(reaction)s", {senderName: getSenderName(event), reaction}); + } + } +} diff --git a/src/stores/room-list/previews/StickerEventPreview.ts b/src/stores/room-list/previews/StickerEventPreview.ts new file mode 100644 index 0000000000..f8263a4a45 --- /dev/null +++ b/src/stores/room-list/previews/StickerEventPreview.ts @@ -0,0 +1,34 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class StickerEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + const stickerName = event.getContent()['body']; + if (!stickerName) return null; + + if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + return stickerName; + } else { + return _t("%(senderName)s: %(stickerName)s", {senderName: getSenderName(event), stickerName}); + } + } +} diff --git a/src/stores/room-list/previews/ThirdPartyInviteEventPreview.ts b/src/stores/room-list/previews/ThirdPartyInviteEventPreview.ts new file mode 100644 index 0000000000..b22cd9fac9 --- /dev/null +++ b/src/stores/room-list/previews/ThirdPartyInviteEventPreview.ts @@ -0,0 +1,42 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; +import { isValid3pidInvite } from "../../../RoomInvite"; + +export class ThirdPartyInviteEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (!isValid3pidInvite(event)) { + const targetName = event.getPrevContent().display_name || _t("Someone"); + if (isSelf(event)) { + return _t("You uninvited %(targetName)s", {targetName}); + } else { + return _t("%(senderName)s uninvited %(targetName)s", {senderName: getSenderName(event), targetName}); + } + } else { + const targetName = event.getContent().display_name; + if (isSelf(event)) { + return _t("You invited %(targetName)s", {targetName}); + } else { + return _t("%(senderName)s invited %(targetName)s", {senderName: getSenderName(event), targetName}); + } + } + } +} diff --git a/src/stores/room-list/previews/TopicEventPreview.ts b/src/stores/room-list/previews/TopicEventPreview.ts new file mode 100644 index 0000000000..9b499aae8f --- /dev/null +++ b/src/stores/room-list/previews/TopicEventPreview.ts @@ -0,0 +1,31 @@ +/* +Copyright 2020 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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class TopicEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (isSelf(event)) { + return _t("You changed the room topic"); + } else { + return _t("%(senderName)s changed the room topic", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/utils.ts b/src/stores/room-list/previews/utils.ts new file mode 100644 index 0000000000..ebbecd7bbd --- /dev/null +++ b/src/stores/room-list/previews/utils.ts @@ -0,0 +1,49 @@ +/* +Copyright 2020 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 { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { DefaultTagID, TagID } from "../models"; + +export function isSelf(event: MatrixEvent): boolean { + const selfUserId = MatrixClientPeg.get().getUserId(); + if (event.getType() === 'm.room.member') { + return event.getStateKey() === selfUserId; + } + return event.getSender() === selfUserId; +} + +export function isSelfTarget(event: MatrixEvent): boolean { + const selfUserId = MatrixClientPeg.get().getUserId(); + return event.getStateKey() === selfUserId; +} + +export function shouldPrefixMessagesIn(roomId: string, tagId: TagID): boolean { + if (tagId !== DefaultTagID.DM) return true; + + // We don't prefix anything in 1:1s + const room = MatrixClientPeg.get().getRoom(roomId); + if (!room) return true; + return room.currentState.getJoinedMemberCount() !== 2; +} + +export function getSenderName(event: MatrixEvent): string { + return event.sender ? event.sender.name : event.getSender(); +} + +export function getTargetName(event: MatrixEvent): string { + return event.target ? event.target.name : event.getStateKey(); +}