Merge branch 'develop' into element

This commit is contained in:
Bruno Windels 2020-07-09 17:59:56 +02:00
commit d90fc57469
29 changed files with 532 additions and 275 deletions

View file

@ -123,16 +123,19 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
} }
.mx_LeftPanel2_roomListWrapper { .mx_LeftPanel2_roomListWrapper {
// Create a flexbox to ensure the containing items cause appropriate overflow.
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
margin-top: 12px; // so we're not up against the search/filter
&.stickyBottom { &.mx_LeftPanel2_roomListWrapper_stickyBottom {
padding-bottom: 32px; padding-bottom: 32px;
} }
&.stickyTop { &.mx_LeftPanel2_roomListWrapper_stickyTop {
padding-top: 32px; padding-top: 32px;
} }
} }

View file

@ -41,6 +41,11 @@ limitations under the License.
// with text-align in parent // with text-align in parent
display: inline-block; display: inline-block;
padding: 0 4px; padding: 0 4px;
color: $roomtile-badge-fg-color;
background-color: $roomtile-name-color;
}
.mx_JumpToBottomButton_highlight .mx_JumpToBottomButton_badge {
color: $secondary-accent-color; color: $secondary-accent-color;
background-color: $warning-color; background-color: $warning-color;
} }

View file

@ -24,10 +24,6 @@ limitations under the License.
margin-left: 8px; margin-left: 8px;
width: 100%; width: 100%;
&:first-child {
margin-top: 12px; // so we're not up against the search/filter
}
.mx_RoomSublist2_headerContainer { .mx_RoomSublist2_headerContainer {
// Create a flexbox to make alignment easy // Create a flexbox to make alignment easy
display: flex; display: flex;
@ -49,10 +45,15 @@ limitations under the License.
padding-bottom: 8px; padding-bottom: 8px;
height: 24px; height: 24px;
// Hide the header container if the contained element is stickied.
// We don't use display:none as that causes the header to go away too.
&.mx_RoomSublist2_headerContainer_hasSticky {
height: 0;
}
.mx_RoomSublist2_stickable { .mx_RoomSublist2_stickable {
flex: 1; flex: 1;
max-width: 100%; max-width: 100%;
z-index: 2; // Prioritize headers in the visible list over sticky ones
// Create a flexbox to make ordering easy // Create a flexbox to make ordering easy
display: flex; display: flex;
@ -64,7 +65,6 @@ limitations under the License.
// when sticky scrolls instead of collapses the list. // when sticky scrolls instead of collapses the list.
&.mx_RoomSublist2_headerContainer_sticky { &.mx_RoomSublist2_headerContainer_sticky {
position: fixed; position: fixed;
z-index: 1; // over top of other elements, but still under the ones in the visible list
height: 32px; // to match the header container height: 32px; // to match the header container
// width set by JS // width set by JS
width: calc(100% - 22px); width: calc(100% - 22px);
@ -188,16 +188,16 @@ limitations under the License.
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
.mx_RoomSublist2_placeholder {
height: 44px; // Height of a room tile plus margins
}
.mx_RoomSublist2_showNButton { .mx_RoomSublist2_showNButton {
cursor: pointer; cursor: pointer;
font-size: $font-13px; font-size: $font-13px;
line-height: $font-18px; line-height: $font-18px;
color: $roomtile2-preview-color; color: $roomtile2-preview-color;
// This is the same color as the left panel background because it needs
// to occlude the lastmost tile in the list.
background-color: $roomlist2-bg-color;
// Update the render() function for RoomSublist2 if these change // Update the render() function for RoomSublist2 if these change
// Update the ListLayout class for minVisibleTiles if these change. // Update the ListLayout class for minVisibleTiles if these change.
// //
@ -210,7 +210,7 @@ limitations under the License.
// We force this to the bottom so it will overlap rooms as needed. // We force this to the bottom so it will overlap rooms as needed.
// We account for the space it takes up (24px) in the code through padding. // We account for the space it takes up (24px) in the code through padding.
position: absolute; position: absolute;
bottom: 0; // the height of the resize handle bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
@ -237,16 +237,6 @@ limitations under the License.
.mx_RoomSublist2_showLessButtonChevron { .mx_RoomSublist2_showLessButtonChevron {
mask-image: url('$(res)/img/feather-customised/chevron-up.svg'); 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 // Class name comes from the ResizableBox component

View file

@ -21,6 +21,7 @@ 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"; import { PlatformPeg } from "../PlatformPeg";
import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
declare global { declare global {
interface Window { interface Window {
@ -34,6 +35,7 @@ declare global {
mx_ToastStore: ToastStore; mx_ToastStore: ToastStore;
mx_DeviceListener: DeviceListener; mx_DeviceListener: DeviceListener;
mx_RoomListStore2: RoomListStore2; mx_RoomListStore2: RoomListStore2;
mx_RoomListLayoutStore: RoomListLayoutStore;
mxPlatformPeg: PlatformPeg; mxPlatformPeg: PlatformPeg;
} }

View file

@ -115,86 +115,130 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
}; };
private handleStickyHeaders(list: HTMLDivElement) { private handleStickyHeaders(list: HTMLDivElement) {
// TODO: Evaluate if this has any performance benefit or detriment.
// See https://github.com/vector-im/riot-web/issues/14035
if (this.isDoingStickyHeaders) return; if (this.isDoingStickyHeaders) return;
this.isDoingStickyHeaders = true; this.isDoingStickyHeaders = true;
if (window.requestAnimationFrame) { window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
this.doStickyHeaders(list);
this.isDoingStickyHeaders = false;
});
} else {
this.doStickyHeaders(list); this.doStickyHeaders(list);
this.isDoingStickyHeaders = false; this.isDoingStickyHeaders = false;
} });
} }
private doStickyHeaders(list: HTMLDivElement) { private doStickyHeaders(list: HTMLDivElement) {
const rlRect = list.getBoundingClientRect(); const topEdge = list.scrollTop;
const bottom = rlRect.bottom; const bottomEdge = list.offsetHeight + list.scrollTop;
const top = rlRect.top;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2"); const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2");
const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = rlRect.width - headerRightMargin; const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin;
// We track which styles we want on a target before making the changes to avoid
// excessive layout updates.
const targetStyles = new Map<HTMLDivElement, {
stickyTop?: boolean;
stickyBottom?: boolean;
makeInvisible?: boolean;
}>();
let gotBottom = false;
let lastTopHeader; let lastTopHeader;
let firstBottomHeader;
for (const sublist of sublists) { for (const sublist of sublists) {
const slRect = sublist.getBoundingClientRect();
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable"); const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable");
header.style.removeProperty("display"); // always clear display:none first header.style.removeProperty("display"); // always clear display:none first
if (slRect.top + HEADER_HEIGHT > bottom && !gotBottom) { // When an element is <=40% off screen, make it take over
header.classList.add("mx_RoomSublist2_headerContainer_sticky"); const offScreenFactor = 0.4;
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom"); const isOffTop = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) <= topEdge;
header.style.width = `${headerStickyWidth}px`; const isOffBottom = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) >= bottomEdge;
header.style.removeProperty("top");
gotBottom = true; if (isOffTop || sublist === sublists[0]) {
} else if (((slRect.top - (HEADER_HEIGHT * 0.6) + HEADER_HEIGHT) < top) || sublist === sublists[0]) { targetStyles.set(header, { stickyTop: true });
// the header should become sticky once it is 60% or less out of view at the top.
// We also add HEADER_HEIGHT because the sticky header is put above the scrollable area,
// into the padding of .mx_LeftPanel2_roomListWrapper,
// by subtracting HEADER_HEIGHT from the top below.
// We also always try to make the first sublist header sticky.
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
header.style.width = `${headerStickyWidth}px`;
header.style.top = `${rlRect.top - HEADER_HEIGHT}px`;
if (lastTopHeader) { if (lastTopHeader) {
lastTopHeader.style.display = "none"; lastTopHeader.style.display = "none";
targetStyles.set(lastTopHeader, { makeInvisible: true });
} }
lastTopHeader = header; lastTopHeader = header;
} else if (isOffBottom && !firstBottomHeader) {
targetStyles.set(header, { stickyBottom: true });
firstBottomHeader = header;
} else { } else {
header.classList.remove("mx_RoomSublist2_headerContainer_sticky"); targetStyles.set(header, {}); // nothing == clear
header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop"); }
header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom"); }
header.style.removeProperty("width");
header.style.removeProperty("top"); // Run over the style changes and make them reality. We check to see if we're about to
// cause a no-op update, as adding/removing properties that are/aren't there cause
// layout updates.
for (const header of targetStyles.keys()) {
const style = targetStyles.get(header);
const headerContainer = header.parentElement; // .mx_RoomSublist2_headerContainer
if (style.makeInvisible) {
// we will have already removed the 'display: none', so add it back.
header.style.display = "none";
continue; // nothing else to do, even if sticky somehow
}
if (style.stickyTop) {
if (!header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) {
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
}
const newTop = `${list.parentElement.offsetTop}px`;
if (header.style.top !== newTop) {
header.style.top = newTop;
}
} else if (style.stickyBottom) {
if (!header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) {
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
}
}
if (style.stickyTop || style.stickyBottom) {
if (!header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) {
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
}
if (!headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) {
headerContainer.classList.add("mx_RoomSublist2_headerContainer_hasSticky");
}
const newWidth = `${headerStickyWidth}px`;
if (header.style.width !== newWidth) {
header.style.width = newWidth;
}
} else if (!style.stickyTop && !style.stickyBottom) {
if (header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) {
header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
}
if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) {
header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
}
if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) {
header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
}
if (headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) {
headerContainer.classList.remove("mx_RoomSublist2_headerContainer_hasSticky");
}
if (header.style.width) {
header.style.removeProperty('width');
}
if (header.style.top) {
header.style.removeProperty('top');
}
} }
} }
// add appropriate sticky classes to wrapper so it has // add appropriate sticky classes to wrapper so it has
// the necessary top/bottom padding to put the sticky header in // the necessary top/bottom padding to put the sticky header in
const listWrapper = list.parentElement; const listWrapper = list.parentElement; // .mx_LeftPanel2_roomListWrapper
if (gotBottom) {
listWrapper.classList.add("stickyBottom");
} else {
listWrapper.classList.remove("stickyBottom");
}
if (lastTopHeader) { if (lastTopHeader) {
listWrapper.classList.add("stickyTop"); listWrapper.classList.add("mx_LeftPanel2_roomListWrapper_stickyTop");
} else { } else {
listWrapper.classList.remove("stickyTop"); listWrapper.classList.remove("mx_LeftPanel2_roomListWrapper_stickyTop");
} }
if (firstBottomHeader) {
// ensure scroll doesn't go above the gap left by the header of listWrapper.classList.add("mx_LeftPanel2_roomListWrapper_stickyBottom");
// the first sublist always being sticky if no other header is sticky } else {
if (list.scrollTop < HEADER_HEIGHT) { listWrapper.classList.remove("mx_LeftPanel2_roomListWrapper_stickyBottom");
list.scrollTop = HEADER_HEIGHT;
} }
} }

View file

@ -2047,6 +2047,7 @@ export default createReactClass({
if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) { if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) {
const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton'); const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton');
jumpToBottom = (<JumpToBottomButton jumpToBottom = (<JumpToBottomButton
highlight={this.state.room.getUnreadNotificationCount('highlight') > 0}
numUnreadMessages={this.state.numUnreadMessages} numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline} onScrollToBottomClick={this.jumpToLiveTimeline}
/>); />);

View file

@ -280,11 +280,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
label={_t("All settings")} label={_t("All settings")}
onClick={(e) => this.onSettingsOpen(e, null)} onClick={(e) => this.onSettingsOpen(e, null)}
/> />
<MenuButton {/* <MenuButton
iconClassName="mx_UserMenu_iconArchive" iconClassName="mx_UserMenu_iconArchive"
label={_t("Archived rooms")} label={_t("Archived rooms")}
onClick={this.onShowArchived} onClick={this.onShowArchived}
/> /> */}
<MenuButton <MenuButton
iconClassName="mx_UserMenu_iconMessage" iconClassName="mx_UserMenu_iconMessage"
label={_t("Feedback")} label={_t("Feedback")}
@ -350,8 +350,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
{name} {name}
{buttons} {buttons}
</div> </div>
{this.renderContextMenu()}
</ContextMenuButton> </ContextMenuButton>
{this.renderContextMenu()}
</React.Fragment> </React.Fragment>
); );
} }

