Merge branch 'develop' into joriks/appearance-advanced

This commit is contained in:
Jorik Schellekens 2020-06-18 14:41:28 +01:00 committed by GitHub
commit 47a825be7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1086 additions and 163 deletions

View file

@ -86,6 +86,43 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
}
};
// TODO: Apply this on resize, init, etc for reliability
private onScroll = (ev: React.MouseEvent<HTMLDivElement>) => {
const list = ev.target as HTMLDivElement;
const rlRect = list.getBoundingClientRect();
const bottom = rlRect.bottom;
const top = rlRect.top;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2");
const headerHeight = 32; // Note: must match the CSS!
const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = rlRect.width - headerRightMargin;
let gotBottom = false;
for (const sublist of sublists) {
const slRect = sublist.getBoundingClientRect();
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable");
if (slRect.top + headerHeight > bottom && !gotBottom) {
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
header.style.width = `${headerStickyWidth}px`;
gotBottom = true;
} else if (slRect.top < top) {
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
header.style.width = `${headerStickyWidth}px`;
header.style.top = `${rlRect.top}px`;
} else {
header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
header.style.width = `unset`;
}
}
};
private renderHeader(): React.ReactNode {
// TODO: Update when profile info changes
// TODO: Presence
@ -191,7 +228,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
<aside className="mx_LeftPanel2_roomListContainer">
{this.renderHeader()}
{this.renderSearchExplore()}
<div className="mx_LeftPanel2_actualRoomListContainer">
<div className="mx_LeftPanel2_actualRoomListContainer" onScroll={this.onScroll}>
{roomList}
</div>
</aside>

View file

@ -1977,8 +1977,9 @@ export default createReactClass({
searchResultsPanel = (<div className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner" />);
} else {
searchResultsPanel = (
<ScrollPanel ref={this._searchResultsPanel}
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
<ScrollPanel
ref={this._searchResultsPanel}
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel mx_GroupLayout"
onFillRequest={this.onSearchResultsFillRequest}
resizeNotifier={this.props.resizeNotifier}
>

View file

@ -32,6 +32,8 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import {getCustomTheme} from "../../theme";
import {getHostingLink} from "../../utils/HostingLink";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig";
import {getHomePageUrl} from "../../utils/pages";
interface IProps {
}
@ -67,6 +69,10 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
}
}
private get hasHomePage(): boolean {
return !!getHomePageUrl(SdkConfig.get());
}
public componentDidMount() {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
@ -147,6 +153,13 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
this.setState({menuDisplayed: false}); // also close the menu
};
private onHomeClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({action: 'view_home_page'});
};
public render() {
let contextMenu;
if (this.state.menuDisplayed) {
@ -172,6 +185,18 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
);
}
let homeButton = null;
if (this.hasHomePage) {
homeButton = (
<li>
<AccessibleButton onClick={this.onHomeClick}>
<img src={require("../../../res/img/feather-customised/home.svg")} width={16} />
<span>{_t("Home")}</span>
</AccessibleButton>
</li>
);
}
const elementRect = this.buttonRef.current.getBoundingClientRect();
contextMenu = (
<ContextMenu
@ -205,6 +230,7 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
{hostingLink}
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
<ul>
{homeButton}
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
<img src={require("../../../res/img/feather-customised/notifications.svg")} width={16} />

View file

@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes} from 'react';
import classNames from 'classnames';
import * as sdk from '../../../index';
import { debounce } from 'lodash';
import {IFieldState, IValidationResult} from "../elements/Validation";
import {IFieldState, IValidationResult} from "./Validation";
// Invoke validation from user input (when typing, etc.) at most once every N ms.
const VALIDATION_THROTTLE_MS = 200;
@ -29,34 +29,28 @@ function getId() {
return `${BASE_ID}_${count++}`;
}
interface IProps extends React.InputHTMLAttributes<HTMLSelectElement | HTMLInputElement> {
interface IProps {
// The field's ID, which binds the input and label together. Immutable.
id?: string,
// The element to create. Defaults to "input".
// To define options for a select, use <Field><option ... /></Field>
element?: "input" | "select" | "textarea",
id?: string;
// The field's type (when used as an <input>). Defaults to "text".
type?: string,
type?: string;
// id of a <datalist> element for suggestions
list?: string,
list?: string;
// The field's label string.
label?: string,
label?: string;
// The field's placeholder string. Defaults to the label.
placeholder?: string,
// The field's value.
// This is a controlled component, so the value is required.
value: string,
placeholder?: string;
// Optional component to include inside the field before the input.
prefixComponent?: React.ReactNode,
prefixComponent?: React.ReactNode;
// Optional component to include inside the field after the input.
postfixComponent?: React.ReactNode,
postfixComponent?: React.ReactNode;
// The callback called whenever the contents of the field
// changes. Returns an object with `valid` boolean field
// and a `feedback` react component field to provide feedback
// to the user.
onValidate?: (input: IFieldState) => Promise<IValidationResult>,
onValidate?: (input: IFieldState) => Promise<IValidationResult>;
// If specified, overrides the value returned by onValidate.
flagInvalid?: boolean,
flagInvalid?: boolean;
// If specified, contents will appear as a tooltip on the element and
// validation feedback tooltips will be suppressed.
tooltipContent?: React.ReactNode,
@ -64,12 +58,34 @@ interface IProps extends React.InputHTMLAttributes<HTMLSelectElement | HTMLInput
forceTooltipVisible?: boolean,
// If specified alongside tooltipContent, the class name to apply to the
// tooltip itself.
tooltipClassName?: string,
tooltipClassName?: string;
// If specified, an additional class name to apply to the field container
className?: string,
className?: string;
// All other props pass through to the <input>.
}
interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
// The element to create. Defaults to "input".
element?: "input";
// The input's value. This is a controlled component, so the value is required.
value: string;
}
interface ISelectProps extends IProps, SelectHTMLAttributes<HTMLSelectElement> {
// To define options for a select, use <Field><option ... /></Field>
element: "select";
// The select's value. This is a controlled component, so the value is required.
value: string;
}
interface ITextareaProps extends IProps, TextareaHTMLAttributes<HTMLTextAreaElement> {
element: "textarea";
// The textarea's value. This is a controlled component, so the value is required.
value: string;
}
type PropShapes = IInputProps | ISelectProps | ITextareaProps;
interface IState {
valid: boolean,
feedback: React.ReactNode,
@ -77,7 +93,7 @@ interface IState {
focused: boolean,
}
export default class Field extends React.PureComponent<IProps, IState> {
export default class Field extends React.PureComponent<PropShapes, IState> {
private id: string;
private input: HTMLInputElement;

View file

@ -748,19 +748,26 @@ const RoomAdminToolsContainer = ({room, children, member, startUpdating, stopUpd
powerLevels.state_default
);
// if these do not exist in the event then they should default to 50 as per the spec
const {
ban: banPowerLevel = 50,
kick: kickPowerLevel = 50,
redact: redactPowerLevel = 50,
} = powerLevels;
const me = room.getMember(cli.getUserId());
const isMe = me.userId === member.userId;
const canAffectUser = member.powerLevel < me.powerLevel || isMe;
if (canAffectUser && me.powerLevel >= powerLevels.kick) {
if (canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
}
if (me.powerLevel >= powerLevels.redact) {
if (me.powerLevel >= redactPowerLevel) {
redactButton = (
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
);
}
if (canAffectUser && me.powerLevel >= powerLevels.ban) {
if (canAffectUser && me.powerLevel >= banPowerLevel) {
banButton = <BanToggleButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
}
if (canAffectUser && me.powerLevel >= editPowerLevel) {

View file

@ -109,6 +109,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
private onShowLessClick = () => {
this.props.layout.visibleTiles = this.props.layout.minVisibleTiles;
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
private onOpenMenuClick = (ev: InputEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -134,7 +139,28 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
private onHeaderClick = (ev: React.MouseEvent<HTMLDivElement>) => {
let target = ev.target as HTMLDivElement;
if (!target.classList.contains('mx_RoomSublist2_headerText')) {
// If we don't have the headerText class, the user clicked the span in the headerText.
target = target.parentElement as HTMLDivElement;
}
const possibleSticky = target.parentElement;
const sublist = possibleSticky.parentElement.parentElement;
if (possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky')) {
// is sticky - jump to list
sublist.scrollIntoView({behavior: 'smooth'});
} else {
// on screen - toggle collapse
this.props.layout.isCollapsed = !this.props.layout.isCollapsed;
this.forceUpdate(); // because the layout doesn't trigger an update
}
};
private renderTiles(): React.ReactElement[] {
if (this.props.layout && this.props.layout.isCollapsed) return []; // don't waste time on rendering
const tiles: React.ReactElement[] = [];
if (this.props.rooms) {
@ -145,6 +171,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
key={`room-${room.roomId}`}
showMessagePreview={this.props.layout.showPreviews}
isMinimized={this.props.isMinimized}
tag={this.props.layout.tagId}
/>
);
}
@ -249,6 +276,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
);
}
const collapseClasses = classNames({
'mx_RoomSublist2_collapseBtn': true,
'mx_RoomSublist2_collapseBtn_collapsed': this.props.layout && this.props.layout.isCollapsed,
});
const classes = classNames({
'mx_RoomSublist2_headerContainer': true,
'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton,
@ -257,19 +289,23 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
// TODO: a11y (see old component)
return (
<div className={classes}>
<AccessibleButton
inputRef={ref}
tabIndex={tabIndex}
className={"mx_RoomSublist2_headerText"}
role="treeitem"
aria-level={1}
>
<span>{this.props.label}</span>
</AccessibleButton>
{this.renderMenu()}
{addRoomButton}
<div className="mx_RoomSublist2_badgeContainer">
{badge}
<div className='mx_RoomSublist2_stickable'>
<AccessibleButton
inputRef={ref}
tabIndex={tabIndex}
className={"mx_RoomSublist2_headerText"}
role="treeitem"
aria-level={1}
onClick={this.onHeaderClick}
>
<span className={collapseClasses} />
<span>{this.props.label}</span>
</AccessibleButton>
{this.renderMenu()}
{addRoomButton}
<div className="mx_RoomSublist2_badgeContainer">
{badge}
</div>
</div>
</div>
);
@ -303,25 +339,42 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const visibleTiles = tiles.slice(0, nVisible);
// If we're hiding rooms, show a 'show more' button to the user. This button
// floats above the resize handle, if we have one present
let showMoreButton = null;
// floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'.
let showNButton = null;
if (tiles.length > nVisible) {
// we have a cutoff condition - add the button to show all
const numMissing = tiles.length - visibleTiles.length;
let showMoreText = (
<span className='mx_RoomSublist2_showMoreButtonText'>
<span className='mx_RoomSublist2_showNButtonText'>
{_t("Show %(count)s more", {count: numMissing})}
</span>
);
if (this.props.isMinimized) showMoreText = null;
showMoreButton = (
<div onClick={this.onShowAllClick} className='mx_RoomSublist2_showMoreButton'>
<span className='mx_RoomSublist2_showMoreButtonChevron'>
showNButton = (
<div onClick={this.onShowAllClick} className='mx_RoomSublist2_showNButton'>
<span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */}
</span>
{showMoreText}
</div>
);
} else if (tiles.length <= nVisible) {
// we have all tiles visible - add a button to show less
let showLessText = (
<span className='mx_RoomSublist2_showNButtonText'>
{_t("Show less")}
</span>
);
if (this.props.isMinimized) showLessText = null;
showNButton = (
<div onClick={this.onShowLessClick} className='mx_RoomSublist2_showNButton'>
<span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */}
</span>
{showLessText}
</div>
);
}
// Figure out if we need a handle
@ -345,7 +398,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
// The padding is variable though, so figure out what we need padding for.
let padding = 0;
if (showMoreButton) padding += showMoreHeight;
if (showNButton) padding += showMoreHeight;
if (handles.length > 0) padding += resizeHandleHeight;
const minTilesPx = layout.calculateTilesToPixelsMin(tiles.length, layout.minVisibleTiles, padding);
@ -365,7 +418,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
className="mx_RoomSublist2_resizeBox"
>
{visibleTiles}
{showMoreButton}
{showNButton}
</ResizableBox>
)
}

View file

@ -21,7 +21,7 @@ import React, { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, {ButtonEvent} from "../../views/elements/AccessibleButton";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import RoomAvatar from "../../views/avatars/RoomAvatar";
import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard";
@ -31,6 +31,7 @@ import { _t } from "../../../languageHandler";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreviewStore } from "../../../stores/MessagePreviewStore";
import RoomTileIcon from "./RoomTileIcon";
/*******************************************************************
* CAUTION *
@ -44,6 +45,7 @@ interface IProps {
room: Room;
showMessagePreview: boolean;
isMinimized: boolean;
tag: TagID;
// TODO: Allow falsifying counts (for invites and stuff)
// TODO: Transparency? Was this ever used?
@ -303,7 +305,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
role="treeitem"
>
<div className="mx_RoomTile2_avatarContainer">
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize}/>
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize} />
<RoomTileIcon room={this.props.room} tag={this.props.tag} />
</div>
{nameContainer}
<div className="mx_RoomTile2_badgeContainer">

View file

@ -0,0 +1,150 @@
/*
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 React from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { User } from "matrix-js-sdk/src/models/user";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import DMRoomMap from "../../../utils/DMRoomMap";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { isPresenceEnabled } from "../../../utils/presence";
enum Icon {
// Note: the names here are used in CSS class names
None = "NONE", // ... except this one
Globe = "GLOBE",
PresenceOnline = "ONLINE",
PresenceAway = "AWAY",
PresenceOffline = "OFFLINE",
}
interface IProps {
room: Room;
tag: TagID;
}
interface IState {
icon: Icon;
}
export default class RoomTileIcon extends React.Component<IProps, IState> {
private _dmUser: User;
private isUnmounted = false;
private isWatchingTimeline = false;
constructor(props: IProps) {
super(props);
this.state = {
icon: this.calculateIcon(),
};
}
private get isPublicRoom(): boolean {
const joinRules = this.props.room.currentState.getStateEvents("m.room.join_rules", "");
const joinRule = joinRules && joinRules.getContent().join_rule;
return joinRule === 'public';
}
private get dmUser(): User {
return this._dmUser;
}
private set dmUser(val: User) {
const oldUser = this._dmUser;
this._dmUser = val;
if (oldUser && oldUser !== this._dmUser) {
oldUser.off('User.currentlyActive', this.onPresenceUpdate);
oldUser.off('User.presence', this.onPresenceUpdate);
}
if (this._dmUser && oldUser !== this._dmUser) {
this._dmUser.on('User.currentlyActive', this.onPresenceUpdate);
this._dmUser.on('User.presence', this.onPresenceUpdate);
}
}
public componentWillUnmount() {
this.isUnmounted = true;
if (this.isWatchingTimeline) this.props.room.off('Room.timeline', this.onRoomTimeline);
this.dmUser = null; // clear listeners, if any
}
private onRoomTimeline = (ev: MatrixEvent, room: Room) => {
if (this.isUnmounted) return;
// apparently these can happen?
if (!room) return;
if (this.props.room.roomId !== room.roomId) return;
if (ev.getType() === 'm.room.join_rules' || ev.getType() === 'm.room.member') {
this.setState({icon: this.calculateIcon()});
}
};
private onPresenceUpdate = () => {
if (this.isUnmounted) return;
let newIcon = this.getPresenceIcon();
if (newIcon !== this.state.icon) this.setState({icon: newIcon});
};
private getPresenceIcon(): Icon {
if (!this.dmUser) return Icon.None;
let icon = Icon.None;
const isOnline = this.dmUser.currentlyActive || this.dmUser.presence === 'online';
if (isOnline) {
icon = Icon.PresenceOnline;
} else if (this.dmUser.presence === 'offline') {
icon = Icon.PresenceOffline;
} else if (this.dmUser.presence === 'unavailable') {
icon = Icon.PresenceAway;
}
return icon;
}
private calculateIcon(): Icon {
let icon = Icon.None;
if (this.props.tag === DefaultTagID.DM && this.props.room.getJoinedMemberCount() === 2) {
// Track presence, if available
if (isPresenceEnabled()) {
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
if (otherUserId) {
this.dmUser = MatrixClientPeg.get().getUser(otherUserId);
icon = this.getPresenceIcon();
}
}
} else {
// Track publicity
icon = this.isPublicRoom ? Icon.Globe : Icon.None;
if (!this.isWatchingTimeline) {
this.props.room.on('Room.timeline', this.onRoomTimeline);
this.isWatchingTimeline = true;
}
}
return icon;
}
public render(): React.ReactElement {
if (this.state.icon === Icon.None) return null;
return <span className={`mx_RoomTileIcon mx_RoomTileIcon_${this.state.icon.toLowerCase()}`} />;
}
}

View file

@ -66,7 +66,6 @@ export default class LabsUserSettingsTab extends React.Component {
<SettingsFlag name={"showHiddenEventsInTimeline"} level={SettingLevel.DEVICE} />
<SettingsFlag name={"lowBandwidth"} level={SettingLevel.DEVICE} />
<SettingsFlag name={"sendReadReceipts"} level={SettingLevel.ACCOUNT} />
<SettingsFlag name={"keepSecretStoragePassphraseForSession"} level={SettingLevel.DEVICE} />
</div>
</div>
);