Merge branches 'develop' and 't3chguy/notifications0' of github.com:matrix-org/matrix-react-sdk into t3chguy/notifications0

pull/21833/head
Michael Telatynski 2020-06-26 15:35:36 +01:00
commit cf3c914382
53 changed files with 1569 additions and 765 deletions

View File

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

View File

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

View File

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

View File

@ -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');
}
}

View File

@ -18,7 +18,7 @@ limitations under the License.
display: inline;
}
.mx_InlineSpinner img {
.mx_InlineSpinner_spin img {
margin: 0px 6px;
vertical-align: -3px;
}

View File

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

View File

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

View File

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

View File

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

3
res/img/spinner.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.60236 3.67346C3.10764 5.59313 1.5 8.60882 1.5 12C1.5 17.799 6.20101 22.5 12 22.5C17.799 22.5 22.5 17.799 22.5 12C22.5 8.6452 20.9267 5.65787 18.4776 3.73562L17.7648 4.44842C20.0354 6.18437 21.5 8.92114 21.5 12C21.5 17.2467 17.2467 21.5 12 21.5C6.75329 21.5 2.5 17.2467 2.5 12C2.5 8.88471 3.9995 6.11966 6.31612 4.38722L5.60236 3.67346Z" fill="#03b381"/>
</svg>

After

Width:  |  Height:  |  Size: 508 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<IProps, IState> {
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private tagPanelWatcherRef: string;
// TODO: Properly support TagPanel
// TODO: Properly support searching/filtering
@ -69,39 +67,25 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
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<IProps, IState> {
// 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<IProps, IState> {
);
}
let name = <span className="mx_LeftPanel2_userName">{OwnProfileStore.instance.displayName}</span>;
let buttons = (
<span className="mx_LeftPanel2_headerButtons">
<UserMenuButton />
</span>
);
if (this.props.isMinimized) {
name = null;
buttons = null;
}
return (
<div className="mx_LeftPanel2_userHeader">
<div className="mx_LeftPanel2_headerRow">
<span className="mx_LeftPanel2_userAvatarContainer">
<BaseAvatar
idName={MatrixClientPeg.get().getUserId()}
name={OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId()}
url={OwnProfileStore.instance.getHttpAvatarUrl(avatarSize)}
width={avatarSize}
height={avatarSize}
resizeMethod="crop"
className="mx_LeftPanel2_userAvatar"
/>
</span>
{name}
{buttons}
</div>
<UserMenu isMinimized={this.props.isMinimized} />
{breadcrumbs}
</div>
);
@ -231,7 +189,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
}
public render(): React.ReactNode {
const tagPanel = (
const tagPanel = !this.state.showTagPanel ? null : (
<div className="mx_LeftPanel2_tagPanelContainer">
<TagPanel/>
</div>
@ -252,6 +210,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
const containerClasses = classNames({
"mx_LeftPanel2": true,
"mx_LeftPanel2_hasTagPanel": !!tagPanel,
"mx_LeftPanel2_minimized": this.props.isMinimized,
});

View File

@ -123,7 +123,7 @@ interface IState {
*
* Components mounted below us can access the matrix client via the react context.
*/
class LoggedInView extends React.PureComponent<IProps, IState> {
class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView';
static propTypes = {

View File

@ -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<IProps, IState> {
});
} 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<IProps, IState> {
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<IProps, IState> {
) {
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<IProps, IState> {
}
} else if (this.state.view === Views.WELCOME) {
const Welcome = sdk.getComponent('auth.Welcome');
view = <Welcome {...this.getServerProperties()} fragmentAfterLogin={fragmentAfterLogin} />;
view = <Welcome />;
} else if (this.state.view === Views.REGISTER) {
const Registration = sdk.getComponent('structures.auth.Registration');
view = (

View File

@ -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<IProps, IState> {
private dispatcherRef: string;
private themeWatcherRef: string;
private buttonRef: React.RefObject<HTMLButtonElement> = 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 = (
<div className="mx_UserMenu_contextMenu_header">
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => (
<a
href={signupLink}
target="_blank"
rel="noreferrer noopener"
tabIndex={-1}
>{sub}</a>
),
},
)}
</div>
);
}
let homeButton = null;
if (this.hasHomePage) {
homeButton = (
<li>
<AccessibleButton onClick={this.onHomeClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconHome" />
<span>{_t("Home")}</span>
</AccessibleButton>
</li>
);
}
const elementRect = this.buttonRef.current.getBoundingClientRect();
return (
<ContextMenu
chevronFace="none"
left={elementRect.width + elementRect.left}
top={elementRect.top + elementRect.height}
onFinished={this.onCloseMenu}
>
<div className="mx_IconizedContextMenu mx_UserMenu_contextMenu">
<div className="mx_UserMenu_contextMenu_header">
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{OwnProfileStore.instance.displayName}
</span>
<span className="mx_UserMenu_contextMenu_userId">
{MatrixClientPeg.get().getUserId()}
</span>
</div>
<div
className="mx_UserMenu_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
>
<img
src={require("../../../res/img/feather-customised/sun.svg")}
alt={_t("Switch theme")}
width={16}
/>
</div>
</div>
{hostingLink}
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
<ul>
{homeButton}
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconBell" />
<span>{_t("Notification settings")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconLock" />
<span>{_t("Security & privacy")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSettings" />
<span>{_t("All settings")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onShowArchived}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconArchive" />
<span>{_t("Archived rooms")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onProvideFeedback}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconMessage" />
<span>{_t("Feedback")}</span>
</AccessibleButton>
</li>
</ul>
</div>
<div className="mx_IconizedContextMenu_optionList">
<ul>
<li className="mx_UserMenu_contextMenu_redRow">
<AccessibleButton onClick={this.onSignOutClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSignOut" />
<span>{_t("Sign out")}</span>
</AccessibleButton>
</li>
</ul>
</div>
</div>
</ContextMenu>
);
};
public render() {
const avatarSize = 32; // should match border-radius of the avatar
let name = <span className="mx_UserMenu_userName">{OwnProfileStore.instance.displayName}</span>;
let buttons = (
<span className="mx_UserMenu_headerButtons">
{/* masked image in CSS */}
</span>
);
if (this.props.isMinimized) {
name = null;
buttons = null;
}
const classes = classNames({
'mx_UserMenu': true,
'mx_UserMenu_minimized': this.props.isMinimized,
});
return (
<React.Fragment>
<ContextMenuButton
className={classes}
onClick={this.onOpenMenuClick}
inputRef={this.buttonRef}
label={_t("Account settings")}
isExpanded={this.state.menuDisplayed}
>
<div className="mx_UserMenu_row">
<span className="mx_UserMenu_userAvatarContainer">
<BaseAvatar
idName={MatrixClientPeg.get().getUserId()}
name={OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId()}
url={OwnProfileStore.instance.getHttpAvatarUrl(avatarSize)}
width={avatarSize}
height={avatarSize}
resizeMethod="crop"
className="mx_UserMenu_userAvatar"
/>
</span>
{name}
{buttons}
</div>
{this.renderContextMenu()}
</ContextMenuButton>
</React.Fragment>
);
}
}

View File

@ -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<IProps, IState> {
private dispatcherRef: string;
private themeWatcherRef: string;
private buttonRef: React.RefObject<HTMLButtonElement> = 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 = (
<div className="mx_UserMenuButton_contextMenu_header">
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => (
<a
href={signupLink}
target="_blank"
rel="noreferrer noopener"
tabIndex={-1}
>{sub}</a>
),
},
)}
</div>
);
}
let homeButton = null;
if (this.hasHomePage) {
homeButton = (
<li>
<AccessibleButton onClick={this.onHomeClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconHome" />
<span>{_t("Home")}</span>
</AccessibleButton>
</li>
);
}
const elementRect = this.buttonRef.current.getBoundingClientRect();
contextMenu = (
<ContextMenu
chevronFace="none"
left={elementRect.left}
top={elementRect.top + elementRect.height}
onFinished={this.onCloseMenu}
>
<div className="mx_IconizedContextMenu mx_UserMenuButton_contextMenu">
<div className="mx_UserMenuButton_contextMenu_header">
<div className="mx_UserMenuButton_contextMenu_name">
<span className="mx_UserMenuButton_contextMenu_displayName">
{OwnProfileStore.instance.displayName}
</span>
<span className="mx_UserMenuButton_contextMenu_userId">
{MatrixClientPeg.get().getUserId()}
</span>
</div>
<div
className="mx_UserMenuButton_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
>
<img
src={require("../../../res/img/feather-customised/sun.svg")}
alt={_t("Switch theme")}
width={16}
/>
</div>
</div>
{hostingLink}
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
<ul>
{homeButton}
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconBell" />
<span>{_t("Notification settings")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconLock" />
<span>{_t("Security & privacy")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconSettings" />
<span>{_t("All settings")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onShowArchived}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconArchive" />
<span>{_t("Archived rooms")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onProvideFeedback}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconMessage" />
<span>{_t("Feedback")}</span>
</AccessibleButton>
</li>
</ul>
</div>
<div className="mx_IconizedContextMenu_optionList">
<ul>
<li>
<AccessibleButton onClick={this.onSignOutClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconSignOut" />
<span>{_t("Sign out")}</span>
</AccessibleButton>
</li>
</ul>
</div>
</div>
</ContextMenu>
);
}
return (
<React.Fragment>
<ContextMenuButton
className="mx_UserMenuButton"
onClick={this.onOpenMenuClick}
inputRef={this.buttonRef}
label={_t("Account settings")}
isExpanded={this.state.menuDisplayed}
>
<span>{/* masked image in CSS */}</span>
</ContextMenuButton>
{contextMenu}
</React.Fragment>
);
}
}

View File

@ -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'],

View File

@ -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 (
<AuthPage>
<div className="mx_Welcome">
@ -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",
}}
/>
<LanguageSelector />

View File

@ -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 = (
<div className="mx_AppLoading_spinner_fadeIn">
<MessageSpinner msg='Loading...' />
<Spinner message={_t("Loading...")} />
</div>
);
if (!this.state.hasPermissionToLoad) {

View File

@ -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 (
<div className="mx_InlineSpinner">
<img src={require("../../../../res/img/spinner.gif")} width={w} height={h} className={imgClass} />
<div className={divClass}>
<img
src={imageSource}
width={w}
height={h}
className={imgClass}
aria-label={_t("Loading...")}
/>
</div>
);
},

View File

@ -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 (
<div className="mx_Spinner">
<div className="mx_Spinner_Msg">{ msg }</div>&nbsp;
<img src={require("../../../../res/img/spinner.gif")} width={w} height={h} className={imgClass} />
</div>
);
},
});

