Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/a11y/composer-list-autocomplete
Conflicts: src/autocomplete/AutocompleteProvider.tsx src/components/views/rooms/Autocomplete.tsx
This commit is contained in:
commit
e9c258a930
118 changed files with 3100 additions and 1003 deletions
3
src/@types/global.d.ts
vendored
3
src/@types/global.d.ts
vendored
|
@ -52,6 +52,9 @@ declare global {
|
|||
init: () => Promise<void>;
|
||||
};
|
||||
|
||||
// Needed for Safari, unknown to TypeScript
|
||||
webkitAudioContext: typeof AudioContext;
|
||||
|
||||
mxContentMessages: ContentMessages;
|
||||
mxToastStore: ToastStore;
|
||||
mxDeviceListener: DeviceListener;
|
||||
|
|
|
@ -40,6 +40,8 @@ export function eventTriggersUnreadCount(ev) {
|
|||
return false;
|
||||
} else if (ev.getType() == 'm.room.server_acl') {
|
||||
return false;
|
||||
} else if (ev.isRedacted()) {
|
||||
return false;
|
||||
}
|
||||
return haveTileForEvent(ev);
|
||||
}
|
||||
|
|
|
@ -167,7 +167,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
|
|||
const onKeyDownHandler = useCallback((ev) => {
|
||||
let handled = false;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
if (handleHomeEnd && ev.target.tagName !== "INPUT") {
|
||||
if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
||||
// check if we actually have any items
|
||||
switch (ev.key) {
|
||||
case Key.HOME:
|
||||
|
|
|
@ -93,7 +93,14 @@ export default abstract class AutocompleteProvider {
|
|||
};
|
||||
}
|
||||
|
||||
abstract getCompletions(query: string, selection: ISelectionRange, force: boolean): Promise<ICompletion[]>;
|
||||
abstract getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force: boolean,
|
||||
limit: number,
|
||||
): Promise<ICompletion[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
abstract getName(): string;
|
||||
|
||||
|
|
|
@ -26,6 +26,8 @@ import EmojiProvider from './EmojiProvider';
|
|||
import NotifProvider from './NotifProvider';
|
||||
import {timeout} from "../utils/promise";
|
||||
import AutocompleteProvider, {ICommand} from "./AutocompleteProvider";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import SpaceProvider from "./SpaceProvider";
|
||||
|
||||
export interface ISelectionRange {
|
||||
beginning?: boolean; // whether the selection is in the first block of the editor or not
|
||||
|
@ -56,6 +58,11 @@ const PROVIDERS = [
|
|||
DuckDuckGoProvider,
|
||||
];
|
||||
|
||||
// as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
PROVIDERS.push(SpaceProvider);
|
||||
}
|
||||
|
||||
// Providers will get rejected if they take longer than this.
|
||||
const PROVIDER_COMPLETION_TIMEOUT = 3000;
|
||||
|
||||
|
@ -82,15 +89,24 @@ export default class Autocompleter {
|
|||
});
|
||||
}
|
||||
|
||||
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<IProviderCompletions[]> {
|
||||
async getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force = false,
|
||||
limit = -1,
|
||||
): Promise<IProviderCompletions[]> {
|
||||
/* Note: This intentionally waits for all providers to return,
|
||||
otherwise, we run into a condition where new completions are displayed
|
||||
while the user is interacting with the list, which makes it difficult
|
||||
to predict whether an action will actually do what is intended
|
||||
*/
|
||||
// list of results from each provider, each being a list of completions or null if it times out
|
||||
const completionsList: ICompletion[][] = await Promise.all(this.providers.map(provider => {
|
||||
return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT);
|
||||
const completionsList: ICompletion[][] = await Promise.all(this.providers.map(async provider => {
|
||||
return await timeout(
|
||||
provider.getCompletions(query, selection, force, limit),
|
||||
null,
|
||||
PROVIDER_COMPLETION_TIMEOUT,
|
||||
);
|
||||
}));
|
||||
|
||||
// map then filter to maintain the index for the map-operation, for this.providers to line up
|
||||
|
|
|
@ -38,7 +38,12 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
});
|
||||
}
|
||||
|
||||
async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise<ICompletion[]> {
|
||||
async getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force?: boolean,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
const {command, range} = this.getCurrentCommand(query, selection);
|
||||
if (!command) return [];
|
||||
|
||||
|
@ -55,10 +60,11 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
} else {
|
||||
if (query === '/') {
|
||||
// If they have just entered `/` show everything
|
||||
// We exclude the limit on purpose to have a comprehensive list
|
||||
matches = Commands;
|
||||
} else {
|
||||
// otherwise fuzzy match against all of the fields
|
||||
matches = this.matcher.match(command[1]);
|
||||
matches = this.matcher.match(command[1], limit);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -50,7 +50,12 @@ export default class CommunityProvider extends AutocompleteProvider {
|
|||
});
|
||||
}
|
||||
|
||||
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
|
||||
async getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar');
|
||||
|
||||
// Disable autocompletions when composing commands because of various issues
|
||||
|
@ -81,7 +86,7 @@ export default class CommunityProvider extends AutocompleteProvider {
|
|||
this.matcher.setObjects(groups);
|
||||
|
||||
const matchedString = command[0];
|
||||
completions = this.matcher.match(matchedString);
|
||||
completions = this.matcher.match(matchedString, limit);
|
||||
completions = sortBy(completions, [
|
||||
(c) => score(matchedString, c.groupId),
|
||||
(c) => c.groupId.length,
|
||||
|
|
|
@ -36,7 +36,12 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
|
|||
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
|
||||
}
|
||||
|
||||
async getCompletions(query: string, selection: ISelectionRange, force= false): Promise<ICompletion[]> {
|
||||
async getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
const {command, range} = this.getCurrentCommand(query, selection);
|
||||
if (!query || !command) {
|
||||
return [];
|
||||
|
@ -46,7 +51,8 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
|
|||
method: 'GET',
|
||||
});
|
||||
const json = await response.json();
|
||||
const results = json.Results.map((result) => {
|
||||
const maxLength = limit > -1 ? limit : json.Results.length;
|
||||
const results = json.Results.slice(0, maxLength).map((result) => {
|
||||
return {
|
||||
completion: result.Text,
|
||||
component: (
|
||||
|
|
|
@ -84,7 +84,12 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
});
|
||||
}
|
||||
|
||||
async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise<ICompletion[]> {
|
||||
async getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force?: boolean,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
if (!SettingsStore.getValue("MessageComposerInput.suggestEmoji")) {
|
||||
return []; // don't give any suggestions if the user doesn't want them
|
||||
}
|
||||
|
@ -93,7 +98,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
const {command, range} = this.getCurrentCommand(query, selection);
|
||||
if (command) {
|
||||
const matchedString = command[0];
|
||||
completions = this.matcher.match(matchedString);
|
||||
completions = this.matcher.match(matchedString, limit);
|
||||
|
||||
// Do second match with shouldMatchWordsOnly in order to match against 'name'
|
||||
completions = completions.concat(this.nameMatcher.match(matchedString));
|
||||
|
|
|
@ -33,7 +33,12 @@ export default class NotifProvider extends AutocompleteProvider {
|
|||
this.room = room;
|
||||
}
|
||||
|
||||
async getCompletions(query: string, selection: ISelectionRange, force= false): Promise<ICompletion[]> {
|
||||
async getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
|
|
|
@ -87,7 +87,7 @@ export default class QueryMatcher<T extends Object> {
|
|||
}
|
||||
}
|
||||
|
||||
match(query: string): T[] {
|
||||
match(query: string, limit = -1): T[] {
|
||||
query = this.processQuery(query);
|
||||
if (this._options.shouldMatchWordsOnly) {
|
||||
query = query.replace(/[^\w]/g, '');
|
||||
|
@ -129,7 +129,10 @@ export default class QueryMatcher<T extends Object> {
|
|||
});
|
||||
|
||||
// Now map the keys to the result objects. Also remove any duplicates.
|
||||
return uniq(matches.map((match) => match.object));
|
||||
const dedupped = uniq(matches.map((match) => match.object));
|
||||
const maxLength = limit === -1 ? dedupped.length : limit;
|
||||
|
||||
return dedupped.slice(0, maxLength);
|
||||
}
|
||||
|
||||
private processQuery(query: string): string {
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
/*
|
||||
Copyright 2016 Aviral Dasgupta
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2017, 2018, 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.
|
||||
|
@ -17,17 +16,19 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
import {uniqBy, sortBy} from "lodash";
|
||||
import Room from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { _t } from '../languageHandler';
|
||||
import AutocompleteProvider from './AutocompleteProvider';
|
||||
import {MatrixClientPeg} from '../MatrixClientPeg';
|
||||
import QueryMatcher from './QueryMatcher';
|
||||
import {PillCompletion} from './Components';
|
||||
import * as sdk from '../index';
|
||||
import {makeRoomPermalink} from "../utils/permalinks/Permalinks";
|
||||
import {ICompletion, ISelectionRange} from "./Autocompleter";
|
||||
import {uniqBy, sortBy} from "lodash";
|
||||
import RoomAvatar from '../components/views/avatars/RoomAvatar';
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
|
||||
const ROOM_REGEX = /\B#\S*/g;
|
||||
|
||||
|
@ -49,7 +50,7 @@ function matcherObject(room: Room, displayedAlias: string, matchName = "") {
|
|||
}
|
||||
|
||||
export default class RoomProvider extends AutocompleteProvider {
|
||||
matcher: QueryMatcher<Room>;
|
||||
protected matcher: QueryMatcher<Room>;
|
||||
|
||||
constructor() {
|
||||
super(ROOM_REGEX);
|
||||
|
@ -58,15 +59,28 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
});
|
||||
}
|
||||
|
||||
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
|
||||
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
|
||||
protected getRooms() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
let rooms = cli.getVisibleRooms();
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
rooms = rooms.filter(r => !r.isSpaceRoom());
|
||||
}
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
async getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
let completions = [];
|
||||
const {command, range} = this.getCurrentCommand(query, selection, force);
|
||||
if (command) {
|
||||
// the only reason we need to do this is because Fuse only matches on properties
|
||||
let matcherObjects = client.getVisibleRooms().reduce((aliases, room) => {
|
||||
let matcherObjects = this.getRooms().reduce((aliases, room) => {
|
||||
if (room.getCanonicalAlias()) {
|
||||
aliases = aliases.concat(matcherObject(room, room.getCanonicalAlias(), room.name));
|
||||
}
|
||||
|
@ -90,7 +104,7 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
|
||||
this.matcher.setObjects(matcherObjects);
|
||||
const matchedString = command[0];
|
||||
completions = this.matcher.match(matchedString);
|
||||
completions = this.matcher.match(matchedString, limit);
|
||||
completions = sortBy(completions, [
|
||||
(c) => score(matchedString, c.displayedAlias),
|
||||
(c) => c.displayedAlias.length,
|
||||
|
@ -110,7 +124,7 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
),
|
||||
range,
|
||||
};
|
||||
}).filter((completion) => !!completion.completion && completion.completion.length > 0).slice(0, 4);
|
||||
}).filter((completion) => !!completion.completion && completion.completion.length > 0);
|
||||
}
|
||||
return completions;
|
||||
}
|
||||
|
|
43
src/autocomplete/SpaceProvider.tsx
Normal file
43
src/autocomplete/SpaceProvider.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
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 { _t } from '../languageHandler';
|
||||
import {MatrixClientPeg} from '../MatrixClientPeg';
|
||||
import RoomProvider from "./RoomProvider";
|
||||
|
||||
export default class SpaceProvider extends RoomProvider {
|
||||
protected getRooms() {
|
||||
return MatrixClientPeg.get().getVisibleRooms().filter(r => r.isSpaceRoom());
|
||||
}
|
||||
|
||||
getName() {
|
||||
return _t("Spaces");
|
||||
}
|
||||
|
||||
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
aria-label={_t("Space Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -102,7 +102,12 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
this.users = null;
|
||||
};
|
||||
|
||||
async getCompletions(rawQuery: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
|
||||
async getCompletions(
|
||||
rawQuery: string,
|
||||
selection: ISelectionRange,
|
||||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
|
||||
|
||||
// lazy-load user list into matcher
|
||||
|
@ -118,7 +123,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
if (fullMatch && fullMatch !== '@') {
|
||||
// Don't include the '@' in our search query - it's only used as a way to trigger completion
|
||||
const query = fullMatch.startsWith('@') ? fullMatch.substring(1) : fullMatch;
|
||||
completions = this.matcher.match(query).map((user) => {
|
||||
completions = this.matcher.match(query, limit).map((user) => {
|
||||
const displayName = (user.name || user.userId || '');
|
||||
return {
|
||||
// Length of completion should equal length of text in decorator. draft-js
|
||||
|
|
|
@ -222,10 +222,12 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
// don't let keyboard handling escape the context menu
|
||||
ev.stopPropagation();
|
||||
|
||||
if (!this.props.managed) {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
this.props.onFinished();
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
return;
|
||||
|
@ -258,7 +260,6 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
|
||||
if (handled) {
|
||||
// consume all other keys in context menu
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -123,12 +123,19 @@ class GroupFilterPanel extends React.Component {
|
|||
mx_GroupFilterPanel_items_selected: itemsSelected,
|
||||
});
|
||||
|
||||
let betaDot;
|
||||
if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) {
|
||||
betaDot = <div className="mx_BetaDot" />;
|
||||
}
|
||||
|
||||
let createButton = (
|
||||
<ActionButton
|
||||
tooltip
|
||||
label={_t("Communities")}
|
||||
action="toggle_my_groups"
|
||||
className="mx_TagTile mx_TagTile_plus" />
|
||||
className="mx_TagTile mx_TagTile_plus">
|
||||
{ betaDot }
|
||||
</ActionButton>
|
||||
);
|
||||
|
||||
if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
|
||||
|
|
|
@ -27,7 +27,7 @@ import CallMediaHandler from '../../CallMediaHandler';
|
|||
import { fixupColorFonts } from '../../utils/FontManager';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg';
|
||||
import { IMatrixClientCreds } from '../../MatrixClientPeg';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
import TagOrderActions from '../../actions/TagOrderActions';
|
||||
|
@ -219,16 +219,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
// Child components assume that the client peg will not be null, so give them some
|
||||
// sort of assurance here by only allowing a re-render if the client is truthy.
|
||||
//
|
||||
// This is required because `LoggedInView` maintains its own state and if this state
|
||||
// updates after the client peg has been made null (during logout), then it will
|
||||
// attempt to re-render and the children will throw errors.
|
||||
shouldComponentUpdate() {
|
||||
return Boolean(MatrixClientPeg.get());
|
||||
}
|
||||
|
||||
canResetTimelineInRoom = (roomId) => {
|
||||
if (!this._roomView.current) {
|
||||
return true;
|
||||
|
|
|
@ -740,6 +740,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.showScreenAfterLogin();
|
||||
break;
|
||||
case 'toggle_my_groups':
|
||||
// persist that the user has interacted with this, use it to dismiss the beta dot
|
||||
localStorage.setItem("mx_seenSpacesBeta", "1");
|
||||
// We just dispatch the page change rather than have to worry about
|
||||
// what the logic is for each of these branches.
|
||||
if (this.state.page_type === PageTypes.MyGroups) {
|
||||
|
@ -906,6 +908,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
let presentedId = roomInfo.room_alias || roomInfo.room_id;
|
||||
const room = MatrixClientPeg.get().getRoom(roomInfo.room_id);
|
||||
if (room) {
|
||||
// Not all timeline events are decrypted ahead of time anymore
|
||||
// Only the critical ones for a typical UI are
|
||||
// This will start the decryption process for all events when a
|
||||
// user views a room
|
||||
room.decryptAllEvents();
|
||||
const theAlias = Rooms.getDisplayAliasForRoom(room);
|
||||
if (theAlias) {
|
||||
presentedId = theAlias;
|
||||
|
@ -1684,6 +1691,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const type = screen === "start_sso" ? "sso" : "cas";
|
||||
PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin());
|
||||
} else if (screen === 'groups') {
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
dis.dispatch({
|
||||
action: 'view_my_groups',
|
||||
});
|
||||
|
@ -1767,6 +1778,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
subAction: params.action,
|
||||
});
|
||||
} else if (screen.indexOf('group/') === 0) {
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
|
||||
const groupId = screen.substring(6);
|
||||
|
||||
// TODO: Check valid group ID
|
||||
|
|
|
@ -34,6 +34,7 @@ import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResiz
|
|||
import DMRoomMap from "../../utils/DMRoomMap";
|
||||
import NewRoomIntro from "../views/rooms/NewRoomIntro";
|
||||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
import defaultDispatcher from '../../dispatcher/dispatcher';
|
||||
|
||||
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const continuedTypes = ['m.sticker', 'm.room.message'];
|
||||
|
@ -471,6 +472,10 @@ export default class MessagePanel extends React.Component {
|
|||
return {nextEvent, nextTile};
|
||||
}
|
||||
|
||||
get _roomHasPendingEdit() {
|
||||
return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`);
|
||||
}
|
||||
|
||||
_getEventTiles() {
|
||||
this.eventNodes = {};
|
||||
|
||||
|
@ -559,6 +564,13 @@ export default class MessagePanel extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
if (!this.props.editState && this._roomHasPendingEdit) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "edit_event",
|
||||
event: this.props.room.findEventById(this._roomHasPendingEdit),
|
||||
});
|
||||
}
|
||||
|
||||
if (grouper) {
|
||||
ret.push(...grouper.getTiles());
|
||||
}
|
||||
|
@ -574,7 +586,6 @@ export default class MessagePanel extends React.Component {
|
|||
|
||||
const isEditing = this.props.editState &&
|
||||
this.props.editState.getEvent().getId() === mxEv.getId();
|
||||
|
||||
// local echoes have a fake date, which could even be yesterday. Treat them
|
||||
// as 'today' for the date separators.
|
||||
let ts1 = mxEv.getTs();
|
||||
|
|
|
@ -25,6 +25,7 @@ import AccessibleButton from '../views/elements/AccessibleButton';
|
|||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
import BetaCard from "../views/beta/BetaCard";
|
||||
|
||||
@replaceableComponent("structures.MyGroups")
|
||||
export default class MyGroups extends React.Component {
|
||||
|
@ -139,6 +140,7 @@ export default class MyGroups extends React.Component {
|
|||
</div>
|
||||
</div>*/}
|
||||
</div>
|
||||
<BetaCard featureId="feature_spaces" title={_t("Communities are changing to Spaces")} />
|
||||
<div className="mx_MyGroups_content">
|
||||
{ contentHeader }
|
||||
{ content }
|
||||
|
|
|
@ -1917,7 +1917,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
);
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("feature_spaces") && this.state.room?.isSpaceRoom()) {
|
||||
if (this.state.room?.isSpaceRoom()) {
|
||||
return <SpaceRoomView
|
||||
space={this.state.room}
|
||||
justCreatedOpts={this.props.justCreatedOpts}
|
||||
|
|
|
@ -41,6 +41,7 @@ import TextWithTooltip from "../views/elements/TextWithTooltip";
|
|||
import {useStateToggle} from "../../hooks/useStateToggle";
|
||||
import {getOrder} from "../../stores/SpaceStore";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import {linkifyElement} from "../../HtmlUtils";
|
||||
|
||||
interface IHierarchyProps {
|
||||
space: Room;
|
||||
|
@ -172,7 +173,16 @@ const Tile: React.FC<ITileProps> = ({
|
|||
{ suggestedSection }
|
||||
</div>
|
||||
|
||||
<div className="mx_SpaceRoomDirectory_roomTile_info">
|
||||
<div
|
||||
className="mx_SpaceRoomDirectory_roomTile_info"
|
||||
ref={e => e && linkifyElement(e)}
|
||||
onClick={ev => {
|
||||
// prevent clicks on links from bubbling up to the room tile
|
||||
if ((ev.target as HTMLElement).tagName === "A") {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ description }
|
||||
</div>
|
||||
<div className="mx_SpaceRoomDirectory_actions">
|
||||
|
@ -574,7 +584,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
return <>
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={ _t("Search names and description") }
|
||||
placeholder={ _t("Search names and descriptions") }
|
||||
onSearch={setQuery}
|
||||
autoFocus={true}
|
||||
initialValue={initialText}
|
||||
|
|
|
@ -52,14 +52,19 @@ import {useStateToggle} from "../../hooks/useStateToggle";
|
|||
import SpaceStore from "../../stores/SpaceStore";
|
||||
import FacePile from "../views/elements/FacePile";
|
||||
import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import {sleep} from "../../utils/promise";
|
||||
import {calculateRoomVia} from "../../utils/permalinks/Permalinks";
|
||||
import {ChevronFace, ContextMenuButton, useContextMenu} from "./ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../views/context_menus/IconizedContextMenu";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import {BetaPill} from "../views/beta/BetaCard";
|
||||
import {USER_LABS_TAB} from "../views/dialogs/UserSettingsDialog";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import Modal from "../../Modal";
|
||||
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
|
@ -71,6 +76,7 @@ interface IProps {
|
|||
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
createdRooms?: boolean; // internal state for the creation wizard
|
||||
showRightPanel: boolean;
|
||||
myMembership: string;
|
||||
}
|
||||
|
@ -85,6 +91,26 @@ enum Phase {
|
|||
PrivateExistingRooms,
|
||||
}
|
||||
|
||||
// XXX: Temporary for the Spaces Beta only
|
||||
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
|
||||
if (!SdkConfig.get().bug_report_endpoint_url) return null;
|
||||
|
||||
return <div className="mx_SpaceFeedbackPrompt">
|
||||
<hr />
|
||||
<div>
|
||||
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
|
||||
<AccessibleButton kind="link" onClick={() => {
|
||||
if (onClick) onClick();
|
||||
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
|
||||
featureId: "feature_spaces",
|
||||
});
|
||||
}}>
|
||||
{ _t("Feedback") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const RoomMemberCount = ({ room, children }) => {
|
||||
const members = useRoomMembers(room);
|
||||
const count = members.length;
|
||||
|
@ -136,15 +162,39 @@ const SpaceInfo = ({ space }) => {
|
|||
</div>
|
||||
};
|
||||
|
||||
const onBetaClick = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: USER_LABS_TAB,
|
||||
});
|
||||
};
|
||||
|
||||
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const myMembership = useMyRoomMembership(space);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const spacesEnabled = SettingsStore.getValue("feature_spaces");
|
||||
|
||||
let inviterSection;
|
||||
let joinButtons;
|
||||
if (myMembership === "invite") {
|
||||
if (myMembership === "join") {
|
||||
// XXX remove this when spaces leaves Beta
|
||||
joinButtons = (
|
||||
<AccessibleButton
|
||||
kind="danger_outline"
|
||||
onClick={() => {
|
||||
dis.dispatch({
|
||||
action: "leave_room",
|
||||
room_id: space.roomId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{ _t("Leave") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else if (myMembership === "invite") {
|
||||
const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender();
|
||||
const inviter = inviteSender && space.getMember(inviteSender);
|
||||
|
||||
|
@ -180,6 +230,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
setBusy(true);
|
||||
onJoinButtonClicked();
|
||||
}}
|
||||
disabled={!spacesEnabled}
|
||||
>
|
||||
{ _t("Accept") }
|
||||
</AccessibleButton>
|
||||
|
@ -192,10 +243,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
setBusy(true);
|
||||
onJoinButtonClicked();
|
||||
}}
|
||||
disabled={!spacesEnabled}
|
||||
>
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
|
@ -203,6 +255,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
}
|
||||
|
||||
return <div className="mx_SpaceRoomView_preview">
|
||||
<BetaPill onClick={onBetaClick} />
|
||||
{ inviterSection }
|
||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||
<h1 className="mx_SpaceRoomView_preview_name">
|
||||
|
@ -220,6 +273,20 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
<div className="mx_SpaceRoomView_preview_joinButtons">
|
||||
{ joinButtons }
|
||||
</div>
|
||||
{ !spacesEnabled && <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
|
||||
{ myMembership === "join"
|
||||
? _t("To view %(spaceName)s, turn on the <a>Spaces beta</a>", {
|
||||
spaceName: space.name,
|
||||
}, {
|
||||
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
|
||||
})
|
||||
: _t("To join %(spaceName)s, turn on the <a>Spaces beta</a>", {
|
||||
spaceName: space.name,
|
||||
}, {
|
||||
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
|
||||
})
|
||||
}
|
||||
</div> }
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -353,6 +420,7 @@ const SpaceLanding = ({ space }) => {
|
|||
<div className="mx_SpaceRoomView_landing_topic">
|
||||
<RoomTopic room={space} />
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
<hr />
|
||||
|
||||
<SpaceHierarchy
|
||||
|
@ -382,14 +450,18 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
value={roomNames[i]}
|
||||
onChange={ev => setRoomName(i, ev.target.value)}
|
||||
autoFocus={i === 2}
|
||||
disabled={busy}
|
||||
/>;
|
||||
});
|
||||
|
||||
const onNextClick = async () => {
|
||||
const onNextClick = async (ev) => {
|
||||
ev.preventDefault();
|
||||
if (busy) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => {
|
||||
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,
|
||||
|
@ -402,7 +474,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
parentSpace: space,
|
||||
});
|
||||
}));
|
||||
onFinished();
|
||||
onFinished(filteredRoomNames.length > 0);
|
||||
} catch (e) {
|
||||
console.error("Failed to create initial space rooms", e);
|
||||
setError(_t("Failed to create initial space rooms"));
|
||||
|
@ -410,7 +482,10 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
setBusy(false);
|
||||
};
|
||||
|
||||
let onClick = onFinished;
|
||||
let onClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
onFinished(false);
|
||||
};
|
||||
let buttonLabel = _t("Skip for now");
|
||||
if (roomNames.some(name => name.trim())) {
|
||||
onClick = onNextClick;
|
||||
|
@ -422,54 +497,26 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
<div className="mx_SpaceRoomView_description">{ description }</div>
|
||||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
{ fields }
|
||||
<form onSubmit={onClick} id="mx_SpaceSetupFirstRooms">
|
||||
{ fields }
|
||||
</form>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
>
|
||||
{ buttonLabel }
|
||||
</AccessibleButton>
|
||||
element="input"
|
||||
type="submit"
|
||||
form="mx_SpaceSetupFirstRooms"
|
||||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceAddExistingRooms = ({ space, onFinished }) => {
|
||||
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
let onClick = onFinished;
|
||||
let buttonLabel = _t("Skip for now");
|
||||
if (selectedToAdd.size > 0) {
|
||||
onClick = async () => {
|
||||
setBusy(true);
|
||||
|
||||
for (const room of selectedToAdd) {
|
||||
const via = calculateRoomVia(room);
|
||||
try {
|
||||
await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => {
|
||||
if (e.errcode === "M_LIMIT_EXCEEDED") {
|
||||
await sleep(e.data.retry_after_ms);
|
||||
return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry
|
||||
}
|
||||
|
||||
throw e;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to add rooms to space", e);
|
||||
setError(_t("Failed to add rooms to space"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
buttonLabel = busy ? _t("Adding...") : _t("Add");
|
||||
}
|
||||
|
||||
return <div>
|
||||
<h1>{ _t("What do you want to organise?") }</h1>
|
||||
<div className="mx_SpaceRoomView_description">
|
||||
|
@ -477,36 +524,28 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
|
|||
"no one will be informed. You can add more later.") }
|
||||
</div>
|
||||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
|
||||
<AddExistingToSpace
|
||||
space={space}
|
||||
selected={selectedToAdd}
|
||||
onChange={(checked, room) => {
|
||||
if (checked) {
|
||||
selectedToAdd.add(room);
|
||||
} else {
|
||||
selectedToAdd.delete(room);
|
||||
}
|
||||
setSelectedToAdd(new Set(selectedToAdd));
|
||||
}}
|
||||
emptySelectionButton={
|
||||
<AccessibleButton kind="primary" onClick={onFinished}>
|
||||
{ _t("Skip for now") }
|
||||
</AccessibleButton>
|
||||
}
|
||||
onFinished={onFinished}
|
||||
/>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
>
|
||||
{ buttonLabel }
|
||||
</AccessibleButton>
|
||||
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupPublicShare = ({ space, onFinished }) => {
|
||||
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => {
|
||||
return <div className="mx_SpaceRoomView_publicShare">
|
||||
<h1>{ _t("Share %(name)s", { name: space.name }) }</h1>
|
||||
<h1>{ _t("Share %(name)s", {
|
||||
name: justCreatedOpts?.createOpts?.name || space.name,
|
||||
}) }</h1>
|
||||
<div className="mx_SpaceRoomView_description">
|
||||
{ _t("It's just you at the moment, it will be even better with others.") }
|
||||
</div>
|
||||
|
@ -515,17 +554,20 @@ const SpaceSetupPublicShare = ({ space, onFinished }) => {
|
|||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton kind="primary" onClick={onFinished}>
|
||||
{ _t("Go to my first room") }
|
||||
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupPrivateScope = ({ space, onFinished }) => {
|
||||
const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
|
||||
return <div className="mx_SpaceRoomView_privateScope">
|
||||
<h1>{ _t("Who are you working with?") }</h1>
|
||||
<div className="mx_SpaceRoomView_description">
|
||||
{ _t("Make sure the right people have access to %(name)s", { name: space.name }) }
|
||||
{ _t("Make sure the right people have access to %(name)s", {
|
||||
name: justCreatedOpts?.createOpts?.name || space.name,
|
||||
}) }
|
||||
</div>
|
||||
|
||||
<AccessibleButton
|
||||
|
@ -542,6 +584,7 @@ const SpaceSetupPrivateScope = ({ space, onFinished }) => {
|
|||
<h3>{ _t("Me and my teammates") }</h3>
|
||||
<div>{ _t("A private space for you and your teammates") }</div>
|
||||
</AccessibleButton>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -572,10 +615,13 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
ref={fieldRefs[i]}
|
||||
onValidate={validateEmailRules}
|
||||
autoFocus={i === 0}
|
||||
disabled={busy}
|
||||
/>;
|
||||
});
|
||||
|
||||
const onNextClick = async () => {
|
||||
const onNextClick = async (ev) => {
|
||||
ev.preventDefault();
|
||||
if (busy) return;
|
||||
setError("");
|
||||
for (let i = 0; i < fieldRefs.length; i++) {
|
||||
const fieldRef = fieldRefs[i];
|
||||
|
@ -609,7 +655,10 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
setBusy(false);
|
||||
};
|
||||
|
||||
let onClick = onFinished;
|
||||
let onClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
onFinished();
|
||||
};
|
||||
let buttonLabel = _t("Skip for now");
|
||||
if (emailAddresses.some(name => name.trim())) {
|
||||
onClick = onNextClick;
|
||||
|
@ -622,8 +671,21 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
{ _t("Make sure the right people have access. You can invite more later.") }
|
||||
</div>
|
||||
|
||||
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
|
||||
<BetaPill onClick={onBetaClick} />
|
||||
{ _t("<b>This is an experimental feature.</b> For now, " +
|
||||
"new users receiving an invite will have to open the invite on <link/> to actually join.", {}, {
|
||||
b: sub => <b>{ sub }</b>,
|
||||
link: () => <a href="https://app.element.io/" rel="noreferrer noopener" target="_blank">
|
||||
app.element.io
|
||||
</a>,
|
||||
}) }
|
||||
</div>
|
||||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
{ fields }
|
||||
<form onSubmit={onClick} id="mx_SpaceSetupPrivateInvite">
|
||||
{ fields }
|
||||
</form>
|
||||
|
||||
<div className="mx_SpaceRoomView_inviteTeammates_buttons">
|
||||
<AccessibleButton
|
||||
|
@ -635,10 +697,17 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
</div>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton kind="primary" disabled={busy} onClick={onClick}>
|
||||
{ buttonLabel }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
element="input"
|
||||
type="submit"
|
||||
form="mx_SpaceSetupPrivateInvite"
|
||||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -759,7 +828,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
private renderBody() {
|
||||
switch (this.state.phase) {
|
||||
case Phase.Landing:
|
||||
if (this.state.myMembership === "join") {
|
||||
if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) {
|
||||
return <SpaceLanding space={this.props.space} />;
|
||||
} else {
|
||||
return <SpacePreview
|
||||
|
@ -772,20 +841,26 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
return <SpaceSetupFirstRooms
|
||||
space={this.props.space}
|
||||
title={_t("What are some things you want to discuss in %(spaceName)s?", {
|
||||
spaceName: this.props.space.name,
|
||||
spaceName: this.props.justCreatedOpts?.createOpts?.name || this.props.space.name,
|
||||
})}
|
||||
description={
|
||||
_t("Let's create a room for each of them.") + "\n" +
|
||||
_t("You can add more later too, including already existing ones.")
|
||||
}
|
||||
onFinished={() => this.setState({ phase: Phase.PublicShare })}
|
||||
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })}
|
||||
/>;
|
||||
case Phase.PublicShare:
|
||||
return <SpaceSetupPublicShare space={this.props.space} onFinished={this.goToFirstRoom} />;
|
||||
return <SpaceSetupPublicShare
|
||||
justCreatedOpts={this.props.justCreatedOpts}
|
||||
space={this.props.space}
|
||||
onFinished={this.goToFirstRoom}
|
||||
createdRooms={this.state.createdRooms}
|
||||
/>;
|
||||
|
||||
case Phase.PrivateScope:
|
||||
return <SpaceSetupPrivateScope
|
||||
space={this.props.space}
|
||||
justCreatedOpts={this.props.justCreatedOpts}
|
||||
onFinished={(invite: boolean) => {
|
||||
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms });
|
||||
}}
|
||||
|
@ -801,7 +876,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
title={_t("What projects are you working on?")}
|
||||
description={_t("We'll create rooms for each of them. " +
|
||||
"You can add more later too, including already existing ones.")}
|
||||
onFinished={() => this.setState({ phase: Phase.Landing })}
|
||||
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })}
|
||||
/>;
|
||||
case Phase.PrivateExistingRooms:
|
||||
return <SpaceAddExistingRooms
|
||||
|
|
|
@ -38,6 +38,7 @@ import {haveTileForEvent} from "../views/rooms/EventTile";
|
|||
import {UIFeature} from "../../settings/UIFeature";
|
||||
import {objectHasDiff} from "../../utils/objects";
|
||||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
import { arrayFastClone } from "../../utils/arrays";
|
||||
|
||||
const PAGINATE_SIZE = 20;
|
||||
const INITIAL_SIZE = 20;
|
||||
|
@ -1141,6 +1142,18 @@ class TimelinePanel extends React.Component {
|
|||
// get the list of events from the timeline window and the pending event list
|
||||
_getEvents() {
|
||||
const events = this._timelineWindow.getEvents();
|
||||
|
||||
// `arrayFastClone` performs a shallow copy of the array
|
||||
// we want the last event to be decrypted first but displayed last
|
||||
// `reverse` is destructive and unfortunately mutates the "events" array
|
||||
arrayFastClone(events)
|
||||
.reverse()
|
||||
.forEach(event => {
|
||||
if (event.shouldAttemptDecryption()) {
|
||||
event.attemptDecryption(MatrixClientPeg.get()._crypto);
|
||||
}
|
||||
});
|
||||
|
||||
const firstVisibleEventIndex = this._checkForPreJoinUISI(events);
|
||||
|
||||
// Hold onto the live events separately. The read receipt and read marker
|
||||
|
|
|
@ -179,7 +179,7 @@ const BaseAvatar = (props: IProps) => {
|
|||
width: toPx(width),
|
||||
height: toPx(height),
|
||||
}}
|
||||
title={title} alt=""
|
||||
title={title} alt={_t("Avatar")}
|
||||
inputRef={inputRef}
|
||||
{...otherProps} />
|
||||
);
|
||||
|
|
|
@ -20,7 +20,6 @@ import { Room } from "matrix-js-sdk/src/models/room";
|
|||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import { TagID } from '../../../stores/room-list/models';
|
||||
import RoomAvatar from "./RoomAvatar";
|
||||
import NotificationBadge from '../rooms/NotificationBadge';
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
|
@ -35,7 +34,6 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
|
|||
interface IProps {
|
||||
room: Room;
|
||||
avatarSize: number;
|
||||
tag: TagID;
|
||||
displayBadge?: boolean;
|
||||
forceCount?: boolean;
|
||||
oobData?: object;
|
||||
|
|
108
src/components/views/beta/BetaCard.tsx
Normal file
108
src/components/views/beta/BetaCard.tsx
Normal file
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
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 classNames from "classnames";
|
||||
|
||||
import {_t} from "../../../languageHandler";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {SettingLevel} from "../../../settings/SettingLevel";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
import Modal from "../../../Modal";
|
||||
import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
|
||||
interface IProps {
|
||||
title?: string;
|
||||
featureId: string;
|
||||
}
|
||||
|
||||
export const BetaPill = ({ onClick }: { onClick?: () => void }) => {
|
||||
if (onClick) {
|
||||
return <TextWithTooltip
|
||||
class={classNames("mx_BetaCard_betaPill", {
|
||||
mx_BetaCard_betaPill_clickable: !!onClick,
|
||||
})}
|
||||
tooltip={<div>
|
||||
<div className="mx_Tooltip_title">
|
||||
{ _t("Spaces is a beta feature") }
|
||||
</div>
|
||||
<div className="mx_Tooltip_sub">
|
||||
{ _t("Tap for more info") }
|
||||
</div>
|
||||
</div>}
|
||||
onClick={onClick}
|
||||
tooltipProps={{ yOffset: -10 }}
|
||||
>
|
||||
{ _t("Beta") }
|
||||
</TextWithTooltip>;
|
||||
}
|
||||
|
||||
return <span
|
||||
className={classNames("mx_BetaCard_betaPill", {
|
||||
mx_BetaCard_betaPill_clickable: !!onClick,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
{ _t("Beta") }
|
||||
</span>;
|
||||
};
|
||||
|
||||
const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
|
||||
const info = SettingsStore.getBetaInfo(featureId);
|
||||
if (!info) return null; // Beta is invalid/disabled
|
||||
|
||||
const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading } = info;
|
||||
const value = SettingsStore.getValue(featureId);
|
||||
|
||||
let feedbackButton;
|
||||
if (value && feedbackLabel && feedbackSubheading && SdkConfig.get().bug_report_endpoint_url) {
|
||||
feedbackButton = <AccessibleButton
|
||||
onClick={() => {
|
||||
Modal.createTrackedDialog("Beta Feedback", featureId, BetaFeedbackDialog, { featureId });
|
||||
}}
|
||||
kind="primary"
|
||||
>
|
||||
{ _t("Feedback") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
return <div className="mx_BetaCard">
|
||||
<div>
|
||||
<h3 className="mx_BetaCard_title">
|
||||
{ titleOverride || _t(title) }
|
||||
<BetaPill />
|
||||
</h3>
|
||||
<span className="mx_BetaCard_caption">{ _t(caption) }</span>
|
||||
<div>
|
||||
{ feedbackButton }
|
||||
<AccessibleButton
|
||||
onClick={() => SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)}
|
||||
kind={feedbackButton ? "primary_outline" : "primary"}
|
||||
>
|
||||
{ value ? _t("Leave the beta") : _t("Join the beta") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
{ disclaimer && <div className="mx_BetaCard_disclaimer">
|
||||
{ disclaimer(value) }
|
||||
</div> }
|
||||
</div>
|
||||
<img src={image} alt="" />
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default BetaCard;
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useContext, useMemo, useState} from "react";
|
||||
import React, {ReactNode, useContext, useMemo, useState} from "react";
|
||||
import classNames from "classnames";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
|
@ -36,6 +36,8 @@ import StyledCheckbox from "../elements/StyledCheckbox";
|
|||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
|
||||
import ProgressBar from "../elements/ProgressBar";
|
||||
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
matrixClient: MatrixClient;
|
||||
|
@ -45,7 +47,10 @@ interface IProps extends IDialogProps {
|
|||
|
||||
const Entry = ({ room, checked, onChange }) => {
|
||||
return <label className="mx_AddExistingToSpace_entry">
|
||||
<RoomAvatar room={room} height={32} width={32} />
|
||||
{ room?.isSpaceRoom()
|
||||
? <RoomAvatar room={room} height={32} width={32} />
|
||||
: <DecoratedRoomAvatar room={room} avatarSize={32} />
|
||||
}
|
||||
<span className="mx_AddExistingToSpace_entry_name">{ room.name }</span>
|
||||
<StyledCheckbox
|
||||
onChange={onChange ? (e) => onChange(e.target.checked) : null}
|
||||
|
@ -57,14 +62,23 @@ const Entry = ({ room, checked, onChange }) => {
|
|||
|
||||
interface IAddExistingToSpaceProps {
|
||||
space: Room;
|
||||
selected: Set<Room>;
|
||||
onChange(checked: boolean, room: Room): void;
|
||||
footerPrompt?: ReactNode;
|
||||
emptySelectionButton?: ReactNode;
|
||||
onFinished(added: boolean): void;
|
||||
}
|
||||
|
||||
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space, selected, onChange }) => {
|
||||
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
||||
space,
|
||||
footerPrompt,
|
||||
emptySelectionButton,
|
||||
onFinished,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const visibleRooms = useMemo(() => sortRooms(cli.getVisibleRooms()), [cli]);
|
||||
|
||||
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
|
||||
const [progress, setProgress] = useState<number>(null);
|
||||
const [error, setError] = useState<Error>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const lcQuery = query.toLowerCase();
|
||||
|
||||
|
@ -92,116 +106,6 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space,
|
|||
return arr;
|
||||
}, [[], [], []]);
|
||||
|
||||
return <div className="mx_AddExistingToSpace">
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={ _t("Filter your rooms and spaces") }
|
||||
onSearch={setQuery}
|
||||
autoComplete={true}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<AutoHideScrollbar className="mx_AddExistingToSpace_content" id="mx_AddExistingToSpace">
|
||||
{ rooms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Rooms") }</h3>
|
||||
{ rooms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selected.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : undefined }
|
||||
|
||||
{ spaces.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
|
||||
<h3>{ _t("Spaces") }</h3>
|
||||
{ spaces.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
checked={selected.has(space)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, space);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : null }
|
||||
|
||||
{ dms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Direct Messages") }</h3>
|
||||
{ dms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selected.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : null }
|
||||
|
||||
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
|
||||
{ _t("No results") }
|
||||
</span> : undefined }
|
||||
</AutoHideScrollbar>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
|
||||
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
|
||||
|
||||
const [progress, setProgress] = useState<number>(null);
|
||||
const [error, setError] = useState<Error>(null);
|
||||
|
||||
let spaceOptionSection;
|
||||
if (existingSubspaces.length > 0) {
|
||||
const options = [space, ...existingSubspaces].map((space) => {
|
||||
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
|
||||
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
|
||||
});
|
||||
return <div key={space.roomId} className={classes}>
|
||||
<RoomAvatar room={space} width={24} height={24} />
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
});
|
||||
|
||||
spaceOptionSection = (
|
||||
<Dropdown
|
||||
id="mx_SpaceSelectDropdown"
|
||||
onOptionChange={(key: string) => {
|
||||
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
|
||||
}}
|
||||
value={selectedSpace.roomId}
|
||||
label={_t("Space selection")}
|
||||
>
|
||||
{ options }
|
||||
</Dropdown>
|
||||
);
|
||||
} else {
|
||||
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
}
|
||||
|
||||
const title = <React.Fragment>
|
||||
<RoomAvatar room={selectedSpace} height={40} width={40} />
|
||||
<div>
|
||||
<h1>{ _t("Add existing rooms") }</h1>
|
||||
{ spaceOptionSection }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
const addRooms = async () => {
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
|
@ -264,20 +168,145 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
|
|||
</div>
|
||||
</span>;
|
||||
} else {
|
||||
let button = emptySelectionButton;
|
||||
if (!button || selectedToAdd.size > 0) {
|
||||
button = <AccessibleButton kind="primary" disabled={selectedToAdd.size < 1} onClick={addRooms}>
|
||||
{ _t("Add") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
footer = <>
|
||||
<span>
|
||||
<div>{ _t("Want to add a new room instead?") }</div>
|
||||
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
|
||||
{ _t("Create a new room") }
|
||||
</AccessibleButton>
|
||||
{ footerPrompt }
|
||||
</span>
|
||||
|
||||
<AccessibleButton kind="primary" disabled={selectedToAdd.size < 1} onClick={addRooms}>
|
||||
{ _t("Add") }
|
||||
</AccessibleButton>
|
||||
{ button }
|
||||
</>;
|
||||
}
|
||||
|
||||
const onChange = !busy && !error ? (checked, room) => {
|
||||
if (checked) {
|
||||
selectedToAdd.add(room);
|
||||
} else {
|
||||
selectedToAdd.delete(room);
|
||||
}
|
||||
setSelectedToAdd(new Set(selectedToAdd));
|
||||
} : null;
|
||||
|
||||
return <div className="mx_AddExistingToSpace">
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={ _t("Filter your rooms and spaces") }
|
||||
onSearch={setQuery}
|
||||
autoComplete={true}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<AutoHideScrollbar className="mx_AddExistingToSpace_content" id="mx_AddExistingToSpace">
|
||||
{ rooms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Rooms") }</h3>
|
||||
{ rooms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : undefined }
|
||||
|
||||
{ spaces.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
|
||||
<h3>{ _t("Spaces") }</h3>
|
||||
<div className="mx_AddExistingToSpace_section_experimental">
|
||||
<div>{ _t("Feeling experimental?") }</div>
|
||||
<div>{ _t("You can add existing spaces to a space.") }</div>
|
||||
</div>
|
||||
{ spaces.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
checked={selectedToAdd.has(space)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, space);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : null }
|
||||
|
||||
{ dms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Direct Messages") }</h3>
|
||||
{ dms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : null }
|
||||
|
||||
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
|
||||
{ _t("No results") }
|
||||
</span> : undefined }
|
||||
</AutoHideScrollbar>
|
||||
|
||||
<div className="mx_AddExistingToSpace_footer">
|
||||
{ footer }
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
|
||||
|
||||
let spaceOptionSection;
|
||||
if (existingSubspaces.length > 0) {
|
||||
const options = [space, ...existingSubspaces].map((space) => {
|
||||
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
|
||||
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
|
||||
});
|
||||
return <div key={space.roomId} className={classes}>
|
||||
<RoomAvatar room={space} width={24} height={24} />
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
});
|
||||
|
||||
spaceOptionSection = (
|
||||
<Dropdown
|
||||
id="mx_SpaceSelectDropdown"
|
||||
onOptionChange={(key: string) => {
|
||||
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
|
||||
}}
|
||||
value={selectedSpace.roomId}
|
||||
label={_t("Space selection")}
|
||||
>
|
||||
{ options }
|
||||
</Dropdown>
|
||||
);
|
||||
} else {
|
||||
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
}
|
||||
|
||||
const title = <React.Fragment>
|
||||
<RoomAvatar room={selectedSpace} height={40} width={40} />
|
||||
<div>
|
||||
<h1>{ _t("Add existing rooms") }</h1>
|
||||
{ spaceOptionSection }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
return <BaseDialog
|
||||
title={title}
|
||||
className="mx_AddExistingToSpaceDialog"
|
||||
|
@ -288,21 +317,17 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
|
|||
<MatrixClientContext.Provider value={cli}>
|
||||
<AddExistingToSpace
|
||||
space={space}
|
||||
selected={selectedToAdd}
|
||||
onChange={!busy && !error ? (checked, room) => {
|
||||
if (checked) {
|
||||
selectedToAdd.add(room);
|
||||
} else {
|
||||
selectedToAdd.delete(room);
|
||||
}
|
||||
setSelectedToAdd(new Set(selectedToAdd));
|
||||
} : null}
|
||||
onFinished={onFinished}
|
||||
footerPrompt={<>
|
||||
<div>{ _t("Want to add a new room instead?") }</div>
|
||||
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
|
||||
{ _t("Create a new room") }
|
||||
</AccessibleButton>
|
||||
</>}
|
||||
/>
|
||||
</MatrixClientContext.Provider>
|
||||
|
||||
<div className="mx_AddExistingToSpaceDialog_footer">
|
||||
{ footer }
|
||||
</div>
|
||||
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
|
|
106
src/components/views/dialogs/BetaFeedbackDialog.tsx
Normal file
106
src/components/views/dialogs/BetaFeedbackDialog.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
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, {useState} from "react";
|
||||
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Field from "../elements/Field";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import {IDialogProps} from "./IDialogProps";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {submitFeedback} from "../../../rageshake/submit-rageshake";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
import Modal from "../../../Modal";
|
||||
import InfoDialog from "./InfoDialog";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import {USER_LABS_TAB} from "./UserSettingsDialog";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
featureId: string;
|
||||
}
|
||||
|
||||
const BetaFeedbackDialog: React.FC<IProps> = ({featureId, onFinished}) => {
|
||||
const info = SettingsStore.getBetaInfo(featureId);
|
||||
|
||||
const [comment, setComment] = useState("");
|
||||
const [canContact, setCanContact] = useState(false);
|
||||
|
||||
const sendFeedback = async (ok: boolean) => {
|
||||
if (!ok) return onFinished(false);
|
||||
|
||||
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact);
|
||||
onFinished(true);
|
||||
|
||||
Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, {
|
||||
title: _t("Beta feedback"),
|
||||
description: _t("Thank you for your feedback, we really appreciate it."),
|
||||
button: _t("Done"),
|
||||
hasCloseButton: false,
|
||||
fixedWidth: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (<QuestionDialog
|
||||
className="mx_BetaFeedbackDialog"
|
||||
hasCancelButton={true}
|
||||
title={_t("%(featureName)s beta feedback", { featureName: info.title })}
|
||||
description={<React.Fragment>
|
||||
<div className="mx_BetaFeedbackDialog_subheading">
|
||||
{ _t(info.feedbackSubheading) }
|
||||
|
||||
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.")}
|
||||
|
||||
<AccessibleButton kind="link" onClick={() => {
|
||||
onFinished(false);
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: USER_LABS_TAB,
|
||||
});
|
||||
}}>
|
||||
{ _t("To leave the beta, visit your settings.") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
id="feedbackComment"
|
||||
label={_t("Feedback")}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={comment}
|
||||
element="textarea"
|
||||
onChange={(ev) => {
|
||||
setComment(ev.target.value);
|
||||
}}
|
||||
autoFocus={true}
|
||||
/>
|
||||
|
||||
<StyledCheckbox
|
||||
checked={canContact}
|
||||
onClick={e => setCanContact((e.target as HTMLInputElement).checked)}
|
||||
>
|
||||
{ _t("You may contact me if you have any follow up questions") }
|
||||
</StyledCheckbox>
|
||||
</React.Fragment>}
|
||||
button={_t("Send feedback")}
|
||||
buttonDisabled={!comment}
|
||||
onFinished={sendFeedback}
|
||||
/>);
|
||||
};
|
||||
|
||||
export default BetaFeedbackDialog;
|
|
@ -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.
|
||||
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
import * as React from 'react';
|
||||
import BaseDialog from './BaseDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { _t, getUserLanguage } from '../../../languageHandler';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import {
|
||||
ClientWidgetApi,
|
||||
|
@ -39,6 +39,8 @@ import {OwnProfileStore} from "../../../stores/OwnProfileStore";
|
|||
import { arrayFastClone } from "../../../utils/arrays";
|
||||
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import {ELEMENT_CLIENT_ID} from "../../../identifiers";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
interface IProps {
|
||||
widgetDefinition: IModalWidgetOpenRequestData;
|
||||
|
@ -129,6 +131,9 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
|
|||
currentUserId: MatrixClientPeg.get().getUserId(),
|
||||
userDisplayName: OwnProfileStore.instance.displayName,
|
||||
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
|
||||
clientId: ELEMENT_CLIENT_ID,
|
||||
clientTheme: SettingsStore.getValue("theme"),
|
||||
clientLanguage: getUserLanguage(),
|
||||
});
|
||||
|
||||
const parsed = new URL(templated);
|
||||
|
|
|
@ -217,6 +217,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
|
|||
value={this.state.otherHomeserver}
|
||||
validateOnChange={false}
|
||||
validateOnFocus={false}
|
||||
id="mx_homeserverInput"
|
||||
/>
|
||||
</StyledRadioButton>
|
||||
<p>
|
||||
|
|
|
@ -32,6 +32,7 @@ import Modal from "../../../Modal";
|
|||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {allSettled} from "../../../utils/promise";
|
||||
import {useDispatcher} from "../../../hooks/useDispatcher";
|
||||
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
matrixClient: MatrixClient;
|
||||
|
@ -111,15 +112,17 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
|
|||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
|
||||
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
|
||||
|
||||
<SpaceBasicSettings
|
||||
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
|
||||
avatarDisabled={!canSetAvatar}
|
||||
avatarDisabled={busy || !canSetAvatar}
|
||||
setAvatar={setNewAvatar}
|
||||
name={name}
|
||||
nameDisabled={!canSetName}
|
||||
nameDisabled={busy || !canSetName}
|
||||
setName={setName}
|
||||
topic={topic}
|
||||
topicDisabled={!canSetTopic}
|
||||
topicDisabled={busy || !canSetTopic}
|
||||
setTopic={setTopic}
|
||||
/>
|
||||
|
||||
|
|
73
src/components/views/dialogs/UntrustedDeviceDialog.tsx
Normal file
73
src/components/views/dialogs/UntrustedDeviceDialog.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
Copyright 2019, 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.
|
||||
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 { User } from "matrix-js-sdk/src/models/user";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import E2EIcon from "../rooms/E2EIcon";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import { IDevice } from "../right_panel/UserInfo";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
user: User;
|
||||
device: IDevice;
|
||||
}
|
||||
|
||||
const UntrustedDeviceDialog: React.FC<IProps> = ({device, user, onFinished}) => {
|
||||
let askToVerifyText;
|
||||
let newSessionText;
|
||||
|
||||
if (MatrixClientPeg.get().getUserId() === user.userId) {
|
||||
newSessionText = _t("You signed in to a new session without verifying it:");
|
||||
askToVerifyText = _t("Verify your other session using one of the options below.");
|
||||
} else {
|
||||
newSessionText = _t("%(name)s (%(userId)s) signed in to a new session without verifying it:",
|
||||
{name: user.displayName, userId: user.userId});
|
||||
askToVerifyText = _t("Ask this user to verify their session, or manually verify it below.");
|
||||
}
|
||||
|
||||
return <BaseDialog
|
||||
onFinished={onFinished}
|
||||
className="mx_UntrustedDeviceDialog"
|
||||
title={<>
|
||||
<E2EIcon status="warning" size={24} hideTooltip={true} />
|
||||
{ _t("Not Trusted")}
|
||||
</>}
|
||||
>
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<p>{newSessionText}</p>
|
||||
<p>{device.getDisplayName()} ({device.deviceId})</p>
|
||||
<p>{askToVerifyText}</p>
|
||||
</div>
|
||||
<div className='mx_Dialog_buttons'>
|
||||
<AccessibleButton element="button" kind="secondary" onClick={() => onFinished("legacy")}>
|
||||
{ _t("Manually Verify by Text") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton element="button" kind="secondary" onClick={() => onFinished("sas")}>
|
||||
{ _t("Interactively verify by Emoji") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind="primary" onClick={() => onFinished(false)}>
|
||||
{ _t("Done") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export default UntrustedDeviceDialog;
|
|
@ -125,7 +125,10 @@ export default class UserSettingsDialog extends React.Component {
|
|||
"mx_UserSettingsDialog_securityIcon",
|
||||
<SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||
));
|
||||
if (SdkConfig.get()['showLabsSettings']) {
|
||||
// Show the Labs tab if enabled or if there are any active betas
|
||||
if (SdkConfig.get()['showLabsSettings']
|
||||
|| SettingsStore.getFeatureSettingNames().some(k => SettingsStore.getBetaInfo(k))
|
||||
) {
|
||||
tabs.push(new Tab(
|
||||
USER_LABS_TAB,
|
||||
_td("Labs"),
|
||||
|
|
|
@ -345,6 +345,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
|||
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this.onPassPhraseNext}>
|
||||
<input
|
||||
type="password"
|
||||
id="mx_passPhraseInput"
|
||||
className="mx_AccessSecretStorageDialog_passPhraseInput"
|
||||
onChange={this.onPassPhraseChange}
|
||||
value={this.state.passPhrase}
|
||||
|
|
|
@ -49,6 +49,18 @@ const inPlaceOf = (elementRect) => ({
|
|||
});
|
||||
|
||||
const validServer = withValidation({
|
||||
deriveData: async ({ value }) => {
|
||||
try {
|
||||
// check if we can successfully load this server's room directory
|
||||
await MatrixClientPeg.get().publicRooms({
|
||||
limit: 1,
|
||||
server: value,
|
||||
});
|
||||
return {};
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
|
@ -57,21 +69,11 @@ const validServer = withValidation({
|
|||
}, {
|
||||
key: "available",
|
||||
final: true,
|
||||
test: async ({ value }) => {
|
||||
try {
|
||||
const opts = {
|
||||
limit: 1,
|
||||
server: value,
|
||||
};
|
||||
// check if we can successfully load this server's room directory
|
||||
await MatrixClientPeg.get().publicRooms(opts);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
test: async (_, { error }) => !error,
|
||||
valid: () => _t("Looks good"),
|
||||
invalid: () => _t("Can't find this server or its room list"),
|
||||
invalid: ({ error }) => error.errcode === "M_FORBIDDEN"
|
||||
? _t("You are not allowed to view this server's rooms list")
|
||||
: _t("Can't find this server or its room list"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -32,6 +32,7 @@ export default class ActionButton extends React.Component {
|
|||
label: PropTypes.string.isRequired,
|
||||
iconPath: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -79,7 +80,8 @@ export default class ActionButton extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<AccessibleButton className={classNames.join(" ")}
|
||||
<AccessibleButton
|
||||
className={classNames.join(" ")}
|
||||
onClick={this._onClick}
|
||||
onMouseEnter={this._onMouseEnter}
|
||||
onMouseLeave={this._onMouseLeave}
|
||||
|
@ -87,6 +89,7 @@ export default class ActionButton extends React.Component {
|
|||
>
|
||||
{ icon }
|
||||
{ tooltip }
|
||||
{ this.props.children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => {
|
|||
effect = new Effect(options);
|
||||
effectsRef.current[name] = effect;
|
||||
} catch (err) {
|
||||
console.warn('Unable to load effect module at \'../../../effects/${name}\'.', err);
|
||||
console.warn(`Unable to load effect module at '../../../effects/${name}.`, err);
|
||||
}
|
||||
}
|
||||
return effect;
|
||||
|
|
|
@ -207,6 +207,7 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
a.href = this.props.src;
|
||||
a.download = this.props.name;
|
||||
a.target = "_blank";
|
||||
a.rel = "noreferrer noopener";
|
||||
a.click();
|
||||
};
|
||||
|
||||
|
@ -442,16 +443,16 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
<div className="mx_ImageView_panel">
|
||||
{info}
|
||||
<div className="mx_ImageView_toolbar">
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_rotateCW"
|
||||
title={_t("Rotate Right")}
|
||||
onClick={this.onRotateClockwiseClick}>
|
||||
</AccessibleTooltipButton>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_rotateCCW"
|
||||
title={_t("Rotate Left")}
|
||||
onClick={ this.onRotateCounterClockwiseClick }>
|
||||
</AccessibleTooltipButton>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_rotateCW"
|
||||
title={_t("Rotate Right")}
|
||||
onClick={this.onRotateClockwiseClick}>
|
||||
</AccessibleTooltipButton>
|
||||
{zoomOutButton}
|
||||
{zoomInButton}
|
||||
<AccessibleTooltipButton
|
||||
|
|
|
@ -58,13 +58,8 @@ export default class LanguageDropdown extends React.Component {
|
|||
// If no value is given, we start with the first
|
||||
// country selected, but our parent component
|
||||
// doesn't know this, therefore we do this.
|
||||
const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true);
|
||||
if (language) {
|
||||
this.props.onOptionChange(language);
|
||||
} else {
|
||||
const language = languageHandler.normalizeLanguageKey(languageHandler.getLanguageFromBrowser());
|
||||
this.props.onOptionChange(language);
|
||||
}
|
||||
const language = languageHandler.getUserLanguage();
|
||||
this.props.onOptionChange(language);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019, 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,29 +14,72 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { Relations } from "matrix-js-sdk/src/models/relations";
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { isContentActionable } from '../../../utils/EventUtils';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
|
||||
import { aboveLeftOf, ContextMenu, useContextMenu } from "../../structures/ContextMenu";
|
||||
import ReactionPicker from "../emojipicker/ReactionPicker";
|
||||
import ReactionsRowButton from "./ReactionsRowButton";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
// The maximum number of reactions to initially show on a message.
|
||||
const MAX_ITEMS_WHEN_LIMITED = 8;
|
||||
|
||||
@replaceableComponent("views.messages.ReactionsRow")
|
||||
export default class ReactionsRow extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
// The Relations model from the JS SDK for reactions to `mxEvent`
|
||||
reactions: PropTypes.object,
|
||||
const ReactButton = ({ mxEvent, reactions }: IProps) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const buttonRect = button.current.getBoundingClientRect();
|
||||
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false}>
|
||||
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeMenu} />
|
||||
</ContextMenu>;
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
return <React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className={classNames("mx_ReactionsRow_addReactionButton", {
|
||||
mx_ReactionsRow_addReactionButton_active: menuDisplayed,
|
||||
})}
|
||||
title={_t("Add reaction")}
|
||||
onClick={openMenu}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
openMenu();
|
||||
}}
|
||||
isExpanded={menuDisplayed}
|
||||
inputRef={button}
|
||||
/>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: MatrixEvent;
|
||||
// The Relations model from the JS SDK for reactions to `mxEvent`
|
||||
reactions?: Relations;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
myReactions: MatrixEvent[];
|
||||
showAll: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.ReactionsRow")
|
||||
export default class ReactionsRow extends React.PureComponent<IProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
if (props.reactions) {
|
||||
props.reactions.on("Relations.add", this.onReactionsChange);
|
||||
|
@ -92,7 +135,7 @@ export default class ReactionsRow extends React.PureComponent {
|
|||
if (!reactions) {
|
||||
return null;
|
||||
}
|
||||
const userId = MatrixClientPeg.get().getUserId();
|
||||
const userId = this.context.getUserId();
|
||||
const myReactions = reactions.getAnnotationsBySender()[userId];
|
||||
if (!myReactions) {
|
||||
return null;
|
||||
|
@ -114,7 +157,6 @@ export default class ReactionsRow extends React.PureComponent {
|
|||
return null;
|
||||
}
|
||||
|
||||
const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton');
|
||||
let items = reactions.getSortedAnnotationsByKey().map(([content, events]) => {
|
||||
const count = events.size;
|
||||
if (!count) {
|
||||
|
@ -136,6 +178,8 @@ export default class ReactionsRow extends React.PureComponent {
|
|||
/>;
|
||||
}).filter(item => !!item);
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
// Show the first MAX_ITEMS if there are MAX_ITEMS + 1 or more items.
|
||||
// The "+ 1" ensure that the "show all" reveals something that takes up
|
||||
// more space than the button itself.
|
||||
|
@ -151,13 +195,21 @@ export default class ReactionsRow extends React.PureComponent {
|
|||
</a>;
|
||||
}
|
||||
|
||||
const cli = this.context;
|
||||
|
||||
let addReactionButton;
|
||||
if (cli.getRoom(mxEvent.getRoomId()).currentState.maySendEvent(EventType.Reaction, cli.getUserId())) {
|
||||
addReactionButton = <ReactButton mxEvent={mxEvent} reactions={reactions} />;
|
||||
}
|
||||
|
||||
return <div
|
||||
className="mx_ReactionsRow"
|
||||
role="toolbar"
|
||||
aria-label={_t("Reactions")}
|
||||
>
|
||||
{items}
|
||||
{showAllButton}
|
||||
{ items }
|
||||
{ showAllButton }
|
||||
{ addReactionButton }
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019, 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,49 +14,54 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import ReactionsRowButtonTooltip from "./ReactionsRowButtonTooltip";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: MatrixEvent;
|
||||
// The reaction content / key / emoji
|
||||
content: string;
|
||||
// The count of votes for this key
|
||||
count: number;
|
||||
// A Set of Matrix reaction events for this key
|
||||
reactionEvents: Set<MatrixEvent>;
|
||||
// A possible Matrix event if the current user has voted for this type
|
||||
myReactionEvent?: MatrixEvent;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
tooltipRendered: boolean;
|
||||
tooltipVisible: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.ReactionsRowButton")
|
||||
export default class ReactionsRowButton extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
// The reaction content / key / emoji
|
||||
content: PropTypes.string.isRequired,
|
||||
// The count of votes for this key
|
||||
count: PropTypes.number.isRequired,
|
||||
// A Set of Martix reaction events for this key
|
||||
reactionEvents: PropTypes.object.isRequired,
|
||||
// A possible Matrix event if the current user has voted for this type
|
||||
myReactionEvent: PropTypes.object,
|
||||
}
|
||||
export default class ReactionsRowButton extends React.PureComponent<IProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
state = {
|
||||
tooltipRendered: false,
|
||||
tooltipVisible: false,
|
||||
};
|
||||
|
||||
this.state = {
|
||||
tooltipVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
onClick = (ev) => {
|
||||
onClick = () => {
|
||||
const { mxEvent, myReactionEvent, content } = this.props;
|
||||
if (myReactionEvent) {
|
||||
MatrixClientPeg.get().redactEvent(
|
||||
this.context.redactEvent(
|
||||
mxEvent.getRoomId(),
|
||||
myReactionEvent.getId(),
|
||||
);
|
||||
} else {
|
||||
MatrixClientPeg.get().sendEvent(mxEvent.getRoomId(), "m.reaction", {
|
||||
this.context.sendEvent(mxEvent.getRoomId(), "m.reaction", {
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.annotation",
|
||||
"event_id": mxEvent.getId(),
|
||||
|
@ -83,8 +88,6 @@ export default class ReactionsRowButton extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const ReactionsRowButtonTooltip =
|
||||
sdk.getComponent('messages.ReactionsRowButtonTooltip');
|
||||
const { mxEvent, content, count, reactionEvents, myReactionEvent } = this.props;
|
||||
|
||||
const classes = classNames({
|
||||
|
@ -102,7 +105,7 @@ export default class ReactionsRowButton extends React.PureComponent {
|
|||
/>;
|
||||
}
|
||||
|
||||
const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId());
|
||||
const room = this.context.getRoom(mxEvent.getRoomId());
|
||||
let label;
|
||||
if (room) {
|
||||
const senders = [];
|
||||
|
@ -130,7 +133,6 @@ export default class ReactionsRowButton extends React.PureComponent {
|
|||
);
|
||||
}
|
||||
const isPeeking = room.getMyMembership() !== "join";
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return <AccessibleButton
|
||||
className={classes}
|
||||
aria-label={label}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 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,33 +14,34 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import * as sdk from '../../../index';
|
||||
import { unicodeToShortcode } from '../../../HtmlUtils';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Tooltip from "../elements/Tooltip";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: MatrixEvent;
|
||||
// The reaction content / key / emoji
|
||||
content: string;
|
||||
// A Set of Matrix reaction events for this key
|
||||
reactionEvents: Set<MatrixEvent>;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.ReactionsRowButtonTooltip")
|
||||
export default class ReactionsRowButtonTooltip extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
// The reaction content / key / emoji
|
||||
content: PropTypes.string.isRequired,
|
||||
// A Set of Martix reaction events for this key
|
||||
reactionEvents: PropTypes.object.isRequired,
|
||||
visible: PropTypes.bool.isRequired,
|
||||
}
|
||||
export default class ReactionsRowButtonTooltip extends React.PureComponent<IProps> {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
render() {
|
||||
const Tooltip = sdk.getComponent('elements.Tooltip');
|
||||
const { content, reactionEvents, mxEvent, visible } = this.props;
|
||||
|
||||
const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId());
|
||||
const room = this.context.getRoom(mxEvent.getRoomId());
|
||||
let tooltipLabel;
|
||||
if (room) {
|
||||
const senders = [];
|
|
@ -67,7 +67,7 @@ import RoomAvatar from "../avatars/RoomAvatar";
|
|||
import RoomName from "../elements/RoomName";
|
||||
import {mediaFromMxc} from "../../../customisations/Media";
|
||||
|
||||
interface IDevice {
|
||||
export interface IDevice {
|
||||
deviceId: string;
|
||||
ambiguous?: boolean;
|
||||
getDisplayName(): string;
|
||||
|
|
|
@ -25,6 +25,8 @@ import SettingsStore from "../../../settings/SettingsStore";
|
|||
import Autocompleter from '../../../autocomplete/Autocompleter';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
const MAX_PROVIDER_MATCHES = 20;
|
||||
|
||||
export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`;
|
||||
|
||||
interface IProps {
|
||||
|
@ -134,7 +136,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
|
||||
processQuery(query: string, selection: ISelectionRange) {
|
||||
return this.autocompleter.getCompletions(
|
||||
query, selection, this.state.forceComplete,
|
||||
query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES,
|
||||
).then((completions) => {
|
||||
// Only ever process the completions for the most recent query being processed
|
||||
if (query !== this.queryRequested) {
|
||||
|
|
|
@ -35,6 +35,7 @@ import {Action} from "../../../dispatcher/actions";
|
|||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import {getKeyBindingsManager, MessageComposerAction} from '../../../KeyBindingsManager';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import SendHistoryManager from '../../../SendHistoryManager';
|
||||
import Modal from '../../../Modal';
|
||||
|
||||
function _isReply(mxEvent) {
|
||||
|
@ -122,6 +123,7 @@ export default class EditMessageComposer extends React.Component {
|
|||
saveDisabled: true,
|
||||
};
|
||||
this._createEditorModel();
|
||||
window.addEventListener("beforeunload", this._saveStoredEditorState);
|
||||
}
|
||||
|
||||
_setEditorRef = ref => {
|
||||
|
@ -175,11 +177,55 @@ export default class EditMessageComposer extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
get _editorRoomKey() {
|
||||
return `mx_edit_room_${this._getRoom().roomId}`;
|
||||
}
|
||||
|
||||
get _editorStateKey() {
|
||||
return `mx_edit_state_${this.props.editState.getEvent().getId()}`;
|
||||
}
|
||||
|
||||
_cancelEdit = () => {
|
||||
this._clearStoredEditorState();
|
||||
dis.dispatch({action: "edit_event", event: null});
|
||||
dis.fire(Action.FocusComposer);
|
||||
}
|
||||
|
||||
get _shouldSaveStoredEditorState() {
|
||||
return localStorage.getItem(this._editorRoomKey) !== null;
|
||||
}
|
||||
|
||||
_restoreStoredEditorState(partCreator) {
|
||||
const json = localStorage.getItem(this._editorStateKey);
|
||||
if (json) {
|
||||
try {
|
||||
const {parts: serializedParts} = JSON.parse(json);
|
||||
const parts = serializedParts.map(p => partCreator.deserializePart(p));
|
||||
return parts;
|
||||
} catch (e) {
|
||||
console.error("Error parsing editing state: ", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_clearStoredEditorState() {
|
||||
localStorage.removeItem(this._editorRoomKey);
|
||||
localStorage.removeItem(this._editorStateKey);
|
||||
}
|
||||
|
||||
_clearPreviousEdit() {
|
||||
if (localStorage.getItem(this._editorRoomKey)) {
|
||||
localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this._editorRoomKey)}`);
|
||||
}
|
||||
}
|
||||
|
||||
_saveStoredEditorState() {
|
||||
const item = SendHistoryManager.createItem(this.model);
|
||||
this._clearPreviousEdit();
|
||||
localStorage.setItem(this._editorRoomKey, this.props.editState.getEvent().getId());
|
||||
localStorage.setItem(this._editorStateKey, JSON.stringify(item));
|
||||
}
|
||||
|
||||
_isSlashCommand() {
|
||||
const parts = this.model.parts;
|
||||
const firstPart = parts[0];
|
||||
|
@ -266,6 +312,7 @@ export default class EditMessageComposer extends React.Component {
|
|||
const editedEvent = this.props.editState.getEvent();
|
||||
const editContent = createEditContent(this.model, editedEvent);
|
||||
const newContent = editContent["m.new_content"];
|
||||
|
||||
let shouldSend = true;
|
||||
|
||||
// If content is modified then send an updated event into the room
|
||||
|
@ -311,6 +358,7 @@ export default class EditMessageComposer extends React.Component {
|
|||
if (shouldSend) {
|
||||
this._cancelPreviousPendingEdit();
|
||||
const prom = this.context.sendMessage(roomId, editContent);
|
||||
this._clearStoredEditorState();
|
||||
dis.dispatch({action: "message_sent"});
|
||||
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
|
||||
}
|
||||
|
@ -346,6 +394,10 @@ export default class EditMessageComposer extends React.Component {
|
|||
// then when mounting the editor again with the same editor state,
|
||||
// it will set the cursor at the end.
|
||||
this.props.editState.setEditorState(caret, parts);
|
||||
window.removeEventListener("beforeunload", this._saveStoredEditorState);
|
||||
if (this._shouldSaveStoredEditorState) {
|
||||
this._saveStoredEditorState();
|
||||
}
|
||||
}
|
||||
|
||||
_createEditorModel() {
|
||||
|
@ -358,10 +410,11 @@ export default class EditMessageComposer extends React.Component {
|
|||
// restore serialized parts from the state
|
||||
parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p));
|
||||
} else {
|
||||
// otherwise, parse the body of the event
|
||||
parts = parseEvent(editState.getEvent(), partCreator);
|
||||
//otherwise, either restore serialized parts from localStorage or parse the body of the event
|
||||
parts = this._restoreStoredEditorState(partCreator) || parseEvent(editState.getEvent(), partCreator);
|
||||
}
|
||||
this.model = new EditorModel(parts, partCreator);
|
||||
this._saveStoredEditorState();
|
||||
}
|
||||
|
||||
_getInitialCaretPosition() {
|
||||
|
|
|
@ -23,11 +23,9 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
|
|||
import Analytics from "../../../Analytics";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
|
||||
import Toolbar from "../../../accessibility/Toolbar";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
@ -84,8 +82,6 @@ export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState>
|
|||
|
||||
public render(): React.ReactElement {
|
||||
const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => {
|
||||
const roomTags = RoomListStore.instance.getTagsForRoom(r);
|
||||
const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0];
|
||||
return (
|
||||
<RovingAccessibleTooltipButton
|
||||
className="mx_RoomBreadcrumbs_crumb"
|
||||
|
@ -98,7 +94,6 @@ export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState>
|
|||
<DecoratedRoomAvatar
|
||||
room={r}
|
||||
avatarSize={32}
|
||||
tag={roomTag}
|
||||
displayBadge={true}
|
||||
forceCount={true}
|
||||
/>
|
||||
|
|
|
@ -27,7 +27,6 @@ import SettingsStore from "../../../settings/SettingsStore";
|
|||
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
|
||||
import E2EIcon from './E2EIcon';
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import {DefaultTagID} from "../../../stores/room-list/models";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import RoomTopic from "../elements/RoomTopic";
|
||||
import RoomName from "../elements/RoomName";
|
||||
|
@ -177,7 +176,6 @@ export default class RoomHeader extends React.Component {
|
|||
roomAvatar = <DecoratedRoomAvatar
|
||||
room={this.props.room}
|
||||
avatarSize={32}
|
||||
tag={DefaultTagID.Untagged} // to apply room publicity badging
|
||||
oobData={this.props.oobData}
|
||||
viewAvatarOnClick={true}
|
||||
/>;
|
||||
|
|
|
@ -576,7 +576,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
const roomAvatar = <DecoratedRoomAvatar
|
||||
room={this.props.room}
|
||||
avatarSize={32}
|
||||
tag={this.props.tag}
|
||||
displayBadge={this.props.isMinimized}
|
||||
oobData={({avatarUrl: roomProfile.avatarMxc})}
|
||||
/>;
|
||||
|
|
|
@ -232,7 +232,7 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
|
|||
<p>
|
||||
{this.state.enabling
|
||||
? <InlineSpinner />
|
||||
: _t("Message search initilisation failed")
|
||||
: _t("Message search initialisation failed")
|
||||
}
|
||||
</p>
|
||||
{EventIndexPeg.error && (
|
||||
|
|
|
@ -22,6 +22,8 @@ import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
|
|||
import * as sdk from "../../../../../index";
|
||||
import {SettingLevel} from "../../../../../settings/SettingLevel";
|
||||
import {replaceableComponent} from "../../../../../utils/replaceableComponent";
|
||||
import SdkConfig from "../../../../../SdkConfig";
|
||||
import BetaCard from "../../../beta/BetaCard";
|
||||
|
||||
export class LabsSettingToggle extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -48,14 +50,40 @@ export default class LabsUserSettingsTab extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
|
||||
const flags = SettingsStore.getFeatureSettingNames().map(f => <LabsSettingToggle featureId={f} key={f} />);
|
||||
const features = SettingsStore.getFeatureSettingNames();
|
||||
const [labs, betas] = features.reduce((arr, f) => {
|
||||
arr[SettingsStore.getBetaInfo(f) ? 1 : 0].push(f);
|
||||
return arr;
|
||||
}, [[], []]);
|
||||
|
||||
let betaSection;
|
||||
if (betas.length) {
|
||||
betaSection = <div className="mx_SettingsTab_section">
|
||||
{ betas.map(f => <BetaCard key={f} featureId={f} /> ) }
|
||||
</div>;
|
||||
}
|
||||
|
||||
let labsSection;
|
||||
if (SdkConfig.get()['showLabsSettings']) {
|
||||
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
|
||||
const flags = labs.map(f => <LabsSettingToggle featureId={f} key={f} />);
|
||||
|
||||
labsSection = <div className="mx_SettingsTab_section">
|
||||
{flags}
|
||||
<SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} />
|
||||
<SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} />
|
||||
<SettingsFlag name="lowBandwidth" level={SettingLevel.DEVICE} />
|
||||
<SettingsFlag name="advancedRoomListLogging" level={SettingLevel.DEVICE} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_SettingsTab">
|
||||
<div className="mx_SettingsTab mx_LabsUserSettingsTab">
|
||||
<div className="mx_SettingsTab_heading">{_t("Labs")}</div>
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
{
|
||||
_t('Customise your experience with experimental labs features. ' +
|
||||
_t('Feeling experimental? Labs are the best way to get things early, ' +
|
||||
'test out new features and help shape them before they actually launch. ' +
|
||||
'<a>Learn more</a>.', {}, {
|
||||
'a': (sub) => {
|
||||
return <a href="https://github.com/vector-im/element-web/blob/develop/docs/labs.md"
|
||||
|
@ -64,13 +92,8 @@ export default class LabsUserSettingsTab extends React.Component {
|
|||
})
|
||||
}
|
||||
</div>
|
||||
<div className="mx_SettingsTab_section">
|
||||
{flags}
|
||||
<SettingsFlag name={"enableWidgetScreenshots"} level={SettingLevel.ACCOUNT} />
|
||||
<SettingsFlag name={"showHiddenEventsInTimeline"} level={SettingLevel.DEVICE} />
|
||||
<SettingsFlag name={"lowBandwidth"} level={SettingLevel.DEVICE} />
|
||||
<SettingsFlag name={"advancedRoomListLogging"} level={SettingLevel.DEVICE} />
|
||||
</div>
|
||||
{ betaSection }
|
||||
{ labsSection }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -32,17 +32,11 @@ interface IProps {
|
|||
setTopic(topic: string): void;
|
||||
}
|
||||
|
||||
const SpaceBasicSettings = ({
|
||||
export const SpaceAvatar = ({
|
||||
avatarUrl,
|
||||
avatarDisabled = false,
|
||||
setAvatar,
|
||||
name = "",
|
||||
nameDisabled = false,
|
||||
setName,
|
||||
topic = "",
|
||||
topicDisabled = false,
|
||||
setTopic,
|
||||
}: IProps) => {
|
||||
}: Pick<IProps, "avatarUrl" | "avatarDisabled" | "setAvatar">) => {
|
||||
const avatarUploadRef = useRef<HTMLInputElement>();
|
||||
const [avatar, setAvatarDataUrl] = useState(avatarUrl); // avatar data url cache
|
||||
|
||||
|
@ -81,20 +75,34 @@ const SpaceBasicSettings = ({
|
|||
}
|
||||
}
|
||||
|
||||
return <div className="mx_SpaceBasicSettings_avatarContainer">
|
||||
{ avatarSection }
|
||||
<input type="file" ref={avatarUploadRef} onChange={(e) => {
|
||||
if (!e.target.files?.length) return;
|
||||
const file = e.target.files[0];
|
||||
setAvatar(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
setAvatarDataUrl(ev.target.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}} accept="image/*" />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceBasicSettings = ({
|
||||
avatarUrl,
|
||||
avatarDisabled = false,
|
||||
setAvatar,
|
||||
name = "",
|
||||
nameDisabled = false,
|
||||
setName,
|
||||
topic = "",
|
||||
topicDisabled = false,
|
||||
setTopic,
|
||||
}: IProps) => {
|
||||
return <div className="mx_SpaceBasicSettings">
|
||||
<div className="mx_SpaceBasicSettings_avatarContainer">
|
||||
{ avatarSection }
|
||||
<input type="file" ref={avatarUploadRef} onChange={(e) => {
|
||||
if (!e.target.files?.length) return;
|
||||
const file = e.target.files[0];
|
||||
setAvatar(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
setAvatarDataUrl(ev.target.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}} accept="image/*" />
|
||||
</div>
|
||||
<SpaceAvatar avatarUrl={avatarUrl} avatarDisabled={avatarDisabled} setAvatar={setAvatar} />
|
||||
|
||||
<Field
|
||||
name="spaceName"
|
||||
|
|
|
@ -14,18 +14,25 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useContext, useState} from "react";
|
||||
import React, {useContext, useRef, useState} from "react";
|
||||
import classNames from "classnames";
|
||||
import {EventType, RoomType, RoomCreateTypeField} from "matrix-js-sdk/src/@types/event";
|
||||
import FocusLock from "react-focus-lock";
|
||||
|
||||
import {_t} from "../../../languageHandler";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import {ChevronFace, ContextMenu} from "../../structures/ContextMenu";
|
||||
import createRoom, {IStateEvent, Preset} from "../../../createRoom";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import SpaceBasicSettings from "./SpaceBasicSettings";
|
||||
import {SpaceAvatar} from "./SpaceBasicSettings";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import FocusLock from "react-focus-lock";
|
||||
import {BetaPill} from "../beta/BetaCard";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import {USER_LABS_TAB} from "../dialogs/UserSettingsDialog";
|
||||
import Field from "../elements/Field";
|
||||
import withValidation from "../elements/Validation";
|
||||
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
|
||||
|
||||
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
|
||||
return (
|
||||
|
@ -41,17 +48,39 @@ enum Visibility {
|
|||
Private,
|
||||
}
|
||||
|
||||
const spaceNameValidator = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: async ({ value }) => !!value,
|
||||
invalid: () => _t("Please enter a name for the space"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const SpaceCreateMenu = ({ onFinished }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [visibility, setVisibility] = useState<Visibility>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [avatar, setAvatar] = useState<File>(null);
|
||||
const [topic, setTopic] = useState<string>("");
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
|
||||
const onSpaceCreateClick = async () => {
|
||||
const [name, setName] = useState("");
|
||||
const spaceNameField = useRef<Field>();
|
||||
const [avatar, setAvatar] = useState<File>(null);
|
||||
const [topic, setTopic] = useState<string>("");
|
||||
|
||||
const onSpaceCreateClick = async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
|
||||
setBusy(true);
|
||||
// require & validate the space name field
|
||||
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
|
||||
spaceNameField.current.focus();
|
||||
spaceNameField.current.validate({ allowEmpty: false, focused: true });
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const initialState: IStateEvent[] = [
|
||||
{
|
||||
type: EventType.RoomHistoryVisibility,
|
||||
|
@ -107,7 +136,7 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
if (visibility === null) {
|
||||
body = <React.Fragment>
|
||||
<h2>{ _t("Create a space") }</h2>
|
||||
<p>{ _t("Spaces are new ways to group rooms and people. " +
|
||||
<p>{ _t("Spaces are a new way to group rooms and people. " +
|
||||
"To join an existing space you'll need an invite.") }</p>
|
||||
|
||||
<SpaceCreateMenuType
|
||||
|
@ -124,6 +153,8 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
/>
|
||||
|
||||
<p>{ _t("You can change this later") }</p>
|
||||
|
||||
<SpaceFeedbackPrompt onClick={onFinished} />
|
||||
</React.Fragment>;
|
||||
} else {
|
||||
body = <React.Fragment>
|
||||
|
@ -146,9 +177,32 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
}
|
||||
</p>
|
||||
|
||||
<SpaceBasicSettings setAvatar={setAvatar} name={name} setName={setName} topic={topic} setTopic={setTopic} />
|
||||
<form className="mx_SpaceBasicSettings" onSubmit={onSpaceCreateClick}>
|
||||
<SpaceAvatar setAvatar={setAvatar} avatarDisabled={busy} />
|
||||
|
||||
<AccessibleButton kind="primary" onClick={onSpaceCreateClick} disabled={!name || busy}>
|
||||
<Field
|
||||
name="spaceName"
|
||||
label={_t("Name")}
|
||||
autoFocus={true}
|
||||
value={name}
|
||||
onChange={ev => setName(ev.target.value)}
|
||||
ref={spaceNameField}
|
||||
onValidate={spaceNameValidator}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
<Field
|
||||
name="spaceTopic"
|
||||
element="textarea"
|
||||
label={_t("Description")}
|
||||
value={topic}
|
||||
onChange={ev => setTopic(ev.target.value)}
|
||||
rows={3}
|
||||
disabled={busy}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<AccessibleButton kind="primary" onClick={onSpaceCreateClick} disabled={busy}>
|
||||
{ busy ? _t("Creating...") : _t("Create") }
|
||||
</AccessibleButton>
|
||||
</React.Fragment>;
|
||||
|
@ -164,6 +218,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
managed={false}
|
||||
>
|
||||
<FocusLock returnFocus={true}>
|
||||
<BetaPill onClick={() => {
|
||||
onFinished();
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: USER_LABS_TAB,
|
||||
});
|
||||
}} />
|
||||
{ body }
|
||||
</FocusLock>
|
||||
</ContextMenu>;
|
||||
|
|
|
@ -23,6 +23,7 @@ import {copyPlaintext} from "../../../utils/strings";
|
|||
import {sleep} from "../../../utils/promise";
|
||||
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
|
||||
import {showRoomInviteDialog} from "../../../RoomInvite";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
|
@ -50,7 +51,7 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => {
|
|||
<h3>{ _t("Share invite link") }</h3>
|
||||
<span>{ copiedText }</span>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
{ space.canInvite(MatrixClientPeg.get()?.getUserId()) ? <AccessibleButton
|
||||
className="mx_SpacePublicShare_inviteButton"
|
||||
onClick={() => {
|
||||
showRoomInviteDialog(space.roomId);
|
||||
|
@ -59,7 +60,7 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => {
|
|||
>
|
||||
<h3>{ _t("Invite people") }</h3>
|
||||
<span>{ _t("Invite with email or username") }</span>
|
||||
</AccessibleButton>
|
||||
</AccessibleButton> : null }
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ import {Room} from "matrix-js-sdk/src/models/room";
|
|||
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore";
|
||||
import NotificationBadge from "../rooms/NotificationBadge";
|
||||
import {RovingAccessibleButton} from "../../../accessibility/roving/RovingAccessibleButton";
|
||||
import {RovingAccessibleTooltipButton} from "../../../accessibility/roving/RovingAccessibleTooltipButton";
|
||||
|
@ -68,8 +69,14 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const collapsed = SpaceTreeLevelLayoutStore.instance.getSpaceCollapsedState(
|
||||
props.space.roomId,
|
||||
this.props.parents,
|
||||
!props.isNested, // default to collapsed for root items
|
||||
);
|
||||
|
||||
this.state = {
|
||||
collapsed: !props.isNested, // default to collapsed for root items
|
||||
collapsed: collapsed,
|
||||
contextMenuPosition: null,
|
||||
};
|
||||
}
|
||||
|
@ -78,7 +85,14 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
if (this.props.onExpand && this.state.collapsed) {
|
||||
this.props.onExpand();
|
||||
}
|
||||
this.setState({collapsed: !this.state.collapsed});
|
||||
const newCollapsedState = !this.state.collapsed;
|
||||
|
||||
SpaceTreeLevelLayoutStore.instance.setSpaceCollapsedState(
|
||||
this.props.space.roomId,
|
||||
this.props.parents,
|
||||
newCollapsedState,
|
||||
);
|
||||
this.setState({collapsed: newCollapsedState});
|
||||
// don't bubble up so encapsulating button for space
|
||||
// doesn't get triggered
|
||||
evt.stopPropagation();
|
||||
|
@ -195,7 +209,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
const userId = this.context.getUserId();
|
||||
|
||||
let inviteOption;
|
||||
if (this.props.space.canInvite(userId)) {
|
||||
if (this.props.space.getJoinRule() === "public" || this.props.space.canInvite(userId)) {
|
||||
inviteOption = (
|
||||
<IconizedContextMenuOption
|
||||
className="mx_SpacePanel_contextMenu_inviteButton"
|
||||
|
|
|
@ -57,8 +57,8 @@ export default class PlaybackWaveform extends React.PureComponent<IProps, IState
|
|||
};
|
||||
|
||||
private onTimeUpdate = (time: number[]) => {
|
||||
// Track percentages to very coarse precision, otherwise 0.002 ends up highlighting a bar.
|
||||
const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(1));
|
||||
// Track percentages to a general precision to avoid over-waking the component.
|
||||
const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(3));
|
||||
this.setState({progress});
|
||||
};
|
||||
|
||||
|
|
43
src/effects/effect.ts
Normal file
43
src/effects/effect.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
Copyright 2020 Nurjin Jafar
|
||||
Copyright 2020 Nordeck IT + Consulting GmbH.
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
export type Effect<TOptions extends { [key: string]: any }> = {
|
||||
/**
|
||||
* one or more emojis that will trigger this effect
|
||||
*/
|
||||
emojis: Array<string>;
|
||||
/**
|
||||
* the matrix message type that will trigger this effect
|
||||
*/
|
||||
msgType: string;
|
||||
/**
|
||||
* the room command to trigger this effect
|
||||
*/
|
||||
command: string;
|
||||
/**
|
||||
* a function that returns the translated description of the effect
|
||||
*/
|
||||
description: () => string;
|
||||
/**
|
||||
* a function that returns the translated fallback message. this message will be shown if the user did not provide a custom message
|
||||
*/
|
||||
fallbackMessage: () => string;
|
||||
/**
|
||||
* animation options
|
||||
*/
|
||||
options: TOptions;
|
||||
}
|
|
@ -15,80 +15,11 @@
|
|||
limitations under the License.
|
||||
*/
|
||||
import { _t, _td } from "../languageHandler";
|
||||
|
||||
export type Effect<TOptions extends { [key: string]: any }> = {
|
||||
/**
|
||||
* one or more emojis that will trigger this effect
|
||||
*/
|
||||
emojis: Array<string>;
|
||||
/**
|
||||
* the matrix message type that will trigger this effect
|
||||
*/
|
||||
msgType: string;
|
||||
/**
|
||||
* the room command to trigger this effect
|
||||
*/
|
||||
command: string;
|
||||
/**
|
||||
* a function that returns the translated description of the effect
|
||||
*/
|
||||
description: () => string;
|
||||
/**
|
||||
* a function that returns the translated fallback message. this message will be shown if the user did not provide a custom message
|
||||
*/
|
||||
fallbackMessage: () => string;
|
||||
/**
|
||||
* animation options
|
||||
*/
|
||||
options: TOptions;
|
||||
}
|
||||
|
||||
type ConfettiOptions = {
|
||||
/**
|
||||
* max confetti count
|
||||
*/
|
||||
maxCount: number;
|
||||
/**
|
||||
* particle animation speed
|
||||
*/
|
||||
speed: number;
|
||||
/**
|
||||
* the confetti animation frame interval in milliseconds
|
||||
*/
|
||||
frameInterval: number;
|
||||
/**
|
||||
* the alpha opacity of the confetti (between 0 and 1, where 1 is opaque and 0 is invisible)
|
||||
*/
|
||||
alpha: number;
|
||||
/**
|
||||
* use gradient instead of solid particle color
|
||||
*/
|
||||
gradient: boolean;
|
||||
};
|
||||
type FireworksOptions = {
|
||||
/**
|
||||
* max fireworks count
|
||||
*/
|
||||
maxCount: number;
|
||||
/**
|
||||
* gravity value that firework adds to shift from it's start position
|
||||
*/
|
||||
gravity: number;
|
||||
}
|
||||
type SnowfallOptions = {
|
||||
/**
|
||||
* The maximum number of snowflakes to render at a given time
|
||||
*/
|
||||
maxCount: number;
|
||||
/**
|
||||
* The amount of gravity to apply to the snowflakes
|
||||
*/
|
||||
gravity: number;
|
||||
/**
|
||||
* The amount of drift (horizontal sway) to apply to the snowflakes. Each snowflake varies.
|
||||
*/
|
||||
maxDrift: number;
|
||||
}
|
||||
import { ConfettiOptions } from "./confetti";
|
||||
import { Effect } from "./effect";
|
||||
import { FireworksOptions } from "./fireworks";
|
||||
import { SnowfallOptions } from "./snowfall";
|
||||
import { SpaceInvadersOptions } from "./spaceinvaders";
|
||||
|
||||
/**
|
||||
* This configuration defines room effects that can be triggered by custom message types and emojis
|
||||
|
@ -131,6 +62,17 @@ export const CHAT_EFFECTS: Array<Effect<{ [key: string]: any }>> = [
|
|||
maxDrift: 5,
|
||||
},
|
||||
} as Effect<SnowfallOptions>,
|
||||
{
|
||||
emojis: ["👾", "🌌"],
|
||||
msgType: "io.element.effects.space_invaders",
|
||||
command: "spaceinvaders",
|
||||
description: () => _td("Sends the given message with a space themed effect"),
|
||||
fallbackMessage: () => _t("sends space invaders") + " 👾",
|
||||
options: {
|
||||
maxCount: 50,
|
||||
gravity: 0.01,
|
||||
},
|
||||
} as Effect<SpaceInvadersOptions>,
|
||||
];
|
||||
|
||||
|
||||
|
|
119
src/effects/spaceinvaders/index.ts
Normal file
119
src/effects/spaceinvaders/index.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
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 ICanvasEffect from '../ICanvasEffect';
|
||||
import { arrayFastClone } from "../../utils/arrays";
|
||||
|
||||
export type SpaceInvadersOptions = {
|
||||
/**
|
||||
* The maximum number of invaders to render at a given time
|
||||
*/
|
||||
maxCount: number;
|
||||
/**
|
||||
* The amount of gravity to apply to the invaders
|
||||
*/
|
||||
gravity: number;
|
||||
}
|
||||
|
||||
type Invader = {
|
||||
x: number;
|
||||
y: number;
|
||||
xCol: number;
|
||||
gravity: number;
|
||||
}
|
||||
|
||||
export const DefaultOptions: SpaceInvadersOptions = {
|
||||
maxCount: 50,
|
||||
gravity: 0.005,
|
||||
};
|
||||
|
||||
const KEY_FRAME_INTERVAL = 15; // 15ms, roughly
|
||||
const GLYPH = "👾";
|
||||
|
||||
export default class SpaceInvaders implements ICanvasEffect {
|
||||
private readonly options: SpaceInvadersOptions;
|
||||
|
||||
constructor(options: { [key: string]: any }) {
|
||||
this.options = {...DefaultOptions, ...options};
|
||||
}
|
||||
|
||||
private context: CanvasRenderingContext2D | null = null;
|
||||
private particles: Array<Invader> = [];
|
||||
private lastAnimationTime: number;
|
||||
|
||||
public isRunning: boolean;
|
||||
|
||||
public start = async (canvas: HTMLCanvasElement, timeout = 3000) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
this.context = canvas.getContext('2d');
|
||||
this.particles = [];
|
||||
const count = this.options.maxCount;
|
||||
while (this.particles.length < count) {
|
||||
this.particles.push(this.resetParticle({} as Invader, canvas.width, canvas.height));
|
||||
}
|
||||
this.isRunning = true;
|
||||
requestAnimationFrame(this.renderLoop);
|
||||
if (timeout) {
|
||||
window.setTimeout(this.stop, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
public stop = async () => {
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
private resetParticle = (particle: Invader, width: number, height: number): Invader => {
|
||||
particle.x = Math.random() * width;
|
||||
particle.y = Math.random() * -height;
|
||||
particle.xCol = particle.x;
|
||||
particle.gravity = this.options.gravity + (Math.random() * 6) + 4;
|
||||
return particle;
|
||||
}
|
||||
|
||||
private renderLoop = (): void => {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
if (this.particles.length === 0) {
|
||||
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
} else {
|
||||
const timeDelta = Date.now() - this.lastAnimationTime;
|
||||
if (timeDelta >= KEY_FRAME_INTERVAL || !this.lastAnimationTime) {
|
||||
// Clear the screen first
|
||||
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
|
||||
|
||||
this.lastAnimationTime = Date.now();
|
||||
this.animateAndRenderInvaders();
|
||||
}
|
||||
requestAnimationFrame(this.renderLoop);
|
||||
}
|
||||
};
|
||||
|
||||
private animateAndRenderInvaders() {
|
||||
if (!this.context || !this.context.canvas) {
|
||||
return;
|
||||
}
|
||||
this.context.font = "50px Twemoji";
|
||||
for (const particle of arrayFastClone(this.particles)) {
|
||||
particle.y += particle.gravity;
|
||||
|
||||
this.context.save();
|
||||
this.context.fillText(GLYPH, particle.x, particle.y);
|
||||
this.context.restore();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1187,7 +1187,7 @@
|
|||
"Changes your display nickname in the current room only": "Změní vaši zobrazovanou přezdívku pouze v této místnosti",
|
||||
"User %(userId)s is already in the room": "Uživatel %(userId)s už je v této místnosti",
|
||||
"The user must be unbanned before they can be invited.": "Uživatel je vykázán, nelze ho pozvat.",
|
||||
"Show read receipts sent by other users": "Zobrazovat potvrzení o přijetí",
|
||||
"Show read receipts sent by other users": "Zobrazovat potvrzení o přečtení",
|
||||
"Scissors": "Nůžky",
|
||||
"Accept all %(invitedRooms)s invites": "Přijmout pozvání do všech těchto místností: %(invitedRooms)s",
|
||||
"Change room avatar": "Změnit avatar místnosti",
|
||||
|
@ -3155,7 +3155,7 @@
|
|||
"Invite People": "Pozvat lidi",
|
||||
"Invite with email or username": "Pozvěte e-mailem nebo uživatelským jménem",
|
||||
"You can change these anytime.": "Tyto údaje můžete kdykoli změnit.",
|
||||
"Add some details to help people recognise it.": "Přidejte několik podrobností, aby to lidé lépe rozpoznali.",
|
||||
"Add some details to help people recognise it.": "Přidejte nějaké podrobnosti, aby ho lidé lépe rozpoznali.",
|
||||
"Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Prostory jsou nový způsob, jak seskupovat místnosti a lidi. Chcete-li se připojit ke stávajícímu prostoru, budete potřebovat pozvánku.",
|
||||
"A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "K vašemu účtu přistupuje nové přihlášení: %(name)s (%(deviceID)s) pomocí %(ip)s",
|
||||
"From %(deviceName)s (%(deviceId)s) at %(ip)s": "Z %(deviceName)s (%(deviceId)s) pomocí %(ip)s",
|
||||
|
@ -3201,7 +3201,7 @@
|
|||
"Please choose a strong password": "Vyberte silné heslo",
|
||||
"What are some things you want to discuss in %(spaceName)s?": "O kterých tématech chcete diskutovat v %(spaceName)s?",
|
||||
"Let's create a room for each of them.": "Vytvořme pro každé z nich místnost.",
|
||||
"Use another login": "Použijte jiné přihlašovací jméno",
|
||||
"Use another login": "Použít jinou relaci",
|
||||
"Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Bez ověření nebudete mít přístup ke všem svým zprávám a ostatním se můžete zobrazit jako nedůvěryhodný.",
|
||||
"You are the only person here. If you leave, no one will be able to join in the future, including you.": "Jste zde jediná osoba. Pokud odejdete, nikdo se v budoucnu nebude moci připojit, včetně vás.",
|
||||
"If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Pokud vše resetujete, začnete bez důvěryhodných relací, bez důvěryhodných uživatelů a možná nebudete moci zobrazit minulé zprávy.",
|
||||
|
@ -3225,5 +3225,16 @@
|
|||
"Including %(commaSeparatedMembers)s": "Včetně %(commaSeparatedMembers)s",
|
||||
"View all %(count)s members|one": "Zobrazit jednoho člena",
|
||||
"View all %(count)s members|other": "Zobrazit všech %(count)s členů",
|
||||
"Failed to send": "Odeslání se nezdařilo"
|
||||
"Failed to send": "Odeslání se nezdařilo",
|
||||
"What do you want to organise?": "Co si přejete organizovat?",
|
||||
"Filter all spaces": "Filtrovat všechny prostory",
|
||||
"Delete recording": "Smazat zvukovou zprávu",
|
||||
"Stop the recording": "Zastavit nahrávání",
|
||||
"%(count)s results in all spaces|one": "%(count)s výsledek ve všech prostorech",
|
||||
"%(count)s results in all spaces|other": "%(count)s výsledků ve všech prostorech",
|
||||
"Play": "Přehrát",
|
||||
"Pause": "Pozastavit",
|
||||
"Enter your Security Phrase a second time to confirm it.": "Zadejte bezpečnostní frázi podruhé a potvrďte ji.",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Vyberte místnosti nebo konverzace, které chcete přidat. Toto je prostor pouze pro vás, nikdo nebude informován. Později můžete přidat další.",
|
||||
"You have no ignored users.": "Nemáte žádné ignorované uživatele."
|
||||
}
|
||||
|
|
|
@ -88,7 +88,7 @@
|
|||
"Unban": "Verbannung aufheben",
|
||||
"unknown error code": "Unbekannter Fehlercode",
|
||||
"Upload avatar": "Profilbild hochladen",
|
||||
"Upload file": "Datei hochladen",
|
||||
"Upload file": "Datei senden",
|
||||
"Users": "Benutzer",
|
||||
"Verification Pending": "Verifizierung ausstehend",
|
||||
"Video call": "Videoanruf",
|
||||
|
@ -114,7 +114,7 @@
|
|||
"VoIP is unsupported": "VoIP wird nicht unterstützt",
|
||||
"You are already in a call.": "Du bist bereits in einem Gespräch.",
|
||||
"You cannot place a call with yourself.": "Du kannst keinen Anruf mit dir selbst starten.",
|
||||
"You cannot place VoIP calls in this browser.": "VoIP-Gespräche werden von diesem Browser nicht unterstützt.",
|
||||
"You cannot place VoIP calls in this browser.": "Anrufe werden von diesem Browser nicht unterstützt.",
|
||||
"Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "Deine E-Mail-Adresse scheint nicht mit einer Matrix-ID auf diesem Heimserver verbunden zu sein.",
|
||||
"Sun": "So",
|
||||
"Mon": "Mo",
|
||||
|
@ -1634,7 +1634,7 @@
|
|||
"Show rooms with unread notifications first": "Räume mit ungelesenen Benachrichtigungen zuerst zeigen",
|
||||
"Show shortcuts to recently viewed rooms above the room list": "Kürzlich besuchte Räume anzeigen",
|
||||
"Use Single Sign On to continue": "Einmalanmeldung zum Fortfahren nutzen",
|
||||
"Confirm adding this email address by using Single Sign On to prove your identity.": "Bestätige die hinzugefügte E-Mail-Adresse mit der Einmalanmeldung, um deine Identität nachzuweisen.",
|
||||
"Confirm adding this email address by using Single Sign On to prove your identity.": "Bestätige das Hinzufügen dieser E-Mail-Adresse durch Single Sign-on, um deine Identität nachzuweisen.",
|
||||
"Single Sign On": "Einmalanmeldung",
|
||||
"Confirm adding email": "Hinzugefügte E-Mail-Addresse bestätigen",
|
||||
"Confirm adding this phone number by using Single Sign On to prove your identity.": "Bestätige die hinzugefügte Telefonnummer, indem du deine Identität mittels der Einmalanmeldung nachweist.",
|
||||
|
@ -2951,7 +2951,7 @@
|
|||
"Call failed because no webcam or microphone could not be accessed. Check that:": "Der Anruf ist fehlgeschlagen weil nicht auf das Mikrofon oder die Webcam zugegriffen werden konnte. Stelle sicher, dass:",
|
||||
"Unable to access webcam / microphone": "Auf Webcam / Mikrofon konnte nicht zugegriffen werden",
|
||||
"Unable to access microphone": "Es konnte nicht auf das Mikrofon zugegriffen werden",
|
||||
"Host account on": "Benutzer*innenkonto betreiben an",
|
||||
"Host account on": "Konto betreiben auf",
|
||||
"Hold": "Halten",
|
||||
"Resume": "Fortsetzen",
|
||||
"We call the places where you can host your account ‘homeservers’.": "Den Ort, an dem du dein Konto betreibst, nennen wir „Heimserver“.",
|
||||
|
@ -3282,5 +3282,16 @@
|
|||
"If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Falls du es wirklich willst: Es werden keine Nachrichten gelöscht. Außerdem wird die Suche, während der Index erstellt wird, etwas langsamer sein",
|
||||
"%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s Mitglieder inklusive %(commaSeparatedMembers)s",
|
||||
"Including %(commaSeparatedMembers)s": "Inklusive%(commaSeparatedMembers)s",
|
||||
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>": "Beratung mit %(transferTarget)s. <a>Übertragung zu %(transferee)s</a>"
|
||||
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>": "Beratung mit %(transferTarget)s. <a>Übertragung zu %(transferee)s</a>",
|
||||
"Play": "Abspielen",
|
||||
"Pause": "Pause",
|
||||
"What do you want to organise?": "Was willst du organisieren?",
|
||||
"Enter your Security Phrase a second time to confirm it.": "Gib dein Kennwort ein zweites Mal zur Bestätigung ein.",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Wähle Räume oder Konversationen die Du hinzufügen möchtest. Dieser Bereich ist nur für Dich, niemand wird informiert. Du kannst später mehr hinzufügen.",
|
||||
"Filter all spaces": "Alle Bereiche filtern",
|
||||
"Delete recording": "Aufnahme löschen",
|
||||
"Stop the recording": "Aufnahme stoppen",
|
||||
"%(count)s results in all spaces|one": "%(count)s Ergebnis in allen Bereichen",
|
||||
"%(count)s results in all spaces|other": "%(count)s Ergebnisse in allen Bereichen",
|
||||
"You have no ignored users.": "Du ignorierst keine Benutzer."
|
||||
}
|
||||
|
|
|
@ -578,14 +578,6 @@
|
|||
"%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s",
|
||||
"Light": "Light",
|
||||
"Dark": "Dark",
|
||||
"You signed in to a new session without verifying it:": "You signed in to a new session without verifying it:",
|
||||
"Verify your other session using one of the options below.": "Verify your other session using one of the options below.",
|
||||
"%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) signed in to a new session without verifying it:",
|
||||
"Ask this user to verify their session, or manually verify it below.": "Ask this user to verify their session, or manually verify it below.",
|
||||
"Not Trusted": "Not Trusted",
|
||||
"Manually Verify by Text": "Manually Verify by Text",
|
||||
"Interactively verify by Emoji": "Interactively verify by Emoji",
|
||||
"Done": "Done",
|
||||
"%(displayName)s is typing …": "%(displayName)s is typing …",
|
||||
"%(names)s and %(count)s others are typing …|other": "%(names)s and %(count)s others are typing …",
|
||||
"%(names)s and %(count)s others are typing …|one": "%(names)s and one other is typing …",
|
||||
|
@ -608,6 +600,10 @@
|
|||
"See when the avatar changes in this room": "See when the avatar changes in this room",
|
||||
"Change the avatar of your active room": "Change the avatar of your active room",
|
||||
"See when the avatar changes in your active room": "See when the avatar changes in your active room",
|
||||
"Kick, ban, or invite people to this room, and make you leave": "Kick, ban, or invite people to this room, and make you leave",
|
||||
"See when people join, leave, or are invited to this room": "See when people join, leave, or are invited to this room",
|
||||
"Kick, ban, or invite people to your active room, and make you leave": "Kick, ban, or invite people to your active room, and make you leave",
|
||||
"See when people join, leave, or are invited to your active room": "See when people join, leave, or are invited to your active room",
|
||||
"Send stickers to this room as you": "Send stickers to this room as you",
|
||||
"See when a sticker is posted in this room": "See when a sticker is posted in this room",
|
||||
"Send stickers to your active room as you": "Send stickers to your active room as you",
|
||||
|
@ -785,8 +781,16 @@
|
|||
"%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s",
|
||||
"Change notification settings": "Change notification settings",
|
||||
"Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.",
|
||||
"Spaces": "Spaces",
|
||||
"Spaces are a new way to group rooms and people.": "Spaces are a new way to group rooms and people.",
|
||||
"If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.",
|
||||
"Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta available for web, desktop and Android. Thank you for trying the beta.",
|
||||
"%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.",
|
||||
"You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "You can leave the beta any time from settings or tapping on a beta badge, like the one above.",
|
||||
"Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.",
|
||||
"Your feedback will help make spaces better. The more detail you can go into, the better.": "Your feedback will help make spaces better. The more detail you can go into, the better.",
|
||||
"Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode",
|
||||
"Send and receive voice messages (in development)": "Send and receive voice messages (in development)",
|
||||
"Send and receive voice messages": "Send and receive voice messages",
|
||||
"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.",
|
||||
"New spinner design": "New spinner design",
|
||||
|
@ -880,6 +884,8 @@
|
|||
"sends fireworks": "sends fireworks",
|
||||
"Sends the given message with snowfall": "Sends the given message with snowfall",
|
||||
"sends snowfall": "sends snowfall",
|
||||
"Sends the given message with a space themed effect": "Sends the given message with a space themed effect",
|
||||
"sends space invaders": "sends space invaders",
|
||||
"unknown person": "unknown person",
|
||||
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
|
||||
"You held the call <a>Switch</a>": "You held the call <a>Switch</a>",
|
||||
|
@ -996,8 +1002,9 @@
|
|||
"Upload": "Upload",
|
||||
"Name": "Name",
|
||||
"Description": "Description",
|
||||
"Please enter a name for the space": "Please enter a name for the space",
|
||||
"Create a space": "Create a space",
|
||||
"Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.",
|
||||
"Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.",
|
||||
"Public": "Public",
|
||||
"Open space for anyone, best for communities": "Open space for anyone, best for communities",
|
||||
"Private": "Private",
|
||||
|
@ -1088,7 +1095,7 @@
|
|||
"Securely cache encrypted messages locally for them to appear in search results.": "Securely cache encrypted messages locally for them to appear in search results.",
|
||||
"%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with <nativeLink>search components added</nativeLink>.": "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with <nativeLink>search components added</nativeLink>.",
|
||||
"%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> for encrypted messages to appear in search results.": "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> for encrypted messages to appear in search results.",
|
||||
"Message search initilisation failed": "Message search initilisation failed",
|
||||
"Message search initialisation failed": "Message search initialisation failed",
|
||||
"Connecting to integration manager...": "Connecting to integration manager...",
|
||||
"Cannot connect to integration manager": "Cannot connect to integration manager",
|
||||
"The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.",
|
||||
|
@ -1258,7 +1265,7 @@
|
|||
"Copy": "Copy",
|
||||
"Clear cache and reload": "Clear cache and reload",
|
||||
"Labs": "Labs",
|
||||
"Customise your experience with experimental labs features. <a>Learn more</a>.": "Customise your experience with experimental labs features. <a>Learn more</a>.",
|
||||
"Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. <a>Learn more</a>.": "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. <a>Learn more</a>.",
|
||||
"Ignored/Blocked": "Ignored/Blocked",
|
||||
"Error adding ignored user/server": "Error adding ignored user/server",
|
||||
"Something went wrong. Please try again or view your console for hints.": "Something went wrong. Please try again or view your console for hints.",
|
||||
|
@ -1855,6 +1862,7 @@
|
|||
"You sent a verification request": "You sent a verification request",
|
||||
"Error decrypting video": "Error decrypting video",
|
||||
"Error processing voice message": "Error processing voice message",
|
||||
"Add reaction": "Add reaction",
|
||||
"Show all": "Show all",
|
||||
"Reactions": "Reactions",
|
||||
"<reactors/><reactedWith> reacted with %(content)s</reactedWith>": "<reactors/><reactedWith> reacted with %(content)s</reactedWith>",
|
||||
|
@ -1939,8 +1947,8 @@
|
|||
"%(count)s people you know have already joined|one": "%(count)s person you know has already joined",
|
||||
"Zoom out": "Zoom out",
|
||||
"Zoom in": "Zoom in",
|
||||
"Rotate Right": "Rotate Right",
|
||||
"Rotate Left": "Rotate Left",
|
||||
"Rotate Right": "Rotate Right",
|
||||
"Download": "Download",
|
||||
"Information": "Information",
|
||||
"View message": "View message",
|
||||
|
@ -2019,6 +2027,7 @@
|
|||
"Home": "Home",
|
||||
"Enter a server name": "Enter a server name",
|
||||
"Looks good": "Looks good",
|
||||
"You are not allowed to view this server's rooms list": "You are not allowed to view this server's rooms list",
|
||||
"Can't find this server or its room list": "Can't find this server or its room list",
|
||||
"Your server": "Your server",
|
||||
"Are you sure you want to remove <b>%(serverName)s</b>": "Are you sure you want to remove <b>%(serverName)s</b>",
|
||||
|
@ -2030,14 +2039,15 @@
|
|||
"Add a new server...": "Add a new server...",
|
||||
"%(networkName)s rooms": "%(networkName)s rooms",
|
||||
"Matrix rooms": "Matrix rooms",
|
||||
"Filter your rooms and spaces": "Filter your rooms and spaces",
|
||||
"Spaces": "Spaces",
|
||||
"Direct Messages": "Direct Messages",
|
||||
"Space selection": "Space selection",
|
||||
"Add existing rooms": "Add existing rooms",
|
||||
"Not all selected were added": "Not all selected were added",
|
||||
"Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)",
|
||||
"Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...",
|
||||
"Filter your rooms and spaces": "Filter your rooms and spaces",
|
||||
"Feeling experimental?": "Feeling experimental?",
|
||||
"You can add existing spaces to a space.": "You can add existing spaces to a space.",
|
||||
"Direct Messages": "Direct Messages",
|
||||
"Space selection": "Space selection",
|
||||
"Add existing rooms": "Add existing rooms",
|
||||
"Want to add a new room instead?": "Want to add a new room instead?",
|
||||
"Create a new room": "Create a new room",
|
||||
"Matrix ID": "Matrix ID",
|
||||
|
@ -2053,6 +2063,15 @@
|
|||
"Invite anyway and never warn me again": "Invite anyway and never warn me again",
|
||||
"Invite anyway": "Invite anyway",
|
||||
"Close dialog": "Close dialog",
|
||||
"Beta feedback": "Beta feedback",
|
||||
"Thank you for your feedback, we really appreciate it.": "Thank you for your feedback, we really appreciate it.",
|
||||
"Done": "Done",
|
||||
"%(featureName)s beta feedback": "%(featureName)s beta feedback",
|
||||
"Your platform and username will be noted to help us use your feedback as much as we can.": "Your platform and username will be noted to help us use your feedback as much as we can.",
|
||||
"To leave the beta, visit your settings.": "To leave the beta, visit your settings.",
|
||||
"Feedback": "Feedback",
|
||||
"You may contact me if you have any follow up questions": "You may contact me if you have any follow up questions",
|
||||
"Send feedback": "Send feedback",
|
||||
"Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.",
|
||||
"Preparing to send logs": "Preparing to send logs",
|
||||
"Logs sent": "Logs sent",
|
||||
|
@ -2183,10 +2202,8 @@
|
|||
"Comment": "Comment",
|
||||
"There are two ways you can provide feedback and help us improve %(brand)s.": "There are two ways you can provide feedback and help us improve %(brand)s.",
|
||||
"PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> to help us track down the problem.": "PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> to help us track down the problem.",
|
||||
"Feedback": "Feedback",
|
||||
"Report a bug": "Report a bug",
|
||||
"Please view <existingIssuesLink>existing bugs on Github</existingIssuesLink> first. No match? <newIssueLink>Start a new one</newIssueLink>.": "Please view <existingIssuesLink>existing bugs on Github</existingIssuesLink> first. No match? <newIssueLink>Start a new one</newIssueLink>.",
|
||||
"Send feedback": "Send feedback",
|
||||
"Confirm abort of host creation": "Confirm abort of host creation",
|
||||
"Are you sure you wish to abort creation of the host? The process cannot be continued.": "Are you sure you wish to abort creation of the host? The process cannot be continued.",
|
||||
"Abort": "Abort",
|
||||
|
@ -2372,6 +2389,13 @@
|
|||
"Summary": "Summary",
|
||||
"Document": "Document",
|
||||
"Next": "Next",
|
||||
"You signed in to a new session without verifying it:": "You signed in to a new session without verifying it:",
|
||||
"Verify your other session using one of the options below.": "Verify your other session using one of the options below.",
|
||||
"%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) signed in to a new session without verifying it:",
|
||||
"Ask this user to verify their session, or manually verify it below.": "Ask this user to verify their session, or manually verify it below.",
|
||||
"Not Trusted": "Not Trusted",
|
||||
"Manually Verify by Text": "Manually Verify by Text",
|
||||
"Interactively verify by Emoji": "Interactively verify by Emoji",
|
||||
"Upload files (%(current)s of %(total)s)": "Upload files (%(current)s of %(total)s)",
|
||||
"Upload files": "Upload files",
|
||||
"Upload all": "Upload all",
|
||||
|
@ -2464,6 +2488,11 @@
|
|||
"Revoke permissions": "Revoke permissions",
|
||||
"Move left": "Move left",
|
||||
"Move right": "Move right",
|
||||
"Spaces is a beta feature": "Spaces is a beta feature",
|
||||
"Tap for more info": "Tap for more info",
|
||||
"Beta": "Beta",
|
||||
"Leave the beta": "Leave the beta",
|
||||
"Join the beta": "Join the beta",
|
||||
"Avatar": "Avatar",
|
||||
"This room is public": "This room is public",
|
||||
"Away": "Away",
|
||||
|
@ -2597,6 +2626,7 @@
|
|||
"Error whilst fetching joined communities": "Error whilst fetching joined communities",
|
||||
"Create a new community": "Create a new community",
|
||||
"Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.",
|
||||
"Communities are changing to Spaces": "Communities are changing to Spaces",
|
||||
"You’re all caught up": "You’re all caught up",
|
||||
"You have no visible notifications.": "You have no visible notifications.",
|
||||
"%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.",
|
||||
|
@ -2664,12 +2694,15 @@
|
|||
"Mark as suggested": "Mark as suggested",
|
||||
"No results found": "No results found",
|
||||
"You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.",
|
||||
"Search names and description": "Search names and description",
|
||||
"Search names and descriptions": "Search names and descriptions",
|
||||
"If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
|
||||
"Create room": "Create room",
|
||||
"Spaces are a beta feature.": "Spaces are a beta feature.",
|
||||
"Public space": "Public space",
|
||||
"Private space": "Private space",
|
||||
"<inviter/> invites you": "<inviter/> invites you",
|
||||
"To view %(spaceName)s, turn on the <a>Spaces beta</a>": "To view %(spaceName)s, turn on the <a>Spaces beta</a>",
|
||||
"To join %(spaceName)s, turn on the <a>Spaces beta</a>": "To join %(spaceName)s, turn on the <a>Spaces beta</a>",
|
||||
"Welcome to <name/>": "Welcome to <name/>",
|
||||
"Random": "Random",
|
||||
"Support": "Support",
|
||||
|
@ -2677,13 +2710,12 @@
|
|||
"Failed to create initial space rooms": "Failed to create initial space rooms",
|
||||
"Skip for now": "Skip for now",
|
||||
"Creating rooms...": "Creating rooms...",
|
||||
"Failed to add rooms to space": "Failed to add rooms to space",
|
||||
"Adding...": "Adding...",
|
||||
"What do you want to organise?": "What do you want to organise?",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.",
|
||||
"Share %(name)s": "Share %(name)s",
|
||||
"It's just you at the moment, it will be even better with others.": "It's just you at the moment, it will be even better with others.",
|
||||
"Go to my first room": "Go to my first room",
|
||||
"Go to my space": "Go to my space",
|
||||
"Who are you working with?": "Who are you working with?",
|
||||
"Make sure the right people have access to %(name)s": "Make sure the right people have access to %(name)s",
|
||||
"Just me": "Just me",
|
||||
|
@ -2694,6 +2726,7 @@
|
|||
"Inviting...": "Inviting...",
|
||||
"Invite your teammates": "Invite your teammates",
|
||||
"Make sure the right people have access. You can invite more later.": "Make sure the right people have access. You can invite more later.",
|
||||
"<b>This is an experimental feature.</b> For now, new users receiving an invite will have to open the invite on <link/> to actually join.": "<b>This is an experimental feature.</b> For now, new users receiving an invite will have to open the invite on <link/> to actually join.",
|
||||
"Invite by username": "Invite by username",
|
||||
"What are some things you want to discuss in %(spaceName)s?": "What are some things you want to discuss in %(spaceName)s?",
|
||||
"Let's create a room for each of them.": "Let's create a room for each of them.",
|
||||
|
@ -2806,6 +2839,7 @@
|
|||
"Room Notification": "Room Notification",
|
||||
"Notification Autocomplete": "Notification Autocomplete",
|
||||
"Room Autocomplete": "Room Autocomplete",
|
||||
"Space Autocomplete": "Space Autocomplete",
|
||||
"Users": "Users",
|
||||
"User Autocomplete": "User Autocomplete",
|
||||
"We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.": "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.",
|
||||
|
|
|
@ -3248,5 +3248,16 @@
|
|||
"%(seconds)ss left": "%(seconds)ss restantes",
|
||||
"Failed to send": "No se ha podido mandar",
|
||||
"Change server ACLs": "Cambiar los ACLs del servidor",
|
||||
"Show options to enable 'Do not disturb' mode": "Mostrar opciones para activar el modo «no molestar»"
|
||||
"Show options to enable 'Do not disturb' mode": "Mostrar opciones para activar el modo «no molestar»",
|
||||
"Stop the recording": "Parar grabación",
|
||||
"Delete recording": "Borrar grabación",
|
||||
"Enter your Security Phrase a second time to confirm it.": "Escribe tu frase de seguridad de nuevo para confirmarla.",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Elige salas o conversaciones para añadirlas. Este espacio es solo para ti, no informaremos a nadie. Puedes añadir más más tarde.",
|
||||
"What do you want to organise?": "¿Qué quieres organizar?",
|
||||
"Filter all spaces": "Filtrar todos los espacios",
|
||||
"%(count)s results in all spaces|one": "%(count)s resultado en todos los espacios",
|
||||
"%(count)s results in all spaces|other": "%(count)s resultados en todos los espacios",
|
||||
"You have no ignored users.": "No has ignorado a nadie.",
|
||||
"Pause": "Pausar",
|
||||
"Play": "Reproducir"
|
||||
}
|
||||
|
|
|
@ -3286,5 +3286,16 @@
|
|||
"Including %(commaSeparatedMembers)s": "Sealhulgas %(commaSeparatedMembers)s",
|
||||
"View all %(count)s members|one": "Vaata üht liiget",
|
||||
"View all %(count)s members|other": "Vaata kõiki %(count)s liiget",
|
||||
"Failed to send": "Saatmine ei õnnestunud"
|
||||
"Failed to send": "Saatmine ei õnnestunud",
|
||||
"Enter your Security Phrase a second time to confirm it.": "Kinnitamiseks palun sisesta turvafraas teist korda.",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Lisamiseks vali vestlusi ja jututubasid. Hetkel on see kogukonnakeskus vaid sinu jaoks ja esialgu keegi ei saa sellest teada. Teisi saad liituma kutsuda hiljem.",
|
||||
"What do you want to organise?": "Mida sa soovid ette võtta?",
|
||||
"Filter all spaces": "Otsi kõikides kogukonnakeskustest",
|
||||
"Delete recording": "Kustuta salvestus",
|
||||
"Stop the recording": "Lõpeta salvestamine",
|
||||
"%(count)s results in all spaces|one": "%(count)s tulemus kõikides kogukonnakeskustes",
|
||||
"%(count)s results in all spaces|other": "%(count)s tulemust kõikides kogukonnakeskustes",
|
||||
"You have no ignored users.": "Sa ei ole veel kedagi eiranud.",
|
||||
"Play": "Esita",
|
||||
"Pause": "Peata"
|
||||
}
|
||||
|
|
|
@ -3024,7 +3024,7 @@
|
|||
"Use Command + F to search": "Utilisez Commande + F pour rechercher",
|
||||
"Show line numbers in code blocks": "Afficher les numéros de ligne dans les blocs de code",
|
||||
"Expand code blocks by default": "Développer les blocs de code par défaut",
|
||||
"Show stickers button": "Afficher le bouton autocollants",
|
||||
"Show stickers button": "Afficher le bouton des autocollants",
|
||||
"Use app": "Utiliser l’application",
|
||||
"Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element Web est expérimental sur téléphone. Pour une meilleure expérience et bénéficier des dernières fonctionnalités, utilisez notre application native gratuite.",
|
||||
"Use app for a better experience": "Utilisez une application pour une meilleure expérience",
|
||||
|
@ -3285,5 +3285,16 @@
|
|||
"Including %(commaSeparatedMembers)s": "Dont %(commaSeparatedMembers)s",
|
||||
"View all %(count)s members|one": "Afficher le membre",
|
||||
"View all %(count)s members|other": "Afficher les %(count)s membres",
|
||||
"Failed to send": "Échec de l’envoi"
|
||||
"Failed to send": "Échec de l’envoi",
|
||||
"Play": "Lecture",
|
||||
"Pause": "Pause",
|
||||
"Enter your Security Phrase a second time to confirm it.": "Saisissez à nouveau votre phrase secrète pour la confirmer.",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Choisissez des salons ou conversations à ajouter. C’est un espace rien que pour vous, personne n’en sera informé. Vous pourrez en ajouter plus tard.",
|
||||
"What do you want to organise?": "Que voulez-vous organiser ?",
|
||||
"Filter all spaces": "Filtrer tous les espaces",
|
||||
"Delete recording": "Supprimer l’enregistrement",
|
||||
"Stop the recording": "Arrêter l’enregistrement",
|
||||
"%(count)s results in all spaces|one": "%(count)s résultat dans tous les espaces",
|
||||
"%(count)s results in all spaces|other": "%(count)s résultats dans tous les espaces",
|
||||
"You have no ignored users.": "Vous n’avez ignoré personne."
|
||||
}
|
||||
|
|
|
@ -3308,5 +3308,16 @@
|
|||
"Including %(commaSeparatedMembers)s": "Incluíndo a %(commaSeparatedMembers)s",
|
||||
"View all %(count)s members|one": "Ver 1 membro",
|
||||
"View all %(count)s members|other": "Ver tódolos %(count)s membros",
|
||||
"Failed to send": "Fallou o envío"
|
||||
"Failed to send": "Fallou o envío",
|
||||
"Enter your Security Phrase a second time to confirm it.": "Escribe a túa Frase de Seguridade por segunda vez para confirmala.",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Elixe salas ou conversas para engadilas. Este é un espazo para ti, ninguén será notificado. Podes engadir máis posteriormente.",
|
||||
"What do you want to organise?": "Que queres organizar?",
|
||||
"Filter all spaces": "Filtrar os espazos",
|
||||
"Delete recording": "Eliminar a gravación",
|
||||
"Stop the recording": "Deter a gravación",
|
||||
"%(count)s results in all spaces|one": "%(count)s resultado en tódolos espazos",
|
||||
"%(count)s results in all spaces|other": "%(count)s resultados en tódolos espazos",
|
||||
"You have no ignored users.": "Non tes usuarias ignoradas.",
|
||||
"Play": "Reproducir",
|
||||
"Pause": "Deter"
|
||||
}
|
||||
|
|
|
@ -3303,5 +3303,16 @@
|
|||
"Including %(commaSeparatedMembers)s": "Beleértve: %(commaSeparatedMembers)s",
|
||||
"View all %(count)s members|one": "1 résztvevő megmutatása",
|
||||
"View all %(count)s members|other": "Az összes %(count)s résztvevő megmutatása",
|
||||
"Failed to send": "Küldés sikertelen"
|
||||
"Failed to send": "Küldés sikertelen",
|
||||
"Enter your Security Phrase a second time to confirm it.": "A megerősítéshez adja meg a biztonsági jelmondatot még egyszer.",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Válassz szobákat vagy beszélgetéseket amit hozzáadhat. Ez csak az ön tere, senki nem lesz értesítve. Továbbiakat később is hozzáadhat.",
|
||||
"What do you want to organise?": "Mit szeretne megszervezni?",
|
||||
"Filter all spaces": "Minden tér szűrése",
|
||||
"Delete recording": "Felvétel törlése",
|
||||
"Stop the recording": "Felvétel megállítása",
|
||||
"%(count)s results in all spaces|one": "%(count)s találat van az összes térben",
|
||||
"%(count)s results in all spaces|other": "%(count)s találat a terekben",
|
||||
"You have no ignored users.": "Nincs figyelmen kívül hagyott felhasználó.",
|
||||
"Play": "Lejátszás",
|
||||
"Pause": "Szünet"
|
||||
}
|
||||
|
|
|
@ -3308,5 +3308,16 @@
|
|||
"Including %(commaSeparatedMembers)s": "Inclusi %(commaSeparatedMembers)s",
|
||||
"View all %(count)s members|one": "Vedi 1 membro",
|
||||
"View all %(count)s members|other": "Vedi tutti i %(count)s membri",
|
||||
"Failed to send": "Invio fallito"
|
||||
"Failed to send": "Invio fallito",
|
||||
"Enter your Security Phrase a second time to confirm it.": "Inserisci di nuovo la password di sicurezza per confermarla.",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Scegli le stanze o le conversazioni da aggiungere. Questo è uno spazio solo per te, nessuno ne saprà nulla. Puoi aggiungerne altre in seguito.",
|
||||
"What do you want to organise?": "Cosa vuoi organizzare?",
|
||||
"Filter all spaces": "Filtra tutti gli spazi",
|
||||
"Delete recording": "Elimina registrazione",
|
||||
"Stop the recording": "Ferma la registrazione",
|
||||
"%(count)s results in all spaces|one": "%(count)s risultato in tutti gli spazi",
|
||||
"%(count)s results in all spaces|other": "%(count)s risultati in tutti gli spazi",
|
||||
"You have no ignored users.": "Non hai utenti ignorati.",
|
||||
"Play": "Riproduci",
|
||||
"Pause": "Pausa"
|
||||
}
|
||||
|
|
|
@ -1517,7 +1517,7 @@
|
|||
"Explore rooms": "Gesprekken ontdekken",
|
||||
"Show previews/thumbnails for images": "Miniaturen voor afbeeldingen tonen",
|
||||
"Clear cache and reload": "Cache wissen en herladen",
|
||||
"You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "U staat op het punt 1 bericht door %(user)s te verwijderen. Dit is onherroepelijk. Wilt u doorgaan?",
|
||||
"You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "U staat op het punt 1 bericht door %(user)s te verwijderen. Dit kan niet ongedaan gemaakt worden. Wilt u doorgaan?",
|
||||
"Remove %(count)s messages|one": "1 bericht verwijderen",
|
||||
"%(count)s unread messages including mentions.|other": "%(count)s ongelezen berichten, inclusief vermeldingen.",
|
||||
"%(count)s unread messages.|other": "%(count)s ongelezen berichten.",
|
||||
|
@ -1932,7 +1932,7 @@
|
|||
"Jump to first unread room.": "Ga naar het eerste ongelezen gesprek.",
|
||||
"Jump to first invite.": "Ga naar de eerste uitnodiging.",
|
||||
"Session verified": "Sessie geverifieerd",
|
||||
"Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Uw nieuwe sessie is nu geverifieerd. Ze heeft nu toegang tot uw versleutelde berichten, en de sessie zal voor andere gebruikers als vertrouwd gemarkeerd worden.",
|
||||
"Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Uw nieuwe sessie is nu geverifieerd. U heeft nu toegang tot uw versleutelde berichten, en deze sessie zal voor andere gebruikers als vertrouwd gemarkeerd worden.",
|
||||
"Your new session is now verified. Other users will see it as trusted.": "Uw nieuwe sessie is nu geverifieerd. Ze zal voor andere gebruikers als vertrouwd gemarkeerd worden.",
|
||||
"Without completing security on this session, it won’t have access to encrypted messages.": "Als u de beveiliging van deze sessie niet vervolledigt, zal ze geen toegang hebben tot uw versleutelde berichten.",
|
||||
"Go Back": "Terugkeren",
|
||||
|
@ -2366,7 +2366,7 @@
|
|||
"Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "De beheerder van uw server heeft eind-tot-eind-versleuteling standaard uitgeschakeld in alle privégesprekken en directe gesprekken.",
|
||||
"Scroll to most recent messages": "Spring naar meest recente bericht",
|
||||
"The authenticity of this encrypted message can't be guaranteed on this device.": "De echtheid van dit versleutelde bericht kan op dit apparaat niet worden gegarandeerd.",
|
||||
"To link to this room, please add an address.": "Voeg een adres toe om naar deze kamer te verwijzen.",
|
||||
"To link to this room, please add an address.": "Voeg een adres toe om naar dit gesprek te kunnen verwijzen.",
|
||||
"Remove messages sent by others": "Berichten van anderen verwijderen",
|
||||
"Privacy": "Privacy",
|
||||
"Keyboard Shortcuts": "Sneltoetsen",
|
||||
|
@ -3170,7 +3170,7 @@
|
|||
"Share decryption keys for room history when inviting users": "Deel ontsleutelsleutels voor de gespreksgeschiedenis wanneer u personen uitnodigd",
|
||||
"Send and receive voice messages (in development)": "Verstuur en ontvang audioberichten (in ontwikkeling)",
|
||||
"%(deviceId)s from %(ip)s": "%(deviceId)s van %(ip)s",
|
||||
"Review to ensure your account is safe": "Controleer om u te verzekeren dat uw account veilig is",
|
||||
"Review to ensure your account is safe": "Controleer ze voor de zekerheid dat uw account veilig is",
|
||||
"Sends the given message as a spoiler": "Verstuurt het bericht als een spoiler",
|
||||
"You are the only person here. If you leave, no one will be able to join in the future, including you.": "U bent de enige persoon hier. Als u weggaat, zal niemand in de toekomst kunnen toetreden, u ook niet.",
|
||||
"If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Als u alles reset, zult u opnieuw opstarten zonder vertrouwde sessies, zonder vertrouwde gebruikers, en zult u misschien geen vroegere berichten meer kunnen zien.",
|
||||
|
@ -3192,7 +3192,18 @@
|
|||
"%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s",
|
||||
"%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s leden inclusief %(commaSeparatedMembers)s",
|
||||
"Including %(commaSeparatedMembers)s": "Inclusief %(commaSeparatedMembers)s",
|
||||
"View all %(count)s members|one": "Bekijk 1 lid",
|
||||
"View all %(count)s members|one": "1 lid bekijken",
|
||||
"View all %(count)s members|other": "Bekijk alle %(count)s leden",
|
||||
"Failed to send": "Verzenden is mislukt"
|
||||
"Failed to send": "Verzenden is mislukt",
|
||||
"Enter your Security Phrase a second time to confirm it.": "Voor uw veiligheidswachtwoord een tweede keer in om het te bevestigen.",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Kies een gesprek om hem toe te voegen. Dit is een space voor u, niemand zal hiervan een melding krijgen. U kan er later meer toevoegen.",
|
||||
"What do you want to organise?": "Wat wilt u organiseren?",
|
||||
"Filter all spaces": "Alle spaces filteren",
|
||||
"Delete recording": "Opname verwijderen",
|
||||
"Stop the recording": "Opname stoppen",
|
||||
"%(count)s results in all spaces|one": "%(count)s resultaat in alle spaces",
|
||||
"%(count)s results in all spaces|other": "%(count)s resultaten in alle spaces",
|
||||
"You have no ignored users.": "U heeft geen gebruiker genegeerd.",
|
||||
"Play": "Afspelen",
|
||||
"Pause": "Pauze"
|
||||
}
|
||||
|
|
|
@ -2265,5 +2265,11 @@
|
|||
"There was an error finding this widget.": "Wystąpił błąd podczas próby odnalezienia tego widżetu.",
|
||||
"Active Widgets": "Aktywne widżety",
|
||||
"Encryption not enabled": "Nie włączono szyfrowania",
|
||||
"Encryption enabled": "Włączono szyfrowanie"
|
||||
"Encryption enabled": "Włączono szyfrowanie",
|
||||
"Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Twój serwer domowy był nieosiągalny i nie mógł Cię zalogować. Spróbuj ponownie. Jeśli to się powtórzy, skontaktuj się z administratorem swojego serwera.",
|
||||
"Try again": "Spróbuj ponownie",
|
||||
"We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Poprosiliśmy przeglądarkę o zapamiętanie, z którego serwera głównego korzystasz, aby umożliwić Ci logowanie, ale niestety Twoja przeglądarka o tym zapomniała. Przejdź do strony logowania i spróbuj ponownie.",
|
||||
"We couldn't log you in": "Nie mogliśmy Cię zalogować",
|
||||
"You're already in a call with this person.": "Prowadzisz już rozmowę z tą osobą.",
|
||||
"Already in call": "Już dzwoni"
|
||||
}
|
||||
|
|
|
@ -3294,5 +3294,16 @@
|
|||
"Including %(commaSeparatedMembers)s": "Prfshi %(commaSeparatedMembers)s",
|
||||
"View all %(count)s members|one": "Shihni 1 anëtar",
|
||||
"View all %(count)s members|other": "Shihni krejt %(count)s anëtarët",
|
||||
"Failed to send": "S’u arrit të dërgohet"
|
||||
"Failed to send": "S’u arrit të dërgohet",
|
||||
"Enter your Security Phrase a second time to confirm it.": "Jepni Frazën tuaj të Sigurisë edhe një herë, për ta ripohuar.",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Zgjidhni dhoma ose biseda që të shtohen. Kjo është thjesht një hapësirë për ju, s’do ta dijë kush tjetër. Mund të shtoni të tjerë më vonë.",
|
||||
"What do you want to organise?": "Ç’doni të sistemoni?",
|
||||
"Filter all spaces": "Filtro krejt hapësirat",
|
||||
"Delete recording": "Fshije regjistrimin",
|
||||
"Stop the recording": "Ndale regjistrimin",
|
||||
"%(count)s results in all spaces|one": "%(count)s përfundim në krejt hapësirat",
|
||||
"%(count)s results in all spaces|other": "%(count)s përfundime në krejt hapësirat",
|
||||
"You have no ignored users.": "S’keni përdorues të shpërfillur.",
|
||||
"Play": "Luaje",
|
||||
"Pause": "Ndalesë"
|
||||
}
|
||||
|
|
|
@ -3239,5 +3239,16 @@
|
|||
"%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s",
|
||||
"%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s medlemmar inklusive %(commaSeparatedMembers)s",
|
||||
"Including %(commaSeparatedMembers)s": "Inklusive %(commaSeparatedMembers)s",
|
||||
"Failed to send": "Misslyckades att skicka"
|
||||
"Failed to send": "Misslyckades att skicka",
|
||||
"Enter your Security Phrase a second time to confirm it.": "Ange din säkerhetsfras igen för att bekräfta den.",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Välj rum eller konversationer att lägga till. Detta är bara ett utrymmer för dig, ingen kommer att informeras. Du kan lägga till fler senare.",
|
||||
"What do you want to organise?": "Vad vill du organisera?",
|
||||
"Filter all spaces": "Filtrera alla utrymmen",
|
||||
"Delete recording": "Radera inspelningen",
|
||||
"Stop the recording": "Stoppa inspelningen",
|
||||
"%(count)s results in all spaces|one": "%(count)s resultat i alla utrymmen",
|
||||
"%(count)s results in all spaces|other": "%(count)s resultat i alla utrymmen",
|
||||
"You have no ignored users.": "Du har inga ignorerade användare.",
|
||||
"Play": "Spela",
|
||||
"Pause": "Pausa"
|
||||
}
|
||||
|
|
|
@ -3311,5 +3311,16 @@
|
|||
"Including %(commaSeparatedMembers)s": "包含 %(commaSeparatedMembers)s",
|
||||
"View all %(count)s members|one": "檢視 1 個成員",
|
||||
"View all %(count)s members|other": "檢視全部 %(count)s 個成員",
|
||||
"Failed to send": "傳送失敗"
|
||||
"Failed to send": "傳送失敗",
|
||||
"Enter your Security Phrase a second time to confirm it.": "再次輸入您的安全密語以進行確認。",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "挑選要新增的聊天室或對話。這是專屬於您的空間,不會有人被通知。您稍後可以再新增更多。",
|
||||
"What do you want to organise?": "您想要整理什麼?",
|
||||
"Filter all spaces": "過濾所有空間",
|
||||
"Delete recording": "刪除錄製",
|
||||
"Stop the recording": "停止錄製",
|
||||
"%(count)s results in all spaces|one": "所有空間中有 %(count)s 個結果",
|
||||
"%(count)s results in all spaces|other": "所有空間中有 %(count)s 個結果",
|
||||
"You have no ignored users.": "您沒有忽略的使用者。",
|
||||
"Play": "播放",
|
||||
"Pause": "暫停"
|
||||
}
|
||||
|
|
17
src/identifiers.ts
Normal file
17
src/identifiers.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const ELEMENT_CLIENT_ID = "io.element.web";
|
|
@ -38,7 +38,6 @@ export default class EventIndex extends EventEmitter {
|
|||
this._eventsPerCrawl = 100;
|
||||
this._crawler = null;
|
||||
this._currentCheckpoint = null;
|
||||
this.liveEventsForIndex = new Set();
|
||||
}
|
||||
|
||||
async init() {
|
||||
|
@ -188,16 +187,12 @@ export default class EventIndex extends EventEmitter {
|
|||
return;
|
||||
}
|
||||
|
||||
// If the event is not yet decrypted mark it for the
|
||||
// Event.decrypted callback.
|
||||
if (ev.isBeingDecrypted()) {
|
||||
const eventId = ev.getId();
|
||||
this.liveEventsForIndex.add(eventId);
|
||||
} else {
|
||||
// If the event is decrypted or is unencrypted add it to the
|
||||
// index now.
|
||||
await this.addLiveEventToIndex(ev);
|
||||
// XXX: Private member access
|
||||
await ev._decryptionPromise;
|
||||
}
|
||||
|
||||
await this.addLiveEventToIndex(ev);
|
||||
}
|
||||
|
||||
onRoomStateEvent = async (ev, state) => {
|
||||
|
@ -216,10 +211,7 @@ export default class EventIndex extends EventEmitter {
|
|||
* listener, if so queues it up to be added to the index.
|
||||
*/
|
||||
onEventDecrypted = async (ev, err) => {
|
||||
const eventId = ev.getId();
|
||||
|
||||
// If the event isn't in our live event set, ignore it.
|
||||
if (!this.liveEventsForIndex.delete(eventId)) return;
|
||||
if (err) return;
|
||||
await this.addLiveEventToIndex(ev);
|
||||
}
|
||||
|
@ -523,18 +515,23 @@ export default class EventIndex extends EventEmitter {
|
|||
}
|
||||
});
|
||||
|
||||
const decryptionPromises = [];
|
||||
|
||||
matrixEvents.forEach(ev => {
|
||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {
|
||||
// TODO the decryption promise is a private property, this
|
||||
// should either be made public or we should convert the
|
||||
// event that gets fired when decryption is done into a
|
||||
// promise using the once event emitter method:
|
||||
// https://nodejs.org/api/events.html#events_events_once_emitter_name
|
||||
decryptionPromises.push(ev._decryptionPromise);
|
||||
}
|
||||
});
|
||||
const decryptionPromises = matrixEvents
|
||||
.filter(event => event.isEncrypted())
|
||||
.map(event => {
|
||||
if (event.shouldAttemptDecryption()) {
|
||||
return event.attemptDecryption(client._crypto, {
|
||||
isRetry: true,
|
||||
emit: false,
|
||||
});
|
||||
} else {
|
||||
// TODO the decryption promise is a private property, this
|
||||
// should either be made public or we should convert the
|
||||
// event that gets fired when decryption is done into a
|
||||
// promise using the once event emitter method:
|
||||
// https://nodejs.org/api/events.html#events_events_once_emitter_name
|
||||
return event._decryptionPromise;
|
||||
}
|
||||
});
|
||||
|
||||
// Let us wait for all the events to get decrypted.
|
||||
await Promise.all(decryptionPromises);
|
||||
|
|
|
@ -56,6 +56,15 @@ export function newTranslatableError(message: string) {
|
|||
return error;
|
||||
}
|
||||
|
||||
export function getUserLanguage(): string {
|
||||
const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true);
|
||||
if (language) {
|
||||
return language;
|
||||
} else {
|
||||
return normalizeLanguageKey(getLanguageFromBrowser());
|
||||
}
|
||||
}
|
||||
|
||||
// Function which only purpose is to mark that a string is translatable
|
||||
// Does not actually do anything. It's helpful for automatic extraction of translatable strings
|
||||
export function _td(s: string): string {
|
||||
|
|
|
@ -254,11 +254,15 @@ matrixLinkify.options = {
|
|||
|
||||
target: function(href, type) {
|
||||
if (type === 'url') {
|
||||
const transformed = tryTransformPermalinkToLocalHref(href);
|
||||
if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) {
|
||||
return null;
|
||||
} else {
|
||||
return '_blank';
|
||||
try {
|
||||
const transformed = tryTransformPermalinkToLocalHref(href);
|
||||
if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) {
|
||||
return null;
|
||||
} else {
|
||||
return '_blank';
|
||||
}
|
||||
} catch (e) {
|
||||
// malformed URI
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -73,7 +73,9 @@ class ConsoleLogger {
|
|||
|
||||
// Convert objects and errors to helpful things
|
||||
args = args.map((arg) => {
|
||||
if (arg instanceof Error) {
|
||||
if (arg instanceof DOMException) {
|
||||
return arg.message + ` (${arg.name} | ${arg.code}) ` + (arg.stack ? `\n${arg.stack}` : '');
|
||||
} else if (arg instanceof Error) {
|
||||
return arg.message + (arg.stack ? `\n${arg.stack}` : '');
|
||||
} else if (typeof (arg) === 'object') {
|
||||
try {
|
||||
|
|
|
@ -28,6 +28,7 @@ import * as rageshake from './rageshake';
|
|||
// polyfill textencoder if necessary
|
||||
import * as TextEncodingUtf8 from 'text-encoding-utf-8';
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import SdkConfig from "../SdkConfig";
|
||||
let TextEncoder = window.TextEncoder;
|
||||
if (!TextEncoder) {
|
||||
TextEncoder = TextEncodingUtf8.TextEncoder;
|
||||
|
@ -268,6 +269,25 @@ function uint8ToString(buf: Buffer) {
|
|||
return out;
|
||||
}
|
||||
|
||||
export async function submitFeedback(endpoint: string, label: string, comment: string, canContact = false) {
|
||||
let version = "UNKNOWN";
|
||||
try {
|
||||
version = await PlatformPeg.get().getAppVersion();
|
||||
} catch (err) {} // PlatformPeg already logs this.
|
||||
|
||||
const body = new FormData();
|
||||
body.append("label", label);
|
||||
body.append("text", comment);
|
||||
body.append("can_contact", canContact ? "yes" : "no");
|
||||
|
||||
body.append("app", "element-web");
|
||||
body.append("version", version);
|
||||
body.append("platform", PlatformPeg.get().getHumanReadableName());
|
||||
body.append("user_id", MatrixClientPeg.get()?.getUserId());
|
||||
|
||||
await _submitReport(SdkConfig.get().bug_report_endpoint_url, body, () => {});
|
||||
}
|
||||
|
||||
function _submitReport(endpoint: string, body: FormData, progressCallback: (string) => void) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const req = new XMLHttpRequest();
|
||||
|
|
|
@ -16,8 +16,9 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
import { _td } from '../languageHandler';
|
||||
import { _t, _td } from '../languageHandler';
|
||||
import {
|
||||
NotificationBodyEnabledController,
|
||||
NotificationsEnabledController,
|
||||
|
@ -39,6 +40,7 @@ import { OrderedMultiController } from "./controllers/OrderedMultiController";
|
|||
import { Layout } from "./Layout";
|
||||
import ReducedMotionController from './controllers/ReducedMotionController';
|
||||
import IncompatibleController from "./controllers/IncompatibleController";
|
||||
import SdkConfig from "../SdkConfig";
|
||||
|
||||
// These are just a bunch of helper arrays to avoid copy/pasting a bunch of times
|
||||
const LEVELS_ROOM_SETTINGS = [
|
||||
|
@ -117,6 +119,15 @@ export interface ISetting {
|
|||
// historical settings which we don't want existing user's values be wiped. Do
|
||||
// not use this for new settings.
|
||||
invertedSettingName?: string;
|
||||
|
||||
betaInfo?: {
|
||||
title: string; // _td
|
||||
caption: string; // _td
|
||||
disclaimer?: (enabled: boolean) => ReactNode;
|
||||
image: string; // require(...)
|
||||
feedbackSubheading?: string;
|
||||
feedbackLabel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const SETTINGS: {[setting: string]: ISetting} = {
|
||||
|
@ -127,6 +138,36 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
|||
supportedLevels: LEVELS_FEATURE,
|
||||
default: false,
|
||||
controller: new ReloadOnChangeController(),
|
||||
betaInfo: {
|
||||
title: _td("Spaces"),
|
||||
caption: _td("Spaces are a new way to group rooms and people."),
|
||||
disclaimer: (enabled) => {
|
||||
if (enabled) {
|
||||
return <>
|
||||
<p>{ _t("If you leave, %(brand)s will reload with Spaces disabled. " +
|
||||
"Communities and custom tags will be visible again.", {
|
||||
brand: SdkConfig.get().brand,
|
||||
}) }</p>
|
||||
<p>{ _t("Beta available for web, desktop and Android. Thank you for trying the beta.") }</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<p>{ _t("%(brand)s will reload with Spaces enabled. " +
|
||||
"Communities and custom tags will be hidden.", {
|
||||
brand: SdkConfig.get().brand,
|
||||
}) }</p>
|
||||
<b>{ _t("You can leave the beta any time from settings or tapping on a beta badge, " +
|
||||
"like the one above.") }</b>
|
||||
<p>{ _t("Beta available for web, desktop and Android. " +
|
||||
"Some features may be unavailable on your homeserver.") }</p>
|
||||
</>;
|
||||
},
|
||||
image: require("../../res/img/betas/spaces.png"),
|
||||
feedbackSubheading: _td("Your feedback will help make spaces better. " +
|
||||
"The more detail you can go into, the better."),
|
||||
feedbackLabel: "spaces-feedback",
|
||||
},
|
||||
},
|
||||
"feature_dnd": {
|
||||
isFeature: true,
|
||||
|
@ -136,7 +177,7 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
|||
},
|
||||
"feature_voice_messages": {
|
||||
isFeature: true,
|
||||
displayName: _td("Send and receive voice messages (in development)"),
|
||||
displayName: _td("Send and receive voice messages"),
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
default: false,
|
||||
},
|
|
@ -257,6 +257,15 @@ export default class SettingsStore {
|
|||
return SETTINGS[settingName].isFeature;
|
||||
}
|
||||
|
||||
public static getBetaInfo(settingName: string) {
|
||||
// consider a beta disabled if the config is explicitly set to false, in which case treat as normal Labs flag
|
||||
if (SettingsStore.isFeature(settingName)
|
||||
&& SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, null, true, true) !== false
|
||||
) {
|
||||
return SETTINGS[settingName]?.betaInfo;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a setting is enabled.
|
||||
* If a setting is disabled then it should be hidden from the user.
|
||||
|
@ -445,8 +454,8 @@ export default class SettingsStore {
|
|||
throw new Error("Setting '" + settingName + "' does not appear to be a setting.");
|
||||
}
|
||||
|
||||
// When features are specified in the config.json, we force them as enabled or disabled.
|
||||
if (SettingsStore.isFeature(settingName)) {
|
||||
// When non-beta features are specified in the config.json, we force them as enabled or disabled.
|
||||
if (SettingsStore.isFeature(settingName) && !SETTINGS[settingName]?.betaInfo) {
|
||||
const configVal = SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, roomId, true, true);
|
||||
if (configVal === true || configVal === false) return false;
|
||||
}
|
||||
|
|
|
@ -62,7 +62,7 @@ export const getOrder = (order: string, creationTs: number, roomId: string): Arr
|
|||
|
||||
if (typeof order === "string" && Array.from(order).every((c: string) => {
|
||||
const charCode = c.charCodeAt(0);
|
||||
return charCode >= 0x20 && charCode <= 0x7F;
|
||||
return charCode >= 0x20 && charCode <= 0x7E;
|
||||
})) {
|
||||
validatedOrder = order;
|
||||
}
|
||||
|
|
48
src/stores/SpaceTreeLevelLayoutStore.ts
Normal file
48
src/stores/SpaceTreeLevelLayoutStore.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
const getSpaceCollapsedKey = (roomId: string, parents: Set<string>): string => {
|
||||
const separator = "/";
|
||||
let path = "";
|
||||
if (parents) {
|
||||
for (const entry of parents.entries()) {
|
||||
path += entry + separator;
|
||||
}
|
||||
}
|
||||
return `mx_space_collapsed_${path + roomId}`;
|
||||
};
|
||||
|
||||
export default class SpaceTreeLevelLayoutStore {
|
||||
private static internalInstance: SpaceTreeLevelLayoutStore;
|
||||
|
||||
public static get instance(): SpaceTreeLevelLayoutStore {
|
||||
if (!SpaceTreeLevelLayoutStore.internalInstance) {
|
||||
SpaceTreeLevelLayoutStore.internalInstance = new SpaceTreeLevelLayoutStore();
|
||||
}
|
||||
return SpaceTreeLevelLayoutStore.internalInstance;
|
||||
}
|
||||
|
||||
public setSpaceCollapsedState(roomId: string, parents: Set<string>, collapsed: boolean) {
|
||||
// XXX: localStorage doesn't allow booleans
|
||||
localStorage.setItem(getSpaceCollapsedKey(roomId, parents), collapsed.toString());
|
||||
}
|
||||
|
||||
public getSpaceCollapsedState(roomId: string, parents: Set<string>, fallback: boolean): boolean {
|
||||
const collapsedLocalStorage = localStorage.getItem(getSpaceCollapsedKey(roomId, parents));
|
||||
// XXX: localStorage doesn't allow booleans
|
||||
return collapsedLocalStorage ? collapsedLocalStorage === "true" : fallback;
|
||||
}
|
||||
}
|
|
@ -34,6 +34,7 @@ import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
|
|||
import { getListAlgorithmInstance } from "./list-ordering";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { VisibilityProvider } from "../filters/VisibilityProvider";
|
||||
import { MultiLock } from "../../../utils/MultiLock";
|
||||
|
||||
/**
|
||||
* Fired when the Algorithm has determined a list has been updated.
|
||||
|
@ -77,6 +78,7 @@ export class Algorithm extends EventEmitter {
|
|||
} = {};
|
||||
private allowedByFilter: Map<IFilterCondition, Room[]> = new Map<IFilterCondition, Room[]>();
|
||||
private allowedRoomsByFilters: Set<Room> = new Set<Room>();
|
||||
private handlerLock = new MultiLock();
|
||||
|
||||
/**
|
||||
* Set to true to suspend emissions of algorithm updates.
|
||||
|
@ -679,191 +681,204 @@ export class Algorithm extends EventEmitter {
|
|||
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`Handle room update for ${room.roomId} called with cause ${cause}`);
|
||||
console.log(`Acquiring lock for ${room.roomId} with cause ${cause}`);
|
||||
}
|
||||
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
|
||||
|
||||
// Note: check the isSticky against the room ID just in case the reference is wrong
|
||||
const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId;
|
||||
if (cause === RoomUpdateCause.NewRoom) {
|
||||
const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room;
|
||||
const roomTags = this.roomIdsToTags[room.roomId];
|
||||
const hasTags = roomTags && roomTags.length > 0;
|
||||
|
||||
// Don't change the cause if the last sticky room is being re-added. If we fail to
|
||||
// pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus
|
||||
// lose the room.
|
||||
if (hasTags && !isForLastSticky) {
|
||||
console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`);
|
||||
cause = RoomUpdateCause.PossibleTagChange;
|
||||
const release = await this.handlerLock.acquire(room.roomId);
|
||||
try {
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`Handle room update for ${room.roomId} called with cause ${cause}`);
|
||||
}
|
||||
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
|
||||
|
||||
// Check to see if the room is known first
|
||||
let knownRoomRef = this.rooms.includes(room);
|
||||
if (hasTags && !knownRoomRef) {
|
||||
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);
|
||||
knownRoomRef = this.rooms.includes(room);
|
||||
if (!knownRoomRef) {
|
||||
console.warn(`${room.roomId} is still not referenced. It may be sticky.`);
|
||||
// Note: check the isSticky against the room ID just in case the reference is wrong
|
||||
const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId;
|
||||
if (cause === RoomUpdateCause.NewRoom) {
|
||||
const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room;
|
||||
const roomTags = this.roomIdsToTags[room.roomId];
|
||||
const hasTags = roomTags && roomTags.length > 0;
|
||||
|
||||
// Don't change the cause if the last sticky room is being re-added. If we fail to
|
||||
// pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus
|
||||
// lose the room.
|
||||
if (hasTags && !isForLastSticky) {
|
||||
console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`);
|
||||
cause = RoomUpdateCause.PossibleTagChange;
|
||||
}
|
||||
|
||||
// Check to see if the room is known first
|
||||
let knownRoomRef = this.rooms.includes(room);
|
||||
if (hasTags && !knownRoomRef) {
|
||||
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);
|
||||
knownRoomRef = this.rooms.includes(room);
|
||||
if (!knownRoomRef) {
|
||||
console.warn(`${room.roomId} is still not referenced. It may be sticky.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
|
||||
// Like above, update the reference to the sticky room if we need to
|
||||
if (hasTags && isSticky) {
|
||||
// Go directly in and set the sticky room's new reference, being careful not
|
||||
// to trigger a sticky room update ourselves.
|
||||
this._stickyRoom.room = room;
|
||||
}
|
||||
|
||||
// If after all that we're still a NewRoom update, add the room if applicable.
|
||||
// We don't do this for the sticky room (because it causes duplication issues)
|
||||
// or if we know about the reference (as it should be replaced).
|
||||
if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) {
|
||||
this.rooms.push(room);
|
||||
}
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
let didTagChange = false;
|
||||
if (cause === RoomUpdateCause.PossibleTagChange) {
|
||||
const oldTags = this.roomIdsToTags[room.roomId] || [];
|
||||
const newTags = this.getTagsForRoom(room);
|
||||
const diff = arrayDiff(oldTags, newTags);
|
||||
if (diff.removed.length > 0 || diff.added.length > 0) {
|
||||
for (const rmTag of diff.removed) {
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`Removing ${room.roomId} from ${rmTag}`);
|
||||
}
|
||||
const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
|
||||
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
|
||||
await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
|
||||
this._cachedRooms[rmTag] = algorithm.orderedRooms;
|
||||
this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list
|
||||
this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed
|
||||
}
|
||||
for (const addTag of diff.added) {
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`Adding ${room.roomId} to ${addTag}`);
|
||||
}
|
||||
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
|
||||
if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
|
||||
await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
|
||||
this._cachedRooms[addTag] = algorithm.orderedRooms;
|
||||
}
|
||||
|
||||
// Like above, update the reference to the sticky room if we need to
|
||||
if (hasTags && isSticky) {
|
||||
// Go directly in and set the sticky room's new reference, being careful not
|
||||
// to trigger a sticky room update ourselves.
|
||||
this._stickyRoom.room = room;
|
||||
}
|
||||
// Update the tag map so we don't regen it in a moment
|
||||
this.roomIdsToTags[room.roomId] = newTags;
|
||||
|
||||
// If after all that we're still a NewRoom update, add the room if applicable.
|
||||
// We don't do this for the sticky room (because it causes duplication issues)
|
||||
// or if we know about the reference (as it should be replaced).
|
||||
if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) {
|
||||
this.rooms.push(room);
|
||||
}
|
||||
}
|
||||
|
||||
let didTagChange = false;
|
||||
if (cause === RoomUpdateCause.PossibleTagChange) {
|
||||
const oldTags = this.roomIdsToTags[room.roomId] || [];
|
||||
const newTags = this.getTagsForRoom(room);
|
||||
const diff = arrayDiff(oldTags, newTags);
|
||||
if (diff.removed.length > 0 || diff.added.length > 0) {
|
||||
for (const rmTag of diff.removed) {
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`Removing ${room.roomId} from ${rmTag}`);
|
||||
console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`);
|
||||
}
|
||||
const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
|
||||
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
|
||||
await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
|
||||
this._cachedRooms[rmTag] = algorithm.orderedRooms;
|
||||
this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list
|
||||
this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed
|
||||
}
|
||||
for (const addTag of diff.added) {
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`Adding ${room.roomId} to ${addTag}`);
|
||||
}
|
||||
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
|
||||
if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
|
||||
await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
|
||||
this._cachedRooms[addTag] = algorithm.orderedRooms;
|
||||
}
|
||||
|
||||
// Update the tag map so we don't regen it in a moment
|
||||
this.roomIdsToTags[room.roomId] = newTags;
|
||||
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`);
|
||||
}
|
||||
cause = RoomUpdateCause.Timeline;
|
||||
didTagChange = true;
|
||||
} else {
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`);
|
||||
}
|
||||
cause = RoomUpdateCause.Timeline;
|
||||
}
|
||||
|
||||
if (didTagChange && isSticky) {
|
||||
// Manually update the tag for the sticky room without triggering a sticky room
|
||||
// update. The update will be handled implicitly by the sticky room handling and
|
||||
// requires no changes on our part, if we're in the middle of a sticky room change.
|
||||
if (this._lastStickyRoom) {
|
||||
this._stickyRoom = {
|
||||
room,
|
||||
tag: this.roomIdsToTags[room.roomId][0],
|
||||
position: 0, // right at the top as it changed tags
|
||||
};
|
||||
cause = RoomUpdateCause.Timeline;
|
||||
didTagChange = true;
|
||||
} else {
|
||||
// We have to clear the lock as the sticky room change will trigger updates.
|
||||
await this.setStickyRoom(room);
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`);
|
||||
}
|
||||
cause = RoomUpdateCause.Timeline;
|
||||
}
|
||||
|
||||
if (didTagChange && isSticky) {
|
||||
// Manually update the tag for the sticky room without triggering a sticky room
|
||||
// update. The update will be handled implicitly by the sticky room handling and
|
||||
// requires no changes on our part, if we're in the middle of a sticky room change.
|
||||
if (this._lastStickyRoom) {
|
||||
this._stickyRoom = {
|
||||
room,
|
||||
tag: this.roomIdsToTags[room.roomId][0],
|
||||
position: 0, // right at the top as it changed tags
|
||||
};
|
||||
} else {
|
||||
// We have to clear the lock as the sticky room change will trigger updates.
|
||||
await this.setStickyRoom(room);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the update is for a room change which might be the sticky room, prevent it. We
|
||||
// need to make sure that the causes (NewRoom and RoomRemoved) are still triggered though
|
||||
// as the sticky room relies on this.
|
||||
if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) {
|
||||
if (this.stickyRoom === room) {
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`);
|
||||
// If the update is for a room change which might be the sticky room, prevent it. We
|
||||
// need to make sure that the causes (NewRoom and RoomRemoved) are still triggered though
|
||||
// as the sticky room relies on this.
|
||||
if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) {
|
||||
if (this.stickyRoom === room) {
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.warn(
|
||||
`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.roomIdsToTags[room.roomId]) {
|
||||
if (CAUSES_REQUIRING_ROOM.includes(cause)) {
|
||||
if (!this.roomIdsToTags[room.roomId]) {
|
||||
if (CAUSES_REQUIRING_ROOM.includes(cause)) {
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`);
|
||||
console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`);
|
||||
}
|
||||
|
||||
// Get the tags for the room and populate the cache
|
||||
const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t]));
|
||||
|
||||
// "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(),
|
||||
// which means we should *always* have a tag to go off of.
|
||||
if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`);
|
||||
|
||||
this.roomIdsToTags[room.roomId] = roomTags;
|
||||
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`);
|
||||
console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`);
|
||||
}
|
||||
|
||||
// Get the tags for the room and populate the cache
|
||||
const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t]));
|
||||
const tags = this.roomIdsToTags[room.roomId];
|
||||
if (!tags) {
|
||||
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(),
|
||||
// which means we should *always* have a tag to go off of.
|
||||
if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`);
|
||||
let changed = didTagChange;
|
||||
for (const tag of tags) {
|
||||
const algorithm: OrderingAlgorithm = this.algorithms[tag];
|
||||
if (!algorithm) throw new Error(`No algorithm for ${tag}`);
|
||||
|
||||
this.roomIdsToTags[room.roomId] = roomTags;
|
||||
await algorithm.handleRoomUpdate(room, cause);
|
||||
this._cachedRooms[tag] = algorithm.orderedRooms;
|
||||
|
||||
// Flag that we've done something
|
||||
this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list
|
||||
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags);
|
||||
console.log(
|
||||
`[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`,
|
||||
);
|
||||
}
|
||||
return changed;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`);
|
||||
}
|
||||
|
||||
const tags = this.roomIdsToTags[room.roomId];
|
||||
if (!tags) {
|
||||
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
let changed = didTagChange;
|
||||
for (const tag of tags) {
|
||||
const algorithm: OrderingAlgorithm = this.algorithms[tag];
|
||||
if (!algorithm) throw new Error(`No algorithm for ${tag}`);
|
||||
|
||||
await algorithm.handleRoomUpdate(room, cause);
|
||||
this._cachedRooms[tag] = algorithm.orderedRooms;
|
||||
|
||||
// Flag that we've done something
|
||||
this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list
|
||||
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
|
||||
console.log(`[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
@ -52,6 +52,8 @@ import {getCustomTheme} from "../../theme";
|
|||
import CountlyAnalytics from "../../CountlyAnalytics";
|
||||
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { ELEMENT_CLIENT_ID } from "../../identifiers";
|
||||
import { getUserLanguage } from "../../languageHandler";
|
||||
|
||||
// TODO: Destroy all of this code
|
||||
|
||||
|
@ -194,6 +196,9 @@ export class StopGapWidget extends EventEmitter {
|
|||
currentUserId: MatrixClientPeg.get().getUserId(),
|
||||
userDisplayName: OwnProfileStore.instance.displayName,
|
||||
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
|
||||
clientId: ELEMENT_CLIENT_ID,
|
||||
clientTheme: SettingsStore.getValue("theme"),
|
||||
clientLanguage: getUserLanguage(),
|
||||
}, opts?.asPopout);
|
||||
|
||||
const parsed = new URL(templated);
|
||||
|
|
|
@ -44,6 +44,7 @@ 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";
|
||||
|
||||
// TODO: Purge this from the universe
|
||||
|
||||
|
@ -144,6 +145,52 @@ export class StopGapWidgetDriver extends WidgetDriver {
|
|||
return {roomId, eventId: r.event_id};
|
||||
}
|
||||
|
||||
public async readRoomEvents(eventType: string, msgtype: string | undefined, limit: number): Promise<MatrixEvent[]> {
|
||||
limit = limit > 0 ? Math.min(limit, 25) : 25; // arbitrary choice
|
||||
|
||||
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 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) continue;
|
||||
if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue;
|
||||
results.push(ev);
|
||||
}
|
||||
|
||||
return results.map(e => e.event);
|
||||
}
|
||||
|
||||
public async readStateEvents(
|
||||
eventType: string, stateKey: string | undefined, limit: number,
|
||||
): Promise<MatrixEvent[]> {
|
||||
limit = limit > 0 ? Math.min(limit, 100) : 100; // arbitrary choice
|
||||
|
||||
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 results: MatrixEvent[] = [];
|
||||
const state = 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()));
|
||||
}
|
||||
}
|
||||
|
||||
return results.slice(0, limit).map(e => e.event);
|
||||
}
|
||||
|
||||
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>) {
|
||||
const oidcState = WidgetPermissionStore.instance.getOIDCState(
|
||||
this.forWidget, this.forWidgetKind, this.inRoomId,
|
||||
|
|
30
src/utils/MultiLock.ts
Normal file
30
src/utils/MultiLock.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
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 { EnhancedMap } from "./maps";
|
||||
import AwaitLock from "await-lock";
|
||||
|
||||
export type DoneFn = () => void;
|
||||
|
||||
export class MultiLock {
|
||||
private locks = new EnhancedMap<string, AwaitLock>();
|
||||
|
||||
public async acquire(key: string): Promise<DoneFn> {
|
||||
const lock = this.locks.getOrCreate(key, new AwaitLock());
|
||||
await lock.acquireAsync();
|
||||
return () => lock.release();
|
||||
}
|
||||
}
|
|
@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {percentageOf, percentageWithin} from "./numbers";
|
||||
|
||||
/**
|
||||
* Quickly resample an array to have less/more data points. If an input which is larger
|
||||
* than the desired size is provided, it will be downsampled. Similarly, if the input
|
||||
|
@ -27,7 +29,7 @@ export function arrayFastResample(input: number[], points: number): number[] {
|
|||
|
||||
// Heavily inspired by matrix-media-repo (used with permission)
|
||||
// https://github.com/turt2live/matrix-media-repo/blob/abe72c87d2e29/util/util_audio/fastsample.go#L10
|
||||
let samples: number[] = [];
|
||||
const samples: number[] = [];
|
||||
if (input.length > points) {
|
||||
// Danger: this loop can cause out of memory conditions if the input is too small.
|
||||
const everyNth = Math.round(input.length / points);
|
||||
|
@ -44,17 +46,63 @@ export function arrayFastResample(input: number[], points: number): number[] {
|
|||
}
|
||||
}
|
||||
|
||||
// Sanity fill, just in case
|
||||
while (samples.length < points) {
|
||||
samples.push(input[input.length - 1]);
|
||||
}
|
||||
// Trim to size & return
|
||||
return arrayTrimFill(samples, points, arraySeed(input[input.length - 1], points));
|
||||
}
|
||||
|
||||
// Sanity trim, just in case
|
||||
if (samples.length > points) {
|
||||
samples = samples.slice(0, points);
|
||||
}
|
||||
/**
|
||||
* Attempts a smooth resample of the given array. This is functionally similar to arrayFastResample
|
||||
* though can take longer due to the smoothing of data.
|
||||
* @param {number[]} input The input array to resample.
|
||||
* @param {number} points The number of samples to end up with.
|
||||
* @returns {number[]} The resampled array.
|
||||
*/
|
||||
export function arraySmoothingResample(input: number[], points: number): number[] {
|
||||
if (input.length === points) return input; // short-circuit a complicated call
|
||||
|
||||
return samples;
|
||||
let samples: number[] = [];
|
||||
if (input.length > points) {
|
||||
// We're downsampling. To preserve the curve we'll actually reduce our sample
|
||||
// selection and average some points between them.
|
||||
|
||||
// All we're doing here is repeatedly averaging the waveform down to near our
|
||||
// target value. We don't average down to exactly our target as the loop might
|
||||
// never end, and we can over-average the data. Instead, we'll get as far as
|
||||
// we can and do a followup fast resample (the neighbouring points will be close
|
||||
// to the actual waveform, so we can get away with this safely).
|
||||
while (samples.length > (points * 2) || samples.length === 0) {
|
||||
samples = [];
|
||||
for (let i = 1; i < input.length - 1; i += 2) {
|
||||
const prevPoint = input[i - 1];
|
||||
const nextPoint = input[i + 1];
|
||||
const currPoint = input[i];
|
||||
const average = (prevPoint + nextPoint + currPoint) / 3;
|
||||
samples.push(average);
|
||||
}
|
||||
input = samples;
|
||||
}
|
||||
|
||||
return arrayFastResample(samples, points);
|
||||
} else {
|
||||
// In practice there's not much purpose in burning CPU for short arrays only to
|
||||
// end up with a result that can't possibly look much different than the fast
|
||||
// resample, so just skip ahead to the fast resample.
|
||||
return arrayFastResample(input, points);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rescales the input array to have values that are inclusively within the provided
|
||||
* minimum and maximum.
|
||||
* @param {number[]} input The array to rescale.
|
||||
* @param {number} newMin The minimum value to scale to.
|
||||
* @param {number} newMax The maximum value to scale to.
|
||||
* @returns {number[]} The rescaled array.
|
||||
*/
|
||||
export function arrayRescale(input: number[], newMin: number, newMax: number): number[] {
|
||||
const min: number = Math.min(...input);
|
||||
const max: number = Math.max(...input);
|
||||
return input.map(v => percentageWithin(percentageOf(v, min, max), newMin, newMax));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -346,9 +346,14 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string {
|
|||
return permalink;
|
||||
}
|
||||
|
||||
const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN);
|
||||
if (m) {
|
||||
return m[1];
|
||||
try {
|
||||
const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN);
|
||||
if (m) {
|
||||
return m[1];
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a valid URI
|
||||
return permalink;
|
||||
}
|
||||
|
||||
// A bit of a hack to convert permalinks of unknown origin to Element links
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 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,16 +14,19 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import Modal from './Modal';
|
||||
import * as sdk from './index';
|
||||
import { _t } from './languageHandler';
|
||||
import {RightPanelPhases} from "./stores/RightPanelStorePhases";
|
||||
import {findDMForUser} from './createRoom';
|
||||
import {accessSecretStorage} from './SecurityManager';
|
||||
import {verificationMethods} from 'matrix-js-sdk/src/crypto';
|
||||
import {Action} from './dispatcher/actions';
|
||||
import { RightPanelPhases } from "./stores/RightPanelStorePhases";
|
||||
import { findDMForUser } from './createRoom';
|
||||
import { accessSecretStorage } from './SecurityManager';
|
||||
import { verificationMethods } from 'matrix-js-sdk/src/crypto';
|
||||
import { Action } from './dispatcher/actions';
|
||||
import UntrustedDeviceDialog from "./components/views/dialogs/UntrustedDeviceDialog";
|
||||
import {IDevice} from "./components/views/right_panel/UserInfo";
|
||||
|
||||
async function enable4SIfNeeded() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
@ -39,40 +42,7 @@ async function enable4SIfNeeded() {
|
|||
return true;
|
||||
}
|
||||
|
||||
function UntrustedDeviceDialog(props) {
|
||||
const {device, user, onFinished} = props;
|
||||
const BaseDialog = sdk.getComponent("dialogs.BaseDialog");
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
let askToVerifyText;
|
||||
let newSessionText;
|
||||
|
||||
if (MatrixClientPeg.get().getUserId() === user.userId) {
|
||||
newSessionText = _t("You signed in to a new session without verifying it:");
|
||||
askToVerifyText = _t("Verify your other session using one of the options below.");
|
||||
} else {
|
||||
newSessionText = _t("%(name)s (%(userId)s) signed in to a new session without verifying it:",
|
||||
{name: user.displayName, userId: user.userId});
|
||||
askToVerifyText = _t("Ask this user to verify their session, or manually verify it below.");
|
||||
}
|
||||
|
||||
return <BaseDialog
|
||||
onFinished={onFinished}
|
||||
headerImage={require("../res/img/e2e/warning.svg")}
|
||||
title={_t("Not Trusted")}>
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<p>{newSessionText}</p>
|
||||
<p>{device.getDisplayName()} ({device.deviceId})</p>
|
||||
<p>{askToVerifyText}</p>
|
||||
</div>
|
||||
<div className='mx_Dialog_buttons'>
|
||||
<AccessibleButton element="button" kind="secondary" onClick={() => onFinished("legacy")}>{_t("Manually Verify by Text")}</AccessibleButton>
|
||||
<AccessibleButton element="button" kind="secondary" onClick={() => onFinished("sas")}>{_t("Interactively verify by Emoji")}</AccessibleButton>
|
||||
<AccessibleButton kind="primary" onClick={() => onFinished()}>{_t("Done")}</AccessibleButton>
|
||||
</div>
|
||||
</BaseDialog>;
|
||||
}
|
||||
|
||||
export async function verifyDevice(user, device) {
|
||||
export async function verifyDevice(user: User, device: IDevice) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli.isGuest()) {
|
||||
dis.dispatch({action: 'require_registration'});
|
||||
|
@ -115,7 +85,7 @@ export async function verifyDevice(user, device) {
|
|||
});
|
||||
}
|
||||
|
||||
export async function legacyVerifyUser(user) {
|
||||
export async function legacyVerifyUser(user: User) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli.isGuest()) {
|
||||
dis.dispatch({action: 'require_registration'});
|
||||
|
@ -135,7 +105,7 @@ export async function legacyVerifyUser(user) {
|
|||
});
|
||||
}
|
||||
|
||||
export async function verifyUser(user) {
|
||||
export async function verifyUser(user: User) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli.isGuest()) {
|
||||
dis.dispatch({action: 'require_registration'});
|
||||
|
@ -155,7 +125,7 @@ export async function verifyUser(user) {
|
|||
});
|
||||
}
|
||||
|
||||
export function pendingVerificationRequestForUser(user) {
|
||||
export function pendingVerificationRequestForUser(user: User) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const dmRoom = findDMForUser(cli, user.userId);
|
||||
if (dmRoom) {
|
|
@ -15,12 +15,13 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import EventEmitter from "events";
|
||||
import {UPDATE_EVENT} from "../stores/AsyncStore";
|
||||
import {arrayFastResample, arraySeed} from "../utils/arrays";
|
||||
import {SimpleObservable} from "matrix-widget-api";
|
||||
import {IDestroyable} from "../utils/IDestroyable";
|
||||
import {PlaybackClock} from "./PlaybackClock";
|
||||
import {clamp} from "../utils/numbers";
|
||||
import { UPDATE_EVENT } from "../stores/AsyncStore";
|
||||
import { arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample } from "../utils/arrays";
|
||||
import { SimpleObservable } from "matrix-widget-api";
|
||||
import { IDestroyable } from "../utils/IDestroyable";
|
||||
import { PlaybackClock } from "./PlaybackClock";
|
||||
import { createAudioContext, decodeOgg } from "./compat";
|
||||
import { clamp } from "../utils/numbers";
|
||||
|
||||
export enum PlaybackState {
|
||||
Decoding = "decoding",
|
||||
|
@ -32,6 +33,23 @@ export enum PlaybackState {
|
|||
export const PLAYBACK_WAVEFORM_SAMPLES = 39;
|
||||
const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
|
||||
|
||||
function makePlaybackWaveform(input: number[]): number[] {
|
||||
// First, convert negative amplitudes to positive so we don't detect zero as "noisy".
|
||||
const noiseWaveform = input.map(v => Math.abs(v));
|
||||
|
||||
// Next, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
|
||||
// We also rescale the waveform to be 0-1 for the remaining function logic.
|
||||
const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
|
||||
|
||||
// Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled
|
||||
// waveform. Most speech happens below the 0.5 mark.
|
||||
const filtered = resampled.map(v => clamp(v, 0.1, 0.5));
|
||||
|
||||
// Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something
|
||||
// sensible. This is what we return to keep our contract of "values between zero and one".
|
||||
return arrayRescale(filtered, 0, 1);
|
||||
}
|
||||
|
||||
export class Playback extends EventEmitter implements IDestroyable {
|
||||
private readonly context: AudioContext;
|
||||
private source: AudioBufferSourceNode;
|
||||
|
@ -49,12 +67,16 @@ export class Playback extends EventEmitter implements IDestroyable {
|
|||
*/
|
||||
constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) {
|
||||
super();
|
||||
this.context = new AudioContext();
|
||||
this.context = createAudioContext();
|
||||
this.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES);
|
||||
this.waveformObservable.update(this.resampledWaveform);
|
||||
this.clock = new PlaybackClock(this.context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable waveform for the playback. Values are guaranteed to be between
|
||||
* zero and one, inclusive.
|
||||
*/
|
||||
public get waveform(): number[] {
|
||||
return this.resampledWaveform;
|
||||
}
|
||||
|
@ -91,15 +113,32 @@ export class Playback extends EventEmitter implements IDestroyable {
|
|||
}
|
||||
|
||||
public async prepare() {
|
||||
this.audioBuf = await this.context.decodeAudioData(this.buf);
|
||||
// Safari compat: promise API not supported on this function
|
||||
this.audioBuf = await new Promise((resolve, reject) => {
|
||||
this.context.decodeAudioData(this.buf, b => resolve(b), async e => {
|
||||
// This error handler is largely for Safari as well, which doesn't support Opus/Ogg
|
||||
// very well.
|
||||
console.error("Error decoding recording: ", e);
|
||||
console.warn("Trying to re-encode to WAV instead...");
|
||||
|
||||
const wav = await decodeOgg(this.buf);
|
||||
|
||||
// noinspection ES6MissingAwait - not needed when using callbacks
|
||||
this.context.decodeAudioData(wav, b => resolve(b), e => {
|
||||
console.error("Still failed to decode recording: ", e);
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Update the waveform to the real waveform once we have channel data to use. We don't
|
||||
// exactly trust the user-provided waveform to be accurate...
|
||||
const waveform = Array.from(this.audioBuf.getChannelData(0)).map(v => clamp(v, 0, 1));
|
||||
this.resampledWaveform = arrayFastResample(waveform, PLAYBACK_WAVEFORM_SAMPLES);
|
||||
const waveform = Array.from(this.audioBuf.getChannelData(0));
|
||||
this.resampledWaveform = makePlaybackWaveform(waveform);
|
||||
this.waveformObservable.update(this.resampledWaveform);
|
||||
|
||||
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
|
||||
this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update
|
||||
this.clock.durationSeconds = this.audioBuf.duration;
|
||||
}
|
||||
|
||||
|
|
|
@ -54,6 +54,15 @@ export class PlaybackClock implements IDestroyable {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark the time in the audio context where the clip starts/has been loaded.
|
||||
* This is to ensure the clock isn't skewed into thinking it is ~0.5s into
|
||||
* a clip when the duration is set.
|
||||
*/
|
||||
public flagLoadTime() {
|
||||
this.clipStart = this.context.currentTime;
|
||||
}
|
||||
|
||||
public flagStart() {
|
||||
if (this.stopped) {
|
||||
this.clipStart = this.context.currentTime;
|
||||
|
|
|
@ -19,16 +19,17 @@ import encoderPath from 'opus-recorder/dist/encoderWorker.min.js';
|
|||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
import CallMediaHandler from "../CallMediaHandler";
|
||||
import {SimpleObservable} from "matrix-widget-api";
|
||||
import {clamp} from "../utils/numbers";
|
||||
import {clamp, percentageOf, percentageWithin} from "../utils/numbers";
|
||||
import EventEmitter from "events";
|
||||
import {IDestroyable} from "../utils/IDestroyable";
|
||||
import {Singleflight} from "../utils/Singleflight";
|
||||
import {PayloadEvent, WORKLET_NAME} from "./consts";
|
||||
import {UPDATE_EVENT} from "../stores/AsyncStore";
|
||||
import {Playback} from "./Playback";
|
||||
import {createAudioContext} from "./compat";
|
||||
|
||||
const CHANNELS = 1; // stereo isn't important
|
||||
const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
|
||||
export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
|
||||
const BITRATE = 24000; // 24kbps is pretty high quality for our use case in opus.
|
||||
const TARGET_MAX_LENGTH = 120; // 2 minutes in seconds. Somewhat arbitrary, though longer == larger files.
|
||||
const TARGET_WARN_TIME_LEFT = 10; // 10 seconds, also somewhat arbitrary.
|
||||
|
@ -55,6 +56,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
private recorderStream: MediaStream;
|
||||
private recorderFFT: AnalyserNode;
|
||||
private recorderWorklet: AudioWorkletNode;
|
||||
private recorderProcessor: ScriptProcessorNode;
|
||||
private buffer = new Uint8Array(0); // use this.audioBuffer to access
|
||||
private mxc: string;
|
||||
private recording = false;
|
||||
|
@ -90,78 +92,107 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
}
|
||||
|
||||
private async makeRecorder() {
|
||||
this.recorderStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: CHANNELS,
|
||||
noiseSuppression: true, // browsers ignore constraints they can't honour
|
||||
deviceId: CallMediaHandler.getAudioInput(),
|
||||
},
|
||||
});
|
||||
this.recorderContext = new AudioContext({
|
||||
// latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing)
|
||||
});
|
||||
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
|
||||
this.recorderFFT = this.recorderContext.createAnalyser();
|
||||
try {
|
||||
this.recorderStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: CHANNELS,
|
||||
noiseSuppression: true, // browsers ignore constraints they can't honour
|
||||
deviceId: CallMediaHandler.getAudioInput(),
|
||||
},
|
||||
});
|
||||
this.recorderContext = createAudioContext({
|
||||
// latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing)
|
||||
});
|
||||
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
|
||||
this.recorderFFT = this.recorderContext.createAnalyser();
|
||||
|
||||
// Bring the FFT time domain down a bit. The default is 2048, and this must be a power
|
||||
// of two. We use 64 points because we happen to know down the line we need less than
|
||||
// that, but 32 would be too few. Large numbers are not helpful here and do not add
|
||||
// precision: they introduce higher precision outputs of the FFT (frequency data), but
|
||||
// it makes the time domain less than helpful.
|
||||
this.recorderFFT.fftSize = 64;
|
||||
// Bring the FFT time domain down a bit. The default is 2048, and this must be a power
|
||||
// of two. We use 64 points because we happen to know down the line we need less than
|
||||
// that, but 32 would be too few. Large numbers are not helpful here and do not add
|
||||
// precision: they introduce higher precision outputs of the FFT (frequency data), but
|
||||
// it makes the time domain less than helpful.
|
||||
this.recorderFFT.fftSize = 64;
|
||||
|
||||
// Set up our worklet. We use this for timing information and waveform analysis: the
|
||||
// web audio API prefers this be done async to avoid holding the main thread with math.
|
||||
const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript;
|
||||
if (!mxRecorderWorkletPath) {
|
||||
throw new Error("Unable to create recorder: no worklet script registered");
|
||||
}
|
||||
await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath);
|
||||
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
|
||||
|
||||
// Connect our inputs and outputs
|
||||
this.recorderSource.connect(this.recorderFFT);
|
||||
this.recorderSource.connect(this.recorderWorklet);
|
||||
this.recorderWorklet.connect(this.recorderContext.destination);
|
||||
|
||||
// Dev note: we can't use `addEventListener` for some reason. It just doesn't work.
|
||||
this.recorderWorklet.port.onmessage = (ev) => {
|
||||
switch (ev.data['ev']) {
|
||||
case PayloadEvent.Timekeep:
|
||||
this.processAudioUpdate(ev.data['timeSeconds']);
|
||||
break;
|
||||
case PayloadEvent.AmplitudeMark:
|
||||
// Sanity check to make sure we're adding about one sample per second
|
||||
if (ev.data['forSecond'] === this.amplitudes.length) {
|
||||
this.amplitudes.push(ev.data['amplitude']);
|
||||
}
|
||||
break;
|
||||
// Set up our worklet. We use this for timing information and waveform analysis: the
|
||||
// web audio API prefers this be done async to avoid holding the main thread with math.
|
||||
const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript;
|
||||
if (!mxRecorderWorkletPath) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Unable to create recorder: no worklet script registered");
|
||||
}
|
||||
};
|
||||
|
||||
this.recorder = new Recorder({
|
||||
encoderPath, // magic from webpack
|
||||
encoderSampleRate: SAMPLE_RATE,
|
||||
encoderApplication: 2048, // voice (default is "audio")
|
||||
streamPages: true, // this speeds up the encoding process by using CPU over time
|
||||
encoderFrameSize: 20, // ms, arbitrary frame size we send to the encoder
|
||||
numberOfChannels: CHANNELS,
|
||||
sourceNode: this.recorderSource,
|
||||
encoderBitRate: BITRATE,
|
||||
// Connect our inputs and outputs
|
||||
this.recorderSource.connect(this.recorderFFT);
|
||||
|
||||
// We use low values for the following to ease CPU usage - the resulting waveform
|
||||
// is indistinguishable for a voice message. Note that the underlying library will
|
||||
// pick defaults which prefer the highest possible quality, CPU be damned.
|
||||
encoderComplexity: 3, // 0-10, 10 is slow and high quality.
|
||||
resampleQuality: 3, // 0-10, 10 is slow and high quality
|
||||
});
|
||||
this.recorder.ondataavailable = (a: ArrayBuffer) => {
|
||||
const buf = new Uint8Array(a);
|
||||
const newBuf = new Uint8Array(this.buffer.length + buf.length);
|
||||
newBuf.set(this.buffer, 0);
|
||||
newBuf.set(buf, this.buffer.length);
|
||||
this.buffer = newBuf;
|
||||
};
|
||||
if (this.recorderContext.audioWorklet) {
|
||||
await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath);
|
||||
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
|
||||
this.recorderSource.connect(this.recorderWorklet);
|
||||
this.recorderWorklet.connect(this.recorderContext.destination);
|
||||
|
||||
// Dev note: we can't use `addEventListener` for some reason. It just doesn't work.
|
||||
this.recorderWorklet.port.onmessage = (ev) => {
|
||||
switch (ev.data['ev']) {
|
||||
case PayloadEvent.Timekeep:
|
||||
this.processAudioUpdate(ev.data['timeSeconds']);
|
||||
break;
|
||||
case PayloadEvent.AmplitudeMark:
|
||||
// Sanity check to make sure we're adding about one sample per second
|
||||
if (ev.data['forSecond'] === this.amplitudes.length) {
|
||||
this.amplitudes.push(ev.data['amplitude']);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Safari fallback: use a processor node instead, buffered to 1024 bytes of data
|
||||
// like the worklet is.
|
||||
this.recorderProcessor = this.recorderContext.createScriptProcessor(1024, CHANNELS, CHANNELS);
|
||||
this.recorderSource.connect(this.recorderProcessor);
|
||||
this.recorderProcessor.connect(this.recorderContext.destination);
|
||||
this.recorderProcessor.addEventListener("audioprocess", this.onAudioProcess);
|
||||
}
|
||||
|
||||
this.recorder = new Recorder({
|
||||
encoderPath, // magic from webpack
|
||||
encoderSampleRate: SAMPLE_RATE,
|
||||
encoderApplication: 2048, // voice (default is "audio")
|
||||
streamPages: true, // this speeds up the encoding process by using CPU over time
|
||||
encoderFrameSize: 20, // ms, arbitrary frame size we send to the encoder
|
||||
numberOfChannels: CHANNELS,
|
||||
sourceNode: this.recorderSource,
|
||||
encoderBitRate: BITRATE,
|
||||
|
||||
// We use low values for the following to ease CPU usage - the resulting waveform
|
||||
// is indistinguishable for a voice message. Note that the underlying library will
|
||||
// pick defaults which prefer the highest possible quality, CPU be damned.
|
||||
encoderComplexity: 3, // 0-10, 10 is slow and high quality.
|
||||
resampleQuality: 3, // 0-10, 10 is slow and high quality
|
||||
});
|
||||
this.recorder.ondataavailable = (a: ArrayBuffer) => {
|
||||
const buf = new Uint8Array(a);
|
||||
const newBuf = new Uint8Array(this.buffer.length + buf.length);
|
||||
newBuf.set(this.buffer, 0);
|
||||
newBuf.set(buf, this.buffer.length);
|
||||
this.buffer = newBuf;
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error starting recording: ", e);
|
||||
if (e instanceof DOMException) { // Unhelpful DOMExceptions are common - parse them sanely
|
||||
console.error(`${e.name} (${e.code}): ${e.message}`);
|
||||
}
|
||||
|
||||
// Clean up as best as possible
|
||||
if (this.recorderStream) this.recorderStream.getTracks().forEach(t => t.stop());
|
||||
if (this.recorderSource) this.recorderSource.disconnect();
|
||||
if (this.recorder) this.recorder.close();
|
||||
if (this.recorderContext) {
|
||||
// noinspection ES6MissingAwait - not important that we wait
|
||||
this.recorderContext.close();
|
||||
}
|
||||
|
||||
throw e; // rethrow so upstream can handle it
|
||||
}
|
||||
}
|
||||
|
||||
private get audioBuffer(): Uint8Array {
|
||||
|
@ -190,6 +221,13 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
return this.mxc;
|
||||
}
|
||||
|
||||
private onAudioProcess = (ev: AudioProcessingEvent) => {
|
||||
this.processAudioUpdate(ev.playbackTime);
|
||||
|
||||
// We skip the functionality of the worklet regarding waveform calculations: we
|
||||
// should get that information pretty quick during the playback info.
|
||||
};
|
||||
|
||||
private processAudioUpdate = (timeSeconds: number) => {
|
||||
if (!this.recording) return;
|
||||
|
||||
|
@ -197,7 +235,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
// size. The time domain is also known as the audio waveform. We're ignoring the
|
||||
// output of the FFT here (frequency data) because we're not interested in it.
|
||||
const data = new Float32Array(this.recorderFFT.fftSize);
|
||||
this.recorderFFT.getFloatTimeDomainData(data);
|
||||
if (!this.recorderFFT.getFloatTimeDomainData) {
|
||||
// Safari compat
|
||||
const data2 = new Uint8Array(this.recorderFFT.fftSize);
|
||||
this.recorderFFT.getByteTimeDomainData(data2);
|
||||
for (let i = 0; i < data2.length; i++) {
|
||||
data[i] = percentageWithin(percentageOf(data2[i], 0, 256), -1, 1);
|
||||
}
|
||||
} else {
|
||||
this.recorderFFT.getFloatTimeDomainData(data);
|
||||
}
|
||||
|
||||
// We can't just `Array.from()` the array because we're dealing with 32bit floats
|
||||
// and the built-in function won't consider that when converting between numbers.
|
||||
|
@ -268,7 +315,11 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
// Disconnect the source early to start shutting down resources
|
||||
await this.recorder.stop(); // stop first to flush the last frame
|
||||
this.recorderSource.disconnect();
|
||||
this.recorderWorklet.disconnect();
|
||||
if (this.recorderWorklet) this.recorderWorklet.disconnect();
|
||||
if (this.recorderProcessor) {
|
||||
this.recorderProcessor.disconnect();
|
||||
this.recorderProcessor.removeEventListener("audioprocess", this.onAudioProcess);
|
||||
}
|
||||
|
||||
// close the context after the recorder so the recorder doesn't try to
|
||||
// connect anything to the context (this would generate a warning)
|
||||
|
|
82
src/voice/compat.ts
Normal file
82
src/voice/compat.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
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 {SAMPLE_RATE} from "./VoiceRecording";
|
||||
|
||||
// @ts-ignore - we know that this is not a module. We're looking for a path.
|
||||
import decoderWasmPath from 'opus-recorder/dist/decoderWorker.min.wasm';
|
||||
import wavEncoderPath from 'opus-recorder/dist/waveWorker.min.js';
|
||||
import decoderPath from 'opus-recorder/dist/decoderWorker.min.js';
|
||||
|
||||
export function createAudioContext(opts?: AudioContextOptions): AudioContext {
|
||||
if (window.AudioContext) {
|
||||
return new AudioContext(opts);
|
||||
} else if (window.webkitAudioContext) {
|
||||
// While the linter is correct that "a constructor name should not start with
|
||||
// a lowercase letter", it's also wrong to think that we have control over this.
|
||||
// eslint-disable-next-line new-cap
|
||||
return new window.webkitAudioContext(opts);
|
||||
} else {
|
||||
throw new Error("Unsupported browser");
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeOgg(audioBuffer: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
// Condensed version of decoder example, using a promise:
|
||||
// https://github.com/chris-rudmin/opus-recorder/blob/master/example/decoder.html
|
||||
return new Promise((resolve) => { // no reject because the workers don't seem to have a fail path
|
||||
console.log("Decoder WASM path: " + decoderWasmPath); // so we use the variable (avoid tree shake)
|
||||
const typedArray = new Uint8Array(audioBuffer);
|
||||
const decoderWorker = new Worker(decoderPath);
|
||||
const wavWorker = new Worker(wavEncoderPath);
|
||||
|
||||
decoderWorker.postMessage({
|
||||
command: 'init',
|
||||
decoderSampleRate: SAMPLE_RATE,
|
||||
outputBufferSampleRate: SAMPLE_RATE,
|
||||
});
|
||||
|
||||
wavWorker.postMessage({
|
||||
command: 'init',
|
||||
wavBitDepth: 24, // standard for 48khz (SAMPLE_RATE)
|
||||
wavSampleRate: SAMPLE_RATE,
|
||||
});
|
||||
|
||||
decoderWorker.onmessage = (ev) => {
|
||||
if (ev.data === null) { // null == done
|
||||
wavWorker.postMessage({command: 'done'});
|
||||
return;
|
||||
}
|
||||
|
||||
wavWorker.postMessage({
|
||||
command: 'encode',
|
||||
buffers: ev.data,
|
||||
}, ev.data.map(b => b.buffer));
|
||||
};
|
||||
|
||||
wavWorker.onmessage = (ev) => {
|
||||
if (ev.data.message === 'page') {
|
||||
// The encoding comes through as a single page
|
||||
resolve(new Blob([ev.data.page], {type: "audio/wav"}).arrayBuffer());
|
||||
}
|
||||
};
|
||||
|
||||
decoderWorker.postMessage({
|
||||
command: 'decode',
|
||||
pages: typedArray,
|
||||
}, [typedArray.buffer]);
|
||||
});
|
||||
}
|
|
@ -96,6 +96,16 @@ export class CapabilityText {
|
|||
[EventDirection.Receive]: _td("See when the avatar changes in your active room"),
|
||||
},
|
||||
},
|
||||
[EventType.RoomMember]: {
|
||||
[WidgetKind.Room]: {
|
||||
[EventDirection.Send]: _td("Kick, ban, or invite people to this room, and make you leave"),
|
||||
[EventDirection.Receive]: _td("See when people join, leave, or are invited to this room"),
|
||||
},
|
||||
[GENERIC_WIDGET_KIND]: {
|
||||
[EventDirection.Send]: _td("Kick, ban, or invite people to your active room, and make you leave"),
|
||||
[EventDirection.Receive]: _td("See when people join, leave, or are invited to your active room"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
private static nonStateSendRecvCaps: ISendRecvStaticCapText = {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue