Merge remote-tracking branch 'origin/develop' into dbkr/fix_roomlist_prompt

This commit is contained in:
David Baker 2021-09-15 15:13:52 +01:00
commit 4f53e6cddd
17 changed files with 218 additions and 154 deletions

View file

@ -23,11 +23,11 @@ limitations under the License.
} }
.mx_EventTile[data-layout=bubble] { .mx_EventTile[data-layout=bubble] {
position: relative; position: relative;
margin-top: var(--gutterSize); margin-top: var(--gutterSize);
margin-left: 50px; margin-left: 49px;
margin-right: 100px; margin-right: 100px;
font-size: $font-14px;
&.mx_EventTile_continuation { &.mx_EventTile_continuation {
margin-top: 2px; margin-top: 2px;
@ -77,10 +77,11 @@ limitations under the License.
max-width: 70%; max-width: 70%;
} }
.mx_SenderProfile { > .mx_SenderProfile {
position: relative; position: relative;
top: -2px; top: -2px;
left: 2px; left: 2px;
font-size: $font-15px;
} }
&[data-self=false] { &[data-self=false] {
@ -113,8 +114,6 @@ limitations under the License.
.mx_ReplyTile .mx_SenderProfile { .mx_ReplyTile .mx_SenderProfile {
display: block; display: block;
top: unset;
left: unset;
} }
.mx_ReactionsRow { .mx_ReactionsRow {
@ -287,6 +286,8 @@ limitations under the License.
.mx_EventTile_line, .mx_EventTile_line,
.mx_EventTile_info { .mx_EventTile_info {
min-width: 100%; min-width: 100%;
// Preserve alignment with left edge of text in bubbles
margin: 0;
} }
.mx_EventTile_e2eIcon { .mx_EventTile_e2eIcon {
@ -294,9 +295,10 @@ limitations under the License.
} }
.mx_EventTile_line > a { .mx_EventTile_line > a {
// Align timestamps with those of normal bubble tiles
right: auto; right: auto;
top: -15px; top: -11px;
left: -68px; left: -95px;
} }
} }
@ -326,11 +328,10 @@ limitations under the License.
} }
.mx_EventTile_line { .mx_EventTile_line {
margin: 0 5px; margin: 0;
> a { > a {
left: auto; // Align timestamps with those of normal bubble tiles
right: 0; left: -76px;
transform: translateX(calc(100% + 5px));
} }
} }
@ -340,7 +341,8 @@ limitations under the License.
} }
.mx_EventListSummary[data-expanded=false][data-layout=bubble] { .mx_EventListSummary[data-expanded=false][data-layout=bubble] {
padding: 0 34px; // Align with left edge of bubble tiles
padding: 0 49px;
} }
/* events that do not require bubble layout */ /* events that do not require bubble layout */

View file