View File

@ -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<IProps, IState> {
else label = _t(label);
if (this.props.useCheckbox) {
return <StyledCheckbox checked={this.state.value} onChange={this.checkBoxOnChange} disabled={!canChange} >
return <StyledCheckbox
checked={this.state.value}
onChange={this.checkBoxOnChange}
disabled={this.props.disabled || !canChange}
>
{label}
</StyledCheckbox>;
} else {
return (
<div className="mx_SettingsFlag">
<span className="mx_SettingsFlag_label">{label}</span>
<ToggleSwitch checked={this.state.value} onChange={this.onChange} disabled={!canChange} aria-label={label} />
<ToggleSwitch
checked={this.state.value}
onChange={this.onChange}
disabled={this.props.disabled || !canChange}
aria-label={label}
/>
</div>
);
}

View File

@ -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 (
<div className="mx_Spinner">
<img src={require("../../../../res/img/spinner.gif")} width={w} height={h} className={imgClass} />
</div>
);
},
});
return (
<div className={divClass}>
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message}</div>&nbsp;</React.Fragment> }
<img
src={imageSource}
width={w}
height={h}
className={imgClassName}
aria-label={_t("Loading...")}
/>
</div>
);
};
Spinner.propTypes = {
w: PropTypes.number,
h: PropTypes.number,
imgClassName: PropTypes.string,
message: PropTypes.node,
};
export default Spinner;

