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

 Conflicts:
	src/components/views/rooms/RoomSublist2.tsx
pull/21833/head
Michael Telatynski 2020-07-07 14:10:58 +01:00
commit 69852ecef4
21 changed files with 363 additions and 122 deletions

View File

@ -120,6 +120,7 @@
"@babel/register": "^7.7.4", "@babel/register": "^7.7.4",
"@peculiar/webcrypto": "^1.0.22", "@peculiar/webcrypto": "^1.0.22",
"@types/classnames": "^2.2.10", "@types/classnames": "^2.2.10",
"@types/counterpart": "^0.18.1",
"@types/flux": "^3.1.9", "@types/flux": "^3.1.9",
"@types/lodash": "^4.14.152", "@types/lodash": "^4.14.152",
"@types/modernizr": "^3.5.3", "@types/modernizr": "^3.5.3",

View File

@ -77,7 +77,7 @@ limitations under the License.
} }
} }
.mx_RoomTile2_menuButton { .mx_RoomTile2_notificationsButton {
margin-left: 4px; // spacing between buttons margin-left: 4px; // spacing between buttons
} }
@ -108,7 +108,8 @@ limitations under the License.
width: 20px; width: 20px;
min-width: 20px; // yay flex min-width: 20px; // yay flex
height: 20px; height: 20px;
margin: auto 0; margin-top: auto;
margin-bottom: auto;
position: relative; position: relative;
display: none; display: none;

View File

