Merge branch 'develop' into gsouquet/compact-composer-18533

This commit is contained in:
Germain Souquet 2021-09-02 08:31:29 +01:00
commit e416952c90
61 changed files with 1009 additions and 367 deletions

View file

@ -250,7 +250,15 @@ export default class CallHandler extends EventEmitter {
* @returns {boolean}
*/
private areAnyCallsUnsilenced(): boolean {
return this.calls.size > this.silencedCalls.size;
for (const call of this.calls.values()) {
if (
call.state === CallState.Ringing &&
!this.isCallSilenced(call.callId)
) {
return true;
}
}
return false;
}
private async checkProtocols(maxTries) {
@ -878,6 +886,8 @@ export default class CallHandler extends EventEmitter {
break;
case 'hangup':
case 'reject':
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
if (!this.calls.get(payload.room_id)) {
return; // no call to hangup
}
@ -890,11 +900,15 @@ export default class CallHandler extends EventEmitter {
// the hangup event away)
break;
case 'hangup_all':
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
for (const call of this.calls.values()) {
call.hangup(CallErrorCode.UserHangup, false);
}
break;
case 'answer': {
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
if (!this.calls.has(payload.room_id)) {
return; // no call to answer
}
@ -929,6 +943,12 @@ export default class CallHandler extends EventEmitter {
}
};
private stopRingingIfPossible(callId: string): void {
this.silencedCalls.delete(callId);
if (this.areAnyCallsUnsilenced()) return;
this.pause(AudioID.Ring);
}
private async dialNumber(number: string) {
const results = await this.pstnLookup(number);
if (!results || results.length === 0 || !results[0].userid) {

View file

@ -213,6 +213,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
opts.pendingEventOrdering = PendingEventOrdering.Detached;
opts.lazyLoadMembers = true;
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
opts.experimentalThreadSupport = SettingsStore.getValue("feature_thread");
// Connect the matrix client to the dispatcher and setting handlers
MatrixActionCreators.start(this.matrixClient);

View file

@ -50,7 +50,6 @@ import CallHandler from "./CallHandler";
import { guessAndSetDMRoom } from "./Rooms";
import { upgradeRoom } from './utils/RoomUpgrade';
import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog';
import ErrorDialog from './components/views/dialogs/ErrorDialog';
import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog';
import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog";
import InfoDialog from "./components/views/dialogs/InfoDialog";
@ -245,21 +244,6 @@ export const Commands = [
},
category: CommandCategories.messages,
}),
new Command({
command: 'ddg',
args: '<query>',
description: _td('Searches DuckDuckGo for results'),
runFn: function() {
// TODO Don't explain this away, actually show a search UI here.
Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
title: _t('/ddg is not a command'),
description: _t('To use it, just wait for autocomplete results to load and tab through them.'),
});
return success();
},
category: CommandCategories.actions,
hideCompletionAfterSpace: true,
}),
new Command({
command: 'upgraderoom',
args: '<new_version>',

View file

@ -45,7 +45,13 @@ class MxVoiceWorklet extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const currentSecond = roundTimeToTargetFreq(currentTime);
if (currentSecond === this.nextAmplitudeSecond) {
// We special case the first ping because there's a fairly good chance that we'll miss the zeroth
// update. Firefox for instance takes 0.06 seconds (roughly) to call this function for the first
// time. Edge and Chrome occasionally lag behind too, but for the most part are on time.
//
// When this doesn't work properly we end up producing a waveform of nulls and no live preview
// of the recorded message.
if (currentSecond === this.nextAmplitudeSecond || this.nextAmplitudeSecond === 0) {
// We're expecting exactly one mono input source, so just grab the very first frame of
// samples for the analysis.
const monoChan = inputs[0][0];

View file

@ -20,7 +20,6 @@ import { Room } from 'matrix-js-sdk/src/models/room';
import CommandProvider from './CommandProvider';
import CommunityProvider from './CommunityProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider';
@ -55,7 +54,6 @@ const PROVIDERS = [
EmojiProvider,
NotifProvider,
CommandProvider,
DuckDuckGoProvider,
];
if (SpaceStore.spacesEnabled) {

View file

@ -53,7 +53,7 @@ export default class CommandProvider extends AutocompleteProvider {
// The input looks like a command with arguments, perform exact match
const name = command[1].substr(1); // strip leading `/`
if (CommandMap.has(name) && CommandMap.get(name).isEnabled()) {
// some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments
// some commands, namely `me` don't suit having the usage shown whilst typing their arguments
if (CommandMap.get(name).hideCompletionAfterSpace) return [];
matches = [CommandMap.get(name)];
}
@ -95,7 +95,7 @@ export default class CommandProvider extends AutocompleteProvider {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_block"
className="mx_Autocomplete_Completion_container_pill"
role="presentation"
aria-label={_t("Command Autocomplete")}
>

View file

@ -1,115 +0,0 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import { TextualCompletion } from './Components';
import { ICompletion, ISelectionRange } from "./Autocompleter";
const DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector';
export default class DuckDuckGoProvider extends AutocompleteProvider {
constructor() {
super(DDG_REGEX);
}
static getQueryUri(query: string) {
return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}`
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
}
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
const { command, range } = this.getCurrentCommand(query, selection);
if (!query || !command) {
return [];
}
const response = await fetch(DuckDuckGoProvider.getQueryUri(command[1]), {
method: 'GET',
});
const json = await response.json();
const maxLength = limit > -1 ? limit : json.Results.length;
const results = json.Results.slice(0, maxLength).map((result) => {
return {
completion: result.Text,
component: (
<TextualCompletion
title={result.Text}
description={result.Result} />
),
range,
};
});
if (json.Answer) {
results.unshift({
completion: json.Answer,
component: (
<TextualCompletion
title={json.Answer}
description={json.AnswerType} />
),
range,
});
}
if (json.RelatedTopics && json.RelatedTopics.length > 0) {
results.unshift({
completion: json.RelatedTopics[0].Text,
component: (
<TextualCompletion
title={json.RelatedTopics[0].Text} />
),
range,
});
}
if (json.AbstractText) {
results.unshift({
completion: json.AbstractText,
component: (
<TextualCompletion
title={json.AbstractText} />
),
range,
});
}
return results;
}
getName() {
return '🔍 ' + _t('Results from DuckDuckGo');
}
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_block"
role="presentation"
aria-label={_t("DuckDuckGo Results")}
>
{ completions }
</div>
);
}
}

View file

@ -115,7 +115,7 @@ const LeftPanelWidget: React.FC = () => {
aria-expanded={expanded}
aria-level={1}
onClick={() => {
setExpanded(e => !e);
setExpanded(!expanded);
}}
>
<span className={classNames({

View file

@ -1016,6 +1016,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.setStateForNewView({
view: Views.LOGGED_IN,
justRegistered,
currentRoomId: null,
});
this.setPage(PageTypes.HomePage);
this.notifyNewScreen('home');

View file

@ -173,6 +173,8 @@ interface IProps {
onUnfillRequest?(backwards: boolean, scrollToken: string): void;
getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations;
hideThreadedMessages?: boolean;
}
interface IState {
@ -265,6 +267,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
componentDidMount() {
this.calculateRoomMembersCount();
this.props.room?.on("RoomState.members", this.calculateRoomMembersCount);
if (SettingsStore.getValue("feature_thread")) {
this.props.room?.getThreads().forEach(thread => thread.fetchReplyChain());
}
this.isMounted = true;
}
@ -443,6 +448,12 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// Always show highlighted event
if (this.props.highlightedEventId === mxEv.getId()) return true;
if (mxEv.replyEventId
&& this.props.hideThreadedMessages
&& SettingsStore.getValue("feature_thread")) {
return false;
}
return !shouldHideEvent(mxEv, this.context);
}

View file

@ -45,17 +45,21 @@ import GroupRoomInfo from "../views/groups/GroupRoomInfo";
import UserInfo from "../views/right_panel/UserInfo";
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
import FilePanel from "./FilePanel";
import ThreadView from "./ThreadView";
import ThreadPanel from "./ThreadPanel";
import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
import { throttle } from 'lodash';
import SpaceStore from "../../stores/SpaceStore";
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
interface IProps {
room?: Room; // if showing panels for a given room, this is set
groupId?: string; // if showing panels for a given group, this is set
user?: User; // used if we know the user ahead of opening the panel
resizeNotifier: ResizeNotifier;
permalinkCreator?: RoomPermalinkCreator;
}
interface IState {
@ -309,6 +313,22 @@ export default class RightPanel extends React.Component<IProps, IState> {
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
break;
case RightPanelPhases.ThreadView:
panel = <ThreadView
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
mxEvent={this.state.event}
permalinkCreator={this.props.permalinkCreator} />;
break;
case RightPanelPhases.ThreadPanel:
panel = <ThreadPanel
roomId={roomId}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose} />;
break;
case RightPanelPhases.RoomSummary:
panel = <RoomSummaryCard room={this.props.room} onClose={this.onClose} />;
break;

View file

@ -2052,7 +2052,10 @@ export default class RoomView extends React.Component<IProps, IState> {
const showRightPanel = this.state.room && this.state.showRightPanel;
const rightPanel = showRightPanel
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
? <RightPanel
room={this.state.room}
resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} />
: null;
const timelineClasses = classNames("mx_RoomView_timeline", {

View file

@ -16,7 +16,7 @@ limitations under the License.
import React, { RefObject, useContext, useRef, useState } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { Preset, JoinRule } from "matrix-js-sdk/src/@types/partials";
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventSubscription } from "fbemitter";
@ -505,11 +505,12 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
setError("");
setBusy(true);
try {
const isPublic = space.getJoinRule() === JoinRule.Public;
const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean);
await Promise.all(filteredRoomNames.map(name => {
return createRoom({
createOpts: {
preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat,
preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
name,
},
spinner: false,
@ -517,6 +518,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
andView: false,
inlineErrors: true,
parentSpace: space,
joinRule: !isPublic ? JoinRule.Restricted : undefined,
});
}));
onFinished(filteredRoomNames.length > 0);

View file

@ -0,0 +1,93 @@
/*
Copyright 2021 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 { MatrixEvent, Room } from 'matrix-js-sdk/src';
import { Thread } from 'matrix-js-sdk/src/models/thread';
import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { MatrixClientPeg } from '../../MatrixClientPeg';
import ResizeNotifier from '../../utils/ResizeNotifier';
import EventTile from '../views/rooms/EventTile';
interface IProps {
roomId: string;
onClose: () => void;
resizeNotifier: ResizeNotifier;
}
interface IState {
threads?: Thread[];
}
@replaceableComponent("structures.ThreadView")
export default class ThreadPanel extends React.Component<IProps, IState> {
private room: Room;
constructor(props: IProps) {
super(props);
this.room = MatrixClientPeg.get().getRoom(this.props.roomId);
}
public componentDidMount(): void {
this.room.on("Thread.update", this.onThreadEventReceived);
this.room.on("Thread.ready", this.onThreadEventReceived);
}
public componentWillUnmount(): void {
this.room.removeListener("Thread.update", this.onThreadEventReceived);
this.room.removeListener("Thread.ready", this.onThreadEventReceived);
}
private onThreadEventReceived = () => this.updateThreads();
private updateThreads = (callback?: () => void): void => {
this.setState({
threads: this.room.getThreads(),
}, callback);
};
private renderEventTile(event: MatrixEvent): JSX.Element {
return <EventTile
key={event.getId()}
mxEvent={event}
enableFlair={false}
showReadReceipts={false}
as="div"
/>;
}
public render(): JSX.Element {
return (
<BaseCard
className="mx_ThreadPanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
{
this.state?.threads.map((thread: Thread) => {
if (thread.ready) {
return this.renderEventTile(thread.rootEvent);
}
})
}
</BaseCard>
);
}
}

View file

@ -0,0 +1,147 @@
/*
Copyright 2021 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 { MatrixEvent, Room } from 'matrix-js-sdk/src';
import { Thread } from 'matrix-js-sdk/src/models/thread';
import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { replaceableComponent } from "../../utils/replaceableComponent";
import ResizeNotifier from '../../utils/ResizeNotifier';
import { TileShape } from '../views/rooms/EventTile';
import MessageComposer from '../views/rooms/MessageComposer';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { Layout } from '../../settings/Layout';
import TimelinePanel from './TimelinePanel';
import dis from "../../dispatcher/dispatcher";
import { ActionPayload } from '../../dispatcher/payloads';
import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload';
import { Action } from '../../dispatcher/actions';
interface IProps {
room: Room;
onClose: () => void;
resizeNotifier: ResizeNotifier;
mxEvent: MatrixEvent;
permalinkCreator?: RoomPermalinkCreator;
}
interface IState {
replyToEvent?: MatrixEvent;
thread?: Thread;
}
@replaceableComponent("structures.ThreadView")
export default class ThreadView extends React.Component<IProps, IState> {
private dispatcherRef: string;
constructor(props: IProps) {
super(props);
this.state = {};
}
public componentDidMount(): void {
this.setupThread(this.props.mxEvent);
this.dispatcherRef = dis.register(this.onAction);
}
public componentWillUnmount(): void {
this.teardownThread();
dis.unregister(this.dispatcherRef);
}
public componentDidUpdate(prevProps) {
if (prevProps.mxEvent !== this.props.mxEvent) {
this.teardownThread();
this.setupThread(this.props.mxEvent);
}
if (prevProps.room !== this.props.room) {
dis.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomSummary,
});
}
}
private onAction = (payload: ActionPayload): void => {
if (payload.phase == RightPanelPhases.ThreadView && payload.event) {
if (payload.event !== this.props.mxEvent) {
this.teardownThread();
this.setupThread(payload.event);
}
}
};
private setupThread = (mxEv: MatrixEvent) => {
const thread = mxEv.getThread();
if (thread) {
thread.on("Thread.update", this.updateThread);
thread.once("Thread.ready", this.updateThread);
this.updateThread(thread);
}
};
private teardownThread = () => {
if (this.state.thread) {
this.state.thread.removeListener("Thread.update", this.updateThread);
this.state.thread.removeListener("Thread.ready", this.updateThread);
}
};
private updateThread = (thread?: Thread) => {
if (thread) {
this.setState({ thread });
} else {
this.forceUpdate();
}
};
public render(): JSX.Element {
return (
<BaseCard
className="mx_ThreadView"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer={true}
>
{ this.state.thread && (
<TimelinePanel
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state?.thread?.timelineSet}
showUrlPreview={false}
tileShape={TileShape.Notif}
empty={<div>empty</div>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
/>
) }
<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state?.thread?.replyToEvent}
showReplyPreview={false}
permalinkCreator={this.props.permalinkCreator}
compact={true}
/>
</BaseCard>
);
}
}

View file

@ -126,6 +126,8 @@ interface IProps {
// callback which is called when we wish to paginate the timeline window.
onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise<boolean>;
hideThreadedMessages?: boolean;
}
interface IState {
@ -214,6 +216,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
timelineCap: Number.MAX_VALUE,
className: 'mx_RoomView_messagePanel',
sendReadReceiptOnLoad: true,
hideThreadedMessages: true,
};
private lastRRSentEventId: string = undefined;
@ -1511,6 +1514,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
showReactions={this.props.showReactions}
layout={this.props.layout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
hideThreadedMessages={this.props.hideThreadedMessages}
/>
);
}

View file

@ -79,7 +79,7 @@ const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick
}
try {
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace });
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace, joinRule });
onFinished(true);
} catch (e) {

View file

@ -80,7 +80,7 @@ const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => {
const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
const [state, setState] = useState<string>(RoomsToLeave.All);
const [state, setState] = useState<string>(RoomsToLeave.None);
useEffect(() => {
if (state === RoomsToLeave.All) {
@ -97,11 +97,11 @@ const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave
onChange={setState}
definitions={[
{
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
}, {
value: RoomsToLeave.None,
label: _t("Don't leave any"),
}, {
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
}, {
value: RoomsToLeave.Specific,
label: _t("Leave specific rooms and spaces"),

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020 - 2021 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.
@ -20,6 +20,7 @@ import { _t } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import {
Capability,
isTimelineCapability,
Widget,
WidgetEventCapability,
WidgetKind,
@ -30,14 +31,7 @@ import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { CapabilityText } from "../../../widgets/CapabilityText";
import { replaceableComponent } from "../../../utils/replaceableComponent";
export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
}
function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) {
localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps));
}
import { lexicographicCompare } from "matrix-js-sdk/src/utils";
interface IProps extends IDialogProps {
requestedCapabilities: Set<Capability>;
@ -95,14 +89,24 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
};
private closeAndTryRemember(approved: Capability[]) {
if (this.state.rememberSelection) {
setRememberedCapabilitiesForWidget(this.props.widget, approved);
}
this.props.onFinished({ approved });
this.props.onFinished({ approved, remember: this.state.rememberSelection });
}
public render() {
const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => {
// We specifically order the timeline capabilities down to the bottom. The capability text
// generation cares strongly about this.
const orderedCapabilities = Object.entries(this.state.booleanStates).sort(([capA], [capB]) => {
const isTimelineA = isTimelineCapability(capA);
const isTimelineB = isTimelineCapability(capB);
if (!isTimelineA && !isTimelineB) return lexicographicCompare(capA, capB);
if (isTimelineA && !isTimelineB) return 1;
if (!isTimelineA && isTimelineB) return -1;
if (isTimelineA && isTimelineB) return lexicographicCompare(capA, capB);
return 0;
});
const checkboxRows = orderedCapabilities.map(([cap, isChecked], i) => {
const text = CapabilityText.for(cap, this.props.widgetKind);
const byline = text.byline
? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{ text.byline }</span>

View file

@ -25,6 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from './Spinner';
interface IProps {
/**
@ -45,7 +46,7 @@ interface IProps {
/**
* The ID of the displayed user
*/
userId: string;
userId?: string;
/**
* The display name of the displayed user
@ -118,13 +119,16 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
}
public render() {
const event = this.fakeEvent(this.state);
const className = classnames(this.props.className, {
"mx_IRCLayout": this.props.layout == Layout.IRC,
"mx_GroupLayout": this.props.layout == Layout.Group,
"mx_EventTilePreview_loader": !this.props.userId,
});
if (!this.props.userId) return <div className={className}><Spinner /></div>;
const event = this.fakeEvent(this.state);
return <div className={className}>
<EventTile
mxEvent={event}

View file

@ -419,6 +419,7 @@ export default class ImageView extends React.Component<IProps, IState> {
const avatar = (
<MemberAvatar
member={mxEvent.sender}
fallbackUserId={mxEvent.getSender()}
width={32}
height={32}
viewUserOnClick={true}

View file

@ -16,11 +16,13 @@ limitations under the License.
import classNames from "classnames";
import React from "react";
import { sanitizedHtmlNode } from "../../../HtmlUtils";
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
reason: string;
htmlReason?: string;
}
interface IState {
@ -51,7 +53,7 @@ export default class InviteReason extends React.PureComponent<IProps, IState> {
});
return <div className={classes}>
<div className="mx_InviteReason_reason">{ this.props.reason }</div>
<div className="mx_InviteReason_reason">{ this.props.htmlReason ? sanitizedHtmlNode(this.props.htmlReason) : this.props.reason }</div>
<div className="mx_InviteReason_view"
onClick={this.onViewClick}
>

View file

@ -173,16 +173,16 @@ class EmojiPicker extends React.Component<IProps, IState> {
};
private onChangeFilter = (filter: string) => {
filter = filter.toLowerCase(); // filter is case insensitive stored lower-case
const lcFilter = filter.toLowerCase().trim(); // filter is case insensitive
for (const cat of this.categories) {
let emojis;
// If the new filter string includes the old filter string, we don't have to re-filter the whole dataset.
if (filter.includes(this.state.filter)) {
if (lcFilter.includes(this.state.filter)) {
emojis = this.memoizedDataByCategory[cat.id];
} else {
emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id];
}
emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, filter));
emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, lcFilter));
this.memoizedDataByCategory[cat.id] = emojis;
cat.enabled = emojis.length > 0;
// The setState below doesn't re-render the header and we already have the refs for updateVisibility, so...
@ -194,9 +194,12 @@ class EmojiPicker extends React.Component<IProps, IState> {
setTimeout(this.updateVisibility, 0);
};
private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean =>
[emoji.annotation, ...emoji.shortcodes, emoji.emoticon, ...emoji.unicode.split(ZERO_WIDTH_JOINER)]
.some(x => x?.includes(filter));
private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean => {
return emoji.annotation.toLowerCase().includes(filter) ||
emoji.emoticon?.toLowerCase().includes(filter) ||
emoji.shortcodes.some(x => x.toLowerCase().includes(filter)) ||
emoji.unicode.split(ZERO_WIDTH_JOINER).includes(filter);
};
private onEnterFilter = () => {
const btn = this.bodyRef.current.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item");

View file

@ -23,6 +23,8 @@ import { EventStatus } from 'matrix-js-sdk/src/models/event';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import { Action } from '../../../dispatcher/actions';
import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu';
import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
import RoomContext from "../../../contexts/RoomContext";
@ -34,6 +36,7 @@ import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import DownloadActionButton from "./DownloadActionButton";
import SettingsStore from '../../../settings/SettingsStore';
const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -170,6 +173,17 @@ export default class MessageActionBar extends React.PureComponent {
});
};
onThreadClick = () => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadView,
allowClose: false,
refireParams: {
event: this.props.mxEvent,
},
});
}
onEditClick = (ev) => {
dis.dispatch({
action: 'edit_event',
@ -254,12 +268,22 @@ export default class MessageActionBar extends React.PureComponent {
// The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply) {
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
key="reply"
/>);
toolbarOpts.splice(0, 0, <>
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
key="reply"
/>
{ SettingsStore.getValue("feature_thread") && (
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_threadButton"
title={_t("Thread")}
onClick={this.onThreadClick}
key="thread"
/>
) }
</>);
}
if (this.context.canReact) {
toolbarOpts.splice(0, 0, <ReactButton

View file

@ -220,6 +220,13 @@ const onRoomFilesClick = () => {
});
};
const onRoomThreadsClick = () => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadPanel,
});
};
const onRoomSettingsClick = () => {
defaultDispatcher.dispatch({ action: "open_room_settings" });
};
@ -273,6 +280,11 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
<Button className="mx_RoomSummaryCard_icon_files" onClick={onRoomFilesClick}>
{ _t("Show files") }
</Button>
{ SettingsStore.getValue("feature_thread") && (
<Button className="mx_RoomSummaryCard_icon_threads" onClick={onRoomThreadsClick}>
{ _t("Show threads") }
</Button>
) }
<Button className="mx_RoomSummaryCard_icon_share" onClick={onShareRoomClick}>
{ _t("Share room") }
</Button>

View file

@ -21,6 +21,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Thread } from 'matrix-js-sdk/src/models/thread';
import ReplyThread from "../elements/ReplyThread";
import { _t } from '../../../languageHandler';
@ -55,6 +56,8 @@ import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import SettingsStore from "../../../settings/SettingsStore";
const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent',
@ -299,6 +302,9 @@ interface IProps {
// whether or not to display the sender
hideSender?: boolean;
// whether or not to display thread info
showThreadInfo?: boolean;
}
interface IState {
@ -315,6 +321,8 @@ interface IState {
reactions: Relations;
hover: boolean;
thread?: Thread;
}
@replaceableComponent("views.rooms.EventTile")
@ -351,6 +359,8 @@ export default class EventTile extends React.Component<IProps, IState> {
reactions: this.getReactions(),
hover: false,
thread: this.props.mxEvent?.getThread(),
};
// don't do RR animations until we are mounted
@ -451,8 +461,20 @@ export default class EventTile extends React.Component<IProps, IState> {
client.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true;
}
if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.once("Thread.ready", this.updateThread);
this.props.mxEvent.on("Thread.update", this.updateThread);
}
}
private updateThread = (thread) => {
this.setState({
thread,
});
this.forceUpdate();
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(nextProps) {
@ -463,7 +485,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}
}
shouldComponentUpdate(nextProps, nextState) {
shouldComponentUpdate(nextProps, nextState, nextContext) {
if (objectHasDiff(this.state, nextState)) {
return true;
}
@ -491,6 +513,43 @@ export default class EventTile extends React.Component<IProps, IState> {
}
}
private renderThreadInfo(): React.ReactNode {
if (!SettingsStore.getValue("feature_thread")) {
return null;
}
const thread = this.state.thread;
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
if (!thread || this.props.showThreadInfo === false) {
return null;
}
const avatars = Array.from(thread.participants).map((mxId: string) => {
const member = room.getMember(mxId);
return <MemberAvatar key={member.userId} member={member} width={14} height={14} />;
});
return (
<div
className="mx_ThreadInfo"
onClick={() => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadView,
refireParams: {
event: this.props.mxEvent,
},
});
}}
>
<span className="mx_EventListSummary_avatars">
{ avatars }
</span>
{ thread.length - 1 } { thread.length === 2 ? 'reply' : 'replies' }
</div>
);
}
private onRoomReceipt = (ev, room) => {
// ignore events for other rooms
const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
@ -1180,6 +1239,7 @@ export default class EventTile extends React.Component<IProps, IState> {
{ keyRequestInfo }
{ actionBar }
{ this.props.layout === Layout.IRC && (reactionsRow) }
{ this.renderThreadInfo() }
</div>
{ this.props.layout !== Layout.IRC && (reactionsRow) }
{ msgOption }

View file

@ -185,8 +185,8 @@ export default class MemberList extends React.Component<IProps, IState> {
return {
loading: false,
members: members,
filteredJoinedMembers: this.filterMembers(members, 'join'),
filteredInvitedMembers: this.filterMembers(members, 'invite'),
filteredJoinedMembers: this.filterMembers(members, 'join', searchQuery),
filteredInvitedMembers: this.filterMembers(members, 'invite', searchQuery),
canInvite: this.canInvite,
// ideally we'd size this to the page height, but

View file

@ -192,7 +192,9 @@ interface IProps {
resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator;
replyToEvent?: MatrixEvent;
showReplyPreview?: boolean;
e2eStatus?: E2EStatus;
compact?: boolean;
}
interface IState {
@ -214,6 +216,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private voiceRecordingButton: VoiceRecordComposerTile;
private ref: React.RefObject<HTMLDivElement> = createRef();
static defaultProps = {
showReplyPreview: true,
compact: false,
};
constructor(props) {
super(props);
VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
@ -471,7 +478,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
render() {
const controls = [
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.state.me && !this.props.compact ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.props.e2eStatus ?
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> :
null,
@ -541,8 +548,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
yOffset={-50}
/>;
}
controls.push(
controls.push(
<Stickerpicker
room={this.props.room}
showStickers={this.state.showStickers}
@ -552,11 +558,19 @@ export default class MessageComposer extends React.Component<IProps, IState> {
const showSendButton = !this.state.isComposerEmpty || this.state.haveRecording;
const classes = classNames({
"mx_MessageComposer": true,
"mx_GroupLayout": true,
"mx_MessageComposer--compact": this.props.compact,
});
return (
<div className="mx_MessageComposer mx_GroupLayout" ref={this.ref}>
<div className={classes}>
{ recordingTooltip }
<div className="mx_MessageComposer_wrapper">
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
{ this.props.showReplyPreview && (
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
) }
<div className="mx_MessageComposer_row">
{ controls }
{ this.renderButtons(menuPosition) }

View file

@ -28,6 +28,8 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import InviteReason from "../elements/InviteReason";
const MemberEventHtmlReasonField = "io.element.html_reason";
const MessageCase = Object.freeze({
NotLoggedIn: "NotLoggedIn",
Joining: "Joining",
@ -492,9 +494,13 @@ export default class RoomPreviewBar extends React.Component {
}
const myUserId = MatrixClientPeg.get().getUserId();
const reason = this.props.room.currentState.getMember(myUserId).events.member.event.content.reason;
if (reason) {
reasonElement = <InviteReason reason={reason} />;
const memberEventContent = this.props.room.currentState.getMember(myUserId).events.member.getContent();
if (memberEventContent.reason) {
reasonElement = <InviteReason
reason={memberEventContent.reason}
htmlReason={memberEventContent[MemberEventHtmlReasonField]}
/>;
}
primaryActionHandler = this.props.onJoinClick;

View file

@ -26,7 +26,7 @@ import { Layout } from "../../../settings/Layout";
import { SettingLevel } from "../../../settings/SettingLevel";
interface IProps {
userId: string;
userId?: string;
displayName: string;
avatarUrl: string;
messagePreviewText: string;

View file

@ -149,10 +149,12 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
"To avoid these issues, create a <a>new encrypted room</a> for " +
"the conversation you plan to have.",
null,
{ "a": (sub) => <a onClick={() => {
dialog.close();
this.createNewRoom(false, true);
}}> { sub } </a> },
{ "a": (sub) => <a
className="mx_linkButton"
onClick={() => {
dialog.close();
this.createNewRoom(false, true);
}}> { sub } </a> },
) } </p>
</div>,
@ -248,10 +250,12 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
"you plan to have.",
null,
{
"a": (sub) => <a onClick={() => {
dialog.close();
this.createNewRoom(true, false);
}}> { sub } </a>,
"a": (sub) => <a
className="mx_linkButton"
onClick={() => {
dialog.close();
this.createNewRoom(true, false);
}}> { sub } </a>,
},
) } </p>
</div>,

View file

@ -67,7 +67,7 @@ interface IState extends IThemeState {
showAdvanced: boolean;
layout: Layout;
// User profile data for the message preview
userId: string;
userId?: string;
displayName: string;
avatarUrl: string;
}
@ -92,8 +92,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
systemFont: SettingsStore.getValue("systemFont"),
showAdvanced: false,
layout: SettingsStore.getValue("layout"),
userId: "@erim:fink.fink",
displayName: "Erimayas Fink",
userId: null,
displayName: null,
avatarUrl: null,
};
}

View file

@ -54,8 +54,8 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => {
{ space.canInvite(MatrixClientPeg.get()?.getUserId()) ? <AccessibleButton
className="mx_SpacePublicShare_inviteButton"
onClick={() => {
showRoomInviteDialog(space.roomId);
if (onFinished) onFinished();
showRoomInviteDialog(space.roomId);
}}
>
<h3>{ _t("Invite people") }</h3>

View file

@ -426,9 +426,6 @@
"Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message",
"Sends a message as plain text, without interpreting it as markdown": "Sends a message as plain text, without interpreting it as markdown",
"Sends a message as html, without interpreting it as markdown": "Sends a message as html, without interpreting it as markdown",
"Searches DuckDuckGo for results": "Searches DuckDuckGo for results",
"/ddg is not a command": "/ddg is not a command",
"To use it, just wait for autocomplete results to load and tab through them.": "To use it, just wait for autocomplete results to load and tab through them.",
"Upgrades a room to a new version": "Upgrades a room to a new version",
"You do not have the required permissions to use this command.": "You do not have the required permissions to use this command.",
"Changes your display nickname": "Changes your display nickname",
@ -604,6 +601,8 @@
"See when anyone posts a sticker to your active room": "See when anyone posts a sticker to your active room",
"with an empty state key": "with an empty state key",
"with state key %(stateKey)s": "with state key %(stateKey)s",
"The above, but in any room you are joined or invited to as well": "The above, but in any room you are joined or invited to as well",
"The above, but in <Room /> as well": "The above, but in <Room /> as well",
"Send <b>%(eventType)s</b> events as you in this room": "Send <b>%(eventType)s</b> events as you in this room",
"See <b>%(eventType)s</b> events posted to this room": "See <b>%(eventType)s</b> events posted to this room",
"Send <b>%(eventType)s</b> events as you in your active room": "Send <b>%(eventType)s</b> events as you in your active room",
@ -809,6 +808,7 @@
"Render LaTeX maths in messages": "Render LaTeX maths in messages",
"Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.",
"Message Pinning": "Message Pinning",
"Threaded messaging": "Threaded messaging",
"Custom user status messages": "Custom user status messages",
"Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)",
"Render simple counters in room header": "Render simple counters in room header",
@ -1807,6 +1807,7 @@
"%(count)s people|other": "%(count)s people",
"%(count)s people|one": "%(count)s person",
"Show files": "Show files",
"Show threads": "Show threads",
"Share room": "Share room",
"Room settings": "Room settings",
"Trusted": "Trusted",
@ -1926,6 +1927,7 @@
"React": "React",
"Edit": "Edit",
"Reply": "Reply",
"Thread": "Thread",
"Message Actions": "Message Actions",
"Download %(text)s": "Download %(text)s",
"Error decrypting attachment": "Error decrypting attachment",
@ -2412,8 +2414,8 @@
"Clear cache and resync": "Clear cache and resync",
"%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!",
"Updating %(brand)s": "Updating %(brand)s",
"Leave all rooms and spaces": "Leave all rooms and spaces",
"Don't leave any": "Don't leave any",
"Leave all rooms and spaces": "Leave all rooms and spaces",
"Leave specific rooms and spaces": "Leave specific rooms and spaces",
"Search %(spaceName)s": "Search %(spaceName)s",
"You won't be able to rejoin unless you are re-invited.": "You won't be able to rejoin unless you are re-invited.",
@ -3003,8 +3005,6 @@
"Commands": "Commands",
"Command Autocomplete": "Command Autocomplete",
"Community Autocomplete": "Community Autocomplete",
"Results from DuckDuckGo": "Results from DuckDuckGo",
"DuckDuckGo Results": "DuckDuckGo Results",
"Emoji": "Emoji",
"Emoji Autocomplete": "Emoji Autocomplete",
"Notify the whole room": "Notify the whole room",

View file

@ -211,6 +211,15 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_thread": {
isFeature: true,
// Requires a reload as we change an option flag on the `js-sdk`
// And the entire sync history needs to be parsed again
controller: new ReloadOnChangeController(),
displayName: _td("Threaded messaging"),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_custom_status": {
isFeature: true,
displayName: _td("Custom user status messages"),

View file

@ -71,7 +71,13 @@ export default class DeviceSettingsHandler extends SettingsHandler {
// Special case for old useIRCLayout setting
if (settingName === "layout") {
const settings = this.getSettings() || {};
if (settings["useIRCLayout"]) return Layout.IRC;
if (settings["useIRCLayout"]) {
// Set the new layout setting and delete the old one so that we
// can delete this block of code after some time
settings["layout"] = Layout.IRC;
delete settings["useIRCLayout"];
localStorage.setItem("mx_local_settings", JSON.stringify(settings));
}
return settings[settingName];
}

View file

@ -37,6 +37,10 @@ export enum RightPanelPhases {
SpaceMemberList = "SpaceMemberList",
SpaceMemberInfo = "SpaceMemberInfo",
Space3pidMemberInfo = "Space3pidMemberInfo",
// Thread stuff
ThreadView = "ThreadView",
ThreadPanel = "ThreadPanel",
}
// These are the phases that are safe to persist (the ones that don't require additional

View file

@ -145,9 +145,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return this._allRoomsInHome;
}
public async setActiveRoomInSpace(space: Room | null): Promise<void> {
public setActiveRoomInSpace(space: Room | null): void {
if (space && !space.isSpaceRoom()) return;
if (space !== this.activeSpace) await this.setActiveSpace(space);
if (space !== this.activeSpace) this.setActiveSpace(space);
if (space) {
const roomId = this.getNotificationState(space.roomId).getFirstRoomWithNotifications();
@ -190,7 +190,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
* @param contextSwitch whether to switch the user's context,
* should not be done when the space switch is done implicitly due to another event like switching room.
*/
public async setActiveSpace(space: Room | null, contextSwitch = true) {
public setActiveSpace(space: Room | null, contextSwitch = true) {
if (space === this.activeSpace || (space && !space.isSpaceRoom())) return;
this._activeSpace = space;
@ -293,11 +293,15 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}
if (space) {
const suggestedRooms = await this.fetchSuggestedRooms(space);
if (this._activeSpace === space) {
this._suggestedRooms = suggestedRooms;
this.emit(SUGGESTED_ROOMS, this._suggestedRooms);
}
this.loadSuggestedRooms(space);
}
}
private async loadSuggestedRooms(space) {
const suggestedRooms = await this.fetchSuggestedRooms(space);
if (this._activeSpace === space) {
this._suggestedRooms = suggestedRooms;
this.emit(SUGGESTED_ROOMS, this._suggestedRooms);
}
}
@ -666,6 +670,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.onSpaceUpdate();
this.emit(room.roomId);
}
if (room === this.activeSpace && // current space
this.matrixClient.getRoom(ev.getStateKey())?.getMyMembership() !== "join" && // target not joined
ev.getPrevContent().suggested !== ev.getContent().suggested // suggested flag changed
) {
this.loadSuggestedRooms(room);
}
break;
case EventType.SpaceParent:
@ -678,12 +690,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}
this.emit(room.roomId);
break;
}
};
case EventType.RoomMember:
if (room.isSpaceRoom()) {
this.onSpaceMembersChange(ev);
}
break;
// listening for m.room.member events in onRoomState above doesn't work as the Member object isn't updated by then
private onRoomStateMembers = (ev: MatrixEvent) => {
const room = this.matrixClient.getRoom(ev.getRoomId());
if (room?.isSpaceRoom()) {
this.onSpaceMembersChange(ev);
}
};
@ -743,6 +757,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.matrixClient.removeListener("Room.myMembership", this.onRoom);
this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData);
this.matrixClient.removeListener("RoomState.events", this.onRoomState);
this.matrixClient.removeListener("RoomState.members", this.onRoomStateMembers);
this.matrixClient.removeListener("accountData", this.onAccountData);
}
await this.reset();
@ -754,6 +769,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.matrixClient.on("Room.myMembership", this.onRoom);
this.matrixClient.on("Room.accountData", this.onRoomAccountData);
this.matrixClient.on("RoomState.events", this.onRoomState);
this.matrixClient.on("RoomState.members", this.onRoomStateMembers);
this.matrixClient.on("accountData", this.onAccountData);
this.matrixClient.getCapabilities().then(capabilities => {

View file

@ -20,6 +20,27 @@ import { IAlgorithm } from "./IAlgorithm";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import * as Unread from "../../../../Unread";
import { EffectiveMembership, getEffectiveMembership } from "../../../../utils/membership";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
export function shouldCauseReorder(event: MatrixEvent): boolean {
const type = event.getType();
const content = event.getContent();
const prevContent = event.getPrevContent();
// Never ignore membership changes
if (type === EventType.RoomMember && prevContent.membership !== content.membership) return true;
// Ignore status changes
// XXX: This should be an enum
if (type === "im.vector.user_status") return false;
// Ignore display name changes
if (type === EventType.RoomMember && prevContent.displayname !== content.displayname) return false;
// Ignore avatar changes
if (type === EventType.RoomMember && prevContent.avatar_url !== content.avatar_url) return false;
return true;
}
export const sortRooms = (rooms: Room[]): Room[] => {
// We cache the timestamp lookup to avoid iterating forever on the timeline
@ -68,7 +89,10 @@ export const sortRooms = (rooms: Room[]): Room[] => {
const ev = r.timeline[i];
if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?)
if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) {
if (
(ev.getSender() === myUserId && shouldCauseReorder(ev)) ||
Unread.eventTriggersUnreadCount(ev)
) {
return ev.getTs();
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
* Copyright 2020 - 2021 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.
@ -55,6 +55,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { ELEMENT_CLIENT_ID } from "../../identifiers";
import { getUserLanguage } from "../../languageHandler";
import { WidgetVariableCustomisations } from "../../customisations/WidgetVariables";
import { arrayFastClone } from "../../utils/arrays";
// TODO: Destroy all of this code
@ -146,6 +147,7 @@ export class StopGapWidget extends EventEmitter {
private scalarToken: string;
private roomId?: string;
private kind: WidgetKind;
private readUpToMap: {[roomId: string]: string} = {}; // room ID to event ID
constructor(private appTileProps: IAppTileProps) {
super();
@ -294,6 +296,14 @@ export class StopGapWidget extends EventEmitter {
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
});
// Populate the map of "read up to" events for this widget with the current event in every room.
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
for (const room of MatrixClientPeg.get().getRooms()) {
// Timelines are most recent last
this.readUpToMap[room.roomId] = arrayFastClone(room.getLiveTimeline().getEvents()).reverse()[0].getId();
}
// Attach listeners for feeding events - the underlying widget classes handle permissions for us
MatrixClientPeg.get().on('event', this.onEvent);
MatrixClientPeg.get().on('Event.decrypted', this.onEventDecrypted);
@ -408,21 +418,56 @@ export class StopGapWidget extends EventEmitter {
private onEvent = (ev: MatrixEvent) => {
MatrixClientPeg.get().decryptEventIfNeeded(ev);
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
if (ev.getRoomId() !== this.eventListenerRoomId) return;
this.feedEvent(ev);
};
private onEventDecrypted = (ev: MatrixEvent) => {
if (ev.isDecryptionFailure()) return;
if (ev.getRoomId() !== this.eventListenerRoomId) return;
this.feedEvent(ev);
};
private feedEvent(ev: MatrixEvent) {
if (!this.messaging) return;
// Check to see if this event would be before or after our "read up to" marker. If it's
// before, or we can't decide, then we assume the widget will have already seen the event.
// If the event is after, or we don't have a marker for the room, then we'll send it through.
//
// This approach of "read up to" prevents widgets receiving decryption spam from startup or
// receiving out-of-order events from backfill and such.
const upToEventId = this.readUpToMap[ev.getRoomId()];
if (upToEventId) {
// Small optimization for exact match (prevent search)
if (upToEventId === ev.getId()) {
return;
}
let isBeforeMark = true;
// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
// to avoid overusing the CPU.
const timeline = MatrixClientPeg.get().getRoom(ev.getRoomId()).getLiveTimeline();
const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100);
for (const timelineEvent of events) {
if (timelineEvent.getId() === upToEventId) {
break;
} else if (timelineEvent.getId() === ev.getId()) {
isBeforeMark = false;
break;
}
}
if (isBeforeMark) {
// Ignore the event: it is before our interest.
return;
}
}
this.readUpToMap[ev.getRoomId()] = ev.getId();
const raw = ev.getEffectiveEvent();
this.messaging.feedEvent(raw).catch(e => {
this.messaging.feedEvent(raw, this.eventListenerRoomId).catch(e => {
console.error("Error sending event to widget: ", e);
});
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
* Copyright 2020 - 2021 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.
@ -23,6 +23,7 @@ import {
MatrixCapabilities,
OpenIDRequestState,
SimpleObservable,
Symbols,
Widget,
WidgetDriver,
WidgetEventCapability,
@ -33,9 +34,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
import ActiveRoomObserver from "../../ActiveRoomObserver";
import Modal from "../../Modal";
import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog";
import WidgetCapabilitiesPromptDialog, {
getRememberedCapabilitiesForWidget,
} from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog";
import WidgetCapabilitiesPromptDialog from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog";
import { WidgetPermissionCustomisations } from "../../customisations/WidgetPermissions";
import { OIDCState, WidgetPermissionStore } from "./WidgetPermissionStore";
import { WidgetType } from "../../widgets/WidgetType";
@ -44,10 +43,19 @@ import { CHAT_EFFECTS } from "../../effects";
import { containsEmoji } from "../../effects/utils";
import dis from "../../dispatcher/dispatcher";
import { tryTransformPermalinkToLocalHref } from "../../utils/permalinks/Permalinks";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { IEvent, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk";
// TODO: Purge this from the universe
function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
}
function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) {
localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps));
}
export class StopGapWidgetDriver extends WidgetDriver {
private allowedCapabilities: Set<Capability>;
@ -100,6 +108,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
}
}
// TODO: Do something when the widget requests new capabilities not yet asked for
let rememberApproved = false;
if (missing.size > 0) {
try {
const [result] = await Modal.createTrackedDialog(
@ -111,17 +120,29 @@ export class StopGapWidgetDriver extends WidgetDriver {
widgetKind: this.forWidgetKind,
}).finished;
(result.approved || []).forEach(cap => allowedSoFar.add(cap));
rememberApproved = result.remember;
} catch (e) {
console.error("Non-fatal error getting capabilities: ", e);
}
}
return new Set(iterableUnion(allowedSoFar, requested));
const allAllowed = new Set(iterableUnion(allowedSoFar, requested));
if (rememberApproved) {
setRememberedCapabilitiesForWidget(this.forWidget, Array.from(allAllowed));
}
return allAllowed;
}
public async sendEvent(eventType: string, content: any, stateKey: string = null): Promise<ISendEventDetails> {
public async sendEvent(
eventType: string,
content: any,
stateKey: string = null,
targetRoomId: string = null,
): Promise<ISendEventDetails> {
const client = MatrixClientPeg.get();
const roomId = ActiveRoomObserver.activeRoomId;
const roomId = targetRoomId || ActiveRoomObserver.activeRoomId;
if (!client || !roomId) throw new Error("Not in a room or not attached to a client");
@ -129,6 +150,9 @@ export class StopGapWidgetDriver extends WidgetDriver {
if (stateKey !== null) {
// state event
r = await client.sendStateEvent(roomId, eventType, content, stateKey);
} else if (eventType === EventType.RoomRedaction) {
// special case: extract the `redacts` property and call redact
r = await client.redactEvent(roomId, content['redacts']);
} else {
// message event
r = await client.sendEvent(roomId, eventType, content);
@ -145,48 +169,68 @@ export class StopGapWidgetDriver extends WidgetDriver {
return { roomId, eventId: r.event_id };
}
public async readRoomEvents(eventType: string, msgtype: string | undefined, limit: number): Promise<object[]> {
limit = limit > 0 ? Math.min(limit, 25) : 25; // arbitrary choice
private pickRooms(roomIds: (string | Symbols.AnyRoom)[] = null): Room[] {
const client = MatrixClientPeg.get();
const roomId = ActiveRoomObserver.activeRoomId;
const room = client.getRoom(roomId);
if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client");
if (!client) throw new Error("Not attached to a client");
const results: MatrixEvent[] = [];
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
for (let i = events.length - 1; i > 0; i--) {
if (results.length >= limit) break;
const ev = events[i];
if (ev.getType() !== eventType || ev.isState()) continue;
if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue;
results.push(ev);
}
return results.map(e => e.getEffectiveEvent());
const targetRooms = roomIds
? (roomIds.includes(Symbols.AnyRoom) ? client.getVisibleRooms() : roomIds.map(r => client.getRoom(r)))
: [client.getRoom(ActiveRoomObserver.activeRoomId)];
return targetRooms.filter(r => !!r);
}
public async readStateEvents(eventType: string, stateKey: string | undefined, limit: number): Promise<object[]> {
limit = limit > 0 ? Math.min(limit, 100) : 100; // arbitrary choice
public async readRoomEvents(
eventType: string,
msgtype: string | undefined,
limitPerRoom: number,
roomIds: (string | Symbols.AnyRoom)[] = null,
): Promise<object[]> {
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
const client = MatrixClientPeg.get();
const roomId = ActiveRoomObserver.activeRoomId;
const room = client.getRoom(roomId);
if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client");
const rooms = this.pickRooms(roomIds);
const allResults: IEvent[] = [];
for (const room of rooms) {
const results: MatrixEvent[] = [];
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
for (let i = events.length - 1; i > 0; i--) {
if (results.length >= limitPerRoom) break;
const results: MatrixEvent[] = [];
const state: Map<string, MatrixEvent> = room.currentState.events.get(eventType);
if (state) {
if (stateKey === "" || !!stateKey) {
const forKey = state.get(stateKey);
if (forKey) results.push(forKey);
} else {
results.push(...Array.from(state.values()));
const ev = events[i];
if (ev.getType() !== eventType || ev.isState()) continue;
if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue;
results.push(ev);
}
}
return results.slice(0, limit).map(e => e.event);
results.forEach(e => allResults.push(e.getEffectiveEvent()));
}
return allResults;
}
public async readStateEvents(
eventType: string,
stateKey: string | undefined,
limitPerRoom: number,
roomIds: (string | Symbols.AnyRoom)[] = null,
): Promise<object[]> {
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
const rooms = this.pickRooms(roomIds);
const allResults: IEvent[] = [];
for (const room of rooms) {
const results: MatrixEvent[] = [];
const state: Map<string, MatrixEvent> = room.currentState.events.get(eventType);
if (state) {
if (stateKey === "" || !!stateKey) {
const forKey = state.get(stateKey);
if (forKey) results.push(forKey);
} else {
results.push(...Array.from(state.values()));
}
}
results.slice(0, limitPerRoom).forEach(e => allResults.push(e.getEffectiveEvent()));
}
return allResults;
}
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>) {

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020 - 2021 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.
@ -14,11 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Capability, EventDirection, MatrixCapabilities, WidgetEventCapability, WidgetKind } from "matrix-widget-api";
import {
Capability,
EventDirection,
getTimelineRoomIDFromCapability,
isTimelineCapability,
isTimelineCapabilityFor,
MatrixCapabilities, Symbols,
WidgetEventCapability,
WidgetKind,
} from "matrix-widget-api";
import { _t, _td, TranslatedString } from "../languageHandler";
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
import { ElementWidgetCapabilities } from "../stores/widgets/ElementWidgetCapabilities";
import React from "react";
import { MatrixClientPeg } from "../MatrixClientPeg";
import TextWithTooltip from "../components/views/elements/TextWithTooltip";
type GENERIC_WIDGET_KIND = "generic"; // eslint-disable-line @typescript-eslint/naming-convention
const GENERIC_WIDGET_KIND: GENERIC_WIDGET_KIND = "generic";
@ -138,8 +149,31 @@ export class CapabilityText {
if (textForKind[GENERIC_WIDGET_KIND]) return { primary: _t(textForKind[GENERIC_WIDGET_KIND]) };
// ... we'll fall through to the generic capability processing at the end of this
// function if we fail to locate a simple string and the capability isn't for an
// event.
// function if we fail to generate a string for the capability.
}
// Try to handle timeline capabilities. The text here implies that the caller has sorted
// the timeline caps to the end for UI purposes.
if (isTimelineCapability(capability)) {
if (isTimelineCapabilityFor(capability, Symbols.AnyRoom)) {
return { primary: _t("The above, but in any room you are joined or invited to as well") };
} else {
const roomId = getTimelineRoomIDFromCapability(capability);
const room = MatrixClientPeg.get().getRoom(roomId);
return {
primary: _t("The above, but in <Room /> as well", {}, {
Room: () => {
if (room) {
return <TextWithTooltip tooltip={room.getCanonicalAlias() ?? roomId}>
<b>{ room.name }</b>
</TextWithTooltip>;
} else {
return <b><code>{ roomId }</code></b>;
}
},
}),
};
}
}
// We didn't have a super simple line of text, so try processing the capability as the