@ -172,14 +172,12 @@ limitations under the License.
} }
} }
// In the general case, we leave height of headers alone even if sticky, so // In the general case, we reserve space for each sublist header to prevent
// that the sublists below them do not jump. However, that leaves a gap // scroll jumps when they become sticky. However, that leaves a gap when
// when scrolled to the top above the first sublist (whose header can only // scrolled to the top above the first sublist (whose header can only ever
// ever stick to top), so we force height to 0 for only that first header. // stick to top), so we make sure to exclude the first visible sublist.
// See also https://github.com/vector-im/element-web/issues/14429. &:not(.mx_RoomSublist_hidden) ~ .mx_RoomSublist .mx_RoomSublist_headerContainer {
&:first-child .mx_RoomSublist_headerContainer { height: 24px;
height: 0;
padding-bottom: 4px;
} }
.mx_RoomSublist_resizeBox { .mx_RoomSublist_resizeBox {

View file

@ -574,11 +574,12 @@ async function doSetLoggedIn(
await abortLogin(); await abortLogin();
} }
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
MatrixClientPeg.replaceUsingCreds(credentials); MatrixClientPeg.replaceUsingCreds(credentials);
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) { if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {

View file

@ -17,8 +17,8 @@ limitations under the License.
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { SettingLevel } from "./settings/SettingLevel"; import { SettingLevel } from "./settings/SettingLevel";
import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
import EventEmitter from 'events'; import EventEmitter from 'events';
import { MatrixClientPeg } from "./MatrixClientPeg";
// XXX: MediaDeviceKind is a union type, so we make our own enum // XXX: MediaDeviceKind is a union type, so we make our own enum
export enum MediaDeviceKindEnum { export enum MediaDeviceKindEnum {
@ -74,8 +74,8 @@ export default class MediaDeviceHandler extends EventEmitter {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
setMatrixCallAudioInput(audioDeviceId); MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId); MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId);
} }
public setAudioOutput(deviceId: string): void { public setAudioOutput(deviceId: string): void {
@ -90,7 +90,7 @@ export default class MediaDeviceHandler extends EventEmitter {
*/ */
public setAudioInput(deviceId: string): void { public setAudioInput(deviceId: string): void {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallAudioInput(deviceId); MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId);
} }
/** /**
@ -100,7 +100,7 @@ export default class MediaDeviceHandler extends EventEmitter {
*/ */
public setVideoInput(deviceId: string): void { public setVideoInput(deviceId: string): void {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallVideoInput(deviceId); MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId);
} }
public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void { public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void {

View file

@ -18,6 +18,8 @@ import posthog, { PostHog } from 'posthog-js';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
import SettingsStore from './settings/SettingsStore'; import SettingsStore from './settings/SettingsStore';
import { MatrixClientPeg } from "./MatrixClientPeg";
import { MatrixClient } from "matrix-js-sdk/src/client";
/* Posthog analytics tracking. /* Posthog analytics tracking.
* *
@ -141,6 +143,7 @@ export class PosthogAnalytics {
private enabled = false; private enabled = false;
private static _instance = null; private static _instance = null;
private platformSuperProperties = {}; private platformSuperProperties = {};
private static ANALYTICS_ID_EVENT_TYPE = "im.vector.web.analytics_id";
public static get instance(): PosthogAnalytics { public static get instance(): PosthogAnalytics {
if (!this._instance) { if (!this._instance) {
@ -274,9 +277,32 @@ export class PosthogAnalytics {
this.anonymity = anonymity; this.anonymity = anonymity;
} }
public async identifyUser(userId: string): Promise<void> { private static getRandomAnalyticsId(): string {
return [...crypto.getRandomValues(new Uint8Array(16))].map((c) => c.toString(16)).join('');
}
public async identifyUser(client: MatrixClient, analyticsIdGenerator: () => string): Promise<void> {
if (this.anonymity == Anonymity.Pseudonymous) { if (this.anonymity == Anonymity.Pseudonymous) {
this.posthog.identify(await hashHex(userId)); // Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows
// different devices to send the same ID.
try {
const accountData = await client.getAccountDataFromServer(PosthogAnalytics.ANALYTICS_ID_EVENT_TYPE);
let analyticsID = accountData?.id;
if (!analyticsID) {
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
// Note there's a race condition here - if two devices do these steps at the same time, last write
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
// until the next time account data is refreshed and this function is called (most likely on next
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
analyticsID = analyticsIdGenerator();
await client.setAccountData("im.vector.web.analytics_id", { id: analyticsID });
}
this.posthog.identify(analyticsID);
} catch (e) {
// The above could fail due to network requests, but not essential to starting the application,
// so swallow it.
console.log("Unable to identify user for tracking" + e.toString());
}
} }
} }
@ -349,7 +375,7 @@ export class PosthogAnalytics {
// Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous // Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings());
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
await this.identifyUser(userId); await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId);
} }
} }
} }

View file

@ -48,11 +48,6 @@ export default class Resend {
// XXX: temporary logging to try to diagnose // XXX: temporary logging to try to diagnose
// https://github.com/vector-im/element-web/issues/3148 // https://github.com/vector-im/element-web/issues/3148
console.log('Resend got send failure: ' + err.name + '(' + err + ')'); console.log('Resend got send failure: ' + err.name + '(' + err + ')');
dis.dispatch({
action: 'message_send_failed',
event: event,
});
}); });
} }

View file

@ -1897,15 +1897,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onSendEvent(roomId: string, event: MatrixEvent) { onSendEvent(roomId: string, event: MatrixEvent) {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (!cli) { if (!cli) return;
dis.dispatch({ action: 'message_send_failed' });
return;
}
cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => { cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => {
dis.dispatch({ action: 'message_sent' }); dis.dispatch({ action: 'message_sent' });
}, (err) => {
dis.dispatch({ action: 'message_send_failed' });
}); });
} }

View file

@ -15,43 +15,48 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import MemberAvatar from '../avatars/MemberAvatar'; import MemberAvatar from '../avatars/MemberAvatar';
import classNames from 'classnames'; import classNames from 'classnames';
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu"; import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import { ChevronFace, ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
interface IProps {
member: RoomMember;
width?: number;
height?: number;
resizeMethod?: ResizeMethod;
}
interface IState {
hasStatus: boolean;
menuDisplayed: boolean;
}
@replaceableComponent("views.avatars.MemberStatusMessageAvatar") @replaceableComponent("views.avatars.MemberStatusMessageAvatar")
export default class MemberStatusMessageAvatar extends React.Component { export default class MemberStatusMessageAvatar extends React.Component<IProps, IState> {
static propTypes = { public static defaultProps: Partial<IProps> = {
member: PropTypes.object.isRequired,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
};
static defaultProps = {
width: 40, width: 40,
height: 40, height: 40,
resizeMethod: 'crop', resizeMethod: 'crop',
}; };
private button = createRef<HTMLDivElement>();
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
hasStatus: this.hasStatus, hasStatus: this.hasStatus,
menuDisplayed: false, menuDisplayed: false,
}; };
this._button = createRef();
} }
componentDidMount() { public componentDidMount(): void {
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) { if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user"); throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
} }
@ -62,44 +67,44 @@ export default class MemberStatusMessageAvatar extends React.Component {
if (!user) { if (!user) {
return; return;
} }
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
} }
componentWillUnmount() { public componentWillUnmount(): void {
const { user } = this.props.member; const { user } = this.props.member;
if (!user) { if (!user) {
return; return;
} }
user.removeListener( user.removeListener(
"User._unstable_statusMessage", "User._unstable_statusMessage",
this._onStatusMessageCommitted, this.onStatusMessageCommitted,
); );
} }
get hasStatus() { private get hasStatus(): boolean {
const { user } = this.props.member; const { user } = this.props.member;
if (!user) { if (!user) {
return false; return false;
} }
return !!user._unstable_statusMessage; return !!user.unstable_statusMessage;
} }
_onStatusMessageCommitted = () => { private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change. // The `User` object has observed a status message change.
this.setState({ this.setState({
hasStatus: this.hasStatus, hasStatus: this.hasStatus,
}); });
}; };
openMenu = () => { private openMenu = (): void => {
this.setState({ menuDisplayed: true }); this.setState({ menuDisplayed: true });
}; };
closeMenu = () => { private closeMenu = (): void => {
this.setState({ menuDisplayed: false }); this.setState({ menuDisplayed: false });
}; };
render() { public render(): JSX.Element {
const avatar = <MemberAvatar const avatar = <MemberAvatar
member={this.props.member} member={this.props.member}
width={this.props.width} width={this.props.width}
@ -118,7 +123,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
let contextMenu; let contextMenu;
if (this.state.menuDisplayed) { if (this.state.menuDisplayed) {
const elementRect = this._button.current.getBoundingClientRect(); const elementRect = this.button.current.getBoundingClientRect();
const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom
const chevronMargin = 1; // Add some spacing away from target const chevronMargin = 1; // Add some spacing away from target
@ -126,13 +131,13 @@ export default class MemberStatusMessageAvatar extends React.Component {
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu
chevronOffset={(elementRect.width - chevronWidth) / 2} chevronOffset={(elementRect.width - chevronWidth) / 2}
chevronFace="bottom" chevronFace={ChevronFace.Bottom}
left={elementRect.left + window.pageXOffset} left={elementRect.left + window.pageXOffset}
top={elementRect.top + window.pageYOffset - chevronMargin} top={elementRect.top + window.pageYOffset - chevronMargin}
menuWidth={226} menuWidth={226}
onFinished={this.closeMenu} onFinished={this.closeMenu}
> >
<StatusMessageContextMenu user={this.props.member.user} onFinished={this.closeMenu} /> <StatusMessageContextMenu user={this.props.member.user} />
</ContextMenu> </ContextMenu>
); );
} }
@ -140,7 +145,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
return <React.Fragment> return <React.Fragment>
<ContextMenuButton <ContextMenuButton
className={classes} className={classes}
inputRef={this._button} inputRef={this.button}
onClick={this.openMenu} onClick={this.openMenu}
isExpanded={this.state.menuDisplayed} isExpanded={this.state.menuDisplayed}
label={_t("User Status")} label={_t("User Status")}

View file

@ -15,45 +15,41 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
/* interface IProps {
element: React.ReactNode;
// Function to be called when the parent window is resized
// This can be used to reposition or close the menu on resize and
// ensure that it is not displayed in a stale position.
onResize?: () => void;
}
/**
* This component can be used to display generic HTML content in a contextual * This component can be used to display generic HTML content in a contextual
* menu. * menu.
*/ */
@replaceableComponent("views.context_menus.GenericElementContextMenu") @replaceableComponent("views.context_menus.GenericElementContextMenu")
export default class GenericElementContextMenu extends React.Component { export default class GenericElementContextMenu extends React.Component<IProps> {
static propTypes = { constructor(props: IProps) {
element: PropTypes.element.isRequired,
// Function to be called when the parent window is resized
// This can be used to reposition or close the menu on resize and
// ensure that it is not displayed in a stale position.
onResize: PropTypes.func,
};
constructor(props) {
super(props); super(props);
this.resize = this.resize.bind(this);
} }
componentDidMount() { public componentDidMount(): void {
this.resize = this.resize.bind(this);
window.addEventListener("resize", this.resize); window.addEventListener("resize", this.resize);
} }
componentWillUnmount() { public componentWillUnmount(): void {
window.removeEventListener("resize", this.resize); window.removeEventListener("resize", this.resize);
} }
resize() { private resize = (): void => {
if (this.props.onResize) { if (this.props.onResize) {
this.props.onResize(); this.props.onResize();
} }
} };
render() { public render(): JSX.Element {
return <div>{ this.props.element }</div>; return <div>{ this.props.element }</div>;
} }
} }

View file

@ -15,16 +15,15 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.context_menus.GenericTextContextMenu") interface IProps {
export default class GenericTextContextMenu extends React.Component { message: string;
static propTypes = { }
message: PropTypes.string.isRequired,
};
render() { @replaceableComponent("views.context_menus.GenericTextContextMenu")
export default class GenericTextContextMenu extends React.Component<IProps> {
public render(): JSX.Element {
return <div>{ this.props.message }</div>; return <div>{ this.props.message }</div>;
} }
} }

View file

@ -14,53 +14,59 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ChangeEvent } from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
import AccessibleButton from '../elements/AccessibleButton';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { User } from "matrix-js-sdk/src/models/user";
import Spinner from "../elements/Spinner";
interface IProps {
// js-sdk User object. Not required because it might not exist.
user?: User;
}
interface IState {
message: string;
waiting: boolean;
}
@replaceableComponent("views.context_menus.StatusMessageContextMenu") @replaceableComponent("views.context_menus.StatusMessageContextMenu")
export default class StatusMessageContextMenu extends React.Component { export default class StatusMessageContextMenu extends React.Component<IProps, IState> {
static propTypes = { constructor(props: IProps) {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
};
constructor(props) {
super(props); super(props);
this.state = { this.state = {
message: this.comittedStatusMessage, message: this.comittedStatusMessage,
waiting: false,
}; };
} }
componentDidMount() { public componentDidMount(): void {
const { user } = this.props; const { user } = this.props;
if (!user) { if (!user) {
return; return;
} }
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
} }
componentWillUnmount() { public componentWillUnmount(): void {
const { user } = this.props; const { user } = this.props;
if (!user) { if (!user) {
return; return;
} }
user.removeListener( user.removeListener(
"User._unstable_statusMessage", "User._unstable_statusMessage",
this._onStatusMessageCommitted, this.onStatusMessageCommitted,
); );
} }
get comittedStatusMessage() { get comittedStatusMessage(): string {
return this.props.user ? this.props.user._unstable_statusMessage : ""; return this.props.user ? this.props.user.unstable_statusMessage : "";
} }
_onStatusMessageCommitted = () => { private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change. // The `User` object has observed a status message change.
this.setState({ this.setState({
message: this.comittedStatusMessage, message: this.comittedStatusMessage,
@ -68,14 +74,14 @@ export default class StatusMessageContextMenu extends React.Component {
}); });
}; };
_onClearClick = (e) => { private onClearClick = (): void=> {
MatrixClientPeg.get()._unstable_setStatusMessage(""); MatrixClientPeg.get()._unstable_setStatusMessage("");
this.setState({ this.setState({
waiting: true, waiting: true,
}); });
}; };
_onSubmit = (e) => { private onSubmit = (e: ButtonEvent): void => {
e.preventDefault(); e.preventDefault();
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message); MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
this.setState({ this.setState({
@ -83,27 +89,25 @@ export default class StatusMessageContextMenu extends React.Component {
}); });
}; };
_onStatusChange = (e) => { private onStatusChange = (e: ChangeEvent): void => {
// The input field's value was changed. // The input field's value was changed.
this.setState({ this.setState({
message: e.target.value, message: (e.target as HTMLInputElement).value,
}); });
}; };
render() { public render(): JSX.Element {
const Spinner = sdk.getComponent('views.elements.Spinner');
let actionButton; let actionButton;
if (this.comittedStatusMessage) { if (this.comittedStatusMessage) {
if (this.state.message === this.comittedStatusMessage) { if (this.state.message === this.comittedStatusMessage) {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear" actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
onClick={this._onClearClick} onClick={this.onClearClick}
> >
<span>{ _t("Clear status") }</span> <span>{ _t("Clear status") }</span>
</AccessibleButton>; </AccessibleButton>;
} else { } else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit" actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
onClick={this._onSubmit} onClick={this.onSubmit}
> >
<span>{ _t("Update status") }</span> <span>{ _t("Update status") }</span>
</AccessibleButton>; </AccessibleButton>;
@ -112,7 +116,7 @@ export default class StatusMessageContextMenu extends React.Component {
actionButton = <AccessibleButton actionButton = <AccessibleButton
className="mx_StatusMessageContextMenu_submit" className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message} disabled={!this.state.message}
onClick={this._onSubmit} onClick={this.onSubmit}
> >
<span>{ _t("Set status") }</span> <span>{ _t("Set status") }</span>
</AccessibleButton>; </AccessibleButton>;
@ -120,13 +124,13 @@ export default class StatusMessageContextMenu extends React.Component {
let spinner = null; let spinner = null;
if (this.state.waiting) { if (this.state.waiting) {
spinner = <Spinner w="24" h="24" />; spinner = <Spinner w={24} h={24} />;
} }
const form = <form const form = <form
className="mx_StatusMessageContextMenu_form" className="mx_StatusMessageContextMenu_form"
autoComplete="off" autoComplete="off"
onSubmit={this._onSubmit} onSubmit={this.onSubmit}
> >
<input <input
type="text" type="text"
@ -134,9 +138,9 @@ export default class StatusMessageContextMenu extends React.Component {
key="message" key="message"
placeholder={_t("Set a new status...")} placeholder={_t("Set a new status...")}
autoFocus={true} autoFocus={true}
maxLength="60" maxLength={60}
value={this.state.message} value={this.state.message}
onChange={this._onStatusChange} onChange={this.onStatusChange}
/> />
<div className="mx_StatusMessageContextMenu_actionContainer"> <div className="mx_StatusMessageContextMenu_actionContainer">
{ actionButton } { actionButton }

View file

@ -50,7 +50,8 @@ import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
// matches emoticons which follow the start of a line or whitespace // matches emoticons which follow the start of a line or whitespace
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s|:^$');
export const REGEX_EMOTICON = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')$');
const IS_MAC = navigator.platform.indexOf("Mac") !== -1; const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
@ -161,7 +162,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
} }
private replaceEmoticon = (caretPosition: DocumentPosition): number => { public replaceEmoticon(caretPosition: DocumentPosition, regex: RegExp): number {
const { model } = this.props; const { model } = this.props;
const range = model.startRange(caretPosition); const range = model.startRange(caretPosition);
// expand range max 8 characters backwards from caretPosition, // expand range max 8 characters backwards from caretPosition,
@ -170,9 +171,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
range.expandBackwardsWhile((index, offset) => { range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index]; const part = model.parts[index];
n -= 1; n -= 1;
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate); return n >= 0 && [Type.Plain, Type.PillCandidate, Type.Newline].includes(part.type);
}); });
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text); const emoticonMatch = regex.exec(range.text);
if (emoticonMatch) { if (emoticonMatch) {
const query = emoticonMatch[1].replace("-", ""); const query = emoticonMatch[1].replace("-", "");
// try both exact match and lower-case, this means that xd won't match xD but :P will match :p // try both exact match and lower-case, this means that xd won't match xD but :P will match :p
@ -180,18 +181,23 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
if (data) { if (data) {
const { partCreator } = model; const { partCreator } = model;
const hasPrecedingSpace = emoticonMatch[0][0] === " "; const moveStart = emoticonMatch[0][0] === " " ? 1 : 0;
const moveEnd = emoticonMatch[0].length - emoticonMatch.length - moveStart;
// we need the range to only comprise of the emoticon // we need the range to only comprise of the emoticon
// because we'll replace the whole range with an emoji, // because we'll replace the whole range with an emoji,
// so move the start forward to the start of the emoticon. // so move the start forward to the start of the emoticon.
// Take + 1 because index is reported without the possible preceding space. // Take + 1 because index is reported without the possible preceding space.
range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0)); range.moveStartForwards(emoticonMatch.index + moveStart);
// and move end backwards so that we don't replace the trailing space/newline
range.moveEndBackwards(moveEnd);
// this returns the amount of added/removed characters during the replace // this returns the amount of added/removed characters during the replace
// so the caret position can be adjusted. // so the caret position can be adjusted.
return range.replace([partCreator.plain(data.unicode + " ")]); return range.replace([partCreator.plain(data.unicode)]);
} }
} }
}; }
private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => { private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => {
renderModel(this.editorRef.current, this.props.model); renderModel(this.editorRef.current, this.props.model);
@ -607,8 +613,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}; };
private configureEmoticonAutoReplace = (): void => { private configureEmoticonAutoReplace = (): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji'); this.props.model.setTransformCallback(this.transform);
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
}; };
private configureShouldShowPillAvatar = (): void => { private configureShouldShowPillAvatar = (): void => {
@ -621,6 +626,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ surroundWith }); this.setState({ surroundWith });
}; };
private transform = (documentPosition: DocumentPosition): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
if (shouldReplace) this.replaceEmoticon(documentPosition, REGEX_EMOTICON_WHITESPACE);
};
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener("selectionchange", this.onSelectionChange); document.removeEventListener("selectionchange", this.onSelectionChange);
this.editorRef.current.removeEventListener("input", this.onInput, true); this.editorRef.current.removeEventListener("input", this.onInput, true);