@ -20,6 +20,7 @@ import { IMatrixClientPeg } from "../MatrixClientPeg";
import ToastStore from "../stores/ToastStore"; import ToastStore from "../stores/ToastStore";
import DeviceListener from "../DeviceListener"; import DeviceListener from "../DeviceListener";
import { RoomListStore2 } from "../stores/room-list/RoomListStore2"; import { RoomListStore2 } from "../stores/room-list/RoomListStore2";
import { PlatformPeg } from "../PlatformPeg";
declare global { declare global {
interface Window { interface Window {
@ -33,6 +34,7 @@ declare global {
mx_ToastStore: ToastStore; mx_ToastStore: ToastStore;
mx_DeviceListener: DeviceListener; mx_DeviceListener: DeviceListener;
mx_RoomListStore2: RoomListStore2; mx_RoomListStore2: RoomListStore2;
mxPlatformPeg: PlatformPeg;
} }
// workaround for https://github.com/microsoft/TypeScript/issues/30933 // workaround for https://github.com/microsoft/TypeScript/issues/30933
@ -45,6 +47,10 @@ declare global {
hasStorageAccess?: () => Promise<boolean>; hasStorageAccess?: () => Promise<boolean>;
} }
interface Navigator {
userLanguage?: string;
}
interface StorageEstimate { interface StorageEstimate {
usageDetails?: {[key: string]: number}; usageDetails?: {[key: string]: number};
} }

View File

@ -53,6 +53,10 @@ export default abstract class BasePlatform {
this.startUpdateCheck = this.startUpdateCheck.bind(this); this.startUpdateCheck = this.startUpdateCheck.bind(this);
} }
abstract async getConfig(): Promise<{}>;
abstract getDefaultDeviceDisplayName(): string;
protected onAction = (payload: ActionPayload) => { protected onAction = (payload: ActionPayload) => {
switch (payload.action) { switch (payload.action) {
case 'on_client_not_viable': case 'on_client_not_viable':

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,6 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import BasePlatform from "./BasePlatform";
/* /*
* Holds the current Platform object used by the code to do anything * Holds the current Platform object used by the code to do anything
* specific to the platform we're running on (eg. web, electron) * specific to the platform we're running on (eg. web, electron)
@ -21,10 +24,8 @@ limitations under the License.
* This allows the app layer to set a Platform without necessarily * This allows the app layer to set a Platform without necessarily
* having to have a MatrixChat object * having to have a MatrixChat object
*/ */
class PlatformPeg { export class PlatformPeg {
constructor() { platform: BasePlatform = null;
this.platform = null;
}
/** /**
* Returns the current Platform object for the application. * Returns the current Platform object for the application.
@ -39,12 +40,12 @@ class PlatformPeg {
* application. * application.
* This should be an instance of a class extending BasePlatform. * This should be an instance of a class extending BasePlatform.
*/ */
set(plaf) { set(plaf: BasePlatform) {
this.platform = plaf; this.platform = plaf;
} }
} }
if (!global.mxPlatformPeg) { if (!window.mxPlatformPeg) {
global.mxPlatformPeg = new PlatformPeg(); window.mxPlatformPeg = new PlatformPeg();
} }
export default global.mxPlatformPeg; export default window.mxPlatformPeg;

View File

@ -32,6 +32,7 @@ import ResizeNotifier from "../../utils/ResizeNotifier";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2";
import {Key} from "../../Keyboard"; import {Key} from "../../Keyboard";
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -124,6 +125,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
const headerStickyWidth = rlRect.width - headerRightMargin; const headerStickyWidth = rlRect.width - headerRightMargin;
let gotBottom = false; let gotBottom = false;
let lastTopHeader;
for (const sublist of sublists) { for (const sublist of sublists) {
const slRect = sublist.getBoundingClientRect(); const slRect = sublist.getBoundingClientRect();
@ -133,19 +135,25 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
header.classList.add("mx_RoomSublist2_headerContainer_sticky"); header.classList.add("mx_RoomSublist2_headerContainer_sticky");
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom"); header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
header.style.width = `${headerStickyWidth}px`; header.style.width = `${headerStickyWidth}px`;
header.style.top = `unset`; header.style.removeProperty("top");
gotBottom = true; gotBottom = true;
} else if (slRect.top < top) { } else if ((slRect.top - (headerHeight / 3)) < top) {
header.classList.add("mx_RoomSublist2_headerContainer_sticky"); header.classList.add("mx_RoomSublist2_headerContainer_sticky");
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop"); header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
header.style.width = `${headerStickyWidth}px`; header.style.width = `${headerStickyWidth}px`;
header.style.top = `${rlRect.top}px`; header.style.top = `${rlRect.top}px`;
if (lastTopHeader) {
lastTopHeader.style.display = "none";
}
// first unset it, if set in last iteration
header.style.removeProperty("display");
lastTopHeader = header;
} else { } else {
header.classList.remove("mx_RoomSublist2_headerContainer_sticky"); header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop"); header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom"); header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
header.style.width = `unset`; header.style.removeProperty("width");
header.style.top = `unset`; header.style.removeProperty("top");
} }
} }
} }
@ -223,11 +231,14 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
private renderHeader(): React.ReactNode { private renderHeader(): React.ReactNode {
let breadcrumbs; let breadcrumbs;
if (this.state.showBreadcrumbs) { if (this.state.showBreadcrumbs && !this.props.isMinimized) {
breadcrumbs = ( breadcrumbs = (
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar"> <IndicatorScrollbar
{this.props.isMinimized ? null : <RoomBreadcrumbs2 />} className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar"
</div> verticalScrollsHorizontally={true}
>
<RoomBreadcrumbs2 />
</IndicatorScrollbar>
); );
} }
@ -277,6 +288,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
onFocus={this.onFocus} onFocus={this.onFocus}
onBlur={this.onBlur} onBlur={this.onBlur}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
onResize={this.onResize}
/>; />;
// TODO: Conference handling / calls: https://github.com/vector-im/riot-web/issues/14177 // TODO: Conference handling / calls: https://github.com/vector-im/riot-web/issues/14177

View File

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {_t} from '../../../languageHandler.js'; import {_t} from '../../../languageHandler';
import Field from "./Field"; import Field from "./Field";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";

View File

@ -51,6 +51,7 @@ interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void; onKeyDown: (ev: React.KeyboardEvent) => void;
onFocus: (ev: React.FocusEvent) => void; onFocus: (ev: React.FocusEvent) => void;
onBlur: (ev: React.FocusEvent) => void; onBlur: (ev: React.FocusEvent) => void;
onResize: () => void;
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
collapsed: boolean; collapsed: boolean;
searchFilter: string; searchFilter: string;
@ -63,8 +64,6 @@ interface IState {
} }
const TAG_ORDER: TagID[] = [ const TAG_ORDER: TagID[] = [
// -- Community Invites Placeholder --
DefaultTagID.Invite, DefaultTagID.Invite,
DefaultTagID.Favourite, DefaultTagID.Favourite,
DefaultTagID.DM, DefaultTagID.DM,
@ -76,7 +75,6 @@ const TAG_ORDER: TagID[] = [
DefaultTagID.ServerNotice, DefaultTagID.ServerNotice,
DefaultTagID.Archived, DefaultTagID.Archived,
]; ];
const COMMUNITY_TAGS_BEFORE_TAG = DefaultTagID.Invite;
const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority; const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority;
const ALWAYS_VISIBLE_TAGS: TagID[] = [ const ALWAYS_VISIBLE_TAGS: TagID[] = [
DefaultTagID.DM, DefaultTagID.DM,
@ -183,14 +181,16 @@ export default class RoomList2 extends React.Component<IProps, IState> {
layoutMap.set(tagId, new ListLayout(tagId)); layoutMap.set(tagId, new ListLayout(tagId));
} }
this.setState({sublists: newLists, layouts: layoutMap}); this.setState({sublists: newLists, layouts: layoutMap}, () => {
this.props.onResize();
});
}; };
private renderCommunityInvites(): React.ReactElement[] { private renderCommunityInvites(): React.ReactElement[] {
// TODO: Put community invites in a more sensible place (not in the room list) // TODO: Put community invites in a more sensible place (not in the room list)
return MatrixClientPeg.get().getGroups().filter(g => { return MatrixClientPeg.get().getGroups().filter(g => {
if (g.myMembership !== 'invite') return false; if (g.myMembership !== 'invite') return false;
return !this.searchFilter || this.searchFilter.matches(g.name); return !this.searchFilter || this.searchFilter.matches(g.name || "");
}).map(g => { }).map(g => {
const avatar = ( const avatar = (
<GroupAvatar <GroupAvatar
@ -224,17 +224,15 @@ export default class RoomList2 extends React.Component<IProps, IState> {
const components: React.ReactElement[] = []; const components: React.ReactElement[] = [];
for (const orderedTagId of TAG_ORDER) { for (const orderedTagId of TAG_ORDER) {
if (COMMUNITY_TAGS_BEFORE_TAG === orderedTagId) {
// Populate community invites if we have the chance
// TODO: Community invites: https://github.com/vector-im/riot-web/issues/14179
}
if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) { if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) {
// Populate custom tags if needed // Populate custom tags if needed
// TODO: Custom tags: https://github.com/vector-im/riot-web/issues/14091 // TODO: Custom tags: https://github.com/vector-im/riot-web/issues/14091
} }
const orderedRooms = this.state.sublists[orderedTagId] || []; const orderedRooms = this.state.sublists[orderedTagId] || [];
if (orderedRooms.length === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) { const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
const totalTiles = orderedRooms.length + (extraTiles ? extraTiles.length : 0);
if (totalTiles === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
continue; // skip tag - not needed continue; // skip tag - not needed
} }
@ -242,7 +240,6 @@ export default class RoomList2 extends React.Component<IProps, IState> {
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`); if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null; const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
components.push( components.push(
<RoomSublist2 <RoomSublist2
key={`sublist-${orderedTagId}`} key={`sublist-${orderedTagId}`}
@ -256,6 +253,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
isInvite={aesthetics.isInvite} isInvite={aesthetics.isInvite}
layout={this.state.layouts.get(orderedTagId)} layout={this.state.layouts.get(orderedTagId)}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
onResize={this.props.onResize}
extraBadTilesThatShouldntExist={extraTiles} extraBadTilesThatShouldntExist={extraTiles}
/> />
); );

View File

@ -40,6 +40,7 @@ import NotificationBadge from "./NotificationBadge";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import StyledCheckbox from "../elements/StyledCheckbox";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -68,6 +69,7 @@ interface IProps {
layout?: ListLayout; layout?: ListLayout;
isMinimized: boolean; isMinimized: boolean;
tagId: TagID; tagId: TagID;
onResize: () => void;
// TODO: Don't use this. It's for community invites, and community invites shouldn't be here. // TODO: Don't use this. It's for community invites, and community invites shouldn't be here.
// You should feel bad if you use this. // You should feel bad if you use this.
@ -233,6 +235,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private toggleCollapsed = () => { private toggleCollapsed = () => {
this.props.layout.isCollapsed = !this.props.layout.isCollapsed; this.props.layout.isCollapsed = !this.props.layout.isCollapsed;
this.forceUpdate(); // because the layout doesn't trigger an update this.forceUpdate(); // because the layout doesn't trigger an update
setImmediate(() => this.props.onResize()); // needs to happen when the DOM is updated
}; };
private onHeaderKeyDown = (ev: React.KeyboardEvent) => { private onHeaderKeyDown = (ev: React.KeyboardEvent) => {
@ -283,10 +286,6 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const tiles: React.ReactElement[] = []; const tiles: React.ReactElement[] = [];
if (this.props.extraBadTilesThatShouldntExist) {
tiles.push(...this.props.extraBadTilesThatShouldntExist);
}
if (this.props.rooms) { if (this.props.rooms) {
const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles); const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles);
for (const room of visibleRooms) { for (const room of visibleRooms) {
@ -302,6 +301,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
} }
if (this.props.extraBadTilesThatShouldntExist) {
tiles.push(...this.props.extraBadTilesThatShouldntExist);
}
// We only have to do this because of the extra tiles. We do it conditionally // We only have to do this because of the extra tiles. We do it conditionally
// to avoid spending cycles on slicing. It's generally fine to do this though // to avoid spending cycles on slicing. It's generally fine to do this though
// as users are unlikely to have more than a handful of tiles when the extra // as users are unlikely to have more than a handful of tiles when the extra
@ -314,15 +317,42 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
private renderMenu(): React.ReactElement { private renderMenu(): React.ReactElement {
// TODO: Get a proper invite context menu, or take invites out of the room list.
if (this.props.tagId === DefaultTagID.Invite) {
return null;
}
let contextMenu = null; let contextMenu = null;
if (this.state.contextMenuPosition) { if (this.state.contextMenuPosition) {
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
// Invites don't get some nonsense options, so only add them if we have to.
let otherSections = null;
if (this.props.tagId !== DefaultTagID.Invite) {
otherSections = (
<React.Fragment>
<hr />
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onUnreadFirstChanged}
checked={isUnreadFirst}
>
{_t("Always show first")}
</StyledMenuItemCheckbox>
</div>
<hr />
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Show")}</div>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onMessagePreviewChanged}
checked={this.props.layout.showPreviews}
>
{_t("Message preview")}
</StyledMenuItemCheckbox>
</div>
</React.Fragment>
);
}
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu
chevronFace="none" chevronFace="none"
@ -350,28 +380,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
{_t("A-Z")} {_t("A-Z")}
</StyledMenuItemRadio> </StyledMenuItemRadio>
</div> </div>
<hr /> {otherSections}
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onUnreadFirstChanged}
checked={isUnreadFirst}
>
{_t("Always show first")}
</StyledMenuItemCheckbox>
</div>
<hr />
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Show")}</div>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onMessagePreviewChanged}
checked={this.props.layout.showPreviews}
>
{_t("Message preview")}
</StyledMenuItemCheckbox>
</div>
</div> </div>
</ContextMenu> </ContextMenu>
); );

View File

@ -499,8 +499,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
{roomAvatar} {roomAvatar}
{nameContainer} {nameContainer}
{badge} {badge}
{this.renderNotificationsMenu(isActive)}
{this.renderGeneralMenu()} {this.renderGeneralMenu()}
{this.renderNotificationsMenu(isActive)}
</AccessibleButton> </AccessibleButton>
} }
</RovingTabIndexWrapper> </RovingTabIndexWrapper>

View File

@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {ReactChild} from "react"; import React, {ReactNode} from "react";
import FormButton from "../elements/FormButton"; import FormButton from "../elements/FormButton";
import {XOR} from "../../../@types/common"; import {XOR} from "../../../@types/common";
export interface IProps { export interface IProps {
description: ReactChild; description: ReactNode;
acceptLabel: string; acceptLabel: string;
onAccept(); onAccept();

View File

@ -15,7 +15,8 @@ limitations under the License.
*/ */
import { createContext } from "react"; import { createContext } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
const MatrixClientContext = createContext(undefined); const MatrixClientContext = createContext<MatrixClient>(undefined);
MatrixClientContext.displayName = "MatrixClientContext"; MatrixClientContext.displayName = "MatrixClientContext";
export default MatrixClientContext; export default MatrixClientContext;

View File

@ -15,6 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MatrixClient} from "matrix-js-sdk/src/client";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
import Modal from './Modal'; import Modal from './Modal';
import * as sdk from './index'; import * as sdk from './index';
@ -26,6 +29,56 @@ import {getAddressType} from "./UserAddress";
const E2EE_WK_KEY = "im.vector.riot.e2ee"; const E2EE_WK_KEY = "im.vector.riot.e2ee";
// TODO move these interfaces over to js-sdk once it has been typescripted enough to accept them
enum Visibility {
Public = "public",
Private = "private",
}
enum Preset {
PrivateChat = "private_chat",
TrustedPrivateChat = "trusted_private_chat",
PublicChat = "public_chat",
}
interface Invite3PID {
id_server: string;
id_access_token?: string; // this gets injected by the js-sdk
medium: string;
address: string;
}
interface IStateEvent {
type: string;
state_key?: string; // defaults to an empty string
content: object;
}
interface ICreateOpts {
visibility?: Visibility;
room_alias_name?: string;
name?: string;
topic?: string;
invite?: string[];
invite_3pid?: Invite3PID[];
room_version?: string;
creation_content?: object;
initial_state?: IStateEvent[];
preset?: Preset;
is_direct?: boolean;
power_level_content_override?: object;
}
interface IOpts {
dmUserId?: string;
createOpts?: ICreateOpts;
spinner?: boolean;
guestAccess?: boolean;
encryption?: boolean;
inlineErrors?: boolean;
andView?: boolean;
}
/** /**
* Create a new room, and switch to it. * Create a new room, and switch to it.
* *
@ -40,11 +93,12 @@ const E2EE_WK_KEY = "im.vector.riot.e2ee";
* Default: False * Default: False
* @param {bool=} opts.inlineErrors True to raise errors off the promise instead of resolving to null. * @param {bool=} opts.inlineErrors True to raise errors off the promise instead of resolving to null.
* Default: False * Default: False
* @param {bool=} opts.andView True to dispatch an action to view the room once it has been created.
* *
* @returns {Promise} which resolves to the room id, or null if the * @returns {Promise} which resolves to the room id, or null if the
* action was aborted or failed. * action was aborted or failed.
*/ */
export default function createRoom(opts) { export default function createRoom(opts: IOpts): Promise<string | null> {
opts = opts || {}; opts = opts || {};
if (opts.spinner === undefined) opts.spinner = true; if (opts.spinner === undefined) opts.spinner = true;
if (opts.guestAccess === undefined) opts.guestAccess = true; if (opts.guestAccess === undefined) opts.guestAccess = true;
@ -59,12 +113,12 @@ export default function createRoom(opts) {
return Promise.resolve(null); return Promise.resolve(null);
} }
const defaultPreset = opts.dmUserId ? 'trusted_private_chat' : 'private_chat'; const defaultPreset = opts.dmUserId ? Preset.TrustedPrivateChat : Preset.PrivateChat;
// set some defaults for the creation // set some defaults for the creation
const createOpts = opts.createOpts || {}; const createOpts = opts.createOpts || {};
createOpts.preset = createOpts.preset || defaultPreset; createOpts.preset = createOpts.preset || defaultPreset;
createOpts.visibility = createOpts.visibility || 'private'; createOpts.visibility = createOpts.visibility || Visibility.Private;
if (opts.dmUserId && createOpts.invite === undefined) { if (opts.dmUserId && createOpts.invite === undefined) {
switch (getAddressType(opts.dmUserId)) { switch (getAddressType(opts.dmUserId)) {
case 'mx-user-id': case 'mx-user-id':
@ -166,7 +220,7 @@ export default function createRoom(opts) {
}); });
} }
export function findDMForUser(client, userId) { export function findDMForUser(client: MatrixClient, userId: string): Room {
const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId); const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId);
const rooms = roomIds.map(id => client.getRoom(id)); const rooms = roomIds.map(id => client.getRoom(id));
const suitableDMRooms = rooms.filter(r => { const suitableDMRooms = rooms.filter(r => {
@ -189,7 +243,7 @@ export function findDMForUser(client, userId) {
* NOTE: this assumes you've just created the room and there's not been an opportunity * NOTE: this assumes you've just created the room and there's not been an opportunity
* for other code to run, so we shouldn't miss RoomState.newMember when it comes by. * for other code to run, so we shouldn't miss RoomState.newMember when it comes by.
*/ */
export async function _waitForMember(client, roomId, userId, opts = { timeout: 1500 }) { export async function _waitForMember(client: MatrixClient, roomId: string, userId: string, opts = { timeout: 1500 }) {
const { timeout } = opts; const { timeout } = opts;
let handler; let handler;
return new Promise((resolve) => { return new Promise((resolve) => {
@ -212,7 +266,7 @@ export async function _waitForMember(client, roomId, userId, opts = { timeout: 1
* Ensure that for every user in a room, there is at least one device that we * Ensure that for every user in a room, there is at least one device that we
* can encrypt to. * can encrypt to.
*/ */
export async function canEncryptToAllUsers(client, userIds) { export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]) {
const usersDeviceMap = await client.downloadKeys(userIds); const usersDeviceMap = await client.downloadKeys(userIds);
// { "@user:host": { "DEVICE": {...}, ... }, ... } // { "@user:host": { "DEVICE": {...}, ... }, ... }
return Object.values(usersDeviceMap).every((userDevices) => return Object.values(usersDeviceMap).every((userDevices) =>
@ -221,7 +275,7 @@ export async function canEncryptToAllUsers(client, userIds) {
); );
} }
export async function ensureDMExists(client, userId) { export async function ensureDMExists(client: MatrixClient, userId: string): Promise<string> {
const existingDMRoom = findDMForUser(client, userId); const existingDMRoom = findDMForUser(client, userId);
let roomId; let roomId;
if (existingDMRoom) { if (existingDMRoom) {

View File

@ -15,7 +15,8 @@ limitations under the License.
*/ */
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from './languageHandler.js';
import { _t } from './languageHandler';
export const GroupMemberType = PropTypes.shape({ export const GroupMemberType = PropTypes.shape({
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,

View File

@ -1200,13 +1200,13 @@
"Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>", "Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>",
"Not now": "Not now", "Not now": "Not now",
"Don't ask me again": "Don't ask me again", "Don't ask me again": "Don't ask me again",
"Sort by": "Sort by",
"Activity": "Activity",
"A-Z": "A-Z",
"Unread rooms": "Unread rooms", "Unread rooms": "Unread rooms",
"Always show first": "Always show first", "Always show first": "Always show first",
"Show": "Show", "Show": "Show",
"Message preview": "Message preview", "Message preview": "Message preview",
"Sort by": "Sort by",
"Activity": "Activity",
"A-Z": "A-Z",
"List options": "List options", "List options": "List options",
"Jump to first unread room.": "Jump to first unread room.", "Jump to first unread room.": "Jump to first unread room.",
"Jump to first invite.": "Jump to first invite.", "Jump to first invite.": "Jump to first invite.",

View File

@ -1,7 +1,7 @@
/* /*
Copyright 2017 MTRNord and Cooperative EITA Copyright 2017 MTRNord and Cooperative EITA
Copyright 2017 Vector Creations Ltd. Copyright 2017 Vector Creations Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
@ -20,10 +20,11 @@ limitations under the License.
import request from 'browser-request'; import request from 'browser-request';
import counterpart from 'counterpart'; import counterpart from 'counterpart';
import React from 'react'; import React from 'react';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
import PlatformPeg from "./PlatformPeg"; import PlatformPeg from "./PlatformPeg";
// $webapp is a webpack resolve alias pointing to the output directory, see webpack config // @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
import webpackLangJsonUrl from "$webapp/i18n/languages.json"; import webpackLangJsonUrl from "$webapp/i18n/languages.json";
const i18nFolder = 'i18n/'; const i18nFolder = 'i18n/';
@ -37,27 +38,31 @@ counterpart.setSeparator('|');
// Fall back to English // Fall back to English
counterpart.setFallbackLocale('en'); counterpart.setFallbackLocale('en');
interface ITranslatableError extends Error {
translatedMessage: string;
}
/** /**
* Helper function to create an error which has an English message * Helper function to create an error which has an English message
* with a translatedMessage property for use by the consumer. * with a translatedMessage property for use by the consumer.
* @param {string} message Message to translate. * @param {string} message Message to translate.
* @returns {Error} The constructed error. * @returns {Error} The constructed error.
*/ */
export function newTranslatableError(message) { export function newTranslatableError(message: string) {
const error = new Error(message); const error = new Error(message) as ITranslatableError;
error.translatedMessage = _t(message); error.translatedMessage = _t(message);
return error; return error;
} }
// Function which only purpose is to mark that a string is translatable // Function which only purpose is to mark that a string is translatable
// Does not actually do anything. It's helpful for automatic extraction of translatable strings // Does not actually do anything. It's helpful for automatic extraction of translatable strings
export function _td(s) { export function _td(s: string): string {
return s; return s;
} }
// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly // Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
// Takes the same arguments as counterpart.translate() // Takes the same arguments as counterpart.translate()
function safeCounterpartTranslate(text, options) { function safeCounterpartTranslate(text: string, options?: object) {
// Horrible hack to avoid https://github.com/vector-im/riot-web/issues/4191 // Horrible hack to avoid https://github.com/vector-im/riot-web/issues/4191
// The interpolation library that counterpart uses does not support undefined/null // The interpolation library that counterpart uses does not support undefined/null
// values and instead will throw an error. This is a problem since everywhere else // values and instead will throw an error. This is a problem since everywhere else
@ -89,6 +94,13 @@ function safeCounterpartTranslate(text, options) {
return translated; return translated;
} }
interface IVariables {
count?: number;
[key: string]: number | string;
}
type Tags = Record<string, (sub: string) => React.ReactNode>;
/* /*
* Translates text and optionally also replaces XML-ish elements in the text with e.g. React components * Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s". * @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
@ -105,7 +117,9 @@ function safeCounterpartTranslate(text, options) {
* *
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/ */
export function _t(text, variables, tags) { export function _t(text: string, variables?: IVariables): string;
export function _t(text: string, variables: IVariables, tags: Tags): React.ReactNode;
export function _t(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
// Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components // Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
// However, still pass the variables to counterpart so that it can choose the correct plural if count is given // However, still pass the variables to counterpart so that it can choose the correct plural if count is given
// It is enough to pass the count variable, but in the future counterpart might make use of other information too // It is enough to pass the count variable, but in the future counterpart might make use of other information too
@ -141,23 +155,25 @@ export function _t(text, variables, tags) {
* *
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/ */
export function substitute(text, variables, tags) { export function substitute(text: string, variables?: IVariables): string;
let result = text; export function substitute(text: string, variables: IVariables, tags: Tags): string;
export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
let result: React.ReactNode | string = text;
if (variables !== undefined) { if (variables !== undefined) {
const regexpMapping = {}; const regexpMapping: IVariables = {};
for (const variable in variables) { for (const variable in variables) {
regexpMapping[`%\\(${variable}\\)s`] = variables[variable]; regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
} }
result = replaceByRegexes(result, regexpMapping); result = replaceByRegexes(result as string, regexpMapping);
} }
if (tags !== undefined) { if (tags !== undefined) {
const regexpMapping = {}; const regexpMapping: Tags = {};
for (const tag in tags) { for (const tag in tags) {
regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag]; regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
} }
result = replaceByRegexes(result, regexpMapping); result = replaceByRegexes(result as string, regexpMapping);
} }
return result; return result;
@ -172,7 +188,9 @@ export function substitute(text, variables, tags) {
* *
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/ */
export function replaceByRegexes(text, mapping) { export function replaceByRegexes(text: string, mapping: IVariables): string;
export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode;
export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode {
// We initially store our output as an array of strings and objects (e.g. React components). // We initially store our output as an array of strings and objects (e.g. React components).
// This will then be converted to a string or a <span> at the end // This will then be converted to a string or a <span> at the end
const output = [text]; const output = [text];
@ -189,7 +207,7 @@ export function replaceByRegexes(text, mapping) {
// and everything after the match. Insert all three into the output. We need to do this because we can insert objects. // and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
// Otherwise there would be no need for the splitting and we could do simple replacement. // Otherwise there would be no need for the splitting and we could do simple replacement.
let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
for (const outputIndex in output) { for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
const inputText = output[outputIndex]; const inputText = output[outputIndex];
if (typeof inputText !== 'string') { // We might have inserted objects earlier, don't try to replace them if (typeof inputText !== 'string') { // We might have inserted objects earlier, don't try to replace them
continue; continue;
@ -216,7 +234,7 @@ export function replaceByRegexes(text, mapping) {
let replaced; let replaced;
// If substitution is a function, call it // If substitution is a function, call it
if (mapping[regexpString] instanceof Function) { if (mapping[regexpString] instanceof Function) {
replaced = mapping[regexpString].apply(null, capturedGroups); replaced = (mapping as Tags)[regexpString].apply(null, capturedGroups);
} else { } else {
replaced = mapping[regexpString]; replaced = mapping[regexpString];
} }
@ -277,11 +295,11 @@ export function replaceByRegexes(text, mapping) {
// Allow overriding the text displayed when no translation exists // Allow overriding the text displayed when no translation exists
// Currently only used in unit tests to avoid having to load // Currently only used in unit tests to avoid having to load
// the translations in riot-web // the translations in riot-web
export function setMissingEntryGenerator(f) { export function setMissingEntryGenerator(f: (value: string) => void) {
counterpart.setMissingEntryGenerator(f); counterpart.setMissingEntryGenerator(f);
} }
export function setLanguage(preferredLangs) { export function setLanguage(preferredLangs: string | string[]) {
if (!Array.isArray(preferredLangs)) { if (!Array.isArray(preferredLangs)) {
preferredLangs = [preferredLangs]; preferredLangs = [preferredLangs];
} }
@ -358,8 +376,8 @@ export function getLanguageFromBrowser() {
* @param {string} language The input language string * @param {string} language The input language string
* @return {string[]} List of normalised languages * @return {string[]} List of normalised languages
*/ */
export function getNormalizedLanguageKeys(language) { export function getNormalizedLanguageKeys(language: string) {
const languageKeys = []; const languageKeys: string[] = [];
const normalizedLanguage = normalizeLanguageKey(language); const normalizedLanguage = normalizeLanguageKey(language);
const languageParts = normalizedLanguage.split('-'); const languageParts = normalizedLanguage.split('-');
if (languageParts.length === 2 && languageParts[0] === languageParts[1]) { if (languageParts.length === 2 && languageParts[0] === languageParts[1]) {
@ -380,7 +398,7 @@ export function getNormalizedLanguageKeys(language) {
* @param {string} language The language string to be normalized * @param {string} language The language string to be normalized
* @returns {string} The normalized language string * @returns {string} The normalized language string
*/ */
export function normalizeLanguageKey(language) { export function normalizeLanguageKey(language: string) {
return language.toLowerCase().replace("_", "-"); return language.toLowerCase().replace("_", "-");
} }
@ -396,7 +414,7 @@ export function getCurrentLanguage() {
* @param {string[]} langs List of language codes to pick from * @param {string[]} langs List of language codes to pick from
* @returns {string} The most appropriate language code from langs * @returns {string} The most appropriate language code from langs
*/ */
export function pickBestLanguage(langs) { export function pickBestLanguage(langs: string[]): string {
const currentLang = getCurrentLanguage(); const currentLang = getCurrentLanguage();
const normalisedLangs = langs.map(normalizeLanguageKey); const normalisedLangs = langs.map(normalizeLanguageKey);
@ -408,13 +426,13 @@ export function pickBestLanguage(langs) {
{ {
// Failing that, a different dialect of the same language // Failing that, a different dialect of the same language
const closeLangIndex = normalisedLangs.find((l) => l.substr(0, 2) === currentLang.substr(0, 2)); const closeLangIndex = normalisedLangs.findIndex((l) => l.substr(0, 2) === currentLang.substr(0, 2));
if (closeLangIndex > -1) return langs[closeLangIndex]; if (closeLangIndex > -1) return langs[closeLangIndex];
} }
{ {
// Neither of those? Try an english variant. // Neither of those? Try an english variant.
const enIndex = normalisedLangs.find((l) => l.startsWith('en')); const enIndex = normalisedLangs.findIndex((l) => l.startsWith('en'));
if (enIndex > -1) return langs[enIndex]; if (enIndex > -1) return langs[enIndex];
} }
@ -422,7 +440,7 @@ export function pickBestLanguage(langs) {
return langs[0]; return langs[0];
} }
function getLangsJson() { function getLangsJson(): Promise<object> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
let url; let url;
if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through
@ -443,7 +461,7 @@ function getLangsJson() {
}); });
} }
function weblateToCounterpart(inTrs) { function weblateToCounterpart(inTrs: object): object {
const outTrs = {}; const outTrs = {};
for (const key of Object.keys(inTrs)) { for (const key of Object.keys(inTrs)) {
@ -463,7 +481,7 @@ function weblateToCounterpart(inTrs) {
return outTrs; return outTrs;
} }
function getLanguage(langPath) { function getLanguage(langPath: string): object {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request( request(
{ method: "GET", url: langPath }, { method: "GET", url: langPath },

View File

@ -85,8 +85,8 @@ export class ListLayout {
} }
public get defaultVisibleTiles(): number { public get defaultVisibleTiles(): number {
// 10 is what "feels right", and mostly subject to design's opinion. // This number is what "feels right", and mostly subject to design's opinion.
return 10 + RESIZER_BOX_FACTOR; return 5 + RESIZER_BOX_FACTOR;
} }
public setVisibleTilesWithin(diff: number, maxPossible: number) { public setVisibleTilesWithin(diff: number, maxPossible: number) {

View File

@ -18,7 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { arrayHasDiff, ArrayUtil } from "../../../utils/arrays"; import { arrayDiff, arrayHasDiff, ArrayUtil } from "../../../utils/arrays";
import { getEnumValues } from "../../../utils/enums"; import { getEnumValues } from "../../../utils/enums";
import { DefaultTagID, RoomUpdateCause, TagID } from "../models"; import { DefaultTagID, RoomUpdateCause, TagID } from "../models";
import { import {
@ -57,6 +57,7 @@ export class Algorithm extends EventEmitter {
private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room
private filteredRooms: ITagMap = {}; private filteredRooms: ITagMap = {};
private _stickyRoom: IStickyRoom = null; private _stickyRoom: IStickyRoom = null;
private _lastStickyRoom: IStickyRoom = null; // only not-null when changing the sticky room
private sortAlgorithms: ITagSortingMap; private sortAlgorithms: ITagSortingMap;
private listAlgorithms: IListOrderingMap; private listAlgorithms: IListOrderingMap;
private algorithms: IOrderingAlgorithmMap; private algorithms: IOrderingAlgorithmMap;
@ -162,9 +163,21 @@ export class Algorithm extends EventEmitter {
} }
private async updateStickyRoom(val: Room) { private async updateStickyRoom(val: Room) {
try {
return await this.doUpdateStickyRoom(val);
} finally {
this._lastStickyRoom = null; // clear to indicate we're done changing
}
}
private async doUpdateStickyRoom(val: Room) {
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing, // Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
// otherwise we risk duplicating rooms. // otherwise we risk duplicating rooms.
// Set the last sticky room to indicate that we're in a change. The code throughout the
// class can safely handle a null room, so this should be safe to do as a backup.
this._lastStickyRoom = this._stickyRoom || <IStickyRoom>{};
// It's possible to have no selected room. In that case, clear the sticky room // It's possible to have no selected room. In that case, clear the sticky room
if (!val) { if (!val) {
if (this._stickyRoom) { if (this._stickyRoom) {
@ -179,7 +192,7 @@ export class Algorithm extends EventEmitter {
} }
// When we do have a room though, we expect to be able to find it // When we do have a room though, we expect to be able to find it
const tag = this.roomIdsToTags[val.roomId][0]; let tag = this.roomIdsToTags[val.roomId][0];
if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`); if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`);
// We specifically do NOT use the ordered rooms set as it contains the sticky room, which // We specifically do NOT use the ordered rooms set as it contains the sticky room, which
@ -196,19 +209,41 @@ export class Algorithm extends EventEmitter {
// the same thing it no-ops. After we're done calling the algorithm, we'll issue // the same thing it no-ops. After we're done calling the algorithm, we'll issue
// a new update for ourselves. // a new update for ourselves.
const lastStickyRoom = this._stickyRoom; const lastStickyRoom = this._stickyRoom;
this._stickyRoom = null; this._stickyRoom = null; // clear before we update the algorithm
this.recalculateStickyRoom(); this.recalculateStickyRoom();
// When we do have the room, re-add the old room (if needed) to the algorithm // When we do have the room, re-add the old room (if needed) to the algorithm
// and remove the sticky room from the algorithm. This is so the underlying // and remove the sticky room from the algorithm. This is so the underlying
// algorithm doesn't try and confuse itself with the sticky room concept. // algorithm doesn't try and confuse itself with the sticky room concept.
if (lastStickyRoom) { // We don't add the new room if the sticky room isn't changing because that's
// an easy way to cause duplication. We have to do room ID checks instead of
// referential checks as the references can differ through the lifecycle.
if (lastStickyRoom && lastStickyRoom.room && lastStickyRoom.room.roomId !== val.roomId) {
// Lie to the algorithm and re-add the room to the algorithm // Lie to the algorithm and re-add the room to the algorithm
await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom); await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
} }
// Lie to the algorithm and remove the room from it's field of view // Lie to the algorithm and remove the room from it's field of view
await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved); await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
// Check for tag & position changes while we're here. We also check the room to ensure
// it is still the same room.
if (this._stickyRoom) {
if (this._stickyRoom.room !== val) {
// Check the room IDs just in case
if (this._stickyRoom.room.roomId === val.roomId) {
console.warn("Sticky room changed references");
} else {
throw new Error("Sticky room changed while the sticky room was changing");
}
}
console.warn(`Sticky room changed tag & position from ${tag} / ${position} `
+ `to ${this._stickyRoom.tag} / ${this._stickyRoom.position}`);
tag = this._stickyRoom.tag;
position = this._stickyRoom.position;
}
// Now that we're done lying to the algorithm, we need to update our position // Now that we're done lying to the algorithm, we need to update our position
// marker only if the user is moving further down the same list. If they're switching // marker only if the user is moving further down the same list. If they're switching
// lists, or moving upwards, the position marker will splice in just fine but if // lists, or moving upwards, the position marker will splice in just fine but if
@ -560,7 +595,7 @@ export class Algorithm extends EventEmitter {
/** /**
* Updates the roomsToTags map * Updates the roomsToTags map
*/ */
protected updateTagsFromCache() { private updateTagsFromCache() {
const newMap = {}; const newMap = {};
const tags = Object.keys(this.cachedRooms); const tags = Object.keys(this.cachedRooms);
@ -607,21 +642,94 @@ export class Algorithm extends EventEmitter {
* processing. * processing.
*/ */
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> { public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Handle room update for ${room.roomId} called with cause ${cause}`);
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from"); if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
// Note: check the isSticky against the room ID just in case the reference is wrong
const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId;
if (cause === RoomUpdateCause.NewRoom) { if (cause === RoomUpdateCause.NewRoom) {
const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room;
const roomTags = this.roomIdsToTags[room.roomId]; const roomTags = this.roomIdsToTags[room.roomId];
if (roomTags && roomTags.length > 0) { const hasTags = roomTags && roomTags.length > 0;
// Don't change the cause if the last sticky room is being re-added. If we fail to
// pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus
// lose the room.
if (hasTags && !isForLastSticky) {
console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`); console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`);
cause = RoomUpdateCause.PossibleTagChange; cause = RoomUpdateCause.PossibleTagChange;
} }
// If we have tags for a room and don't have the room referenced, the room reference
// probably changed. We need to swap out the problematic reference.
if (hasTags && !this.rooms.includes(room) && !isSticky) {
console.warn(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r);
// Sanity check
if (!this.rooms.includes(room)) {
throw new Error(`Failed to replace ${room.roomId} with an updated reference`);
}
}
// Like above, update the reference to the sticky room if we need to
if (hasTags && isSticky) {
// Go directly in and set the sticky room's new reference, being careful not
// to trigger a sticky room update ourselves.
this._stickyRoom.room = room;
}
} }
if (cause === RoomUpdateCause.PossibleTagChange) { if (cause === RoomUpdateCause.PossibleTagChange) {
// TODO: Be smarter and splice rather than regen the planet. https://github.com/vector-im/riot-web/issues/14035 let didTagChange = false;
// TODO: No-op if no change. https://github.com/vector-im/riot-web/issues/14035 const oldTags = this.roomIdsToTags[room.roomId] || [];
await this.setKnownRooms(this.rooms); const newTags = this.getTagsForRoom(room);
return true; const diff = arrayDiff(oldTags, newTags);
if (diff.removed.length > 0 || diff.added.length > 0) {
for (const rmTag of diff.removed) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Removing ${room.roomId} from ${rmTag}`);
const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
}
for (const addTag of diff.added) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Adding ${room.roomId} to ${addTag}`);
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
}
// Update the tag map so we don't regen it in a moment
this.roomIdsToTags[room.roomId] = newTags;
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`);
cause = RoomUpdateCause.Timeline;
didTagChange = true;
} else {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.warn(`Received no-op update for ${room.roomId} - changing to Timeline update`);
cause = RoomUpdateCause.Timeline;
}
if (didTagChange && isSticky) {
// Manually update the tag for the sticky room without triggering a sticky room
// update. The update will be handled implicitly by the sticky room handling and
// requires no changes on our part, if we're in the middle of a sticky room change.
if (this._lastStickyRoom) {
this._stickyRoom = {
room,
tag: this.roomIdsToTags[room.roomId][0],
position: 0, // right at the top as it changed tags
};
} else {
// We have to clear the lock as the sticky room change will trigger updates.
await this.setStickyRoomAsync(room);
}
}
} }
// If the update is for a room change which might be the sticky room, prevent it. We // If the update is for a room change which might be the sticky room, prevent it. We
@ -635,8 +743,9 @@ export class Algorithm extends EventEmitter {
} }
} }
if (cause === RoomUpdateCause.NewRoom && !this.roomIdsToTags[room.roomId]) { if (!this.roomIdsToTags[room.roomId]) {
console.log(`[RoomListDebug] Updating tags for new room ${room.roomId} (${room.name})`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`);
// Get the tags for the room and populate the cache // Get the tags for the room and populate the cache
const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t])); const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t]));
@ -646,9 +755,15 @@ export class Algorithm extends EventEmitter {
if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`); if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`);
this.roomIdsToTags[room.roomId] = roomTags; this.roomIdsToTags[room.roomId] = roomTags;
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags);
} }
let tags = this.roomIdsToTags[room.roomId]; // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`);
const tags = this.roomIdsToTags[room.roomId];
if (!tags) { if (!tags) {
console.warn(`No tags known for "${room.name}" (${room.roomId})`); console.warn(`No tags known for "${room.name}" (${room.roomId})`);
return false; return false;
@ -668,6 +783,8 @@ export class Algorithm extends EventEmitter {
changed = true; changed = true;
} }
return true; // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`);
return changed;
} }
} }

View File

@ -19,6 +19,7 @@ import { TagID } from "../../models";
import { IAlgorithm } from "./IAlgorithm"; import { IAlgorithm } from "./IAlgorithm";
import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import * as Unread from "../../../../Unread"; import * as Unread from "../../../../Unread";
import { EffectiveMembership, getEffectiveMembership } from "../../membership";
/** /**
* Sorts rooms according to the last event's timestamp in each room that seems * Sorts rooms according to the last event's timestamp in each room that seems
@ -37,6 +38,8 @@ export class RecentAlgorithm implements IAlgorithm {
// actually changed (probably needs to be done higher up?) then we could do an // actually changed (probably needs to be done higher up?) then we could do an
// insertion sort or similar on the limited set of changes. // insertion sort or similar on the limited set of changes.
const myUserId = MatrixClientPeg.get().getUserId();
const tsCache: { [roomId: string]: number } = {}; const tsCache: { [roomId: string]: number } = {};
const getLastTs = (r: Room) => { const getLastTs = (r: Room) => {
if (tsCache[r.roomId]) { if (tsCache[r.roomId]) {
@ -50,13 +53,23 @@ export class RecentAlgorithm implements IAlgorithm {
return Number.MAX_SAFE_INTEGER; return Number.MAX_SAFE_INTEGER;
} }
// If the room hasn't been joined yet, it probably won't have a timeline to
// parse. We'll still fall back to the timeline if this fails, but chances
// are we'll at least have our own membership event to go off of.
const effectiveMembership = getEffectiveMembership(r.getMyMembership());
if (effectiveMembership !== EffectiveMembership.Join) {
const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId);
if (membershipEvent && !Array.isArray(membershipEvent)) {
return membershipEvent.getTs();
}
}
for (let i = r.timeline.length - 1; i >= 0; --i) { for (let i = r.timeline.length - 1; i >= 0; --i) {
const ev = r.timeline[i]; const ev = r.timeline[i];
if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?)
// TODO: Don't assume we're using the same client as the peg // TODO: Don't assume we're using the same client as the peg
if (ev.getSender() === MatrixClientPeg.get().getUserId() if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) {
|| Unread.eventTriggersUnreadCount(ev)) {
return ev.getTs(); return ev.getTs();
} }
} }

View File

@ -15,7 +15,7 @@ limitations under the License.
*/ */
// Returns a promise which resolves with a given value after the given number of ms // Returns a promise which resolves with a given value after the given number of ms
export function sleep<T>(ms: number, value: T): Promise<T> { export function sleep<T>(ms: number, value?: T): Promise<T> {
return new Promise((resolve => { setTimeout(resolve, ms, value); })); return new Promise((resolve => { setTimeout(resolve, ms, value); }));
} }

View File

@ -1257,6 +1257,11 @@
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999" resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999"
integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ== integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ==
"@types/counterpart@^0.18.1":
version "0.18.1"
resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8"
integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ==
"@types/fbemitter@*": "@types/fbemitter@*":
version "2.0.32" version "2.0.32"
resolved "https://registry.yarnpkg.com/@types/fbemitter/-/fbemitter-2.0.32.tgz#8ed204da0f54e9c8eaec31b1eec91e25132d082c" resolved "https://registry.yarnpkg.com/@types/fbemitter/-/fbemitter-2.0.32.tgz#8ed204da0f54e9c8eaec31b1eec91e25132d082c"