View File

@ -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 (
<span className="mx_MAudioBody">
<img src={require("../../../../res/img/spinner.gif")} alt={content.body} width="16" height="16" />
<InlineSpinner />
</span>
);
}

View File

@ -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 = <img
src={require("../../../../res/img/spinner.gif")}
alt={content.body}
width="32"
height="32"
/>;
placeholder = <InlineSpinner w={32} h={32} />;
} else if (!this.state.imgLoaded) {
// Deliberately, getSpinner is left unimplemented here, MStickerBody overides
placeholder = this.getPlaceholder();

View File

@ -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 (
<span className="mx_MVideoBody">
<div className="mx_MImageBody_thumbnail mx_MImageBody_thumbnail_spinner">
<img src={require("../../../../res/img/spinner.gif")} alt={content.body} width="16" height="16" />
<InlineSpinner />
</div>
</span>
);

View File

@ -70,6 +70,7 @@ interface IProps {
interface IState {
notificationState: ListNotificationState;
menuDisplayed: boolean;
isResizing: boolean;
}
export default class RoomSublist2 extends React.Component<IProps, IState> {
@ -82,6 +83,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
<span>{this.props.label}</span>
</AccessibleButton>
{this.renderMenu()}
{this.props.isMinimized ? null : addRoomButton}
{this.props.isMinimized ? null : badgeContainer}
{this.props.isMinimized ? null : addRoomButton}
</div>
{this.props.isMinimized ? badgeContainer : null}
{this.props.isMinimized ? addRoomButton : null}
@ -356,6 +366,12 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
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<IProps, IState> {
);
if (this.props.isMinimized) showMoreText = null;
showNButton = (
<div onClick={this.onShowAllClick} className='mx_RoomSublist2_showNButton'>
<div onClick={this.onShowAllClick} className={showMoreBtnClasses}>
<span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */}
</span>
@ -386,7 +402,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
);
if (this.props.isMinimized) showLessText = null;
showNButton = (
<div onClick={this.onShowLessClick} className='mx_RoomSublist2_showNButton'>
<div onClick={this.onShowLessClick} className={showMoreBtnClasses}>
<span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */}
</span>
@ -432,6 +448,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
resizeHandles={handles}
onResize={this.onResize}
className="mx_RoomSublist2_resizeBox"
onResizeStart={this.onResizeStart}
onResizeStop={this.onResizeStop}
>
{visibleTiles}
{showNButton}

View File

@ -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<IProps, IState> {
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) {

View File

@ -390,7 +390,13 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
let advanced: React.ReactNode;
if (this.state.showAdvanced) {
advanced = <div>
advanced = <>
<SettingsFlag
name="useCompactLayout"
level={SettingLevel.DEVICE}
useCheckbox={true}
disabled={this.state.useIRCLayout}
/>
<SettingsFlag
name="useSystemFont"
level={SettingLevel.DEVICE}
@ -412,7 +418,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
disabled={!this.state.useSystemFont}
value={this.state.systemFont}
/>
</div>;
</>;
}
return <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_Advanced">
{toggle}

View File

@ -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",

View File

@ -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": {

View File

@ -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<IState> {
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};
}
}

View File

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

View File

@ -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<TagID | TAG_ANY, string | null>; // null indicates the preview is empty / irrelevant
}
export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
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<TagID | TAG_ANY, string | null>();
// 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);
}
}
}

View File

@ -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");
}
}
}

View File

@ -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");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
}