View file

@ -57,7 +57,7 @@ let instanceCount = 0;
const NARROW_MODE_BREAKPOINT = 500; const NARROW_MODE_BREAKPOINT = 500;
interface IComposerAvatarProps { interface IComposerAvatarProps {
me: object; me: RoomMember;
} }
function ComposerAvatar(props: IComposerAvatarProps) { function ComposerAvatar(props: IComposerAvatarProps) {

View file

@ -31,8 +31,8 @@ import {
textSerialize, textSerialize,
unescapeMessage, unescapeMessage,
} from '../../../editor/serialize'; } from '../../../editor/serialize';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts'; import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread"; import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils'; import { findEditableEvent } from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager"; import SendHistoryManager from "../../../SendHistoryManager";
@ -347,15 +347,24 @@ export default class SendMessageComposer extends React.Component<IProps> {
} }
public async sendMessage(): Promise<void> { public async sendMessage(): Promise<void> {
if (this.model.isEmpty) { const model = this.model;
if (model.isEmpty) {
return; return;
} }
// Replace emoticon at the end of the message
if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
const caret = this.editorRef.current?.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
}
const replyToEvent = this.props.replyToEvent; const replyToEvent = this.props.replyToEvent;
let shouldSend = true; let shouldSend = true;
let content; let content;
if (!containsEmote(this.model) && this.isSlashCommand()) { if (!containsEmote(model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this.getSlashCommand(); const [cmd, args, commandText] = this.getSlashCommand();
if (cmd) { if (cmd) {
if (cmd.category === CommandCategories.messages) { if (cmd.category === CommandCategories.messages) {
@ -400,7 +409,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
} }
} }
if (isQuickReaction(this.model)) { if (isQuickReaction(model)) {
shouldSend = false; shouldSend = false;
this.sendQuickReaction(); this.sendQuickReaction();
} }
@ -410,7 +419,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
const { roomId } = this.props.room; const { roomId } = this.props.room;
if (!content) { if (!content) {
content = createMessageContent( content = createMessageContent(
this.model, model,
replyToEvent, replyToEvent,
this.props.replyInThread, this.props.replyInThread,
this.props.permalinkCreator, this.props.permalinkCreator,
@ -446,9 +455,9 @@ export default class SendMessageComposer extends React.Component<IProps> {
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
} }
this.sendHistoryManager.save(this.model, replyToEvent); this.sendHistoryManager.save(model, replyToEvent);
// clear composer // clear composer
this.model.reset([]); model.reset([]);
this.editorRef.current?.clearUndoHistory(); this.editorRef.current?.clearUndoHistory();
this.editorRef.current?.focus(); this.editorRef.current?.focus();
this.clearStoredEditorState(); this.clearStoredEditorState();

View file

@ -277,9 +277,13 @@ export default class CallView extends React.Component<IProps, IState> {
if (this.state.screensharing) { if (this.state.screensharing) {
isScreensharing = await this.props.call.setScreensharingEnabled(false); isScreensharing = await this.props.call.setScreensharingEnabled(false);
} else { } else {
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); if (window.electron?.getDesktopCapturerSources) {
const [source] = await finished; const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
isScreensharing = await this.props.call.setScreensharingEnabled(true, source); const [source] = await finished;
isScreensharing = await this.props.call.setScreensharingEnabled(true, source);
} else {
isScreensharing = await this.props.call.setScreensharingEnabled(true);
}
} }
this.setState({ this.setState({

View file

@ -32,13 +32,20 @@ export default class Range {
this._end = bIsLarger ? positionB : positionA; this._end = bIsLarger ? positionB : positionA;
} }
public moveStart(delta: number): void { public moveStartForwards(delta: number): void {
this._start = this._start.forwardsWhile(this.model, () => { this._start = this._start.forwardsWhile(this.model, () => {
delta -= 1; delta -= 1;
return delta >= 0; return delta >= 0;
}); });
} }
public moveEndBackwards(delta: number): void {
this._end = this._end.backwardsWhile(this.model, () => {
delta -= 1;
return delta >= 0;
});
}
public trim(): void { public trim(): void {
this._start = this._start.forwardsWhile(this.model, whitespacePredicate); this._start = this._start.forwardsWhile(this.model, whitespacePredicate);
this._end = this._end.backwardsWhile(this.model, whitespacePredicate); this._end = this._end.backwardsWhile(this.model, whitespacePredicate);

View file

@ -218,15 +218,28 @@ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`);
it("Should identify the user to posthog if pseudonymous", async () => { it("Should identify the user to posthog if pseudonymous", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous); analytics.setAnonymity(Anonymity.Pseudonymous);
await analytics.identifyUser("foo"); class FakeClient {
expect(fakePosthog.identify.mock.calls[0][0]) getAccountDataFromServer = jest.fn().mockResolvedValue(null);
.toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); setAccountData = jest.fn().mockResolvedValue({});
}
await analytics.identifyUser(new FakeClient(), () => "analytics_id" );
expect(fakePosthog.identify.mock.calls[0][0]).toBe("analytics_id");
}); });
it("Should not identify the user to posthog if anonymous", async () => { it("Should not identify the user to posthog if anonymous", async () => {
analytics.setAnonymity(Anonymity.Anonymous); analytics.setAnonymity(Anonymity.Anonymous);
await analytics.identifyUser("foo"); await analytics.identifyUser(null);
expect(fakePosthog.identify.mock.calls.length).toBe(0); expect(fakePosthog.identify.mock.calls.length).toBe(0);
}); });
it("Should identify using the server's analytics id if present", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous);
class FakeClient {
getAccountDataFromServer = jest.fn().mockResolvedValue({ id: "existing_analytics_id" });
setAccountData = jest.fn().mockResolvedValue({});
}
await analytics.identifyUser(new FakeClient(), () => "new_analytics_id" );
expect(fakePosthog.identify.mock.calls[0][0]).toBe("existing_analytics_id");
});
}); });
}); });