- { shareType ?
+ { shouldAdvertiseLiveLabsFlag &&
+
+ }
+ { !shouldAdvertiseLiveLabsFlag && !!shareType &&
:
+ />
+ }
+ { !shareType &&
}
setShareType(undefined)} onCancel={onFinished} />
diff --git a/src/components/views/location/Map.tsx b/src/components/views/location/Map.tsx
index 8776e8e8264..023ff2d5ccb 100644
--- a/src/components/views/location/Map.tsx
+++ b/src/components/views/location/Map.tsx
@@ -16,6 +16,7 @@ limitations under the License.
import React, { ReactNode, useContext, useEffect } from 'react';
import classNames from 'classnames';
+import maplibregl from 'maplibre-gl';
import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/matrix';
import { logger } from 'matrix-js-sdk/src/logger';
@@ -24,8 +25,9 @@ import { useEventEmitterState } from '../../../hooks/useEventEmitter';
import { parseGeoUri } from '../../../utils/location';
import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils';
import { useMap } from '../../../utils/location/useMap';
+import { Bounds } from '../../../utils/beacon/bounds';
-const useMapWithStyle = ({ id, centerGeoUri, onError, interactive }) => {
+const useMapWithStyle = ({ id, centerGeoUri, onError, interactive, bounds }) => {
const bodyId = `mx_Map_${id}`;
// style config
@@ -49,12 +51,26 @@ const useMapWithStyle = ({ id, centerGeoUri, onError, interactive }) => {
try {
const coords = parseGeoUri(centerGeoUri);
map.setCenter({ lon: coords.longitude, lat: coords.latitude });
- } catch (error) {
- logger.error('Could not set map center', centerGeoUri);
+ } catch (_error) {
+ logger.error('Could not set map center');
}
}
}, [map, centerGeoUri]);
+ useEffect(() => {
+ if (map && bounds) {
+ try {
+ const lngLatBounds = new maplibregl.LngLatBounds(
+ [bounds.west, bounds.south],
+ [bounds.east, bounds.north],
+ );
+ map.fitBounds(lngLatBounds, { padding: 100, maxZoom: 15 });
+ } catch (_error) {
+ logger.error('Invalid map bounds');
+ }
+ }
+ }, [map, bounds]);
+
return {
map,
bodyId,
@@ -65,6 +81,7 @@ interface MapProps {
id: string;
interactive?: boolean;
centerGeoUri?: string;
+ bounds?: Bounds;
className?: string;
onClick?: () => void;
onError?: (error: Error) => void;
@@ -74,9 +91,15 @@ interface MapProps {
}
const Map: React.FC = ({
- centerGeoUri, className, id, onError, onClick, children, interactive,
+ bounds,
+ centerGeoUri,
+ children,
+ className,
+ id,
+ interactive,
+ onError, onClick,
}) => {
- const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive });
+ const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive, bounds });
const onMapClick = (
event: React.MouseEvent,
diff --git a/src/components/views/location/shareLocation.ts b/src/components/views/location/shareLocation.ts
index 6654a389a06..895f2c23c6c 100644
--- a/src/components/views/location/shareLocation.ts
+++ b/src/components/views/location/shareLocation.ts
@@ -25,6 +25,7 @@ import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import QuestionDialog from "../dialogs/QuestionDialog";
import SdkConfig from "../../../SdkConfig";
+import { OwnBeaconStore } from "../../../stores/OwnBeaconStore";
export enum LocationShareType {
Own = 'Own',
@@ -70,7 +71,7 @@ export const shareLiveLocation = (
): ShareLocationFn => async ({ timeout }) => {
const description = _t(`%(displayName)s's live location`, { displayName });
try {
- await client.unstable_createLiveBeacon(
+ await OwnBeaconStore.instance.createLiveBeacon(
roomId,
makeBeaconInfoContent(
timeout ?? DEFAULT_LIVE_DURATION,
diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx
index f61ec346e4f..fb82cff29e2 100644
--- a/src/components/views/messages/MBeaconBody.tsx
+++ b/src/components/views/messages/MBeaconBody.tsx
@@ -32,8 +32,8 @@ import Spinner from '../elements/Spinner';
import Map from '../location/Map';
import SmartMarker from '../location/SmartMarker';
import OwnBeaconStatus from '../beacon/OwnBeaconStatus';
-import { IBodyProps } from "./IBodyProps";
import BeaconViewDialog from '../beacon/BeaconViewDialog';
+import { IBodyProps } from "./IBodyProps";
const useBeaconState = (beaconInfoEvent: MatrixEvent): {
beacon?: Beacon;
@@ -105,6 +105,7 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) =>
{
roomId: mxEvent.getRoomId(),
matrixClient,
+ focusBeacon: beacon,
},
"mx_BeaconViewDialog_wrapper",
false, // isPriority
@@ -145,12 +146,14 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) =>
className='mx_MBeaconBody_chin'
beacon={beacon}
displayStatus={displayStatus}
+ withIcon
/> :
}
diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx
index 5ed699b2327..73c59472fdd 100644
--- a/src/components/views/messages/MImageBody.tsx
+++ b/src/components/views/messages/MImageBody.tsx
@@ -138,7 +138,7 @@ export default class MImageBody extends React.Component {
}
};
- private onImageEnter = (e: React.MouseEvent): void => {
+ protected onImageEnter = (e: React.MouseEvent): void => {
this.setState({ hover: true });
if (!this.state.showImage || !this.state.isAnimated || SettingsStore.getValue("autoplayGifs")) {
@@ -148,7 +148,7 @@ export default class MImageBody extends React.Component {
imgElement.src = this.state.contentUrl;
};
- private onImageLeave = (e: React.MouseEvent): void => {
+ protected onImageLeave = (e: React.MouseEvent): void => {
this.setState({ hover: false });
if (!this.state.showImage || !this.state.isAnimated || SettingsStore.getValue("autoplayGifs")) {
diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx
index 94abc1c7a88..ff87af1dc3a 100644
--- a/src/components/views/messages/MLocationBody.tsx
+++ b/src/components/views/messages/MLocationBody.tsx
@@ -96,7 +96,7 @@ export const LocationBodyFallbackContent: React.FC<{ event: MatrixEvent, error:
(_t('Shared their location: ') + event.getContent()?.body) :
(_t('Shared a location: ') + event.getContent()?.body);
- return
+ return
{ message }
diff --git a/src/components/views/messages/MStickerBody.tsx b/src/components/views/messages/MStickerBody.tsx
index eb56d8d2e5f..d754f04b08a 100644
--- a/src/components/views/messages/MStickerBody.tsx
+++ b/src/components/views/messages/MStickerBody.tsx
@@ -39,11 +39,19 @@ export default class MStickerBody extends MImageBody {
return
{ children }
;
}
- // Placeholder to show in place of the sticker image if
- // img onLoad hasn't fired yet.
+ // Placeholder to show in place of the sticker image if img onLoad hasn't fired yet.
protected getPlaceholder(width: number, height: number): JSX.Element {
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) return super.getPlaceholder(width, height);
- return
;
+ return (
+
+ );
}
// Tooltip to show on mouse over
diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx
index f4733df19f3..1ac389a6bc1 100644
--- a/src/components/views/messages/MVideoBody.tsx
+++ b/src/components/views/messages/MVideoBody.tsx
@@ -281,6 +281,9 @@ export default class MVideoBody extends React.PureComponent
src={contentUrl}
title={content.body}
controls
+ // Disable downloading as it doesn't work with e2ee video,
+ // users should use the dedicated Download button in the Message Action Bar
+ controlsList="nodownload"
preload={preload}
muted={autoplay}
autoPlay={autoplay}
diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx
index 7bca48097c7..0effb252888 100644
--- a/src/components/views/messages/MessageActionBar.tsx
+++ b/src/components/views/messages/MessageActionBar.tsx
@@ -26,11 +26,11 @@ import type { Relations } from 'matrix-js-sdk/src/models/relations';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import ContextMenu, { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu';
-import { isContentActionable, canEditContent, editEvent } from '../../../utils/EventUtils';
+import { isContentActionable, canEditContent, editEvent, canCancel } from '../../../utils/EventUtils';
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import Toolbar from "../../../accessibility/Toolbar";
import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
-import MessageContextMenu, { canCancel } from "../context_menus/MessageContextMenu";
+import MessageContextMenu from "../context_menus/MessageContextMenu";
import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
@@ -308,6 +308,11 @@ export default class MessageActionBar extends React.PureComponent {
@@ -54,6 +54,10 @@ interface IProps extends Omit implements IMediaBody, IOperableEventTile {
private body: React.RefObject = createRef();
private mediaHelper: MediaEventHelper;
diff --git a/src/components/views/messages/UnknownBody.tsx b/src/components/views/messages/UnknownBody.tsx
index d9e70ff241f..cd1f06a788b 100644
--- a/src/components/views/messages/UnknownBody.tsx
+++ b/src/components/views/messages/UnknownBody.tsx
@@ -23,12 +23,12 @@ interface IProps {
children?: React.ReactNode;
}
-export default forwardRef(({ mxEvent, children }: IProps, ref: React.RefObject) => {
+export default forwardRef(({ mxEvent, children }: IProps, ref: React.RefObject) => {
const text = mxEvent.getContent().body;
return (
-
+
{ text }
{ children }
-
+
);
});
diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx
index 018d2c6927f..fd69f46ef9c 100644
--- a/src/components/views/right_panel/RoomSummaryCard.tsx
+++ b/src/components/views/right_panel/RoomSummaryCard.tsx
@@ -207,11 +207,8 @@ const AppsSection: React.FC = ({ room }) => {
if (!managers.hasManager()) {
managers.openNoManagerDialog();
} else {
- if (SettingsStore.getValue("feature_many_integration_managers")) {
- managers.openAll(room);
- } else {
- managers.getPrimaryManager().open(room);
- }
+ // noinspection JSIgnoredPromiseFromCall
+ managers.getPrimaryManager().open(room);
}
};
diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx
index 934f88b0a74..7b45746c770 100644
--- a/src/components/views/right_panel/UserInfo.tsx
+++ b/src/components/views/right_panel/UserInfo.tsx
@@ -75,7 +75,6 @@ import { UIComponent } from "../../../settings/UIFeature";
import { TimelineRenderingType } from "../../../contexts/RoomContext";
import RightPanelStore from '../../../stores/right-panel/RightPanelStore';
import { IRightPanelCardState } from '../../../stores/right-panel/RightPanelStoreIPanelState';
-import { useUserStatusMessage } from "../../../hooks/useUserStatusMessage";
import UserIdentifierCustomisations from '../../../customisations/UserIdentifier';
import PosthogTrackers from "../../../PosthogTrackers";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
@@ -292,13 +291,17 @@ function DevicesSection({ devices, userId, loading }: { devices: IDevice[], user
let expandButton;
if (expandSectionDevices.length) {
if (isExpanded) {
- expandButton = ( setExpanded(false)}
>
{ expandHideCaption }
);
} else {
- expandButton = ( setExpanded(true)}
>
@@ -331,6 +334,7 @@ const MessageButton = ({ userId }: { userId: string }) => {
return (
{
if (busy) return;
setBusy(true);
@@ -383,6 +387,7 @@ const UserOptionsSection: React.FC<{
ignoreButton = (
@@ -413,14 +418,22 @@ const UserOptionsSection: React.FC<{
const room = cli.getRoom(member.roomId);
if (room?.getEventReadUpTo(member.userId)) {
readReceiptButton = (
-
+
{ _t('Jump to read receipt') }
);
}
insertPillButton = (
-
+
{ _t('Mention') }
);
@@ -448,7 +461,11 @@ const UserOptionsSection: React.FC<{
};
inviteUserButton = (
-
+
{ _t('Invite') }
);
@@ -456,7 +473,11 @@ const UserOptionsSection: React.FC<{
}
const shareUserButton = (
-
+
{ _t('Share Link to User') }
);
@@ -575,7 +596,9 @@ const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit
+ const kickLabel = room.isSpaceRoom() ?
+ member.membership === "invite" ? _t("Disinvite from space") : _t("Remove from space")
+ : member.membership === "invite" ? _t("Disinvite from room") : _t("Remove from room");
+
+ return
{ kickLabel }
;
};
@@ -637,7 +667,11 @@ const RedactMessagesButton: React.FC = ({ member }) => {
});
};
- return
+ return
{ _t("Remove recent messages") }
;
};
@@ -734,7 +768,11 @@ const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit
+ return
{ label }
;
};
@@ -804,7 +842,11 @@ const MuteToggleButton: React.FC = ({ member, room, powerLevels,
});
const muteLabel = muted ? _t("Unmute") : _t("Mute");
- return
+ return
{ muteLabel }
;
};
@@ -921,14 +963,9 @@ function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IR
canEdit: false,
canInvite: false,
});
- const updateRoomPermissions = useCallback(() => {
- if (!room) {
- return;
- }
- const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
- if (!powerLevelEvent) return;
- const powerLevels = powerLevelEvent.getContent();
+ const updateRoomPermissions = useCallback(() => {
+ const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
if (!powerLevels) return;
const me = room.getMember(cli.getUserId());
@@ -940,17 +977,14 @@ function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IR
let modifyLevelMax = -1;
if (canAffectUser) {
- const editPowerLevel = (
- (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) ||
- powerLevels.state_default
- );
- if (me.powerLevel >= editPowerLevel && (isMe || me.powerLevel > them.powerLevel)) {
+ const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50;
+ if (me.powerLevel >= editPowerLevel) {
modifyLevelMax = me.powerLevel;
}
}
setRoomPermissions({
- canInvite: me.powerLevel >= powerLevels.invite,
+ canInvite: me.powerLevel >= (powerLevels.invite ?? 0),
canEdit: modifyLevelMax >= 0,
modifyLevelMax,
});
@@ -1215,7 +1249,11 @@ const BasicUserInfo: React.FC<{
// FIXME this should be using cli instead of MatrixClientPeg.matrixClient
if (isSynapseAdmin && member.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) {
synapseDeactivateButton = (
-
+
{ _t("Deactivate user") }
);
@@ -1293,8 +1331,9 @@ const BasicUserInfo: React.FC<{
if (canVerify) {
if (hasCrossSigningKeys !== undefined) {
// Note: mx_UserInfo_verifyButton is for the end-to-end tests
- verifyButton = (
+ verifyButton = (
{
if (hasCrossSigningKeys) {
@@ -1306,7 +1345,7 @@ const BasicUserInfo: React.FC<{
>
{ _t("Verify") }
- );
+
);
} else if (!showDeviceListSpinner) {
// HACK: only show a spinner if the device section spinner is not shown,
// to avoid showing a double spinner
@@ -1319,6 +1358,7 @@ const BasicUserInfo: React.FC<{
if (member.userId == cli.getUserId()) {
editDevices = (
{
dis.dispatch({
@@ -1370,7 +1410,6 @@ const UserInfoHeader: React.FC<{
roomId?: string;
}> = ({ member, e2eStatus, roomId }) => {
const cli = useContext(MatrixClientContext);
- const statusMessage = useUserStatusMessage(member);
const onMemberAvatarClick = useCallback(() => {
const avatarUrl = (member as RoomMember).getMxcAvatarUrl
@@ -1431,11 +1470,6 @@ const UserInfoHeader: React.FC<{
);
}
- let statusLabel = null;
- if (statusMessage) {
- statusLabel = { statusMessage };
- }
-
let e2eIcon;
if (e2eStatus) {
e2eIcon = ;
@@ -1458,7 +1492,6 @@ const UserInfoHeader: React.FC<{
{ UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, { roomId, withDisplayName: true }) }
{ presenceLabel }
- { statusLabel }
diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx
index a5dcf038133..068d096624f 100644
--- a/src/components/views/rooms/BasicMessageComposer.tsx
+++ b/src/components/views/rooms/BasicMessageComposer.tsx
@@ -32,7 +32,7 @@ import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
import { renderModel } from '../../../editor/render';
import TypingStore from "../../../stores/TypingStore";
import SettingsStore from "../../../settings/SettingsStore";
-import { Key } from "../../../Keyboard";
+import { IS_MAC, Key } from "../../../Keyboard";
import { EMOTICON_TO_EMOJI } from "../../../emoji";
import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands";
import Range from "../../../editor/range";
@@ -50,8 +50,6 @@ import { _t } from "../../../languageHandler";
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 SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"];
const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([
["(", ")"],
@@ -103,6 +101,7 @@ interface IProps {
}
interface IState {
+ useMarkdown: boolean;
showPillAvatar: boolean;
query?: string;
showVisualBell?: boolean;
@@ -124,6 +123,7 @@ export default class BasicMessageEditor extends React.Component
private lastCaret: DocumentOffset;
private lastSelection: ReturnType;
+ private readonly useMarkdownHandle: string;
private readonly emoticonSettingHandle: string;
private readonly shouldShowPillAvatarSettingHandle: string;
private readonly surroundWithHandle: string;
@@ -133,10 +133,13 @@ export default class BasicMessageEditor extends React.Component
super(props);
this.state = {
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
+ useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
showVisualBell: false,
};
+ this.useMarkdownHandle = SettingsStore.watchSetting('MessageComposerInput.useMarkdown', null,
+ this.configureUseMarkdown);
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
this.configureEmoticonAutoReplace);
this.configureEmoticonAutoReplace();
@@ -442,7 +445,7 @@ export default class BasicMessageEditor extends React.Component
}
} else if (!selection.isCollapsed && !isEmpty) {
this.hasTextSelected = true;
- if (this.formatBarRef.current) {
+ if (this.formatBarRef.current && this.state.useMarkdown) {
const selectionRect = selection.getRangeAt(0).getBoundingClientRect();
this.formatBarRef.current.showAt(selectionRect);
}
@@ -630,6 +633,14 @@ export default class BasicMessageEditor extends React.Component
this.setState({ completionIndex });
};
+ private configureUseMarkdown = (): void => {
+ const useMarkdown = SettingsStore.getValue("MessageComposerInput.useMarkdown");
+ this.setState({ useMarkdown });
+ if (!useMarkdown && this.formatBarRef.current) {
+ this.formatBarRef.current.hide();
+ }
+ };
+
private configureEmoticonAutoReplace = (): void => {
this.props.model.setTransformCallback(this.transform);
};
@@ -654,6 +665,7 @@ export default class BasicMessageEditor extends React.Component
this.editorRef.current.removeEventListener("input", this.onInput, true);
this.editorRef.current.removeEventListener("compositionstart", this.onCompositionStart, true);
this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true);
+ SettingsStore.unwatchSetting(this.useMarkdownHandle);
SettingsStore.unwatchSetting(this.emoticonSettingHandle);
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
SettingsStore.unwatchSetting(this.surroundWithHandle);
@@ -694,6 +706,10 @@ export default class BasicMessageEditor extends React.Component
}
public onFormatAction = (action: Formatting): void => {
+ if (!this.state.useMarkdown) {
+ return;
+ }
+
const range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection());
this.historyManager.ensureLastChangesPushed(this.props.model);
diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx
index ce6d1b844e0..de1bdc9c85b 100644
--- a/src/components/views/rooms/EditMessageComposer.tsx
+++ b/src/components/views/rooms/EditMessageComposer.tsx
@@ -95,7 +95,10 @@ function createEditContent(
body: `${plainPrefix} * ${body}`,
};
- const formattedBody = htmlSerializeIfNeeded(model, { forceHTML: isReply });
+ const formattedBody = htmlSerializeIfNeeded(model, {
+ forceHTML: isReply,
+ useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
+ });
if (formattedBody) {
newContent.format = "org.matrix.custom.html";
newContent.formatted_body = formattedBody;
@@ -404,7 +407,9 @@ class EditMessageComposer extends React.Component Relations;
@@ -92,10 +96,19 @@ export type GetRelationsForEvent = (eventId: string, relationType: string, event
export interface IReadReceiptProps {
userId: string;
- roomMember: RoomMember;
+ roomMember: RoomMember | null;
ts: number;
}
+export interface IEventTileOps {
+ isWidgetHidden(): boolean;
+ unhideWidget(): void;
+}
+
+export interface IEventTileType extends React.Component {
+ getEventTileOps?(): IEventTileOps;
+}
+
interface IProps {
// the MatrixEvent to show
mxEvent: MatrixEvent;
@@ -209,9 +222,6 @@ interface IProps {
interface IState {
// Whether the action bar is focused.
actionBarFocused: boolean;
- // Whether all read receipts are being displayed. If not, only display
- // a truncation of them.
- allReadAvatars: boolean;
// Whether the event's sender has been verified.
verified: string;
// Whether onRequestKeysClick has been called since mounting.
@@ -220,6 +230,13 @@ interface IState {
reactions: Relations;
hover: boolean;
+
+ // Position of the context menu
+ contextMenu?: {
+ position: Pick;
+ showPermalink?: boolean;
+ };
+
isQuoteExpanded?: boolean;
thread: Thread;
@@ -230,8 +247,7 @@ interface IState {
export class UnwrappedEventTile extends React.Component {
private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean;
- // TODO: Types
- private tile = React.createRef();
+ private tile = React.createRef();
private replyChain = React.createRef();
private threadState: ThreadNotificationState;
@@ -255,15 +271,14 @@ export class UnwrappedEventTile extends React.Component {
this.state = {
// Whether the action bar is focused.
actionBarFocused: false,
- // Whether all read receipts are being displayed. If not, only display
- // a truncation of them.
- allReadAvatars: false,
// Whether the event's sender has been verified.
verified: null,
// Whether onRequestKeysClick has been called since mounting.
previouslyRequestedKeys: false,
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: this.getReactions(),
+ // Context menu position
+ contextMenu: null,
hover: false,
@@ -387,8 +402,7 @@ export class UnwrappedEventTile extends React.Component {
}
private setupNotificationListener = (thread: Thread): void => {
- const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
- const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(room);
+ const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room);
this.threadState = notifications.getThreadRoomState(thread);
@@ -484,16 +498,18 @@ export class UnwrappedEventTile extends React.Component {
return null;
}
+ let thread = this.props.mxEvent.getThread();
/**
* Accessing the threads value through the room due to a race condition
* that will be solved when there are proper backend support for threads
* We currently have no reliable way to discover than an event is a thread
* when we are at the sync stage
*/
- const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
- const thread = room?.threads?.get(this.props.mxEvent.getId());
-
- return thread || null;
+ if (!thread) {
+ const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
+ thread = room?.findThreadForEvent(this.props.mxEvent);
+ }
+ return thread ?? null;
}
private renderThreadPanelSummary(): JSX.Element | null {
@@ -502,7 +518,7 @@ export class UnwrappedEventTile extends React.Component {
}
return
-
+
{ this.state.thread.length }
@@ -711,108 +727,6 @@ export class UnwrappedEventTile extends React.Component {
return actions.tweaks.highlight;
}
- private toggleAllReadAvatars = () => {
- this.setState({
- allReadAvatars: !this.state.allReadAvatars,
- });
- };
-
- private getReadAvatars() {
- if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
- return ;
- }
-
- const MAX_READ_AVATARS = this.props.layout == Layout.Bubble
- ? 2
- : 5;
-
- // return early if there are no read receipts
- if (!this.props.readReceipts || this.props.readReceipts.length === 0) {
- // We currently must include `mx_EventTile_readAvatars` in the DOM
- // of all events, as it is the positioned parent of the animated
- // read receipts. We can't let it unmount when a receipt moves
- // events, so for now we mount it for all events. Without it, the
- // animation will start from the top of the timeline (because it
- // lost its container).
- // See also https://github.com/vector-im/element-web/issues/17561
- return (
-
-
-
- );
- }
-
- const avatars = [];
- const receiptOffset = 15;
- let left = 0;
-
- const receipts = this.props.readReceipts;
-
- for (let i = 0; i < receipts.length; ++i) {
- const receipt = receipts[i];
-
- let hidden = true;
- if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) {
- hidden = false;
- }
- // TODO: we keep the extra read avatars in the dom to make animation simpler
- // we could optimise this to reduce the dom size.
-
- // If hidden, set offset equal to the offset of the final visible avatar or
- // else set it proportional to index
- left = (hidden ? MAX_READ_AVATARS - 1 : i) * -receiptOffset;
-
- const userId = receipt.userId;
- let readReceiptInfo: IReadReceiptInfo;
-
- if (this.props.readReceiptMap) {
- readReceiptInfo = this.props.readReceiptMap[userId];
- if (!readReceiptInfo) {
- readReceiptInfo = {};
- this.props.readReceiptMap[userId] = readReceiptInfo;
- }
- }
-
- // add to the start so the most recent is on the end (ie. ends up rightmost)
- avatars.unshift(
- ,
- );
- }
-
- let remText: JSX.Element;
- if (!this.state.allReadAvatars) {
- const remainder = receipts.length - MAX_READ_AVATARS;
- if (remainder > 0) {
- remText = { remainder }+
- ;
- }
- }
-
- return (
-
-
- { remText }
- { avatars }
-
-
- );
- }
-
private onSenderProfileClick = () => {
dis.dispatch({
action: Action.ComposerInsert,
@@ -898,10 +812,10 @@ export class UnwrappedEventTile extends React.Component {
private onActionBarFocusChange = (actionBarFocused: boolean) => {
this.setState({ actionBarFocused });
};
- // TODO: Types
- private getTile: () => any | null = () => this.tile.current;
- private getReplyChain = () => this.replyChain.current;
+ private getTile: () => IEventTileType = () => this.tile.current;
+
+ private getReplyChain = (): ReplyChain => this.replyChain.current;
private getReactions = () => {
if (
@@ -923,6 +837,57 @@ export class UnwrappedEventTile extends React.Component {
});
};
+ private onContextMenu = (ev: React.MouseEvent): void => {
+ this.showContextMenu(ev);
+ };
+
+ private onTimestampContextMenu = (ev: React.MouseEvent): void => {
+ this.showContextMenu(ev, true);
+ };
+
+ private showContextMenu(ev: React.MouseEvent, showPermalink?: boolean): void {
+ // Return if message right-click context menu isn't enabled
+ if (!SettingsStore.getValue("feature_message_right_click_context_menu")) return;
+
+ // Return if we're in a browser and click either an a tag or we have
+ // selected text, as in those cases we want to use the native browser
+ // menu
+ const clickTarget = ev.target as HTMLElement;
+ if (
+ !PlatformPeg.get().allowOverridingNativeContextMenus() &&
+ (clickTarget.tagName === "a" || clickTarget.closest("a") || getSelectedText())
+ ) return;
+
+ // There is no way to copy non-PNG images into clipboard, so we can't
+ // have our own handling for copying images, so we leave it to the
+ // Electron layer (webcontents-handler.ts)
+ if (ev.target instanceof HTMLImageElement) return;
+
+ // We don't want to show the menu when editing a message
+ if (this.props.editState) return;
+
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.setState({
+ contextMenu: {
+ position: {
+ left: ev.clientX,
+ top: ev.clientY,
+ bottom: ev.clientY,
+ },
+ showPermalink: showPermalink,
+ },
+ actionBarFocused: true,
+ });
+ }
+
+ private onCloseMenu = (): void => {
+ this.setState({
+ contextMenu: null,
+ actionBarFocused: false,
+ });
+ };
+
private setQuoteExpanded = (expanded: boolean) => {
this.setState({
isQuoteExpanded: expanded,
@@ -941,6 +906,29 @@ export class UnwrappedEventTile extends React.Component {
return false;
}
+ private renderContextMenu(): React.ReactFragment {
+ if (!this.state.contextMenu) return null;
+
+ const tile = this.getTile();
+ const replyChain = this.getReplyChain();
+ const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined;
+ const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined;
+
+ return (
+
+ );
+ }
+
public render() {
const msgtype = this.props.mxEvent.getContent().msgtype;
const eventType = this.props.mxEvent.getType() as EventType;
@@ -951,7 +939,7 @@ export class UnwrappedEventTile extends React.Component {
isLeftAlignedBubbleMessage,
noBubbleEvent,
isSeeingThroughMessageHiddenForModeration,
- } = getEventDisplayInfo(this.props.mxEvent, this.shouldHideEvent());
+ } = getEventDisplayInfo(this.props.mxEvent, this.context.showHiddenEvents, this.shouldHideEvent());
const { isQuoteExpanded } = this.state;
// This shouldn't happen: the caller should check we support this type
@@ -1005,7 +993,7 @@ export class UnwrappedEventTile extends React.Component {
// Note: we keep the `sending` state class for tests, not for our styles
mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_highlight: this.shouldHighlight(),
- mx_EventTile_selected: this.props.isSelectedEvent,
+ mx_EventTile_selected: this.props.isSelectedEvent || this.state.contextMenu,
mx_EventTile_continuation: isContinuation || eventType === EventType.CallInvite,
mx_EventTile_last: this.props.last,
mx_EventTile_lastInSection: this.props.lastInSection,
@@ -1043,8 +1031,10 @@ export class UnwrappedEventTile extends React.Component {
if (this.context.timelineRenderingType === TimelineRenderingType.Notification) {
avatarSize = 24;
needsSenderProfile = true;
- } else if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList) {
- avatarSize = 36;
+ } else if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList ||
+ (this.context.timelineRenderingType === TimelineRenderingType.Thread && !this.props.continuation)
+ ) {
+ avatarSize = 32;
needsSenderProfile = true;
} else if (eventType === EventType.RoomCreate || isBubbleMessage) {
avatarSize = 0;
@@ -1126,7 +1116,8 @@ export class UnwrappedEventTile extends React.Component {
&& (this.props.alwaysShowTimestamps
|| this.props.last
|| this.state.hover
- || this.state.actionBarFocused);
+ || this.state.actionBarFocused
+ || Boolean(this.state.contextMenu));
// Thread panel shows the timestamp of the last reply in that thread
const ts = this.context.timelineRenderingType !== TimelineRenderingType.ThreadsList
@@ -1197,6 +1188,7 @@ export class UnwrappedEventTile extends React.Component {
href={permalink}
onClick={this.onPermalinkClicked}
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
+ onContextMenu={this.onTimestampContextMenu}
>
{ timestamp }
;
@@ -1210,8 +1202,17 @@ export class UnwrappedEventTile extends React.Component {
let msgOption;
if (this.props.showReadReceipts) {
- const readAvatars = this.getReadAvatars();
- msgOption = readAvatars;
+ if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
+ msgOption = ;
+ } else {
+ msgOption = ;
+ }
}
let replyChain;
@@ -1252,12 +1253,17 @@ export class UnwrappedEventTile extends React.Component {
,
,
-
+
+ { this.renderContextMenu() }
{ renderTile(TimelineRenderingType.Notification, {
...this.props,
@@ -1298,7 +1304,8 @@ export class UnwrappedEventTile extends React.Component {
{ avatar }
{ sender }
,
-
+
+ { this.renderContextMenu() }
{ replyChain }
{ renderTile(TimelineRenderingType.Thread, {
...this.props,
@@ -1385,7 +1392,8 @@ export class UnwrappedEventTile extends React.Component
{
"aria-atomic": true,
"data-scroll-tokens": scrollToken,
}, [
-
+
+ { this.renderContextMenu() }
{ renderTile(TimelineRenderingType.File, {
...this.props,
@@ -1406,7 +1414,10 @@ export class UnwrappedEventTile extends React.Component
{
href={permalink}
onClick={this.onPermalinkClicked}
>
-
+
{ sender }
{ timestamp }
@@ -1434,7 +1445,8 @@ export class UnwrappedEventTile extends React.Component
{
{ sender }
{ ircPadlock }
{ avatar }
-
+
+ { this.renderContextMenu() }
{ groupTimestamp }
{ groupPadlock }
{ replyChain }
@@ -1565,66 +1577,51 @@ interface ISentReceiptProps {
messageState: string; // TODO: Types for message sending state
}
-interface ISentReceiptState {
- hover: boolean;
-}
-
-class SentReceipt extends React.PureComponent
{
- constructor(props) {
- super(props);
-
- this.state = {
- hover: false,
- };
+function SentReceipt({ messageState }: ISentReceiptProps) {
+ const isSent = !messageState || messageState === 'sent';
+ const isFailed = messageState === 'not_sent';
+ const receiptClasses = classNames({
+ 'mx_EventTile_receiptSent': isSent,
+ 'mx_EventTile_receiptSending': !isSent && !isFailed,
+ });
+
+ let nonCssBadge = null;
+ if (isFailed) {
+ nonCssBadge = (
+
+ );
}
- onHoverStart = () => {
- this.setState({ hover: true });
- };
-
- onHoverEnd = () => {
- this.setState({ hover: false });
- };
-
- render() {
- const isSent = !this.props.messageState || this.props.messageState === 'sent';
- const isFailed = this.props.messageState === 'not_sent';
- const receiptClasses = classNames({
- 'mx_EventTile_receiptSent': isSent,
- 'mx_EventTile_receiptSending': !isSent && !isFailed,
- });
-
- let nonCssBadge = null;
- if (isFailed) {
- nonCssBadge = ;
- }
-
- let tooltip = null;
- if (this.state.hover) {
- let label = _t("Sending your message...");
- if (this.props.messageState === 'encrypting') {
- label = _t("Encrypting your message...");
- } else if (isSent) {
- label = _t("Your message was sent");
- } else if (isFailed) {
- label = _t("Failed to send");
- }
- // The yOffset is somewhat arbitrary - it just brings the tooltip down to be more associated
- // with the read receipt.
- tooltip = ;
- }
+ let label = _t("Sending your message...");
+ if (messageState === 'encrypting') {
+ label = _t("Encrypting your message...");
+ } else if (isSent) {
+ label = _t("Your message was sent");
+ } else if (isFailed) {
+ label = _t("Failed to send");
+ }
+ const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({
+ label: label,
+ alignment: Alignment.TopRight,
+ });
- return (
-
-
-
- { nonCssBadge }
- { tooltip }
+ return (
+
+
+
+
+
+ { nonCssBadge }
+
-
+
+ { tooltip }
- );
- }
+
+ );
}
diff --git a/src/components/views/rooms/MemberTile.tsx b/src/components/views/rooms/MemberTile.tsx
index b652771a43e..f292ec3b589 100644
--- a/src/components/views/rooms/MemberTile.tsx
+++ b/src/components/views/rooms/MemberTile.tsx
@@ -20,12 +20,10 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
-import { UserEvent } from "matrix-js-sdk/src/models/user";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { UserTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
-import SettingsStore from "../../../settings/SettingsStore";
import dis from "../../../dispatcher/dispatcher";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -41,7 +39,6 @@ interface IProps {
}
interface IState {
- statusMessage: string;
isRoomEncrypted: boolean;
e2eStatus: string;
}
@@ -58,7 +55,6 @@ export default class MemberTile extends React.Component {
super(props);
this.state = {
- statusMessage: this.getStatusMessage(),
isRoomEncrypted: false,
e2eStatus: null,
};
@@ -67,13 +63,6 @@ export default class MemberTile extends React.Component {
componentDidMount() {
const cli = MatrixClientPeg.get();
- if (SettingsStore.getValue("feature_custom_status")) {
- const { user } = this.props.member;
- if (user) {
- user.on(UserEvent._UnstableStatusMessage, this.onStatusMessageCommitted);
- }
- }
-
const { roomId } = this.props.member;
if (roomId) {
const isRoomEncrypted = cli.isRoomEncrypted(roomId);
@@ -94,11 +83,6 @@ export default class MemberTile extends React.Component {
componentWillUnmount() {
const cli = MatrixClientPeg.get();
- const { user } = this.props.member;
- if (user) {
- user.removeListener(UserEvent._UnstableStatusMessage, this.onStatusMessageCommitted);
- }
-
if (cli) {
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
@@ -158,21 +142,6 @@ export default class MemberTile extends React.Component {
});
}
- private getStatusMessage(): string {
- const { user } = this.props.member;
- if (!user) {
- return "";
- }
- return user.unstable_statusMessage;
- }
-
- private onStatusMessageCommitted = (): void => {
- // The `User` object has observed a status message change.
- this.setState({
- statusMessage: this.getStatusMessage(),
- });
- };
-
shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean {
if (
this.memberLastModifiedTime === undefined ||
@@ -222,11 +191,6 @@ export default class MemberTile extends React.Component {
const name = this.getDisplayName();
const presenceState = member.user ? member.user.presence : null;
- let statusMessage = null;
- if (member.user && SettingsStore.getValue("feature_custom_status")) {
- statusMessage = this.state.statusMessage;
- }
-
const av = (
);
@@ -277,7 +241,6 @@ export default class MemberTile extends React.Component {
nameJSX={nameJSX}
powerStatus={powerStatus}
showPresence={this.props.showPresence}
- subtextLabel={statusMessage}
e2eStatus={e2eStatus}
onClick={this.onClick}
/>
diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx
index 363e687c9f9..9c9f190210d 100644
--- a/src/components/views/rooms/NewRoomIntro.tsx
+++ b/src/components/views/rooms/NewRoomIntro.tsx
@@ -28,7 +28,6 @@ import AccessibleButton from "../elements/AccessibleButton";
import MiniAvatarUploader, { AVATAR_SIZE } from "../elements/MiniAvatarUploader";
import RoomAvatar from "../avatars/RoomAvatar";
import defaultDispatcher from "../../../dispatcher/dispatcher";
-import dis from "../../../dispatcher/dispatcher";
import { ViewUserPayload } from "../../../dispatcher/payloads/ViewUserPayload";
import { Action } from "../../../dispatcher/actions";
import SpaceStore from "../../../stores/spaces/SpaceStore";
@@ -87,7 +86,7 @@ const NewRoomIntro = () => {
const canAddTopic = inRoom && room.currentState.maySendStateEvent(EventType.RoomTopic, cli.getUserId());
const onTopicClick = () => {
- dis.dispatch({
+ defaultDispatcher.dispatch({
action: "open_room_settings",
room_id: roomId,
}, true);
@@ -150,7 +149,7 @@ const NewRoomIntro = () => {
className="mx_NewRoomIntro_inviteButton"
kind="primary_outline"
onClick={() => {
- dis.dispatch({ action: "view_invite", roomId });
+ defaultDispatcher.dispatch({ action: "view_invite", roomId });
}}
>
{ _t("Invite to just this room") }
@@ -162,7 +161,7 @@ const NewRoomIntro = () => {
className="mx_NewRoomIntro_inviteButton"
kind="primary"
onClick={() => {
- dis.dispatch({ action: "view_invite", roomId });
+ defaultDispatcher.dispatch({ action: "view_invite", roomId });
}}
>
{ _t("Invite to this room") }
@@ -192,7 +191,7 @@ const NewRoomIntro = () => {
function openRoomSettings(event) {
event.preventDefault();
- dis.dispatch({
+ defaultDispatcher.dispatch({
action: "open_room_settings",
initial_tab_id: ROOM_SECURITY_TAB,
});
diff --git a/src/components/views/rooms/ReadReceiptGroup.tsx b/src/components/views/rooms/ReadReceiptGroup.tsx
new file mode 100644
index 00000000000..e1c0de14286
--- /dev/null
+++ b/src/components/views/rooms/ReadReceiptGroup.tsx
@@ -0,0 +1,311 @@
+/*
+Copyright 2022 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, { PropsWithChildren, useRef } from "react";
+import { User } from "matrix-js-sdk/src/matrix";
+
+import ReadReceiptMarker, { IReadReceiptInfo } from "./ReadReceiptMarker";
+import { IReadReceiptProps } from "./EventTile";
+import AccessibleButton from "../elements/AccessibleButton";
+import MemberAvatar from "../avatars/MemberAvatar";
+import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
+import { Alignment } from "../elements/Tooltip";
+import { formatDate } from "../../../DateUtils";
+import { Action } from "../../../dispatcher/actions";
+import dis from "../../../dispatcher/dispatcher";
+import ContextMenu, { aboveLeftOf, MenuItem, useContextMenu } from "../../structures/ContextMenu";
+import { useTooltip } from "../../../utils/useTooltip";
+import { _t } from "../../../languageHandler";
+import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
+
+// #20547 Design specified that we should show the three latest read receipts
+const MAX_READ_AVATARS_PLUS_N = 3;
+// #21935 If we’ve got just 4, don’t show +1, just show all of them
+const MAX_READ_AVATARS = MAX_READ_AVATARS_PLUS_N + 1;
+
+const READ_AVATAR_OFFSET = 10;
+export const READ_AVATAR_SIZE = 16;
+
+interface Props {
+ readReceipts: IReadReceiptProps[];
+ readReceiptMap: { [userId: string]: IReadReceiptInfo };
+ checkUnmounting: () => boolean;
+ suppressAnimation: boolean;
+ isTwelveHour: boolean;
+}
+
+interface IAvatarPosition {
+ hidden: boolean;
+ position: number;
+}
+
+export function determineAvatarPosition(index: number, max: number): IAvatarPosition {
+ if (index < max) {
+ return {
+ hidden: false,
+ position: index,
+ };
+ } else {
+ return {
+ hidden: true,
+ position: 0,
+ };
+ }
+}
+
+export function readReceiptTooltip(members: string[], hasMore: boolean): string | null {
+ if (hasMore) {
+ return _t("%(members)s and more", {
+ members: members.join(", "),
+ });
+ } else if (members.length > 1) {
+ return _t("%(members)s and %(last)s", {
+ last: members.pop(),
+ members: members.join(", "),
+ });
+ } else if (members.length) {
+ return members[0];
+ } else {
+ return null;
+ }
+}
+
+export function ReadReceiptGroup(
+ { readReceipts, readReceiptMap, checkUnmounting, suppressAnimation, isTwelveHour }: Props,
+) {
+ const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
+
+ // If we are above MAX_READ_AVATARS, we’ll have to remove a few to have space for the +n count.
+ const hasMore = readReceipts.length > MAX_READ_AVATARS;
+ const maxAvatars = hasMore
+ ? MAX_READ_AVATARS_PLUS_N
+ : MAX_READ_AVATARS;
+
+ const tooltipMembers: string[] = readReceipts.slice(0, maxAvatars)
+ .map(it => it.roomMember?.name ?? it.userId);
+ const tooltipText = readReceiptTooltip(tooltipMembers, hasMore);
+
+ const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({
+ label: (
+ <>
+
+ { _t("Seen by %(count)s people", { count: readReceipts.length }) }
+
+
+ { tooltipText }
+
+ >
+ ),
+ alignment: Alignment.TopRight,
+ });
+
+ // return early if there are no read receipts
+ if (readReceipts.length === 0) {
+ // We currently must include `mx_ReadReceiptGroup_container` in
+ // the DOM of all events, as it is the positioned parent of the
+ // animated read receipts. We can't let it unmount when a receipt
+ // moves events, so for now we mount it for all events. Without
+ // it, the animation will start from the top of the timeline
+ // (because it lost its container).
+ // See also https://github.com/vector-im/element-web/issues/17561
+ return (
+
+ );
+ }
+
+ const avatars = readReceipts.map((receipt, index) => {
+ const { hidden, position } = determineAvatarPosition(index, maxAvatars);
+
+ const userId = receipt.userId;
+ let readReceiptInfo: IReadReceiptInfo;
+
+ if (readReceiptMap) {
+ readReceiptInfo = readReceiptMap[userId];
+ if (!readReceiptInfo) {
+ readReceiptInfo = {};
+ readReceiptMap[userId] = readReceiptInfo;
+ }
+ }
+
+ return (
+
+ );
+ }).reverse();
+
+ let remText: JSX.Element;
+ const remainder = readReceipts.length - maxAvatars;
+ if (remainder > 0) {
+ remText = (
+
+ +{ remainder }
+
+ );
+ }
+
+ let contextMenu;
+ if (menuDisplayed) {
+ const buttonRect = button.current.getBoundingClientRect();
+ contextMenu = (
+
+
+
+ { _t("Seen by %(count)s people", { count: readReceipts.length }) }
+
+ { readReceipts.map(receipt => (
+
+ )) }
+
+
+ );
+ }
+
+ return (
+
+
+
+ { remText }
+
+ { avatars }
+
+
+ { tooltip }
+ { contextMenu }
+
+
+ );
+}
+
+interface ReadReceiptPersonProps extends IReadReceiptProps {
+ isTwelveHour: boolean;
+ onAfterClick?: () => void;
+}
+
+function ReadReceiptPerson({ userId, roomMember, ts, isTwelveHour, onAfterClick }: ReadReceiptPersonProps) {
+ const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({
+ alignment: Alignment.TopCenter,
+ tooltipClassName: "mx_ReadReceiptGroup_person--tooltip",
+ label: (
+ <>
+
+ { roomMember?.rawDisplayName ?? userId }
+
+
+ { userId }
+
+ >
+ ),
+ });
+
+ return (
+
+ );
+}
+
+interface ISectionHeaderProps {
+ className?: string;
+}
+
+function SectionHeader({ className, children }: PropsWithChildren) {
+ const ref = useRef();
+ const [onFocus] = useRovingTabIndex(ref);
+
+ return (
+
+ { children }
+
+ );
+}
diff --git a/src/components/views/rooms/ReadReceiptMarker.tsx b/src/components/views/rooms/ReadReceiptMarker.tsx
index 916d886447f..934fd7af7ab 100644
--- a/src/components/views/rooms/ReadReceiptMarker.tsx
+++ b/src/components/views/rooms/ReadReceiptMarker.tsx
@@ -19,28 +19,27 @@ import React, { createRef, RefObject } from 'react';
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { logger } from "matrix-js-sdk/src/logger";
-import { _t } from '../../../languageHandler';
-import { formatDate } from '../../../DateUtils';
import NodeAnimator from "../../../NodeAnimator";
import { toPx } from "../../../utils/units";
import MemberAvatar from '../avatars/MemberAvatar';
+import { READ_AVATAR_SIZE } from "./ReadReceiptGroup";
export interface IReadReceiptInfo {
top?: number;
- left?: number;
+ right?: number;
parent?: Element;
}
interface IProps {
// the RoomMember to show the RR for
- member?: RoomMember;
+ member?: RoomMember | null;
// userId to fallback the avatar to
// if the member hasn't been loaded yet
fallbackUserId: string;
// number of pixels to offset the avatar from the right of its parent;
// typically a negative value.
- leftOffset?: number;
+ offset: number;
// true to hide the avatar (it will still be animated)
hidden?: boolean;
@@ -56,9 +55,6 @@ interface IProps {
// are being unmounted.
checkUnmounting?: () => boolean;
- // callback for clicks on this RR
- onClick?: (e: React.MouseEvent) => void;
-
// Timestamp when the receipt was read
timestamp?: number;
@@ -73,16 +69,12 @@ interface IState {
interface IReadReceiptMarkerStyle {
top: number;
- left: number;
+ right: number;
}
export default class ReadReceiptMarker extends React.PureComponent {
private avatar: React.RefObject = createRef();
- static defaultProps = {
- leftOffset: 0,
- };
-
constructor(props: IProps) {
super(props);
@@ -110,10 +102,7 @@ export default class ReadReceiptMarker extends React.PureComponent
}
+ hideTitle
+ tabIndex={-1}
/>
);
diff --git a/src/components/views/rooms/ReplyPreview.tsx b/src/components/views/rooms/ReplyPreview.tsx
index 72340e99819..611c58f8529 100644
--- a/src/components/views/rooms/ReplyPreview.tsx
+++ b/src/components/views/rooms/ReplyPreview.tsx
@@ -22,6 +22,7 @@ import { _t } from '../../../languageHandler';
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import ReplyTile from './ReplyTile';
import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
+import AccessibleButton from "../elements/AccessibleButton";
function cancelQuoting(context: TimelineRenderingType) {
dis.dispatch({
@@ -44,25 +45,17 @@ export default class ReplyPreview extends React.Component {
return
-
- { _t('Replying') }
-
-
-
+
{ _t('Replying') }
+
cancelQuoting(this.context.timelineRenderingType)}
/>
-
-
-
-
+
;
}
diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx
index 1cdd2e770e8..9983b6f39c3 100644
--- a/src/components/views/rooms/RoomHeader.tsx
+++ b/src/components/views/rooms/RoomHeader.tsx
@@ -53,6 +53,7 @@ interface IProps {
oobData?: IOOBData;
inRoom: boolean;
onSearchClick: () => void;
+ onInviteClick: () => void;
onForgetClick: () => void;
onCallPlaced: (type: CallType) => void;
onAppsClick: () => void;
@@ -255,6 +256,16 @@ export default class RoomHeader extends React.Component {
buttons.push(searchButton);
}
+ if (this.props.onInviteClick && this.props.inRoom) {
+ const inviteButton = ;
+ buttons.push(inviteButton);
+ }
+
const rightRow =
{ buttons }
diff --git a/src/components/views/rooms/RoomInfoLine.tsx b/src/components/views/rooms/RoomInfoLine.tsx
new file mode 100644
index 00000000000..09214043d63
--- /dev/null
+++ b/src/components/views/rooms/RoomInfoLine.tsx
@@ -0,0 +1,86 @@
+/*
+Copyright 2022 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, { FC } from "react";
+import { Room } from "matrix-js-sdk/src/models/room";
+import { JoinRule } from "matrix-js-sdk/src/@types/partials";
+
+import { _t } from "../../../languageHandler";
+import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
+import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
+import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
+import { useRoomState } from "../../../hooks/useRoomState";
+import { useRoomMemberCount, useMyRoomMembership } from "../../../hooks/useRoomMembers";
+import AccessibleButton from "../elements/AccessibleButton";
+
+interface IProps {
+ room: Room;
+}
+
+const RoomInfoLine: FC
= ({ room }) => {
+ // summary will begin as undefined whilst loading and go null if it fails to load or we are not invited.
+ const summary = useAsyncMemo(async () => {
+ if (room.getMyMembership() !== "invite") return null;
+ try {
+ return room.client.getRoomSummary(room.roomId);
+ } catch (e) {
+ return null;
+ }
+ }, [room]);
+ const joinRule = useRoomState(room, state => state.getJoinRule());
+ const membership = useMyRoomMembership(room);
+ const memberCount = useRoomMemberCount(room);
+
+ let iconClass: string;
+ let roomType: string;
+ if (room.isElementVideoRoom()) {
+ iconClass = "mx_RoomInfoLine_video";
+ roomType = _t("Video room");
+ } else if (joinRule === JoinRule.Public) {
+ iconClass = "mx_RoomInfoLine_public";
+ roomType = room.isSpaceRoom() ? _t("Public space") : _t("Public room");
+ } else {
+ iconClass = "mx_RoomInfoLine_private";
+ roomType = room.isSpaceRoom() ? _t("Private space") : _t("Private room");
+ }
+
+ let members: JSX.Element;
+ if (membership === "invite" && summary) {
+ // Don't trust local state and instead use the summary API
+ members =
+ { _t("%(count)s members", { count: summary.num_joined_members }) }
+ ;
+ } else if (memberCount && summary !== undefined) { // summary is not still loading
+ const viewMembers = () => RightPanelStore.instance.setCard({
+ phase: room.isSpaceRoom() ? RightPanelPhases.SpaceMemberList : RightPanelPhases.RoomMemberList,
+ });
+
+ members =
+ { _t("%(count)s members", { count: memberCount }) }
+ ;
+ }
+
+ return
+ { roomType }
+ { members }
+
;
+};
+
+export default RoomInfoLine;
diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx
index e2550bc2b6e..e534b713f8d 100644
--- a/src/components/views/rooms/RoomList.tsx
+++ b/src/components/views/rooms/RoomList.tsx
@@ -16,9 +16,8 @@ limitations under the License.
import React, { ComponentType, createRef, ReactComponentElement, RefObject } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
-import { RoomType } from "matrix-js-sdk/src/@types/event";
+import { RoomType, EventType } from "matrix-js-sdk/src/@types/event";
import * as fbEmitter from "fbemitter";
-import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t, _td } from "../../../languageHandler";
import { IState as IRovingTabIndexState, RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
@@ -142,6 +141,7 @@ const DmAuxButton = ({ tabIndex, dispatcher = defaultDispatcher }: IAuxButtonPro
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({ action: "view_create_chat" });
+ PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateChatItem", e);
}}
/> }
{ showInviteUsers && dispatcher.dispatch({ action: 'view_create_chat' })}
+ onClick={(e) => {
+ dispatcher.dispatch({ action: 'view_create_chat' });
+ PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateChatItem", e);
+ }}
className="mx_RoomSublist_auxButton"
tooltipClassName="mx_RoomSublist_addRoomTooltip"
aria-label={_t("Start chat")}
@@ -236,7 +239,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
: _t("You do not have permissions to create new rooms in this space")}
/>
{ SettingsStore.getValue("feature_video_rooms") && {
e.preventDefault();
@@ -280,7 +283,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
}}
/>
{ SettingsStore.getValue("feature_video_rooms") && {
e.preventDefault();
@@ -300,6 +303,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
+ PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e);
defaultDispatcher.fire(Action.ViewRoomDirectory);
}}
/>
@@ -496,9 +500,10 @@ export default class RoomList extends React.PureComponent