Merge remote-tracking branch 'origin/develop' into travis/voicemessages/indev
This commit is contained in:
commit
92e30f0607
64 changed files with 1676 additions and 537 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;
|
||||
|
|
|
@ -422,8 +422,12 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
|
|||
safeBody = sanitizeHtml(formattedBody, sanitizeParams);
|
||||
|
||||
if (SettingsStore.getValue("feature_latex_maths")) {
|
||||
const phtml = cheerio.load(safeBody,
|
||||
{ _useHtmlParser2: true, decodeEntities: false })
|
||||
const phtml = cheerio.load(safeBody, {
|
||||
// @ts-ignore: The `_useHtmlParser2` internal option is the
|
||||
// simplest way to both parse and render using `htmlparser2`.
|
||||
_useHtmlParser2: true,
|
||||
decodeEntities: false,
|
||||
});
|
||||
// @ts-ignore - The types for `replaceWith` wrongly expect
|
||||
// Cheerio instance to be returned.
|
||||
phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) {
|
||||
|
@ -431,6 +435,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
|
|||
AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')),
|
||||
{
|
||||
throwOnError: false,
|
||||
// @ts-ignore - `e` can be an Element, not just a Node
|
||||
displayMode: e.name == 'div',
|
||||
output: "htmlAndMathml",
|
||||
});
|
||||
|
|
|
@ -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")) {
|
||||
|
|
|
@ -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) {
|
||||
|
@ -1684,6 +1686,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 +1773,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
|
||||
|
|
|
@ -544,11 +544,13 @@ export default class MessagePanel extends React.Component {
|
|||
}
|
||||
if (!grouper) {
|
||||
const wantTile = this._shouldShowEvent(mxEv);
|
||||
const isGrouped = false;
|
||||
if (wantTile) {
|
||||
// make sure we unpack the array returned by _getTilesForEvent,
|
||||
// otherwise react will auto-generate keys and we will end up
|
||||
// replacing all of the DOM elements every time we paginate.
|
||||
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextTile));
|
||||
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, isGrouped,
|
||||
nextEvent, nextTile));
|
||||
prevEvent = mxEv;
|
||||
}
|
||||
|
||||
|
@ -564,7 +566,7 @@ export default class MessagePanel extends React.Component {
|
|||
return ret;
|
||||
}
|
||||
|
||||
_getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) {
|
||||
_getTilesForEvent(prevEvent, mxEv, last, isGrouped=false, nextEvent, nextEventWithTile) {
|
||||
const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary');
|
||||
const EventTile = sdk.getComponent('rooms.EventTile');
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
|
@ -584,7 +586,7 @@ export default class MessagePanel extends React.Component {
|
|||
|
||||
// do we need a date separator since the last event?
|
||||
const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate);
|
||||
if (wantsDateSeparator) {
|
||||
if (wantsDateSeparator && !isGrouped) {
|
||||
const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>;
|
||||
ret.push(dateSeparator);
|
||||
}
|
||||
|
@ -968,9 +970,9 @@ class CreationGrouper {
|
|||
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
|
||||
|
||||
const panel = this.panel;
|
||||
const ret = [];
|
||||
const isGrouped = true;
|
||||
const createEvent = this.createEvent;
|
||||
const lastShownEvent = this.lastShownEvent;
|
||||
|
||||
|
@ -984,12 +986,12 @@ class CreationGrouper {
|
|||
// If this m.room.create event should be shown (room upgrade) then show it before the summary
|
||||
if (panel._shouldShowEvent(createEvent)) {
|
||||
// pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered
|
||||
ret.push(...panel._getTilesForEvent(createEvent, createEvent, false));
|
||||
ret.push(...panel._getTilesForEvent(createEvent, createEvent));
|
||||
}
|
||||
|
||||
for (const ejected of this.ejectedEvents) {
|
||||
ret.push(...panel._getTilesForEvent(
|
||||
createEvent, ejected, createEvent === lastShownEvent,
|
||||
createEvent, ejected, createEvent === lastShownEvent, isGrouped,
|
||||
));
|
||||
}
|
||||
|
||||
|
@ -998,7 +1000,7 @@ class CreationGrouper {
|
|||
// of EventListSummary, render each member event as if the previous
|
||||
// one was itself. This way, the timestamp of the previous event === the
|
||||
// timestamp of the current event, and no DateSeparator is inserted.
|
||||
return panel._getTilesForEvent(e, e, e === lastShownEvent);
|
||||
return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
|
||||
}).reduce((a, b) => a.concat(b), []);
|
||||
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
|
||||
const ev = this.events[this.events.length - 1];
|
||||
|
@ -1083,7 +1085,7 @@ class RedactionGrouper {
|
|||
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
|
||||
|
||||
const isGrouped = true;
|
||||
const panel = this.panel;
|
||||
const ret = [];
|
||||
const lastShownEvent = this.lastShownEvent;
|
||||
|
@ -1103,7 +1105,8 @@ class RedactionGrouper {
|
|||
let eventTiles = this.events.map((e, i) => {
|
||||
senders.add(e.sender);
|
||||
const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1];
|
||||
return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile);
|
||||
return panel._getTilesForEvent(
|
||||
prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile);
|
||||
}).reduce((a, b) => a.concat(b), []);
|
||||
|
||||
if (eventTiles.length === 0) {
|
||||
|
@ -1182,7 +1185,7 @@ class MemberGrouper {
|
|||
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
|
||||
|
||||
const isGrouped = true;
|
||||
const panel = this.panel;
|
||||
const lastShownEvent = this.lastShownEvent;
|
||||
const ret = [];
|
||||
|
@ -1215,7 +1218,7 @@ class MemberGrouper {
|
|||
// of MemberEventListSummary, render each member event as if the previous
|
||||
// one was itself. This way, the timestamp of the previous event === the
|
||||
// timestamp of the current event, and no DateSeparator is inserted.
|
||||
return panel._getTilesForEvent(e, e, e === lastShownEvent);
|
||||
return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
|
||||
}).reduce((a, b) => a.concat(b), []);
|
||||
|
||||
if (eventTiles.length === 0) {
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -27,8 +27,8 @@ import { Action } from "../../dispatcher/actions";
|
|||
import RoomListStore from "../../stores/room-list/RoomListStore";
|
||||
import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition";
|
||||
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
|
||||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
import SpaceStore, {UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES} from "../../stores/SpaceStore";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -60,6 +60,10 @@ import IconizedContextMenu, {
|
|||
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";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
|
@ -136,15 +140,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 +208,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
setBusy(true);
|
||||
onJoinButtonClicked();
|
||||
}}
|
||||
disabled={!spacesEnabled}
|
||||
>
|
||||
{ _t("Accept") }
|
||||
</AccessibleButton>
|
||||
|
@ -192,10 +221,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
setBusy(true);
|
||||
onJoinButtonClicked();
|
||||
}}
|
||||
disabled={!spacesEnabled}
|
||||
>
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
|
@ -203,6 +233,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 +251,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>;
|
||||
};
|
||||
|
||||
|
@ -385,7 +430,9 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
/>;
|
||||
});
|
||||
|
||||
const onNextClick = async () => {
|
||||
const onNextClick = async (ev) => {
|
||||
ev.preventDefault();
|
||||
if (busy) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
|
@ -410,7 +457,10 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
setBusy(false);
|
||||
};
|
||||
|
||||
let onClick = onFinished;
|
||||
let onClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
onFinished();
|
||||
};
|
||||
let buttonLabel = _t("Skip for now");
|
||||
if (roomNames.some(name => name.trim())) {
|
||||
onClick = onNextClick;
|
||||
|
@ -422,16 +472,20 @@ 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>
|
||||
</div>;
|
||||
};
|
||||
|
@ -504,9 +558,11 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
|
|||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupPublicShare = ({ space, onFinished }) => {
|
||||
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished }) => {
|
||||
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>
|
||||
|
@ -521,11 +577,13 @@ const SpaceSetupPublicShare = ({ space, onFinished }) => {
|
|||
</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
|
||||
|
@ -575,7 +633,9 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
/>;
|
||||
});
|
||||
|
||||
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 +669,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 +685,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,9 +711,15 @@ 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>
|
||||
</div>;
|
||||
};
|
||||
|
@ -759,7 +841,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,7 +854,7 @@ 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" +
|
||||
|
@ -781,11 +863,16 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
onFinished={() => this.setState({ phase: Phase.PublicShare })}
|
||||
/>;
|
||||
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}
|
||||
/>;
|
||||
|
||||
case Phase.PrivateScope:
|
||||
return <SpaceSetupPrivateScope
|
||||
space={this.props.space}
|
||||
justCreatedOpts={this.props.justCreatedOpts}
|
||||
onFinished={(invite: boolean) => {
|
||||
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms });
|
||||
}}
|
||||
|
|
90
src/components/views/beta/BetaCard.tsx
Normal file
90
src/components/views/beta/BetaCard.tsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
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";
|
||||
|
||||
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 } = info;
|
||||
const value = SettingsStore.getValue(featureId);
|
||||
|
||||
return <div className="mx_BetaCard">
|
||||
<div>
|
||||
<h3 className="mx_BetaCard_title">
|
||||
{ titleOverride || _t(title) }
|
||||
<BetaPill />
|
||||
</h3>
|
||||
<span className="mx_BetaCard_caption">{ _t(caption) }</span>
|
||||
<AccessibleButton
|
||||
onClick={() => SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)}
|
||||
kind="primary"
|
||||
>
|
||||
{ value ? _t("Leave the beta") : _t("Join the beta") }
|
||||
</AccessibleButton>
|
||||
{ disclaimer && <div className="mx_BetaCard_disclaimer">
|
||||
{ disclaimer(value) }
|
||||
</div> }
|
||||
</div>
|
||||
<img src={image} alt="" />
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default BetaCard;
|
|
@ -120,6 +120,10 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space,
|
|||
{ 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}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
@ -16,20 +16,23 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import filesize from "filesize";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { getBlobSafeMimeType } from '../../../utils/blobs';
|
||||
|
||||
interface IProps {
|
||||
file: File;
|
||||
currentIndex: number;
|
||||
totalFiles?: number;
|
||||
onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.dialogs.UploadConfirmDialog")
|
||||
export default class UploadConfirmDialog extends React.Component {
|
||||
static propTypes = {
|
||||
file: PropTypes.object.isRequired,
|
||||
currentIndex: PropTypes.number,
|
||||
totalFiles: PropTypes.number,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
}
|
||||
export default class UploadConfirmDialog extends React.Component<IProps> {
|
||||
private objectUrl: string;
|
||||
private mimeType: string;
|
||||
|
||||
static defaultProps = {
|
||||
totalFiles: 1,
|
||||
|
@ -38,22 +41,28 @@ export default class UploadConfirmDialog extends React.Component {
|
|||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._objectUrl = URL.createObjectURL(props.file);
|
||||
// Create a fresh `Blob` for previewing (even though `File` already is
|
||||
// one) so we can adjust the MIME type if needed.
|
||||
this.mimeType = getBlobSafeMimeType(props.file.type);
|
||||
const blob = new Blob([props.file], { type:
|
||||
this.mimeType,
|
||||
});
|
||||
this.objectUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._objectUrl) URL.revokeObjectURL(this._objectUrl);
|
||||
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
|
||||
}
|
||||
|
||||
_onCancelClick = () => {
|
||||
private onCancelClick = () => {
|
||||
this.props.onFinished(false);
|
||||
}
|
||||
|
||||
_onUploadClick = () => {
|
||||
private onUploadClick = () => {
|
||||
this.props.onFinished(true);
|
||||
}
|
||||
|
||||
_onUploadAllClick = () => {
|
||||
private onUploadAllClick = () => {
|
||||
this.props.onFinished(true, true);
|
||||
}
|
||||
|
||||
|
@ -75,10 +84,10 @@ export default class UploadConfirmDialog extends React.Component {
|
|||
}
|
||||
|
||||
let preview;
|
||||
if (this.props.file.type.startsWith('image/')) {
|
||||
if (this.mimeType.startsWith('image/')) {
|
||||
preview = <div className="mx_UploadConfirmDialog_previewOuter">
|
||||
<div className="mx_UploadConfirmDialog_previewInner">
|
||||
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this._objectUrl} /></div>
|
||||
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this.objectUrl} /></div>
|
||||
<div>{this.props.file.name} ({filesize(this.props.file.size)})</div>
|
||||
</div>
|
||||
</div>;
|
||||
|
@ -95,7 +104,7 @@ export default class UploadConfirmDialog extends React.Component {
|
|||
|
||||
let uploadAllButton;
|
||||
if (this.props.currentIndex + 1 < this.props.totalFiles) {
|
||||
uploadAllButton = <button onClick={this._onUploadAllClick}>
|
||||
uploadAllButton = <button onClick={this.onUploadAllClick}>
|
||||
{_t("Upload all")}
|
||||
</button>;
|
||||
}
|
||||
|
@ -103,7 +112,7 @@ export default class UploadConfirmDialog extends React.Component {
|
|||
return (
|
||||
<BaseDialog className='mx_UploadConfirmDialog'
|
||||
fixedWidth={false}
|
||||
onFinished={this._onCancelClick}
|
||||
onFinished={this.onCancelClick}
|
||||
title={title}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
|
@ -113,7 +122,7 @@ export default class UploadConfirmDialog extends React.Component {
|
|||
|
||||
<DialogButtons primaryButton={_t('Upload')}
|
||||
hasCancel={false}
|
||||
onPrimaryButtonClick={this._onUploadClick}
|
||||
onPrimaryButtonClick={this.onUploadClick}
|
||||
focus={true}
|
||||
>
|
||||
{uploadAllButton}
|
|
@ -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"),
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -108,8 +108,6 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
window.addEventListener("resize", this.calculateZoom);
|
||||
// After the image loads for the first time we want to calculate the zoom
|
||||
this.image.current.addEventListener("load", this.calculateZoom);
|
||||
// Try to precalculate the zoom from width and height props
|
||||
this.calculateZoom();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
@ -122,11 +120,8 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
const image = this.image.current;
|
||||
const imageWrapper = this.imageWrapper.current;
|
||||
|
||||
const width = this.props.width || image.naturalWidth;
|
||||
const height = this.props.height || image.naturalHeight;
|
||||
|
||||
const zoomX = imageWrapper.clientWidth / width;
|
||||
const zoomY = imageWrapper.clientHeight / height;
|
||||
const zoomX = imageWrapper.clientWidth / image.naturalWidth;
|
||||
const zoomY = imageWrapper.clientHeight / image.naturalHeight;
|
||||
|
||||
// If the image is smaller in both dimensions set its the zoom to 1 to
|
||||
// display it in its original size
|
||||
|
|
|
@ -187,9 +187,15 @@ function DeviceItem({userId, device}: {userId: string, device: IDevice}) {
|
|||
verifyDevice(cli.getUser(userId), device);
|
||||
};
|
||||
|
||||
const deviceName = device.ambiguous ?
|
||||
(device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" :
|
||||
device.getDisplayName();
|
||||
let deviceName;
|
||||
if (!device.getDisplayName()?.trim()) {
|
||||
deviceName = device.deviceId;
|
||||
} else {
|
||||
deviceName = device.ambiguous ?
|
||||
device.getDisplayName() + " (" + device.deviceId + ")" :
|
||||
device.getDisplayName();
|
||||
}
|
||||
|
||||
let trustedLabel = null;
|
||||
if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted");
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
import React from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import {_t, _td} from '../../../languageHandler';
|
||||
import PropTypes from 'prop-types';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import EditorModel from '../../../editor/model';
|
||||
|
@ -24,16 +24,18 @@ import {getCaretOffsetAndText} from '../../../editor/dom';
|
|||
import {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize';
|
||||
import {findEditableEvent} from '../../../utils/EventUtils';
|
||||
import {parseEvent} from '../../../editor/deserialize';
|
||||
import {PartCreator} from '../../../editor/parts';
|
||||
import {CommandPartCreator} from '../../../editor/parts';
|
||||
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
|
||||
import classNames from 'classnames';
|
||||
import {EventStatus} from 'matrix-js-sdk/src/models/event';
|
||||
import BasicMessageComposer from "./BasicMessageComposer";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {CommandCategories, getCommand} from '../../../SlashCommands';
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import {getKeyBindingsManager, MessageComposerAction} from '../../../KeyBindingsManager';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import Modal from '../../../Modal';
|
||||
|
||||
function _isReply(mxEvent) {
|
||||
const relatesTo = mxEvent.getContent()["m.relates_to"];
|
||||
|
@ -178,6 +180,22 @@ export default class EditMessageComposer extends React.Component {
|
|||
dis.fire(Action.FocusComposer);
|
||||
}
|
||||
|
||||
_isSlashCommand() {
|
||||
const parts = this.model.parts;
|
||||
const firstPart = parts[0];
|
||||
if (firstPart) {
|
||||
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
||||
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_isContentModified(newContent) {
|
||||
// if nothing has changed then bail
|
||||
const oldContent = this.props.editState.getEvent().getContent();
|
||||
|
@ -190,19 +208,112 @@ export default class EditMessageComposer extends React.Component {
|
|||
return true;
|
||||
}
|
||||
|
||||
_sendEdit = () => {
|
||||
_getSlashCommand() {
|
||||
const commandText = this.model.parts.reduce((text, part) => {
|
||||
// use mxid to textify user pills in a command
|
||||
if (part.type === "user-pill") {
|
||||
return text + part.resourceId;
|
||||
}
|
||||
return text + part.text;
|
||||
}, "");
|
||||
const {cmd, args} = getCommand(commandText);
|
||||
return [cmd, args, commandText];
|
||||
}
|
||||
|
||||
async _runSlashCommand(cmd, args, roomId) {
|
||||
const result = cmd.run(roomId, args);
|
||||
let messageContent;
|
||||
let error = result.error;
|
||||
if (result.promise) {
|
||||
try {
|
||||
if (cmd.category === CommandCategories.messages) {
|
||||
messageContent = await result.promise;
|
||||
} else {
|
||||
await result.promise;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
console.error("Command failure: %s", error);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
// assume the error is a server error when the command is async
|
||||
const isServerError = !!result.promise;
|
||||
const title = isServerError ? _td("Server error") : _td("Command error");
|
||||
|
||||
let errText;
|
||||
if (typeof error === 'string') {
|
||||
errText = error;
|
||||
} else if (error.message) {
|
||||
errText = error.message;
|
||||
} else {
|
||||
errText = _t("Server unavailable, overloaded, or something else went wrong.");
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog(title, '', ErrorDialog, {
|
||||
title: _t(title),
|
||||
description: errText,
|
||||
});
|
||||
} else {
|
||||
console.log("Command success.");
|
||||
if (messageContent) return messageContent;
|
||||
}
|
||||
}
|
||||
|
||||
_sendEdit = async () => {
|
||||
const startTime = CountlyAnalytics.getTimestamp();
|
||||
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
|
||||
if (this._isContentModified(newContent)) {
|
||||
const roomId = editedEvent.getRoomId();
|
||||
this._cancelPreviousPendingEdit();
|
||||
const prom = this.context.sendMessage(roomId, editContent);
|
||||
dis.dispatch({action: "message_sent"});
|
||||
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
|
||||
if (!containsEmote(this.model) && this._isSlashCommand()) {
|
||||
const [cmd, args, commandText] = this._getSlashCommand();
|
||||
if (cmd) {
|
||||
if (cmd.category === CommandCategories.messages) {
|
||||
editContent["m.new_content"] = await this._runSlashCommand(cmd, args, roomId);
|
||||
} else {
|
||||
this._runSlashCommand(cmd, args, roomId);
|
||||
shouldSend = false;
|
||||
}
|
||||
} else {
|
||||
// ask the user if their unknown command should be sent as a message
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const {finished} = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
|
||||
title: _t("Unknown Command"),
|
||||
description: <div>
|
||||
<p>
|
||||
{ _t("Unrecognised command: %(commandText)s", {commandText}) }
|
||||
</p>
|
||||
<p>
|
||||
{ _t("You can use <code>/help</code> to list available commands. " +
|
||||
"Did you mean to send this as a message?", {}, {
|
||||
code: t => <code>{ t }</code>,
|
||||
}) }
|
||||
</p>
|
||||
<p>
|
||||
{ _t("Hint: Begin your message with <code>//</code> to start it with a slash.", {}, {
|
||||
code: t => <code>{ t }</code>,
|
||||
}) }
|
||||
</p>
|
||||
</div>,
|
||||
button: _t('Send as message'),
|
||||
});
|
||||
const [sendAnyway] = await finished;
|
||||
// if !sendAnyway bail to let the user edit the composer and try again
|
||||
if (!sendAnyway) return;
|
||||
}
|
||||
}
|
||||
if (shouldSend) {
|
||||
this._cancelPreviousPendingEdit();
|
||||
const prom = this.context.sendMessage(roomId, editContent);
|
||||
dis.dispatch({action: "message_sent"});
|
||||
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
|
||||
}
|
||||
}
|
||||
|
||||
// close the event editing and focus composer
|
||||
|
@ -240,7 +351,7 @@ export default class EditMessageComposer extends React.Component {
|
|||
_createEditorModel() {
|
||||
const {editState} = this.props;
|
||||
const room = this._getRoom();
|
||||
const partCreator = new PartCreator(room, this.context);
|
||||
const partCreator = new CommandPartCreator(room, this.context);
|
||||
let parts;
|
||||
if (editState.hasEditorState()) {
|
||||
// if restoring state from a previous editor,
|
||||
|
|
|
@ -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,24 @@ 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";
|
||||
|
||||
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
|
||||
return (
|
||||
|
@ -41,17 +47,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 +135,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
|
||||
|
@ -146,9 +174,30 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
}
|
||||
</p>
|
||||
|
||||
<SpaceBasicSettings setAvatar={setAvatar} name={name} setName={setName} topic={topic} setTopic={setTopic} />
|
||||
<form className="mx_SpaceBasicSettings" onSubmit={onSpaceCreateClick}>
|
||||
<SpaceAvatar setAvatar={setAvatar} />
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<Field
|
||||
name="spaceTopic"
|
||||
element="textarea"
|
||||
label={_t("Description")}
|
||||
value={topic}
|
||||
onChange={ev => setTopic(ev.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<AccessibleButton kind="primary" onClick={onSpaceCreateClick} disabled={busy}>
|
||||
{ busy ? _t("Creating...") : _t("Create") }
|
||||
</AccessibleButton>
|
||||
</React.Fragment>;
|
||||
|
@ -164,6 +213,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
managed={false}
|
||||
>
|
||||
<FocusLock returnFocus={true}>
|
||||
<BetaPill onClick={() => {
|
||||
onFinished();
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: USER_LABS_TAB,
|
||||
});
|
||||
}} />
|
||||
{ body }
|
||||
</FocusLock>
|
||||
</ContextMenu>;
|
||||
|
|
|
@ -26,13 +26,11 @@ import {SpaceItem} from "./SpaceTreeLevel";
|
|||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import {useEventEmitter} from "../../../hooks/useEventEmitter";
|
||||
import SpaceStore, {
|
||||
HOME_SPACE,
|
||||
UPDATE_INVITED_SPACES,
|
||||
UPDATE_SELECTED_SPACE,
|
||||
UPDATE_TOP_LEVEL_SPACES,
|
||||
} from "../../../stores/SpaceStore";
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
import {SpaceNotificationState} from "../../../stores/notifications/SpaceNotificationState";
|
||||
import NotificationBadge from "../rooms/NotificationBadge";
|
||||
import {
|
||||
RovingAccessibleButton,
|
||||
|
@ -40,13 +38,15 @@ import {
|
|||
RovingTabIndexProvider,
|
||||
} from "../../../accessibility/RovingTabIndex";
|
||||
import {Key} from "../../../Keyboard";
|
||||
import {RoomNotificationStateStore} from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import {NotificationState} from "../../../stores/notifications/NotificationState";
|
||||
|
||||
interface IButtonProps {
|
||||
space?: Room;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
tooltip?: string;
|
||||
notificationState?: SpaceNotificationState;
|
||||
notificationState?: NotificationState;
|
||||
isNarrow?: boolean;
|
||||
onClick(): void;
|
||||
}
|
||||
|
@ -212,8 +212,8 @@ const SpacePanel = () => {
|
|||
className="mx_SpaceButton_home"
|
||||
onClick={() => SpaceStore.instance.setActiveSpace(null)}
|
||||
selected={!activeSpace}
|
||||
tooltip={_t("Home")}
|
||||
notificationState={SpaceStore.instance.getNotificationState(HOME_SPACE)}
|
||||
tooltip={_t("All rooms")}
|
||||
notificationState={RoomNotificationStateStore.instance.globalState}
|
||||
isNarrow={isPanelCollapsed}
|
||||
/>
|
||||
{ invites.map(s => <SpaceItem
|
||||
|
|
|
@ -116,14 +116,22 @@ export function htmlSerializeIfNeeded(model: EditorModel, {forceHTML = false} =
|
|||
const parser = new Markdown(md);
|
||||
if (!parser.isPlainText() || forceHTML) {
|
||||
// feed Markdown output to HTML parser
|
||||
const phtml = cheerio.load(parser.toHTML(),
|
||||
{ _useHtmlParser2: true, decodeEntities: false });
|
||||
const phtml = cheerio.load(parser.toHTML(), {
|
||||
// @ts-ignore: The `_useHtmlParser2` internal option is the
|
||||
// simplest way to both parse and render using `htmlparser2`.
|
||||
_useHtmlParser2: true,
|
||||
decodeEntities: false,
|
||||
});
|
||||
|
||||
if (SettingsStore.getValue("feature_latex_maths")) {
|
||||
// original Markdown without LaTeX replacements
|
||||
const parserOrig = new Markdown(orig);
|
||||
const phtmlOrig = cheerio.load(parserOrig.toHTML(),
|
||||
{ _useHtmlParser2: true, decodeEntities: false });
|
||||
const phtmlOrig = cheerio.load(parserOrig.toHTML(), {
|
||||
// @ts-ignore: The `_useHtmlParser2` internal option is the
|
||||
// simplest way to both parse and render using `htmlparser2`.
|
||||
_useHtmlParser2: true,
|
||||
decodeEntities: false,
|
||||
});
|
||||
|
||||
// since maths delimiters are handled before Markdown,
|
||||
// code blocks could contain mangled content.
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -785,6 +785,13 @@
|
|||
"%(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.",
|
||||
"%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(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.",
|
||||
"Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode",
|
||||
"Send and receive voice messages": "Send and receive voice messages",
|
||||
"Render LaTeX maths in messages": "Render LaTeX maths in messages",
|
||||
|
@ -996,8 +1003,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",
|
||||
|
@ -1012,7 +1020,7 @@
|
|||
"Create": "Create",
|
||||
"Expand space panel": "Expand space panel",
|
||||
"Collapse space panel": "Collapse space panel",
|
||||
"Home": "Home",
|
||||
"All rooms": "All rooms",
|
||||
"Click to copy": "Click to copy",
|
||||
"Copied!": "Copied!",
|
||||
"Failed to copy": "Failed to copy",
|
||||
|
@ -1258,7 +1266,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.",
|
||||
|
@ -1441,6 +1449,13 @@
|
|||
"Someone is using an unknown session": "Someone is using an unknown session",
|
||||
"This room is end-to-end encrypted": "This room is end-to-end encrypted",
|
||||
"Everyone in this room is verified": "Everyone in this room is verified",
|
||||
"Server error": "Server error",
|
||||
"Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.",
|
||||
"Unknown Command": "Unknown Command",
|
||||
"Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s",
|
||||
"You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?",
|
||||
"Hint: Begin your message with <code>//</code> to start it with a slash.": "Hint: Begin your message with <code>//</code> to start it with a slash.",
|
||||
"Send as message": "Send as message",
|
||||
"Edit message": "Edit message",
|
||||
"Mod": "Mod",
|
||||
"This event could not be displayed": "This event could not be displayed",
|
||||
|
@ -1631,13 +1646,6 @@
|
|||
"This Room": "This Room",
|
||||
"All Rooms": "All Rooms",
|
||||
"Search…": "Search…",
|
||||
"Server error": "Server error",
|
||||
"Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.",
|
||||
"Unknown Command": "Unknown Command",
|
||||
"Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s",
|
||||
"You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?",
|
||||
"Hint: Begin your message with <code>//</code> to start it with a slash.": "Hint: Begin your message with <code>//</code> to start it with a slash.",
|
||||
"Send as message": "Send as message",
|
||||
"Failed to connect to integration manager": "Failed to connect to integration manager",
|
||||
"You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled",
|
||||
"Add some now": "Add some now",
|
||||
|
@ -2016,10 +2024,11 @@
|
|||
"Continue with %(provider)s": "Continue with %(provider)s",
|
||||
"Sign in with single sign-on": "Sign in with single sign-on",
|
||||
"And %(count)s more...|other": "And %(count)s more...",
|
||||
"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",
|
||||
"All rooms": "All rooms",
|
||||
"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>",
|
||||
"Remove server": "Remove server",
|
||||
|
@ -2031,7 +2040,8 @@
|
|||
"%(networkName)s rooms": "%(networkName)s rooms",
|
||||
"Matrix rooms": "Matrix rooms",
|
||||
"Filter your rooms and spaces": "Filter your rooms and spaces",
|
||||
"Spaces": "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",
|
||||
|
@ -2464,6 +2474,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 +2612,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.",
|
||||
|
@ -2670,6 +2686,8 @@
|
|||
"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",
|
||||
|
@ -2694,6 +2712,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.",
|
||||
|
|
|
@ -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": "暫停"
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,13 @@ 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(...)
|
||||
};
|
||||
}
|
||||
|
||||
export const SETTINGS: {[setting: string]: ISetting} = {
|
||||
|
@ -127,6 +136,33 @@ 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("%(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"),
|
||||
},
|
||||
},
|
||||
"feature_dnd": {
|
||||
isFeature: true,
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -31,28 +31,23 @@ import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateS
|
|||
import {DefaultTagID} from "./room-list/models";
|
||||
import {EnhancedMap, mapDiff} from "../utils/maps";
|
||||
import {setHasDiff} from "../utils/sets";
|
||||
import {objectDiff} from "../utils/objects";
|
||||
import {arrayHasDiff} from "../utils/arrays";
|
||||
import {ISpaceSummaryEvent, ISpaceSummaryRoom} from "../components/structures/SpaceRoomDirectory";
|
||||
import RoomViewStore from "./RoomViewStore";
|
||||
|
||||
type SpaceKey = string | symbol;
|
||||
|
||||
interface IState {}
|
||||
|
||||
const ACTIVE_SPACE_LS_KEY = "mx_active_space";
|
||||
|
||||
export const HOME_SPACE = Symbol("home-space");
|
||||
export const SUGGESTED_ROOMS = Symbol("suggested-rooms");
|
||||
|
||||
export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces");
|
||||
export const UPDATE_INVITED_SPACES = Symbol("invited-spaces");
|
||||
export const UPDATE_SELECTED_SPACE = Symbol("selected-space");
|
||||
// Space Room ID/HOME_SPACE will be emitted when a Space's children change
|
||||
// Space Room ID will be emitted when a Space's children change
|
||||
|
||||
const MAX_SUGGESTED_ROOMS = 20;
|
||||
|
||||
const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "home_space"}`;
|
||||
const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "ALL_ROOMS"}`;
|
||||
|
||||
const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms]
|
||||
return arr.reduce((result, room: Room) => {
|
||||
|
@ -86,15 +81,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
|
||||
// The spaces representing the roots of the various tree-like hierarchies
|
||||
private rootSpaces: Room[] = [];
|
||||
// The list of rooms not present in any currently joined spaces
|
||||
private orphanedRooms = new Set<string>();
|
||||
// Map from room ID to set of spaces which list it as a child
|
||||
private parentMap = new EnhancedMap<string, Set<string>>();
|
||||
// Map from space key to SpaceNotificationState instance representing that space
|
||||
private notificationStateMap = new Map<SpaceKey, SpaceNotificationState>();
|
||||
// Map from spaceId to SpaceNotificationState instance representing that space
|
||||
private notificationStateMap = new Map<string, SpaceNotificationState>();
|
||||
// Map from space key to Set of room IDs that should be shown as part of that space's filter
|
||||
private spaceFilteredRooms = new Map<string | symbol, Set<string>>();
|
||||
// The space currently selected in the Space Panel - if null then `Home` is selected
|
||||
private spaceFilteredRooms = new Map<string, Set<string>>();
|
||||
// The space currently selected in the Space Panel - if null then All Rooms is selected
|
||||
private _activeSpace?: Room = null;
|
||||
private _suggestedRooms: ISpaceSummaryRoom[] = [];
|
||||
private _invitedSpaces = new Set<Room>();
|
||||
|
@ -244,7 +237,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
}
|
||||
|
||||
public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => {
|
||||
return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set();
|
||||
if (!space) {
|
||||
return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId));
|
||||
}
|
||||
return this.spaceFilteredRooms.get(space.roomId) || new Set();
|
||||
};
|
||||
|
||||
private rebuild = throttle(() => {
|
||||
|
@ -275,7 +271,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
});
|
||||
});
|
||||
|
||||
const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren));
|
||||
const [rootSpaces] = partitionSpacesAndRooms(Array.from(unseenChildren));
|
||||
|
||||
// somewhat algorithm to handle full-cycles
|
||||
const detachedNodes = new Set<Room>(spaces);
|
||||
|
@ -316,7 +312,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
// rootSpaces.push(space);
|
||||
// });
|
||||
|
||||
this.orphanedRooms = new Set(orphanedRooms);
|
||||
this.rootSpaces = rootSpaces;
|
||||
this.parentMap = backrefs;
|
||||
|
||||
|
@ -337,25 +332,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
this.rebuild();
|
||||
}
|
||||
|
||||
private showInHomeSpace = (room: Room) => {
|
||||
if (room.isSpaceRoom()) return false;
|
||||
return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space
|
||||
|| DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space
|
||||
|| RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite) // show all favourites
|
||||
};
|
||||
|
||||
// Update a given room due to its tag changing (e.g DM-ness or Fav-ness)
|
||||
// This can only change whether it shows up in the HOME_SPACE or not
|
||||
private onRoomUpdate = (room: Room) => {
|
||||
if (this.showInHomeSpace(room)) {
|
||||
this.spaceFilteredRooms.get(HOME_SPACE)?.add(room.roomId);
|
||||
this.emit(HOME_SPACE);
|
||||
} else if (!this.orphanedRooms.has(room.roomId)) {
|
||||
this.spaceFilteredRooms.get(HOME_SPACE)?.delete(room.roomId);
|
||||
this.emit(HOME_SPACE);
|
||||
}
|
||||
};
|
||||
|
||||
private onSpaceMembersChange = (ev: MatrixEvent) => {
|
||||
// skip this update if we do not have a DM with this user
|
||||
if (DMRoomMap.shared().getDMRoomsForUserId(ev.getStateKey()).length < 1) return;
|
||||
|
@ -369,16 +345,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
const oldFilteredRooms = this.spaceFilteredRooms;
|
||||
this.spaceFilteredRooms = new Map();
|
||||
|
||||
// put all room invites in the Home Space
|
||||
const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite");
|
||||
this.spaceFilteredRooms.set(HOME_SPACE, new Set<string>(invites.map(room => room.roomId)));
|
||||
|
||||
visibleRooms.forEach(room => {
|
||||
if (this.showInHomeSpace(room)) {
|
||||
this.spaceFilteredRooms.get(HOME_SPACE).add(room.roomId);
|
||||
}
|
||||
});
|
||||
|
||||
this.rootSpaces.forEach(s => {
|
||||
// traverse each space tree in DFS to build up the supersets as you go up,
|
||||
// reusing results from like subtrees.
|
||||
|
@ -425,13 +391,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
// Update NotificationStates
|
||||
this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => {
|
||||
if (roomIds.has(room.roomId)) {
|
||||
// Don't aggregate notifications for DMs except in the Home Space
|
||||
if (s !== HOME_SPACE) {
|
||||
return !DMRoomMap.shared().getUserIdForRoomId(room.roomId)
|
||||
|| RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite);
|
||||
}
|
||||
|
||||
return true;
|
||||
return !DMRoomMap.shared().getUserIdForRoomId(room.roomId)
|
||||
|| RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
@ -513,8 +474,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
// TODO confirm this after implementing parenting behaviour
|
||||
if (room.isSpaceRoom()) {
|
||||
this.onSpaceUpdate();
|
||||
} else {
|
||||
this.onRoomUpdate(room);
|
||||
}
|
||||
this.emit(room.roomId);
|
||||
break;
|
||||
|
@ -527,38 +486,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onRoomAccountData = (ev: MatrixEvent, room: Room, lastEvent?: MatrixEvent) => {
|
||||
if (ev.getType() === EventType.Tag && !room.isSpaceRoom()) {
|
||||
// If the room was in favourites and now isn't or the opposite then update its position in the trees
|
||||
const oldTags = lastEvent?.getContent()?.tags || {};
|
||||
const newTags = ev.getContent()?.tags || {};
|
||||
if (!!oldTags[DefaultTagID.Favourite] !== !!newTags[DefaultTagID.Favourite]) {
|
||||
this.onRoomUpdate(room);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => {
|
||||
if (ev.getType() === EventType.Direct) {
|
||||
const lastContent = lastEvent.getContent();
|
||||
const content = ev.getContent();
|
||||
|
||||
const diff = objectDiff<Record<string, string[]>>(lastContent, content);
|
||||
// filter out keys which changed by reference only by checking whether the sets differ
|
||||
const changed = diff.changed.filter(k => arrayHasDiff(lastContent[k], content[k]));
|
||||
// DM tag changes, refresh relevant rooms
|
||||
new Set([...diff.added, ...diff.removed, ...changed]).forEach(roomId => {
|
||||
const room = this.matrixClient?.getRoom(roomId);
|
||||
if (room) {
|
||||
this.onRoomUpdate(room);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
protected async reset() {
|
||||
this.rootSpaces = [];
|
||||
this.orphanedRooms = new Set();
|
||||
this.parentMap = new EnhancedMap();
|
||||
this.notificationStateMap = new Map();
|
||||
this.spaceFilteredRooms = new Map();
|
||||
|
@ -573,8 +502,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
this.matrixClient.removeListener("Room", this.onRoom);
|
||||
this.matrixClient.removeListener("Room.myMembership", this.onRoom);
|
||||
this.matrixClient.removeListener("RoomState.events", this.onRoomState);
|
||||
this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData);
|
||||
this.matrixClient.removeListener("accountData", this.onAccountData);
|
||||
}
|
||||
await this.reset();
|
||||
}
|
||||
|
@ -584,8 +511,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
this.matrixClient.on("Room", this.onRoom);
|
||||
this.matrixClient.on("Room.myMembership", this.onRoom);
|
||||
this.matrixClient.on("RoomState.events", this.onRoomState);
|
||||
this.matrixClient.on("Room.accountData", this.onRoomAccountData);
|
||||
this.matrixClient.on("accountData", this.onAccountData);
|
||||
|
||||
await this.onSpaceUpdate(); // trigger an initial update
|
||||
|
||||
|
@ -610,7 +535,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
// Don't context switch when navigating to the space room
|
||||
// as it will cause you to end up in the wrong room
|
||||
this.setActiveSpace(room, false);
|
||||
} else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) {
|
||||
} else if (this.activeSpace && !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) {
|
||||
this.switchToRelatedSpace(roomId);
|
||||
}
|
||||
|
||||
|
@ -628,7 +553,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
}
|
||||
}
|
||||
|
||||
public getNotificationState(key: SpaceKey): SpaceNotificationState {
|
||||
public getNotificationState(key: string): SpaceNotificationState {
|
||||
if (this.notificationStateMap.has(key)) {
|
||||
return this.notificationStateMap.get(key);
|
||||
}
|
||||
|
|
|
@ -680,7 +680,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
|
|||
promise = this.recalculatePrefiltering();
|
||||
} else {
|
||||
this.filterConditions.push(filter);
|
||||
// Runtime filters with spaces disable prefiltering for the search all spaces effect
|
||||
// Runtime filters with spaces disable prefiltering for the search all spaces feature
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
// this has to be awaited so that `setKnownRooms` is called in time for the `addFilterCondition` below
|
||||
// this way the runtime filters are only evaluated on one dataset and not both.
|
||||
|
@ -712,10 +712,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
|
|||
|
||||
if (this.algorithm) {
|
||||
this.algorithm.removeFilterCondition(filter);
|
||||
// Runtime filters with spaces disable prefiltering for the search all spaces effect
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
promise = this.recalculatePrefiltering();
|
||||
}
|
||||
}
|
||||
// Runtime filters with spaces disable prefiltering for the search all spaces feature
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
promise = this.recalculatePrefiltering();
|
||||
}
|
||||
}
|
||||
idx = this.prefilterConditions.indexOf(filter);
|
||||
|
|
|
@ -24,26 +24,34 @@ import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore";
|
|||
* Watches for changes in spaces to manage the filter on the provided RoomListStore
|
||||
*/
|
||||
export class SpaceWatcher {
|
||||
private filter = new SpaceFilterCondition();
|
||||
private filter: SpaceFilterCondition;
|
||||
private activeSpace: Room = SpaceStore.instance.activeSpace;
|
||||
|
||||
constructor(private store: RoomListStoreClass) {
|
||||
this.updateFilter(); // get the filter into a consistent state
|
||||
store.addFilter(this.filter);
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated);
|
||||
}
|
||||
|
||||
private onSelectedSpaceUpdated = (activeSpace: Room) => {
|
||||
private onSelectedSpaceUpdated = (activeSpace?: Room) => {
|
||||
this.activeSpace = activeSpace;
|
||||
this.updateFilter();
|
||||
|
||||
if (this.filter) {
|
||||
if (activeSpace) {
|
||||
this.updateFilter();
|
||||
} else {
|
||||
this.store.removeFilter(this.filter);
|
||||
this.filter = null;
|
||||
}
|
||||
} else if (activeSpace) {
|
||||
this.filter = new SpaceFilterCondition();
|
||||
this.updateFilter();
|
||||
this.store.addFilter(this.filter);
|
||||
}
|
||||
};
|
||||
|
||||
private updateFilter = () => {
|
||||
if (this.activeSpace) {
|
||||
SpaceStore.instance.traverseSpace(this.activeSpace.roomId, roomId => {
|
||||
this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded();
|
||||
});
|
||||
}
|
||||
SpaceStore.instance.traverseSpace(this.activeSpace.roomId, roomId => {
|
||||
this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded();
|
||||
});
|
||||
this.filter.updateSpace(this.activeSpace);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
|
|||
|
||||
import { FILTER_CHANGED, FilterKind, IFilterCondition } from "./IFilterCondition";
|
||||
import { IDestroyable } from "../../../utils/IDestroyable";
|
||||
import SpaceStore, {HOME_SPACE} from "../../SpaceStore";
|
||||
import SpaceStore from "../../SpaceStore";
|
||||
import { setHasDiff } from "../../../utils/sets";
|
||||
|
||||
/**
|
||||
|
@ -55,10 +55,12 @@ export class SpaceFilterCondition extends EventEmitter implements IFilterConditi
|
|||
}
|
||||
};
|
||||
|
||||
private getSpaceEventKey = (space: Room | null) => space ? space.roomId : HOME_SPACE;
|
||||
private getSpaceEventKey = (space: Room) => space.roomId;
|
||||
|
||||
public updateSpace(space: Room) {
|
||||
SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate);
|
||||
if (this.space) {
|
||||
SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate);
|
||||
}
|
||||
SpaceStore.instance.on(this.getSpaceEventKey(this.space = space), this.onStoreUpdate);
|
||||
this.onStoreUpdate(); // initial update from the change to the space
|
||||
}
|
||||
|
|
|
@ -17,63 +17,8 @@ limitations under the License.
|
|||
// Pull in the encryption lib so that we can decrypt attachments.
|
||||
import encrypt from 'browser-encrypt-attachment';
|
||||
import {mediaFromContent} from "../customisations/Media";
|
||||
import {IEncryptedFile} from "../customisations/models/IMediaEventContent";
|
||||
|
||||
// WARNING: We have to be very careful about what mime-types we allow into blobs,
|
||||
// as for performance reasons these are now rendered via URL.createObjectURL()
|
||||
// rather than by converting into data: URIs.
|
||||
//
|
||||
// This means that the content is rendered using the origin of the script which
|
||||
// called createObjectURL(), and so if the content contains any scripting then it
|
||||
// will pose a XSS vulnerability when the browser renders it. This is particularly
|
||||
// bad if the user right-clicks the URI and pastes it into a new window or tab,
|
||||
// as the blob will then execute with access to Element's full JS environment(!)
|
||||
//
|
||||
// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647
|
||||
// for details.
|
||||
//
|
||||
// We mitigate this by only allowing mime-types into blobs which we know don't
|
||||
// contain any scripting, and instantiate all others as application/octet-stream
|
||||
// regardless of what mime-type the event claimed. Even if the payload itself
|
||||
// is some malicious HTML, the fact we instantiate it with a media mimetype or
|
||||
// application/octet-stream means the browser doesn't try to render it as such.
|
||||
//
|
||||
// One interesting edge case is image/svg+xml, which empirically *is* rendered
|
||||
// correctly if the blob is set to the src attribute of an img tag (for thumbnails)
|
||||
// *even if the mimetype is application/octet-stream*. However, empirically JS
|
||||
// in the SVG isn't executed in this scenario, so we seem to be okay.
|
||||
//
|
||||
// Tested on Chrome 65 and Firefox 60
|
||||
//
|
||||
// The list below is taken mainly from
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats
|
||||
// N.B. Matrix doesn't currently specify which mimetypes are valid in given
|
||||
// events, so we pick the ones which HTML5 browsers should be able to display
|
||||
//
|
||||
// For the record, mime-types which must NEVER enter this list below include:
|
||||
// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar.
|
||||
|
||||
const ALLOWED_BLOB_MIMETYPES = [
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/png',
|
||||
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/ogg',
|
||||
|
||||
'audio/mp4',
|
||||
'audio/webm',
|
||||
'audio/aac',
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
'audio/wave',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/x-pn-wav',
|
||||
'audio/flac',
|
||||
'audio/x-flac',
|
||||
];
|
||||
import { IEncryptedFile } from "../customisations/models/IMediaEventContent";
|
||||
import { getBlobSafeMimeType } from "./blobs";
|
||||
|
||||
/**
|
||||
* Decrypt a file attached to a matrix event.
|
||||
|
@ -100,9 +45,7 @@ export function decryptFile(file: IEncryptedFile): Promise<Blob> {
|
|||
// browser (e.g. by copying the URI into a new tab or window.)
|
||||
// See warning at top of file.
|
||||
let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : '';
|
||||
if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) {
|
||||
mimetype = 'application/octet-stream';
|
||||
}
|
||||
mimetype = getBlobSafeMimeType(mimetype);
|
||||
|
||||
return new Blob([dataArray], {type: mimetype});
|
||||
});
|
||||
|
|
78
src/utils/blobs.ts
Normal file
78
src/utils/blobs.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// WARNING: We have to be very careful about what mime-types we allow into blobs,
|
||||
// as for performance reasons these are now rendered via URL.createObjectURL()
|
||||
// rather than by converting into data: URIs.
|
||||
//
|
||||
// This means that the content is rendered using the origin of the script which
|
||||
// called createObjectURL(), and so if the content contains any scripting then it
|
||||
// will pose a XSS vulnerability when the browser renders it. This is particularly
|
||||
// bad if the user right-clicks the URI and pastes it into a new window or tab,
|
||||
// as the blob will then execute with access to Element's full JS environment(!)
|
||||
//
|
||||
// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647
|
||||
// for details.
|
||||
//
|
||||
// We mitigate this by only allowing mime-types into blobs which we know don't
|
||||
// contain any scripting, and instantiate all others as application/octet-stream
|
||||
// regardless of what mime-type the event claimed. Even if the payload itself
|
||||
// is some malicious HTML, the fact we instantiate it with a media mimetype or
|
||||
// application/octet-stream means the browser doesn't try to render it as such.
|
||||
//
|
||||
// One interesting edge case is image/svg+xml, which empirically *is* rendered
|
||||
// correctly if the blob is set to the src attribute of an img tag (for thumbnails)
|
||||
// *even if the mimetype is application/octet-stream*. However, empirically JS
|
||||
// in the SVG isn't executed in this scenario, so we seem to be okay.
|
||||
//
|
||||
// Tested on Chrome 65 and Firefox 60
|
||||
//
|
||||
// The list below is taken mainly from
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats
|
||||
// N.B. Matrix doesn't currently specify which mimetypes are valid in given
|
||||
// events, so we pick the ones which HTML5 browsers should be able to display
|
||||
//
|
||||
// For the record, mime-types which must NEVER enter this list below include:
|
||||
// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar.
|
||||
|
||||
const ALLOWED_BLOB_MIMETYPES = [
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/png',
|
||||
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/ogg',
|
||||
|
||||
'audio/mp4',
|
||||
'audio/webm',
|
||||
'audio/aac',
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
'audio/wave',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/x-pn-wav',
|
||||
'audio/flac',
|
||||
'audio/x-flac',
|
||||
];
|
||||
|
||||
export function getBlobSafeMimeType(mimetype: string): string {
|
||||
if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) {
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
return mimetype;
|
||||
}
|
|
@ -21,6 +21,7 @@ import {SimpleObservable} from "matrix-widget-api";
|
|||
import {IDestroyable} from "../utils/IDestroyable";
|
||||
import {PlaybackClock} from "./PlaybackClock";
|
||||
import {clamp} from "../utils/numbers";
|
||||
import {createAudioContext, decodeOgg} from "./compat";
|
||||
|
||||
export enum PlaybackState {
|
||||
Decoding = "decoding",
|
||||
|
@ -49,7 +50,7 @@ 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);
|
||||
|
@ -91,7 +92,23 @@ 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...
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue