({
+ action: Action.ViewRoom,
+ metricsTrigger: "WebUnifiedSearch",
+ metricsViaKeyboard: viaKeyboard,
+ room_id: room.roomId,
+ room_alias: room.roomAlias,
+ auto_join: room.autoJoin && !canAskToJoin(room.joinRule),
+ should_peek: room.shouldPeek,
+ via_servers: room.viaServers,
+ });
+
+ if (canAskToJoin(room.joinRule)) {
+ defaultDispatcher.dispatch({ action: Action.PromptAskToJoin });
+ }
+
+ onFinished();
+ };
+
+ let otherSearchesSection: JSX.Element | undefined;
+ if (trimmedQuery || (filter !== Filter.PublicRooms && filter !== Filter.PublicSpaces)) {
+ otherSearchesSection = (
+
+
+ {trimmedQuery
+ ? _t("spotlight_dialog|heading_with_query", { query })
+ : _t("spotlight_dialog|heading_without_query")}
+
+
+ {filter !== Filter.PublicSpaces && supportsSpaceFiltering && (
+ setFilter(Filter.PublicSpaces)}
+ >
+ {filterToLabel(Filter.PublicSpaces)}
+
+ )}
+ {filter !== Filter.PublicRooms && (
+ setFilter(Filter.PublicRooms)}
+ >
+ {filterToLabel(Filter.PublicRooms)}
+
+ )}
+ {filter !== Filter.People && (
+ setFilter(Filter.People)}
+ >
+ {filterToLabel(Filter.People)}
+
+ )}
+
+
+ );
+ }
+
+ let content: JSX.Element;
+ if (trimmedQuery || filter !== null) {
+ const resultMapper = (result: Result): JSX.Element => {
+ if (isRoomResult(result)) {
+ const notification = RoomNotificationStateStore.instance.getRoomState(result.room);
+ const unreadLabel = roomAriaUnreadLabel(result.room, notification);
+ const ariaProperties = {
+ "aria-label": unreadLabel ? `${result.room.name} ${unreadLabel}` : result.room.name,
+ "aria-describedby": `mx_SpotlightDialog_button_result_${result.room.roomId}_details`,
+ };
+ return (
+ {
+ viewRoom({ roomId: result.room.roomId }, true, ev?.type !== "click");
+ }}
+ endAdornment={ }
+ {...ariaProperties}
+ >
+
+
+
+
+ );
+ }
+ if (isMemberResult(result)) {
+ return (
+ {
+ startDmOnFirstMessage(cli, [result.member]);
+ onFinished();
+ }}
+ aria-label={
+ result.member instanceof RoomMember ? result.member.rawDisplayName : result.member.name
+ }
+ aria-describedby={`mx_SpotlightDialog_button_result_${result.member.userId}_details`}
+ >
+
+ {result.member instanceof RoomMember ? result.member.rawDisplayName : result.member.name}
+
+ {result.member.userId}
+
+
+ );
+ }
+ if (isPublicRoomResult(result)) {
+ const clientRoom = cli.getRoom(result.publicRoom.room_id);
+ const joinRule = result.publicRoom.join_rule;
+ // Element Web currently does not allow guests to join rooms, so we
+ // instead show them view buttons for all rooms. If the room is not
+ // world readable, a modal will appear asking you to register first. If
+ // it is readable, the preview appears as normal.
+ const showViewButton =
+ clientRoom?.getMyMembership() === "join" ||
+ (result.publicRoom.world_readable && !canAskToJoin(joinRule)) ||
+ cli.isGuest();
+
+ const listener = (ev: ButtonEvent): void => {
+ ev.stopPropagation();
+
+ const { publicRoom } = result;
+ viewRoom(
+ {
+ roomAlias: publicRoom.canonical_alias || publicRoom.aliases?.[0],
+ roomId: publicRoom.room_id,
+ autoJoin: !result.publicRoom.world_readable && !cli.isGuest(),
+ shouldPeek: result.publicRoom.world_readable || cli.isGuest(),
+ viaServers: config ? [config.roomServer] : undefined,
+ joinRule,
+ },
+ true,
+ ev.type !== "click",
+ );
+ };
+
+ let buttonLabel;
+ if (showViewButton) {
+ buttonLabel = _t("action|view");
+ } else {
+ buttonLabel = canAskToJoin(joinRule) ? _t("action|ask_to_join") : _t("action|join");
+ }
+
+ return (
+
+ {buttonLabel}
+
+ }
+ aria-labelledby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_name`}
+ aria-describedby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_alias`}
+ aria-details={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_details`}
+ >
+
+
+
+ );
+ }
+
+ // IResult case
+ return (
+
+ {result.avatar}
+ {result.name}
+ {result.description}
+
+ );
+ };
+
+ let peopleSection: JSX.Element | undefined;
+ if (results[Section.People].length) {
+ peopleSection = (
+
+
{_t("invite|recents_section")}
+
{results[Section.People].slice(0, SECTION_LIMIT).map(resultMapper)}
+
+ );
+ }
+
+ let suggestionsSection: JSX.Element | undefined;
+ if (results[Section.Suggestions].length && filter === Filter.People) {
+ suggestionsSection = (
+
+
{_t("common|suggestions")}
+
{results[Section.Suggestions].slice(0, SECTION_LIMIT).map(resultMapper)}
+
+ );
+ }
+
+ let roomsSection: JSX.Element | undefined;
+ if (results[Section.Rooms].length) {
+ roomsSection = (
+
+
{_t("common|rooms")}
+
{results[Section.Rooms].slice(0, SECTION_LIMIT).map(resultMapper)}
+
+ );
+ }
+
+ let spacesSection: JSX.Element | undefined;
+ if (results[Section.Spaces].length) {
+ spacesSection = (
+
+
{_t("spotlight_dialog|spaces_title")}
+
{results[Section.Spaces].slice(0, SECTION_LIMIT).map(resultMapper)}
+
+ );
+ }
+
+ let publicRoomsSection: JSX.Element | undefined;
+ if (filter === Filter.PublicRooms || filter === Filter.PublicSpaces) {
+ let content: JSX.Element | JSX.Element[];
+ if (publicRoomsError) {
+ content = (
+
+ {filter === Filter.PublicRooms
+ ? _t("spotlight_dialog|failed_querying_public_rooms")
+ : _t("spotlight_dialog|failed_querying_public_spaces")}
+
+ );
+ } else {
+ content = results[Section.PublicRoomsAndSpaces].slice(0, SECTION_LIMIT).map(resultMapper);
+ }
+
+ publicRoomsSection = (
+
+
+
{_t("common|suggestions")}
+
+
+
+
+
{content}
+
+ );
+ }
+
+ let spaceRoomsSection: JSX.Element | undefined;
+ if (spaceResults.length && activeSpace && filter === null) {
+ spaceRoomsSection = (
+
+
+ {_t("spotlight_dialog|other_rooms_in_space", { spaceName: activeSpace.name })}
+
+
+ {spaceResults.slice(0, SECTION_LIMIT).map(
+ (room: HierarchyRoom): JSX.Element => (
+
{
+ viewRoom({ roomId: room.room_id }, true, ev?.type !== "click");
+ }}
+ >
+
+ {room.name || room.canonical_alias}
+ {room.name && room.canonical_alias && (
+ {room.canonical_alias}
+ )}
+
+ ),
+ )}
+ {spaceResultsLoading &&
}
+
+
+ );
+ }
+
+ let joinRoomSection: JSX.Element | undefined;
+ if (
+ trimmedQuery.startsWith("#") &&
+ trimmedQuery.includes(":") &&
+ (!getCachedRoomIDForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIDForAlias(trimmedQuery)))
+ ) {
+ joinRoomSection = (
+
+
+ {
+ defaultDispatcher.dispatch({
+ action: Action.ViewRoom,
+ room_alias: trimmedQuery,
+ auto_join: true,
+ metricsTrigger: "WebUnifiedSearch",
+ metricsViaKeyboard: ev?.type !== "click",
+ });
+ onFinished();
+ }}
+ >
+ {_t("spotlight_dialog|join_button_text", {
+ roomAddress: trimmedQuery,
+ })}
+
+
+
+ );
+ }
+
+ let hiddenResultsSection: JSX.Element | undefined;
+ if (filter === Filter.People) {
+ hiddenResultsSection = (
+
+
{_t("spotlight_dialog|result_may_be_hidden_privacy_warning")}
+
+ {_t("spotlight_dialog|cant_find_person_helpful_hint")}
+
+
{
+ setInviteLinkCopied(true);
+ copyPlaintext(ownInviteLink);
+ }}
+ onHideTooltip={() => setInviteLinkCopied(false)}
+ title={inviteLinkCopied ? _t("common|copied") : _t("action|copy")}
+ >
+
+ {_t("spotlight_dialog|copy_link_text")}
+
+
+
+ );
+ } else if (trimmedQuery && (filter === Filter.PublicRooms || filter === Filter.PublicSpaces)) {
+ hiddenResultsSection = (
+
+
{_t("spotlight_dialog|result_may_be_hidden_warning")}
+
+ {_t("spotlight_dialog|cant_find_room_helpful_hint")}
+
+
+ defaultDispatcher.dispatch({
+ action: "view_create_room",
+ public: true,
+ defaultName: capitalize(trimmedQuery),
+ })
+ }
+ >
+
+ {_t("spotlight_dialog|create_new_room_button")}
+
+
+
+ );
+ }
+
+ let groupChatSection: JSX.Element | undefined;
+ if (filter === Filter.People) {
+ groupChatSection = (
+
+
{_t("spotlight_dialog|group_chat_section_title")}
+ showStartChatInviteDialog(trimmedQuery)}
+ >
+ {_t("spotlight_dialog|start_group_chat_button")}
+
+
+ );
+ }
+
+ let messageSearchSection: JSX.Element | undefined;
+ if (filter === null) {
+ messageSearchSection = (
+
+
+ {_t("spotlight_dialog|message_search_section_title")}
+
+
+ {_t(
+ "spotlight_dialog|search_messages_hint",
+ {},
+ { icon: () =>
},
+ )}
+
+
+ );
+ }
+
+ content = (
+ <>
+ {peopleSection}
+ {suggestionsSection}
+ {roomsSection}
+ {spacesSection}
+ {spaceRoomsSection}
+ {publicRoomsSection}
+ {joinRoomSection}
+ {hiddenResultsSection}
+ {otherSearchesSection}
+ {groupChatSection}
+ {messageSearchSection}
+ >
+ );
+ } else {
+ let recentSearchesSection: JSX.Element | undefined;
+ if (recentSearches.length) {
+ recentSearchesSection = (
+
+
+
+ {_t("spotlight_dialog|recent_searches_section_title")}
+
+
+ {_t("action|clear")}
+
+
+
+ {recentSearches.map((room) => {
+ const notification = RoomNotificationStateStore.instance.getRoomState(room);
+ const unreadLabel = roomAriaUnreadLabel(room, notification);
+ const ariaProperties = {
+ "aria-label": unreadLabel ? `${room.name} ${unreadLabel}` : room.name,
+ "aria-describedby": `mx_SpotlightDialog_button_recentSearch_${room.roomId}_details`,
+ };
+ return (
+ {
+ viewRoom({ roomId: room.roomId }, true, ev?.type !== "click");
+ }}
+ endAdornment={ }
+ {...ariaProperties}
+ >
+
+
+
+
+ );
+ })}
+
+
+ );
+ }
+
+ content = (
+ <>
+
+
+ {_t("spotlight_dialog|recently_viewed_section_title")}
+
+
+ {BreadcrumbsStore.instance.rooms
+ .filter((r) => r.roomId !== SdkContextClass.instance.roomViewStore.getRoomId())
+ .map((room) => (
+ {
+ viewRoom({ roomId: room.roomId }, false, ev.type !== "click");
+ }}
+ >
+
+
+
+ ))}
+
+
+
+ {recentSearchesSection}
+ {otherSearchesSection}
+ >
+ );
+ }
+
+ const onDialogKeyDown = (ev: KeyboardEvent | React.KeyboardEvent): void => {
+ const navigationAction = getKeyBindingsManager().getNavigationAction(ev);
+ switch (navigationAction) {
+ case KeyBindingAction.FilterRooms:
+ ev.stopPropagation();
+ ev.preventDefault();
+ onFinished();
+ break;
+ }
+
+ let ref: RefObject | undefined;
+ const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev);
+ switch (accessibilityAction) {
+ case KeyBindingAction.Escape:
+ ev.stopPropagation();
+ ev.preventDefault();
+ onFinished();
+ break;
+ case KeyBindingAction.ArrowUp:
+ case KeyBindingAction.ArrowDown:
+ ev.stopPropagation();
+ ev.preventDefault();
+
+ if (rovingContext.state.activeRef && rovingContext.state.refs.length > 0) {
+ let refs = rovingContext.state.refs;
+ if (!query && !filter !== null) {
+ // If the current selection is not in the recently viewed row then only include the
+ // first recently viewed so that is the target when the user is switching into recently viewed.
+ const keptRecentlyViewedRef = refIsForRecentlyViewed(rovingContext.state.activeRef)
+ ? rovingContext.state.activeRef
+ : refs.find(refIsForRecentlyViewed);
+ // exclude all other recently viewed items from the list so up/down arrows skip them
+ refs = refs.filter((ref) => ref === keptRecentlyViewedRef || !refIsForRecentlyViewed(ref));
+ }
+
+ const idx = refs.indexOf(rovingContext.state.activeRef);
+ ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
+ }
+ break;
+
+ case KeyBindingAction.ArrowLeft:
+ case KeyBindingAction.ArrowRight:
+ // only handle these keys when we are in the recently viewed row of options
+ if (
+ !query &&
+ !filter !== null &&
+ rovingContext.state.activeRef &&
+ rovingContext.state.refs.length > 0 &&
+ refIsForRecentlyViewed(rovingContext.state.activeRef)
+ ) {
+ // we only intercept left/right arrows when the field is empty, and they'd do nothing anyway
+ ev.stopPropagation();
+ ev.preventDefault();
+
+ const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed);
+ const idx = refs.indexOf(rovingContext.state.activeRef);
+ ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1));
+ }
+ break;
+ }
+
+ if (ref) {
+ rovingContext.dispatch({
+ type: Type.SetFocus,
+ payload: { ref },
+ });
+ ref.current?.scrollIntoView({
+ block: "nearest",
+ });
+ }
+ };
+
+ const onKeyDown = (ev: React.KeyboardEvent): void => {
+ const action = getKeyBindingsManager().getAccessibilityAction(ev);
+
+ switch (action) {
+ case KeyBindingAction.Backspace:
+ if (!query && filter !== null) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ setFilter(null);
+ }
+ break;
+ case KeyBindingAction.Enter:
+ ev.stopPropagation();
+ ev.preventDefault();
+ rovingContext.state.activeRef?.current?.click();
+ break;
+ }
+ };
+
+ const activeDescendant = rovingContext.state.activeRef?.current?.id;
+
+ return (
+ <>
+
+ {_t(
+ "spotlight_dialog|keyboard_scroll_hint",
+ {},
+ {
+ arrows: () => (
+ <>
+ ↓
+ ↑
+ {!filter !== null && !query && ← }
+ {!filter !== null && !query && → }
+ >
+ ),
+ },
+ )}
+
+
+
+
+ {filter !== null && (
+
+
{filterToLabel(filter)}
+
setFilter(null)}
+ />
+
+ )}
+
+ {(publicRoomsLoading || peopleLoading || profileLoading) &&
}
+
+
+
+ {content}
+
+
+ >
+ );
+};
+
+const RovingSpotlightDialog: React.FC = (props) => {
+ return {() => } ;
+};
+
+export default RovingSpotlightDialog;
diff --git a/src/components/views/elements/BotVerifiedBadge.tsx b/src/components/views/elements/BotVerifiedBadge.tsx
new file mode 100644
index 00000000000..e9426fa5482
--- /dev/null
+++ b/src/components/views/elements/BotVerifiedBadge.tsx
@@ -0,0 +1,31 @@
+import React from "react";
+
+import { useVerifiedBot } from "../../../hooks/useVerifiedBot";
+
+export interface UserVerifiedBadgeProps {
+ userId: string;
+}
+
+export const BotVerifiedBadge = ({ userId }: UserVerifiedBadgeProps): JSX.Element => {
+ const isVerifiedBot = useVerifiedBot(userId);
+
+ return (
+ <>
+ {isVerifiedBot && (
+
+ Verified Bot
+
+ )}
+ >
+ );
+};
diff --git a/src/components/views/elements/CommunityRoomPeekMessage.tsx b/src/components/views/elements/CommunityRoomPeekMessage.tsx
new file mode 100644
index 00000000000..3f10662d4f9
--- /dev/null
+++ b/src/components/views/elements/CommunityRoomPeekMessage.tsx
@@ -0,0 +1,20 @@
+import { useAtom } from "jotai";
+import React, { ReactElement } from "react";
+
+import { minimumTokenThresholdAtom } from "../../../atoms";
+import { _t } from "../../../languageHandler";
+import { cleanRoomName } from "../../../hooks/useVerifiedRoom";
+
+export function CommunityRoomPeekMessage({ roomName }: { roomName: string }): ReactElement {
+ const [allTokens] = useAtom(minimumTokenThresholdAtom);
+ const cleanedRoomName = cleanRoomName(roomName);
+
+ const tokenThreshold = allTokens[cleanedRoomName];
+
+ return (
+
+ {_t("room|no_peek_join_prompt_community", { roomName: cleanedRoomName })}{" "}
+ {tokenThreshold ? _t("room|no_peek_join_prompt_community_threshold", tokenThreshold) : ""}
+
+ );
+}
diff --git a/src/components/views/elements/DisabledMessageField.tsx b/src/components/views/elements/DisabledMessageField.tsx
new file mode 100644
index 00000000000..a51575e9b3a
--- /dev/null
+++ b/src/components/views/elements/DisabledMessageField.tsx
@@ -0,0 +1,44 @@
+import { useAtom } from "jotai";
+import React from "react";
+import { Room } from "matrix-js-sdk/src/matrix";
+
+import { minimumTokenThresholdAtom } from "../../../atoms";
+import { _t } from "../../../languageHandler";
+import { useVerifiedRoom } from "../../../hooks/useVerifiedRoom";
+import { MessageCommunityBotButton } from "./MessageButton";
+
+export function DisabledMessageField({ room }: { room: Room }): JSX.Element {
+ const [allTokens] = useAtom(minimumTokenThresholdAtom);
+ const { isTokenGatedRoom, isCommunityRoom } = useVerifiedRoom(room);
+
+ let tokenThreshold = allTokens[room.name];
+ if (!tokenThreshold) {
+ const tokenName = room.name.match(/\[TG] (.*) \(ct_.*\)/)?.[1];
+ if (isTokenGatedRoom && tokenName) {
+ tokenThreshold = {
+ threshold: "1",
+ symbol: tokenName,
+ };
+ }
+ }
+
+ if (tokenThreshold) {
+ return (
+
+ {_t("composer|no_perms_token_notice", tokenThreshold)}
+ {isCommunityRoom ? (
+ <>
+
+
+ >
+ ) : null}
+
+ );
+ } else {
+ return (
+
+ {_t("composer|no_perms_notice")}
+
+ );
+ }
+}
diff --git a/src/components/views/elements/MessageButton.tsx b/src/components/views/elements/MessageButton.tsx
new file mode 100644
index 00000000000..6d4b272cb90
--- /dev/null
+++ b/src/components/views/elements/MessageButton.tsx
@@ -0,0 +1,52 @@
+import React, { useContext, useState } from "react";
+import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext";
+import AccessibleButton from "matrix-react-sdk/src/components/views/elements/AccessibleButton";
+import { useAtom } from "jotai";
+
+import { Member } from "../right_panel/UserInfo";
+import { Icon as SendMessage } from "../../../../res/themes/superhero/img/icons/send.svg";
+import { BareUser, botAccountsAtom } from "../../../atoms";
+import { openDmForUser } from "../../../utils";
+
+/**
+ * Converts the member to a DirectoryMember and starts a DM with them.
+ */
+
+export const MessageButton = ({
+ member,
+ text = "Send Message",
+}: {
+ member: Member | BareUser;
+ text?: string;
+}): JSX.Element => {
+ const cli = useContext(MatrixClientContext);
+ const [busy, setBusy] = useState(false);
+
+ return (
+ => {
+ if (busy) return;
+ setBusy(true);
+ await openDmForUser(cli, member);
+ setBusy(false);
+ }}
+ className="mx_UserInfo_field"
+ disabled={busy}
+ >
+
+ {text}
+
+ );
+};
+
+export const MessageCommunityBotButton = ({ text = "Send Message" }: { text?: string }): JSX.Element => {
+ const [botAccounts] = useAtom(botAccountsAtom);
+
+ const botUser = {
+ userId: botAccounts?.communityBot || "",
+ rawDisplayName: "Community Bot",
+ } as Member;
+
+ return ;
+};
diff --git a/src/components/views/elements/Pill.tsx b/src/components/views/elements/Pill.tsx
new file mode 100644
index 00000000000..8ddcde2e198
--- /dev/null
+++ b/src/components/views/elements/Pill.tsx
@@ -0,0 +1,194 @@
+/*
+Copyright 2017 - 2019, 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+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, { ReactElement } from "react";
+import classNames from "classnames";
+import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
+import { Tooltip } from "@vector-im/compound-web";
+import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg";
+import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext";
+import { usePermalink } from "matrix-react-sdk/src/hooks/usePermalink";
+import RoomAvatar from "matrix-react-sdk/src/components/views/avatars/RoomAvatar";
+import MemberAvatar from "matrix-react-sdk/src/components/views/avatars/MemberAvatar";
+import { _t } from "matrix-react-sdk/src/languageHandler";
+import { Icon as LinkIcon } from "matrix-react-sdk/res/img/element-icons/room/composer/link.svg";
+import { Icon as UserIcon } from "matrix-react-sdk/res/img/compound/user.svg";
+
+import { Icon as TokenGatedRoomIcon } from "../../../../res/themes/superhero/img/icons/tokengated-room.svg";
+import { isVerifiedRoom } from "../../../hooks/useVerifiedRoom";
+import { Icon as CommunityRoomIcon } from "../../../../res/themes/superhero/img/icons/community-room.svg";
+import { getSafeRoomName } from "../../../hooks/useRoomName";
+
+export enum PillType {
+ UserMention = "TYPE_USER_MENTION",
+ RoomMention = "TYPE_ROOM_MENTION",
+ AtRoomMention = "TYPE_AT_ROOM_MENTION", // '@room' mention
+ EventInSameRoom = "TYPE_EVENT_IN_SAME_ROOM",
+ EventInOtherRoom = "TYPE_EVENT_IN_OTHER_ROOM",
+}
+
+export const pillRoomNotifPos = (text: string | null): number => {
+ return text?.indexOf("@room") ?? -1;
+};
+
+export const pillRoomNotifLen = (): number => {
+ return "@room".length;
+};
+
+const linkIcon = ;
+
+const PillRoomAvatar: React.FC<{
+ shouldShowPillAvatar: boolean;
+ room: Room | null;
+}> = ({ shouldShowPillAvatar, room }) => {
+ if (!shouldShowPillAvatar) {
+ return null;
+ }
+
+ if (room) {
+ return ;
+ }
+ return linkIcon;
+};
+
+const PillMemberAvatar: React.FC<{
+ shouldShowPillAvatar: boolean;
+ member: RoomMember | null;
+}> = ({ shouldShowPillAvatar, member }) => {
+ if (!shouldShowPillAvatar) {
+ return null;
+ }
+
+ if (member) {
+ return ;
+ }
+ return ;
+};
+
+export interface PillProps {
+ // The Type of this Pill. If url is given, this is auto-detected.
+ type?: PillType;
+ // The URL to pillify (no validation is done)
+ url?: string;
+ /** Whether the pill is in a message. It will act as a link then. */
+ inMessage?: boolean;
+ // The room in which this pill is being rendered
+ room?: Room;
+ // Whether to include an avatar in the pill
+ shouldShowPillAvatar?: boolean;
+}
+
+export const Pill: React.FC = ({ type: propType, url, inMessage, room, shouldShowPillAvatar = true }) => {
+ const { event, member, onClick, resourceId, targetRoom, text, type } = usePermalink({
+ room,
+ type: propType,
+ url,
+ });
+
+ if (!type || !text) {
+ return null;
+ }
+
+ const classes = classNames("mx_Pill", {
+ mx_AtRoomPill: type === PillType.AtRoomMention,
+ mx_RoomPill: type === PillType.RoomMention,
+ mx_SpacePill: type === "space" || targetRoom?.isSpaceRoom(),
+ mx_UserPill: type === PillType.UserMention,
+ mx_UserPill_me: resourceId === MatrixClientPeg.safeGet().getUserId(),
+ mx_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom,
+ });
+
+ let avatar: ReactElement | null = null;
+ let pillText: string | null = text;
+
+ switch (type) {
+ case PillType.EventInOtherRoom:
+ {
+ avatar = ;
+ pillText = _t("pill|permalink_other_room", {
+ room: text,
+ });
+ }
+ break;
+ case PillType.EventInSameRoom:
+ {
+ if (event) {
+ avatar = ;
+ pillText = _t("pill|permalink_this_room", {
+ user: text,
+ });
+ } else {
+ avatar = linkIcon;
+ pillText = _t("common|message");
+ }
+ }
+ break;
+ case PillType.AtRoomMention:
+ case PillType.RoomMention:
+ case "space":
+ avatar = ;
+ break;
+ case PillType.UserMention:
+ avatar = ;
+ break;
+ default:
+ return null;
+ }
+
+ const isAnchor = !!inMessage && !!url;
+ const { isCommunityRoom, isTokenGatedRoom } = isVerifiedRoom(pillText);
+
+ const renderPillText = (): ReactElement => {
+ return (
+ <>
+ {isCommunityRoom ? (
+ <>
+
+ $
+ >
+ ) : null}
+ {isTokenGatedRoom ? (
+
+ ) : null}
+ {getSafeRoomName(pillText || "")}
+ >
+ );
+ };
+ return (
+
+
+
+ {isAnchor ? (
+
+ {avatar}
+ {renderPillText()}
+
+ ) : (
+
+ {avatar}
+ {renderPillText()}
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/views/elements/RoomName.tsx b/src/components/views/elements/RoomName.tsx
new file mode 100644
index 00000000000..66139b6e64c
--- /dev/null
+++ b/src/components/views/elements/RoomName.tsx
@@ -0,0 +1,80 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { IPublicRoomsChunkRoom, Room } from "matrix-js-sdk/src/matrix";
+import React, { useCallback, useMemo } from "react";
+
+import { Icon as TokenGatedRoomIcon } from "../../../../res/themes/superhero/img/icons/tokengated-room.svg";
+import { Icon as CommunityRoomIcon } from "../../../../res/themes/superhero/img/icons/community-room.svg";
+import { useRoomName } from "../../../hooks/useRoomName";
+import { useVerifiedRoom } from "../../../hooks/useVerifiedRoom";
+import { UserVerifiedBadge } from "./UserVerifiedBadge";
+import { BotVerifiedBadge } from "./BotVerifiedBadge";
+
+interface IProps {
+ room?: Room | IPublicRoomsChunkRoom;
+ children?(name: JSX.Element): JSX.Element;
+ maxLength?: number;
+}
+
+export const RoomName = ({ room, children, maxLength }: IProps): JSX.Element => {
+ const roomName = useRoomName(room);
+ const { isTokenGatedRoom, isCommunityRoom } = useVerifiedRoom(room);
+
+ const roomUser: string | undefined = useMemo(() => {
+ // check if this is a DM room and if so, return the other user's ID
+ const dmUserId = (room as Room)?.guessDMUserId?.();
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ // need to access the private summaryHeroes property, to know if it's a DM room
+ if (!(room as Room)?.summaryHeroes) {
+ return undefined;
+ }
+
+ return dmUserId && dmUserId !== (room as Room).myUserId ? dmUserId : undefined;
+ }, [room]);
+
+ const truncatedRoomName = useMemo(() => {
+ if (maxLength && roomName.length > maxLength) {
+ return `${roomName.substring(0, maxLength)}...`;
+ }
+ return roomName;
+ }, [roomName, maxLength]);
+
+ const renderRoomName = useCallback(
+ () => (
+
+ {isCommunityRoom && (
+ <>
+
+ $
+ >
+ )}
+ {isTokenGatedRoom && }
+ {truncatedRoomName}
+ {roomUser && !isTokenGatedRoom && !isCommunityRoom ? : null}
+ {roomUser && !isTokenGatedRoom && !isCommunityRoom ? : null}
+
+ ),
+ [truncatedRoomName, isCommunityRoom, isTokenGatedRoom, roomUser],
+ );
+
+ if (children) return children(renderRoomName());
+ return renderRoomName();
+};
+
+export default RoomName;
diff --git a/src/components/views/elements/UserVerifiedBadge.tsx b/src/components/views/elements/UserVerifiedBadge.tsx
new file mode 100644
index 00000000000..799be8f8638
--- /dev/null
+++ b/src/components/views/elements/UserVerifiedBadge.tsx
@@ -0,0 +1,14 @@
+import React from "react";
+
+import { Icon as VerifiedIcon } from "../../../../res/themes/superhero/img/icons/verified.svg";
+import { useVerifiedUser } from "../../../hooks/useVerifiedUser";
+
+export interface UserVerifiedBadgeProps {
+ userId: string;
+}
+
+export const UserVerifiedBadge = ({ userId }: UserVerifiedBadgeProps): JSX.Element => {
+ const isVerifiedUser = useVerifiedUser(userId);
+
+ return <>{isVerifiedUser && }>;
+};
diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx
new file mode 100644
index 00000000000..fdfa87a5b80
--- /dev/null
+++ b/src/components/views/right_panel/UserInfo.tsx
@@ -0,0 +1,1752 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017, 2018 Vector Creations Ltd
+Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
+Copyright 2019, 2020 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, { ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
+import classNames from "classnames";
+import {
+ ClientEvent,
+ MatrixClient,
+ RoomMember,
+ Room,
+ RoomStateEvent,
+ MatrixEvent,
+ User,
+ Device,
+ EventType,
+} from "matrix-js-sdk/src/matrix";
+import { UserVerificationStatus, VerificationRequest } from "matrix-js-sdk/src/crypto-api";
+import { logger } from "matrix-js-sdk/src/logger";
+import { CryptoEvent } from "matrix-js-sdk/src/crypto";
+import { UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning";
+import dis from "matrix-react-sdk/src/dispatcher/dispatcher";
+import Modal from "matrix-react-sdk/src/Modal";
+import { _t, UserFriendlyError } from "matrix-react-sdk/src/languageHandler";
+import DMRoomMap from "matrix-react-sdk/src/utils/DMRoomMap";
+import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton";
+import SdkConfig from "matrix-react-sdk/src/SdkConfig";
+import MultiInviter from "matrix-react-sdk/src/utils/MultiInviter";
+import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg";
+import E2EIcon from "matrix-react-sdk/src/components/views/rooms/E2EIcon";
+import { useTypedEventEmitter } from "matrix-react-sdk/src/hooks/useEventEmitter";
+import { textualPowerLevel } from "matrix-react-sdk/src/Roles";
+import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext";
+import { RightPanelPhases } from "matrix-react-sdk/src/stores/right-panel/RightPanelStorePhases";
+import EncryptionPanel from "matrix-react-sdk/src/components/views/right_panel/EncryptionPanel";
+import { useAsyncMemo } from "matrix-react-sdk/src/hooks/useAsyncMemo";
+import { legacyVerifyUser, verifyDevice, verifyUser } from "matrix-react-sdk/src/verification";
+import { Action } from "matrix-react-sdk/src/dispatcher/actions";
+import { useIsEncrypted } from "matrix-react-sdk/src/hooks/useIsEncrypted";
+import BaseCard from "matrix-react-sdk/src/components/views/right_panel/BaseCard";
+import { E2EStatus } from "matrix-react-sdk/src/utils/ShieldUtils";
+import ImageView from "matrix-react-sdk/src/components/views/elements/ImageView";
+import Spinner from "matrix-react-sdk/src/components/views/elements/Spinner";
+import PowerSelector from "matrix-react-sdk/src/components/views/elements/PowerSelector";
+import MemberAvatar from "matrix-react-sdk/src/components/views/avatars/MemberAvatar";
+import PresenceLabel from "matrix-react-sdk/src/components/views/rooms/PresenceLabel";
+import BulkRedactDialog from "matrix-react-sdk/src/components/views/dialogs/BulkRedactDialog";
+import ShareDialog from "matrix-react-sdk/src/components/views/dialogs/ShareDialog";
+import ErrorDialog from "matrix-react-sdk/src/components/views/dialogs/ErrorDialog";
+import QuestionDialog from "matrix-react-sdk/src/components/views/dialogs/QuestionDialog";
+import ConfirmUserActionDialog from "matrix-react-sdk/src/components/views/dialogs/ConfirmUserActionDialog";
+import RoomAvatar from "matrix-react-sdk/src/components/views/avatars/RoomAvatar";
+import RoomName from "matrix-react-sdk/src/components/views/elements/RoomName";
+import { mediaFromMxc } from "matrix-react-sdk/src/customisations/Media";
+import { ComposerInsertPayload } from "matrix-react-sdk/src/dispatcher/payloads/ComposerInsertPayload";
+import ConfirmSpaceUserActionDialog from "matrix-react-sdk/src/components/views/dialogs/ConfirmSpaceUserActionDialog";
+import { bulkSpaceBehaviour } from "matrix-react-sdk/src/utils/space";
+import { shouldShowComponent } from "matrix-react-sdk/src/customisations/helpers/UIComponents";
+import { UIComponent } from "matrix-react-sdk/src/settings/UIFeature";
+import { TimelineRenderingType } from "matrix-react-sdk/src/contexts/RoomContext";
+import RightPanelStore from "matrix-react-sdk/src/stores/right-panel/RightPanelStore";
+import { IRightPanelCardState } from "matrix-react-sdk/src/stores/right-panel/RightPanelStoreIPanelState";
+import UserIdentifierCustomisations from "matrix-react-sdk/src/customisations/UserIdentifier";
+import PosthogTrackers from "matrix-react-sdk/src/PosthogTrackers";
+import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload";
+import { SdkContextClass } from "matrix-react-sdk/src/contexts/SDKContext";
+import { asyncSome } from "matrix-react-sdk/src/utils/arrays";
+import UIStore from "matrix-react-sdk/src/stores/UIStore";
+
+import { UserVerifiedBadge } from "../elements/UserVerifiedBadge";
+import { MessageButton } from "../elements/MessageButton";
+import { BotVerifiedBadge } from "../elements/BotVerifiedBadge";
+
+export interface IDevice extends Device {
+ ambiguous?: boolean;
+}
+
+export const disambiguateDevices = (devices: IDevice[]): void => {
+ const names = Object.create(null);
+ for (let i = 0; i < devices.length; i++) {
+ const name = devices[i].displayName ?? "";
+ const indexList = names[name] || [];
+ indexList.push(i);
+ names[name] = indexList;
+ }
+ for (const name in names) {
+ if (names[name].length > 1) {
+ names[name].forEach((j: number) => {
+ devices[j].ambiguous = true;
+ });
+ }
+ }
+};
+
+export const getE2EStatus = async (
+ cli: MatrixClient,
+ userId: string,
+ devices: IDevice[],
+): Promise => {
+ const crypto = cli.getCrypto();
+ if (!crypto) return undefined;
+ const isMe = userId === cli.getUserId();
+ const userTrust = await crypto.getUserVerificationStatus(userId);
+ if (!userTrust.isCrossSigningVerified()) {
+ return userTrust.wasCrossSigningVerified() ? E2EStatus.Warning : E2EStatus.Normal;
+ }
+
+ const anyDeviceUnverified = await asyncSome(devices, async (device) => {
+ const { deviceId } = device;
+ // For your own devices, we use the stricter check of cross-signing
+ // verification to encourage everyone to trust their own devices via
+ // cross-signing so that other users can then safely trust you.
+ // For other people's devices, the more general verified check that
+ // includes locally verified devices can be used.
+ const deviceTrust = await crypto.getDeviceVerificationStatus(userId, deviceId);
+ return isMe ? !deviceTrust?.crossSigningVerified : !deviceTrust?.isVerified();
+ });
+ return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified;
+};
+
+type SetUpdating = (updating: boolean) => void;
+
+function useHasCrossSigningKeys(
+ cli: MatrixClient,
+ member: User,
+ canVerify: boolean,
+ setUpdating: SetUpdating,
+): boolean | undefined {
+ return useAsyncMemo(async () => {
+ if (!canVerify) {
+ return undefined;
+ }
+ setUpdating(true);
+ try {
+ return await cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true);
+ } finally {
+ setUpdating(false);
+ }
+ }, [cli, member, canVerify]);
+}
+
+/**
+ * Display one device and the related actions
+ * @param userId current user id
+ * @param device device to display
+ * @param isUserVerified false when the user is not verified
+ * @constructor
+ */
+export function DeviceItem({
+ userId,
+ device,
+ isUserVerified,
+}: {
+ userId: string;
+ device: IDevice;
+ isUserVerified: boolean;
+}): JSX.Element {
+ const cli = useContext(MatrixClientContext);
+ const isMe = userId === cli.getUserId();
+
+ /** is the device verified? */
+ const isVerified = useAsyncMemo(async () => {
+ const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, device.deviceId);
+ if (!deviceTrust) return false;
+
+ // For your own devices, we use the stricter check of cross-signing
+ // verification to encourage everyone to trust their own devices via
+ // cross-signing so that other users can then safely trust you.
+ // For other people's devices, the more general verified check that
+ // includes locally verified devices can be used.
+ return isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified();
+ }, [cli, userId, device]);
+
+ const classes = classNames("mx_UserInfo_device", {
+ mx_UserInfo_device_verified: isVerified,
+ mx_UserInfo_device_unverified: !isVerified,
+ });
+ const iconClasses = classNames("mx_E2EIcon", {
+ mx_E2EIcon_normal: !isUserVerified,
+ mx_E2EIcon_verified: isVerified,
+ mx_E2EIcon_warning: isUserVerified && !isVerified,
+ });
+
+ const onDeviceClick = (): void => {
+ const user = cli.getUser(userId);
+ if (user) {
+ verifyDevice(cli, user, device);
+ }
+ };
+
+ let deviceName;
+ if (!device.displayName?.trim()) {
+ deviceName = device.deviceId;
+ } else {
+ deviceName = device.ambiguous ? device.displayName + " (" + device.deviceId + ")" : device.displayName;
+ }
+
+ let trustedLabel: string | undefined;
+ if (isUserVerified) trustedLabel = isVerified ? _t("common|trusted") : _t("common|not_trusted");
+
+ if (isVerified === undefined) {
+ // we're still deciding if the device is verified
+ return
;
+ } else if (isVerified) {
+ return (
+
+
+
{deviceName}
+
{trustedLabel}
+
+ );
+ } else {
+ return (
+
+
+ {deviceName}
+ {trustedLabel}
+
+ );
+ }
+}
+
+/**
+ * Display a list of devices
+ * @param devices devices to display
+ * @param userId current user id
+ * @param loading displays a spinner instead of the device section
+ * @param isUserVerified is false when
+ * - the user is not verified, or
+ * - `MatrixClient.getCrypto.getUserVerificationStatus` async call is in progress (in which case `loading` will also be `true`)
+ * @constructor
+ */
+function DevicesSection({
+ devices,
+ userId,
+ loading,
+ isUserVerified,
+}: {
+ devices: IDevice[];
+ userId: string;
+ loading: boolean;
+ isUserVerified: boolean;
+}): JSX.Element {
+ const cli = useContext(MatrixClientContext);
+
+ const [isExpanded, setExpanded] = useState(false);
+
+ const deviceTrusts = useAsyncMemo(() => {
+ const cryptoApi = cli.getCrypto();
+ if (!cryptoApi) return Promise.resolve(undefined);
+ return Promise.all(devices.map((d) => cryptoApi.getDeviceVerificationStatus(userId, d.deviceId)));
+ }, [cli, userId, devices]);
+
+ if (loading || deviceTrusts === undefined) {
+ // still loading
+ return ;
+ }
+ const isMe = userId === cli.getUserId();
+
+ let expandSectionDevices: IDevice[] = [];
+ const unverifiedDevices: IDevice[] = [];
+
+ let expandCountCaption;
+ let expandHideCaption;
+ let expandIconClasses = "mx_E2EIcon";
+
+ if (isUserVerified) {
+ for (let i = 0; i < devices.length; ++i) {
+ const device = devices[i];
+ const deviceTrust = deviceTrusts[i];
+ // For your own devices, we use the stricter check of cross-signing
+ // verification to encourage everyone to trust their own devices via
+ // cross-signing so that other users can then safely trust you.
+ // For other people's devices, the more general verified check that
+ // includes locally verified devices can be used.
+ const isVerified = deviceTrust && (isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified());
+
+ if (isVerified) {
+ expandSectionDevices.push(device);
+ } else {
+ unverifiedDevices.push(device);
+ }
+ }
+ expandCountCaption = _t("user_info|count_of_verified_sessions", { count: expandSectionDevices.length });
+ expandHideCaption = _t("user_info|hide_verified_sessions");
+ expandIconClasses += " mx_E2EIcon_verified";
+ } else {
+ expandSectionDevices = devices;
+ expandCountCaption = _t("user_info|count_of_sessions", { count: devices.length });
+ expandHideCaption = _t("user_info|hide_sessions");
+ expandIconClasses += " mx_E2EIcon_normal";
+ }
+
+ let expandButton;
+ if (expandSectionDevices.length) {
+ if (isExpanded) {
+ expandButton = (
+ setExpanded(false)}>
+ {expandHideCaption}
+
+ );
+ } else {
+ expandButton = (
+ setExpanded(true)}>
+
+ {expandCountCaption}
+
+ );
+ }
+ }
+
+ let deviceList = unverifiedDevices.map((device, i) => {
+ return ;
+ });
+ if (isExpanded) {
+ const keyStart = unverifiedDevices.length;
+ deviceList = deviceList.concat(
+ expandSectionDevices.map((device, i) => {
+ return (
+
+ );
+ }),
+ );
+ }
+
+ return (
+
+
{deviceList}
+
{expandButton}
+
+ );
+}
+
+export const UserOptionsSection: React.FC<{
+ member: Member;
+ isIgnored: boolean;
+ canInvite: boolean;
+ isSpace?: boolean;
+}> = ({ member, isIgnored, canInvite, isSpace }) => {
+ const cli = useContext(MatrixClientContext);
+
+ let ignoreButton: JSX.Element | undefined;
+ let insertPillButton: JSX.Element | undefined;
+ let inviteUserButton: JSX.Element | undefined;
+ let readReceiptButton: JSX.Element | undefined;
+
+ const isMe = member.userId === cli.getUserId();
+ const onShareUserClick = (): void => {
+ Modal.createDialog(ShareDialog, {
+ target: member,
+ });
+ };
+
+ const unignore = useCallback(() => {
+ const ignoredUsers = cli.getIgnoredUsers();
+ const index = ignoredUsers.indexOf(member.userId);
+ if (index !== -1) ignoredUsers.splice(index, 1);
+ cli.setIgnoredUsers(ignoredUsers);
+ }, [cli, member]);
+
+ const ignore = useCallback(async () => {
+ const name = (member instanceof User ? member.displayName : member.name) || member.userId;
+ const { finished } = Modal.createDialog(QuestionDialog, {
+ title: _t("user_info|ignore_confirm_title", { user: name }),
+ description: {_t("user_info|ignore_confirm_description")}
,
+ button: _t("action|ignore"),
+ });
+ const [confirmed] = await finished;
+
+ if (confirmed) {
+ const ignoredUsers = cli.getIgnoredUsers();
+ ignoredUsers.push(member.userId);
+ cli.setIgnoredUsers(ignoredUsers);
+ }
+ }, [cli, member]);
+
+ // Only allow the user to ignore the user if its not ourselves
+ // same goes for jumping to read receipt
+ if (!isMe) {
+ ignoreButton = (
+
+ {isIgnored ? _t("action|unignore") : _t("action|ignore")}
+
+ );
+
+ if (member instanceof RoomMember && member.roomId && !isSpace) {
+ const onReadReceiptButton = function (): void {
+ const room = cli.getRoom(member.roomId);
+ dis.dispatch({
+ action: Action.ViewRoom,
+ highlighted: true,
+ // this could return null, the default prevents a type error
+ event_id: room?.getEventReadUpTo(member.userId) || undefined,
+ room_id: member.roomId,
+ metricsTrigger: undefined, // room doesn't change
+ });
+ };
+
+ const onInsertPillButton = function (): void {
+ dis.dispatch({
+ action: Action.ComposerInsert,
+ userId: member.userId,
+ timelineRenderingType: TimelineRenderingType.Room,
+ });
+ };
+
+ const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : undefined;
+ if (room?.getEventReadUpTo(member.userId)) {
+ readReceiptButton = (
+
+ {_t("user_info|jump_to_rr_button")}
+
+ );
+ }
+
+ insertPillButton = (
+
+ {_t("action|mention")}
+
+ );
+ }
+
+ if (
+ member instanceof RoomMember &&
+ canInvite &&
+ (member?.membership ?? "leave") === "leave" &&
+ shouldShowComponent(UIComponent.InviteUsers)
+ ) {
+ const roomId = member && member.roomId ? member.roomId : SdkContextClass.instance.roomViewStore.getRoomId();
+ const onInviteUserButton = async (ev: ButtonEvent): Promise => {
+ try {
+ // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user.
+ const inviter = new MultiInviter(cli, roomId || "");
+ await inviter.invite([member.userId]).then(() => {
+ if (inviter.getCompletionState(member.userId) !== "invited") {
+ const errorStringFromInviterUtility = inviter.getErrorText(member.userId);
+ if (errorStringFromInviterUtility) {
+ throw new Error(errorStringFromInviterUtility);
+ } else {
+ throw new UserFriendlyError("slash_command|invite_failed", {
+ user: member.userId,
+ roomId,
+ cause: undefined,
+ });
+ }
+ }
+ });
+ } catch (err) {
+ const description = err instanceof Error ? err.message : _t("invite|failed_generic");
+
+ Modal.createDialog(ErrorDialog, {
+ title: _t("invite|failed_title"),
+ description,
+ });
+ }
+
+ PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoInviteButton", ev);
+ };
+
+ inviteUserButton = (
+
+ {_t("action|invite")}
+
+ );
+ }
+ }
+
+ const shareUserButton = (
+
+ {_t("user_info|share_button")}
+
+ );
+
+ return (
+
+
{_t("common|options")}
+
+ {readReceiptButton}
+ {shareUserButton}
+ {insertPillButton}
+ {inviteUserButton}
+ {ignoreButton}
+
+
+ );
+};
+
+export const warnSelfDemote = async (isSpace: boolean): Promise => {
+ const { finished } = Modal.createDialog(QuestionDialog, {
+ title: _t("user_info|demote_self_confirm_title"),
+ description: (
+
+ {isSpace
+ ? _t("user_info|demote_self_confirm_description_space")
+ : _t("user_info|demote_self_confirm_room")}
+
+ ),
+ button: _t("user_info|demote_button"),
+ });
+
+ const [confirmed] = await finished;
+ return !!confirmed;
+};
+
+const GenericAdminToolsContainer: React.FC<{
+ children: ReactNode;
+}> = ({ children }) => {
+ return (
+
+
{_t("user_info|admin_tools_section")}
+
{children}
+
+ );
+};
+
+interface IPowerLevelsContent {
+ events?: Record;
+ // eslint-disable-next-line camelcase
+ users_default?: number;
+ // eslint-disable-next-line camelcase
+ events_default?: number;
+ // eslint-disable-next-line camelcase
+ state_default?: number;
+ ban?: number;
+ kick?: number;
+ redact?: number;
+}
+
+export const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent): boolean => {
+ if (!powerLevelContent || !member) return false;
+
+ const levelToSend =
+ (powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) ||
+ powerLevelContent.events_default;
+
+ // levelToSend could be undefined as .events_default is optional. Coercing in this case using
+ // Number() would always return false, so this preserves behaviour
+ // FIXME: per the spec, if `events_default` is unset, it defaults to zero. If
+ // the member has a negative powerlevel, this will give an incorrect result.
+ if (levelToSend === undefined) return false;
+
+ return member.powerLevel < levelToSend;
+};
+
+export const getPowerLevels = (room: Room): IPowerLevelsContent =>
+ room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {};
+
+export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsContent => {
+ const [powerLevels, setPowerLevels] = useState(getPowerLevels(room));
+
+ const update = useCallback(
+ (ev?: MatrixEvent) => {
+ if (!room) return;
+ if (ev && ev.getType() !== EventType.RoomPowerLevels) return;
+ setPowerLevels(getPowerLevels(room));
+ },
+ [room],
+ );
+
+ useTypedEventEmitter(cli, RoomStateEvent.Events, update);
+ useEffect(() => {
+ update();
+ return () => {
+ setPowerLevels({});
+ };
+ }, [update]);
+ return powerLevels;
+};
+
+interface IBaseProps {
+ member: RoomMember;
+ isUpdating: boolean;
+ startUpdating(): void;
+ stopUpdating(): void;
+}
+
+export const RoomKickButton = ({
+ room,
+ member,
+ isUpdating,
+ startUpdating,
+ stopUpdating,
+}: Omit): JSX.Element | null => {
+ const cli = useContext(MatrixClientContext);
+
+ // check if user can be kicked/disinvited
+ if (member.membership !== "invite" && member.membership !== "join") return <>>;
+
+ const onKick = async (): Promise => {
+ if (isUpdating) return; // only allow one operation at a time
+ startUpdating();
+
+ const commonProps = {
+ member,
+ action: room.isSpaceRoom()
+ ? member.membership === "invite"
+ ? _t("user_info|disinvite_button_space")
+ : _t("user_info|kick_button_space")
+ : member.membership === "invite"
+ ? _t("user_info|disinvite_button_room")
+ : _t("user_info|kick_button_room"),
+ title:
+ member.membership === "invite"
+ ? _t("user_info|disinvite_button_room_name", { roomName: room.name })
+ : _t("user_info|kick_button_room_name", { roomName: room.name }),
+ askReason: member.membership === "join",
+ danger: true,
+ };
+
+ let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;
+
+ if (room.isSpaceRoom()) {
+ ({ finished } = Modal.createDialog(
+ ConfirmSpaceUserActionDialog,
+ {
+ ...commonProps,
+ space: room,
+ spaceChildFilter: (child: Room) => {
+ // Return true if the target member is not banned and we have sufficient PL to ban them
+ const myMember = child.getMember(cli.credentials.userId || "");
+ const theirMember = child.getMember(member.userId);
+ return (
+ !!myMember &&
+ !!theirMember &&
+ theirMember.membership === member.membership &&
+ myMember.powerLevel > theirMember.powerLevel &&
+ child.currentState.hasSufficientPowerLevelFor("kick", myMember.powerLevel)
+ );
+ },
+ allLabel: _t("user_info|kick_button_space_everything"),
+ specificLabel: _t("user_info|kick_space_specific"),
+ warningMessage: _t("user_info|kick_space_warning"),
+ },
+ "mx_ConfirmSpaceUserActionDialog_wrapper",
+ ));
+ } else {
+ ({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
+ }
+
+ const [proceed, reason, rooms = []] = await finished;
+ if (!proceed) {
+ stopUpdating();
+ return;
+ }
+
+ bulkSpaceBehaviour(room, rooms, (room) => cli.kick(room.roomId, member.userId, reason || undefined))
+ .then(
+ () => {
+ // NO-OP; rely on the m.room.member event coming down else we could
+ // get out of sync if we force setState here!
+ logger.log("Kick success");
+ },
+ function (err) {
+ logger.error("Kick error: " + err);
+ Modal.createDialog(ErrorDialog, {
+ title: _t("user_info|error_kicking_user"),
+ description: err && err.message ? err.message : "Operation failed",
+ });
+ },
+ )
+ .finally(() => {
+ stopUpdating();
+ });
+ };
+
+ const kickLabel = room.isSpaceRoom()
+ ? member.membership === "invite"
+ ? _t("user_info|disinvite_button_space")
+ : _t("user_info|kick_button_space")
+ : member.membership === "invite"
+ ? _t("user_info|disinvite_button_room")
+ : _t("user_info|kick_button_room");
+
+ return (
+
+ {kickLabel}
+
+ );
+};
+
+const RedactMessagesButton: React.FC = ({ member }) => {
+ const cli = useContext(MatrixClientContext);
+
+ const onRedactAllMessages = (): void => {
+ const room = cli.getRoom(member.roomId);
+ if (!room) return;
+
+ Modal.createDialog(BulkRedactDialog, {
+ matrixClient: cli,
+ room,
+ member,
+ });
+ };
+
+ return (
+
+ {_t("user_info|redact_button")}
+
+ );
+};
+
+export const BanToggleButton = ({
+ room,
+ member,
+ isUpdating,
+ startUpdating,
+ stopUpdating,
+}: Omit): JSX.Element => {
+ const cli = useContext(MatrixClientContext);
+
+ const isBanned = member.membership === "ban";
+ const onBanOrUnban = async (): Promise => {
+ if (isUpdating) return; // only allow one operation at a time
+ startUpdating();
+
+ const commonProps = {
+ member,
+ action: room.isSpaceRoom()
+ ? isBanned
+ ? _t("user_info|unban_button_space")
+ : _t("user_info|ban_button_space")
+ : isBanned
+ ? _t("user_info|unban_button_room")
+ : _t("user_info|ban_button_room"),
+ title: isBanned
+ ? _t("user_info|unban_room_confirm_title", { roomName: room.name })
+ : _t("user_info|ban_room_confirm_title", { roomName: room.name }),
+ askReason: !isBanned,
+ danger: !isBanned,
+ };
+
+ let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;
+
+ if (room.isSpaceRoom()) {
+ ({ finished } = Modal.createDialog(
+ ConfirmSpaceUserActionDialog,
+ {
+ ...commonProps,
+ space: room,
+ spaceChildFilter: isBanned
+ ? (child: Room): boolean => {
+ // Return true if the target member is banned and we have sufficient PL to unban
+ const myMember = child.getMember(cli.credentials.userId || "");
+ const theirMember = child.getMember(member.userId);
+ return (
+ !!myMember &&
+ !!theirMember &&
+ theirMember.membership === "ban" &&
+ myMember.powerLevel > theirMember.powerLevel &&
+ child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
+ );
+ }
+ : (child: Room): boolean => {
+ // Return true if the target member isn't banned and we have sufficient PL to ban
+ const myMember = child.getMember(cli.credentials.userId || "");
+ const theirMember = child.getMember(member.userId);
+ return (
+ !!myMember &&
+ !!theirMember &&
+ theirMember.membership !== "ban" &&
+ myMember.powerLevel > theirMember.powerLevel &&
+ child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
+ );
+ },
+ allLabel: isBanned ? _t("user_info|unban_space_everything") : _t("user_info|ban_space_everything"),
+ specificLabel: isBanned ? _t("user_info|unban_space_specific") : _t("user_info|ban_space_specific"),
+ warningMessage: isBanned ? _t("user_info|unban_space_warning") : _t("user_info|kick_space_warning"),
+ },
+ "mx_ConfirmSpaceUserActionDialog_wrapper",
+ ));
+ } else {
+ ({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
+ }
+
+ const [proceed, reason, rooms = []] = await finished;
+ if (!proceed) {
+ stopUpdating();
+ return;
+ }
+
+ const fn = (roomId: string): Promise => {
+ if (isBanned) {
+ return cli.unban(roomId, member.userId);
+ } else {
+ return cli.ban(roomId, member.userId, reason || undefined);
+ }
+ };
+
+ bulkSpaceBehaviour(room, rooms, (room) => fn(room.roomId))
+ .then(
+ () => {
+ // NO-OP; rely on the m.room.member event coming down else we could
+ // get out of sync if we force setState here!
+ logger.log("Ban success");
+ },
+ function (err) {
+ logger.error("Ban error: " + err);
+ Modal.createDialog(ErrorDialog, {
+ title: _t("common|error"),
+ description: _t("user_info|error_ban_user"),
+ });
+ },
+ )
+ .finally(() => {
+ stopUpdating();
+ });
+ };
+
+ let label = room.isSpaceRoom() ? _t("user_info|ban_button_space") : _t("user_info|ban_button_room");
+ if (isBanned) {
+ label = room.isSpaceRoom() ? _t("user_info|unban_button_space") : _t("user_info|unban_button_room");
+ }
+
+ const classes = classNames("mx_UserInfo_field", {
+ mx_UserInfo_destructive: !isBanned,
+ });
+
+ return (
+
+ {label}
+
+ );
+};
+
+interface IBaseRoomProps extends IBaseProps {
+ room: Room;
+ powerLevels: IPowerLevelsContent;
+ children?: ReactNode;
+}
+
+// We do not show a Mute button for ourselves so it doesn't need to handle warning self demotion
+const MuteToggleButton: React.FC = ({
+ member,
+ room,
+ powerLevels,
+ isUpdating,
+ startUpdating,
+ stopUpdating,
+}) => {
+ const cli = useContext(MatrixClientContext);
+
+ // Don't show the mute/unmute option if the user is not in the room
+ if (member.membership !== "join") return null;
+
+ const muted = isMuted(member, powerLevels);
+ const onMuteToggle = async (): Promise => {
+ if (isUpdating) return; // only allow one operation at a time
+ startUpdating();
+
+ const roomId = member.roomId;
+ const target = member.userId;
+
+ const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
+ const powerLevels = powerLevelEvent?.getContent();
+ const levelToSend = powerLevels?.events?.["m.room.message"] ?? powerLevels?.events_default;
+ let level;
+ if (muted) {
+ // unmute
+ level = levelToSend;
+ } else {
+ // mute
+ level = levelToSend - 1;
+ }
+ level = parseInt(level);
+
+ if (isNaN(level)) {
+ stopUpdating();
+ return;
+ }
+
+ cli.setPowerLevel(roomId, target, level, powerLevelEvent)
+ .then(
+ () => {
+ // NO-OP; rely on the m.room.member event coming down else we could
+ // get out of sync if we force setState here!
+ logger.log("Mute toggle success");
+ },
+ function (err) {
+ logger.error("Mute error: " + err);
+ Modal.createDialog(ErrorDialog, {
+ title: _t("common|error"),
+ description: _t("user_info|error_mute_user"),
+ });
+ },
+ )
+ .finally(() => {
+ stopUpdating();
+ });
+ };
+
+ const classes = classNames("mx_UserInfo_field", {
+ mx_UserInfo_destructive: !muted,
+ });
+
+ const muteLabel = muted ? _t("common|unmute") : _t("common|mute");
+ return (
+
+ {muteLabel}
+
+ );
+};
+
+export const RoomAdminToolsContainer: React.FC = ({
+ room,
+ children,
+ member,
+ isUpdating,
+ startUpdating,
+ stopUpdating,
+ powerLevels,
+}) => {
+ const cli = useContext(MatrixClientContext);
+ let kickButton;
+ let banButton;
+ let muteButton;
+ let redactButton;
+
+ const editPowerLevel =
+ (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || powerLevels.state_default;
+
+ // if these do not exist in the event then they should default to 50 as per the spec
+ const { ban: banPowerLevel = 50, kick: kickPowerLevel = 50, redact: redactPowerLevel = 50 } = powerLevels;
+
+ const me = room.getMember(cli.getUserId() || "");
+ if (!me) {
+ // we aren't in the room, so return no admin tooling
+ return
;
+ }
+
+ const isMe = me.userId === member.userId;
+ const canAffectUser = member.powerLevel < me.powerLevel || isMe;
+
+ if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) {
+ kickButton = (
+
+ );
+ }
+ if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) {
+ redactButton = (
+
+ );
+ }
+ if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) {
+ banButton = (
+
+ );
+ }
+ if (!isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom()) {
+ muteButton = (
+
+ );
+ }
+
+ if (kickButton || banButton || muteButton || redactButton || children) {
+ return (
+
+ {muteButton}
+ {kickButton}
+ {banButton}
+ {redactButton}
+ {children}
+
+ );
+ }
+
+ return
;
+};
+
+const useIsSynapseAdmin = (cli?: MatrixClient): boolean => {
+ return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false);
+};
+
+const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => {
+ return useAsyncMemo(
+ async () => {
+ return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
+ },
+ [cli],
+ false,
+ );
+};
+
+interface IRoomPermissions {
+ modifyLevelMax: number;
+ canEdit: boolean;
+ canInvite: boolean;
+}
+
+function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IRoomPermissions {
+ const [roomPermissions, setRoomPermissions] = useState({
+ // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL
+ modifyLevelMax: -1,
+ canEdit: false,
+ canInvite: false,
+ });
+
+ const updateRoomPermissions = useCallback(() => {
+ const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
+ if (!powerLevels) return;
+
+ const me = room.getMember(cli.getUserId() || "");
+ if (!me) return;
+
+ const them = user;
+ const isMe = me.userId === them.userId;
+ const canAffectUser = them.powerLevel < me.powerLevel || isMe;
+
+ let modifyLevelMax = -1;
+ if (canAffectUser) {
+ const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50;
+ if (me.powerLevel >= editPowerLevel) {
+ modifyLevelMax = me.powerLevel;
+ }
+ }
+
+ setRoomPermissions({
+ canInvite: me.powerLevel >= (powerLevels.invite ?? 0),
+ canEdit: modifyLevelMax >= 0,
+ modifyLevelMax,
+ });
+ }, [cli, user, room]);
+
+ useTypedEventEmitter(cli, RoomStateEvent.Update, updateRoomPermissions);
+ useEffect(() => {
+ updateRoomPermissions();
+ return () => {
+ setRoomPermissions({
+ modifyLevelMax: -1,
+ canEdit: false,
+ canInvite: false,
+ });
+ };
+ }, [updateRoomPermissions]);
+
+ return roomPermissions;
+}
+
+const PowerLevelSection: React.FC<{
+ user: RoomMember;
+ room: Room;
+ roomPermissions: IRoomPermissions;
+ powerLevels: IPowerLevelsContent;
+}> = ({ user, room, roomPermissions, powerLevels }) => {
+ if (roomPermissions.canEdit) {
+ return ;
+ } else {
+ const powerLevelUsersDefault = powerLevels.users_default || 0;
+ const powerLevel = user.powerLevel;
+ const role = textualPowerLevel(powerLevel, powerLevelUsersDefault);
+ return (
+
+ );
+ }
+};
+
+export const PowerLevelEditor: React.FC<{
+ user: RoomMember;
+ room: Room;
+ roomPermissions: IRoomPermissions;
+}> = ({ user, room, roomPermissions }) => {
+ const cli = useContext(MatrixClientContext);
+
+ const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel);
+ useEffect(() => {
+ setSelectedPowerLevel(user.powerLevel);
+ }, [user]);
+
+ const onPowerChange = useCallback(
+ async (powerLevel: number) => {
+ setSelectedPowerLevel(powerLevel);
+
+ const applyPowerChange = (
+ roomId: string,
+ target: string,
+ powerLevel: number,
+ powerLevelEvent: MatrixEvent,
+ ): Promise => {
+ return cli.setPowerLevel(roomId, target, powerLevel, powerLevelEvent).then(
+ function () {
+ // NO-OP; rely on the m.room.member event coming down else we could
+ // get out of sync if we force setState here!
+ logger.log("Power change success");
+ },
+ function (err) {
+ logger.error("Failed to change power level " + err);
+ Modal.createDialog(ErrorDialog, {
+ title: _t("common|error"),
+ description: _t("error|update_power_level"),
+ });
+ },
+ );
+ };
+
+ const roomId = user.roomId;
+ const target = user.userId;
+
+ const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
+ if (!powerLevelEvent) return;
+
+ const myUserId = cli.getUserId();
+ const myPower = powerLevelEvent.getContent().users[myUserId || ""];
+ if (myPower && parseInt(myPower) <= powerLevel && myUserId !== target) {
+ const { finished } = Modal.createDialog(QuestionDialog, {
+ title: _t("common|warning"),
+ description: (
+
+ {_t("user_info|promote_warning")}
+
+ {_t("common|are_you_sure")}
+
+ ),
+ button: _t("action|continue"),
+ });
+
+ const [confirmed] = await finished;
+ if (!confirmed) return;
+ } else if (myUserId === target && myPower && parseInt(myPower) > powerLevel) {
+ // If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
+ try {
+ if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
+ } catch (e) {
+ logger.error("Failed to warn about self demotion: ", e);
+ }
+ }
+
+ await applyPowerChange(roomId, target, powerLevel, powerLevelEvent);
+ },
+ [user.roomId, user.userId, cli, room],
+ );
+
+ const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
+ const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
+
+ return (
+
+ );
+};
+
+async function getUserDeviceInfo(
+ userId: string,
+ cli: MatrixClient,
+ downloadUncached = false,
+): Promise {
+ const userDeviceMap = await cli.getCrypto()?.getUserDeviceInfo([userId], downloadUncached);
+ const devicesMap = userDeviceMap?.get(userId);
+
+ if (!devicesMap) return;
+
+ return Array.from(devicesMap.values());
+}
+
+export const useDevices = (userId: string): IDevice[] | undefined | null => {
+ const cli = useContext(MatrixClientContext);
+
+ // undefined means yet to be loaded, null means failed to load, otherwise list of devices
+ const [devices, setDevices] = useState(undefined);
+ // Download device lists
+ useEffect(() => {
+ setDevices(undefined);
+
+ let cancelled = false;
+
+ async function downloadDeviceList(): Promise {
+ try {
+ const devices = await getUserDeviceInfo(userId, cli, true);
+
+ if (cancelled || !devices) {
+ // we got cancelled - presumably a different user now
+ return;
+ }
+
+ disambiguateDevices(devices);
+ setDevices(devices);
+ } catch (err) {
+ setDevices(null);
+ }
+ }
+ downloadDeviceList();
+
+ // Handle being unmounted
+ return () => {
+ cancelled = true;
+ };
+ }, [cli, userId]);
+
+ // Listen to changes
+ useEffect(() => {
+ let cancel = false;
+ const updateDevices = async (): Promise => {
+ const newDevices = await getUserDeviceInfo(userId, cli);
+ if (cancel || !newDevices) return;
+ setDevices(newDevices);
+ };
+ const onDevicesUpdated = (users: string[]): void => {
+ if (!users.includes(userId)) return;
+ updateDevices();
+ };
+ const onDeviceVerificationChanged = (_userId: string, deviceId: string): void => {
+ if (_userId !== userId) return;
+ updateDevices();
+ };
+ const onUserTrustStatusChanged = (_userId: string, trustLevel: UserTrustLevel): void => {
+ if (_userId !== userId) return;
+ updateDevices();
+ };
+ cli.on(CryptoEvent.DevicesUpdated, onDevicesUpdated);
+ cli.on(CryptoEvent.DeviceVerificationChanged, onDeviceVerificationChanged);
+ cli.on(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged);
+ // Handle being unmounted
+ return () => {
+ cancel = true;
+ cli.removeListener(CryptoEvent.DevicesUpdated, onDevicesUpdated);
+ cli.removeListener(CryptoEvent.DeviceVerificationChanged, onDeviceVerificationChanged);
+ cli.removeListener(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged);
+ };
+ }, [cli, userId]);
+
+ return devices;
+};
+
+const BasicUserInfo: React.FC<{
+ room: Room;
+ member: User | RoomMember;
+ devices: IDevice[];
+ isRoomEncrypted: boolean;
+}> = ({ room, member, devices, isRoomEncrypted }) => {
+ const cli = useContext(MatrixClientContext);
+
+ const powerLevels = useRoomPowerLevels(cli, room);
+ // Load whether or not we are a Synapse Admin
+ const isSynapseAdmin = useIsSynapseAdmin(cli);
+
+ // Check whether the user is ignored
+ const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId));
+ // Recheck if the user or client changes
+ useEffect(() => {
+ setIsIgnored(cli.isUserIgnored(member.userId));
+ }, [cli, member.userId]);
+ // Recheck also if we receive new accountData m.ignored_user_list
+ const accountDataHandler = useCallback(
+ (ev) => {
+ if (ev.getType() === "m.ignored_user_list") {
+ setIsIgnored(cli.isUserIgnored(member.userId));
+ }
+ },
+ [cli, member.userId],
+ );
+ useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler);
+
+ // Count of how many operations are currently in progress, if > 0 then show a Spinner
+ const [pendingUpdateCount, setPendingUpdateCount] = useState(0);
+ const startUpdating = useCallback(() => {
+ setPendingUpdateCount(pendingUpdateCount + 1);
+ }, [pendingUpdateCount]);
+ const stopUpdating = useCallback(() => {
+ setPendingUpdateCount(pendingUpdateCount - 1);
+ }, [pendingUpdateCount]);
+
+ const roomPermissions = useRoomPermissions(cli, room, member as RoomMember);
+
+ const onSynapseDeactivate = useCallback(async () => {
+ const { finished } = Modal.createDialog(QuestionDialog, {
+ title: _t("user_info|deactivate_confirm_title"),
+ description: {_t("user_info|deactivate_confirm_description")}
,
+ button: _t("user_info|deactivate_confirm_action"),
+ danger: true,
+ });
+
+ const [accepted] = await finished;
+ if (!accepted) return;
+ try {
+ await cli.deactivateSynapseUser(member.userId);
+ } catch (err) {
+ logger.error("Failed to deactivate user");
+ logger.error(err);
+
+ const description = err instanceof Error ? err.message : _t("invite|failed_generic");
+
+ Modal.createDialog(ErrorDialog, {
+ title: _t("user_info|error_deactivate"),
+ description,
+ });
+ }
+ }, [cli, member.userId]);
+
+ let synapseDeactivateButton;
+ let spinner;
+
+ // We don't need a perfect check here, just something to pass as "probably not our homeserver". If
+ // someone does figure out how to bypass this check the worst that happens is an error.
+ // FIXME this should be using cli instead of MatrixClientPeg.matrixClient
+ if (isSynapseAdmin && member.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) {
+ synapseDeactivateButton = (
+
+ {_t("user_info|deactivate_confirm_action")}
+
+ );
+ }
+
+ let memberDetails;
+ let adminToolsContainer;
+ if (room && (member as RoomMember).roomId) {
+ // hide the Roles section for DMs as it doesn't make sense there
+ if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) {
+ memberDetails = (
+
+
+ {_t(
+ "user_info|role_label",
+ {},
+ {
+ RoomName: () => {room.name} ,
+ },
+ )}
+
+
+
+ );
+ }
+
+ adminToolsContainer = (
+ 0}
+ startUpdating={startUpdating}
+ stopUpdating={stopUpdating}
+ >
+ {synapseDeactivateButton}
+
+ );
+ } else if (synapseDeactivateButton) {
+ adminToolsContainer = {synapseDeactivateButton} ;
+ }
+
+ if (pendingUpdateCount > 0) {
+ spinner = ;
+ }
+
+ // only display the devices list if our client supports E2E
+ const cryptoEnabled = Boolean(cli.getCrypto());
+
+ let text;
+ if (!isRoomEncrypted) {
+ if (!cryptoEnabled) {
+ text = _t("encryption|unsupported");
+ } else if (room && !room.isSpaceRoom()) {
+ text = _t("user_info|room_unencrypted");
+ }
+ } else if (!room.isSpaceRoom()) {
+ text = _t("user_info|room_encrypted");
+ }
+
+ let verifyButton;
+ const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
+
+ const userTrust = useAsyncMemo(
+ async () => cli.getCrypto()?.getUserVerificationStatus(member.userId),
+ [member.userId],
+ // the user verification status is not initialized
+ undefined,
+ );
+ const hasUserVerificationStatus = Boolean(userTrust);
+ const isUserVerified = Boolean(userTrust?.isVerified());
+ const isMe = member.userId === cli.getUserId();
+ const canVerify =
+ hasUserVerificationStatus &&
+ homeserverSupportsCrossSigning &&
+ !isUserVerified &&
+ !isMe &&
+ devices &&
+ devices.length > 0;
+
+ const setUpdating: SetUpdating = (updating) => {
+ setPendingUpdateCount((count) => count + (updating ? 1 : -1));
+ };
+ const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify, setUpdating);
+
+ // Display the spinner only when
+ // - the devices are not populated yet, or
+ // - the crypto is available and we don't have the user verification status yet
+ const showDeviceListSpinner = (cryptoEnabled && !hasUserVerificationStatus) || devices === undefined;
+ if (canVerify) {
+ if (hasCrossSigningKeys !== undefined) {
+ // Note: mx_UserInfo_verifyButton is for the end-to-end tests
+ verifyButton = (
+
+
{
+ if (hasCrossSigningKeys) {
+ verifyUser(cli, member as User);
+ } else {
+ legacyVerifyUser(cli, member as User);
+ }
+ }}
+ >
+ {_t("action|verify")}
+
+
+ );
+ } else if (!showDeviceListSpinner) {
+ // HACK: only show a spinner if the device section spinner is not shown,
+ // to avoid showing a double spinner
+ // We should ask for a design that includes all the different loading states here
+ verifyButton = ;
+ }
+ }
+
+ let editDevices;
+ if (member.userId == cli.getUserId()) {
+ editDevices = (
+
+
{
+ dis.dispatch({
+ action: Action.ViewUserDeviceSettings,
+ });
+ }}
+ >
+ {_t("user_info|edit_own_devices")}
+
+
+ );
+ }
+
+ const securitySection = (
+
+
{_t("common|security")}
+
{text}
+ {verifyButton}
+ {cryptoEnabled && (
+
+ )}
+ {editDevices}
+
+ );
+
+ return (
+
+ {memberDetails}
+
+ {securitySection}
+
+
+ {adminToolsContainer}
+
+ {spinner}
+
+ );
+};
+
+export type Member = User | RoomMember;
+
+export const UserInfoHeader: React.FC<{
+ member: Member;
+ e2eStatus?: E2EStatus;
+ roomId?: string;
+}> = ({ member, e2eStatus, roomId }) => {
+ const cli = useContext(MatrixClientContext);
+
+ const onMemberAvatarClick = useCallback(() => {
+ const avatarUrl = (member as RoomMember).getMxcAvatarUrl
+ ? (member as RoomMember).getMxcAvatarUrl()
+ : (member as User).avatarUrl;
+
+ const httpUrl = mediaFromMxc(avatarUrl).srcHttp;
+ if (!httpUrl) return;
+
+ const params = {
+ src: httpUrl,
+ name: (member as RoomMember).name || (member as User).displayName,
+ };
+
+ Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
+ }, [member]);
+
+ const avatarUrl = (member as User).avatarUrl;
+
+ const avatarElement = (
+
+ );
+
+ let presenceState: string | undefined;
+ let presenceLastActiveAgo: number | undefined;
+ let presenceCurrentlyActive: boolean | undefined;
+ if (member instanceof RoomMember && member.user) {
+ presenceState = member.user.presence;
+ presenceLastActiveAgo = member.user.lastActiveAgo;
+ presenceCurrentlyActive = member.user.currentlyActive;
+ }
+
+ const enablePresenceByHsUrl = SdkConfig.get("enable_presence_by_hs_url");
+ let showPresence = true;
+ if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) {
+ showPresence = enablePresenceByHsUrl[cli.baseUrl];
+ }
+
+ let presenceLabel: JSX.Element | undefined;
+ if (showPresence) {
+ presenceLabel = (
+
+ );
+ }
+
+ const e2eIcon = e2eStatus ? : null;
+
+ const displayName = (member as RoomMember).rawDisplayName;
+ return (
+
+ {avatarElement}
+
+
+
+
+
+
+
+
+ {displayName}
+
+ {e2eIcon}
+
+
+
+ {UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
+ roomId,
+ withDisplayName: true,
+ })}
+
+
{presenceLabel}
+
+
+
+
+
+
+ );
+};
+
+interface IProps {
+ user: Member;
+ room?: Room;
+ phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.SpaceMemberInfo | RightPanelPhases.EncryptionPanel;
+ onClose(): void;
+ verificationRequest?: VerificationRequest;
+ verificationRequestPromise?: Promise;
+}
+
+const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPhases.RoomMemberInfo, ...props }) => {
+ const cli = useContext(MatrixClientContext);
+
+ // fetch latest room member if we have a room, so we don't show historical information, falling back to user
+ const member = useMemo(() => (room ? room.getMember(user.userId) || user : user), [room, user]);
+
+ const isRoomEncrypted = useIsEncrypted(cli, room);
+ const devices = useDevices(user.userId) ?? [];
+
+ const e2eStatus = useAsyncMemo(async () => {
+ if (!isRoomEncrypted || !devices) {
+ return undefined;
+ }
+ return await getE2EStatus(cli, user.userId, devices);
+ }, [cli, isRoomEncrypted, user.userId, devices]);
+
+ const classes = ["mx_UserInfo"];
+
+ let cardState: IRightPanelCardState = {};
+ // We have no previousPhase for when viewing a UserInfo without a Room at this time
+ if (room && phase === RightPanelPhases.EncryptionPanel) {
+ cardState = { member };
+ } else if (room?.isSpaceRoom()) {
+ cardState = { spaceId: room.roomId };
+ }
+
+ const onEncryptionPanelClose = (): void => {
+ RightPanelStore.instance.popCard();
+ };
+
+ let content: JSX.Element | undefined;
+ switch (phase) {
+ case RightPanelPhases.RoomMemberInfo:
+ case RightPanelPhases.SpaceMemberInfo:
+ content = (
+
+ );
+ break;
+ case RightPanelPhases.EncryptionPanel:
+ classes.push("mx_UserInfo_smallAvatar");
+ content = (
+ )}
+ member={member as User | RoomMember}
+ onClose={onEncryptionPanelClose}
+ isRoomEncrypted={Boolean(isRoomEncrypted)}
+ />
+ );
+ break;
+ }
+
+ let closeLabel: string | undefined;
+ if (phase === RightPanelPhases.EncryptionPanel) {
+ const verificationRequest = (props as React.ComponentProps).verificationRequest;
+ if (verificationRequest && verificationRequest.pending) {
+ closeLabel = _t("action|cancel");
+ }
+ }
+
+ let scopeHeader;
+ if (room?.isSpaceRoom()) {
+ scopeHeader = (
+
+
+
+
+ );
+ }
+
+ const header = (
+ <>
+ {scopeHeader}
+
+ >
+ );
+ return (
+ }
+ onClose={onClose}
+ closeLabel={closeLabel}
+ cardState={cardState}
+ onBack={(ev: ButtonEvent): void => {
+ if (RightPanelStore.instance.previousCard.phase === RightPanelPhases.RoomMemberList) {
+ PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoBackButton", ev);
+ }
+ }}
+ >
+ {header}
+ {content}
+
+ );
+};
+
+export default UserInfo;
diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx
new file mode 100644
index 00000000000..ce1004c4dfd
--- /dev/null
+++ b/src/components/views/rooms/MessageComposer.tsx
@@ -0,0 +1,667 @@
+/*
+Copyright 2015 - 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, { createRef, ReactNode } from "react";
+import classNames from "classnames";
+import {
+ IEventRelation,
+ MatrixEvent,
+ Room,
+ RoomMember,
+ EventType,
+ THREAD_RELATION_TYPE,
+} from "matrix-js-sdk/src/matrix";
+import { Optional } from "matrix-events-sdk";
+import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg";
+import { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton";
+import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton";
+import { MatrixClientProps, withMatrixClientHOC } from "matrix-react-sdk/src/contexts/MatrixClientContext";
+import ResizeNotifier from "matrix-react-sdk/src/utils/ResizeNotifier";
+import { E2EStatus } from "matrix-react-sdk/src/utils/ShieldUtils";
+import { makeRoomPermalink, RoomPermalinkCreator } from "matrix-react-sdk/src/utils/permalinks/Permalinks";
+import VoiceRecordComposerTile from "matrix-react-sdk/src/components/views/rooms/VoiceRecordComposerTile";
+import { VoiceMessageRecording } from "matrix-react-sdk/src/audio/VoiceMessageRecording";
+import RoomContext from "matrix-react-sdk/src/contexts/RoomContext";
+import { UPDATE_EVENT } from "matrix-react-sdk/src/stores/AsyncStore";
+import { VoiceRecordingStore } from "matrix-react-sdk/src/stores/VoiceRecordingStore";
+import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore";
+import { Features } from "matrix-react-sdk/src/settings/Settings";
+import { RecordingState } from "matrix-react-sdk/src/audio/VoiceRecording";
+import dis from "matrix-react-sdk/src/dispatcher/dispatcher";
+import UIStore, { UI_EVENTS } from "matrix-react-sdk/src/stores/UIStore";
+import { ActionPayload } from "matrix-react-sdk/src/dispatcher/payloads";
+import { Action } from "matrix-react-sdk/src/dispatcher/actions";
+import { SettingUpdatedPayload } from "matrix-react-sdk/src/dispatcher/payloads/SettingUpdatedPayload";
+import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload";
+import { ComposerInsertPayload } from "matrix-react-sdk/src/dispatcher/payloads/ComposerInsertPayload";
+import {
+ getConversionFunctions,
+ sendMessage,
+ SendWysiwygComposer,
+} from "matrix-react-sdk/src/components/views/rooms/wysiwyg_composer";
+import EditorModel from "matrix-react-sdk/src/editor/model";
+import { isLocalRoom } from "matrix-react-sdk/src/utils/localRoom/isLocalRoom";
+import { aboveLeftOf, MenuProps } from "matrix-react-sdk/src/components/structures/ContextMenu";
+import { SdkContextClass } from "matrix-react-sdk/src/contexts/SDKContext";
+import { VoiceBroadcastInfoState } from "matrix-react-sdk/src/voice-broadcast";
+import { createCantStartVoiceMessageBroadcastDialog } from "matrix-react-sdk/src/components/views/dialogs/CantStartVoiceMessageBroadcastDialog";
+import E2EIcon from "matrix-react-sdk/src/components/views/rooms/E2EIcon";
+import SendMessageComposer, {
+ SendMessageComposer as SendMessageComposerClass,
+} from "matrix-react-sdk/src/components/views/rooms/SendMessageComposer";
+import Tooltip, { Alignment } from "matrix-react-sdk/src/components/views/elements/Tooltip";
+import { formatTimeLeft } from "matrix-react-sdk/src/DateUtils";
+import Stickerpicker from "matrix-react-sdk/src/components/views/rooms/Stickerpicker";
+import ReplyPreview from "matrix-react-sdk/src/components/views/rooms/ReplyPreview";
+import MessageComposerButtons from "matrix-react-sdk/src/components/views/rooms/MessageComposerButtons";
+import { UIFeature } from "matrix-react-sdk/src/settings/UIFeature";
+import { setUpVoiceBroadcastPreRecording } from "matrix-react-sdk/src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording";
+import { Icon as RoomReplacedIcon } from "matrix-react-sdk/res/img/room_replaced.svg";
+
+import { _t } from "../../../languageHandler";
+import { DisabledMessageField } from "../elements/DisabledMessageField";
+
+let instanceCount = 0;
+
+interface ISendButtonProps {
+ onClick: (ev: ButtonEvent) => void;
+ title?: string; // defaults to something generic
+}
+
+function SendButton(props: ISendButtonProps): JSX.Element {
+ return (
+
+ );
+}
+
+interface IProps extends MatrixClientProps {
+ room: Room;
+ resizeNotifier: ResizeNotifier;
+ permalinkCreator?: RoomPermalinkCreator;
+ replyToEvent?: MatrixEvent;
+ relation?: IEventRelation;
+ e2eStatus?: E2EStatus;
+ compact?: boolean;
+}
+
+interface IState {
+ composerContent: string;
+ isComposerEmpty: boolean;
+ haveRecording: boolean;
+ recordingTimeLeftSeconds?: number;
+ me?: RoomMember;
+ isMenuOpen: boolean;
+ isStickerPickerOpen: boolean;
+ showStickersButton: boolean;
+ showPollsButton: boolean;
+ showVoiceBroadcastButton: boolean;
+ isWysiwygLabEnabled: boolean;
+ isRichTextEnabled: boolean;
+ initialComposerContent: string;
+}
+
+export class MessageComposer extends React.Component {
+ private tooltipId = `mx_MessageComposer_${Math.random()}`;
+ private dispatcherRef?: string;
+ private messageComposerInput = createRef();
+ private voiceRecordingButton = createRef();
+ private ref: React.RefObject = createRef();
+ private instanceId: number;
+
+ private _voiceRecording: Optional;
+
+ public static contextType = RoomContext;
+ public context!: React.ContextType;
+
+ public static defaultProps = {
+ compact: false,
+ showVoiceBroadcastButton: false,
+ isRichTextEnabled: true,
+ };
+
+ public constructor(props: IProps) {
+ super(props);
+ VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
+
+ this.state = {
+ isComposerEmpty: true,
+ composerContent: "",
+ haveRecording: false,
+ recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
+ isMenuOpen: false,
+ isStickerPickerOpen: false,
+ showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"),
+ showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"),
+ showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast),
+ isWysiwygLabEnabled: SettingsStore.getValue("feature_wysiwyg_composer"),
+ isRichTextEnabled: true,
+ initialComposerContent: "",
+ };
+
+ this.instanceId = instanceCount++;
+
+ SettingsStore.monitorSetting("MessageComposerInput.showStickersButton", null);
+ SettingsStore.monitorSetting("MessageComposerInput.showPollsButton", null);
+ SettingsStore.monitorSetting(Features.VoiceBroadcast, null);
+ SettingsStore.monitorSetting("feature_wysiwyg_composer", null);
+ }
+
+ private get voiceRecording(): Optional {
+ return this._voiceRecording;
+ }
+
+ private set voiceRecording(rec: Optional) {
+ if (this._voiceRecording) {
+ this._voiceRecording.off(RecordingState.Started, this.onRecordingStarted);
+ this._voiceRecording.off(RecordingState.EndingSoon, this.onRecordingEndingSoon);
+ }
+
+ this._voiceRecording = rec;
+
+ if (rec) {
+ // Delay saying we have a recording until it is started, as we might not yet
+ // have A/V permissions
+ rec.on(RecordingState.Started, this.onRecordingStarted);
+
+ // We show a little heads up that the recording is about to automatically end soon. The 3s
+ // display time is completely arbitrary.
+ rec.on(RecordingState.EndingSoon, this.onRecordingEndingSoon);
+ }
+ }
+
+ public componentDidMount(): void {
+ this.dispatcherRef = dis.register(this.onAction);
+ this.waitForOwnMember();
+ UIStore.instance.trackElementDimensions(`MessageComposer${this.instanceId}`, this.ref.current!);
+ UIStore.instance.on(`MessageComposer${this.instanceId}`, this.onResize);
+ this.updateRecordingState(); // grab any cached recordings
+ }
+
+ private onResize = (type: UI_EVENTS, entry: ResizeObserverEntry): void => {
+ if (type === UI_EVENTS.Resize) {
+ const { narrow } = this.context;
+ this.setState({
+ isMenuOpen: !narrow ? false : this.state.isMenuOpen,
+ isStickerPickerOpen: false,
+ });
+ }
+ };
+
+ private onAction = (payload: ActionPayload): void => {
+ switch (payload.action) {
+ case "reply_to_event":
+ if (payload.context === this.context.timelineRenderingType) {
+ // add a timeout for the reply preview to be rendered, so
+ // that the ScrollPanel listening to the resizeNotifier can
+ // correctly measure it's new height and scroll down to keep
+ // at the bottom if it already is
+ window.setTimeout(() => {
+ this.props.resizeNotifier.notifyTimelineHeightChanged();
+ }, 100);
+ }
+ break;
+
+ case Action.SettingUpdated: {
+ const settingUpdatedPayload = payload as SettingUpdatedPayload;
+ switch (settingUpdatedPayload.settingName) {
+ case "MessageComposerInput.showStickersButton": {
+ const showStickersButton = SettingsStore.getValue("MessageComposerInput.showStickersButton");
+ if (this.state.showStickersButton !== showStickersButton) {
+ this.setState({ showStickersButton });
+ }
+ break;
+ }
+ case "MessageComposerInput.showPollsButton": {
+ const showPollsButton = SettingsStore.getValue("MessageComposerInput.showPollsButton");
+ if (this.state.showPollsButton !== showPollsButton) {
+ this.setState({ showPollsButton });
+ }
+ break;
+ }
+ case Features.VoiceBroadcast: {
+ if (this.state.showVoiceBroadcastButton !== settingUpdatedPayload.newValue) {
+ this.setState({ showVoiceBroadcastButton: !!settingUpdatedPayload.newValue });
+ }
+ break;
+ }
+ case "feature_wysiwyg_composer": {
+ if (this.state.isWysiwygLabEnabled !== settingUpdatedPayload.newValue) {
+ this.setState({ isWysiwygLabEnabled: Boolean(settingUpdatedPayload.newValue) });
+ }
+ break;
+ }
+ }
+ }
+ }
+ };
+
+ private waitForOwnMember(): void {
+ // If we have the member already, do that
+ const me = this.props.room.getMember(MatrixClientPeg.safeGet().getUserId()!);
+ if (me) {
+ this.setState({ me });
+ return;
+ }
+ // Otherwise, wait for member loading to finish and then update the member for the avatar.
+ // The members should already be loading, and loadMembersIfNeeded
+ // will return the promise for the existing operation
+ this.props.room.loadMembersIfNeeded().then(() => {
+ const me = this.props.room.getMember(MatrixClientPeg.safeGet().getSafeUserId()) ?? undefined;
+ this.setState({ me });
+ });
+ }
+
+ public componentWillUnmount(): void {
+ VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate);
+ if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
+ UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`);
+ UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize);
+
+ // clean up our listeners by setting our cached recording to falsy (see internal setter)
+ this.voiceRecording = null;
+ }
+
+ private onTombstoneClick = (ev: ButtonEvent): void => {
+ ev.preventDefault();
+
+ const replacementRoomId = this.context.tombstone?.getContent()["replacement_room"];
+ const replacementRoom = MatrixClientPeg.safeGet().getRoom(replacementRoomId);
+ let createEventId: string | undefined;
+ if (replacementRoom) {
+ const createEvent = replacementRoom.currentState.getStateEvents(EventType.RoomCreate, "");
+ if (createEvent?.getId()) createEventId = createEvent.getId();
+ }
+
+ const sender = this.context.tombstone?.getSender();
+ const viaServers = sender ? [sender.split(":").slice(1).join(":")] : undefined;
+
+ dis.dispatch({
+ action: Action.ViewRoom,
+ highlighted: true,
+ event_id: createEventId,
+ room_id: replacementRoomId,
+ auto_join: true,
+ // Try to join via the server that sent the event. This converts @something:example.org
+ // into a server domain by splitting on colons and ignoring the first entry ("@something").
+ via_servers: viaServers,
+ metricsTrigger: "Tombstone",
+ metricsViaKeyboard: ev.type !== "click",
+ });
+ };
+
+ private renderPlaceholderText = (): string => {
+ if (this.props.replyToEvent) {
+ const replyingToThread = this.props.relation?.rel_type === THREAD_RELATION_TYPE.name;
+ if (replyingToThread && this.props.e2eStatus) {
+ return _t("composer|placeholder_thread_encrypted");
+ } else if (replyingToThread) {
+ return _t("composer|placeholder_thread");
+ } else if (this.props.e2eStatus) {
+ return _t("composer|placeholder_reply_encrypted");
+ } else {
+ return _t("composer|placeholder_reply");
+ }
+ } else {
+ if (this.props.e2eStatus) {
+ return _t("composer|placeholder_encrypted");
+ } else {
+ return _t("composer|placeholder");
+ }
+ }
+ };
+
+ private addEmoji = (emoji: string): boolean => {
+ dis.dispatch({
+ action: Action.ComposerInsert,
+ text: emoji,
+ timelineRenderingType: this.context.timelineRenderingType,
+ });
+ return true;
+ };
+
+ private sendMessage = async (): Promise => {
+ if (this.state.haveRecording && this.voiceRecordingButton.current) {
+ // There shouldn't be any text message to send when a voice recording is active, so
+ // just send out the voice recording.
+ await this.voiceRecordingButton.current?.send();
+ return;
+ }
+
+ this.messageComposerInput.current?.sendMessage();
+
+ if (this.state.isWysiwygLabEnabled) {
+ const { permalinkCreator, relation, replyToEvent } = this.props;
+ const composerContent = this.state.composerContent;
+ this.setState({ composerContent: "", initialComposerContent: "" });
+ dis.dispatch({
+ action: Action.ClearAndFocusSendMessageComposer,
+ timelineRenderingType: this.context.timelineRenderingType,
+ });
+ await sendMessage(composerContent, this.state.isRichTextEnabled, {
+ mxClient: this.props.mxClient,
+ roomContext: this.context,
+ permalinkCreator,
+ relation,
+ replyToEvent,
+ });
+ }
+ };
+
+ private onChange = (model: EditorModel): void => {
+ this.setState({
+ isComposerEmpty: model.isEmpty,
+ });
+ };
+
+ private onWysiwygChange = (content: string): void => {
+ this.setState({
+ composerContent: content,
+ isComposerEmpty: content?.length === 0,
+ });
+ };
+
+ private onRichTextToggle = async (): Promise => {
+ const { richToPlain, plainToRich } = await getConversionFunctions();
+
+ const { isRichTextEnabled, composerContent } = this.state;
+ const convertedContent = isRichTextEnabled
+ ? await richToPlain(composerContent, false)
+ : await plainToRich(composerContent, false);
+
+ this.setState({
+ isRichTextEnabled: !isRichTextEnabled,
+ composerContent: convertedContent,
+ initialComposerContent: convertedContent,
+ });
+ };
+
+ private onVoiceStoreUpdate = (): void => {
+ this.updateRecordingState();
+ };
+
+ private updateRecordingState(): void {
+ const voiceRecordingId = VoiceRecordingStore.getVoiceRecordingId(this.props.room, this.props.relation);
+ this.voiceRecording = VoiceRecordingStore.instance.getActiveRecording(voiceRecordingId);
+ if (this.voiceRecording) {
+ // If the recording has already started, it's probably a cached one.
+ if (this.voiceRecording.hasRecording && !this.voiceRecording.isRecording) {
+ this.setState({ haveRecording: true });
+ }
+
+ // Note: Listeners for recording states are set by the `this.voiceRecording` setter.
+ } else {
+ this.setState({ haveRecording: false });
+ }
+ }
+
+ private onRecordingStarted = (): void => {
+ // update the recording instance, just in case
+ const voiceRecordingId = VoiceRecordingStore.getVoiceRecordingId(this.props.room, this.props.relation);
+ this.voiceRecording = VoiceRecordingStore.instance.getActiveRecording(voiceRecordingId);
+ this.setState({
+ haveRecording: !!this.voiceRecording,
+ });
+ };
+
+ private onRecordingEndingSoon = ({ secondsLeft }: { secondsLeft: number }): void => {
+ this.setState({ recordingTimeLeftSeconds: secondsLeft });
+ window.setTimeout(() => this.setState({ recordingTimeLeftSeconds: undefined }), 3000);
+ };
+
+ private setStickerPickerOpen = (isStickerPickerOpen: boolean): void => {
+ this.setState({
+ isStickerPickerOpen,
+ isMenuOpen: false,
+ });
+ };
+
+ private toggleStickerPickerOpen = (): void => {
+ this.setStickerPickerOpen(!this.state.isStickerPickerOpen);
+ };
+
+ private toggleButtonMenu = (): void => {
+ this.setState({
+ isMenuOpen: !this.state.isMenuOpen,
+ });
+ };
+
+ private get showStickersButton(): boolean {
+ return this.state.showStickersButton && !isLocalRoom(this.props.room);
+ }
+
+ private getMenuPosition(): MenuProps | undefined {
+ if (this.ref.current) {
+ const hasFormattingButtons = this.state.isWysiwygLabEnabled && this.state.isRichTextEnabled;
+ const contentRect = this.ref.current.getBoundingClientRect();
+ // Here we need to remove the all the extra space above the editor
+ // Instead of doing a querySelector or pass a ref to find the compute the height formatting buttons
+ // We are using an arbitrary value, the formatting buttons height doesn't change during the lifecycle of the component
+ // It's easier to just use a constant here instead of an over-engineering way to find the height
+ const heightToRemove = hasFormattingButtons ? 36 : 0;
+ const fixedRect = new DOMRect(
+ contentRect.x,
+ contentRect.y + heightToRemove,
+ contentRect.width,
+ contentRect.height - heightToRemove,
+ );
+ return aboveLeftOf(fixedRect);
+ }
+ }
+
+ private onRecordStartEndClick = (): void => {
+ const currentBroadcastRecording = SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent();
+
+ if (currentBroadcastRecording && currentBroadcastRecording.getState() !== VoiceBroadcastInfoState.Stopped) {
+ createCantStartVoiceMessageBroadcastDialog();
+ } else {
+ this.voiceRecordingButton.current?.onRecordStartEndClick();
+ }
+
+ if (this.context.narrow) {
+ this.toggleButtonMenu();
+ }
+ };
+
+ public render(): React.ReactNode {
+ const hasE2EIcon = Boolean(!this.state.isWysiwygLabEnabled && this.props.e2eStatus);
+ const e2eIcon = hasE2EIcon && (
+
+ );
+
+ const controls: ReactNode[] = [];
+ const menuPosition = this.getMenuPosition();
+
+ const canSendMessages = this.context.canSendMessages && !this.context.tombstone;
+ let composer: ReactNode;
+ if (canSendMessages) {
+ if (this.state.isWysiwygLabEnabled && menuPosition) {
+ composer = (
+
+ );
+ } else {
+ composer = (
+
+ );
+ }
+
+ controls.push(
+ ,
+ );
+ } else if (this.context.tombstone) {
+ const replacementRoomId = this.context.tombstone.getContent()["replacement_room"];
+
+ const continuesLink = replacementRoomId ? (
+
+ {_t("composer|room_upgraded_link")}
+
+ ) : (
+ ""
+ );
+
+ controls.push(
+
+
+
+
+ {_t("composer|room_upgraded_notice")}
+
+
+ {continuesLink}
+
+
,
+ );
+ } else {
+ controls.push( );
+ }
+
+ let recordingTooltip: JSX.Element | undefined;
+ if (this.state.recordingTimeLeftSeconds) {
+ const secondsLeft = Math.round(this.state.recordingTimeLeftSeconds);
+ recordingTooltip = (
+
+ );
+ }
+
+ const threadId =
+ this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : null;
+
+ controls.push(
+ ,
+ );
+
+ const showSendButton = canSendMessages && (!this.state.isComposerEmpty || this.state.haveRecording);
+
+ const classes = classNames({
+ "mx_MessageComposer": true,
+ "mx_MessageComposer--compact": this.props.compact,
+ "mx_MessageComposer_e2eStatus": hasE2EIcon,
+ "mx_MessageComposer_wysiwyg": this.state.isWysiwygLabEnabled,
+ });
+
+ return (
+
+ {recordingTooltip}
+
+
+
+ {e2eIcon}
+ {composer}
+
+ {controls}
+ {canSendMessages && (
+ {
+ setUpVoiceBroadcastPreRecording(
+ this.props.room,
+ MatrixClientPeg.safeGet(),
+ SdkContextClass.instance.voiceBroadcastPlaybacksStore,
+ SdkContextClass.instance.voiceBroadcastRecordingsStore,
+ SdkContextClass.instance.voiceBroadcastPreRecordingStore,
+ );
+ this.toggleButtonMenu();
+ }}
+ />
+ )}
+ {showSendButton && (
+
+ )}
+
+
+
+
+ );
+ }
+}
+
+const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer);
+export default MessageComposerWithMatrixClient;
diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx
new file mode 100644
index 00000000000..7bfaeccb972
--- /dev/null
+++ b/src/components/views/rooms/NewRoomIntro.tsx
@@ -0,0 +1,336 @@
+/*
+Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+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 { EventType, ISendEventResponse, MatrixClient, Room, User } from "matrix-js-sdk/src/matrix";
+import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg";
+import RoomAvatar from "matrix-react-sdk/src/components/views/avatars/RoomAvatar";
+import { RoomSettingsTab } from "matrix-react-sdk/src/components/views/dialogs/RoomSettingsDialog";
+import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton";
+import MiniAvatarUploader, { AVATAR_SIZE } from "matrix-react-sdk/src/components/views/elements/MiniAvatarUploader";
+import EventTileBubble from "matrix-react-sdk/src/components/views/messages/EventTileBubble";
+import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext";
+import RoomContext from "matrix-react-sdk/src/contexts/RoomContext";
+import { shouldShowComponent } from "matrix-react-sdk/src/customisations/helpers/UIComponents";
+import { Action } from "matrix-react-sdk/src/dispatcher/actions";
+import defaultDispatcher from "matrix-react-sdk/src/dispatcher/dispatcher";
+import { ViewUserPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewUserPayload";
+import { TranslationKey, _t, _td } from "matrix-react-sdk/src/languageHandler";
+import { LocalRoom } from "matrix-react-sdk/src/models/LocalRoom";
+import { UIComponent } from "matrix-react-sdk/src/settings/UIFeature";
+import SpaceStore from "matrix-react-sdk/src/stores/spaces/SpaceStore";
+import DMRoomMap from "matrix-react-sdk/src/utils/DMRoomMap";
+import { shouldEncryptRoomWithSingle3rdPartyInvite } from "matrix-react-sdk/src/utils/room/shouldEncryptRoomWithSingle3rdPartyInvite";
+import { privateShouldBeEncrypted } from "matrix-react-sdk/src/utils/rooms";
+import { showSpaceInvite } from "matrix-react-sdk/src/utils/space";
+import { sendMessage } from "matrix-react-sdk/src/components/views/rooms/wysiwyg_composer/utils/message";
+import React, { useContext } from "react";
+
+import { getRoomName } from "../../../hooks/useRoomName";
+import RoomName from "../elements/RoomName";
+
+function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean {
+ const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId);
+ const isPublic: boolean = room.getJoinRule() === "public";
+ return isPublic || !privateShouldBeEncrypted(matrixClient) || isEncrypted;
+}
+
+const determineIntroMessage = (room: Room, encryptedSingle3rdPartyInvite: boolean): TranslationKey => {
+ if (room instanceof LocalRoom) {
+ return _td("room|intro|send_message_start_dm");
+ }
+
+ if (encryptedSingle3rdPartyInvite) {
+ return _td("room|intro|encrypted_3pid_dm_pending_join");
+ }
+
+ return _td("room|intro|start_of_dm_history");
+};
+
+const NewRoomIntro: React.FC = () => {
+ const cli = useContext(MatrixClientContext);
+ const roomContext = useContext(RoomContext);
+ const { room, roomId } = roomContext;
+
+ if (!room || !roomId) {
+ throw new Error("Unable to create a NewRoomIntro without room and roomId");
+ }
+
+ const isLocalRoom = room instanceof LocalRoom;
+ const dmPartner = isLocalRoom ? room.targets[0]?.userId : DMRoomMap.shared().getUserIdForRoomId(roomId);
+
+ const onSendHelloClick = (): void => {
+ if (!dmPartner) return;
+ sendMessage("👋", false, {
+ mxClient: cli,
+ roomContext: roomContext,
+ });
+ };
+
+ const sendHelloButton = !room.getLastActiveTimestamp() && (
+
+ 👋
+ Say Hello
+
+ );
+
+ let body: JSX.Element;
+ if (dmPartner) {
+ const { shouldEncrypt: encryptedSingle3rdPartyInvite } = shouldEncryptRoomWithSingle3rdPartyInvite(room);
+ const introMessage = determineIntroMessage(room, encryptedSingle3rdPartyInvite);
+ let caption: string | undefined;
+
+ if (
+ !(room instanceof LocalRoom) &&
+ !encryptedSingle3rdPartyInvite &&
+ room.getJoinedMemberCount() + room.getInvitedMemberCount() === 2
+ ) {
+ caption = _t("room|intro|dm_caption");
+ }
+
+ const member = room?.getMember(dmPartner);
+ const displayName = room?.name || member?.rawDisplayName || dmPartner;
+ body = (
+
+ {
+ defaultDispatcher.dispatch({
+ action: Action.ViewUser,
+ // XXX: We should be using a real member object and not assuming what the receiver wants.
+ member: member || ({ userId: dmPartner } as User),
+ });
+ }}
+ />
+
+
+
+
+
+
+ {_t(
+ introMessage,
+ {},
+ {
+ displayName: () => {displayName} ,
+ },
+ )}
+
+ {caption && {caption}
}
+ {sendHelloButton}
+
+ );
+ } else {
+ const inRoom = room && room.getMyMembership() === "join";
+ const topic = room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic;
+ const canAddTopic = inRoom && room.currentState.maySendStateEvent(EventType.RoomTopic, cli.getSafeUserId());
+
+ const onTopicClick = (): void => {
+ defaultDispatcher.dispatch(
+ {
+ action: "open_room_settings",
+ room_id: roomId,
+ },
+ true,
+ );
+ // focus the topic field to help the user find it as it'll gain an outline
+ setImmediate(() => {
+ window.document.getElementById("profileTopic")?.focus();
+ });
+ };
+
+ let topicText;
+ if (canAddTopic && topic) {
+ topicText = _t(
+ "room|intro|topic_edit",
+ { topic },
+ {
+ a: (sub) => (
+
+ {sub}
+
+ ),
+ },
+ );
+ } else if (topic) {
+ topicText = _t("room|intro|topic", { topic });
+ } else if (canAddTopic) {
+ topicText = _t(
+ "room|intro|no_topic",
+ {},
+ {
+ a: (sub) => (
+
+ {sub}
+
+ ),
+ },
+ );
+ }
+
+ const creator = room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
+ const creatorName = (creator && room?.getMember(creator)?.rawDisplayName) || creator;
+
+ let createdText: string;
+ if (creator === cli.getUserId()) {
+ createdText = _t("room|intro|you_created");
+ } else {
+ createdText = _t("room|intro|user_created", {
+ displayName: creatorName,
+ });
+ }
+
+ let parentSpace: Room | undefined;
+ if (
+ SpaceStore.instance.activeSpaceRoom?.canInvite(cli.getSafeUserId()) &&
+ SpaceStore.instance.isRoomInSpace(SpaceStore.instance.activeSpace!, room.roomId)
+ ) {
+ parentSpace = SpaceStore.instance.activeSpaceRoom;
+ }
+
+ let buttons: JSX.Element | undefined;
+ if (parentSpace && shouldShowComponent(UIComponent.InviteUsers)) {
+ buttons = (
+
+
{
+ showSpaceInvite(parentSpace!);
+ }}
+ >
+ {_t("invite|to_space", { spaceName: parentSpace.name })}
+
+ {room.canInvite(cli.getSafeUserId()) && (
+
{
+ defaultDispatcher.dispatch({ action: "view_invite", roomId });
+ }}
+ >
+ {_t("room|intro|room_invite")}
+
+ )}
+
+ );
+ } else if (room.canInvite(cli.getSafeUserId()) && shouldShowComponent(UIComponent.InviteUsers)) {
+ buttons = (
+
+
{
+ defaultDispatcher.dispatch({ action: "view_invite", roomId });
+ }}
+ >
+ {_t("room|invite_this_room")}
+
+
+ );
+ }
+
+ const avatarUrl = room.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url;
+ let avatar = ;
+
+ if (!avatarUrl) {
+ avatar = (
+ =>
+ cli.sendStateEvent(roomId, EventType.RoomAvatar, { url }, "")
+ }
+ >
+ {avatar}
+
+ );
+ }
+
+ body = (
+
+ {avatar}
+
+
+
+
+
+
+ {createdText}{" "}
+ {_t(
+ "room|intro|start_of_room",
+ {},
+ {
+ roomName: () => {getRoomName(room)} ,
+ },
+ )}
+
+ {topicText}
+ {buttons}
+ {sendHelloButton}
+
+ );
+ }
+
+ function openRoomSettings(event: ButtonEvent): void {
+ event.preventDefault();
+ defaultDispatcher.dispatch({
+ action: "open_room_settings",
+ initial_tab_id: RoomSettingsTab.Security,
+ });
+ }
+
+ const subText = _t("room|intro|private_unencrypted_warning");
+
+ let subButton: JSX.Element | undefined;
+ if (
+ room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, MatrixClientPeg.safeGet()) &&
+ !isLocalRoom
+ ) {
+ subButton = (
+
+ {_t("room|intro|enable_encryption_prompt")}
+
+ );
+ }
+
+ const subtitle = (
+
+ {" "}
+ {subText} {subButton}{" "}
+
+ );
+
+ return (
+
+ {!hasExpectedEncryptionSettings(cli, room) && (
+
+ )}
+
+ {body}
+
+ );
+};
+
+export default NewRoomIntro;
diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx
new file mode 100644
index 00000000000..05d9dc172d7
--- /dev/null
+++ b/src/components/views/rooms/RoomList.tsx
@@ -0,0 +1,682 @@
+/*
+Copyright 2015-2018, 2020, 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EventType, Room, RoomType } from "matrix-js-sdk/src/matrix";
+import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg";
+import PosthogTrackers from "matrix-react-sdk/src/PosthogTrackers";
+import {
+ IState as IRovingTabIndexState,
+ RovingTabIndexProvider,
+} from "matrix-react-sdk/src/accessibility/RovingTabIndex";
+import {
+ ChevronFace,
+ ContextMenuTooltipButton,
+ MenuProps,
+ useContextMenu,
+} from "matrix-react-sdk/src/components/structures/ContextMenu";
+import RoomAvatar from "matrix-react-sdk/src/components/views/avatars/RoomAvatar";
+import { BetaPill } from "matrix-react-sdk/src/components/views/beta/BetaCard";
+import IconizedContextMenu, {
+ IconizedContextMenuOption,
+ IconizedContextMenuOptionList,
+} from "matrix-react-sdk/src/components/views/context_menus/IconizedContextMenu";
+import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton";
+import ExtraTile from "matrix-react-sdk/src/components/views/rooms/ExtraTile";
+import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext";
+import { SdkContextClass } from "matrix-react-sdk/src/contexts/SDKContext";
+import { shouldShowComponent } from "matrix-react-sdk/src/customisations/helpers/UIComponents";
+import { Action } from "matrix-react-sdk/src/dispatcher/actions";
+import defaultDispatcher from "matrix-react-sdk/src/dispatcher/dispatcher";
+import { ActionPayload } from "matrix-react-sdk/src/dispatcher/payloads";
+import { ViewRoomDeltaPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomDeltaPayload";
+import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload";
+import { useEventEmitterState } from "matrix-react-sdk/src/hooks/useEventEmitter";
+import { useFeatureEnabled } from "matrix-react-sdk/src/hooks/useSettings";
+import { TranslationKey, _t, _td } from "matrix-react-sdk/src/languageHandler";
+import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore";
+import { UIComponent } from "matrix-react-sdk/src/settings/UIFeature";
+import { UPDATE_EVENT } from "matrix-react-sdk/src/stores/AsyncStore";
+import RoomListStore, { LISTS_UPDATE_EVENT } from "matrix-react-sdk/src/stores/room-list/RoomListStore";
+import { ITagMap } from "matrix-react-sdk/src/stores/room-list/algorithms/models";
+import { DefaultTagID, TagID } from "matrix-react-sdk/src/stores/room-list/models";
+import {
+ ISuggestedRoom,
+ MetaSpace,
+ SpaceKey,
+ UPDATE_SELECTED_SPACE,
+ UPDATE_SUGGESTED_ROOMS,
+ isMetaSpace,
+} from "matrix-react-sdk/src/stores/spaces";
+import SpaceStore from "matrix-react-sdk/src/stores/spaces/SpaceStore";
+import ResizeNotifier from "matrix-react-sdk/src/utils/ResizeNotifier";
+import { arrayFastClone, arrayHasDiff } from "matrix-react-sdk/src/utils/arrays";
+import { objectShallowClone, objectWithOnly } from "matrix-react-sdk/src/utils/objects";
+import {
+ shouldShowSpaceInvite,
+ showAddExistingRooms,
+ showCreateNewRoom,
+ showSpaceInvite,
+} from "matrix-react-sdk/src/utils/space";
+import React, { ComponentType, ReactComponentElement, SyntheticEvent, createRef } from "react";
+import { RoomNotificationStateStore } from "matrix-react-sdk/src/stores/notifications/RoomNotificationStateStore";
+
+import { CustomTagID, SuperheroTagID } from "../../../stores/room-list/custom-models";
+import RoomSublist, { IAuxButtonProps } from "./RoomSublist";
+
+interface IProps {
+ onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void;
+ onFocus: (ev: React.FocusEvent) => void;
+ onBlur: (ev: React.FocusEvent) => void;
+ onResize: () => void;
+ onListCollapse?: (isExpanded: boolean) => void;
+ resizeNotifier: ResizeNotifier;
+ isMinimized: boolean;
+ activeSpace: SpaceKey;
+}
+
+interface IState {
+ sublists: ITagMap;
+ currentRoomId?: string;
+ suggestedRooms: ISuggestedRoom[];
+}
+
+export const TAG_ORDER: CustomTagID[] = [
+ DefaultTagID.Invite,
+ DefaultTagID.Favourite,
+ DefaultTagID.DM,
+ DefaultTagID.Untagged,
+ DefaultTagID.LowPriority,
+ DefaultTagID.ServerNotice,
+ DefaultTagID.Suggested,
+ DefaultTagID.Archived,
+ SuperheroTagID.CommunityRooms,
+];
+const ALWAYS_VISIBLE_TAGS: CustomTagID[] = [DefaultTagID.DM, DefaultTagID.Untagged, SuperheroTagID.CommunityRooms];
+
+interface ITagAesthetics {
+ sectionLabel: TranslationKey;
+ sectionLabelRaw?: string;
+ AuxButtonComponent?: ComponentType;
+ isInvite: boolean;
+ defaultHidden: boolean;
+}
+
+type TagAestheticsMap = Partial<{
+ [tagId in TagID]: ITagAesthetics;
+}>;
+
+const auxButtonContextMenuPosition = (handle: HTMLDivElement): MenuProps => {
+ const rect = handle.getBoundingClientRect();
+ return {
+ chevronFace: ChevronFace.None,
+ left: rect.left - 7,
+ top: rect.top + rect.height,
+ };
+};
+
+const DmAuxButton: React.FC = ({ tabIndex, dispatcher = defaultDispatcher }) => {
+ const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
+ const activeSpace = useEventEmitterState(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
+ return SpaceStore.instance.activeSpaceRoom;
+ });
+
+ const showCreateRooms = shouldShowComponent(UIComponent.CreateRooms);
+ const showInviteUsers = shouldShowComponent(UIComponent.InviteUsers);
+
+ if (activeSpace && (showCreateRooms || showInviteUsers)) {
+ let contextMenu: JSX.Element | undefined;
+ if (menuDisplayed && handle.current) {
+ const canInvite = shouldShowSpaceInvite(activeSpace);
+
+ contextMenu = (
+
+
+ {showCreateRooms && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ closeMenu();
+ defaultDispatcher.dispatch({ action: "view_create_chat" });
+ PosthogTrackers.trackInteraction(
+ "WebRoomListRoomsSublistPlusMenuCreateChatItem",
+ e,
+ );
+ }}
+ />
+ )}
+ {showInviteUsers && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ closeMenu();
+ showSpaceInvite(activeSpace);
+ }}
+ disabled={!canInvite}
+ tooltip={canInvite ? undefined : _t("spaces|error_no_permission_invite")}
+ />
+ )}
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+ {contextMenu}
+ >
+ );
+ } else if (!activeSpace && showCreateRooms) {
+ return (
+ {
+ dispatcher.dispatch({ action: "view_create_chat" });
+ PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateChatItem", e);
+ }}
+ className="mx_RoomSublist_auxButton"
+ aria-label={_t("action|start_chat")}
+ title={_t("action|start_chat")}
+ />
+ );
+ }
+
+ return null;
+};
+
+const UntaggedAuxButton: React.FC = ({ tabIndex }) => {
+ const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
+ const activeSpace = useEventEmitterState(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
+ return SpaceStore.instance.activeSpaceRoom;
+ });
+
+ const showCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
+ const showExploreRooms = shouldShowComponent(UIComponent.ExploreRooms);
+
+ const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
+ const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
+
+ let contextMenuContent: JSX.Element | undefined;
+ if (menuDisplayed && activeSpace) {
+ const canAddRooms = activeSpace.currentState.maySendStateEvent(
+ EventType.SpaceChild,
+ MatrixClientPeg.safeGet().getSafeUserId(),
+ );
+
+ contextMenuContent = (
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ closeMenu();
+ defaultDispatcher.dispatch({
+ action: Action.ViewRoom,
+ room_id: activeSpace.roomId,
+ metricsTrigger: undefined, // other
+ });
+ PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e);
+ }}
+ />
+ {showCreateRoom ? (
+ <>
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ closeMenu();
+ showCreateNewRoom(activeSpace);
+ PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
+ }}
+ disabled={!canAddRooms}
+ tooltip={canAddRooms ? undefined : _t("spaces|error_no_permission_create_room")}
+ />
+ {videoRoomsEnabled && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ closeMenu();
+ showCreateNewRoom(
+ activeSpace,
+ elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
+ );
+ }}
+ disabled={!canAddRooms}
+ tooltip={canAddRooms ? undefined : _t("spaces|error_no_permission_create_room")}
+ >
+
+
+ )}
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ closeMenu();
+ showAddExistingRooms(activeSpace);
+ }}
+ disabled={!canAddRooms}
+ tooltip={canAddRooms ? undefined : _t("spaces|error_no_permission_add_room")}
+ />
+ >
+ ) : null}
+
+ );
+ } else if (menuDisplayed) {
+ contextMenuContent = (
+
+ {showCreateRoom && (
+ <>
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ closeMenu();
+ defaultDispatcher.dispatch({ action: "view_create_room" });
+ PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
+ }}
+ />
+ {videoRoomsEnabled && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ closeMenu();
+ defaultDispatcher.dispatch({
+ action: "view_create_room",
+ type: elementCallVideoRoomsEnabled
+ ? RoomType.UnstableCall
+ : RoomType.ElementVideo,
+ });
+ }}
+ >
+
+
+ )}
+ >
+ )}
+ {showExploreRooms ? (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ closeMenu();
+ PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e);
+ defaultDispatcher.fire(Action.ViewRoomDirectory);
+ }}
+ />
+ ) : null}
+
+ );
+ }
+
+ let contextMenu: JSX.Element | null = null;
+ if (menuDisplayed && handle.current) {
+ contextMenu = (
+
+ {contextMenuContent}
+
+ );
+ }
+
+ if (showCreateRoom || showExploreRooms) {
+ return (
+ <>
+
+
+ {contextMenu}
+ >
+ );
+ }
+
+ return null;
+};
+
+const TAG_AESTHETICS: TagAestheticsMap = {
+ [DefaultTagID.Invite]: {
+ sectionLabel: _td("action|invites_list"),
+ isInvite: true,
+ defaultHidden: false,
+ },
+ [DefaultTagID.Favourite]: {
+ sectionLabel: _td("common|favourites"),
+ isInvite: false,
+ defaultHidden: false,
+ },
+ [DefaultTagID.DM]: {
+ sectionLabel: _td("common|people"),
+ isInvite: false,
+ defaultHidden: false,
+ AuxButtonComponent: DmAuxButton,
+ },
+ [DefaultTagID.Untagged]: {
+ sectionLabel: _td("common|rooms"),
+ isInvite: false,
+ defaultHidden: false,
+ AuxButtonComponent: UntaggedAuxButton,
+ },
+ [DefaultTagID.LowPriority]: {
+ sectionLabel: _td("common|low_priority"),
+ isInvite: false,
+ defaultHidden: false,
+ },
+ [DefaultTagID.ServerNotice]: {
+ sectionLabel: _td("common|system_alerts"),
+ isInvite: false,
+ defaultHidden: false,
+ },
+
+ // TODO: Replace with archived view: https://github.com/vector-im/element-web/issues/14038
+ [DefaultTagID.Archived]: {
+ sectionLabel: _td("common|historical"),
+ isInvite: true,
+ defaultHidden: true,
+ },
+
+ [DefaultTagID.Suggested]: {
+ sectionLabel: _td("room_list|suggested_rooms_heading"),
+ isInvite: false,
+ defaultHidden: false,
+ },
+ [SuperheroTagID.CommunityRooms]: {
+ sectionLabel: _td("room_list|suggested_rooms_heading"),
+ sectionLabelRaw: "Community Rooms",
+ isInvite: false,
+ defaultHidden: false,
+ AuxButtonComponent: DmAuxButton,
+ },
+};
+
+export default class RoomList extends React.PureComponent {
+ private dispatcherRef?: string;
+ private treeRef = createRef();
+
+ public static contextType = MatrixClientContext;
+ public context!: React.ContextType;
+
+ public constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ sublists: {},
+ suggestedRooms: SpaceStore.instance.suggestedRooms,
+ };
+ }
+
+ public componentDidMount(): void {
+ this.dispatcherRef = defaultDispatcher.register(this.onAction);
+ SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate);
+ SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms);
+ RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
+ this.updateLists(); // trigger the first update
+ }
+
+ public componentWillUnmount(): void {
+ SpaceStore.instance.off(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms);
+ RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
+ if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
+ SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate);
+ }
+
+ private onRoomViewStoreUpdate = (): void => {
+ this.setState({
+ currentRoomId: SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined,
+ });
+ };
+
+ private onAction = (payload: ActionPayload): void => {
+ if (payload.action === Action.ViewRoomDelta) {
+ const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload;
+ const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
+ if (!currentRoomId) return;
+ const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread);
+ if (room) {
+ defaultDispatcher.dispatch({
+ action: Action.ViewRoom,
+ room_id: room.roomId,
+ show_room_tile: true, // to make sure the room gets scrolled into view
+ metricsTrigger: "WebKeyboardShortcut",
+ metricsViaKeyboard: true,
+ });
+ }
+ } else if (payload.action === Action.PstnSupportUpdated) {
+ this.updateLists();
+ }
+ };
+
+ private getRoomDelta = (roomId: string, delta: number, unread = false): Room => {
+ const lists = RoomListStore.instance.orderedLists;
+ const rooms: Room[] = [];
+ TAG_ORDER.forEach((t) => {
+ let listRooms = lists[t];
+
+ if (unread) {
+ // filter to only notification rooms (and our current active room so we can index properly)
+ listRooms = listRooms.filter((r) => {
+ const state = RoomNotificationStateStore.instance.getRoomState(r);
+ return state.room.roomId === roomId || state.isUnread;
+ });
+ }
+
+ rooms.push(...listRooms);
+ });
+
+ const currentIndex = rooms.findIndex((r) => r.roomId === roomId);
+ // use slice to account for looping around the start
+ const [room] = rooms.slice((currentIndex + delta) % rooms.length);
+ return room;
+ };
+
+ private updateSuggestedRooms = (suggestedRooms: ISuggestedRoom[]): void => {
+ this.setState({ suggestedRooms });
+ };
+
+ private updateLists = (): void => {
+ const newLists = RoomListStore.instance.orderedLists;
+ const previousListIds = Object.keys(this.state.sublists);
+ const newListIds = Object.keys(newLists);
+
+ let doUpdate = arrayHasDiff(previousListIds, newListIds);
+ if (!doUpdate) {
+ // so we didn't have the visible sublists change, but did the contents of those
+ // sublists change significantly enough to break the sticky headers? Probably, so
+ // let's check the length of each.
+ for (const tagId of newListIds) {
+ const oldRooms = this.state.sublists[tagId];
+ const newRooms = newLists[tagId];
+ if (oldRooms.length !== newRooms.length) {
+ doUpdate = true;
+ break;
+ }
+ }
+ }
+
+ if (doUpdate) {
+ // We have to break our reference to the room list store if we want to be able to
+ // diff the object for changes, so do that.
+ const newSublists = objectWithOnly(newLists, newListIds);
+ const sublists = objectShallowClone(newSublists, (k, v) => arrayFastClone(v));
+
+ this.setState({ sublists }, () => {
+ this.props.onResize();
+ });
+ }
+ };
+
+ private renderSuggestedRooms(): ReactComponentElement[] {
+ return this.state.suggestedRooms.map((room) => {
+ const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("empty_room");
+ const avatar = (
+
+ );
+ const viewRoom = (ev: SyntheticEvent): void => {
+ defaultDispatcher.dispatch({
+ action: Action.ViewRoom,
+ room_alias: room.canonical_alias || room.aliases?.[0],
+ room_id: room.room_id,
+ via_servers: room.viaServers,
+ oob_data: {
+ avatarUrl: room.avatar_url,
+ name,
+ },
+ metricsTrigger: "RoomList",
+ metricsViaKeyboard: ev.type !== "click",
+ });
+ };
+ return (
+
+ );
+ });
+ }
+
+ private renderSublists(): React.ReactElement[] {
+ // show a skeleton UI if the user is in no rooms and they are not filtering and have no suggested rooms
+ const showSkeleton =
+ !this.state.suggestedRooms?.length &&
+ Object.values(RoomListStore.instance.orderedLists).every((list) => !list?.length);
+
+ return TAG_ORDER.map((orderedTagId) => {
+ let extraTiles: ReactComponentElement[] | undefined;
+ if (orderedTagId === DefaultTagID.Suggested) {
+ extraTiles = this.renderSuggestedRooms();
+ }
+
+ const aesthetics = TAG_AESTHETICS[orderedTagId];
+ if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
+
+ let alwaysVisible = ALWAYS_VISIBLE_TAGS.includes(orderedTagId);
+ if (
+ (this.props.activeSpace === MetaSpace.Favourites && orderedTagId !== DefaultTagID.Favourite) ||
+ (this.props.activeSpace === MetaSpace.People && orderedTagId !== DefaultTagID.DM) ||
+ (this.props.activeSpace === MetaSpace.Orphans && orderedTagId === DefaultTagID.DM) ||
+ (this.props.activeSpace === SuperheroTagID.CommunityRooms &&
+ orderedTagId === SuperheroTagID.CommunityRooms) ||
+ (!isMetaSpace(this.props.activeSpace) &&
+ orderedTagId === DefaultTagID.DM &&
+ !SettingsStore.getValue("Spaces.showPeopleInSpace", this.props.activeSpace))
+ ) {
+ alwaysVisible = false;
+ }
+
+ let forceExpanded = false;
+ if (
+ (this.props.activeSpace === MetaSpace.Favourites && orderedTagId === DefaultTagID.Favourite) ||
+ (this.props.activeSpace === MetaSpace.People && orderedTagId === DefaultTagID.DM) ||
+ (this.props.activeSpace === SuperheroTagID.CommunityRooms &&
+ orderedTagId !== SuperheroTagID.CommunityRooms)
+ ) {
+ forceExpanded = true;
+ }
+ // The cost of mounting/unmounting this component offsets the cost
+ // of keeping it in the DOM and hiding it when it is not required
+ return (
+
+ );
+ });
+ }
+
+ public focus(): void {
+ // focus the first focusable element in this aria treeview widget
+ const treeItems = this.treeRef.current?.querySelectorAll('[role="treeitem"]');
+ if (!treeItems) return;
+ [...treeItems].find((e) => e.offsetParent !== null)?.focus();
+ }
+
+ public render(): React.ReactNode {
+ const sublists = this.renderSublists();
+ return (
+
+ {({ onKeyDownHandler }) => (
+
+ {sublists}
+
+ )}
+
+ );
+ }
+}
diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx
new file mode 100644
index 00000000000..d7a8aa6945a
--- /dev/null
+++ b/src/components/views/rooms/RoomPreviewBar.tsx
@@ -0,0 +1,752 @@
+/*
+Copyright 2015-2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, { ChangeEvent, ReactNode } from "react";
+import {
+ Room,
+ RoomMember,
+ EventType,
+ RoomType,
+ IJoinRuleEventContent,
+ JoinRule,
+ MatrixError,
+} from "matrix-js-sdk/src/matrix";
+import classNames from "classnames";
+import { RoomPreviewOpts, RoomViewLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
+import { Icon as AskToJoinIcon } from "matrix-react-sdk/res/img/element-icons/ask-to-join.svg";
+import { IOOBData } from "matrix-react-sdk/src/stores/ThreepidInviteStore";
+import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg";
+import IdentityAuthClient from "matrix-react-sdk/src/IdentityAuthClient";
+import { UserFriendlyError } from "matrix-react-sdk/src/languageHandler";
+import SdkConfig from "matrix-react-sdk/src/SdkConfig";
+import { ModuleRunner } from "matrix-react-sdk/src/modules/ModuleRunner";
+import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore";
+import { UIFeature } from "matrix-react-sdk/src/settings/UIFeature";
+import Spinner from "matrix-react-sdk/src/components/views/elements/Spinner";
+import RoomAvatar from "matrix-react-sdk/src/components/views/avatars/RoomAvatar";
+import InviteReason from "matrix-react-sdk/src/components/views/elements/InviteReason";
+import AccessibleButton from "matrix-react-sdk/src/components/views/elements/AccessibleButton";
+import Field from "matrix-react-sdk/src/components/views/elements/Field";
+import dis from "matrix-react-sdk/src/dispatcher/dispatcher";
+
+import { _t } from "../../../languageHandler";
+import { isVerifiedRoom } from "../../../hooks/useVerifiedRoom";
+import { MessageCommunityBotButton } from "../elements/MessageButton";
+import { CommunityRoomPeekMessage } from "../elements/CommunityRoomPeekMessage";
+
+const MemberEventHtmlReasonField = "io.element.html_reason";
+
+enum MessageCase {
+ NotLoggedIn = "NotLoggedIn",
+ Joining = "Joining",
+ Loading = "Loading",
+ Rejecting = "Rejecting",
+ Kicked = "Kicked",
+ Banned = "Banned",
+ OtherThreePIDError = "OtherThreePIDError",
+ InvitedEmailNotFoundInAccount = "InvitedEmailNotFoundInAccount",
+ InvitedEmailNoIdentityServer = "InvitedEmailNoIdentityServer",
+ InvitedEmailMismatch = "InvitedEmailMismatch",
+ Invite = "Invite",
+ ViewingRoom = "ViewingRoom",
+ RoomNotFound = "RoomNotFound",
+ OtherError = "OtherError",
+ PromptAskToJoin = "PromptAskToJoin",
+ Knocked = "Knocked",
+ RequestDenied = "requestDenied",
+}
+
+interface IProps {
+ // if inviterName is specified, the preview bar will shown an invite to the room.
+ // You should also specify onRejectClick if specifying inviterName
+ inviterName?: string;
+
+ // If invited by 3rd party invite, the email address the invite was sent to
+ invitedEmail?: string;
+
+ // For third party invites, information passed about the room out-of-band
+ oobData?: IOOBData;
+
+ // For third party invites, a URL for a 3pid invite signing service
+ signUrl?: string;
+
+ // A standard client/server API error object. If supplied, indicates that the
+ // caller was unable to fetch details about the room for the given reason.
+ error?: MatrixError;
+
+ canPreview?: boolean;
+ previewLoading?: boolean;
+
+ // The id of the room to be previewed, if it is known.
+ // (It may be unknown if we are waiting for an alias to be resolved.)
+ roomId?: string;
+
+ // A `Room` object for the room to be previewed, if we have one.
+ room?: Room;
+
+ loading?: boolean;
+ joining?: boolean;
+ rejecting?: boolean;
+ // The alias that was used to access this room, if appropriate
+ // If given, this will be how the room is referred to (eg.
+ // in error messages).
+ roomAlias?: string;
+
+ onJoinClick?(): void;
+ onRejectClick?(): void;
+ onRejectAndIgnoreClick?(): void;
+ onForgetClick?(): void;
+
+ canAskToJoinAndMembershipIsLeave?: boolean;
+ promptAskToJoin?: boolean;
+ knocked?: boolean;
+ onSubmitAskToJoin?(reason?: string): void;
+ onCancelAskToJoin?(): void;
+}
+
+interface IState {
+ busy: boolean;
+ accountEmails?: string[];
+ invitedEmailMxid?: string;
+ threePidFetchError?: MatrixError;
+ reason?: string;
+}
+
+export default class RoomPreviewBar extends React.Component {
+ public static defaultProps = {
+ onJoinClick(): void {},
+ };
+
+ public constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ busy: false,
+ };
+ }
+
+ public componentDidMount(): void {
+ this.checkInvitedEmail();
+ }
+
+ public componentDidUpdate(prevProps: IProps, prevState: IState): void {
+ if (this.props.invitedEmail !== prevProps.invitedEmail || this.props.inviterName !== prevProps.inviterName) {
+ this.checkInvitedEmail();
+ }
+ }
+
+ private async checkInvitedEmail(): Promise {
+ // If this is an invite and we've been told what email address was
+ // invited, fetch the user's account emails and discovery bindings so we
+ // can check them against the email that was invited.
+ if (this.props.inviterName && this.props.invitedEmail) {
+ this.setState({ busy: true });
+ try {
+ // Gather the account 3PIDs
+ const account3pids = await MatrixClientPeg.safeGet().getThreePids();
+ this.setState({
+ accountEmails: account3pids.threepids.filter((b) => b.medium === "email").map((b) => b.address),
+ });
+ // If we have an IS connected, use that to lookup the email and
+ // check the bound MXID.
+ if (!MatrixClientPeg.safeGet().getIdentityServerUrl()) {
+ this.setState({ busy: false });
+ return;
+ }
+ const authClient = new IdentityAuthClient();
+ const identityAccessToken = await authClient.getAccessToken();
+ const result = await MatrixClientPeg.safeGet().lookupThreePid(
+ "email",
+ this.props.invitedEmail,
+ identityAccessToken!,
+ );
+ if (!("mxid" in result)) {
+ throw new UserFriendlyError("room|error_3pid_invite_email_lookup");
+ }
+ this.setState({ invitedEmailMxid: result.mxid });
+ } catch (err) {
+ this.setState({ threePidFetchError: err as MatrixError });
+ }
+ this.setState({ busy: false });
+ }
+ }
+
+ private getMessageCase(): MessageCase {
+ const isGuest = MatrixClientPeg.safeGet().isGuest();
+
+ if (isGuest) {
+ return MessageCase.NotLoggedIn;
+ }
+
+ const myMember = this.getMyMember();
+
+ if (myMember) {
+ const previousMembership = myMember.events.member?.getPrevContent().membership;
+ if (myMember.isKicked()) {
+ if (previousMembership === "knock") {
+ return MessageCase.RequestDenied;
+ } else if (this.props.promptAskToJoin) {
+ return MessageCase.PromptAskToJoin;
+ }
+ return MessageCase.Kicked;
+ } else if (myMember.membership === "ban") {
+ return MessageCase.Banned;
+ }
+ }
+
+ if (this.props.joining) {
+ return MessageCase.Joining;
+ } else if (this.props.rejecting) {
+ return MessageCase.Rejecting;
+ } else if (this.props.loading || this.state.busy) {
+ return MessageCase.Loading;
+ } else if (this.props.knocked) {
+ return MessageCase.Knocked;
+ } else if (this.props.canAskToJoinAndMembershipIsLeave || this.props.promptAskToJoin) {
+ return MessageCase.PromptAskToJoin;
+ }
+
+ if (this.props.inviterName) {
+ if (this.props.invitedEmail) {
+ if (this.state.threePidFetchError) {
+ return MessageCase.OtherThreePIDError;
+ } else if (this.state.accountEmails && !this.state.accountEmails.includes(this.props.invitedEmail)) {
+ return MessageCase.InvitedEmailNotFoundInAccount;
+ } else if (!MatrixClientPeg.safeGet().getIdentityServerUrl()) {
+ return MessageCase.InvitedEmailNoIdentityServer;
+ } else if (this.state.invitedEmailMxid != MatrixClientPeg.safeGet().getUserId()) {
+ return MessageCase.InvitedEmailMismatch;
+ }
+ }
+ return MessageCase.Invite;
+ } else if (this.props.error) {
+ if ((this.props.error as MatrixError).errcode == "M_NOT_FOUND") {
+ return MessageCase.RoomNotFound;
+ } else {
+ return MessageCase.OtherError;
+ }
+ } else {
+ return MessageCase.ViewingRoom;
+ }
+ }
+
+ private getKickOrBanInfo(): { memberName?: string; reason?: string } {
+ const myMember = this.getMyMember();
+ if (!myMember) {
+ return {};
+ }
+
+ const kickerUserId = myMember.events.member?.getSender();
+ const kickerMember = kickerUserId ? this.props.room?.currentState.getMember(kickerUserId) : undefined;
+ const memberName = kickerMember?.name ?? kickerUserId;
+ const reason = myMember.events.member?.getContent().reason;
+ return { memberName, reason };
+ }
+
+ private joinRule(): JoinRule | null {
+ return (
+ this.props.room?.currentState
+ .getStateEvents(EventType.RoomJoinRules, "")
+ ?.getContent().join_rule ?? null
+ );
+ }
+
+ private getMyMember(): RoomMember | null {
+ return this.props.room?.getMember(MatrixClientPeg.safeGet().getSafeUserId()) ?? null;
+ }
+
+ private getInviteMember(): RoomMember | null {
+ const { room } = this.props;
+ if (!room) {
+ return null;
+ }
+ const myUserId = MatrixClientPeg.safeGet().getSafeUserId();
+ const inviteEvent = room.currentState.getMember(myUserId);
+ if (!inviteEvent) {
+ return null;
+ }
+ const inviterUserId = inviteEvent.events.member?.getSender();
+ return inviterUserId ? room.currentState.getMember(inviterUserId) : null;
+ }
+
+ private isDMInvite(): boolean {
+ const myMember = this.getMyMember();
+ if (!myMember) {
+ return false;
+ }
+ const memberContent = myMember.events.member?.getContent();
+ return memberContent?.membership === "invite" && memberContent.is_direct;
+ }
+
+ private makeScreenAfterLogin(): { screen: string; params: Record } {
+ return {
+ screen: "room",
+ params: {
+ email: this.props.invitedEmail,
+ signurl: this.props.signUrl,
+ room_name: this.props.oobData?.name ?? null,
+ room_avatar_url: this.props.oobData?.avatarUrl ?? null,
+ inviter_name: this.props.oobData?.inviterName ?? null,
+ },
+ };
+ }
+
+ private onLoginClick = (): void => {
+ dis.dispatch({ action: "start_login", screenAfterLogin: this.makeScreenAfterLogin() });
+ };
+
+ private onRegisterClick = (): void => {
+ dis.dispatch({ action: "start_registration", screenAfterLogin: this.makeScreenAfterLogin() });
+ };
+
+ private onChangeReason = (event: ChangeEvent): void => {
+ this.setState({ reason: event.target.value });
+ };
+
+ public render(): React.ReactNode {
+ const brand = SdkConfig.get().brand;
+ const roomName = this.props.room?.name ?? this.props.roomAlias ?? "";
+ const isSpace = this.props.room?.isSpaceRoom() ?? this.props.oobData?.roomType === RoomType.Space;
+
+ let showSpinner = false;
+ let title: string | undefined;
+ let subTitle: string | ReactNode[] | undefined;
+ let reasonElement: JSX.Element | undefined;
+ let primaryActionHandler: (() => void) | undefined;
+ let primaryActionLabel: string | undefined;
+ let secondaryActionHandler: (() => void) | undefined;
+ let secondaryActionLabel: string | undefined;
+ let footer: JSX.Element | undefined;
+ const extraComponents: JSX.Element[] = [];
+
+ const { isCommunityRoom } = isVerifiedRoom(roomName);
+
+ const messageCase = this.getMessageCase();
+ switch (messageCase) {
+ case MessageCase.Joining: {
+ if (this.props.oobData?.roomType || isSpace) {
+ title = isSpace ? _t("room|joining_space") : _t("room|joining_room");
+ } else {
+ title = _t("room|joining");
+ }
+
+ showSpinner = true;
+ break;
+ }
+ case MessageCase.Loading: {
+ title = _t("common|loading");
+ showSpinner = true;
+ break;
+ }
+ case MessageCase.Rejecting: {
+ title = _t("room|rejecting");
+ showSpinner = true;
+ break;
+ }
+ case MessageCase.NotLoggedIn: {
+ const opts: RoomPreviewOpts = { canJoin: false };
+ if (this.props.roomId) {
+ ModuleRunner.instance.invoke(RoomViewLifecycle.PreviewRoomNotLoggedIn, opts, this.props.roomId);
+ }
+ if (opts.canJoin) {
+ title = _t("room|join_title");
+ primaryActionLabel = _t("action|join");
+ primaryActionHandler = (): void => {
+ ModuleRunner.instance.invoke(RoomViewLifecycle.JoinFromRoomPreview, this.props.roomId);
+ };
+ } else {
+ title = _t("room|join_title_account");
+ if (SettingsStore.getValue(UIFeature.Registration)) {
+ primaryActionLabel = _t("room|join_button_account");
+ primaryActionHandler = this.onRegisterClick;
+ }
+ secondaryActionLabel = _t("action|sign_in");
+ secondaryActionHandler = this.onLoginClick;
+ }
+ if (this.props.previewLoading) {
+ footer = (
+
+
+ {_t("room|loading_preview")}
+
+ );
+ }
+ break;
+ }
+ case MessageCase.Kicked: {
+ const { memberName, reason } = this.getKickOrBanInfo();
+ if (roomName) {
+ title = _t("room|kicked_from_room_by", { memberName, roomName });
+ } else {
+ title = _t("room|kicked_by", { memberName });
+ }
+ subTitle = reason ? _t("room|kick_reason", { reason }) : undefined;
+
+ if (isSpace) {
+ primaryActionLabel = _t("room|forget_space");
+ } else {
+ primaryActionLabel = _t("room|forget_room");
+ }
+ primaryActionHandler = this.props.onForgetClick;
+
+ if (this.joinRule() !== JoinRule.Invite) {
+ secondaryActionLabel = primaryActionLabel;
+ secondaryActionHandler = primaryActionHandler;
+
+ primaryActionLabel = _t("room|rejoin_button");
+ primaryActionHandler = this.props.onJoinClick;
+ }
+ break;
+ }
+ case MessageCase.RequestDenied: {
+ title = _t("room|knock_denied_title");
+
+ subTitle = _t("room|knock_denied_subtitle");
+
+ if (isSpace) {
+ primaryActionLabel = _t("room|forget_space");
+ } else {
+ primaryActionLabel = _t("room|forget_room");
+ }
+ primaryActionHandler = this.props.onForgetClick;
+ break;
+ }
+ case MessageCase.Banned: {
+ const { memberName, reason } = this.getKickOrBanInfo();
+ if (roomName) {
+ title = _t("room|banned_from_room_by", { memberName, roomName });
+ } else {
+ title = _t("room|banned_by", { memberName });
+ }
+ subTitle = reason ? _t("room|kick_reason", { reason }) : undefined;
+ if (isSpace) {
+ primaryActionLabel = _t("room|forget_space");
+ } else {
+ primaryActionLabel = _t("room|forget_room");
+ }
+ primaryActionHandler = this.props.onForgetClick;
+ break;
+ }
+ case MessageCase.OtherThreePIDError: {
+ if (roomName) {
+ title = _t("room|3pid_invite_error_title_room", { roomName });
+ } else {
+ title = _t("room|3pid_invite_error_title");
+ }
+ const joinRule = this.joinRule();
+ const errCodeMessage = _t("room|3pid_invite_error_description", {
+ errcode: this.state.threePidFetchError?.errcode || _t("error|unknown_error_code"),
+ });
+ switch (joinRule) {
+ case "invite":
+ subTitle = [_t("room|3pid_invite_error_invite_subtitle"), errCodeMessage];
+ primaryActionLabel = _t("room|3pid_invite_error_invite_action");
+ primaryActionHandler = this.props.onJoinClick;
+ break;
+ case "public":
+ subTitle = _t("room|3pid_invite_error_public_subtitle");
+ primaryActionLabel = _t("room|join_the_discussion");
+ primaryActionHandler = this.props.onJoinClick;
+ break;
+ default:
+ subTitle = errCodeMessage;
+ primaryActionLabel = _t("room|3pid_invite_error_invite_action");
+ primaryActionHandler = this.props.onJoinClick;
+ break;
+ }
+ break;
+ }
+ case MessageCase.InvitedEmailNotFoundInAccount: {
+ if (roomName) {
+ title = _t("room|3pid_invite_email_not_found_account_room", {
+ roomName,
+ email: this.props.invitedEmail,
+ });
+ } else {
+ title = _t("room|3pid_invite_email_not_found_account", {
+ email: this.props.invitedEmail,
+ });
+ }
+
+ subTitle = _t("room|link_email_to_receive_3pid_invite", { brand });
+ primaryActionLabel = _t("room|join_the_discussion");
+ primaryActionHandler = this.props.onJoinClick;
+ break;
+ }
+ case MessageCase.InvitedEmailNoIdentityServer: {
+ if (roomName) {
+ title = _t("room|invite_sent_to_email_room", {
+ roomName,
+ email: this.props.invitedEmail,
+ });
+ } else {
+ title = _t("room|invite_sent_to_email", { email: this.props.invitedEmail });
+ }
+
+ subTitle = _t("room|3pid_invite_no_is_subtitle", {
+ brand,
+ });
+ primaryActionLabel = _t("room|join_the_discussion");
+ primaryActionHandler = this.props.onJoinClick;
+ break;
+ }
+ case MessageCase.InvitedEmailMismatch: {
+ if (roomName) {
+ title = _t("room|invite_sent_to_email_room", {
+ roomName,
+ email: this.props.invitedEmail,
+ });
+ } else {
+ title = _t("room|invite_sent_to_email", { email: this.props.invitedEmail });
+ }
+
+ subTitle = _t("room|invite_email_mismatch_suggestion", { brand });
+ primaryActionLabel = _t("room|join_the_discussion");
+ primaryActionHandler = this.props.onJoinClick;
+ break;
+ }
+ case MessageCase.Invite: {
+ const avatar = ;
+
+ const inviteMember = this.getInviteMember();
+ let inviterElement: JSX.Element;
+ if (inviteMember) {
+ inviterElement = (
+
+ {inviteMember.rawDisplayName} (
+ {inviteMember.userId})
+
+ );
+ } else {
+ inviterElement = {this.props.inviterName} ;
+ }
+
+ const isDM = this.isDMInvite();
+ if (isDM) {
+ title = _t("room|dm_invite_title", {
+ user: inviteMember?.name ?? this.props.inviterName,
+ });
+ subTitle = [avatar, _t("room|dm_invite_subtitle", {}, { userName: () => inviterElement })];
+ primaryActionLabel = _t("room|dm_invite_action");
+ } else {
+ title = _t("room|invite_title", { roomName });
+ subTitle = [avatar, _t("room|invite_subtitle", {}, { userName: () => inviterElement })];
+ primaryActionLabel = _t("action|accept");
+ }
+
+ const myUserId = MatrixClientPeg.safeGet().getSafeUserId();
+ const member = this.props.room?.currentState.getMember(myUserId);
+ const memberEventContent = member?.events.member?.getContent();
+
+ if (memberEventContent?.reason) {
+ reasonElement = (
+
+ );
+ }
+
+ primaryActionHandler = this.props.onJoinClick;
+ secondaryActionLabel = _t("action|reject");
+ secondaryActionHandler = this.props.onRejectClick;
+
+ if (this.props.onRejectAndIgnoreClick) {
+ extraComponents.push(
+
+ {_t("room|invite_reject_ignore")}
+ ,
+ );
+ }
+ break;
+ }
+ case MessageCase.ViewingRoom: {
+ if (this.props.canPreview) {
+ title = _t("room|peek_join_prompt", { roomName });
+ } else if (roomName) {
+ title = _t("room|no_peek_join_prompt", { roomName });
+ } else {
+ title = _t("room|no_peek_no_name_join_prompt");
+ }
+ primaryActionLabel = _t("room|join_the_discussion");
+ primaryActionHandler = this.props.onJoinClick;
+ break;
+ }
+ case MessageCase.RoomNotFound: {
+ if (roomName) {
+ title = _t("room|not_found_title_name", { roomName });
+ } else {
+ title = _t("room|not_found_title");
+ }
+ subTitle = _t("room|not_found_subtitle");
+ break;
+ }
+ case MessageCase.OtherError: {
+ if (roomName) {
+ title = _t("room|inaccessible_name", { roomName });
+ } else {
+ title = _t("room|inaccessible");
+ }
+ subTitle = [
+ _t("room|inaccessible_subtitle_1"),
+ _t(
+ "room|inaccessible_subtitle_2",
+ { errcode: String(this.props.error?.errcode) },
+ {
+ issueLink: (label) => (
+
+ {label}
+
+ ),
+ },
+ ),
+ ];
+ break;
+ }
+ case MessageCase.PromptAskToJoin: {
+ if (roomName) {
+ title = _t("room|knock_prompt_name", { roomName });
+ } else {
+ title = _t("room|knock_prompt");
+ }
+
+ const avatar = ;
+ subTitle = [avatar, _t("room|knock_subtitle")];
+
+ reasonElement = (
+
+ );
+
+ primaryActionHandler = (): void =>
+ this.props.onSubmitAskToJoin && this.props.onSubmitAskToJoin(this.state.reason);
+ primaryActionLabel = _t("room|knock_send_action");
+
+ break;
+ }
+ case MessageCase.Knocked: {
+ title = _t("room|knock_sent");
+
+ subTitle = [
+ <>
+
+ {_t("room|knock_sent_subtitle")}
+ >,
+ ];
+
+ secondaryActionHandler = this.props.onCancelAskToJoin;
+ secondaryActionLabel = _t("room|knock_cancel_action");
+
+ break;
+ }
+ }
+
+ let subTitleElements;
+ if (subTitle) {
+ if (!Array.isArray(subTitle)) {
+ subTitle = [subTitle];
+ }
+ subTitleElements = subTitle.map((t, i) => {t}
);
+ }
+
+ let titleElement;
+ if (showSpinner) {
+ titleElement = (
+
+
+ {title}
+
+ );
+ } else {
+ titleElement = {title} ;
+ }
+
+ let primaryButton;
+ if (primaryActionHandler) {
+ primaryButton = (
+
+ {primaryActionLabel}
+
+ );
+ }
+
+ let secondaryButton;
+ if (secondaryActionHandler) {
+ secondaryButton = (
+
+ {secondaryActionLabel}
+
+ );
+ }
+
+ if (isCommunityRoom) {
+ secondaryButton = primaryButton;
+ primaryButton = ;
+ titleElement = ;
+ }
+
+ const isPanel = this.props.canPreview;
+
+ const classes = classNames("mx_RoomPreviewBar", `mx_RoomPreviewBar_${messageCase}`, {
+ mx_RoomPreviewBar_panel: isPanel,
+ mx_RoomPreviewBar_dialog: !isPanel,
+ });
+
+ // ensure correct tab order for both views
+ const actions = isPanel ? (
+ <>
+ {secondaryButton}
+ {extraComponents}
+ {primaryButton}
+ >
+ ) : (
+ <>
+ {primaryButton}
+ {extraComponents}
+ {secondaryButton}
+ >
+ );
+
+ return (
+
+
+ {titleElement}
+ {subTitleElements}
+
+ {reasonElement}
+
+ {actions}
+
+
{footer}
+
+ );
+ }
+}
diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx
new file mode 100644
index 00000000000..adccfb549ed
--- /dev/null
+++ b/src/components/views/rooms/RoomSublist.tsx
@@ -0,0 +1,918 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017, 2018 Vector Creations Ltd
+Copyright 2020 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 { Room } from "matrix-js-sdk/src/matrix";
+import classNames from "classnames";
+import { Enable, Resizable } from "re-resizable";
+import { Direction } from "re-resizable/lib/resizer";
+import * as React from "react";
+import { ComponentType, createRef, ReactComponentElement, ReactNode } from "react";
+import { polyfillTouchEvent } from "matrix-react-sdk/src/@types/polyfill";
+import { KeyBindingAction } from "matrix-react-sdk/src/accessibility/KeyboardShortcuts";
+import { RovingAccessibleButton, RovingTabIndexWrapper } from "matrix-react-sdk/src/accessibility/RovingTabIndex";
+import { Action } from "matrix-react-sdk/src/dispatcher/actions";
+import defaultDispatcher, { MatrixDispatcher } from "matrix-react-sdk/src/dispatcher/dispatcher";
+import { ActionPayload } from "matrix-react-sdk/src/dispatcher/payloads";
+import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload";
+import { getKeyBindingsManager } from "matrix-react-sdk/src/KeyBindingsManager";
+import { _t } from "matrix-react-sdk/src/languageHandler";
+import { ListNotificationState } from "matrix-react-sdk/src/stores/notifications/ListNotificationState";
+import { RoomNotificationStateStore } from "matrix-react-sdk/src/stores/notifications/RoomNotificationStateStore";
+import { ListAlgorithm, SortAlgorithm } from "matrix-react-sdk/src/stores/room-list/algorithms/models";
+import { ListLayout } from "matrix-react-sdk/src/stores/room-list/ListLayout";
+import { DefaultTagID, TagID } from "matrix-react-sdk/src/stores/room-list/models";
+import RoomListLayoutStore from "matrix-react-sdk/src/stores/room-list/RoomListLayoutStore";
+import RoomListStore, {
+ LISTS_UPDATE_EVENT,
+ LISTS_LOADING_EVENT,
+} from "matrix-react-sdk/src/stores/room-list/RoomListStore";
+import { arrayFastClone, arrayHasOrderChange } from "matrix-react-sdk/src/utils/arrays";
+import { objectExcluding, objectHasDiff } from "matrix-react-sdk/src/utils/objects";
+import ResizeNotifier from "matrix-react-sdk/src/utils/ResizeNotifier";
+import ContextMenu, {
+ ChevronFace,
+ ContextMenuTooltipButton,
+ StyledMenuItemCheckbox,
+ StyledMenuItemRadio,
+} from "matrix-react-sdk/src/components/structures/ContextMenu";
+import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton";
+import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton";
+import ExtraTile from "matrix-react-sdk/src/components/views/rooms/ExtraTile";
+import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore";
+import { SlidingSyncManager } from "matrix-react-sdk/src/SlidingSyncManager";
+import NotificationBadge from "matrix-react-sdk/src/components/views/rooms/NotificationBadge";
+
+import RoomTile from "./RoomTile";
+import { CustomTagID, SuperheroTagID } from "../../../stores/room-list/custom-models";
+import { isVerifiedRoom } from "../../../hooks/useVerifiedRoom";
+
+const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS
+const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
+export const HEADER_HEIGHT = 32; // As defined by CSS
+
+const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
+
+// HACK: We really shouldn't have to do this.
+polyfillTouchEvent();
+
+export interface IAuxButtonProps {
+ tabIndex: number;
+ dispatcher?: MatrixDispatcher;
+}
+
+interface IProps {
+ forRooms: boolean;
+ startAsHidden: boolean;
+ label: string;
+ AuxButtonComponent?: ComponentType;
+ isMinimized: boolean;
+ tagId: TagID;
+ showSkeleton?: boolean;
+ alwaysVisible?: boolean;
+ forceExpanded?: boolean;
+ resizeNotifier: ResizeNotifier;
+ extraTiles?: ReactComponentElement[] | null;
+ onListCollapse?: (isExpanded: boolean) => void;
+}
+
+function getLabelId(tagId: TagID): string {
+ return `mx_RoomSublist_label_${tagId}`;
+}
+
+// TODO: Use re-resizer's NumberSize when it is exposed as the type
+interface ResizeDelta {
+ width: number;
+ height: number;
+}
+
+type PartialDOMRect = Pick;
+
+interface IState {
+ contextMenuPosition?: PartialDOMRect;
+ isResizing: boolean;
+ isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
+ height: number;
+ rooms: Room[];
+ roomsLoading: boolean;
+}
+
+export default class RoomSublist extends React.Component {
+ private headerButton = createRef();
+ private sublistRef = createRef();
+ private tilesRef = createRef();
+ private dispatcherRef?: string;
+ private layout: ListLayout;
+ private heightAtStart: number;
+ private notificationState: ListNotificationState;
+
+ private slidingSyncMode: boolean;
+
+ public constructor(props: IProps) {
+ super(props);
+ // when this setting is toggled it restarts the app so it's safe to not watch this.
+ this.slidingSyncMode = SettingsStore.getValue("feature_sliding_sync");
+
+ this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
+ this.heightAtStart = 0;
+ this.notificationState = RoomNotificationStateStore.instance.getListState(this.getTagId());
+ this.state = {
+ isResizing: false,
+ isExpanded: !this.layout.isCollapsed,
+ height: 0, // to be fixed in a moment, we need `rooms` to calculate this.
+ rooms: this.getFilteredRooms(this.props.tagId),
+ roomsLoading: false,
+ };
+ // Why Object.assign() and not this.state.height? Because TypeScript says no.
+ this.state = Object.assign(this.state, { height: this.calculateInitialHeight() });
+ }
+
+ private getTagId(): TagID {
+ return this.props.tagId === SuperheroTagID.CommunityRooms ? DefaultTagID.Untagged : this.props.tagId;
+ }
+
+ private getFilteredRooms(tagId: CustomTagID): Room[] {
+ const listRooms = arrayFastClone(RoomListStore.instance.orderedLists[this.getTagId()] || []);
+
+ return listRooms?.filter((room) => {
+ const verifiedRoom = isVerifiedRoom(room.name);
+ const isCommunityRoom = verifiedRoom.isCommunityRoom || verifiedRoom.isTokenGatedRoom;
+
+ if (tagId === SuperheroTagID.CommunityRooms) {
+ return isCommunityRoom;
+ }
+ return !isCommunityRoom;
+ });
+ }
+
+ private calculateInitialHeight(): number {
+ const requestedVisibleTiles = Math.max(Math.floor(this.layout.visibleTiles), this.layout.minVisibleTiles);
+ const tileCount = Math.min(this.numTiles, requestedVisibleTiles);
+ return this.layout.tilesToPixelsWithPadding(tileCount, this.padding);
+ }
+
+ private get padding(): number {
+ let padding = RESIZE_HANDLE_HEIGHT;
+ // this is used for calculating the max height of the whole container,
+ // and takes into account whether there should be room reserved for the show more/less button
+ // when fully expanded. We can't rely purely on the layout's defaultVisible tile count
+ // because there are conditions in which we need to know that the 'show more' button
+ // is present while well under the default tile limit.
+ const needsShowMore = this.numTiles > this.numVisibleTiles;
+
+ // ...but also check this or we'll miss if the section is expanded and we need a
+ // 'show less'
+ const needsShowLess = this.numTiles > this.layout.defaultVisibleTiles;
+
+ if (needsShowMore || needsShowLess) {
+ padding += SHOW_N_BUTTON_HEIGHT;
+ }
+ return padding;
+ }
+
+ private get extraTiles(): ReactComponentElement[] | null {
+ return this.props.extraTiles ?? null;
+ }
+
+ private get numTiles(): number {
+ return RoomSublist.calcNumTiles(this.state.rooms, this.extraTiles);
+ }
+
+ private static calcNumTiles(rooms: Room[], extraTiles?: any[] | null): number {
+ return (rooms || []).length + (extraTiles || []).length;
+ }
+
+ private get numVisibleTiles(): number {
+ if (this.slidingSyncMode) {
+ return this.state.rooms.length;
+ }
+ const nVisible = Math.ceil(this.layout.visibleTiles);
+ return Math.min(nVisible, this.numTiles);
+ }
+
+ public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void {
+ const prevExtraTiles = prevProps.extraTiles;
+ // as the rooms can come in one by one we need to reevaluate
+ // the amount of available rooms to cap the amount of requested visible rooms by the layout
+ if (RoomSublist.calcNumTiles(prevState.rooms, prevExtraTiles) !== this.numTiles) {
+ this.setState({ height: this.calculateInitialHeight() });
+ }
+ }
+
+ public shouldComponentUpdate(nextProps: Readonly, nextState: Readonly): boolean {
+ if (objectHasDiff(this.props, nextProps)) {
+ // Something we don't care to optimize has updated, so update.
+ return true;
+ }
+
+ // Do the same check used on props for state, without the rooms we're going to no-op
+ const prevStateNoRooms = objectExcluding(this.state, ["rooms"]);
+ const nextStateNoRooms = objectExcluding(nextState, ["rooms"]);
+ if (objectHasDiff(prevStateNoRooms, nextStateNoRooms)) {
+ return true;
+ }
+
+ // If we're supposed to handle extra tiles, take the performance hit and re-render all the
+ // time so we don't have to consider them as part of the visible room optimization.
+ const prevExtraTiles = this.props.extraTiles || [];
+ const nextExtraTiles = nextProps.extraTiles || [];
+ if (prevExtraTiles.length > 0 || nextExtraTiles.length > 0) {
+ return true;
+ }
+
+ // If we're about to update the height of the list, we don't really care about which rooms
+ // are visible or not for no-op purposes, so ensure that the height calculation runs through.
+ if (RoomSublist.calcNumTiles(nextState.rooms, nextExtraTiles) !== this.numTiles) {
+ return true;
+ }
+
+ // Before we go analyzing the rooms, we can see if we're collapsed. If we're collapsed, we don't need
+ // to render anything. We do this after the height check though to ensure that the height gets appropriately
+ // calculated for when/if we become uncollapsed.
+ if (!nextState.isExpanded) {
+ return false;
+ }
+
+ // Quickly double check we're not about to break something due to the number of rooms changing.
+ if (this.state.rooms.length !== nextState.rooms.length) {
+ return true;
+ }
+
+ // Finally, determine if the room update (as presumably that's all that's left) is within
+ // our visible range. If it is, then do a render. If the update is outside our visible range
+ // then we can skip the update.
+ //
+ // We also optimize for order changing here: if the update did happen in our visible range
+ // but doesn't result in the list re-sorting itself then there's no reason for us to update
+ // on our own.
+ const prevSlicedRooms = this.state.rooms.slice(0, this.numVisibleTiles);
+ const nextSlicedRooms = nextState.rooms.slice(0, this.numVisibleTiles);
+ if (arrayHasOrderChange(prevSlicedRooms, nextSlicedRooms)) {
+ return true;
+ }
+
+ // Finally, nothing happened so no-op the update
+ return false;
+ }
+
+ public componentDidMount(): void {
+ this.dispatcherRef = defaultDispatcher.register(this.onAction);
+ RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated);
+ RoomListStore.instance.on(LISTS_LOADING_EVENT, this.onListsLoading);
+
+ // Using the passive option to not block the main thread
+ // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
+ this.tilesRef.current?.addEventListener("scroll", this.onScrollPrevent, { passive: true });
+ }
+
+ public componentWillUnmount(): void {
+ if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
+ RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated);
+ RoomListStore.instance.off(LISTS_LOADING_EVENT, this.onListsLoading);
+ this.tilesRef.current?.removeEventListener("scroll", this.onScrollPrevent);
+ }
+
+ private onListsLoading = (tagId: TagID, isLoading: boolean): void => {
+ if (this.props.tagId !== tagId) {
+ return;
+ }
+ this.setState({
+ roomsLoading: isLoading,
+ });
+ };
+
+ private onListsUpdated = (): void => {
+ const stateUpdates = {} as IState;
+ const currentRooms = this.state.rooms;
+ const newRooms = this.getFilteredRooms(this.props.tagId);
+ if (arrayHasOrderChange(currentRooms, newRooms)) {
+ stateUpdates.rooms = newRooms;
+ }
+
+ if (Object.keys(stateUpdates).length > 0) {
+ this.setState(stateUpdates);
+ }
+ };
+
+ private onAction = (payload: ActionPayload): void => {
+ if (payload.action === Action.ViewRoom && payload.show_room_tile && this.state.rooms) {
+ // XXX: we have to do this a tick later because we have incorrect intermediate props during a room change
+ // where we lose the room we are changing from temporarily and then it comes back in an update right after.
+ setImmediate(() => {
+ const roomIndex = this.state.rooms.findIndex((r) => r.roomId === payload.room_id);
+
+ if (!this.state.isExpanded && roomIndex > -1) {
+ this.toggleCollapsed();
+ }
+ // extend the visible section to include the room if it is entirely invisible
+ if (roomIndex >= this.numVisibleTiles) {
+ this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
+ this.forceUpdate(); // because the layout doesn't trigger a re-render
+ }
+ });
+ }
+ };
+
+ private applyHeightChange(newHeight: number): void {
+ const heightInTiles = Math.ceil(this.layout.pixelsToTiles(newHeight - this.padding));
+ this.layout.visibleTiles = Math.min(this.numTiles, heightInTiles);
+ }
+
+ private onResize = (
+ e: MouseEvent | TouchEvent,
+ travelDirection: Direction,
+ refToElement: HTMLElement,
+ delta: ResizeDelta,
+ ): void => {
+ const newHeight = this.heightAtStart + delta.height;
+ this.applyHeightChange(newHeight);
+ this.setState({ height: newHeight });
+ };
+
+ private onResizeStart = (): void => {
+ this.heightAtStart = this.state.height;
+ this.setState({ isResizing: true });
+ };
+
+ private onResizeStop = (
+ e: MouseEvent | TouchEvent,
+ travelDirection: Direction,
+ refToElement: HTMLElement,
+ delta: ResizeDelta,
+ ): void => {
+ const newHeight = this.heightAtStart + delta.height;
+ this.applyHeightChange(newHeight);
+ this.setState({ isResizing: false, height: newHeight });
+ };
+
+ private onShowAllClick = async (): Promise => {
+ if (this.slidingSyncMode) {
+ const count = RoomListStore.instance.getCount(this.props.tagId);
+ await SlidingSyncManager.instance.ensureListRegistered(this.props.tagId, {
+ ranges: [[0, count]],
+ });
+ }
+ // read number of visible tiles before we mutate it
+ const numVisibleTiles = this.numVisibleTiles;
+ const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
+ this.applyHeightChange(newHeight);
+ this.setState({ height: newHeight }, () => {
+ // focus the top-most new room
+ this.focusRoomTile(numVisibleTiles);
+ });
+ };
+
+ private onShowLessClick = (): void => {
+ const newHeight = this.layout.tilesToPixelsWithPadding(this.layout.defaultVisibleTiles, this.padding);
+ this.applyHeightChange(newHeight);
+ this.setState({ height: newHeight });
+ };
+
+ private focusRoomTile = (index: number): void => {
+ if (!this.sublistRef.current) return;
+ const elements = this.sublistRef.current.querySelectorAll(".mx_RoomTile");
+ const element = elements && elements[index];
+ if (element) {
+ element.focus();
+ }
+ };
+
+ private onOpenMenuClick = (ev: ButtonEvent): void => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const target = ev.target as HTMLButtonElement;
+ this.setState({ contextMenuPosition: target.getBoundingClientRect() });
+ };
+
+ private onContextMenu = (ev: React.MouseEvent): void => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.setState({
+ contextMenuPosition: {
+ left: ev.clientX,
+ top: ev.clientY,
+ height: 0,
+ },
+ });
+ };
+
+ private onCloseMenu = (): void => {
+ this.setState({ contextMenuPosition: undefined });
+ };
+
+ private onUnreadFirstChanged = (): void => {
+ const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
+ const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
+ RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
+ this.forceUpdate(); // because if the sublist doesn't have any changes then we will miss the list order change
+ };
+
+ private onTagSortChanged = async (sort: SortAlgorithm): Promise => {
+ RoomListStore.instance.setTagSorting(this.getTagId(), sort);
+ this.forceUpdate();
+ };
+
+ private onMessagePreviewChanged = (): void => {
+ this.layout.showPreviews = !this.layout.showPreviews;
+ this.forceUpdate(); // because the layout doesn't trigger a re-render
+ };
+
+ private onBadgeClick = (ev: React.MouseEvent): void => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ let room;
+ if (this.props.tagId === DefaultTagID.Invite) {
+ // switch to first room as that'll be the top of the list for the user
+ room = this.state.rooms && this.state.rooms[0];
+ } else {
+ // find the first room with a count of the same colour as the badge count
+ room = RoomListStore.instance.orderedLists[this.getTagId()].find((r: Room) => {
+ const notifState = this.notificationState.getForRoom(r);
+ return notifState.count > 0 && notifState.level === this.notificationState.level;
+ });
+ }
+
+ if (room) {
+ defaultDispatcher.dispatch({
+ action: Action.ViewRoom,
+ room_id: room.roomId,
+ show_room_tile: true, // to make sure the room gets scrolled into view
+ metricsTrigger: "WebRoomListNotificationBadge",
+ metricsViaKeyboard: ev.type !== "click",
+ });
+ }
+ };
+
+ private onHeaderClick = (): void => {
+ const possibleSticky = this.headerButton.current?.parentElement;
+ const sublist = possibleSticky?.parentElement?.parentElement;
+ const list = sublist?.parentElement?.parentElement;
+ if (!possibleSticky || !list) return;
+
+ // the scrollTop is capped at the height of the header in LeftPanel, the top header is always sticky
+ const listScrollTop = Math.round(list.scrollTop);
+ const isAtTop = listScrollTop <= Math.round(HEADER_HEIGHT);
+ const isAtBottom = listScrollTop >= Math.round(list.scrollHeight - list.offsetHeight);
+ const isStickyTop = possibleSticky.classList.contains("mx_RoomSublist_headerContainer_stickyTop");
+ const isStickyBottom = possibleSticky.classList.contains("mx_RoomSublist_headerContainer_stickyBottom");
+
+ if ((isStickyBottom && !isAtBottom) || (isStickyTop && !isAtTop)) {
+ // is sticky - jump to list
+ sublist.scrollIntoView({ behavior: "smooth" });
+ } else {
+ // on screen - toggle collapse
+ const isExpanded = this.state.isExpanded;
+ this.toggleCollapsed();
+ // if the bottom list is collapsed then scroll it in so it doesn't expand off screen
+ if (!isExpanded && isStickyBottom) {
+ setImmediate(() => {
+ sublist.scrollIntoView({ behavior: "smooth" });
+ });
+ }
+ }
+ };
+
+ private toggleCollapsed = (): void => {
+ if (this.props.forceExpanded) return;
+ this.layout.isCollapsed = this.state.isExpanded;
+ this.setState({ isExpanded: !this.layout.isCollapsed });
+ if (this.props.onListCollapse) {
+ this.props.onListCollapse(!this.layout.isCollapsed);
+ }
+ };
+
+ private onHeaderKeyDown = (ev: React.KeyboardEvent): void => {
+ const action = getKeyBindingsManager().getRoomListAction(ev);
+ switch (action) {
+ case KeyBindingAction.CollapseRoomListSection:
+ ev.stopPropagation();
+ if (this.state.isExpanded) {
+ // Collapse the room sublist if it isn't already
+ this.toggleCollapsed();
+ }
+ break;
+ case KeyBindingAction.ExpandRoomListSection: {
+ ev.stopPropagation();
+ if (!this.state.isExpanded) {
+ // Expand the room sublist if it isn't already
+ this.toggleCollapsed();
+ } else if (this.sublistRef.current) {
+ // otherwise focus the first room
+ const element = this.sublistRef.current.querySelector(".mx_RoomTile") as HTMLDivElement;
+ if (element) {
+ element.focus();
+ }
+ }
+ break;
+ }
+ }
+ };
+
+ private onKeyDown = (ev: React.KeyboardEvent): void => {
+ const action = getKeyBindingsManager().getAccessibilityAction(ev);
+ switch (action) {
+ // On ArrowLeft go to the sublist header
+ case KeyBindingAction.ArrowLeft:
+ ev.stopPropagation();
+ this.headerButton.current?.focus();
+ break;
+ // Consume ArrowRight so it doesn't cause focus to get sent to composer
+ case KeyBindingAction.ArrowRight:
+ ev.stopPropagation();
+ }
+ };
+
+ private renderVisibleTiles(): React.ReactElement[] {
+ if (!this.state.isExpanded && !this.props.forceExpanded) {
+ // don't waste time on rendering
+ return [];
+ }
+
+ const tiles: React.ReactElement[] = [];
+
+ if (this.state.rooms) {
+ let visibleRooms = this.state.rooms;
+ if (!this.props.forceExpanded) {
+ visibleRooms = visibleRooms.slice(0, this.numVisibleTiles);
+ }
+
+ for (const room of visibleRooms) {
+ tiles.push(
+ ,
+ );
+ }
+ }
+
+ if (this.extraTiles) {
+ // HACK: We break typing here, but this 'extra tiles' property shouldn't exist.
+ (tiles as any[]).push(...this.extraTiles);
+ }
+
+ // We only have to do this because of the extra tiles. We do it conditionally
+ // to avoid spending cycles on slicing. It's generally fine to do this though
+ // as users are unlikely to have more than a handful of tiles when the extra
+ // tiles are used.
+ if (tiles.length > this.numVisibleTiles && !this.props.forceExpanded) {
+ return tiles.slice(0, this.numVisibleTiles);
+ }
+
+ return tiles;
+ }
+
+ private renderMenu(): ReactNode {
+ if (this.props.tagId === DefaultTagID.Suggested) return null; // not sortable
+
+ let contextMenu: JSX.Element | undefined;
+ if (this.state.contextMenuPosition) {
+ let isAlphabetical = RoomListStore.instance.getTagSorting(this.getTagId()) === SortAlgorithm.Alphabetic;
+ let isUnreadFirst = RoomListStore.instance.getListOrder(this.getTagId()) === ListAlgorithm.Importance;
+ if (this.slidingSyncMode) {
+ const slidingList = SlidingSyncManager.instance.slidingSync?.getListParams(this.props.tagId);
+ isAlphabetical = (slidingList?.sort || [])[0] === "by_name";
+ isUnreadFirst = (slidingList?.sort || [])[0] === "by_notification_level";
+ }
+
+ // Invites don't get some nonsense options, so only add them if we have to.
+ let otherSections: JSX.Element | undefined;
+ if (this.props.tagId !== DefaultTagID.Invite) {
+ otherSections = (
+
+
+
+ {_t("common|appearance")}
+
+ {_t("room_list|sort_unread_first")}
+
+
+ {_t("room_list|show_previews")}
+
+
+
+ );
+ }
+
+ contextMenu = (
+
+
+
+ {_t("room_list|sort_by")}
+ this.onTagSortChanged(SortAlgorithm.Recent)}
+ checked={!isAlphabetical}
+ name={`mx_${this.props.tagId}_sortBy`}
+ >
+ {_t("room_list|sort_by_activity")}
+
+ this.onTagSortChanged(SortAlgorithm.Alphabetic)}
+ checked={isAlphabetical}
+ name={`mx_${this.props.tagId}_sortBy`}
+ >
+ {_t("room_list|sort_by_alphabet")}
+
+
+ {otherSections}
+
+
+ );
+ }
+
+ return (
+
+
+ {contextMenu}
+
+ );
+ }
+
+ private renderHeader(): React.ReactElement {
+ return (
+
+ {({ onFocus, isActive, ref }) => {
+ const tabIndex = isActive ? 0 : -1;
+
+ let ariaLabel = _t("a11y_jump_first_unread_room");
+ if (this.props.tagId === DefaultTagID.Invite) {
+ ariaLabel = _t("a11y|jump_first_invite");
+ }
+
+ const badge = (
+
+ );
+
+ let addRoomButton: JSX.Element | undefined;
+ if (this.props.AuxButtonComponent) {
+ const AuxButtonComponent = this.props.AuxButtonComponent;
+ addRoomButton = ;
+ }
+
+ const collapseClasses = classNames({
+ mx_RoomSublist_collapseBtn: true,
+ mx_RoomSublist_collapseBtn_collapsed: !this.state.isExpanded && !this.props.forceExpanded,
+ });
+
+ const classes = classNames({
+ mx_RoomSublist_headerContainer: true,
+ mx_RoomSublist_headerContainer_withAux: !!addRoomButton,
+ });
+
+ const badgeContainer = {badge}
;
+
+ let Button: React.ComponentType> = AccessibleButton;
+ if (this.props.isMinimized) {
+ Button = AccessibleTooltipButton;
+ }
+
+ // Note: the addRoomButton conditionally gets moved around
+ // the DOM depending on whether or not the list is minimized.
+ // If we're minimized, we want it below the header so it
+ // doesn't become sticky.
+ // The same applies to the notification badge.
+ return (
+
+
+
+
+
+ {this.props.label}
+
+ {this.renderMenu()}
+ {this.props.isMinimized ? null : badgeContainer}
+ {this.props.isMinimized ? null : addRoomButton}
+
+
+ {this.props.isMinimized ? badgeContainer : null}
+ {this.props.isMinimized ? addRoomButton : null}
+
+ );
+ }}
+
+ );
+ }
+
+ private onScrollPrevent(e: Event): void {
+ // the RoomTile calls scrollIntoView and the browser may scroll a div we do not wish to be scrollable
+ // this fixes https://github.com/vector-im/element-web/issues/14413
+ (e.target as HTMLDivElement).scrollTop = 0;
+ }
+
+ public render(): React.ReactElement {
+ const visibleTiles = this.renderVisibleTiles();
+ const hidden = !this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true;
+ const classes = classNames({
+ mx_RoomSublist: true,
+ mx_RoomSublist_hasMenuOpen: !!this.state.contextMenuPosition,
+ mx_RoomSublist_minimized: this.props.isMinimized,
+ mx_RoomSublist_hidden: hidden,
+ });
+
+ let content: JSX.Element | undefined;
+ if (this.state.roomsLoading) {
+ content =
;
+ } else if (visibleTiles.length > 0 && this.props.forceExpanded) {
+ content = (
+
+ );
+ } else if (visibleTiles.length > 0) {
+ const layout = this.layout; // to shorten calls
+
+ const minTiles = Math.min(layout.minVisibleTiles, this.numTiles);
+ const showMoreAtMinHeight = minTiles < this.numTiles;
+ const minHeightPadding = RESIZE_HANDLE_HEIGHT + (showMoreAtMinHeight ? SHOW_N_BUTTON_HEIGHT : 0);
+ const minTilesPx = layout.tilesToPixelsWithPadding(minTiles, minHeightPadding);
+ const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
+ const showMoreBtnClasses = classNames({
+ mx_RoomSublist_showNButton: true,
+ });
+
+ // If we're hiding rooms, show a 'show more' button to the user. This button
+ // floats above the resize handle, if we have one present. If the user has all
+ // tiles visible, it becomes 'show less'.
+ let showNButton: JSX.Element | undefined;
+ const hasMoreSlidingSync =
+ this.slidingSyncMode && RoomListStore.instance.getCount(this.getTagId()) > this.state.rooms.length;
+ if (maxTilesPx > this.state.height || hasMoreSlidingSync) {
+ // the height of all the tiles is greater than the section height: we need a 'show more' button
+ const nonPaddedHeight = this.state.height - RESIZE_HANDLE_HEIGHT - SHOW_N_BUTTON_HEIGHT;
+ const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight);
+ let numMissing = this.numTiles - amountFullyShown;
+ if (this.slidingSyncMode) {
+ numMissing = RoomListStore.instance.getCount(this.getTagId()) - amountFullyShown;
+ }
+ const label = _t("room_list|show_n_more", { count: numMissing });
+ let showMoreText: ReactNode = {label} ;
+ if (this.props.isMinimized) showMoreText = null;
+ showNButton = (
+
+
+ {/* set by CSS masking */}
+
+ {showMoreText}
+
+ );
+ } else if (this.numTiles > this.layout.defaultVisibleTiles) {
+ // we have all tiles visible - add a button to show less
+ const label = _t("room_list|show_less");
+ let showLessText: ReactNode = {label} ;
+ if (this.props.isMinimized) showLessText = null;
+ showNButton = (
+
+
+ {/* set by CSS masking */}
+
+ {showLessText}
+
+ );
+ }
+
+ // Figure out if we need a handle
+ const handles: Enable = {
+ bottom: true, // the only one we need, but the others must be explicitly false
+ bottomLeft: false,
+ bottomRight: false,
+ left: false,
+ right: false,
+ top: false,
+ topLeft: false,
+ topRight: false,
+ };
+ if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) {
+ // we're at a minimum, don't have a bottom handle
+ handles.bottom = false;
+ }
+
+ // We have to account for padding so we can accommodate a 'show more' button and
+ // the resize handle, which are pinned to the bottom of the container. This is the
+ // easiest way to have a resize handle below the button as otherwise we're writing
+ // our own resize handling and that doesn't sound fun.
+ //
+ // The layout class has some helpers for dealing with padding, as we don't want to
+ // apply it in all cases. If we apply it in all cases, the resizing feels like it
+ // goes backwards and can become wildly incorrect (visibleTiles says 18 when there's
+ // only mathematically 7 possible).
+
+ const handleWrapperClasses = classNames({
+ mx_RoomSublist_resizerHandles: true,
+ mx_RoomSublist_resizerHandles_showNButton: !!showNButton,
+ });
+
+ content = (
+
+
+
+ {visibleTiles}
+
+ {showNButton}
+
+
+ );
+ } else if (this.props.showSkeleton && this.state.isExpanded) {
+ content =
;
+ }
+
+ return (
+
+ {this.renderHeader()}
+ {content}
+
+ );
+ }
+}
diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx
new file mode 100644
index 00000000000..b2cc0f0bfce
--- /dev/null
+++ b/src/components/views/rooms/RoomTile.tsx
@@ -0,0 +1,519 @@
+/*
+Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
+Copyright 2015-2017, 2019-2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+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 classNames from "classnames";
+import { Room, RoomEvent } from "matrix-js-sdk/src/matrix";
+import React, { ReactElement, createRef } from "react";
+import { getKeyBindingsManager } from "matrix-react-sdk/src/KeyBindingsManager";
+import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg";
+import PosthogTrackers from "matrix-react-sdk/src/PosthogTrackers";
+import { RoomNotifState } from "matrix-react-sdk/src/RoomNotifs";
+import { KeyBindingAction } from "matrix-react-sdk/src/accessibility/KeyboardShortcuts";
+import { RovingTabIndexWrapper } from "matrix-react-sdk/src/accessibility/RovingTabIndex";
+import {
+ ChevronFace,
+ ContextMenuTooltipButton,
+ MenuProps,
+} from "matrix-react-sdk/src/components/structures/ContextMenu";
+import DecoratedRoomAvatar from "matrix-react-sdk/src/components/views/avatars/DecoratedRoomAvatar";
+import { RoomGeneralContextMenu } from "matrix-react-sdk/src/components/views/context_menus/RoomGeneralContextMenu";
+import { RoomNotificationContextMenu } from "matrix-react-sdk/src/components/views/context_menus/RoomNotificationContextMenu";
+import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton";
+import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton";
+import NotificationBadge from "matrix-react-sdk/src/components/views/rooms/NotificationBadge";
+import { RoomTileSubtitle } from "matrix-react-sdk/src/components/views/rooms/RoomTileSubtitle";
+import { SdkContextClass } from "matrix-react-sdk/src/contexts/SDKContext";
+import { shouldShowComponent } from "matrix-react-sdk/src/customisations/helpers/UIComponents";
+import { Action } from "matrix-react-sdk/src/dispatcher/actions";
+import defaultDispatcher from "matrix-react-sdk/src/dispatcher/dispatcher";
+import { ActionPayload } from "matrix-react-sdk/src/dispatcher/payloads";
+import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload";
+import { _t } from "matrix-react-sdk/src/languageHandler";
+import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore";
+import { UIComponent } from "matrix-react-sdk/src/settings/UIFeature";
+import { CallStore, CallStoreEvent } from "matrix-react-sdk/src/stores/CallStore";
+import { EchoChamber } from "matrix-react-sdk/src/stores/local-echo/EchoChamber";
+import { PROPERTY_UPDATED } from "matrix-react-sdk/src/stores/local-echo/GenericEchoChamber";
+import { CachedRoomKey, RoomEchoChamber } from "matrix-react-sdk/src/stores/local-echo/RoomEchoChamber";
+import {
+ NotificationState,
+ NotificationStateEvents,
+} from "matrix-react-sdk/src/stores/notifications/NotificationState";
+import { RoomNotificationStateStore } from "matrix-react-sdk/src/stores/notifications/RoomNotificationStateStore";
+import { MessagePreview, MessagePreviewStore } from "matrix-react-sdk/src/stores/room-list/MessagePreviewStore";
+import { DefaultTagID, TagID } from "matrix-react-sdk/src/stores/room-list/models";
+import { isKnockDenied } from "matrix-react-sdk/src/utils/membership";
+import { useHasRoomLiveVoiceBroadcast } from "matrix-react-sdk/src/voice-broadcast";
+
+import type { Call } from "matrix-react-sdk/src/models/Call";
+import { RoomName } from "../elements/RoomName";
+import { getRoomName } from "../../../hooks/useRoomName";
+
+interface Props {
+ room: Room;
+ showMessagePreview: boolean;
+ isMinimized: boolean;
+ tag: TagID;
+}
+
+interface ClassProps extends Props {
+ hasLiveVoiceBroadcast: boolean;
+}
+
+type PartialDOMRect = Pick;
+
+interface State {
+ selected: boolean;
+ notificationsMenuPosition: PartialDOMRect | null;
+ generalMenuPosition: PartialDOMRect | null;
+ call: Call | null;
+ messagePreview: MessagePreview | null;
+}
+
+const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
+
+export const contextMenuBelow = (elementRect: PartialDOMRect): MenuProps => {
+ // align the context menu's icons with the icon which opened the context menu
+ const left = elementRect.left + window.scrollX - 9;
+ const top = elementRect.bottom + window.scrollY + 17;
+ const chevronFace = ChevronFace.None;
+ return { left, top, chevronFace };
+};
+
+export class RoomTile extends React.PureComponent {
+ private dispatcherRef?: string;
+ private roomTileRef = createRef();
+ private notificationState: NotificationState;
+ private roomProps: RoomEchoChamber;
+
+ public constructor(props: ClassProps) {
+ super(props);
+
+ this.state = {
+ selected: SdkContextClass.instance.roomViewStore.getRoomId() === this.props.room.roomId,
+ notificationsMenuPosition: null,
+ generalMenuPosition: null,
+ call: CallStore.instance.getCall(this.props.room.roomId),
+ // generatePreview() will return nothing if the user has previews disabled
+ messagePreview: null,
+ };
+ this.generatePreview();
+
+ this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
+ this.roomProps = EchoChamber.forRoom(this.props.room);
+ }
+
+ private onRoomNameUpdate = (room: Room): void => {
+ this.forceUpdate();
+ };
+
+ private onNotificationUpdate = (): void => {
+ this.forceUpdate(); // notification state changed - update
+ };
+
+ private onRoomPropertyUpdate = (property: CachedRoomKey): void => {
+ if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
+ // else ignore - not important for this tile
+ };
+
+ private get showContextMenu(): boolean {
+ return (
+ this.props.tag !== DefaultTagID.Invite &&
+ this.props.room.getMyMembership() !== "knock" &&
+ !isKnockDenied(this.props.room) &&
+ shouldShowComponent(UIComponent.RoomOptionsMenu)
+ );
+ }
+
+ private get showMessagePreview(): boolean {
+ return !this.props.isMinimized && this.props.showMessagePreview;
+ }
+
+ public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void {
+ const showMessageChanged = prevProps.showMessagePreview !== this.props.showMessagePreview;
+ const minimizedChanged = prevProps.isMinimized !== this.props.isMinimized;
+ if (showMessageChanged || minimizedChanged) {
+ this.generatePreview();
+ }
+ if (prevProps.room?.roomId !== this.props.room?.roomId) {
+ MessagePreviewStore.instance.off(
+ MessagePreviewStore.getPreviewChangedEventName(prevProps.room),
+ this.onRoomPreviewChanged,
+ );
+ MessagePreviewStore.instance.on(
+ MessagePreviewStore.getPreviewChangedEventName(this.props.room),
+ this.onRoomPreviewChanged,
+ );
+ prevProps.room?.off(RoomEvent.Name, this.onRoomNameUpdate);
+ this.props.room?.on(RoomEvent.Name, this.onRoomNameUpdate);
+ }
+ }
+
+ public componentDidMount(): void {
+ // when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
+ if (this.state.selected) {
+ this.scrollIntoView();
+ }
+
+ SdkContextClass.instance.roomViewStore.addRoomListener(this.props.room.roomId, this.onActiveRoomUpdate);
+ this.dispatcherRef = defaultDispatcher.register(this.onAction);
+ MessagePreviewStore.instance.on(
+ MessagePreviewStore.getPreviewChangedEventName(this.props.room),
+ this.onRoomPreviewChanged,
+ );
+ this.notificationState.on(NotificationStateEvents.Update, this.onNotificationUpdate);
+ this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
+ this.props.room.on(RoomEvent.Name, this.onRoomNameUpdate);
+ CallStore.instance.on(CallStoreEvent.Call, this.onCallChanged);
+
+ // Recalculate the call for this room, since it could've changed between
+ // construction and mounting
+ this.setState({ call: CallStore.instance.getCall(this.props.room.roomId) });
+ }
+
+ public componentWillUnmount(): void {
+ SdkContextClass.instance.roomViewStore.removeRoomListener(this.props.room.roomId, this.onActiveRoomUpdate);
+ MessagePreviewStore.instance.off(
+ MessagePreviewStore.getPreviewChangedEventName(this.props.room),
+ this.onRoomPreviewChanged,
+ );
+ this.props.room.off(RoomEvent.Name, this.onRoomNameUpdate);
+ if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
+ this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate);
+ this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
+ CallStore.instance.off(CallStoreEvent.Call, this.onCallChanged);
+ }
+
+ private onAction = (payload: ActionPayload): void => {
+ if (
+ payload.action === Action.ViewRoom &&
+ payload.room_id === this.props.room.roomId &&
+ payload.show_room_tile
+ ) {
+ setImmediate(() => {
+ this.scrollIntoView();
+ });
+ }
+ };
+
+ private onRoomPreviewChanged = (room: Room): void => {
+ if (this.props.room && room.roomId === this.props.room.roomId) {
+ this.generatePreview();
+ }
+ };
+
+ private onCallChanged = (call: Call, roomId: string): void => {
+ if (roomId === this.props.room?.roomId) this.setState({ call });
+ };
+
+ private async generatePreview(): Promise {
+ if (!this.showMessagePreview) {
+ return;
+ }
+
+ const messagePreview =
+ (await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag)) ?? null;
+ this.setState({ messagePreview });
+ }
+
+ private scrollIntoView = (): void => {
+ if (!this.roomTileRef.current) return;
+ this.roomTileRef.current.scrollIntoView({
+ block: "nearest",
+ behavior: "auto",
+ });
+ };
+
+ private onTileClick = async (ev: ButtonEvent): Promise => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent);
+ const clearSearch = ([KeyBindingAction.Enter, KeyBindingAction.Space] as Array).includes(
+ action,
+ );
+
+ defaultDispatcher.dispatch({
+ action: Action.ViewRoom,
+ show_room_tile: true, // make sure the room is visible in the list
+ room_id: this.props.room.roomId,
+ clear_search: clearSearch,
+ metricsTrigger: "RoomList",
+ metricsViaKeyboard: ev.type !== "click",
+ });
+ };
+
+ private onActiveRoomUpdate = (isActive: boolean): void => {
+ this.setState({ selected: isActive });
+ };
+
+ private onNotificationsMenuOpenClick = (ev: ButtonEvent): void => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const target = ev.target as HTMLButtonElement;
+ this.setState({ notificationsMenuPosition: target.getBoundingClientRect() });
+
+ PosthogTrackers.trackInteraction("WebRoomListRoomTileNotificationsMenu", ev);
+ };
+
+ private onCloseNotificationsMenu = (): void => {
+ this.setState({ notificationsMenuPosition: null });
+ };
+
+ private onGeneralMenuOpenClick = (ev: ButtonEvent): void => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const target = ev.target as HTMLButtonElement;
+ this.setState({ generalMenuPosition: target.getBoundingClientRect() });
+ };
+
+ private onContextMenu = (ev: React.MouseEvent): void => {
+ // If we don't have a context menu to show, ignore the action.
+ if (!this.showContextMenu) return;
+
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.setState({
+ generalMenuPosition: {
+ left: ev.clientX,
+ bottom: ev.clientY,
+ },
+ });
+ };
+
+ private onCloseGeneralMenu = (): void => {
+ this.setState({ generalMenuPosition: null });
+ };
+
+ private renderNotificationsMenu(isActive: boolean): React.ReactElement | null {
+ if (
+ MatrixClientPeg.safeGet().isGuest() ||
+ this.props.tag === DefaultTagID.Archived ||
+ !this.showContextMenu ||
+ this.props.isMinimized
+ ) {
+ // the menu makes no sense in these cases so do not show one
+ return null;
+ }
+
+ const state = this.roomProps.notificationVolume;
+
+ const classes = classNames("mx_RoomTile_notificationsButton", {
+ // Show bell icon for the default case too.
+ mx_RoomNotificationContextMenu_iconBell: state === RoomNotifState.AllMessages,
+ mx_RoomNotificationContextMenu_iconBellDot: state === RoomNotifState.AllMessagesLoud,
+ mx_RoomNotificationContextMenu_iconBellMentions: state === RoomNotifState.MentionsOnly,
+ mx_RoomNotificationContextMenu_iconBellCrossed: state === RoomNotifState.Mute,
+
+ // Only show the icon by default if the room is overridden to muted.
+ // TODO: [FTUE Notifications] Probably need to detect global mute state
+ mx_RoomTile_notificationsButton_show: state === RoomNotifState.Mute,
+ });
+
+ return (
+
+
+ {this.state.notificationsMenuPosition && (
+
+ )}
+
+ );
+ }
+
+ private renderGeneralMenu(): React.ReactElement | null {
+ if (!this.showContextMenu) return null; // no menu to show
+ return (
+
+
+ {this.state.generalMenuPosition && (
+
+ PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", ev)
+ }
+ onPostInviteClick={(ev: ButtonEvent): void =>
+ PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", ev)
+ }
+ onPostSettingsClick={(ev: ButtonEvent): void =>
+ PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuSettingsItem", ev)
+ }
+ onPostLeaveClick={(ev: ButtonEvent): void =>
+ PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", ev)
+ }
+ />
+ )}
+
+ );
+ }
+
+ /**
+ * RoomTile has a subtile if one of the following applies:
+ * - there is a call
+ * - there is a live voice broadcast
+ * - message previews are enabled and there is a previewable message
+ */
+ private get shouldRenderSubtitle(): boolean {
+ return (
+ !!this.state.call ||
+ this.props.hasLiveVoiceBroadcast ||
+ (this.props.showMessagePreview && !!this.state.messagePreview)
+ );
+ }
+
+ public render(): React.ReactElement {
+ const classes = classNames({
+ mx_RoomTile: true,
+ mx_RoomTile_sticky:
+ SettingsStore.getValue("feature_ask_to_join") &&
+ (this.props.room.getMyMembership() === "knock" || isKnockDenied(this.props.room)),
+ mx_RoomTile_selected: this.state.selected,
+ mx_RoomTile_hasMenuOpen: !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition),
+ mx_RoomTile_minimized: this.props.isMinimized,
+ });
+
+ const name = getRoomName(this.props.room);
+ let badge: React.ReactNode;
+ if (!this.props.isMinimized && this.notificationState) {
+ // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
+ badge = (
+
+
+
+ );
+ }
+
+ const subtitle = this.shouldRenderSubtitle ? (
+
+ ) : null;
+
+ const titleClasses = classNames({
+ mx_RoomTile_title: true,
+ mx_RoomTile_titleWithSubtitle: !!subtitle,
+ mx_RoomTile_titleHasUnreadEvents: this.notificationState.isUnread,
+ });
+
+ const titleContainer = this.props.isMinimized ? null : (
+
+ );
+
+ let ariaLabel = name;
+ // The following labels are written in such a fashion to increase screen reader efficiency (speed).
+ if (this.props.tag === DefaultTagID.Invite) {
+ // append nothing
+ } else if (this.notificationState.hasMentions) {
+ ariaLabel +=
+ " " +
+ _t("a11y|n_unread_messages_mentions", {
+ count: this.notificationState.count,
+ });
+ } else if (this.notificationState.hasUnreadCount) {
+ ariaLabel +=
+ " " +
+ _t("a11y|n_unread_messages", {
+ count: this.notificationState.count,
+ });
+ } else if (this.notificationState.isUnread) {
+ ariaLabel += " " + _t("a11y|unread_messages");
+ }
+
+ let ariaDescribedBy: string;
+ if (this.showMessagePreview) {
+ ariaDescribedBy = messagePreviewId(this.props.room.roomId);
+ }
+
+ const props: Partial> = {};
+ let Button: React.ComponentType> = AccessibleButton;
+ if (this.props.isMinimized) {
+ Button = AccessibleTooltipButton;
+ props.title = name;
+ // force the tooltip to hide whilst we are showing the context menu
+ props.forceHide = !!this.state.generalMenuPosition;
+ }
+
+ return (
+
+
+ {({ onFocus, isActive, ref }): ReactElement => (
+
+
+ {titleContainer}
+ {badge}
+ {this.renderGeneralMenu()}
+ {this.renderNotificationsMenu(isActive)}
+
+ )}
+
+
+ );
+ }
+}
+
+const RoomTileHOC: React.FC = (props: Props) => {
+ const hasLiveVoiceBroadcast = useHasRoomLiveVoiceBroadcast(props.room);
+ return ;
+};
+
+export default RoomTileHOC;
diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx
new file mode 100644
index 00000000000..ae04cc82fa4
--- /dev/null
+++ b/src/components/views/spaces/SpacePanel.tsx
@@ -0,0 +1,419 @@
+/*
+Copyright 2021 - 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 classNames from "classnames";
+import { Room } from "matrix-js-sdk/src/matrix";
+import { IS_MAC, Key } from "matrix-react-sdk/src/Keyboard";
+import { ALTERNATE_KEY_NAME } from "matrix-react-sdk/src/accessibility/KeyboardShortcuts";
+import { RovingTabIndexProvider } from "matrix-react-sdk/src/accessibility/RovingTabIndex";
+import { useContextMenu } from "matrix-react-sdk/src/components/structures/ContextMenu";
+import IndicatorScrollbar from "matrix-react-sdk/src/components/structures/IndicatorScrollbar";
+import UserMenu from "matrix-react-sdk/src/components/structures/UserMenu";
+import IconizedContextMenu, {
+ IconizedContextMenuCheckbox,
+ IconizedContextMenuOptionList,
+} from "matrix-react-sdk/src/components/views/context_menus/IconizedContextMenu";
+import SpaceContextMenu from "matrix-react-sdk/src/components/views/context_menus/SpaceContextMenu";
+import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton";
+import QuickSettingsButton from "matrix-react-sdk/src/components/views/spaces/QuickSettingsButton";
+import SpaceCreateMenu from "matrix-react-sdk/src/components/views/spaces/SpaceCreateMenu";
+import { SpaceButton, SpaceItem } from "matrix-react-sdk/src/components/views/spaces/SpaceTreeLevel";
+import { shouldShowComponent } from "matrix-react-sdk/src/customisations/helpers/UIComponents";
+import { Action } from "matrix-react-sdk/src/dispatcher/actions";
+import defaultDispatcher from "matrix-react-sdk/src/dispatcher/dispatcher";
+import { ActionPayload } from "matrix-react-sdk/src/dispatcher/payloads";
+import { useDispatcher } from "matrix-react-sdk/src/hooks/useDispatcher";
+import { useEventEmitter, useEventEmitterState } from "matrix-react-sdk/src/hooks/useEventEmitter";
+import { useSettingValue } from "matrix-react-sdk/src/hooks/useSettings";
+import { _t } from "matrix-react-sdk/src/languageHandler";
+import { SettingLevel } from "matrix-react-sdk/src/settings/SettingLevel";
+import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore";
+import { UIComponent } from "matrix-react-sdk/src/settings/UIFeature";
+import UIStore from "matrix-react-sdk/src/stores/UIStore";
+import { NotificationState } from "matrix-react-sdk/src/stores/notifications/NotificationState";
+import {
+ RoomNotificationStateStore,
+ UPDATE_STATUS_INDICATOR,
+} from "matrix-react-sdk/src/stores/notifications/RoomNotificationStateStore";
+import {
+ MetaSpace,
+ SpaceKey,
+ UPDATE_HOME_BEHAVIOUR,
+ UPDATE_INVITED_SPACES,
+ UPDATE_SELECTED_SPACE,
+ UPDATE_TOP_LEVEL_SPACES,
+ getMetaSpaceName,
+} from "matrix-react-sdk/src/stores/spaces";
+import SpaceStore from "matrix-react-sdk/src/stores/spaces/SpaceStore";
+import React, {
+ ComponentProps,
+ Dispatch,
+ ReactElement,
+ ReactNode,
+ RefCallback,
+ SetStateAction,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import { DragDropContext, Draggable, Droppable, DroppableProvidedProps } from "react-beautiful-dnd";
+
+import SuperheroDexButton from "./SuperheroDexButton";
+
+const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => {
+ const invites = useEventEmitterState(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
+ return SpaceStore.instance.invitedSpaces;
+ });
+ const [metaSpaces, actualSpaces] = useEventEmitterState<[MetaSpace[], Room[]]>(
+ SpaceStore.instance,
+ UPDATE_TOP_LEVEL_SPACES,
+ () => [SpaceStore.instance.enabledMetaSpaces, SpaceStore.instance.spacePanelSpaces],
+ );
+ const activeSpace = useEventEmitterState(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
+ return SpaceStore.instance.activeSpace;
+ });
+ return [invites, metaSpaces, actualSpaces, activeSpace];
+};
+
+export const HomeButtonContextMenu: React.FC> = ({
+ onFinished,
+ hideHeader,
+ ...props
+}) => {
+ const allRoomsInHome = useSettingValue("Spaces.allRoomsInHome");
+
+ return (
+
+ {!hideHeader && {_t("common|home")}
}
+
+ {
+ onFinished();
+ SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.ACCOUNT, !allRoomsInHome);
+ }}
+ />
+
+
+ );
+};
+
+interface IMetaSpaceButtonProps extends ComponentProps {
+ selected: boolean;
+ isPanelCollapsed: boolean;
+}
+
+type MetaSpaceButtonProps = Pick;
+
+const MetaSpaceButton: React.FC = ({ selected, isPanelCollapsed, size = "32px", ...props }) => {
+ return (
+
+
+
+ );
+};
+
+const getHomeNotificationState = (): NotificationState => {
+ return SpaceStore.instance.allRoomsInHome
+ ? RoomNotificationStateStore.instance.globalState
+ : SpaceStore.instance.getNotificationState(MetaSpace.Home);
+};
+
+const HomeButton: React.FC = ({ selected, isPanelCollapsed }) => {
+ const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
+ return SpaceStore.instance.allRoomsInHome;
+ });
+ const [notificationState, setNotificationState] = useState(getHomeNotificationState());
+ const updateNotificationState = useCallback(() => {
+ setNotificationState(getHomeNotificationState());
+ }, []);
+ useEffect(updateNotificationState, [updateNotificationState, allRoomsInHome]);
+ useEventEmitter(RoomNotificationStateStore.instance, UPDATE_STATUS_INDICATOR, updateNotificationState);
+
+ return (
+
+ );
+};
+
+const FavouritesButton: React.FC = ({ selected, isPanelCollapsed }) => {
+ return (
+
+ );
+};
+
+const PeopleButton: React.FC = ({ selected, isPanelCollapsed }) => {
+ return (
+
+ );
+};
+
+const OrphansButton: React.FC = ({ selected, isPanelCollapsed }) => {
+ return (
+
+ );
+};
+
+const CreateSpaceButton: React.FC> = ({
+ isPanelCollapsed,
+ setPanelCollapsed,
+}) => {
+ const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
+
+ useEffect(() => {
+ if (!isPanelCollapsed && menuDisplayed) {
+ closeMenu();
+ }
+ }, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ let contextMenu: JSX.Element | undefined;
+ if (menuDisplayed) {
+ contextMenu = ;
+ }
+
+ const onNewClick = menuDisplayed
+ ? closeMenu
+ : (): void => {
+ if (!isPanelCollapsed) setPanelCollapsed(true);
+ openMenu();
+ };
+
+ return (
+
+
+
+ {contextMenu}
+
+ );
+};
+
+const metaSpaceComponentMap: Record = {
+ [MetaSpace.Home]: HomeButton,
+ [MetaSpace.Favourites]: FavouritesButton,
+ [MetaSpace.People]: PeopleButton,
+ [MetaSpace.Orphans]: OrphansButton,
+};
+
+interface IInnerSpacePanelProps extends DroppableProvidedProps {
+ children?: ReactNode;
+ isPanelCollapsed: boolean;
+ setPanelCollapsed: Dispatch>;
+ isDraggingOver: boolean;
+ innerRef: RefCallback;
+}
+
+// Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation
+const InnerSpacePanel = React.memo(
+ ({ children, isPanelCollapsed, setPanelCollapsed, isDraggingOver, innerRef, ...props }) => {
+ const [invites, metaSpaces, actualSpaces, activeSpace] = useSpaces();
+ const activeSpaces = activeSpace ? [activeSpace] : [];
+
+ const metaSpacesSection = metaSpaces.map((key) => {
+ const Component = metaSpaceComponentMap[key];
+ return ;
+ });
+
+ return (
+
+ {metaSpacesSection}
+ {invites.map((s) => (
+ setPanelCollapsed(false)}
+ />
+ ))}
+ {actualSpaces.map((s, i) => (
+
+ {(provided, snapshot): ReactElement => (
+ setPanelCollapsed(false)}
+ />
+ )}
+
+ ))}
+ {children}
+ {shouldShowComponent(UIComponent.CreateSpaces) && (
+
+ )}
+
+ );
+ },
+);
+
+const SpacePanel: React.FC = () => {
+ const [dragging, setDragging] = useState(false);
+ const [isPanelCollapsed, setPanelCollapsed] = useState(true);
+ const ref = useRef(null);
+ useLayoutEffect(() => {
+ if (ref.current) UIStore.instance.trackElementDimensions("SpacePanel", ref.current);
+ return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel");
+ }, []);
+
+ useDispatcher(defaultDispatcher, (payload: ActionPayload) => {
+ if (payload.action === Action.ToggleSpacePanel) {
+ setPanelCollapsed(!isPanelCollapsed);
+ }
+ });
+
+ return (
+
+ {({ onKeyDownHandler, onDragEndHandler }): ReactElement => (
+ {
+ setDragging(true);
+ }}
+ onDragEnd={(result): void => {
+ setDragging(false);
+ if (!result.destination) return; // dropped outside the list
+ SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index);
+ onDragEndHandler();
+ }}
+ >
+
+
+ setPanelCollapsed(!isPanelCollapsed)}
+ title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")}
+ tooltip={
+
+
+ {isPanelCollapsed ? _t("action|expand") : _t("action|collapse")}
+
+
+ {IS_MAC
+ ? "⌘ + ⇧ + D"
+ : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) +
+ " + " +
+ _t(ALTERNATE_KEY_NAME[Key.SHIFT]) +
+ " + D"}
+
+
+ }
+ />
+
+
+ {(provided, snapshot): ReactElement => (
+
+ {provided.placeholder}
+
+ )}
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default SpacePanel;
diff --git a/src/components/views/spaces/SuperheroDexButton.tsx b/src/components/views/spaces/SuperheroDexButton.tsx
new file mode 100644
index 00000000000..f570d2a18c4
--- /dev/null
+++ b/src/components/views/spaces/SuperheroDexButton.tsx
@@ -0,0 +1,33 @@
+import classNames from "classnames";
+import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton";
+import React, { useCallback } from "react";
+
+import { _t } from "../../../languageHandler";
+
+const SuperheroDexButton: React.FC<{
+ isPanelCollapsed: boolean;
+}> = ({ isPanelCollapsed = false }) => {
+ const DEX_URL = "https://aepp.dex.superhero.com/";
+
+ const onOpenDex = useCallback(async () => {
+ window.open(DEX_URL, "_blank");
+ }, []);
+
+ return (
+ <>
+
+ {!isPanelCollapsed ? _t("superhero_dex") : null}
+
+ >
+ );
+};
+
+export default SuperheroDexButton;
diff --git a/src/components/views/user-onboarding/UserOnboardingHeader.tsx b/src/components/views/user-onboarding/UserOnboardingHeader.tsx
new file mode 100644
index 00000000000..22e6c39b853
--- /dev/null
+++ b/src/components/views/user-onboarding/UserOnboardingHeader.tsx
@@ -0,0 +1,111 @@
+/*
+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 SdkConfig from "matrix-react-sdk/src/SdkConfig";
+import AccessibleButton from "matrix-react-sdk/src/components/views/elements/AccessibleButton";
+import { useMatrixClientContext } from "matrix-react-sdk/src/contexts/MatrixClientContext";
+import { _t } from "matrix-react-sdk/src/languageHandler";
+import { DirectoryMember, startDmOnFirstMessage } from "matrix-react-sdk/src/utils/direct-messages";
+import * as React from "react";
+import { useAtom } from "jotai";
+
+//import { ChatBot } from require("../../../../res/themes/superhero/img/arts/chat-bot.png");
+
+import { botAccountsAtom } from "../../../atoms";
+import { Icon as ChromeIcon } from "../../../../res/themes/superhero/img/icons/chrome.svg";
+import { Icon as FirefoxIcon } from "../../../../res/themes/superhero/img/icons/firefox.svg";
+//import { Icon as WelcomeAeBot } from "../../../../res/themes/superhero/img/arts/welcome-ae-bot.svg";
+
+export function UserOnboardingHeader(): JSX.Element {
+ const cli = useMatrixClientContext();
+ const [botAccounts] = useAtom(botAccountsAtom);
+ const title = _t("onboarding|welcome_to_brand", {
+ brand: SdkConfig.get("brand"),
+ });
+
+ return (
+
+
+
+
{title}
+
+ With free end-to-end encrypted messaging, and unlimited voice and video calls, Superhero is a great
+ way to stay in touch. But that's not all! With Superhero Chat you will be able to access token-gated
+ chat rooms and create your own communities.
+
+
+
+
+
+
+
+
+
+
+
Let's get started!
+
+
+
Download and install Superhero Wallet browser extension:
+
+
{
+ window.open(
+ "https://chromewebstore.google.com/detail/superhero/mnhmmkepfddpifjkamaligfeemcbhdne",
+ "_blank",
+ );
+ }}
+ className="sh_userOnboarding_download_option"
+ >
+
+ from Chrome Web Store
+
+
{
+ window.open(
+ "https://addons.mozilla.org/en-US/firefox/addon/superhero-wallet/",
+ "_blank",
+ );
+ }}
+ className="sh_userOnboarding_download_option"
+ >
+
+ from Firefox Add-ons
+
+
+
+
+
Say hello to Wallet Bot and connect your Superhero Wallet:
+
{
+ startDmOnFirstMessage(cli, [
+ new DirectoryMember({
+ user_id: botAccounts?.superheroBot || "",
+ }),
+ ]);
+ }}
+ kind="primary"
+ className="sh_userOnboarding_btn"
+ >
+ Chat With Wallet Bot
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/context/SuperheroProvider.tsx b/src/context/SuperheroProvider.tsx
new file mode 100644
index 00000000000..fed939eecfa
--- /dev/null
+++ b/src/context/SuperheroProvider.tsx
@@ -0,0 +1,105 @@
+import { useAtom } from "jotai";
+import React, { useCallback, useEffect } from "react";
+
+import { minimumTokenThresholdAtom, verifiedAccountsAtom, botAccountsAtom } from "../atoms";
+
+type BotAccounts = {
+ domain: string;
+ communityBot: {
+ userId: string;
+ };
+ superheroBot: {
+ userId: string;
+ };
+ blockchainBot: {
+ userId: string;
+ };
+};
+
+const useMinimumTokenThreshold = (config: any): void => {
+ const [, setMinimumTokenThreshold] = useAtom(minimumTokenThresholdAtom);
+
+ const loadMinimumTokenThreshold = useCallback(() => {
+ if (config.bots_backend_url) {
+ fetch(`${config.bots_backend_url}/ui/minimum-token-threshold`, {
+ method: "GET",
+ })
+ .then((res) => res.json())
+ .then(setMinimumTokenThreshold)
+ .catch((e) => {
+ console.error("Error loading minimum token threshold", e);
+ });
+ }
+ }, [setMinimumTokenThreshold, config.bots_backend_url]);
+
+ useEffect(() => {
+ loadMinimumTokenThreshold();
+
+ const interval = setInterval(() => {
+ loadMinimumTokenThreshold();
+ }, 10000);
+
+ return (): void => clearInterval(interval);
+ }, [loadMinimumTokenThreshold]);
+};
+
+/**
+ * Provides the superhero context to its children components.
+ * @param children The child components to be wrapped by the provider.
+ * @param config The SDK config
+ * @returns The superhero provider component.
+ */
+export const SuperheroProvider = ({ children, config }: any): any => {
+ const [verifiedAccounts, setVerifiedAccounts] = useAtom(verifiedAccountsAtom);
+ const [, setBotAccounts] = useAtom(botAccountsAtom);
+
+ function loadVerifiedAccounts(): void {
+ if (config.bots_backend_url) {
+ fetch(`${config.bots_backend_url}/ui/get-verified-accounts`, {
+ method: "POST",
+ })
+ .then((res) => res.json())
+ .then(setVerifiedAccounts)
+ .catch(() => {
+ //
+ });
+ }
+ }
+
+ useEffect(() => {
+ if (config.bots_backend_url) {
+ fetch(`${config.bots_backend_url}/ui/bot-accounts`, {
+ method: "GET",
+ })
+ .then((res) => res.json())
+ .then((data: BotAccounts) => {
+ setBotAccounts({
+ communityBot: "@" + data.communityBot.userId + ":" + data.domain,
+ superheroBot: "@" + data.superheroBot.userId + ":" + data.domain,
+ blockchainBot: "@" + data.blockchainBot.userId + ":" + data.domain,
+ });
+ })
+ .catch(() => {
+ //
+ });
+ }
+ }, [config.bots_backend_url, setBotAccounts]);
+
+ useEffect(() => {
+ if (!verifiedAccounts?.length) {
+ loadVerifiedAccounts();
+ }
+
+ const interval = setInterval(() => {
+ loadVerifiedAccounts();
+ }, 10000);
+
+ return () => clearInterval(interval);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Load minimum token threshold
+ useMinimumTokenThreshold(config);
+
+ return <>{children}>;
+};
diff --git a/src/editor/commands.tsx b/src/editor/commands.tsx
new file mode 100644
index 00000000000..e655e811713
--- /dev/null
+++ b/src/editor/commands.tsx
@@ -0,0 +1,34 @@
+import { IContent, MatrixClient } from "matrix-js-sdk/src/matrix";
+import { Command, getCommand } from "matrix-react-sdk/src/SlashCommands";
+import EditorModel from "matrix-react-sdk/src/editor/model";
+import { Type } from "matrix-react-sdk/src/editor/parts";
+
+export function isSlashCommand(model: EditorModel): boolean {
+ return false;
+}
+
+export function getSlashCommand(model: EditorModel): [Command | undefined, string | undefined, string] {
+ const commandText = model.parts.reduce((text, part) => {
+ // use mxid to textify user pills in a command and room alias/id for room pills
+ if (part.type === Type.UserPill || part.type === Type.RoomPill) {
+ return text + part.resourceId;
+ }
+ return text + part.text;
+ }, "");
+ const { cmd, args } = getCommand(commandText);
+ return [cmd, args, commandText];
+}
+
+export async function runSlashCommand(
+ matrixClient: MatrixClient,
+ cmd: Command,
+ args: string | undefined,
+ roomId: string,
+ threadId: string | null,
+): Promise<[content: IContent | null, success: boolean]> {
+ return [null, false];
+}
+
+export async function shouldSendAnyway(commandText: string): Promise {
+ return true;
+}
diff --git a/src/hooks/useRoomName.ts b/src/hooks/useRoomName.ts
new file mode 100644
index 00000000000..ced2c4ba051
--- /dev/null
+++ b/src/hooks/useRoomName.ts
@@ -0,0 +1,52 @@
+import { IPublicRoomsChunkRoom, Room } from "matrix-js-sdk/src/matrix";
+import { getDisplayAliasForAliasSet } from "matrix-react-sdk/src/Rooms";
+import { _t } from "matrix-react-sdk/src/languageHandler";
+import { IOOBData } from "matrix-react-sdk/src/stores/ThreepidInviteStore";
+import { useMemo } from "react";
+
+/**
+ * Removes the [TG] prefix and leading whitespace from a room name
+ * @param roomName
+ * @returns {string}
+ */
+export function getSafeRoomName(roomName?: string): string {
+ return roomName?.replace(/^(\s|\[TG\])*/, "").replace(/^(\s|\$)*/, "") || "";
+}
+
+/**
+ * Determines the room name from a combination of the room model and potential
+ * @param room - The room model
+ * @param oobData - out-of-band information about the room
+ * @returns {string} the room name
+ */
+export function getRoomName(room?: Room | IPublicRoomsChunkRoom, oobName?: IOOBData): string {
+ const roomName =
+ room?.name ||
+ oobName?.name ||
+ getDisplayAliasForAliasSet(
+ (room as IPublicRoomsChunkRoom)?.canonical_alias ?? "",
+ (room as IPublicRoomsChunkRoom)?.aliases ?? [],
+ ) ||
+ _t("common|unnamed_room");
+
+ return getSafeRoomName(
+ (roomName || "").replace(":", ":\u200b"), // add a zero-width space to allow linewrapping after the colon (matrix defaults)
+ );
+}
+
+/**
+ * Determines the room name from a combination of the room model and potential
+ * out-of-band information
+ * @param room - The room model
+ * @param oobData - out-of-band information about the room
+ * @returns {string} the room name
+ *
+ * TODO: check if useTypedEventEmitter is needed
+ */
+export function useRoomName(room?: Room | IPublicRoomsChunkRoom, oobData?: IOOBData): string {
+ const name = useMemo(() => {
+ return getRoomName(room, oobData);
+ }, [room, oobData]);
+
+ return name;
+}
diff --git a/src/hooks/useVerifiedBot.ts b/src/hooks/useVerifiedBot.ts
new file mode 100644
index 00000000000..7a14f1a732d
--- /dev/null
+++ b/src/hooks/useVerifiedBot.ts
@@ -0,0 +1,33 @@
+import { useMemo } from "react";
+import { useAtom } from "jotai";
+
+import { botAccountsAtom, getBotAccountData } from "../atoms";
+
+/**
+ * Custom hook to check if a bot is verified.
+ * @param botId - The ID of the bot to check.
+ * @returns A boolean indicating whether the bot is verified or not.
+ */
+export function useVerifiedBot(botId?: string): boolean {
+ const [botAccounts] = useAtom(botAccountsAtom);
+
+ return useMemo(() => {
+ return !!(
+ botId &&
+ (botId === botAccounts?.communityBot ||
+ botId === botAccounts?.superheroBot ||
+ botId === botAccounts?.blockchainBot)
+ );
+ }, [botId, botAccounts]);
+}
+
+export function isVerifiedBot(botId?: string): boolean {
+ const botAccounts = getBotAccountData();
+
+ return !!(
+ botId &&
+ (botId === botAccounts?.communityBot ||
+ botId === botAccounts?.superheroBot ||
+ botId === botAccounts?.blockchainBot)
+ );
+}
diff --git a/src/hooks/useVerifiedRoom.ts b/src/hooks/useVerifiedRoom.ts
new file mode 100644
index 00000000000..d5abbab6049
--- /dev/null
+++ b/src/hooks/useVerifiedRoom.ts
@@ -0,0 +1,47 @@
+import { IPublicRoomsChunkRoom, Room } from "matrix-js-sdk/src/matrix";
+import { useMemo } from "react";
+
+/**
+ * Custom hook to check if a room is verified
+ * @param room - The room model
+ */
+export function useVerifiedRoom(room?: Room | IPublicRoomsChunkRoom): {
+ isTokenGatedRoom: boolean;
+ isCommunityRoom: boolean;
+} {
+ const isTokenGatedRoom = useMemo(() => {
+ return !!room?.name?.startsWith("[TG]");
+ }, [room]);
+
+ const isCommunityRoom = useMemo(() => {
+ return !!room?.name?.startsWith("$");
+ }, [room]);
+
+ return {
+ isTokenGatedRoom,
+ isCommunityRoom,
+ };
+}
+
+export const cleanRoomName = (roomName: string): string => {
+ // remove # in the beginning
+ let parsedName = roomName.startsWith("#") ? roomName.slice(1) : roomName;
+
+ // remove domain
+ parsedName = parsedName.split(":")[0];
+
+ return parsedName;
+};
+
+export const isVerifiedRoom = (
+ roomName: string,
+): {
+ isTokenGatedRoom: boolean;
+ isCommunityRoom: boolean;
+} => {
+ const parsedRoomName = cleanRoomName(roomName);
+ return {
+ isTokenGatedRoom: parsedRoomName.startsWith("[TG]"),
+ isCommunityRoom: parsedRoomName.startsWith("$"),
+ };
+};
diff --git a/src/hooks/useVerifiedUser.ts b/src/hooks/useVerifiedUser.ts
new file mode 100644
index 00000000000..fcf130d9be9
--- /dev/null
+++ b/src/hooks/useVerifiedUser.ts
@@ -0,0 +1,19 @@
+import { useMemo } from "react";
+import { useAtom } from "jotai";
+
+import { verifiedAccountsAtom } from "../atoms";
+
+/**
+ * Custom hook to check if a user is verified.
+ * @param userId - The ID of the user to check.
+ * @returns A boolean indicating whether the user is verified or not.
+ */
+export function useVerifiedUser(userId?: string): boolean {
+ const [verifiedAccounts] = useAtom(verifiedAccountsAtom);
+
+ const isVerifiedUser: boolean = useMemo(() => {
+ return !!(userId && !!verifiedAccounts[userId]);
+ }, [userId, verifiedAccounts]);
+
+ return isVerifiedUser;
+}
diff --git a/src/i18n/strings/cs.json b/src/i18n/strings/cs.json
index 58314d3fe8b..7ef824b2209 100644
--- a/src/i18n/strings/cs.json
+++ b/src/i18n/strings/cs.json
@@ -16,13 +16,13 @@
"cannot_load_config": "Nepodařilo se načíst konfigurační soubor: abyste to zkusili znovu, načtěte prosím znovu stránku.",
"invalid_configuration_mixed_server": "Neplatná konfigurace: default_hs_url nelze použít spolu s default_server_name nebo default_server_config",
"invalid_configuration_no_server": "Neplatná konfigurace: není zadán výchozí server.",
- "invalid_json": "Vaše konfigurace Elementu obsahuje nesprávná data JSON. Vyřešte prosím problém a načtěte znovu stránku.",
+ "invalid_json": "Vaše konfigurace Superhero obsahuje nesprávná data JSON. Vyřešte prosím problém a načtěte znovu stránku.",
"invalid_json_detail": "Zpráva z parseru je: %(message)s",
"invalid_json_generic": "Neplatný JSON",
- "misconfigured": "Váš Element je nesprávně nastaven"
+ "misconfigured": "Váš Superhero je nesprávně nastaven"
},
"failed_to_start": "Nepovedlo se nastartovat",
- "go_to_element_io": "Přejít na element.io",
+ "go_to_chat_superhero_com": "Přejít na chat.superhero.com",
"incompatible_browser": {
"browser_links": "Pro nejlepší zážitek si prosím nainstalujte prohlížeč Chrome , Firefox , nebo Safari .",
"continue_warning": "Rozumím a přesto chci pokračovat",
@@ -36,5 +36,5 @@
"unknown_device": "Neznámé zařízení",
"use_brand_on_mobile": "Používání %(brand)s v mobilních zařízeních",
"web_default_device_name": "%(appName)s: %(browserName)s na %(osName)s",
- "welcome_to_element": "Vítá vás Element"
+ "welcome_to_superhero": "Vítá vás Superhero"
}
diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json
index f667b53c4b2..965db173f27 100644
--- a/src/i18n/strings/de_DE.json
+++ b/src/i18n/strings/de_DE.json
@@ -16,13 +16,13 @@
"cannot_load_config": "Konfigurationsdatei kann nicht geladen werden: Bitte aktualisiere die Seite, um es erneut zu versuchen.",
"invalid_configuration_mixed_server": "Ungültige Konfiguration: default_hs_url kann nicht zeitgleich mit default_server_name oder default_server_config festgelegt werden",
"invalid_configuration_no_server": "Ungültige Konfiguration: Es wurde kein Standardserver angegeben.",
- "invalid_json": "Deine Element-Konfiguration enthält ungültiges JSON. Bitte korrigiere das Problem und lade die Seite neu.",
+ "invalid_json": "Deine Superhero-Konfiguration enthält ungültiges JSON. Bitte korrigiere das Problem und lade die Seite neu.",
"invalid_json_detail": "Die Nachricht des Parsers ist: %(message)s",
"invalid_json_generic": "Ungültiges JSON",
- "misconfigured": "Dein Element ist falsch konfiguriert"
+ "misconfigured": "Dein Superhero ist falsch konfiguriert"
},
"failed_to_start": "Start fehlgeschlagen",
- "go_to_element_io": "Gehe zu element.io",
+ "go_to_chat_superhero_com": "Gehe zu chat.superhero.com",
"incompatible_browser": {
"browser_links": "Bitte installiere Chrome , Firefox oder Safari für das beste Erlebnis.",
"continue_warning": "Ich verstehe die Risiken und möchte fortfahren",
@@ -36,5 +36,5 @@
"unknown_device": "Unbekanntes Gerät",
"use_brand_on_mobile": "Verwende %(brand)s am Handy",
"web_default_device_name": "%(appName)s: %(browserName)s auf %(osName)s",
- "welcome_to_element": "Willkommen bei Element"
+ "welcome_to_superhero": "Willkommen bei Superhero"
}
diff --git a/src/i18n/strings/el.json b/src/i18n/strings/el.json
index f49a44ff67a..3d3e53a95c6 100644
--- a/src/i18n/strings/el.json
+++ b/src/i18n/strings/el.json
@@ -14,13 +14,13 @@
"app_launch_unexpected_error": "Απρόοπτο σφάλμα κατά την προετοιμασία της εφαρμογής. Δείτε το τερματικό για λεπτομέρειες.",
"cannot_load_config": "Δεν είναι δυνατή η φόρτωση του αρχείου config: ανανεώστε τη σελίδα για να δοκιμάσετε ξανά.",
"invalid_configuration_no_server": "Μη έγκυρη ρύθμιση παραμέτρων: δεν έχει οριστεί προκαθορισμένος διακομιστής.",
- "invalid_json": "Η ρύθμιση του Element περιέχει μη έγκυρο JSON. Διορθώστε το πρόβλημα και φορτώστε ξανά τη σελίδα.",
+ "invalid_json": "Η ρύθμιση του Superhero περιέχει μη έγκυρο JSON. Διορθώστε το πρόβλημα και φορτώστε ξανά τη σελίδα.",
"invalid_json_detail": "Το μήνυμα από τον αναλυτή είναι: %(message)s",
"invalid_json_generic": "Μη έγκυρο JSON",
- "misconfigured": "Το Element σας δεν εχει ρυθμιστεί σωστά"
+ "misconfigured": "Το Superhero σας δεν εχει ρυθμιστεί σωστά"
},
"failed_to_start": "Αποτυχία έναρξης",
- "go_to_element_io": "Πήγαινε στο element.io",
+ "go_to_chat_superhero_com": "Πήγαινε στο chat.superhero.com",
"incompatible_browser": {
"browser_links": "Παρακαλούμε εγκαταστήστε Chrome , Firefox , ή Safari για καλύτερη εμπειρία χρήσης.",
"continue_warning": "Κατανοώ τους κινδύνους και επιθυμώ να συνεχίσω",
@@ -33,5 +33,5 @@
"powered_by_matrix_with_logo": "Αποκεντρωμένη, κρυπτογραφημένη συνομιλία και συνεργασία χρησιμοποιώντας το $matrixLogo",
"unknown_device": "Άγνωστη συσκευή",
"use_brand_on_mobile": "Χρήση %(brand)s σε κινητό",
- "welcome_to_element": "Καλώς ήλθατε στο Element"
+ "welcome_to_superhero": "Καλώς ήλθατε στο Superhero"
}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 39725bfee71..e56fd27057a 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -16,13 +16,13 @@
"cannot_load_config": "Unable to load config file: please refresh the page to try again.",
"invalid_configuration_mixed_server": "Invalid configuration: a default_hs_url can't be specified along with default_server_name or default_server_config",
"invalid_configuration_no_server": "Invalid configuration: no default server specified.",
- "invalid_json": "Your Element configuration contains invalid JSON. Please correct the problem and reload the page.",
+ "invalid_json": "Your Superhero configuration contains invalid JSON. Please correct the problem and reload the page.",
"invalid_json_detail": "The message from the parser is: %(message)s",
"invalid_json_generic": "Invalid JSON",
- "misconfigured": "Your Element is misconfigured"
+ "misconfigured": "Your Superhero is misconfigured"
},
"failed_to_start": "Failed to start",
- "go_to_element_io": "Go to element.io",
+ "go_to_chat_superhero_com": "Go to chat.superhero.com",
"incompatible_browser": {
"browser_links": "Please install Chrome , Firefox , or Safari for the best experience.",
"continue_warning": "I understand the risks and wish to continue",
@@ -36,5 +36,14 @@
"unknown_device": "Unknown device",
"use_brand_on_mobile": "Use %(brand)s on mobile",
"web_default_device_name": "%(appName)s: %(browserName)s on %(osName)s",
- "welcome_to_element": "Welcome to Element"
+ "welcome_to_superhero": "Welcome to Superhero",
+ "superhero_dex": "Superhero DEX",
+ "mint_a_token": "Mint a token",
+ "composer": {
+ "no_perms_token_notice": "You need to own more than %(threshold)s %(symbol)s tokens to be able to post in this room."
+ },
+ "room": {
+ "no_peek_join_prompt_community": "%(roomName)s is a private token-gated room.",
+ "no_peek_join_prompt_community_threshold": "You need to own %(threshold)s %(symbol)s tokens to access it."
+ }
}
diff --git a/src/i18n/strings/eo.json b/src/i18n/strings/eo.json
index 0edc0a52c07..3c22e329acd 100644
--- a/src/i18n/strings/eo.json
+++ b/src/i18n/strings/eo.json
@@ -15,13 +15,13 @@
"app_launch_unexpected_error": "Neatendita eraro okazis dum la preparado de la aplikaĵo. Rigardu la konzolon por detaloj.",
"cannot_load_config": "Ne povas enlegi agordan dosieron: bonvolu reprovi per aktualigo de la paĝo.",
"invalid_configuration_no_server": "Nevalida agordo: neniu implicita servilo estas specifita.",
- "invalid_json": "Via agordaro de Elemento enhavas nevalidajn datumojn de JSON. Bonvolu korekti la problemon kaj aktualigi la paĝon.",
+ "invalid_json": "Via agordaro de Superhero enhavas nevalidajn datumojn de JSON. Bonvolu korekti la problemon kaj aktualigi la paĝon.",
"invalid_json_detail": "La mesaĝo el la analizilo estas: %(message)s",
"invalid_json_generic": "Nevalida JSON",
- "misconfigured": "Via Elemento estas misagordita"
+ "misconfigured": "Via Superhero estas misagordita"
},
"failed_to_start": "Malsukcesis starti",
- "go_to_element_io": "Iri al element.io",
+ "go_to_chat_superhero_com": "Iri al chat.superhero.com",
"incompatible_browser": {
"browser_links": "Bonvolu instali retumilon Chrome , Firefox , aŭ Safari , por la plej bona sperto.",
"continue_warning": "Mi komprenas la riskon kaj volas pluiĝi",
@@ -35,5 +35,5 @@
"unknown_device": "Nekonata aparato",
"use_brand_on_mobile": "Uzi %(brand)s poŝtelefone",
"web_default_device_name": "%(appName)s: %(browserName)s sur %(osName)s",
- "welcome_to_element": "Bonvenon al Element"
+ "welcome_to_superhero": "Bonvenon al Superhero"
}
diff --git a/src/i18n/strings/es.json b/src/i18n/strings/es.json
index 7dc7996019e..5eff1cd1fe3 100644
--- a/src/i18n/strings/es.json
+++ b/src/i18n/strings/es.json
@@ -15,13 +15,13 @@
"app_launch_unexpected_error": "Error inesperado preparando la aplicación. Ver la consola para más detalles.",
"cannot_load_config": "No se ha podido cargar el archivo de configuración. Recarga la página para intentarlo otra vez.",
"invalid_configuration_no_server": "Configuración errónea: no se ha especificado servidor.",
- "invalid_json": "Tu configuración de Element contiene JSON inválido. Por favor corrígelo e inténtelo de nuevo.",
+ "invalid_json": "Tu configuración de Superhero contiene JSON inválido. Por favor corrígelo e inténtelo de nuevo.",
"invalid_json_detail": "El mensaje del parser es: %(message)s",
"invalid_json_generic": "JSON inválido",
- "misconfigured": "Tu aplicación Element está mal configurada"
+ "misconfigured": "Tu aplicación Superhero está mal configurada"
},
"failed_to_start": "Fallo al iniciar",
- "go_to_element_io": "Ir a element.io",
+ "go_to_chat_superhero_com": "Ir a chat.superhero.com",
"incompatible_browser": {
"browser_links": "Por favor, instale Chrome , Firefox , o Safari para la mejor experiencia.",
"continue_warning": "Entiendo los riesgos y quiero continuar",
@@ -35,5 +35,5 @@
"unknown_device": "Dispositivo desconocido",
"use_brand_on_mobile": "Usar %(brand)s en modo móvil",
"web_default_device_name": "%(appName)s: %(browserName)s en %(osName)s",
- "welcome_to_element": "Te damos la bienvenida a Element"
+ "welcome_to_superhero": "Te damos la bienvenida a Superhero"
}
diff --git a/src/i18n/strings/et.json b/src/i18n/strings/et.json
index 552e784d564..53f3f41ed10 100644
--- a/src/i18n/strings/et.json
+++ b/src/i18n/strings/et.json
@@ -16,13 +16,13 @@
"cannot_load_config": "Seadistuste faili laadimine ei õnnestunud: uuesti proovimiseks palun laadi leht uuesti.",
"invalid_configuration_mixed_server": "Vigane seadistus: default_hs_url ei saa olla määratud koos default_server_name või default_server_config tunnustega",
"invalid_configuration_no_server": "Vigane seadistus: vaikimisi server on määramata.",
- "invalid_json": "Sinu Element'i seadistustes on vigased JSON-vormingus andmed. Palun paranda see viga ja laadi leht uuesti.",
+ "invalid_json": "Sinu Superhero'i seadistustes on vigased JSON-vormingus andmed. Palun paranda see viga ja laadi leht uuesti.",
"invalid_json_detail": "Sõnum parserist on: %(message)s",
"invalid_json_generic": "Vigane JSON",
- "misconfigured": "Sinu Element on valesti seadistatud"
+ "misconfigured": "Sinu Superhero on valesti seadistatud"
},
"failed_to_start": "Käivitamine ei õnnestunud",
- "go_to_element_io": "Mine element.io lehele",
+ "go_to_chat_superhero_com": "Mine chat.superhero.com lehele",
"incompatible_browser": {
"browser_links": "Parima kasutuskogemuse jaoks palun paigalda Chrome , Firefox või Safari .",
"continue_warning": "Ma mõistan riske ja soovin jätkata",
@@ -36,5 +36,5 @@
"unknown_device": "Tundmatu seade",
"use_brand_on_mobile": "Kasuta rakendust %(brand)s nutiseadmes",
"web_default_device_name": "%(appName)s: %(browserName)s operatsioonisüsteemis %(osName)s",
- "welcome_to_element": "Tere tulemast kasutama suhtlusrakendust Element"
+ "welcome_to_superhero": "Tere tulemast kasutama suhtlusrakendust Superhero"
}
diff --git a/src/i18n/strings/fa.json b/src/i18n/strings/fa.json
index 0daedeca606..3dc797985b4 100644
--- a/src/i18n/strings/fa.json
+++ b/src/i18n/strings/fa.json
@@ -18,10 +18,10 @@
"invalid_json": "پیکربندی المنت شما شامل JSON نا معتبر است. لطفا مشکل را اصلاح کنید و صفحه را بارگذاری مجدد کنید.",
"invalid_json_detail": "پیام از طرف تجزیه کننده: %(message)s",
"invalid_json_generic": "JSON اشتباه",
- "misconfigured": "Element شما پیکربندی نشده است"
+ "misconfigured": "Superhero شما پیکربندی نشده است"
},
"failed_to_start": "خطا در شروع",
- "go_to_element_io": "برو به element.io",
+ "go_to_chat_superhero_com": "برو به chat.superhero.com",
"incompatible_browser": {
"browser_links": "لطفا برای تجربه بهتر کروم ، فایرفاکس ، یا سافاری را نصب کنید.",
"continue_warning": "از خطرات این کار آگاهم و مایلم که ادامه بدهم",
@@ -35,5 +35,5 @@
"unknown_device": "دستگاه ناشناخته",
"use_brand_on_mobile": "از %(brand)s گوشی استفاده کنید",
"web_default_device_name": "%(appName)s: %(browserName)s: روی %(osName)s",
- "welcome_to_element": "به Element خوشآمدید"
+ "welcome_to_superhero": "به Superhero خوشآمدید"
}
diff --git a/src/i18n/strings/fi.json b/src/i18n/strings/fi.json
index 64daeff058d..134641fe9b6 100644
--- a/src/i18n/strings/fi.json
+++ b/src/i18n/strings/fi.json
@@ -15,13 +15,13 @@
"app_launch_unexpected_error": "Odottamaton virhe sovellusta valmisteltaessa. Katso konsolista lisätietoja.",
"cannot_load_config": "Asetustiedostoa ei voi ladata. Yritä uudelleen lataamalla sivu uudelleen.",
"invalid_configuration_no_server": "Virheellinen asetus: oletuspalvelinta ei ole määritetty.",
- "invalid_json": "Element-asetuksesi sisältävät epäkelpoa JSONia. Korjaa ongelma ja lataa sivu uudelleen.",
+ "invalid_json": "Superhero-asetuksesi sisältävät epäkelpoa JSONia. Korjaa ongelma ja lataa sivu uudelleen.",
"invalid_json_detail": "Viesti jäsentimeltä: %(message)s",
"invalid_json_generic": "Virheellinen JSON",
- "misconfigured": "Elementisi asetukset ovat pielessä"
+ "misconfigured": "Superheroisi asetukset ovat pielessä"
},
"failed_to_start": "Käynnistys ei onnistunut",
- "go_to_element_io": "Mene osoitteeseen riot.im",
+ "go_to_chat_superhero_com": "Mene osoitteeseen riot.im",
"incompatible_browser": {
"browser_links": "Asenna Chrome , Firefox tai Safari , jotta kaikki toimii parhaiten.",
"continue_warning": "Ymmärrän riskit ja haluan jatkaa",
@@ -35,5 +35,5 @@
"unknown_device": "Tuntematon laite",
"use_brand_on_mobile": "Käytä %(brand)sia mobiilisti",
"web_default_device_name": "%(appName)s: %(browserName)s käyttöjärjestelmällä %(osName)s",
- "welcome_to_element": "Tervetuloa Element-sovellukseen"
+ "welcome_to_superhero": "Tervetuloa Superhero-sovellukseen"
}
diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json
index 94f8e00fadc..918db3f0b6b 100644
--- a/src/i18n/strings/fr.json
+++ b/src/i18n/strings/fr.json
@@ -16,13 +16,13 @@
"cannot_load_config": "Impossible de charger le fichier de configuration : rechargez la page pour réessayer.",
"invalid_configuration_mixed_server": "Configuration invalide : default_hs_url ne peut pas être défini en même temps que default_server_name ou default_server_config",
"invalid_configuration_no_server": "Configuration invalide : aucun serveur par défaut indiqué.",
- "invalid_json": "La configuration de votre Element contient du JSON invalide. Veuillez corriger le problème et recharger la page.",
+ "invalid_json": "La configuration de votre Superhero contient du JSON invalide. Veuillez corriger le problème et recharger la page.",
"invalid_json_detail": "Le message de l’analyseur est : %(message)s",
"invalid_json_generic": "JSON non valide",
- "misconfigured": "Votre Element est mal configuré"
+ "misconfigured": "Votre Superhero est mal configuré"
},
"failed_to_start": "Échec au démarrage",
- "go_to_element_io": "Aller vers element.io",
+ "go_to_chat_superhero_com": "Aller vers chat.superhero.com",
"incompatible_browser": {
"browser_links": "Veuillez installer Chrome , Firefox ou Safari pour une expérience optimale.",
"continue_warning": "Je comprends les risques et souhaite continuer",
@@ -36,5 +36,5 @@
"unknown_device": "Appareil inconnu",
"use_brand_on_mobile": "Utiliser %(brand)s sur téléphone",
"web_default_device_name": "%(appName)s : %(browserName)s pour %(osName)s",
- "welcome_to_element": "Bienvenue sur Element"
+ "welcome_to_superhero": "Bienvenue sur Superhero"
}
diff --git a/src/i18n/strings/gl.json b/src/i18n/strings/gl.json
index 8ffe6738684..bc7a96fa70b 100644
--- a/src/i18n/strings/gl.json
+++ b/src/i18n/strings/gl.json
@@ -15,13 +15,13 @@
"app_launch_unexpected_error": "Fallo non agardado ao preparar a app. Detalles na consola.",
"cannot_load_config": "Non se cargou o ficheiro de configuración: actualiza a páxina para reintentalo.",
"invalid_configuration_no_server": "Configuración non válida: non se indicou servidor por defecto.",
- "invalid_json": "A configuración de Element contén JSON non válido. Corrixe o problema e recarga a páxina.",
+ "invalid_json": "A configuración de Superhero contén JSON non válido. Corrixe o problema e recarga a páxina.",
"invalid_json_detail": "A mensaxe desde o intérprete é: %(message)s",
"invalid_json_generic": "JSON non válido",
- "misconfigured": "Element non está ben configurado"
+ "misconfigured": "Superhero non está ben configurado"
},
"failed_to_start": "Fallou o inicio",
- "go_to_element_io": "Ir a element.io",
+ "go_to_chat_superhero_com": "Ir a chat.superhero.com",
"incompatible_browser": {
"browser_links": "Instala Chrome , Firefox , ou Safari para ter unha mellor experiencia.",
"continue_warning": "Entendo os riscos e desexo continuar",
@@ -35,5 +35,5 @@
"unknown_device": "Dispositivo descoñecido",
"use_brand_on_mobile": "Utiliza %(brand)s no móbil",
"web_default_device_name": "%(appName)s: %(browserName)s en %(osName)s",
- "welcome_to_element": "Benvida/o a Element"
+ "welcome_to_superhero": "Benvida/o a Superhero"
}
diff --git a/src/i18n/strings/he.json b/src/i18n/strings/he.json
index 89a8cd31c56..f1c00dc9aac 100644
--- a/src/i18n/strings/he.json
+++ b/src/i18n/strings/he.json
@@ -18,10 +18,10 @@
"invalid_json": "האלמנט מכיל הגדרת JSON שגויה, אנא תקנו את הבעיה ואתחלו את הדף.",
"invalid_json_detail": "ההודעה מהמנתח היא: %(message)s",
"invalid_json_generic": "JSON לא חוקי",
- "misconfigured": "Element אינו מוגדר תקין"
+ "misconfigured": "Superhero אינו מוגדר תקין"
},
"failed_to_start": "כשל בהעלאת התוכנה",
- "go_to_element_io": "חזור לאתר הראשי: element.io",
+ "go_to_chat_superhero_com": "חזור לאתר הראשי: chat.superhero.com",
"incompatible_browser": {
"browser_links": "נא התקן את דפדפן כרום , פיירפוקס או סאפרי בשביל החוויה הטובה ביותר.",
"continue_warning": "הסיכונים מובנים לי ואני מעוניינ/ת להמשיך",
@@ -35,5 +35,5 @@
"unknown_device": "מכשיר לא ידוע",
"use_brand_on_mobile": "השתמש ב-%(brand)s במכשיר הנייד",
"web_default_device_name": "%(appName)s: %(browserName)s עַל %(osName)s",
- "welcome_to_element": "ברוכים הבאים ל Element"
+ "welcome_to_superhero": "ברוכים הבאים ל Superhero"
}
diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json
index b5143820e2e..691852220de 100644
--- a/src/i18n/strings/hu.json
+++ b/src/i18n/strings/hu.json
@@ -16,12 +16,13 @@
"cannot_load_config": "A konfigurációs fájlt nem sikerült betölteni: frissítse az oldalt és próbálja meg újra.",
"invalid_configuration_mixed_server": "Érvénytelen konfiguráció: a default_hs_url nem adható meg a default_server_name vagy a default_server_config kulcsokkal együtt",
"invalid_configuration_no_server": "Érvénytelen konfiguráció: nincs megadva alapértelmezett kiszolgáló.",
- "invalid_json": "Az Element érvénytelen JSON-t tartalmazó konfigurációval rendelkezik. Javítsa és töltse újra az oldalt.",
+ "invalid_json": "Az Superhero érvénytelen JSON-t tartalmazó konfigurációval rendelkezik. Javítsa és töltse újra az oldalt.",
"invalid_json_detail": "A feldolgozó algoritmus üzenete: %(message)s",
"invalid_json_generic": "Érvénytelen JSON",
- "misconfigured": "Az Element hibásan van beállítva"
+ "misconfigured": "Az Superhero hibásan van beállítva"
},
"failed_to_start": "Az indítás sikertelen",
+ "go_to_chat_superhero_com": "Irány a chat.superhero.com",
"go_to_element_io": "Irány az element.io",
"incompatible_browser": {
"browser_links": "A legjobb élmény érdékében telepítsen Chrome-ot , Firefoxot vagy Safarit .",
@@ -36,5 +37,5 @@
"unknown_device": "Ismeretlen eszköz",
"use_brand_on_mobile": "Mobilon használja ezt: %(brand)s",
"web_default_device_name": "%(appName)s: (%(browserName)s itt: %(osName)s)",
- "welcome_to_element": "Üdvözli az Element"
+ "welcome_to_superhero": "Üdvözli az Superhero"
}
diff --git a/src/i18n/strings/id.json b/src/i18n/strings/id.json
index 30169aa675a..37ed4f93956 100644
--- a/src/i18n/strings/id.json
+++ b/src/i18n/strings/id.json
@@ -16,13 +16,13 @@
"cannot_load_config": "Tidak dapat memuat file konfigurasi: mohon muat ulang laman ini untuk mencoba lagi.",
"invalid_configuration_mixed_server": "Konfigurasi tidak valid: default_hs_url tidak dapat ditentukan bersama dengan default_server_name atau default_server_config",
"invalid_configuration_no_server": "Konfigurasi tidak absah: server bawaan belum ditentukan.",
- "invalid_json": "Konfigurasi Element Anda berisi JSON yang tidak absah. Mohon perbaiki masalahnya dan muat ulang laman ini.",
+ "invalid_json": "Konfigurasi Superhero Anda berisi JSON yang tidak absah. Mohon perbaiki masalahnya dan muat ulang laman ini.",
"invalid_json_detail": "Pesan dari pengurai adalah: %(message)s",
"invalid_json_generic": "JSON tidak absah",
- "misconfigured": "Anda salah mengatur Element"
+ "misconfigured": "Anda salah mengatur Superhero"
},
"failed_to_start": "Gagal untuk memulai",
- "go_to_element_io": "Buka element.io",
+ "go_to_chat_superhero_com": "Buka chat.superhero.com",
"incompatible_browser": {
"browser_links": "Silakan instal Chrome , Firefox , atau Safari untuk pengalaman yang terbaik.",
"continue_warning": "Saya memahami risikonya dan ingin melanjutkan",
@@ -36,5 +36,5 @@
"unknown_device": "Perangkat tidak diketahui",
"use_brand_on_mobile": "Gunakan %(brand)s di ponsel",
"web_default_device_name": "%(appName)s: %(browserName)s di %(osName)s",
- "welcome_to_element": "Selamat datang di Element"
+ "welcome_to_superhero": "Selamat datang di Superhero"
}
diff --git a/src/i18n/strings/is.json b/src/i18n/strings/is.json
index ca1997cf7d3..b9b6ef14e51 100644
--- a/src/i18n/strings/is.json
+++ b/src/i18n/strings/is.json
@@ -15,13 +15,13 @@
"app_launch_unexpected_error": "Óvænt villa við undirbúning forritsins. Sjá nánar á stjórnskjá.",
"cannot_load_config": "Ekki er hægt að hlaða stillingaskrána: endurnýjaðu síðuna til að reyna aftur.",
"invalid_configuration_no_server": "Ógild uppsetning: enginn sjálfgefinn vefþjónn tilgreindur.",
- "invalid_json": "Element-stillingar þínar innihalda ógilt JSON. Leiðréttu vandamálið og endurlestu síðuna.",
+ "invalid_json": "Superhero-stillingar þínar innihalda ógilt JSON. Leiðréttu vandamálið og endurlestu síðuna.",
"invalid_json_detail": "Skilaboðið frá þáttaranum er %(message)s",
"invalid_json_generic": "Ógilt JSON",
- "misconfigured": "Element-tilvikið þitt er rangt stillt"
+ "misconfigured": "Superhero-tilvikið þitt er rangt stillt"
},
"failed_to_start": "Mistókst að ræsa",
- "go_to_element_io": "Fara á element.io",
+ "go_to_chat_superhero_com": "Fara á chat.superhero.com",
"incompatible_browser": {
"browser_links": "Þú ættir að setja upp Chrome , Firefox , eða Safari til að fá sem besta útkomu.",
"continue_warning": "Ég skil áhættuna og óska að halda áfram",
@@ -35,5 +35,5 @@
"unknown_device": "Óþekkt tæki",
"use_brand_on_mobile": "Nota %(brand)s í síma",
"web_default_device_name": "%(appName)s: %(browserName)s á %(osName)s",
- "welcome_to_element": "Velkomin í Element"
+ "welcome_to_superhero": "Velkomin í Superhero"
}
diff --git a/src/i18n/strings/it.json b/src/i18n/strings/it.json
index 7dad3d664d8..fb1278dd019 100644
--- a/src/i18n/strings/it.json
+++ b/src/i18n/strings/it.json
@@ -22,7 +22,7 @@
"misconfigured": "Il tuo elemento è configurato male"
},
"failed_to_start": "Avvio fallito",
- "go_to_element_io": "Vai su element.io",
+ "go_to_chat_superhero_com": "Vai su chat.superhero.com",
"incompatible_browser": {
"browser_links": "Installa Chrome , Firefox , o Safari per una migliore esperienza.",
"continue_warning": "Capisco i rischi e desidero continuare",
@@ -36,5 +36,5 @@
"unknown_device": "Dispositivo sconosciuto",
"use_brand_on_mobile": "Usa %(brand)s su mobile",
"web_default_device_name": "%(appName)s: %(browserName)s su %(osName)s",
- "welcome_to_element": "Benvenuti su Element"
+ "welcome_to_superhero": "Benvenuti su Superhero"
}
diff --git a/src/i18n/strings/ja.json b/src/i18n/strings/ja.json
index 637ca698a36..0182f42f201 100644
--- a/src/i18n/strings/ja.json
+++ b/src/i18n/strings/ja.json
@@ -15,13 +15,13 @@
"app_launch_unexpected_error": "アプリケーションの準備中に予期しないエラーが発生しました。詳細はコンソールを参照してください。",
"cannot_load_config": "設定ファイルの読み込みに失敗しました:ページを再読み込みして、もう一度やり直してください。",
"invalid_configuration_no_server": "不正な設定:デフォルトのサーバーが設定されていません。",
- "invalid_json": "Elementの設定ファイルに不正なJSONが含まれています。問題を修正してからページを再読み込みしてください。",
+ "invalid_json": "Superheroの設定ファイルに不正なJSONが含まれています。問題を修正してからページを再読み込みしてください。",
"invalid_json_detail": "パーサーのメッセージ:%(message)s",
"invalid_json_generic": "不正なJSON",
- "misconfigured": "Elementの設定が誤っています"
+ "misconfigured": "Superheroの設定が誤っています"
},
"failed_to_start": "起動に失敗しました",
- "go_to_element_io": "element.ioへ移動",
+ "go_to_chat_superhero_com": "chat.superhero.comへ移動",
"incompatible_browser": {
"browser_links": "最高のユーザー体験を得るためには、Chrome かFirefox 、もしくはSafari をインストールしてください。",
"continue_warning": "リスクを理解して続行",
@@ -34,5 +34,5 @@
"unknown_device": "不明な端末",
"use_brand_on_mobile": "携帯端末で%(brand)sを使用できます",
"web_default_device_name": "%(appName)s: %(osName)sの%(browserName)s",
- "welcome_to_element": "Elementにようこそ"
+ "welcome_to_superhero": "Superheroにようこそ"
}
diff --git a/src/i18n/strings/lo.json b/src/i18n/strings/lo.json
index b6dc8b5490b..ed247ad284f 100644
--- a/src/i18n/strings/lo.json
+++ b/src/i18n/strings/lo.json
@@ -14,13 +14,13 @@
"app_launch_unexpected_error": "ເກີດຄວາມຜິດພາດທີ່ບໍ່ຄາດຄິດໃນການກະກຽມແອັບຯ. ເບິ່ງ console ສໍາລັບລາຍລະອຽດ.",
"cannot_load_config": "ບໍ່ສາມາດໂຫຼດໄຟລ໌ config ໄດ້: ກະລຸນາໂຫຼດໜ້ານີ້ຄືນໃໝ່ເພື່ອລອງອີກຄັ້ງ.",
"invalid_configuration_no_server": "ການຕັ້ງຄ່າບໍ່ຖືກຕ້ອງ: ບໍ່ໄດ້ລະບຸເຊີບເວີເລີ່ມຕົ້ນ.",
- "invalid_json": "ການຕັ້ງຄ່າແອັບ Element ຂອງທ່ານມີຄ່າ JSON ທີ່ບໍ່ຖືກຕ້ອງ. ກະລຸນາແກ້ໄຂບັນຫາ ແລະໂຫຼດໜ້ານີ້ຄືນໃໝ່.",
+ "invalid_json": "ການຕັ້ງຄ່າແອັບ Superhero ຂອງທ່ານມີຄ່າ JSON ທີ່ບໍ່ຖືກຕ້ອງ. ກະລຸນາແກ້ໄຂບັນຫາ ແລະໂຫຼດໜ້ານີ້ຄືນໃໝ່.",
"invalid_json_detail": "ຂໍ້ຄວາມຈາກຕົວປ່ຽນແມ່ນ: %(message)s",
"invalid_json_generic": "JSON ບໍ່ຖືກຕ້ອງ",
- "misconfigured": "ການຕັ້ງຄ່າແອັບ Element ຂອງທ່ານບໍ່ຖືກຕ້ອງ"
+ "misconfigured": "ການຕັ້ງຄ່າແອັບ Superhero ຂອງທ່ານບໍ່ຖືກຕ້ອງ"
},
"failed_to_start": "ບໍ່ສາມາດເປີດໄດ້",
- "go_to_element_io": "ໄປຫາ element.io",
+ "go_to_chat_superhero_com": "ໄປຫາ chat.superhero.com",
"incompatible_browser": {
"browser_links": "ກະລຸນາຕິດຕັ້ງ Chrome , Firefox , or Safari ສຳລັບປະສົບການທີ່ດີທີ່ສຸດ.",
"continue_warning": "ຂ້າພະເຈົ້າເຂົ້າໃຈຄວາມສ່ຽງ ແລະຢາກສືບຕໍ່",
@@ -33,5 +33,5 @@
"powered_by_matrix_with_logo": "ການສົນທະນາແບບເຂົ້າລະຫັດ ແລະກະຈ່າຍການຄຸ້ມຄອງ & ການຮ່ວມມື້ ແລະສະໜັບສະໜູນໂດຍ $matrixLogo",
"unknown_device": "ທີ່ບໍ່ຮູ້ຈັກອຸປະກອນນີ້",
"use_brand_on_mobile": "ໃຊ້ມືຖື %(brand)s",
- "welcome_to_element": "ຍິນດີຕ້ອນຮັບ"
+ "welcome_to_superhero": "ຍິນດີຕ້ອນຮັບ"
}
diff --git a/src/i18n/strings/lt.json b/src/i18n/strings/lt.json
index 8b09734717b..191a415dd43 100644
--- a/src/i18n/strings/lt.json
+++ b/src/i18n/strings/lt.json
@@ -15,13 +15,13 @@
"app_launch_unexpected_error": "Netikėta klaida ruošiant programą. Norėdami sužinoti daugiau detalių, žiūrėkite konsolę.",
"cannot_load_config": "Nepavyko įkelti konfigūracijos failo: atnaujinkite puslapį, kad pabandytumėte dar kartą.",
"invalid_configuration_no_server": "Klaidinga konfigūracija: nenurodytas numatytasis serveris.",
- "invalid_json": "Jūsų Element konfigūracijoje yra klaidingas JSON. Ištaisykite problemą ir iš naujo įkelkite puslapį.",
+ "invalid_json": "Jūsų Superhero konfigūracijoje yra klaidingas JSON. Ištaisykite problemą ir iš naujo įkelkite puslapį.",
"invalid_json_detail": "Analizatoriaus žinutė yra: %(message)s",
"invalid_json_generic": "Klaidingas JSON",
- "misconfigured": "Jūsų Element yra neteisingai sukonfigūruotas"
+ "misconfigured": "Jūsų Superhero yra neteisingai sukonfigūruotas"
},
"failed_to_start": "Nepavyko paleisti",
- "go_to_element_io": "Eiti į element.io",
+ "go_to_chat_superhero_com": "Eiti į chat.superhero.com",
"incompatible_browser": {
"browser_links": "Geriausiam veikimui suinstaliuokite Chrome , Firefox , arba Safari .",
"continue_warning": "Suprantu šią riziką ir noriu tęsti",
@@ -34,5 +34,5 @@
"powered_by_matrix_with_logo": "Decentralizuotas, užšifruotų pokalbių & bendradarbiavimas, paremtas $matrixLogo",
"unknown_device": "Nežinomas įrenginys",
"use_brand_on_mobile": "Naudoti %(brand)s mobiliajame telefone",
- "welcome_to_element": "Sveiki atvykę į Element"
+ "welcome_to_superhero": "Sveiki atvykę į Superhero"
}
diff --git a/src/i18n/strings/nl.json b/src/i18n/strings/nl.json
index e5a73f1f22b..5611179ca55 100644
--- a/src/i18n/strings/nl.json
+++ b/src/i18n/strings/nl.json
@@ -14,13 +14,13 @@
"app_launch_unexpected_error": "Er is een onverwachte fout opgetreden bij het voorbereiden van de app. Zie de console voor details.",
"cannot_load_config": "Kan het configuratiebestand niet laden. Herlaad de pagina.",
"invalid_configuration_no_server": "Configuratie ongeldig: geen standaardserver opgegeven.",
- "invalid_json": "Jouw Element configuratie bevat ongeldige JSON. Corrigeer het probleem en herlaad de pagina.",
+ "invalid_json": "Jouw Superhero configuratie bevat ongeldige JSON. Corrigeer het probleem en herlaad de pagina.",
"invalid_json_detail": "De ontleder meldt: %(message)s",
"invalid_json_generic": "Ongeldige JSON",
- "misconfigured": "Jouw Element is verkeerd geconfigureerd"
+ "misconfigured": "Jouw Superhero is verkeerd geconfigureerd"
},
"failed_to_start": "Opstarten mislukt",
- "go_to_element_io": "Ga naar element.io",
+ "go_to_chat_superhero_com": "Ga naar chat.superhero.com",
"incompatible_browser": {
"browser_links": "Installeer Chrome , Firefox , of Safari voor de beste gebruikservaring.",
"continue_warning": "Ik begrijp de risico's en wil verder gaan",
@@ -34,5 +34,5 @@
"unknown_device": "Onbekend apparaat",
"use_brand_on_mobile": "Gebruik %(brand)s op je mobiel",
"web_default_device_name": "%(appName)s: %(browserName)s op %(osName)s",
- "welcome_to_element": "Welkom bij Element"
+ "welcome_to_superhero": "Welkom bij Superhero"
}
diff --git a/src/i18n/strings/pl.json b/src/i18n/strings/pl.json
index fdc628852f5..375e8afe202 100644
--- a/src/i18n/strings/pl.json
+++ b/src/i18n/strings/pl.json
@@ -16,13 +16,13 @@
"cannot_load_config": "Nie udało się załadować pliku konfiguracyjnego: odśwież stronę, aby spróbować ponownie.",
"invalid_configuration_mixed_server": "Nieprawidłowa konfiguracja: nie można określić default_hs_url wraz z default_server_name lub default_server_config",
"invalid_configuration_no_server": "Błędna konfiguracja: nie wybrano domyślnego serwera.",
- "invalid_json": "Twoja konfiguracja Element zawiera nieprawidłowy JSON. Rozwiąż problem i odśwież stronę.",
+ "invalid_json": "Twoja konfiguracja Superhero zawiera nieprawidłowy JSON. Rozwiąż problem i odśwież stronę.",
"invalid_json_detail": "Wiadomość od parsera to: %(message)s",
"invalid_json_generic": "Błędny JSON",
- "misconfigured": "Twój Element jest nieprawidłowo skonfigurowany"
+ "misconfigured": "Twój Superhero jest nieprawidłowo skonfigurowany"
},
"failed_to_start": "Nie udało się wystartować",
- "go_to_element_io": "Przejdź do element.io",
+ "go_to_chat_superhero_com": "Przejdź do chat.superhero.com",
"incompatible_browser": {
"browser_links": "Zainstaluj Chrome , Firefox , lub Safari w celu zapewnienia najlepszego działania.",
"continue_warning": "Rozumiem ryzyko i chcę kontynuować",
@@ -36,5 +36,5 @@
"unknown_device": "Nieznane urządzenie",
"use_brand_on_mobile": "Użyj %(brand)s w telefonie",
"web_default_device_name": "%(appName)s: %(browserName)s na %(osName)s",
- "welcome_to_element": "Witamy w Element"
+ "welcome_to_superhero": "Witamy w Superhero"
}
diff --git a/src/i18n/strings/pt_BR.json b/src/i18n/strings/pt_BR.json
index 4307e78f6c9..ab54ba9393b 100644
--- a/src/i18n/strings/pt_BR.json
+++ b/src/i18n/strings/pt_BR.json
@@ -14,13 +14,13 @@
"app_launch_unexpected_error": "Erro inesperado preparando o app. Veja console para detalhes.",
"cannot_load_config": "Incapaz de carregar arquivo de configuração: por favor atualize a página para tentar de novo.",
"invalid_configuration_no_server": "Configuração inválida: nenhum servidor default especificado.",
- "invalid_json": "Sua configuração do Element contém JSON inválido. Por favor corrija o problema e recarregue a página.",
+ "invalid_json": "Sua configuração do Superhero contém JSON inválido. Por favor corrija o problema e recarregue a página.",
"invalid_json_detail": "A mensagem do parser é: %(message)s",
"invalid_json_generic": "JSON inválido",
- "misconfigured": "Seu Element está mal configurado"
+ "misconfigured": "Seu Superhero está mal configurado"
},
"failed_to_start": "Falha para iniciar",
- "go_to_element_io": "Ir para element.io",
+ "go_to_chat_superhero_com": "Ir para chat.superhero.com",
"incompatible_browser": {
"browser_links": "Por favor instale Chrome , Firefox , ou Safari para a melhor experiência.",
"continue_warning": "Eu entendo os riscos e desejo continuar",
@@ -33,5 +33,5 @@
"unknown_device": "Dispositivo desconhecido",
"use_brand_on_mobile": "Usar %(brand)s em celular",
"web_default_device_name": "%(appName)s: %(browserName)s em %(osName)s",
- "welcome_to_element": "Boas-vindas a Element"
+ "welcome_to_superhero": "Boas-vindas a Superhero"
}
diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json
index fa36484a07f..511244f9fea 100644
--- a/src/i18n/strings/ru.json
+++ b/src/i18n/strings/ru.json
@@ -16,13 +16,13 @@
"cannot_load_config": "Не удалось загрузить файл конфигурации. Попробуйте обновить страницу.",
"invalid_configuration_mixed_server": "Неверная конфигурация: нельзя указать default_hs_url вместе с default_server_name или default_server_config",
"invalid_configuration_no_server": "Неверная конфигурация: сервер по умолчанию не указан.",
- "invalid_json": "Конфигурация Element содержит неверный JSON. Исправьте проблему и обновите страницу.",
+ "invalid_json": "Конфигурация Superhero содержит неверный JSON. Исправьте проблему и обновите страницу.",
"invalid_json_detail": "Сообщение из парсера: %(message)s",
"invalid_json_generic": "Неверный JSON",
- "misconfigured": "Ваш Element неверно настроен"
+ "misconfigured": "Ваш Superhero неверно настроен"
},
"failed_to_start": "Старт не удался",
- "go_to_element_io": "К element.io",
+ "go_to_chat_superhero_com": "К chat.superhero.com",
"incompatible_browser": {
"browser_links": "Пожалуйста поставьте Chrome , Firefox , или Safari для лучшей совместимости.",
"continue_warning": "Я понимаю риск и хочу продолжить",
@@ -36,5 +36,5 @@
"unknown_device": "Неизвестное устройство",
"use_brand_on_mobile": "Воспользуйтесь %(brand)s на мобильном телефоне",
"web_default_device_name": "%(appName)s: %(browserName)s на %(osName)s",
- "welcome_to_element": "Добро пожаловать в Element"
+ "welcome_to_superhero": "Добро пожаловать в Superhero"
}
diff --git a/src/i18n/strings/sk.json b/src/i18n/strings/sk.json
index d70a4354e41..1964ddb368a 100644
--- a/src/i18n/strings/sk.json
+++ b/src/i18n/strings/sk.json
@@ -16,13 +16,13 @@
"cannot_load_config": "Nemožno načítať konfiguračný súbor: prosím obnovte stránku a skúste to znova.",
"invalid_configuration_mixed_server": "Neplatná konfigurácia: default_hs_url nemôže byť určená spolu s default_server_name alebo default_server_config",
"invalid_configuration_no_server": "Neplatné nastavenie: nebol určený východiskový server.",
- "invalid_json": "Vaša konfigurácia Elementu obsahuje nesprávny údaj JSON. Prosím, opravte chybu a obnovte stránku.",
+ "invalid_json": "Vaša konfigurácia Superherou obsahuje nesprávny údaj JSON. Prosím, opravte chybu a obnovte stránku.",
"invalid_json_detail": "Správa z parsera je: %(message)s",
"invalid_json_generic": "Neplatný JSON",
- "misconfigured": "Váš Element je nesprávne nastavený"
+ "misconfigured": "Váš Superhero je nesprávne nastavený"
},
"failed_to_start": "Spustenie zlyhalo",
- "go_to_element_io": "Prejsť na element.io",
+ "go_to_chat_superhero_com": "Prejsť na chat.superhero.com",
"incompatible_browser": {
"browser_links": "Prosím, nainštalujte si Chrome , Firefox alebo Safari pre najlepší zážitok.",
"continue_warning": "Rozumiem riziku a chcem pokračovať",
@@ -36,5 +36,5 @@
"unknown_device": "Neznáme zariadenie",
"use_brand_on_mobile": "Používať %(brand)s na mobilnom zariadení",
"web_default_device_name": "%(appName)s: %(browserName)s na %(osName)s",
- "welcome_to_element": "Víta vás Element"
+ "welcome_to_superhero": "Víta vás Superhero"
}
diff --git a/src/i18n/strings/sq.json b/src/i18n/strings/sq.json
index 22368a1b27f..ffb49a192e9 100644
--- a/src/i18n/strings/sq.json
+++ b/src/i18n/strings/sq.json
@@ -16,13 +16,13 @@
"cannot_load_config": "S’arrihet të ngarkohet kartelë formësimesh: ju lutemi, rifreskoni faqen dhe riprovoni.",
"invalid_configuration_mixed_server": "Formësim i pavlefshëm: një default_hs_url s’mund të jepet tok me default_server_name, apo default_server_config",
"invalid_configuration_no_server": "Formësim i pavlefshëm: s’është caktuar shërbyes parazgjedhje.",
- "invalid_json": "Formësimi juaj i Element-it përmban JSON të pavlefshëm. Ju lutemi, ndreqeni problemin dhe ringarkoni faqen.",
+ "invalid_json": "Formësimi juaj i Superhero-it përmban JSON të pavlefshëm. Ju lutemi, ndreqeni problemin dhe ringarkoni faqen.",
"invalid_json_detail": "Mesazhi prej procesit është: %(message)s",
"invalid_json_generic": "JSON i pavlefshëm",
- "misconfigured": "Element-i juaj është i keqformësuar"
+ "misconfigured": "Superhero-i juaj është i keqformësuar"
},
"failed_to_start": "S’u arrit të nisej",
- "go_to_element_io": "Shko te element.io",
+ "go_to_chat_superhero_com": "Shko te chat.superhero.com",
"incompatible_browser": {
"browser_links": "Ju lutemi, për funksionimin më të mirë, instaloni Chrome , Firefox , ose Safari .",
"continue_warning": "I kuptoj rreziqet dhe dëshiroj të vazhdoj",
@@ -36,5 +36,5 @@
"unknown_device": "Pajisje e panjohur",
"use_brand_on_mobile": "Përdor %(brand)s në celular",
"web_default_device_name": "%(appName)s: %(browserName)s në %(osName)s",
- "welcome_to_element": "Mirë se vini te Element"
+ "welcome_to_superhero": "Mirë se vini te Superhero"
}
diff --git a/src/i18n/strings/sv.json b/src/i18n/strings/sv.json
index 773c2fe5185..49280790234 100644
--- a/src/i18n/strings/sv.json
+++ b/src/i18n/strings/sv.json
@@ -16,13 +16,13 @@
"cannot_load_config": "Kan inte ladda konfigurationsfilen: ladda om sidan för att försöka igen.",
"invalid_configuration_mixed_server": "Ogiltig konfiguration: en default_hs_url kan inte anges tillsammans med default_server_name eller default_server_config",
"invalid_configuration_no_server": "Ogiltiga inställningar: ingen standardserver specificerad.",
- "invalid_json": "Din Elementkonfiguration innehåller ogiltig JSON. Vänligen rätta till problemet och ladda om sidan.",
+ "invalid_json": "Din Superherokonfiguration innehåller ogiltig JSON. Vänligen rätta till problemet och ladda om sidan.",
"invalid_json_detail": "Meddelandet från parsern är: %(message)s",
"invalid_json_generic": "Ogiltig JSON",
- "misconfigured": "Din Element är felkonfigurerad"
+ "misconfigured": "Din Superhero är felkonfigurerad"
},
"failed_to_start": "Misslyckade att starta",
- "go_to_element_io": "Gå till element.io",
+ "go_to_chat_superhero_com": "Gå till chat.superhero.com",
"incompatible_browser": {
"browser_links": "Installera Chrome , Firefox , eller Safari för den bästa upplevelsen.",
"continue_warning": "Jag förstår riskerna och vill fortsätta",
@@ -36,5 +36,5 @@
"unknown_device": "Okänd enhet",
"use_brand_on_mobile": "Använd %(brand)s på mobilen",
"web_default_device_name": "%(appName)s: %(browserName)s på %(osName)s",
- "welcome_to_element": "Välkommen till Element"
+ "welcome_to_superhero": "Välkommen till Superhero"
}
diff --git a/src/i18n/strings/uk.json b/src/i18n/strings/uk.json
index aea71ee1e0b..7cce0793964 100644
--- a/src/i18n/strings/uk.json
+++ b/src/i18n/strings/uk.json
@@ -16,13 +16,13 @@
"cannot_load_config": "Неможливо завантажити файл конфігурації. Оновіть, будь ласка, сторінку, щоб спробувати знову.",
"invalid_configuration_mixed_server": "Неправильна конфігурація: не можна вказати default_hs_url разом з default_server_name або default_server_config",
"invalid_configuration_no_server": "Невірна конфігурація: не вказано сервер за замовчуванням.",
- "invalid_json": "Ваша конфігурація Element містить хибний JSON. Виправте проблему та оновіть сторінку.",
+ "invalid_json": "Ваша конфігурація Superhero містить хибний JSON. Виправте проблему та оновіть сторінку.",
"invalid_json_detail": "Повідомлення від аналізатора : %(message)s",
"invalid_json_generic": "Хибний JSON",
- "misconfigured": "Ваш Element налаштовано неправильно"
+ "misconfigured": "Ваш Superhero налаштовано неправильно"
},
"failed_to_start": "Не вдалося запустити",
- "go_to_element_io": "Перейти на element.io",
+ "go_to_chat_superhero_com": "Перейти на chat.superhero.com",
"incompatible_browser": {
"browser_links": "Для найкращих вражень від користування встановіть, будь ласка, Chrome , Firefox , або Safari .",
"continue_warning": "Я усвідомлюю ризик і бажаю продовжити",
@@ -36,5 +36,5 @@
"unknown_device": "Невідомий пристрій",
"use_brand_on_mobile": "Користуйтеся %(brand)s на мобільному",
"web_default_device_name": "%(appName)s: %(browserName)s на %(osName)s",
- "welcome_to_element": "Ласкаво просимо до Element"
+ "welcome_to_superhero": "Ласкаво просимо до Superhero"
}
diff --git a/src/i18n/strings/vi.json b/src/i18n/strings/vi.json
index 47d874a1df7..7d741c0abc6 100644
--- a/src/i18n/strings/vi.json
+++ b/src/i18n/strings/vi.json
@@ -16,13 +16,13 @@
"cannot_load_config": "Không thể tải tệp cấu hình: hãy tải lại trang để thử lại.",
"invalid_configuration_mixed_server": "Cấu hình không hợp lệ: không thể xác định default_hs_url song song với default_server_name hay default_server_config",
"invalid_configuration_no_server": "Thiết lập không hợp lệ: chưa chỉ định máy chủ mặc định.",
- "invalid_json": "Thiết lập Element của bạn đang chứa mã JSON không hợp lệ. Vui lòng sửa lại và tải lại trang.",
+ "invalid_json": "Thiết lập Superhero của bạn đang chứa mã JSON không hợp lệ. Vui lòng sửa lại và tải lại trang.",
"invalid_json_detail": "Thông báo của trình xử lý là: %(message)s",
"invalid_json_generic": "JSON không hợp lệ",
- "misconfigured": "Element đang bị thiết lập sai"
+ "misconfigured": "Superhero đang bị thiết lập sai"
},
"failed_to_start": "Không khởi động được",
- "go_to_element_io": "Qua element.io",
+ "go_to_chat_superhero_com": "Qua chat.superhero.com",
"incompatible_browser": {
"browser_links": "Hãy cài đặt Chrome , Firefox , hoặc Safari để có trải nghiệm tốt nhất.",
"continue_warning": "Tôi hiểu rủi ro và muốn tiếp tục",
@@ -36,5 +36,5 @@
"unknown_device": "Thiết bị không xác định",
"use_brand_on_mobile": "Sử dụng %(brand)s trên di động",
"web_default_device_name": "%(appName)s: %(browserName)s trên %(osName)s",
- "welcome_to_element": "Chào mừng tới Element"
+ "welcome_to_superhero": "Chào mừng tới Superhero"
}
diff --git a/src/i18n/strings/zh_Hans.json b/src/i18n/strings/zh_Hans.json
index bf6cf573b03..d30431ce12e 100644
--- a/src/i18n/strings/zh_Hans.json
+++ b/src/i18n/strings/zh_Hans.json
@@ -16,13 +16,13 @@
"cannot_load_config": "无法加载配置文件:请刷新页面以重试。",
"invalid_configuration_mixed_server": "配置无效:无法与 default_server_name 或 default_server_config 一起指定 default_hs_url",
"invalid_configuration_no_server": "配置无效:没有指定默认服务器。",
- "invalid_json": "Element 配置文件中包含无效的 JSON。请改正错误并重新加载页面。",
+ "invalid_json": "Superhero 配置文件中包含无效的 JSON。请改正错误并重新加载页面。",
"invalid_json_detail": "来自解析器的消息:%(message)s",
"invalid_json_generic": "无效的 JSON",
- "misconfigured": "Element 配置错误"
+ "misconfigured": "Superhero 配置错误"
},
"failed_to_start": "启动失败",
- "go_to_element_io": "前往 element.io",
+ "go_to_chat_superhero_com": "前往 chat.superhero.com",
"incompatible_browser": {
"browser_links": "请安装 Chrome 、Firefox 或 Safari 以获得最佳体验。",
"continue_warning": "我了解风险并希望继续",
@@ -36,5 +36,5 @@
"unknown_device": "未知设备",
"use_brand_on_mobile": "在移动设备上使用 %(brand)s",
"web_default_device_name": "%(appName)s:%(browserName)s在%(osName)s",
- "welcome_to_element": "欢迎来到 Element"
+ "welcome_to_superhero": "欢迎来到 Superhero"
}
diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json
index b869386ca0d..1bff220cda0 100644
--- a/src/i18n/strings/zh_Hant.json
+++ b/src/i18n/strings/zh_Hant.json
@@ -16,13 +16,13 @@
"cannot_load_config": "無法載入設定檔:請重新整理頁面以再試一次。",
"invalid_configuration_mixed_server": "無效設定:default_hs_url 不能與 default_server_name 或 default_server_config 一起指定",
"invalid_configuration_no_server": "無效設定:未指定預設伺服器。",
- "invalid_json": "您的 Element 設定中包含無效 JSON,請修正後重新載入網頁。",
+ "invalid_json": "您的 Superhero 設定中包含無效 JSON,請修正後重新載入網頁。",
"invalid_json_detail": "解析器收到的訊息:%(message)s",
"invalid_json_generic": "無效的 JSON",
- "misconfigured": "您的 Element 設定錯誤"
+ "misconfigured": "您的 Superhero 設定錯誤"
},
"failed_to_start": "啟動失敗",
- "go_to_element_io": "前往 element.io",
+ "go_to_chat_superhero_com": "前往 chat.superhero.com",
"incompatible_browser": {
"browser_links": "請安裝 Chrome 、Firefox 或 Safari 以取得最佳體驗。",
"continue_warning": "我了解風險並希望繼續",
@@ -35,5 +35,5 @@
"unknown_device": "未知裝置",
"use_brand_on_mobile": "在行動裝置上使用 %(brand)s",
"web_default_device_name": "%(appName)s:%(osName)s 的 %(browserName)s",
- "welcome_to_element": "歡迎使用 Element"
+ "welcome_to_superhero": "歡迎使用 Superhero"
}
diff --git a/src/linkify-matrix.ts b/src/linkify-matrix.ts
new file mode 100644
index 00000000000..005dac1e09e
--- /dev/null
+++ b/src/linkify-matrix.ts
@@ -0,0 +1,286 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2019 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 * as linkifyjs from "linkifyjs";
+import { EventListeners, Opts, registerCustomProtocol, registerPlugin } from "linkifyjs";
+import linkifyElement from "linkify-element";
+import linkifyString from "linkify-string";
+import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix";
+import { ViewUserPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewUserPayload";
+import { Action } from "matrix-react-sdk/src/dispatcher/actions";
+import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload";
+import {
+ parsePermalink,
+ tryTransformEntityToPermalink,
+ tryTransformPermalinkToLocalHref,
+} from "matrix-react-sdk/src/utils/permalinks/Permalinks";
+import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg";
+import dis from "matrix-react-sdk/src/dispatcher/dispatcher";
+import { PERMITTED_URL_SCHEMES } from "matrix-react-sdk/src/utils/UrlUtils";
+
+import { isVerifiedBot } from "./hooks/useVerifiedBot";
+import { openDmForUser } from "./utils";
+
+export enum Type {
+ URL = "url",
+ UserId = "userid",
+ RoomAlias = "roomalias",
+}
+
+function matrixOpaqueIdLinkifyParser({
+ scanner,
+ parser,
+ token,
+ name,
+}: {
+ scanner: linkifyjs.ScannerInit;
+ parser: linkifyjs.ParserInit;
+ token: "#" | "+" | "@";
+ name: Type;
+}): void {
+ const {
+ DOT,
+ // IPV4 necessity
+ NUM,
+ COLON,
+ SYM,
+ SLASH,
+ EQUALS,
+ HYPHEN,
+ UNDERSCORE,
+ } = scanner.tokens;
+
+ // Contains NUM, WORD, UWORD, EMOJI, TLD, UTLD, SCHEME, SLASH_SCHEME and LOCALHOST plus custom protocols (e.g. "matrix")
+ const { domain } = scanner.tokens.groups;
+
+ // Tokens we need that are not contained in the domain group
+ const additionalLocalpartTokens = [DOT, SYM, SLASH, EQUALS, UNDERSCORE, HYPHEN];
+ const additionalDomainpartTokens = [HYPHEN];
+
+ const matrixToken = linkifyjs.createTokenClass(name, { isLink: true });
+ const matrixTokenState = new linkifyjs.State(matrixToken) as any as linkifyjs.State; // linkify doesn't appear to type this correctly
+
+ const matrixTokenWithPort = linkifyjs.createTokenClass(name, { isLink: true });
+ const matrixTokenWithPortState = new linkifyjs.State(
+ matrixTokenWithPort,
+ ) as any as linkifyjs.State; // linkify doesn't appear to type this correctly
+
+ const INITIAL_STATE = parser.start.tt(token);
+
+ // Localpart
+ const LOCALPART_STATE = new linkifyjs.State();
+ INITIAL_STATE.ta(domain, LOCALPART_STATE);
+ INITIAL_STATE.ta(additionalLocalpartTokens, LOCALPART_STATE);
+ LOCALPART_STATE.ta(domain, LOCALPART_STATE);
+ LOCALPART_STATE.ta(additionalLocalpartTokens, LOCALPART_STATE);
+
+ // Domainpart
+ const DOMAINPART_STATE_DOT = LOCALPART_STATE.tt(COLON);
+ DOMAINPART_STATE_DOT.ta(domain, matrixTokenState);
+ DOMAINPART_STATE_DOT.ta(additionalDomainpartTokens, matrixTokenState);
+ matrixTokenState.ta(domain, matrixTokenState);
+ matrixTokenState.ta(additionalDomainpartTokens, matrixTokenState);
+ matrixTokenState.tt(DOT, DOMAINPART_STATE_DOT);
+
+ // Port suffixes
+ matrixTokenState.tt(COLON).tt(NUM, matrixTokenWithPortState);
+}
+
+function onUserClick(event: MouseEvent, userId: string): void {
+ event.preventDefault();
+
+ const client = MatrixClientPeg.get();
+ if (client && isVerifiedBot(userId)) {
+ void openDmForUser(client, { userId, rawDisplayName: userId });
+ } else {
+ dis.dispatch({
+ action: Action.ViewUser,
+ member: new User(userId),
+ });
+ }
+}
+
+function onAliasClick(event: MouseEvent, roomAlias: string): void {
+ event.preventDefault();
+ dis.dispatch({
+ action: Action.ViewRoom,
+ room_alias: roomAlias,
+ metricsTrigger: "Timeline",
+ metricsViaKeyboard: false,
+ });
+}
+
+const escapeRegExp = function (s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+};
+
+// Recognise URLs from both our local and official Element deployments.
+// Anyone else really should be using matrix.to. vector:// allowed to support Element Desktop relative links.
+export const ELEMENT_URL_PATTERN =
+ "^(?:vector://|https?://)?(?:" +
+ escapeRegExp(window.location.host + window.location.pathname) +
+ "|" +
+ "(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/|" +
+ "(?:app|beta|staging|develop)\\.element\\.io/" +
+ ")(#.*)";
+
+export const options: Opts = {
+ events: function (href: string, type: string): EventListeners {
+ switch (type as Type) {
+ case Type.URL: {
+ // intercept local permalinks to users and show them like userids (in userinfo of current room)
+ try {
+ const permalink = parsePermalink(href);
+ if (permalink?.userId) {
+ return {
+ click: function (e: MouseEvent): void {
+ onUserClick(e, permalink.userId!);
+ },
+ };
+ } else {
+ // for events, rooms etc. (anything other than users)
+ const localHref = tryTransformPermalinkToLocalHref(href);
+ if (localHref !== href) {
+ // it could be converted to a localHref -> therefore handle locally
+ return {
+ click: function (e: MouseEvent): void {
+ e.preventDefault();
+ window.location.hash = localHref;
+ },
+ };
+ }
+ }
+ } catch (e) {
+ // OK fine, it's not actually a permalink
+ }
+ break;
+ }
+ case Type.UserId:
+ return {
+ click: function (e: MouseEvent): void {
+ const userId = parsePermalink(href)?.userId ?? href;
+ if (userId) onUserClick(e, userId);
+ },
+ };
+ case Type.RoomAlias:
+ return {
+ click: function (e: MouseEvent): void {
+ const alias = parsePermalink(href)?.roomIdOrAlias ?? href;
+ if (alias) onAliasClick(e, alias);
+ },
+ };
+ }
+
+ return {};
+ },
+
+ formatHref: function (href: string, type: Type | string): string {
+ switch (type) {
+ case "url":
+ if (href.startsWith("mxc://") && MatrixClientPeg.get()) {
+ return getHttpUriForMxc(MatrixClientPeg.get()!.baseUrl, href);
+ }
+ // fallthrough
+ case Type.RoomAlias:
+ case Type.UserId:
+ default: {
+ return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? "";
+ }
+ }
+ },
+
+ attributes: {
+ rel: "noreferrer noopener",
+ },
+
+ ignoreTags: ["pre", "code"],
+
+ className: "linkified",
+
+ target: function (href: string, type: Type | string): string {
+ if (type === Type.URL) {
+ try {
+ const transformed = tryTransformPermalinkToLocalHref(href);
+ if (
+ transformed !== href || // if it could be converted to handle locally for matrix symbols e.g. @user:server.tdl and matrix.to
+ decodeURIComponent(href).match(ELEMENT_URL_PATTERN) // for https links to Element domains
+ ) {
+ return "";
+ } else {
+ return "_blank";
+ }
+ } catch (e) {
+ // malformed URI
+ }
+ }
+ return "";
+ },
+};
+
+// Run the plugins
+registerPlugin(Type.RoomAlias, ({ scanner, parser }) => {
+ const token = scanner.tokens.POUND as "#";
+ matrixOpaqueIdLinkifyParser({
+ scanner,
+ parser,
+ token,
+ name: Type.RoomAlias,
+ });
+});
+
+registerPlugin(Type.UserId, ({ scanner, parser }) => {
+ const token = scanner.tokens.AT as "@";
+ matrixOpaqueIdLinkifyParser({
+ scanner,
+ parser,
+ token,
+ name: Type.UserId,
+ });
+});
+
+// Linkify supports some common protocols but not others, register all permitted url schemes if unsupported
+// https://github.com/Hypercontext/linkifyjs/blob/f4fad9df1870259622992bbfba38bfe3d0515609/packages/linkifyjs/src/scanner.js#L133-L141
+// This also handles registering the `matrix:` protocol scheme
+const linkifySupportedProtocols = ["file", "mailto", "http", "https", "ftp", "ftps"];
+const optionalSlashProtocols = [
+ "bitcoin",
+ "geo",
+ "im",
+ "magnet",
+ "mailto",
+ "matrix",
+ "news",
+ "openpgp4fpr",
+ "sip",
+ "sms",
+ "smsto",
+ "tel",
+ "urn",
+ "xmpp",
+];
+
+PERMITTED_URL_SCHEMES.forEach((scheme) => {
+ if (!linkifySupportedProtocols.includes(scheme)) {
+ registerCustomProtocol(scheme, optionalSlashProtocols.includes(scheme));
+ }
+});
+
+registerCustomProtocol("mxc", false);
+
+export const linkify = linkifyjs;
+export const _linkifyElement = linkifyElement;
+export const _linkifyString = linkifyString;
diff --git a/src/stores/room-list/custom-models.ts b/src/stores/room-list/custom-models.ts
new file mode 100644
index 00000000000..89d96ed1bab
--- /dev/null
+++ b/src/stores/room-list/custom-models.ts
@@ -0,0 +1,7 @@
+import { DefaultTagID } from "matrix-react-sdk/src/stores/room-list/models";
+
+export enum SuperheroTagID {
+ CommunityRooms = "CommunityRooms",
+}
+
+export type CustomTagID = string | DefaultTagID | SuperheroTagID;
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 00000000000..082f433f1c0
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,15 @@
+import { MatrixClient, RoomMember, User } from "matrix-js-sdk/src/matrix";
+import { DirectoryMember, startDmOnFirstMessage } from "matrix-react-sdk/src/utils/direct-messages";
+
+import { Member } from "./components/views/right_panel/UserInfo";
+import { BareUser } from "./atoms";
+
+export async function openDmForUser(matrixClient: MatrixClient, user: Member | BareUser): Promise {
+ const avatarUrl = user instanceof User ? user.avatarUrl : user instanceof RoomMember ? user.getMxcAvatarUrl() : "";
+ const startDmUser = new DirectoryMember({
+ user_id: user.userId,
+ display_name: user.rawDisplayName,
+ avatar_url: avatarUrl,
+ });
+ await startDmOnFirstMessage(matrixClient, [startDmUser]);
+}
diff --git a/src/vector/app.tsx b/src/vector/app.tsx
index 68dd8229a27..e8cdba8b821 100644
--- a/src/vector/app.tsx
+++ b/src/vector/app.tsx
@@ -40,6 +40,7 @@ import { parseQs } from "./url_utils";
import VectorBasePlatform from "./platform/VectorBasePlatform";
import { getInitialScreenAfterLogin, getScreenFromLocation, init as initRouting, onNewScreen } from "./routing";
import { UserFriendlyError } from "../languageHandler";
+import { SuperheroProvider } from "../context/SuperheroProvider";
// add React and ReactPerf to the global namespace, to make them easier to access via the console
// this incidentally means we can forget our React imports in JSX files without penalty.
@@ -116,17 +117,19 @@ export async function loadApp(fragParams: {}, matrixChatRef: React.Ref
-
+
+
+
);
}
diff --git a/src/vector/index.html b/src/vector/index.html
index f5213701cf4..edbdff4ec6b 100644
--- a/src/vector/index.html
+++ b/src/vector/index.html
@@ -2,7 +2,7 @@
- Element
+ Superhero
@@ -16,8 +16,8 @@
-
-
+
+
@@ -55,11 +55,17 @@
<% }
} %>
+
- Sorry, Element requires JavaScript to be enabled.
-
+ Sorry, Superhero requires JavaScript to be enabled.
+
<%
// insert