View file

@ -21,8 +21,8 @@ import { TagID } from '../../../stores/room-list/models';
import RoomAvatar from "./RoomAvatar"; import RoomAvatar from "./RoomAvatar";
import RoomTileIcon from "../rooms/RoomTileIcon"; import RoomTileIcon from "../rooms/RoomTileIcon";
import NotificationBadge from '../rooms/NotificationBadge'; import NotificationBadge from '../rooms/NotificationBadge';
import { INotificationState } from "../../../stores/notifications/INotificationState"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState"; import { NotificationState } from "../../../stores/notifications/NotificationState";
interface IProps { interface IProps {
room: Room; room: Room;
@ -33,7 +33,7 @@ interface IProps {
} }
interface IState { interface IState {
notificationState?: INotificationState; notificationState?: NotificationState;
} }
export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> { export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> {
@ -42,7 +42,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
super(props); super(props);
this.state = { this.state = {
notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag), notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room, this.props.tag),
}; };
} }

View file

@ -16,13 +16,18 @@ limitations under the License.
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import classNames from 'classnames';
export default (props) => { export default (props) => {
const className = classNames({
'mx_JumpToBottomButton': true,
'mx_JumpToBottomButton_highlight': props.highlight,
});
let badge; let badge;
if (props.numUnreadMessages) { if (props.numUnreadMessages) {
badge = (<div className="mx_JumpToBottomButton_badge">{props.numUnreadMessages}</div>); badge = (<div className="mx_JumpToBottomButton_badge">{props.numUnreadMessages}</div>);
} }
return (<div className="mx_JumpToBottomButton"> return (<div className={className}>
<AccessibleButton className="mx_JumpToBottomButton_scrollDown" <AccessibleButton className="mx_JumpToBottomButton_scrollDown"
title={_t("Scroll to most recent messages")} title={_t("Scroll to most recent messages")}
onClick={props.onScrollToBottomClick}> onClick={props.onScrollToBottomClick}>

View file

@ -22,11 +22,10 @@ import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { readReceiptChangeIsFor } from "../../../utils/read-receipts"; import { readReceiptChangeIsFor } from "../../../utils/read-receipts";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common"; import { XOR } from "../../../@types/common";
import { INotificationState, NOTIFICATION_STATE_UPDATE } from "../../../stores/notifications/INotificationState"; import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
interface IProps { interface IProps {
notification: INotificationState; notification: NotificationState;
/** /**
* If true, the badge will show a count if at all possible. This is typically * If true, the badge will show a count if at all possible. This is typically
@ -97,19 +96,17 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
const {notification, forceCount, roomId, onClick, ...props} = this.props; const {notification, forceCount, roomId, onClick, ...props} = this.props;
// Don't show a badge if we don't need to // Don't show a badge if we don't need to
if (notification.color <= NotificationColor.None) return null; if (notification.isIdle) return null;
// TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261 // TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261
// As of writing, that is "if red, show count always" and "optionally show counts instead of dots". // As of writing, that is "if red, show count always" and "optionally show counts instead of dots".
// See git diff for what that boolean state looks like. // See git diff for what that boolean state looks like.
// XXX: We ignore this.state.showCounts (the setting which controls counts vs dots). // XXX: We ignore this.state.showCounts (the setting which controls counts vs dots).
const hasNotif = notification.color >= NotificationColor.Red;
const hasCount = notification.color >= NotificationColor.Grey;
const hasAnySymbol = notification.symbol || notification.count > 0; const hasAnySymbol = notification.symbol || notification.count > 0;
let isEmptyBadge = !hasAnySymbol || !hasCount; let isEmptyBadge = !hasAnySymbol || !notification.hasUnreadCount;
if (forceCount) { if (forceCount) {
isEmptyBadge = false; isEmptyBadge = false;
if (!hasCount) return null; // Can't render a badge if (!notification.hasUnreadCount) return null; // Can't render a badge
} }
let symbol = notification.symbol || formatMinimalBadgeCount(notification.count); let symbol = notification.symbol || formatMinimalBadgeCount(notification.count);
@ -117,8 +114,8 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
const classes = classNames({ const classes = classNames({
'mx_NotificationBadge': true, 'mx_NotificationBadge': true,
'mx_NotificationBadge_visible': isEmptyBadge ? true : hasCount, 'mx_NotificationBadge_visible': isEmptyBadge ? true : notification.hasUnreadCount,
'mx_NotificationBadge_highlighted': hasNotif, 'mx_NotificationBadge_highlighted': notification.hasMentions,
'mx_NotificationBadge_dot': isEmptyBadge, 'mx_NotificationBadge_dot': isEmptyBadge,
'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3, 'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
'mx_NotificationBadge_3char': symbol.length > 2, 'mx_NotificationBadge_3char': symbol.length > 2,

View file

@ -16,7 +16,6 @@ limitations under the License.
import React from "react"; import React from "react";
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore"; import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
import AccessibleButton from "../elements/AccessibleButton";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
@ -92,9 +91,6 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
}; };
public render(): React.ReactElement { public render(): React.ReactElement {
// TODO: Decorate crumbs with icons: https://github.com/vector-im/riot-web/issues/14040
// TODO: Scrolling: https://github.com/vector-im/riot-web/issues/14040
// TODO: Tooltips: https://github.com/vector-im/riot-web/issues/14040
const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => { const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => {
const roomTags = RoomListStore.instance.getTagsForRoom(r); const roomTags = RoomListStore.instance.getTagsForRoom(r);
const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0]; const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0];

View file

@ -32,15 +32,14 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomSublist2 from "./RoomSublist2"; import RoomSublist2 from "./RoomSublist2";
import { ActionPayload } from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition"; import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
import { ListLayout } from "../../../stores/room-list/ListLayout";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import GroupAvatar from "../avatars/GroupAvatar"; import GroupAvatar from "../avatars/GroupAvatar";
import TemporaryTile from "./TemporaryTile"; import TemporaryTile from "./TemporaryTile";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload"; import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
// 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
@ -66,7 +65,6 @@ interface IProps {
interface IState { interface IState {
sublists: ITagMap; sublists: ITagMap;
layouts: Map<TagID, ListLayout>;
} }
const TAG_ORDER: TagID[] = [ const TAG_ORDER: TagID[] = [
@ -151,7 +149,6 @@ export default class RoomList2 extends React.Component<IProps, IState> {
this.state = { this.state = {
sublists: {}, sublists: {},
layouts: new Map<TagID, ListLayout>(),
}; };
this.dispatcherRef = defaultDispatcher.register(this.onAction); this.dispatcherRef = defaultDispatcher.register(this.onAction);
@ -204,14 +201,11 @@ export default class RoomList2 extends React.Component<IProps, IState> {
let listRooms = lists[t]; let listRooms = lists[t];
if (unread) { if (unread) {
// TODO Be smarter and not spin up a bunch of wasted listeners just to kill them 4 lines later
// https://github.com/vector-im/riot-web/issues/14035
const notificationStates = rooms.map(r => new TagSpecificNotificationState(r, t));
// filter to only notification rooms (and our current active room so we can index properly) // filter to only notification rooms (and our current active room so we can index properly)
listRooms = notificationStates.filter(state => { listRooms = listRooms.filter(r => {
return state.room.roomId === roomId || state.color >= NotificationColor.Bold; const state = RoomNotificationStateStore.instance.getRoomState(r, t);
return state.room.roomId === roomId || state.isUnread;
}); });
notificationStates.forEach(state => state.destroy());
} }
rooms.push(...listRooms); rooms.push(...listRooms);
@ -227,12 +221,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
const newLists = RoomListStore.instance.orderedLists; const newLists = RoomListStore.instance.orderedLists;
console.log("new lists", newLists); console.log("new lists", newLists);
const layoutMap = new Map<TagID, ListLayout>(); this.setState({sublists: newLists}, () => {
for (const tagId of Object.keys(newLists)) {
layoutMap.set(tagId, new ListLayout(tagId));
}
this.setState({sublists: newLists, layouts: layoutMap}, () => {
this.props.onResize(); this.props.onResize();
}); });
}; };
@ -301,8 +290,6 @@ export default class RoomList2 extends React.Component<IProps, IState> {
label={_t(aesthetics.sectionLabel)} label={_t(aesthetics.sectionLabel)}
onAddRoom={onAddRoomFn} onAddRoom={onAddRoomFn}
addRoomLabel={aesthetics.addRoomLabel} addRoomLabel={aesthetics.addRoomLabel}
isInvite={aesthetics.isInvite}
layout={this.state.layouts.get(orderedTagId)}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
onResize={this.props.onResize} onResize={this.props.onResize}
extraBadTilesThatShouldntExist={extraTiles} extraBadTilesThatShouldntExist={extraTiles}

View file

@ -45,6 +45,8 @@ import {ActionPayload} from "../../../dispatcher/payloads";
import { Enable, Resizable } from "re-resizable"; import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer"; import { Direction } from "re-resizable/lib/resizer";
import { polyfillTouchEvent } from "../../../@types/polyfill"; import { polyfillTouchEvent } from "../../../@types/polyfill";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
// 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
@ -73,8 +75,6 @@ interface IProps {
label: string; label: string;
onAddRoom?: () => void; onAddRoom?: () => void;
addRoomLabel: string; addRoomLabel: string;
isInvite: boolean;
layout?: ListLayout;
isMinimized: boolean; isMinimized: boolean;
tagId: TagID; tagId: TagID;
onResize: () => void; onResize: () => void;
@ -98,12 +98,15 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef<HTMLDivElement>(); private headerButton = createRef<HTMLDivElement>();
private sublistRef = createRef<HTMLDivElement>(); private sublistRef = createRef<HTMLDivElement>();
private dispatcherRef: string; private dispatcherRef: string;
private layout: ListLayout;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
this.state = { this.state = {
notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId), notificationState: RoomNotificationStateStore.instance.getListState(this.props.tagId),
contextMenuPosition: null, contextMenuPosition: null,
isResizing: false, isResizing: false,
}; };
@ -116,8 +119,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
private get numVisibleTiles(): number { private get numVisibleTiles(): number {
if (!this.props.layout) return 0; const nVisible = Math.floor(this.layout.visibleTiles);
const nVisible = Math.floor(this.props.layout.visibleTiles);
return Math.min(nVisible, this.numTiles); return Math.min(nVisible, this.numTiles);
} }
@ -135,7 +137,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
// XXX: we have to do this a tick later because we have incorrect intermediate props during a room change // XXX: we have to do this a tick later because we have incorrect intermediate props during a room change
// where we lose the room we are changing from temporarily and then it comes back in an update right after. // where we lose the room we are changing from temporarily and then it comes back in an update right after.
setImmediate(() => { setImmediate(() => {
const isCollapsed = this.props.layout.isCollapsed; const isCollapsed = this.layout.isCollapsed;
const roomIndex = this.props.rooms.findIndex((r) => r.roomId === payload.room_id); const roomIndex = this.props.rooms.findIndex((r) => r.roomId === payload.room_id);
if (isCollapsed && roomIndex > -1) { if (isCollapsed && roomIndex > -1) {
@ -143,7 +145,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
// extend the visible section to include the room if it is entirely invisible // extend the visible section to include the room if it is entirely invisible
if (roomIndex >= this.numVisibleTiles) { if (roomIndex >= this.numVisibleTiles) {
this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT); this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render this.forceUpdate(); // because the layout doesn't trigger a re-render
} }
}); });
@ -170,10 +172,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
// resizing started*, meaning it is fairly useless for us. This is why we just use // resizing started*, meaning it is fairly useless for us. This is why we just use
// the client height and run with it. // the client height and run with it.
const heightBefore = this.props.layout.visibleTiles; const heightBefore = this.layout.visibleTiles;
const heightInTiles = this.props.layout.pixelsToTiles(refToElement.clientHeight); const heightInTiles = this.layout.pixelsToTiles(refToElement.clientHeight);
this.props.layout.setVisibleTilesWithin(heightInTiles, this.numTiles); this.layout.setVisibleTilesWithin(heightInTiles, this.numTiles);
if (heightBefore === this.props.layout.visibleTiles) return; // no-op if (heightBefore === this.layout.visibleTiles) return; // no-op
this.forceUpdate(); // because the layout doesn't trigger a re-render this.forceUpdate(); // because the layout doesn't trigger a re-render
}; };
@ -187,13 +189,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private onShowAllClick = () => { private onShowAllClick = () => {
const numVisibleTiles = this.numVisibleTiles; const numVisibleTiles = this.numVisibleTiles;
this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT); this.layout.visibleTiles = this.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render this.forceUpdate(); // because the layout doesn't trigger a re-render
setImmediate(this.focusRoomTile, numVisibleTiles); // focus the tile after the current bottom one setImmediate(this.focusRoomTile, numVisibleTiles); // focus the tile after the current bottom one
}; };
private onShowLessClick = () => { private onShowLessClick = () => {
this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles; this.layout.visibleTiles = this.layout.defaultVisibleTiles;
this.forceUpdate(); // because the layout doesn't trigger a re-render this.forceUpdate(); // because the layout doesn't trigger a re-render
// focus will flow to the show more button here // focus will flow to the show more button here
}; };
@ -241,7 +243,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}; };
private onMessagePreviewChanged = () => { private onMessagePreviewChanged = () => {
this.props.layout.showPreviews = !this.props.layout.showPreviews; this.layout.showPreviews = !this.layout.showPreviews;
this.forceUpdate(); // because the layout doesn't trigger a re-render this.forceUpdate(); // because the layout doesn't trigger a re-render
}; };
@ -293,13 +295,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}; };
private toggleCollapsed = () => { private toggleCollapsed = () => {
this.props.layout.isCollapsed = !this.props.layout.isCollapsed; this.layout.isCollapsed = !this.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 setImmediate(() => this.props.onResize()); // needs to happen when the DOM is updated
}; };
private onHeaderKeyDown = (ev: React.KeyboardEvent) => { private onHeaderKeyDown = (ev: React.KeyboardEvent) => {
const isCollapsed = this.props.layout && this.props.layout.isCollapsed; const isCollapsed = this.layout && this.layout.isCollapsed;
switch (ev.key) { switch (ev.key) {
case Key.ARROW_LEFT: case Key.ARROW_LEFT:
ev.stopPropagation(); ev.stopPropagation();
@ -339,7 +341,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}; };
private renderVisibleTiles(): React.ReactElement[] { private renderVisibleTiles(): React.ReactElement[] {
if (this.props.layout && this.props.layout.isCollapsed) { if (this.layout && this.layout.isCollapsed) {
// don't waste time on rendering // don't waste time on rendering
return []; return [];
} }
@ -353,7 +355,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<RoomTile2 <RoomTile2
room={room} room={room}
key={`room-${room.roomId}`} key={`room-${room.roomId}`}
showMessagePreview={this.props.layout.showPreviews} showMessagePreview={this.layout.showPreviews}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
tag={this.props.tagId} tag={this.props.tagId}
/> />
@ -404,7 +406,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<StyledMenuItemCheckbox <StyledMenuItemCheckbox
onClose={this.onCloseMenu} onClose={this.onCloseMenu}
onChange={this.onMessagePreviewChanged} onChange={this.onMessagePreviewChanged}
checked={this.props.layout.showPreviews} checked={this.layout.showPreviews}
> >
{_t("Message preview")} {_t("Message preview")}
</StyledMenuItemCheckbox> </StyledMenuItemCheckbox>
@ -496,7 +498,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const collapseClasses = classNames({ const collapseClasses = classNames({
'mx_RoomSublist2_collapseBtn': true, 'mx_RoomSublist2_collapseBtn': true,
'mx_RoomSublist2_collapseBtn_collapsed': this.props.layout && this.props.layout.isCollapsed, 'mx_RoomSublist2_collapseBtn_collapsed': this.layout && this.layout.isCollapsed,
}); });
const classes = classNames({ const classes = classNames({
@ -524,7 +526,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
tabIndex={tabIndex} tabIndex={tabIndex}
className="mx_RoomSublist2_headerText" className="mx_RoomSublist2_headerText"
role="treeitem" role="treeitem"
aria-expanded={!this.props.layout || !this.props.layout.isCollapsed} aria-expanded={!this.layout.isCollapsed}
aria-level={1} aria-level={1}
onClick={this.onHeaderClick} onClick={this.onHeaderClick}
onContextMenu={this.onContextMenu} onContextMenu={this.onContextMenu}
@ -558,12 +560,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
let content = null; let content = null;
if (visibleTiles.length > 0) { if (visibleTiles.length > 0) {
const layout = this.props.layout; // to shorten calls const layout = this.layout; // to shorten calls
const maxTilesFactored = layout.tilesWithResizerBoxFactor(this.numTiles);
const showMoreBtnClasses = classNames({ const showMoreBtnClasses = classNames({
'mx_RoomSublist2_showNButton': true, '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 // If we're hiding rooms, show a 'show more' button to the user. This button
@ -587,7 +587,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
{showMoreText} {showMoreText}
</RovingAccessibleButton> </RovingAccessibleButton>
); );
} else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) { } else if (this.numTiles <= visibleTiles.length && this.numTiles > this.layout.defaultVisibleTiles) {
// we have all tiles visible - add a button to show less // we have all tiles visible - add a button to show less
let showLessText = ( let showLessText = (
<span className='mx_RoomSublist2_showNButtonText'> <span className='mx_RoomSublist2_showNButtonText'>
@ -642,6 +642,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles); const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles);
const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding); const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding);
// Now that we know our padding constraints, let's find out if we need to chop off the
// last rendered visible tile so it doesn't collide with the 'show more' button
let visibleUnpaddedTiles = Math.round(layout.visibleTiles - layout.pixelsToTiles(padding));
if (visibleUnpaddedTiles === visibleTiles.length - 1) {
const placeholder = <div className="mx_RoomSublist2_placeholder" key='placeholder' />;
visibleTiles.splice(visibleUnpaddedTiles, 1, placeholder);
}
const dimensions = { const dimensions = {
height: tilesPx, height: tilesPx,
}; };

View file

@ -46,15 +46,14 @@ import {
MUTE, MUTE,
} from "../../../RoomNotifs"; } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
import { INotificationState } from "../../../stores/notifications/INotificationState";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { Volume } from "../../../RoomNotifsTypes"; import { Volume } from "../../../RoomNotifsTypes";
import RoomListStore from "../../../stores/room-list/RoomListStore2"; import RoomListStore from "../../../stores/room-list/RoomListStore2";
import RoomListActions from "../../../actions/RoomListActions"; import RoomListActions from "../../../actions/RoomListActions";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import {ActionPayload} from "../../../dispatcher/payloads"; import {ActionPayload} from "../../../dispatcher/payloads";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationState } from "../../../stores/notifications/NotificationState";
// 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
@ -80,7 +79,7 @@ type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
interface IState { interface IState {
hover: boolean; hover: boolean;
notificationState: INotificationState; notificationState: NotificationState;
selected: boolean; selected: boolean;
notificationsMenuPosition: PartialDOMRect; notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect; generalMenuPosition: PartialDOMRect;
@ -132,7 +131,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.state = { this.state = {
hover: false, hover: false,
notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag), notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room, this.props.tag),
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuPosition: null, notificationsMenuPosition: null,
generalMenuPosition: null, generalMenuPosition: null,
@ -492,11 +491,10 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
} }
} }
const notificationColor = this.state.notificationState.color;
const nameClasses = classNames({ const nameClasses = classNames({
"mx_RoomTile2_name": true, "mx_RoomTile2_name": true,
"mx_RoomTile2_nameWithPreview": !!messagePreview, "mx_RoomTile2_nameWithPreview": !!messagePreview,
"mx_RoomTile2_nameHasUnreadEvents": notificationColor >= NotificationColor.Bold, "mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.isUnread,
}); });
let nameContainer = ( let nameContainer = (
@ -513,15 +511,15 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
// The following labels are written in such a fashion to increase screen reader efficiency (speed). // The following labels are written in such a fashion to increase screen reader efficiency (speed).
if (this.props.tag === DefaultTagID.Invite) { if (this.props.tag === DefaultTagID.Invite) {
// append nothing // append nothing
} else if (notificationColor >= NotificationColor.Red) { } else if (this.state.notificationState.hasMentions) {
ariaLabel += " " + _t("%(count)s unread messages including mentions.", { ariaLabel += " " + _t("%(count)s unread messages including mentions.", {
count: this.state.notificationState.count, count: this.state.notificationState.count,
}); });
} else if (notificationColor >= NotificationColor.Grey) { } else if (this.state.notificationState.hasUnreadCount) {
ariaLabel += " " + _t("%(count)s unread messages.", { ariaLabel += " " + _t("%(count)s unread messages.", {
count: this.state.notificationState.count, count: this.state.notificationState.count,
}); });
} else if (notificationColor >= NotificationColor.Bold) { } else if (this.state.notificationState.isUnread) {
ariaLabel += " " + _t("Unread messages."); ariaLabel += " " + _t("Unread messages.");
} }

View file

@ -18,16 +18,15 @@ import React from "react";
import classNames from "classnames"; import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton from "../../views/elements/AccessibleButton"; import AccessibleButton from "../../views/elements/AccessibleButton";
import { INotificationState } from "../../../stores/notifications/INotificationState";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { NotificationState } from "../../../stores/notifications/NotificationState";
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;
isSelected: boolean; isSelected: boolean;
displayName: string; displayName: string;
avatar: React.ReactElement; avatar: React.ReactElement;
notificationState: INotificationState; notificationState: NotificationState;
onClick: () => void; onClick: () => void;
} }
@ -74,7 +73,7 @@ export default class TemporaryTile extends React.Component<IProps, IState> {
const nameClasses = classNames({ const nameClasses = classNames({
"mx_RoomTile2_name": true, "mx_RoomTile2_name": true,
"mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.color >= NotificationColor.Bold, "mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.isUnread,
}); });
let nameContainer = ( let nameContainer = (

View file

@ -402,6 +402,12 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
useCheckbox={true} useCheckbox={true}
disabled={this.state.useIRCLayout} disabled={this.state.useIRCLayout}
/> />
<SettingsFlag
name="useIRCLayout"
level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({useIRCLayout: checked})}
/>
<SettingsFlag <SettingsFlag
name="useSystemFont" name="useSystemFont"
level={SettingLevel.DEVICE} level={SettingLevel.DEVICE}
@ -440,7 +446,6 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
</div> </div>
{this.renderThemeSection()} {this.renderThemeSection()}
{SettingsStore.isFeatureEnabled("feature_font_scaling") ? this.renderFontSection() : null} {SettingsStore.isFeatureEnabled("feature_font_scaling") ? this.renderFontSection() : null}
{SettingsStore.isFeatureEnabled("feature_irc_ui") ? this.renderLayoutSection() : null}
{this.renderAdvancedSection()} {this.renderAdvancedSection()}
</div> </div>
); );

View file

@ -490,7 +490,6 @@
"Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)", "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
"Use the improved room list (will refresh to apply changes)": "Use the improved room list (will refresh to apply changes)", "Use the improved room list (will refresh to apply changes)": "Use the improved room list (will refresh to apply changes)",
"Support adding custom themes": "Support adding custom themes", "Support adding custom themes": "Support adding custom themes",
"Enable IRC layout option in the appearance tab": "Enable IRC layout option in the appearance tab",
"Show info about bridges in room settings": "Show info about bridges in room settings", "Show info about bridges in room settings": "Show info about bridges in room settings",
"Font size": "Font size", "Font size": "Font size",
"Use custom size": "Use custom size", "Use custom size": "Use custom size",
@ -540,7 +539,7 @@
"How fast should messages be downloaded.": "How fast should messages be downloaded.", "How fast should messages be downloaded.": "How fast should messages be downloaded.",
"Manually verify all remote sessions": "Manually verify all remote sessions", "Manually verify all remote sessions": "Manually verify all remote sessions",
"IRC display name width": "IRC display name width", "IRC display name width": "IRC display name width",
"Use IRC layout": "Use IRC layout", "Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout",
"Collecting app version information": "Collecting app version information", "Collecting app version information": "Collecting app version information",
"Collecting logs": "Collecting logs", "Collecting logs": "Collecting logs",
"Uploading report": "Uploading report", "Uploading report": "Uploading report",
@ -2138,7 +2137,6 @@
"Switch theme": "Switch theme", "Switch theme": "Switch theme",
"Security & privacy": "Security & privacy", "Security & privacy": "Security & privacy",
"All settings": "All settings", "All settings": "All settings",
"Archived rooms": "Archived rooms",
"Feedback": "Feedback", "Feedback": "Feedback",
"User menu": "User menu", "User menu": "User menu",
"Could not load user profile": "Could not load user profile", "Could not load user profile": "Could not load user profile",

View file

@ -159,12 +159,6 @@ export const SETTINGS = {
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
}, },
"feature_irc_ui": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Enable IRC layout option in the appearance tab'),
default: false,
isFeature: true,
},
"mjolnirRooms": { "mjolnirRooms": {
supportedLevels: ['account'], supportedLevels: ['account'],
default: [], default: [],
@ -574,7 +568,7 @@ export const SETTINGS = {
}, },
"useIRCLayout": { "useIRCLayout": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Use IRC layout"), displayName: _td("Enable experimental, compact IRC style layout"),
default: false, default: false,
}, },
}; };

View file

@ -125,6 +125,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
} }
private async appendRoom(room: Room) { private async appendRoom(room: Room) {
let updated = false;
const rooms = (this.state.rooms || []).slice(); // cheap clone const rooms = (this.state.rooms || []).slice(); // cheap clone
// If the room is upgraded, use that room instead. We'll also splice out // If the room is upgraded, use that room instead. We'll also splice out
@ -136,30 +137,42 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
// Take out any room that isn't the most recent room // Take out any room that isn't the most recent room
for (let i = 0; i < history.length - 1; i++) { for (let i = 0; i < history.length - 1; i++) {
const idx = rooms.findIndex(r => r.roomId === history[i].roomId); const idx = rooms.findIndex(r => r.roomId === history[i].roomId);
if (idx !== -1) rooms.splice(idx, 1); if (idx !== -1) {
rooms.splice(idx, 1);
updated = true;
}
} }
} }
// Remove the existing room, if it is present // Remove the existing room, if it is present
const existingIdx = rooms.findIndex(r => r.roomId === room.roomId); const existingIdx = rooms.findIndex(r => r.roomId === room.roomId);
if (existingIdx !== -1) {
rooms.splice(existingIdx, 1);
}
// Splice the room to the start of the list // If we're focusing on the first room no-op
rooms.splice(0, 0, room); if (existingIdx !== 0) {
if (existingIdx !== -1) {
rooms.splice(existingIdx, 1);
}
// Splice the room to the start of the list
rooms.splice(0, 0, room);
updated = true;
}
if (rooms.length > MAX_ROOMS) { if (rooms.length > MAX_ROOMS) {
// This looks weird, but it's saying to start at the MAX_ROOMS point in the // This looks weird, but it's saying to start at the MAX_ROOMS point in the
// list and delete everything after it. // list and delete everything after it.
rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS); rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
updated = true;
} }
// Update the breadcrumbs
await this.updateState({rooms}); if (updated) {
const roomIds = rooms.map(r => r.roomId); // Update the breadcrumbs
if (roomIds.length > 0) { await this.updateState({rooms});
await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds); const roomIds = rooms.map(r => r.roomId);
if (roomIds.length > 0) {
await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
}
} }
} }

View file

@ -1,26 +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 { EventEmitter } from "events";
import { NotificationColor } from "./NotificationColor";
export const NOTIFICATION_STATE_UPDATE = "update";
export interface INotificationState extends EventEmitter {
symbol?: string;
count: number;
color: NotificationColor;
}

View file

@ -14,23 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { EventEmitter } from "events";
import { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState";
import { NotificationColor } from "./NotificationColor"; import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable";
import { TagID } from "../room-list/models"; import { TagID } from "../room-list/models";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { arrayDiff } from "../../utils/arrays"; import { arrayDiff } from "../../utils/arrays";
import { RoomNotificationState } from "./RoomNotificationState"; import { RoomNotificationState } from "./RoomNotificationState";
import { TagSpecificNotificationState } from "./TagSpecificNotificationState"; import { NOTIFICATION_STATE_UPDATE, NotificationState } from "./NotificationState";
export class ListNotificationState extends EventEmitter implements IDestroyable, INotificationState { export type FetchRoomFn = (room: Room) => RoomNotificationState;
private _count: number;
private _color: NotificationColor; export class ListNotificationState extends NotificationState {
private rooms: Room[] = []; private rooms: Room[] = [];
private states: { [roomId: string]: RoomNotificationState } = {}; private states: { [roomId: string]: RoomNotificationState } = {};
constructor(private byTileCount = false, private tagId: TagID) { constructor(private byTileCount = false, private tagId: TagID, private getRoomFn: FetchRoomFn) {
super(); super();
} }
@ -38,14 +35,6 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
return null; // This notification state doesn't support symbols return null; // This notification state doesn't support symbols
} }
public get count(): number {
return this._count;
}
public get color(): NotificationColor {
return this._color;
}
public setRooms(rooms: Room[]) { public setRooms(rooms: Room[]) {
// If we're only concerned about the tile count, don't bother setting up listeners. // If we're only concerned about the tile count, don't bother setting up listeners.
if (this.byTileCount) { if (this.byTileCount) {
@ -62,10 +51,9 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
if (!state) continue; // We likely just didn't have a badge (race condition) if (!state) continue; // We likely just didn't have a badge (race condition)
delete this.states[oldRoom.roomId]; delete this.states[oldRoom.roomId];
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
state.destroy();
} }
for (const newRoom of diff.added) { for (const newRoom of diff.added) {
const state = new TagSpecificNotificationState(newRoom, this.tagId); const state = this.getRoomFn(newRoom);
state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
if (this.states[newRoom.roomId]) { if (this.states[newRoom.roomId]) {
// "Should never happen" disclaimer. // "Should never happen" disclaimer.
@ -85,8 +73,9 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
} }
public destroy() { public destroy() {
super.destroy();
for (const state of Object.values(this.states)) { for (const state of Object.values(this.states)) {
state.destroy(); state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
} }
this.states = {}; this.states = {};
} }
@ -96,7 +85,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
}; };
private calculateTotalState() { private calculateTotalState() {
const before = {count: this.count, symbol: this.symbol, color: this.color}; const snapshot = this.snapshot();
if (this.byTileCount) { if (this.byTileCount) {
this._color = NotificationColor.Red; this._color = NotificationColor.Red;
@ -111,10 +100,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
} }
// finally, publish an update if needed // finally, publish an update if needed
const after = {count: this.count, symbol: this.symbol, color: this.color}; this.emitIfUpdated(snapshot);
if (JSON.stringify(before) !== JSON.stringify(after)) {
this.emit(NOTIFICATION_STATE_UPDATE);
}
} }
} }

View file

@ -0,0 +1,87 @@
/*
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 { EventEmitter } from "events";
import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable";
export const NOTIFICATION_STATE_UPDATE = "update";
export abstract class NotificationState extends EventEmitter implements IDestroyable {
protected _symbol: string;
protected _count: number;
protected _color: NotificationColor;
public get symbol(): string {
return this._symbol;
}
public get count(): number {
return this._count;
}
public get color(): NotificationColor {
return this._color;
}
public get isIdle(): boolean {
return this.color <= NotificationColor.None;
}
public get isUnread(): boolean {
return this.color >= NotificationColor.Bold;
}
public get hasUnreadCount(): boolean {
return this.color >= NotificationColor.Grey && (!!this.count || !!this.symbol);
}
public get hasMentions(): boolean {
return this.color >= NotificationColor.Red;
}
protected emitIfUpdated(snapshot: NotificationStateSnapshot) {
if (snapshot.isDifferentFrom(this)) {
this.emit(NOTIFICATION_STATE_UPDATE);
}
}
protected snapshot(): NotificationStateSnapshot {
return new NotificationStateSnapshot(this);
}
public destroy(): void {
this.removeAllListeners(NOTIFICATION_STATE_UPDATE);
}
}
export class NotificationStateSnapshot {
private readonly symbol: string;
private readonly count: number;
private readonly color: NotificationColor;
constructor(state: NotificationState) {
this.symbol = state.symbol;
this.count = state.count;
this.color = state.color;
}
public isDifferentFrom(other: NotificationState): boolean {
const before = {count: this.count, symbol: this.symbol, color: this.color};
const after = {count: other.count, symbol: other.symbol, color: other.color};
return JSON.stringify(before) !== JSON.stringify(after);
}
}

View file

@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { EventEmitter } from "events";
import { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState";
import { NotificationColor } from "./NotificationColor"; import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable"; import { IDestroyable } from "../../utils/IDestroyable";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
@ -25,12 +23,9 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import * as RoomNotifs from '../../RoomNotifs'; import * as RoomNotifs from '../../RoomNotifs';
import * as Unread from '../../Unread'; import * as Unread from '../../Unread';
import { NotificationState } from "./NotificationState";
export class RoomNotificationState extends EventEmitter implements IDestroyable, INotificationState { export class RoomNotificationState extends NotificationState implements IDestroyable {
private _symbol: string;
private _count: number;
private _color: NotificationColor;
constructor(public readonly room: Room) { constructor(public readonly room: Room) {
super(); super();
this.room.on("Room.receipt", this.handleReadReceipt); this.room.on("Room.receipt", this.handleReadReceipt);
@ -41,23 +36,12 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable,
this.updateNotificationState(); this.updateNotificationState();
} }
public get symbol(): string {
return this._symbol;
}
public get count(): number {
return this._count;
}
public get color(): NotificationColor {
return this._color;
}
private get roomIsInvite(): boolean { private get roomIsInvite(): boolean {
return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite; return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
} }
public destroy(): void { public destroy(): void {
super.destroy();
this.room.removeListener("Room.receipt", this.handleReadReceipt); this.room.removeListener("Room.receipt", this.handleReadReceipt);
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate); this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate); this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
@ -87,7 +71,7 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable,
}; };
private updateNotificationState() { private updateNotificationState() {
const before = {count: this.count, symbol: this.symbol, color: this.color}; const snapshot = this.snapshot();
if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) { if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) {
// When muted we suppress all notification states, even if we have context on them. // When muted we suppress all notification states, even if we have context on them.
@ -136,9 +120,6 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable,
} }
// finally, publish an update if needed // finally, publish an update if needed
const after = {count: this.count, symbol: this.symbol, color: this.color}; this.emitIfUpdated(snapshot);
if (JSON.stringify(before) !== JSON.stringify(after)) {
this.emit(NOTIFICATION_STATE_UPDATE);
}
} }
} }

View file

@ -0,0 +1,101 @@
/*
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 { ActionPayload } from "../../dispatcher/payloads";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { DefaultTagID, TagID } from "../room-list/models";
import { FetchRoomFn, ListNotificationState } from "./ListNotificationState";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomNotificationState } from "./RoomNotificationState";
import { TagSpecificNotificationState } from "./TagSpecificNotificationState";
const INSPECIFIC_TAG = "INSPECIFIC_TAG";
type INSPECIFIC_TAG = "INSPECIFIC_TAG";
interface IState {}
export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new RoomNotificationStateStore();
private roomMap = new Map<Room, Map<TagID | INSPECIFIC_TAG, RoomNotificationState>>();
private constructor() {
super(defaultDispatcher, {});
}
/**
* Creates a new list notification state. The consumer is expected to set the rooms
* on the notification state, and destroy the state when it no longer needs it.
* @param tagId The tag to create the notification state for.
* @returns The notification state for the tag.
*/
public getListState(tagId: TagID): ListNotificationState {
// Note: we don't cache these notification states as the consumer is expected to call
// .setRooms() on the returned object, which could confuse other consumers.
// TODO: Update if/when invites move out of the room list.
const useTileCount = tagId === DefaultTagID.Invite;
const getRoomFn: FetchRoomFn = (room: Room) => {
return this.getRoomState(room, tagId);
};
return new ListNotificationState(useTileCount, tagId, getRoomFn);
}
/**
* Gets a copy of the notification state for a room. The consumer should not
* attempt to destroy the returned state as it may be shared with other
* consumers.
* @param room The room to get the notification state for.
* @param inTagId Optional tag ID to scope the notification state to.
* @returns The room's notification state.
*/
public getRoomState(room: Room, inTagId?: TagID): RoomNotificationState {
if (!this.roomMap.has(room)) {
this.roomMap.set(room, new Map<TagID | INSPECIFIC_TAG, RoomNotificationState>());
}
const targetTag = inTagId ? inTagId : INSPECIFIC_TAG;
const forRoomMap = this.roomMap.get(room);
if (!forRoomMap.has(targetTag)) {
if (inTagId) {
forRoomMap.set(inTagId, new TagSpecificNotificationState(room, inTagId));
} else {
forRoomMap.set(INSPECIFIC_TAG, new RoomNotificationState(room));
}
}
return forRoomMap.get(targetTag);
}
public static get instance(): RoomNotificationStateStore {
return RoomNotificationStateStore.internalInstance;
}
protected async onNotReady(): Promise<any> {
for (const roomMap of this.roomMap.values()) {
for (const roomState of roomMap.values()) {
roomState.destroy();
}
}
}
// We don't need this, but our contract says we do.
protected async onAction(payload: ActionPayload) {
return Promise.resolve();
}
}

View file

@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { EventEmitter } from "events";
import { INotificationState } from "./INotificationState";
import { NotificationColor } from "./NotificationColor"; import { NotificationColor } from "./NotificationColor";
import { NotificationState } from "./NotificationState";
export class StaticNotificationState extends EventEmitter implements INotificationState { export class StaticNotificationState extends NotificationState {
constructor(public symbol: string, public count: number, public color: NotificationColor) { constructor(symbol: string, count: number, color: NotificationColor) {
super(); super();
this._symbol = symbol;
this._count = count;
this._color = color;
} }
public static forCount(count: number, color: NotificationColor): StaticNotificationState { public static forCount(count: number, color: NotificationColor): StaticNotificationState {

View file

@ -109,10 +109,6 @@ export class ListLayout {
return this.tilesToPixels(Math.min(maxTiles, n)) + padding; 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 { public tilesWithPadding(n: number, paddingPx: number): number {
return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx)); return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx));
} }

View file

@ -0,0 +1,73 @@
/*
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 { TagID } from "./models";
import { ListLayout } from "./ListLayout";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
interface IState {}
export default class RoomListLayoutStore extends AsyncStoreWithClient<IState> {
private static internalInstance: RoomListLayoutStore;
private readonly layoutMap = new Map<TagID, ListLayout>();
constructor() {
super(defaultDispatcher);
}
public static get instance(): RoomListLayoutStore {
if (!RoomListLayoutStore.internalInstance) {
RoomListLayoutStore.internalInstance = new RoomListLayoutStore();
}
return RoomListLayoutStore.internalInstance;
}
public ensureLayoutExists(tagId: TagID) {
if (!this.layoutMap.has(tagId)) {
this.layoutMap.set(tagId, new ListLayout(tagId));
}
}
public getLayoutFor(tagId: TagID): ListLayout {
if (!this.layoutMap.has(tagId)) {
this.layoutMap.set(tagId, new ListLayout(tagId));
}
return this.layoutMap.get(tagId);
}
// Note: this primarily exists for debugging, and isn't really intended to be used by anything.
public async resetLayouts() {
console.warn("Resetting layouts for room list");
for (const layout of this.layoutMap.values()) {
layout.reset();
}
}
protected async onNotReady(): Promise<any> {
// On logout, clear the map.
this.layoutMap.clear();
}
// We don't need this function, but our contract says we do
protected async onAction(payload: ActionPayload): Promise<any> {
return Promise.resolve();
}
}
window.mx_RoomListLayoutStore = RoomListLayoutStore.instance;

View file

@ -32,6 +32,7 @@ import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
import { EffectiveMembership, getEffectiveMembership } from "./membership"; import { EffectiveMembership, getEffectiveMembership } from "./membership";
import { ListLayout } from "./ListLayout"; import { ListLayout } from "./ListLayout";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import RoomListLayoutStore from "./RoomListLayoutStore";
interface IState { interface IState {
tagsEnabled?: boolean; tagsEnabled?: boolean;
@ -50,6 +51,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
private algorithm = new Algorithm(); private algorithm = new Algorithm();
private filterConditions: IFilterCondition[] = []; private filterConditions: IFilterCondition[] = [];
private tagWatcher = new TagWatcher(this); private tagWatcher = new TagWatcher(this);
private layoutMap: Map<TagID, ListLayout> = new Map<TagID, ListLayout>();
private readonly watchedSettings = [ private readonly watchedSettings = [
'feature_custom_tags', 'feature_custom_tags',
@ -416,6 +418,8 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
for (const tagId of OrderedDefaultTagIDs) { for (const tagId of OrderedDefaultTagIDs) {
sorts[tagId] = this.calculateTagSorting(tagId); sorts[tagId] = this.calculateTagSorting(tagId);
orders[tagId] = this.calculateListOrder(tagId); orders[tagId] = this.calculateListOrder(tagId);
RoomListLayoutStore.instance.ensureLayoutExists(tagId);
} }
if (this.state.tagsEnabled) { if (this.state.tagsEnabled) {
@ -434,15 +438,6 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
this.emit(LISTS_UPDATE_EVENT, this); this.emit(LISTS_UPDATE_EVENT, this);
} }
// Note: this primarily exists for debugging, and isn't really intended to be used by anything.
public async resetLayouts() {
console.warn("Resetting layouts for room list");
for (const tagId of Object.keys(this.orderedLists)) {
new ListLayout(tagId).reset();
}
await this.regenerateAllLists();
}
public addFilter(filter: IFilterCondition): void { public addFilter(filter: IFilterCondition): void {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log("Adding filter condition:", filter); console.log("Adding filter condition:", filter);

View file

@ -655,18 +655,35 @@ export class Algorithm extends EventEmitter {
cause = RoomUpdateCause.PossibleTagChange; cause = RoomUpdateCause.PossibleTagChange;
} }
// If we have tags for a room and don't have the room referenced, the room reference // Check to see if the room is known first
// probably changed. We need to swap out the problematic reference. let knownRoomRef = this.rooms.includes(room);
if (hasTags && !this.rooms.includes(room) && !isSticky) { if (hasTags && !knownRoomRef) {
console.warn(`${room.roomId} is missing from room array but is known - trying to find duplicate`); console.warn(`${room.roomId} might be a reference change - attempting to update reference`);
this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r); this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r);
knownRoomRef = this.rooms.includes(room);
// Sanity check if (!knownRoomRef) {
if (!this.rooms.includes(room)) { console.warn(`${room.roomId} is still not referenced. It may be sticky.`);
throw new Error(`Failed to replace ${room.roomId} with an updated reference`);
} }
} }
if (hasTags && isForLastSticky && !knownRoomRef) {
// we have a fairly good chance at losing a room right now. Under some circumstances,
// we can end up with a room which transitions references and tag changes, then gets
// lost when the sticky room changes. To counter this, we try and add the room to the
// list manually as the condition below to update the reference will fail.
//
// Other conditions *should* result in the room being sorted into the right place.
console.warn(`${room.roomId} was about to be lost - inserting at end of room list`);
this.rooms.push(room);
knownRoomRef = true;
}
// If we have tags for a room and don't have the room referenced, something went horribly
// wrong - the reference should have been updated above.
if (hasTags && !knownRoomRef && !isSticky) {
throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
}
// Like above, update the reference to the sticky room if we need to // Like above, update the reference to the sticky room if we need to
if (hasTags && isSticky) { if (hasTags && isSticky) {
// Go directly in and set the sticky room's new reference, being careful not // Go directly in and set the sticky room's new reference, being careful not