Implement new memberlist design with MVVM architecture (#28874)
* Add new e2e icon for the member tile * Add new presence icon for member tile * Implement new member tile * Implement memberlist view model * Implement new memberlist header view * Support the new memberlist in Diasambiguated profile 1. Use MemberInfo instead of RoomMember 2. CSS changes to reflect the new design * Implement new memberlist view * Add and use a new overflow component We used the EntityTile component as a pretend overflow tile in some places. This new lighter component is added so that we can remove the complex EntityTile component. * Remove old code * Add/remove css files from _components.pcss * Increase minimum width as per design * Actually use the new memberlist view * Fix broken jest tests * Add jest tests * Playwright: Make it possible to disable presence * Add playwright tests * Fix lint error * Undo translation changes that must be done via localazy * Update license header * Use waitFor instead of setTimeout * Remove comment * Switch over from template to container hs * Revert unintended change * Move config to top level
This commit is contained in:
@@ -99,7 +99,7 @@ export default class MainSplit extends React.Component<IProps> {
|
||||
<Resizable
|
||||
key={this.props.sizeKey}
|
||||
defaultSize={this.loadSidePanelSize()}
|
||||
minWidth={264}
|
||||
minWidth={320}
|
||||
maxWidth="50%"
|
||||
enable={{
|
||||
top: false,
|
||||
|
||||
@@ -17,7 +17,6 @@ import RightPanelStore from "../../stores/right-panel/RightPanelStore";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
|
||||
import WidgetCard from "../views/right_panel/WidgetCard";
|
||||
import MemberList from "../views/rooms/MemberList";
|
||||
import UserInfo from "../views/right_panel/UserInfo";
|
||||
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
|
||||
import FilePanel from "./FilePanel";
|
||||
@@ -34,6 +33,7 @@ import { IRightPanelCard, IRightPanelCardState } from "../../stores/right-panel/
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { XOR } from "../../@types/common";
|
||||
import ExtensionsCard from "../views/right_panel/ExtensionsCard";
|
||||
import MemberListView from "../views/rooms/MemberList/MemberListView";
|
||||
|
||||
interface BaseProps {
|
||||
overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView)
|
||||
@@ -57,7 +57,6 @@ type Props = XOR<RoomlessProps, RoomProps>;
|
||||
|
||||
interface IState {
|
||||
phase?: RightPanelPhases;
|
||||
searchQuery: string;
|
||||
cardState?: IRightPanelCardState;
|
||||
}
|
||||
|
||||
@@ -67,10 +66,6 @@ export default class RightPanel extends React.Component<Props, IState> {
|
||||
|
||||
public constructor(props: Props, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
searchQuery: "",
|
||||
};
|
||||
}
|
||||
|
||||
private readonly delayedUpdate = throttle(
|
||||
@@ -147,10 +142,6 @@ export default class RightPanel extends React.Component<Props, IState> {
|
||||
}
|
||||
};
|
||||
|
||||
private onSearchQueryChanged = (searchQuery: string): void => {
|
||||
this.setState({ searchQuery });
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
let card = <div />;
|
||||
const roomId = this.props.room?.roomId;
|
||||
@@ -159,15 +150,7 @@ export default class RightPanel extends React.Component<Props, IState> {
|
||||
switch (phase) {
|
||||
case RightPanelPhases.MemberList:
|
||||
if (!!roomId) {
|
||||
card = (
|
||||
<MemberList
|
||||
roomId={roomId}
|
||||
key={roomId}
|
||||
onClose={this.onClose}
|
||||
searchQuery={this.state.searchQuery}
|
||||
onSearchQueryChanged={this.onSearchQueryChanged}
|
||||
/>
|
||||
);
|
||||
card = <MemberListView roomId={roomId} onClose={this.onClose} />;
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
263
src/components/viewmodels/memberlist/MemberListViewModel.tsx
Normal file
263
src/components/viewmodels/memberlist/MemberListViewModel.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
EventType,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomMemberEvent,
|
||||
RoomState,
|
||||
RoomStateEvent,
|
||||
RoomMember as SdkRoomMember,
|
||||
User,
|
||||
UserEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { throttle } from "lodash";
|
||||
|
||||
import { RoomMember } from "../../../models/rooms/RoomMember";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
|
||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../settings/UIFeature";
|
||||
import { PresenceState } from "../../../models/rooms/PresenceState";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { SDKContext } from "../../../contexts/SDKContext";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
import { ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||
import { inviteToRoom } from "../../../utils/room/inviteToRoom";
|
||||
import { canInviteTo } from "../../../utils/room/canInviteTo";
|
||||
import { isValid3pidInvite } from "../../../RoomInvite";
|
||||
import { ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite";
|
||||
import { XOR } from "../../../@types/common";
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
|
||||
type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>;
|
||||
|
||||
export function getPending3PidInvites(room: Room, searchQuery?: string): Member[] {
|
||||
// include 3pid invites (m.room.third_party_invite) state events.
|
||||
// The HS may have already converted these into m.room.member invites so
|
||||
// we shouldn't add them if the 3pid invite state key (token) is in the
|
||||
// member invite (content.third_party_invite.signed.token)
|
||||
const inviteEvents = room.currentState.getStateEvents("m.room.third_party_invite").filter(function (e) {
|
||||
if (!isValid3pidInvite(e)) return false;
|
||||
if (searchQuery && !(e.getContent().display_name as string)?.includes(searchQuery)) return false;
|
||||
|
||||
// discard all invites which have a m.room.member event since we've
|
||||
// already added them.
|
||||
const memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey()!);
|
||||
if (memberEvent) return false;
|
||||
return true;
|
||||
});
|
||||
const invites: Member[] = inviteEvents.map((e) => {
|
||||
return {
|
||||
threePidInvite: {
|
||||
event: e,
|
||||
},
|
||||
};
|
||||
});
|
||||
return invites;
|
||||
}
|
||||
|
||||
export function sdkRoomMemberToRoomMember(member: SdkRoomMember): Member {
|
||||
const displayUserId =
|
||||
UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, {
|
||||
roomId: member.roomId,
|
||||
}) ?? member.userId;
|
||||
|
||||
const mxcAvatarURL = member.getMxcAvatarUrl();
|
||||
const avatarThumbnailUrl =
|
||||
(mxcAvatarURL && mediaFromMxc(mxcAvatarURL).getThumbnailOfSourceHttp(96, 96, "crop")) ?? undefined;
|
||||
|
||||
const user = member.user;
|
||||
let presenceState: PresenceState | undefined;
|
||||
if (user) {
|
||||
presenceState = (user.presence as PresenceState) || undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
member: {
|
||||
roomId: member.roomId,
|
||||
userId: member.userId,
|
||||
displayUserId: displayUserId,
|
||||
name: member.name,
|
||||
rawDisplayName: member.rawDisplayName,
|
||||
disambiguate: member.disambiguate,
|
||||
avatarThumbnailUrl: avatarThumbnailUrl,
|
||||
powerLevel: member.powerLevel,
|
||||
lastModifiedTime: member.getLastModifiedTime(),
|
||||
presenceState,
|
||||
isInvite: member.membership === KnownMembership.Invite,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface MemberListViewState {
|
||||
members: Member[];
|
||||
search: (searchQuery: string) => void;
|
||||
isPresenceEnabled: boolean;
|
||||
shouldShowInvite: boolean;
|
||||
shouldShowSearch: boolean;
|
||||
isLoading: boolean;
|
||||
canInvite: boolean;
|
||||
onInviteButtonClick: (ev: ButtonEvent) => void;
|
||||
}
|
||||
export function useMemberListViewModel(roomId: string): MemberListViewState {
|
||||
const cli = useMatrixClientContext();
|
||||
|
||||
const room = useMemo(() => cli.getRoom(roomId), [roomId, cli]);
|
||||
if (!room) {
|
||||
throw new Error(`Room with id ${roomId} does not exist!`);
|
||||
}
|
||||
|
||||
const sdkContext = useContext(SDKContext);
|
||||
const [memberMap, setMemberMap] = useState<Map<string, Member>>(new Map());
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// This is the last known total number of members in this room.
|
||||
const totalMemberCount = useRef<number>(0);
|
||||
|
||||
const searchQuery = useRef("");
|
||||
|
||||
const loadMembers = useMemo(
|
||||
() =>
|
||||
throttle(
|
||||
async (): Promise<void> => {
|
||||
const { joined: joinedSdk, invited: invitedSdk } = await sdkContext.memberListStore.loadMemberList(
|
||||
roomId,
|
||||
searchQuery.current,
|
||||
);
|
||||
const newMemberMap = new Map<string, Member>();
|
||||
// First add the invited room members
|
||||
for (const member of invitedSdk) {
|
||||
const roomMember = sdkRoomMemberToRoomMember(member);
|
||||
newMemberMap.set(member.userId, roomMember);
|
||||
}
|
||||
// Then add the third party invites
|
||||
const threePidInvited = getPending3PidInvites(room, searchQuery.current);
|
||||
for (const invited of threePidInvited) {
|
||||
const key = invited.threePidInvite!.event.getContent().display_name;
|
||||
newMemberMap.set(key, invited);
|
||||
}
|
||||
// Finally add the joined room members
|
||||
for (const member of joinedSdk) {
|
||||
const roomMember = sdkRoomMemberToRoomMember(member);
|
||||
newMemberMap.set(member.userId, roomMember);
|
||||
}
|
||||
setMemberMap(newMemberMap);
|
||||
if (!searchQuery.current) {
|
||||
/**
|
||||
* Since searching for members only gives you the relevant
|
||||
* members matching the query, do not update the totalMemberCount!
|
||||
**/
|
||||
totalMemberCount.current = newMemberMap.size;
|
||||
}
|
||||
},
|
||||
500,
|
||||
{ leading: true, trailing: true },
|
||||
),
|
||||
[roomId, sdkContext.memberListStore, room],
|
||||
);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string) => {
|
||||
searchQuery.current = query;
|
||||
loadMembers();
|
||||
},
|
||||
[loadMembers],
|
||||
);
|
||||
|
||||
const isPresenceEnabled = useMemo(
|
||||
() => sdkContext.memberListStore.isPresenceEnabled(),
|
||||
[sdkContext.memberListStore],
|
||||
);
|
||||
|
||||
// Determines whether the rendered invite button is enabled or disabled
|
||||
const getCanUserInviteToThisRoom = useCallback((): boolean => !!room && canInviteTo(room), [room]);
|
||||
const [canInvite, setCanInvite] = useState<boolean>(getCanUserInviteToThisRoom());
|
||||
|
||||
// Determines whether the invite button should be shown or not.
|
||||
const getShouldShowInvite = useCallback(
|
||||
(): boolean => room?.getMyMembership() === KnownMembership.Join && shouldShowComponent(UIComponent.InviteUsers),
|
||||
[room],
|
||||
);
|
||||
const [shouldShowInvite, setShouldShowInvite] = useState<boolean>(getShouldShowInvite());
|
||||
|
||||
const onInviteButtonClick = (ev: ButtonEvent): void => {
|
||||
PosthogTrackers.trackInteraction("WebRightPanelMemberListInviteButton", ev);
|
||||
ev.preventDefault();
|
||||
inviteToRoom(room);
|
||||
};
|
||||
|
||||
useTypedEventEmitter(cli, RoomStateEvent.Events, (event: MatrixEvent) => {
|
||||
if (event.getRoomId() === roomId && event.getType() === EventType.RoomThirdPartyInvite) {
|
||||
loadMembers();
|
||||
const newCanInvite = getCanUserInviteToThisRoom();
|
||||
setCanInvite(newCanInvite);
|
||||
}
|
||||
});
|
||||
|
||||
useTypedEventEmitter(cli, RoomStateEvent.Update, (state: RoomState) => {
|
||||
if (state.roomId === roomId) loadMembers();
|
||||
});
|
||||
|
||||
useTypedEventEmitter(cli, RoomMemberEvent.Name, (_: MatrixEvent, member: SdkRoomMember) => {
|
||||
if (member.roomId === roomId) loadMembers();
|
||||
});
|
||||
|
||||
useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) => {
|
||||
// We listen for room events because when we accept an invite
|
||||
// we need to wait till the room is fully populated with state
|
||||
// before refreshing the member list else we get a stale list.
|
||||
if (room.roomId === roomId) loadMembers();
|
||||
});
|
||||
|
||||
useTypedEventEmitter(cli, RoomEvent.MyMembership, (room: Room, membership: string, oldMembership?: string) => {
|
||||
if (room.roomId !== roomId) return;
|
||||
if (membership === KnownMembership.Join && oldMembership !== KnownMembership.Join) {
|
||||
// we just joined the room, load the member list
|
||||
loadMembers();
|
||||
const newShouldShowInvite = getShouldShowInvite();
|
||||
setShouldShowInvite(newShouldShowInvite);
|
||||
}
|
||||
});
|
||||
|
||||
useTypedEventEmitter(cli, UserEvent.Presence, (_: MatrixEvent | undefined, user: User) => {
|
||||
if (memberMap.has(user.userId)) loadMembers();
|
||||
});
|
||||
|
||||
useTypedEventEmitter(cli, UserEvent.CurrentlyActive, (_: MatrixEvent | undefined, user: User) => {
|
||||
if (memberMap.has(user.userId)) loadMembers();
|
||||
});
|
||||
|
||||
// Initial load of the memberlist
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await loadMembers();
|
||||
/**
|
||||
* isLoading is used to render a spinner on initial call.
|
||||
* Further calls need not mutate this state since it's perfectly fine to
|
||||
* show the existing memberlist until the new one loads.
|
||||
*/
|
||||
setIsLoading(false);
|
||||
})();
|
||||
}, [loadMembers]);
|
||||
|
||||
return {
|
||||
members: Array.from(memberMap.values()),
|
||||
search,
|
||||
shouldShowInvite,
|
||||
isPresenceEnabled,
|
||||
isLoading,
|
||||
onInviteButtonClick,
|
||||
shouldShowSearch: totalMemberCount.current >= 20,
|
||||
canInvite,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { RoomStateEvent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
|
||||
import { UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import dis from "../../../../dispatcher/dispatcher";
|
||||
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
|
||||
import { Action } from "../../../../dispatcher/actions";
|
||||
import { asyncSome } from "../../../../utils/arrays";
|
||||
import { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo";
|
||||
import { RoomMember } from "../../../../models/rooms/RoomMember";
|
||||
import { E2EState } from "../../../views/rooms/E2EIcon";
|
||||
import { _t, _td, TranslationKey } from "../../../../languageHandler";
|
||||
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
|
||||
|
||||
interface MemberTileViewModelProps {
|
||||
member: RoomMember;
|
||||
showPresence?: boolean;
|
||||
}
|
||||
|
||||
export interface MemberTileViewState extends MemberTileViewModelProps {
|
||||
e2eStatus?: E2EState;
|
||||
name: string;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
userLabel?: string;
|
||||
}
|
||||
|
||||
export enum PowerStatus {
|
||||
Admin = "admin",
|
||||
Moderator = "moderator",
|
||||
}
|
||||
|
||||
const PowerLabel: Record<PowerStatus, TranslationKey> = {
|
||||
[PowerStatus.Admin]: _td("power_level|admin"),
|
||||
[PowerStatus.Moderator]: _td("power_level|moderator"),
|
||||
};
|
||||
|
||||
export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberTileViewState {
|
||||
const [e2eStatus, setE2eStatus] = useState<E2EState | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
|
||||
const updateE2EStatus = async (): Promise<void> => {
|
||||
const { userId } = props.member;
|
||||
const isMe = userId === cli.getUserId();
|
||||
const userTrust = await cli.getCrypto()?.getUserVerificationStatus(userId);
|
||||
if (!userTrust?.isCrossSigningVerified()) {
|
||||
setE2eStatus(userTrust?.wasCrossSigningVerified() ? E2EState.Warning : E2EState.Normal);
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceIDs = await getUserDeviceIds(cli, userId);
|
||||
const anyDeviceUnverified = await asyncSome(deviceIDs, async (deviceId) => {
|
||||
// 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 cli.getCrypto()?.getDeviceVerificationStatus(userId, deviceId);
|
||||
return !deviceTrust || (isMe ? !deviceTrust.crossSigningVerified : !deviceTrust.isVerified());
|
||||
});
|
||||
setE2eStatus(anyDeviceUnverified ? E2EState.Warning : E2EState.Verified);
|
||||
};
|
||||
|
||||
const onRoomStateEvents = (ev: MatrixEvent): void => {
|
||||
if (ev.getType() !== EventType.RoomEncryption) return;
|
||||
const { roomId } = props.member;
|
||||
if (ev.getRoomId() !== roomId) return;
|
||||
|
||||
// The room is encrypted now.
|
||||
cli.removeListener(RoomStateEvent.Events, onRoomStateEvents);
|
||||
updateE2EStatus();
|
||||
};
|
||||
|
||||
const onUserTrustStatusChanged = (userId: string, trustStatus: UserVerificationStatus): void => {
|
||||
if (userId !== props.member.userId) return;
|
||||
updateE2EStatus();
|
||||
};
|
||||
|
||||
const { roomId } = props.member;
|
||||
if (roomId) {
|
||||
const isRoomEncrypted = cli.isRoomEncrypted(roomId);
|
||||
if (isRoomEncrypted) {
|
||||
cli.on(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged);
|
||||
updateE2EStatus();
|
||||
} else {
|
||||
// Listen for room to become encrypted
|
||||
cli.on(RoomStateEvent.Events, onRoomStateEvents);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (cli) {
|
||||
cli.removeListener(RoomStateEvent.Events, onRoomStateEvents);
|
||||
cli.removeListener(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged);
|
||||
}
|
||||
};
|
||||
}, [props.member]);
|
||||
|
||||
const onClick = (): void => {
|
||||
dis.dispatch({
|
||||
action: Action.ViewUser,
|
||||
member: props.member,
|
||||
push: true,
|
||||
});
|
||||
};
|
||||
|
||||
const member = props.member;
|
||||
const name = props.member.name;
|
||||
|
||||
const powerStatusMap = new Map([
|
||||
[100, PowerStatus.Admin],
|
||||
[50, PowerStatus.Moderator],
|
||||
]);
|
||||
|
||||
// Find the nearest power level with a badge
|
||||
let powerLevel = props.member.powerLevel;
|
||||
for (const [pl] of powerStatusMap) {
|
||||
if (props.member.powerLevel >= pl) {
|
||||
powerLevel = pl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const title = useMemo(() => {
|
||||
return _t("member_list|power_label", {
|
||||
userName: UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, {
|
||||
roomId: member.roomId,
|
||||
}),
|
||||
powerLevelNumber: member.powerLevel,
|
||||
}).trim();
|
||||
}, [member.powerLevel, member.roomId, member.userId]);
|
||||
|
||||
let userLabel;
|
||||
const powerStatus = powerStatusMap.get(powerLevel);
|
||||
if (powerStatus) {
|
||||
userLabel = _t(PowerLabel[powerStatus]);
|
||||
}
|
||||
if (props.member.isInvite) {
|
||||
userLabel = `(${_t("member_list|invited_label")})`;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
member,
|
||||
name,
|
||||
onClick,
|
||||
e2eStatus,
|
||||
showPresence: props.showPresence,
|
||||
userLabel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import dis from "../../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../../dispatcher/actions";
|
||||
import { ThreePIDInvite } from "../../../../models/rooms/ThreePIDInvite";
|
||||
|
||||
interface ThreePidTileViewModelProps {
|
||||
threePidInvite: ThreePIDInvite;
|
||||
}
|
||||
|
||||
export interface ThreePidTileViewState {
|
||||
name: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): ThreePidTileViewState {
|
||||
const invite = props.threePidInvite;
|
||||
const name = invite.event.getContent().display_name;
|
||||
const onClick = (): void => {
|
||||
dis.dispatch({
|
||||
action: Action.View3pidInvite,
|
||||
event: invite.event,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
name,
|
||||
onClick,
|
||||
};
|
||||
}
|
||||
@@ -23,8 +23,6 @@ import {
|
||||
TimelineEvents,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import OverflowHorizontalSvg from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
@@ -42,8 +40,6 @@ import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
|
||||
import QueryMatcher from "../../../autocomplete/QueryMatcher";
|
||||
import TruncatedList from "../elements/TruncatedList";
|
||||
import EntityTile from "../rooms/EntityTile";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
@@ -60,6 +56,7 @@ import {
|
||||
} from "../../../accessibility/RovingTabIndex";
|
||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
||||
import { OverflowTileView } from "../rooms/OverflowTileView";
|
||||
|
||||
const AVATAR_SIZE = 30;
|
||||
|
||||
@@ -275,17 +272,9 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
|
||||
}
|
||||
|
||||
const [truncateAt, setTruncateAt] = useState(20);
|
||||
|
||||
function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
|
||||
const text = _t("common|and_n_others", { count: overflowCount });
|
||||
return (
|
||||
<EntityTile
|
||||
className="mx_EntityTile_ellipsis"
|
||||
avatarJsx={<BaseAvatar url={OverflowHorizontalSvg} name="..." size="36px" />}
|
||||
name={text}
|
||||
showPresence={false}
|
||||
onClick={() => setTruncateAt(totalCount)}
|
||||
/>
|
||||
);
|
||||
return <OverflowTileView remaining={overflowCount} onClick={() => setTruncateAt(totalCount)} />;
|
||||
}
|
||||
|
||||
const onKeyDown = (ev: React.KeyboardEvent, state: IState): void => {
|
||||
|
||||
@@ -8,15 +8,21 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
|
||||
import UserIdentifier from "../../../customisations/UserIdentifier";
|
||||
|
||||
interface MemberInfo {
|
||||
userId: string;
|
||||
roomId: string;
|
||||
rawDisplayName?: string;
|
||||
disambiguate: boolean;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
member?: RoomMember | null;
|
||||
member?: MemberInfo | null;
|
||||
fallbackName: string;
|
||||
onClick?(): void;
|
||||
colored?: boolean;
|
||||
|
||||
@@ -22,7 +22,7 @@ export enum E2EState {
|
||||
Normal = "normal",
|
||||
}
|
||||
|
||||
const crossSigningUserTitles: { [key in E2EState]?: TranslationKey } = {
|
||||
export const crossSigningUserTitles: { [key in E2EState]?: TranslationKey } = {
|
||||
[E2EState.Warning]: _td("encryption|cross_signing_user_warning"),
|
||||
[E2EState.Normal]: _td("encryption|cross_signing_user_normal"),
|
||||
[E2EState.Verified]: _td("encryption|cross_signing_user_verified"),
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { _t, _td, TranslationKey } from "../../../languageHandler";
|
||||
import E2EIcon, { E2EState } from "./E2EIcon";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
import PresenceLabel from "./PresenceLabel";
|
||||
|
||||
export enum PowerStatus {
|
||||
Admin = "admin",
|
||||
Moderator = "moderator",
|
||||
}
|
||||
|
||||
const PowerLabel: Record<PowerStatus, TranslationKey> = {
|
||||
[PowerStatus.Admin]: _td("power_level|admin"),
|
||||
[PowerStatus.Moderator]: _td("power_level|mod"),
|
||||
};
|
||||
|
||||
export type PresenceState = "offline" | "online" | "unavailable" | "io.element.unreachable";
|
||||
|
||||
const PRESENCE_CLASS: Record<PresenceState, string> = {
|
||||
"offline": "mx_EntityTile_offline",
|
||||
"online": "mx_EntityTile_online",
|
||||
"unavailable": "mx_EntityTile_unavailable",
|
||||
"io.element.unreachable": "mx_EntityTile_unreachable",
|
||||
};
|
||||
|
||||
function presenceClassForMember(presenceState?: PresenceState, lastActiveAgo?: number, showPresence?: boolean): string {
|
||||
if (showPresence === false) {
|
||||
return "mx_EntityTile_online_beenactive";
|
||||
}
|
||||
|
||||
// offline is split into two categories depending on whether we have
|
||||
// a last_active_ago for them.
|
||||
if (presenceState === "offline") {
|
||||
if (lastActiveAgo) {
|
||||
return PRESENCE_CLASS["offline"] + "_beenactive";
|
||||
} else {
|
||||
return PRESENCE_CLASS["offline"] + "_neveractive";
|
||||
}
|
||||
} else if (presenceState) {
|
||||
return PRESENCE_CLASS[presenceState];
|
||||
} else {
|
||||
return PRESENCE_CLASS["offline"] + "_neveractive";
|
||||
}
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
name?: string;
|
||||
nameJSX?: JSX.Element;
|
||||
title?: string;
|
||||
avatarJsx?: JSX.Element; // <BaseAvatar />
|
||||
className?: string;
|
||||
presenceState: PresenceState;
|
||||
presenceLastActiveAgo: number;
|
||||
presenceLastTs: number;
|
||||
presenceCurrentlyActive?: boolean;
|
||||
onClick(): void;
|
||||
showPresence: boolean;
|
||||
subtextLabel?: string;
|
||||
e2eStatus?: E2EState;
|
||||
powerStatus?: PowerStatus;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
export default class EntityTile extends React.PureComponent<IProps, IState> {
|
||||
public static defaultProps = {
|
||||
onClick: () => {},
|
||||
presenceState: "offline",
|
||||
presenceLastActiveAgo: 0,
|
||||
presenceLastTs: 0,
|
||||
showInviteButton: false,
|
||||
showPresence: true,
|
||||
};
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hover: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the PresenceLabel component if needed
|
||||
* @returns The PresenceLabel component if we need to render it, undefined otherwise
|
||||
*/
|
||||
private getPresenceLabel(): JSX.Element | undefined {
|
||||
if (!this.props.showPresence) return;
|
||||
const activeAgo = this.props.presenceLastActiveAgo
|
||||
? Date.now() - (this.props.presenceLastTs - this.props.presenceLastActiveAgo)
|
||||
: -1;
|
||||
return (
|
||||
<PresenceLabel
|
||||
activeAgo={activeAgo}
|
||||
currentlyActive={this.props.presenceCurrentlyActive}
|
||||
presenceState={this.props.presenceState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const mainClassNames: Record<string, boolean> = {
|
||||
mx_EntityTile: true,
|
||||
};
|
||||
if (this.props.className) mainClassNames[this.props.className] = true;
|
||||
|
||||
const presenceClass = presenceClassForMember(
|
||||
this.props.presenceState,
|
||||
this.props.presenceLastActiveAgo,
|
||||
this.props.showPresence,
|
||||
);
|
||||
mainClassNames[presenceClass] = true;
|
||||
|
||||
const name = this.props.nameJSX || this.props.name;
|
||||
const nameAndPresence = (
|
||||
<div className="mx_EntityTile_details">
|
||||
<div className="mx_EntityTile_name">{name}</div>
|
||||
{this.getPresenceLabel()}
|
||||
</div>
|
||||
);
|
||||
|
||||
let powerLabel;
|
||||
const powerStatus = this.props.powerStatus;
|
||||
if (powerStatus) {
|
||||
const powerText = _t(PowerLabel[powerStatus]);
|
||||
powerLabel = <div className="mx_EntityTile_power">{powerText}</div>;
|
||||
}
|
||||
|
||||
let e2eIcon;
|
||||
const { e2eStatus } = this.props;
|
||||
if (e2eStatus) {
|
||||
e2eIcon = <E2EIcon status={e2eStatus} isUser={true} bordered={true} />;
|
||||
}
|
||||
|
||||
const av = this.props.avatarJsx || <BaseAvatar name={this.props.name} size="36px" aria-hidden="true" />;
|
||||
|
||||
// The wrapping div is required to make the magic mouse listener work, for some reason.
|
||||
return (
|
||||
<div>
|
||||
<AccessibleButton
|
||||
className={classNames(mainClassNames)}
|
||||
title={this.props.title}
|
||||
onClick={this.props.onClick}
|
||||
>
|
||||
<div className="mx_EntityTile_avatar">
|
||||
{av}
|
||||
{e2eIcon}
|
||||
</div>
|
||||
{nameAndPresence}
|
||||
{powerLabel}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
MatrixEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomMember,
|
||||
RoomMemberEvent,
|
||||
RoomState,
|
||||
RoomStateEvent,
|
||||
User,
|
||||
UserEvent,
|
||||
EventType,
|
||||
ClientEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { throttle } from "lodash";
|
||||
import { Button, Tooltip } from "@vector-im/compound-web";
|
||||
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import OverflowHorizontalSvg from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { isValid3pidInvite } from "../../../RoomInvite";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import BaseCard from "../right_panel/BaseCard";
|
||||
import TruncatedList from "../elements/TruncatedList";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import SearchBox from "../../structures/SearchBox";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import EntityTile from "./EntityTile";
|
||||
import MemberTile from "./MemberTile";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../settings/UIFeature";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
import { SDKContext } from "../../../contexts/SDKContext";
|
||||
import { canInviteTo } from "../../../utils/room/canInviteTo";
|
||||
import { inviteToRoom } from "../../../utils/room/inviteToRoom";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
|
||||
const INITIAL_LOAD_NUM_MEMBERS = 30;
|
||||
const INITIAL_LOAD_NUM_INVITED = 5;
|
||||
const SHOW_MORE_INCREMENT = 100;
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
searchQuery: string;
|
||||
onClose(): void;
|
||||
onSearchQueryChanged: (query: string) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
loading: boolean;
|
||||
filteredJoinedMembers: Array<RoomMember>;
|
||||
filteredInvitedMembers: Array<RoomMember | MatrixEvent>;
|
||||
canInvite: boolean;
|
||||
truncateAtJoined: number;
|
||||
truncateAtInvited: number;
|
||||
}
|
||||
|
||||
export default class MemberList extends React.Component<IProps, IState> {
|
||||
private readonly showPresence: boolean;
|
||||
private unmounted = false;
|
||||
|
||||
public static contextType = SDKContext;
|
||||
declare public context: React.ContextType<typeof SDKContext>;
|
||||
private tiles: Map<string, MemberTile> = new Map();
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
|
||||
super(props, context);
|
||||
this.state = this.getMembersState([], []);
|
||||
this.showPresence = context?.memberListStore.isPresenceEnabled() ?? true;
|
||||
}
|
||||
|
||||
private listenForMembersChanges(): void {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
cli.on(RoomStateEvent.Update, this.onRoomStateUpdate);
|
||||
cli.on(RoomMemberEvent.Name, this.onRoomMemberName);
|
||||
cli.on(RoomStateEvent.Events, this.onRoomStateEvent);
|
||||
// We listen for changes to the lastPresenceTs which is essentially
|
||||
// listening for all presence events (we display most of not all of
|
||||
// the information contained in presence events).
|
||||
cli.on(UserEvent.LastPresenceTs, this.onUserPresenceChange);
|
||||
cli.on(UserEvent.Presence, this.onUserPresenceChange);
|
||||
cli.on(UserEvent.CurrentlyActive, this.onUserPresenceChange);
|
||||
cli.on(ClientEvent.Room, this.onRoom); // invites & joining after peek
|
||||
cli.on(RoomEvent.MyMembership, this.onMyMembership);
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
this.listenForMembersChanges();
|
||||
this.updateListNow(true);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli) {
|
||||
cli.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate);
|
||||
cli.removeListener(RoomMemberEvent.Name, this.onRoomMemberName);
|
||||
cli.removeListener(RoomEvent.MyMembership, this.onMyMembership);
|
||||
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvent);
|
||||
cli.removeListener(ClientEvent.Room, this.onRoom);
|
||||
cli.removeListener(UserEvent.LastPresenceTs, this.onUserPresenceChange);
|
||||
cli.removeListener(UserEvent.Presence, this.onUserPresenceChange);
|
||||
cli.removeListener(UserEvent.CurrentlyActive, this.onUserPresenceChange);
|
||||
}
|
||||
|
||||
// cancel any pending calls to the rate_limited_funcs
|
||||
this.updateList.cancel();
|
||||
}
|
||||
|
||||
private get canInvite(): boolean {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const room = cli.getRoom(this.props.roomId);
|
||||
|
||||
return !!room && canInviteTo(room);
|
||||
}
|
||||
|
||||
private getMembersState(invitedMembers: Array<RoomMember>, joinedMembers: Array<RoomMember>): IState {
|
||||
return {
|
||||
loading: false,
|
||||
filteredJoinedMembers: joinedMembers,
|
||||
filteredInvitedMembers: invitedMembers,
|
||||
canInvite: this.canInvite,
|
||||
|
||||
// ideally we'd size this to the page height, but
|
||||
// in practice I find that a little constraining
|
||||
truncateAtJoined: INITIAL_LOAD_NUM_MEMBERS,
|
||||
truncateAtInvited: INITIAL_LOAD_NUM_INVITED,
|
||||
};
|
||||
}
|
||||
|
||||
private onUserPresenceChange = (event: MatrixEvent | undefined, user: User): void => {
|
||||
// Attach a SINGLE listener for global presence changes then locate the
|
||||
// member tile and re-render it. This is more efficient than every tile
|
||||
// ever attaching their own listener.
|
||||
const tile = this.tiles.get(user.userId);
|
||||
if (tile) {
|
||||
this.updateList(); // reorder the membership list
|
||||
}
|
||||
};
|
||||
|
||||
private onRoom = (room: Room): void => {
|
||||
if (room.roomId !== this.props.roomId) {
|
||||
return;
|
||||
}
|
||||
// We listen for room events because when we accept an invite
|
||||
// we need to wait till the room is fully populated with state
|
||||
// before refreshing the member list else we get a stale list.
|
||||
this.updateListNow(true);
|
||||
};
|
||||
|
||||
private onMyMembership = (room: Room, membership: string, oldMembership?: string): void => {
|
||||
if (
|
||||
room.roomId === this.props.roomId &&
|
||||
membership === KnownMembership.Join &&
|
||||
oldMembership !== KnownMembership.Join
|
||||
) {
|
||||
// we just joined the room, load the member list
|
||||
this.updateListNow(true);
|
||||
}
|
||||
};
|
||||
|
||||
private onRoomStateUpdate = (state: RoomState): void => {
|
||||
if (state.roomId !== this.props.roomId) return;
|
||||
this.updateList();
|
||||
};
|
||||
|
||||
private onRoomMemberName = (ev: MatrixEvent, member: RoomMember): void => {
|
||||
if (member.roomId !== this.props.roomId) {
|
||||
return;
|
||||
}
|
||||
this.updateList();
|
||||
};
|
||||
|
||||
private onRoomStateEvent = (event: MatrixEvent): void => {
|
||||
if (event.getRoomId() === this.props.roomId && event.getType() === EventType.RoomThirdPartyInvite) {
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite });
|
||||
};
|
||||
|
||||
private updateList = throttle(
|
||||
() => {
|
||||
this.updateListNow(false);
|
||||
},
|
||||
500,
|
||||
{ leading: true, trailing: true },
|
||||
);
|
||||
|
||||
// XXX: exported for tests
|
||||
public async updateListNow(showLoadingSpinner?: boolean): Promise<void> {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
if (showLoadingSpinner) {
|
||||
this.setState({ loading: true });
|
||||
}
|
||||
const { joined, invited } = await this.context.memberListStore.loadMemberList(
|
||||
this.props.roomId,
|
||||
this.props.searchQuery,
|
||||
);
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
loading: false,
|
||||
filteredJoinedMembers: joined,
|
||||
filteredInvitedMembers: invited,
|
||||
});
|
||||
}
|
||||
|
||||
private createOverflowTileJoined = (overflowCount: number, totalCount: number): JSX.Element => {
|
||||
return this.createOverflowTile(overflowCount, totalCount, this.showMoreJoinedMemberList);
|
||||
};
|
||||
|
||||
private createOverflowTileInvited = (overflowCount: number, totalCount: number): JSX.Element => {
|
||||
return this.createOverflowTile(overflowCount, totalCount, this.showMoreInvitedMemberList);
|
||||
};
|
||||
|
||||
private createOverflowTile = (overflowCount: number, totalCount: number, onClick: () => void): JSX.Element => {
|
||||
// For now we'll pretend this is any entity. It should probably be a separate tile.
|
||||
const text = _t("common|and_n_others", { count: overflowCount });
|
||||
return (
|
||||
<EntityTile
|
||||
className="mx_EntityTile_ellipsis"
|
||||
avatarJsx={<BaseAvatar url={OverflowHorizontalSvg} name="..." size="36px" />}
|
||||
name={text}
|
||||
showPresence={false}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private showMoreJoinedMemberList = (): void => {
|
||||
this.setState({
|
||||
truncateAtJoined: this.state.truncateAtJoined + SHOW_MORE_INCREMENT,
|
||||
});
|
||||
};
|
||||
|
||||
private showMoreInvitedMemberList = (): void => {
|
||||
this.setState({
|
||||
truncateAtInvited: this.state.truncateAtInvited + SHOW_MORE_INCREMENT,
|
||||
});
|
||||
};
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>, snapshot?: any): void {
|
||||
if (prevProps.searchQuery !== this.props.searchQuery) {
|
||||
this.updateListNow(false);
|
||||
}
|
||||
}
|
||||
|
||||
private onSearchQueryChanged = (searchQuery: string): void => {
|
||||
this.props.onSearchQueryChanged(searchQuery);
|
||||
};
|
||||
|
||||
private onPending3pidInviteClick = (inviteEvent: MatrixEvent): void => {
|
||||
dis.dispatch({
|
||||
action: Action.View3pidInvite,
|
||||
event: inviteEvent,
|
||||
});
|
||||
};
|
||||
|
||||
private getPending3PidInvites(): MatrixEvent[] {
|
||||
// include 3pid invites (m.room.third_party_invite) state events.
|
||||
// The HS may have already converted these into m.room.member invites so
|
||||
// we shouldn't add them if the 3pid invite state key (token) is in the
|
||||
// member invite (content.third_party_invite.signed.token)
|
||||
const room = MatrixClientPeg.safeGet().getRoom(this.props.roomId);
|
||||
|
||||
if (room) {
|
||||
return room.currentState.getStateEvents("m.room.third_party_invite").filter(function (e) {
|
||||
if (!isValid3pidInvite(e)) return false;
|
||||
|
||||
// discard all invites which have a m.room.member event since we've
|
||||
// already added them.
|
||||
const memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey()!);
|
||||
if (memberEvent) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private makeMemberTiles(members: Array<RoomMember | MatrixEvent>): JSX.Element[] {
|
||||
return members.map((m) => {
|
||||
if (m instanceof RoomMember) {
|
||||
// Is a Matrix invite
|
||||
return (
|
||||
<MemberTile
|
||||
key={m.userId}
|
||||
member={m}
|
||||
ref={(tile) => {
|
||||
if (tile) this.tiles.set(m.userId, tile);
|
||||
else this.tiles.delete(m.userId);
|
||||
}}
|
||||
showPresence={this.showPresence}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// Is a 3pid invite
|
||||
return (
|
||||
<EntityTile
|
||||
key={m.getStateKey()}
|
||||
name={m.getContent().display_name}
|
||||
showPresence={false}
|
||||
onClick={() => this.onPending3pidInviteClick(m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getChildrenJoined = (start: number, end: number): Array<JSX.Element> => {
|
||||
return this.makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end));
|
||||
};
|
||||
|
||||
private getChildCountJoined = (): number => this.state.filteredJoinedMembers.length;
|
||||
|
||||
private getChildrenInvited = (start: number, end: number): Array<JSX.Element> => {
|
||||
let targets = this.state.filteredInvitedMembers;
|
||||
if (end > this.state.filteredInvitedMembers.length) {
|
||||
targets = targets.concat(this.getPending3PidInvites());
|
||||
}
|
||||
|
||||
return this.makeMemberTiles(targets.slice(start, end));
|
||||
};
|
||||
|
||||
private getChildCountInvited = (): number => {
|
||||
return this.state.filteredInvitedMembers.length + (this.getPending3PidInvites() || []).length;
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
if (this.state.loading) {
|
||||
return (
|
||||
<BaseCard
|
||||
id="memberlist-panel"
|
||||
className="mx_MemberList"
|
||||
ariaLabelledBy="memberlist-panel-tab"
|
||||
role="tabpanel"
|
||||
header={_t("common|people")}
|
||||
onClose={this.props.onClose}
|
||||
>
|
||||
<Spinner />
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const room = cli.getRoom(this.props.roomId);
|
||||
let inviteButton: JSX.Element | undefined;
|
||||
|
||||
if (room?.getMyMembership() === KnownMembership.Join && shouldShowComponent(UIComponent.InviteUsers)) {
|
||||
const inviteButtonText = room.isSpaceRoom() ? _t("space|invite_this_space") : _t("room|invite_this_room");
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
size="sm"
|
||||
kind="secondary"
|
||||
className="mx_MemberList_invite"
|
||||
onClick={this.onInviteButtonClick}
|
||||
disabled={!this.state.canInvite}
|
||||
>
|
||||
<UserAddIcon width="1em" height="1em" />
|
||||
{inviteButtonText}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (this.state.canInvite) {
|
||||
inviteButton = button;
|
||||
} else {
|
||||
inviteButton = <Tooltip label={_t("member_list|invite_button_no_perms_tooltip")}>{button}</Tooltip>;
|
||||
}
|
||||
}
|
||||
|
||||
let invitedHeader;
|
||||
let invitedSection;
|
||||
if (this.getChildCountInvited() > 0) {
|
||||
invitedHeader = <h2>{_t("member_list|invited_list_heading")}</h2>;
|
||||
invitedSection = (
|
||||
<TruncatedList
|
||||
className="mx_MemberList_section mx_MemberList_invited"
|
||||
truncateAt={this.state.truncateAtInvited}
|
||||
createOverflowElement={this.createOverflowTileInvited}
|
||||
getChildren={this.getChildrenInvited}
|
||||
getChildCount={this.getChildCountInvited}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<SearchBox
|
||||
className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
|
||||
placeholder={_t("member_list|filter_placeholder")}
|
||||
onSearch={this.onSearchQueryChanged}
|
||||
initialValue={this.props.searchQuery}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
id="memberlist-panel"
|
||||
className="mx_MemberList"
|
||||
ariaLabelledBy="memberlist-panel-tab"
|
||||
role="tabpanel"
|
||||
header={_t("common|people")}
|
||||
footer={footer}
|
||||
onClose={this.props.onClose}
|
||||
>
|
||||
{inviteButton}
|
||||
<div className="mx_MemberList_wrapper">
|
||||
<TruncatedList
|
||||
className="mx_MemberList_section mx_MemberList_joined"
|
||||
truncateAt={this.state.truncateAtJoined}
|
||||
createOverflowElement={this.createOverflowTileJoined}
|
||||
getChildren={this.getChildrenJoined}
|
||||
getChildCount={this.getChildCountJoined}
|
||||
/>
|
||||
{invitedHeader}
|
||||
{invitedSection}
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
|
||||
private onInviteButtonClick = (ev: ButtonEvent): void => {
|
||||
PosthogTrackers.trackInteraction("WebRightPanelMemberListInviteButton", ev);
|
||||
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const room = cli.getRoom(this.props.roomId)!;
|
||||
|
||||
inviteToRoom(room);
|
||||
};
|
||||
}
|
||||
137
src/components/views/rooms/MemberList/MemberListHeaderView.tsx
Normal file
137
src/components/views/rooms/MemberList/MemberListHeaderView.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Search, Text, Button, Tooltip, InlineSpinner } from "@vector-im/compound-web";
|
||||
import React from "react";
|
||||
import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
|
||||
import { UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { Flex } from "../../../utils/Flex";
|
||||
import { MemberListViewState } from "../../../viewmodels/memberlist/MemberListViewModel";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
|
||||
interface TooltipProps {
|
||||
canInvite: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const OptionalTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
|
||||
if (canInvite) return children;
|
||||
// If the user isn't allowed to invite others to this room, wrap with a relevant tooltip.
|
||||
return <Tooltip description={_t("member_list|invite_button_no_perms_tooltip")}>{children}</Tooltip>;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
vm: MemberListViewState;
|
||||
}
|
||||
|
||||
const InviteButton: React.FC<Props> = ({ vm }) => {
|
||||
const shouldShowInvite = vm.shouldShowInvite;
|
||||
const shouldShowSearch = vm.shouldShowSearch;
|
||||
const disabled = !vm.canInvite;
|
||||
|
||||
if (!shouldShowInvite) {
|
||||
// In this case, invite button should not be rendered.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shouldShowSearch) {
|
||||
/// When rendered alongside a search box, the invite button is just an icon.
|
||||
return (
|
||||
<OptionalTooltip canInvite={vm.canInvite}>
|
||||
<Button
|
||||
className="mx_MemberListHeaderView_invite_small"
|
||||
kind="primary"
|
||||
onClick={vm.onInviteButtonClick}
|
||||
size="sm"
|
||||
iconOnly={true}
|
||||
Icon={InviteIcon}
|
||||
disabled={disabled}
|
||||
aria-label={_t("action|invite")}
|
||||
/>
|
||||
</OptionalTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Without a search box, invite button is a full size button.
|
||||
return (
|
||||
<OptionalTooltip canInvite={vm.canInvite}>
|
||||
<Button
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
Icon={UserAddIcon}
|
||||
className="mx_MemberListHeaderView_invite_large"
|
||||
disabled={!vm.canInvite}
|
||||
onClick={vm.onInviteButtonClick}
|
||||
>
|
||||
{_t("action|invite")}
|
||||
</Button>
|
||||
</OptionalTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This should be:
|
||||
* A loading text with spinner while the memberlist loads.
|
||||
* Member count of the room when there's nothing in the search field.
|
||||
* Number of matching members during search or 'No result' if search found nothing.
|
||||
*/
|
||||
function getHeaderLabelJSX(vm: MemberListViewState): React.ReactNode {
|
||||
if (vm.isLoading) {
|
||||
return (
|
||||
<Flex align="center" gap="8px">
|
||||
<InlineSpinner /> {_t("common|loading")}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredMemberCount = vm.members.length;
|
||||
if (filteredMemberCount === 0) {
|
||||
return _t("member_list|no_matches");
|
||||
}
|
||||
return _t("member_list|count", { count: filteredMemberCount });
|
||||
}
|
||||
|
||||
export const MemberListHeaderView: React.FC<Props> = (props: Props) => {
|
||||
const vm = props.vm;
|
||||
|
||||
let contentJSX: React.ReactNode;
|
||||
|
||||
if (vm.shouldShowSearch) {
|
||||
// When we need to show the search box
|
||||
contentJSX = (
|
||||
<Flex justify="center" className="mx_MemberListHeaderView_container">
|
||||
<Search
|
||||
className="mx_MemberListHeaderView_search mx_no_textinput"
|
||||
name="searchMembers"
|
||||
placeholder={_t("member_list|filter_placeholder")}
|
||||
onChange={(e) => vm.search((e as React.ChangeEvent<HTMLInputElement>).target.value)}
|
||||
/>
|
||||
<InviteButton vm={vm} />
|
||||
</Flex>
|
||||
);
|
||||
} else if (!vm.shouldShowSearch && vm.shouldShowInvite) {
|
||||
// When we don't need to show the search box but still need an invite button
|
||||
contentJSX = (
|
||||
<Flex justify="center" className="mx_MemberListHeaderView_container">
|
||||
<InviteButton vm={vm} />
|
||||
</Flex>
|
||||
);
|
||||
} else {
|
||||
// No search box and no invite icon, so nothing to render!
|
||||
contentJSX = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex className="mx_MemberListHeaderView" as="header" align="center" justify="space-between" direction="column">
|
||||
{!vm.isLoading && contentJSX}
|
||||
<Text as="div" size="sm" weight="semibold" className="mx_MemberListHeaderView_label">
|
||||
{getHeaderLabelJSX(vm)}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
82
src/components/views/rooms/MemberList/MemberListView.tsx
Normal file
82
src/components/views/rooms/MemberList/MemberListView.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Form } from "@vector-im/compound-web";
|
||||
import React from "react";
|
||||
import { List, ListRowProps } from "react-virtualized/dist/commonjs/List";
|
||||
import { AutoSizer } from "react-virtualized";
|
||||
|
||||
import { Flex } from "../../../utils/Flex";
|
||||
import { useMemberListViewModel } from "../../../viewmodels/memberlist/MemberListViewModel";
|
||||
import { RoomMemberTileView } from "./tiles/RoomMemberTileView";
|
||||
import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
|
||||
import { MemberListHeaderView } from "./MemberListHeaderView";
|
||||
import BaseCard from "../../right_panel/BaseCard";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MemberListView: React.FC<IProps> = (props: IProps) => {
|
||||
const vm = useMemberListViewModel(props.roomId);
|
||||
|
||||
const memberCount = vm.members.length;
|
||||
|
||||
const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => {
|
||||
if (index === memberCount) {
|
||||
// We've rendered all the members,
|
||||
// now we render an empty div to add some space to the end of the list.
|
||||
return <div key={key} style={style} />;
|
||||
}
|
||||
const item = vm.members[index];
|
||||
return (
|
||||
<div key={key} style={style}>
|
||||
{item.member ? (
|
||||
<RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />
|
||||
) : (
|
||||
<ThreePidInviteTileView threePidInvite={item.threePidInvite} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
id="memberlist-panel"
|
||||
className="mx_MemberListView"
|
||||
ariaLabelledBy="memberlist-panel-tab"
|
||||
role="tabpanel"
|
||||
header={_t("common|people")}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
<Flex align="stretch" direction="column" className="mx_MemberListView_container">
|
||||
<Form.Root>
|
||||
<MemberListHeaderView vm={vm} />
|
||||
</Form.Root>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
rowRenderer={rowRenderer}
|
||||
// All the member tiles will have a height of 56px.
|
||||
// The additional empty div at the end of the list should have a height of 32px.
|
||||
rowHeight={({ index }) => (index === memberCount ? 32 : 56)}
|
||||
// The +1 refers to the additional empty div that we render at the end of the list.
|
||||
rowCount={memberCount + 1}
|
||||
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
|
||||
height={height - 113}
|
||||
width={width}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Flex>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberListView;
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import DisambiguatedProfile from "../../../messages/DisambiguatedProfile";
|
||||
import { RoomMember } from "../../../../../models/rooms/RoomMember";
|
||||
import { useMemberTileViewModel } from "../../../../viewmodels/memberlist/tiles/MemberTileViewModel";
|
||||
import { E2EIconView } from "./common/E2EIconView";
|
||||
import AvatarPresenceIconView from "./common/PresenceIconView";
|
||||
import BaseAvatar from "../../../avatars/BaseAvatar";
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import { MemberTileLayout } from "./common/MemberTileLayout";
|
||||
|
||||
interface IProps {
|
||||
member: RoomMember;
|
||||
showPresence?: boolean;
|
||||
}
|
||||
|
||||
export function RoomMemberTileView(props: IProps): JSX.Element {
|
||||
const vm = useMemberTileViewModel(props);
|
||||
const member = vm.member;
|
||||
const av = (
|
||||
<BaseAvatar
|
||||
size="32px"
|
||||
name={member.name}
|
||||
idName={member.userId}
|
||||
title={member.displayUserId}
|
||||
url={member.avatarThumbnailUrl}
|
||||
altText={_t("common|user_avatar")}
|
||||
/>
|
||||
);
|
||||
const name = vm.name;
|
||||
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
|
||||
|
||||
const presenceState = member.presenceState;
|
||||
let presenceJSX: JSX.Element | undefined;
|
||||
if (vm.showPresence && presenceState) {
|
||||
presenceJSX = <AvatarPresenceIconView presenceState={presenceState} />;
|
||||
}
|
||||
|
||||
let userLabelJSX;
|
||||
if (vm.userLabel) {
|
||||
userLabelJSX = <div className="mx_MemberTileView_user_label">{vm.userLabel}</div>;
|
||||
}
|
||||
|
||||
let e2eIcon;
|
||||
if (vm.e2eStatus) {
|
||||
e2eIcon = <E2EIconView status={vm.e2eStatus} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MemberTileLayout
|
||||
title={vm.title}
|
||||
onClick={vm.onClick}
|
||||
avatarJsx={av}
|
||||
presenceJsx={presenceJSX}
|
||||
nameJsx={nameJSX}
|
||||
userLabelJsx={userLabelJSX}
|
||||
e2eIconJsx={e2eIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useThreePidTileViewModel } from "../../../../viewmodels/memberlist/tiles/ThreePidTileViewModel";
|
||||
import { ThreePIDInvite } from "../../../../../models/rooms/ThreePIDInvite";
|
||||
import BaseAvatar from "../../../avatars/BaseAvatar";
|
||||
import { MemberTileLayout } from "./common/MemberTileLayout";
|
||||
|
||||
interface Props {
|
||||
threePidInvite: ThreePIDInvite;
|
||||
}
|
||||
|
||||
export function ThreePidInviteTileView(props: Props): JSX.Element {
|
||||
const vm = useThreePidTileViewModel(props);
|
||||
const av = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
|
||||
return <MemberTileLayout nameJsx={vm.name} avatarJsx={av} onClick={vm.onClick} />;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
|
||||
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
|
||||
|
||||
import { _t } from "../../../../../../languageHandler";
|
||||
import { E2EStatus } from "../../../../../../utils/ShieldUtils";
|
||||
import { E2EState, crossSigningUserTitles } from "../../../E2EIcon";
|
||||
|
||||
function getIconFromStatus(status: E2EState | E2EStatus): React.JSX.Element | undefined {
|
||||
switch (status) {
|
||||
case E2EState.Normal:
|
||||
case E2EStatus.Normal:
|
||||
return undefined;
|
||||
case E2EState.Verified:
|
||||
case E2EStatus.Verified:
|
||||
return <VerifiedIcon height="16px" width="16px" className="mx_E2EIconView_verified" />;
|
||||
case E2EState.Warning:
|
||||
case E2EStatus.Warning:
|
||||
return <ErrorIcon height="16px" width="16px" className="mx_E2EIconView_warning" />;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
status: E2EState | E2EStatus;
|
||||
}
|
||||
|
||||
export const E2EIconView: React.FC<Props> = ({ status }) => {
|
||||
const e2eTitle = crossSigningUserTitles[status];
|
||||
const label = e2eTitle ? _t(e2eTitle) : "";
|
||||
|
||||
const icon = getIconFromStatus(status);
|
||||
if (!icon) return null;
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<div className="mx_E2EIconView">{icon}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import AccessibleButton from "../../../../elements/AccessibleButton";
|
||||
|
||||
interface Props {
|
||||
avatarJsx: JSX.Element;
|
||||
nameJsx: JSX.Element | string;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
presenceJsx?: JSX.Element;
|
||||
userLabelJsx?: JSX.Element;
|
||||
e2eIconJsx?: JSX.Element;
|
||||
}
|
||||
|
||||
export function MemberTileLayout(props: Props): JSX.Element {
|
||||
return (
|
||||
// The wrapping div is required to make the magic mouse listener work, for some reason.
|
||||
<div>
|
||||
<AccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
|
||||
<div className="mx_MemberTileView_left">
|
||||
<div className="mx_MemberTileView_avatar">
|
||||
{props.avatarJsx} {props.presenceJsx}
|
||||
</div>
|
||||
<div className="mx_MemberTileView_name">{props.nameJsx}</div>
|
||||
</div>
|
||||
<div className="mx_MemberTileView_right">
|
||||
{props.userLabelJsx}
|
||||
{props.e2eIconJsx}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-solid-8x8";
|
||||
import OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8";
|
||||
import DNDIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8";
|
||||
import classNames from "classnames";
|
||||
import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
presenceState: string;
|
||||
}
|
||||
|
||||
export const BUSY_PRESENCE_NAME = new UnstableValue("busy", "org.matrix.msc3026.busy");
|
||||
|
||||
function getIconForPresenceState(state: string): React.JSX.Element {
|
||||
switch (state) {
|
||||
case "online":
|
||||
return <OnlineOrUnavailableIcon height="8px" width="8px" className="mx_PresenceIconView_online" />;
|
||||
case "offline":
|
||||
return <OfflineIcon height="8px" width="8px" className="mx_PresenceIconView_offline" />;
|
||||
case "unavailable":
|
||||
case "io.element.unreachable":
|
||||
return <OnlineOrUnavailableIcon height="8px" width="8px" className="mx_PresenceIconView_unavailable" />;
|
||||
case BUSY_PRESENCE_NAME.name:
|
||||
case BUSY_PRESENCE_NAME.altName:
|
||||
return <DNDIcon height="8px" width="8px" className="mx_PresenceIconView_dnd" />;
|
||||
default:
|
||||
throw new Error(`Presence state "${state}" is unknown.`);
|
||||
}
|
||||
}
|
||||
|
||||
const AvatarPresenceIconView: React.FC<Props> = ({ className, presenceState }) => {
|
||||
const names = classNames("mx_PresenceIconView", className);
|
||||
return <div className={names}>{getIconForPresenceState(presenceState)}</div>;
|
||||
};
|
||||
|
||||
export default AvatarPresenceIconView;
|
||||
@@ -1,220 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { RoomMember, RoomStateEvent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
|
||||
import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import EntityTile, { PowerStatus, PresenceState } from "./EntityTile";
|
||||
import MemberAvatar from "./../avatars/MemberAvatar";
|
||||
import DisambiguatedProfile from "../messages/DisambiguatedProfile";
|
||||
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
|
||||
import { E2EState } from "./E2EIcon";
|
||||
import { asyncSome } from "../../../utils/arrays";
|
||||
import { getUserDeviceIds } from "../../../utils/crypto/deviceInfo";
|
||||
|
||||
interface IProps {
|
||||
member: RoomMember;
|
||||
showPresence?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
isRoomEncrypted: boolean;
|
||||
e2eStatus?: E2EState;
|
||||
}
|
||||
|
||||
export default class MemberTile extends React.Component<IProps, IState> {
|
||||
private userLastModifiedTime?: number;
|
||||
private memberLastModifiedTime?: number;
|
||||
|
||||
public static defaultProps = {
|
||||
showPresence: true,
|
||||
};
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isRoomEncrypted: false,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
|
||||
const { roomId } = this.props.member;
|
||||
if (roomId) {
|
||||
const isRoomEncrypted = cli.isRoomEncrypted(roomId);
|
||||
this.setState({
|
||||
isRoomEncrypted,
|
||||
});
|
||||
if (isRoomEncrypted) {
|
||||
cli.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||
this.updateE2EStatus();
|
||||
} else {
|
||||
// Listen for room to become encrypted
|
||||
cli.on(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
||||
if (cli) {
|
||||
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private onRoomStateEvents = (ev: MatrixEvent): void => {
|
||||
if (ev.getType() !== EventType.RoomEncryption) return;
|
||||
const { roomId } = this.props.member;
|
||||
if (ev.getRoomId() !== roomId) return;
|
||||
|
||||
// The room is encrypted now.
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
this.setState({
|
||||
isRoomEncrypted: true,
|
||||
});
|
||||
this.updateE2EStatus();
|
||||
};
|
||||
|
||||
private onUserTrustStatusChanged = (userId: string, trustStatus: UserVerificationStatus): void => {
|
||||
if (userId !== this.props.member.userId) return;
|
||||
this.updateE2EStatus();
|
||||
};
|
||||
|
||||
private async updateE2EStatus(): Promise<void> {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const { userId } = this.props.member;
|
||||
const isMe = userId === cli.getUserId();
|
||||
const userTrust = await cli.getCrypto()?.getUserVerificationStatus(userId);
|
||||
if (!userTrust?.isCrossSigningVerified()) {
|
||||
this.setState({
|
||||
e2eStatus: userTrust?.wasCrossSigningVerified() ? E2EState.Warning : E2EState.Normal,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceIDs = await getUserDeviceIds(cli, userId);
|
||||
const anyDeviceUnverified = await asyncSome(deviceIDs, async (deviceId) => {
|
||||
// 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 cli.getCrypto()?.getDeviceVerificationStatus(userId, deviceId);
|
||||
return !deviceTrust || (isMe ? !deviceTrust.crossSigningVerified : !deviceTrust.isVerified());
|
||||
});
|
||||
this.setState({
|
||||
e2eStatus: anyDeviceUnverified ? E2EState.Warning : E2EState.Verified,
|
||||
});
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean {
|
||||
if (
|
||||
this.memberLastModifiedTime === undefined ||
|
||||
this.memberLastModifiedTime < nextProps.member.getLastModifiedTime()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
nextProps.member.user &&
|
||||
(this.userLastModifiedTime === undefined ||
|
||||
this.userLastModifiedTime < nextProps.member.user.getLastModifiedTime())
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (nextState.isRoomEncrypted !== this.state.isRoomEncrypted || nextState.e2eStatus !== this.state.e2eStatus) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private onClick = (): void => {
|
||||
dis.dispatch({
|
||||
action: Action.ViewUser,
|
||||
member: this.props.member,
|
||||
push: true,
|
||||
});
|
||||
};
|
||||
|
||||
private getDisplayName(): string {
|
||||
return this.props.member.name;
|
||||
}
|
||||
|
||||
private getPowerLabel(): string {
|
||||
return _t("member_list|power_label", {
|
||||
userName: UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, {
|
||||
roomId: this.props.member.roomId,
|
||||
}),
|
||||
powerLevelNumber: this.props.member.powerLevel,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const member = this.props.member;
|
||||
const name = this.getDisplayName();
|
||||
const presenceState = member.user?.presence as PresenceState | undefined;
|
||||
|
||||
const av = <MemberAvatar member={member} size="36px" aria-hidden="true" />;
|
||||
|
||||
if (member.user) {
|
||||
this.userLastModifiedTime = member.user.getLastModifiedTime();
|
||||
}
|
||||
this.memberLastModifiedTime = member.getLastModifiedTime();
|
||||
|
||||
const powerStatusMap = new Map([
|
||||
[100, PowerStatus.Admin],
|
||||
[50, PowerStatus.Moderator],
|
||||
]);
|
||||
|
||||
// Find the nearest power level with a badge
|
||||
let powerLevel = this.props.member.powerLevel;
|
||||
for (const [pl] of powerStatusMap) {
|
||||
if (this.props.member.powerLevel >= pl) {
|
||||
powerLevel = pl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const powerStatus = powerStatusMap.get(powerLevel);
|
||||
|
||||
let e2eStatus: E2EState | undefined;
|
||||
if (this.state.isRoomEncrypted) {
|
||||
e2eStatus = this.state.e2eStatus;
|
||||
}
|
||||
|
||||
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
|
||||
|
||||
return (
|
||||
<EntityTile
|
||||
{...this.props}
|
||||
presenceState={presenceState}
|
||||
presenceLastActiveAgo={member.user ? member.user.lastActiveAgo : 0}
|
||||
presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
|
||||
presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
|
||||
avatarJsx={av}
|
||||
title={this.getPowerLabel()}
|
||||
name={name}
|
||||
nameJSX={nameJSX}
|
||||
powerStatus={powerStatus}
|
||||
showPresence={this.props.showPresence}
|
||||
e2eStatus={e2eStatus}
|
||||
onClick={this.onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
32
src/components/views/rooms/OverflowTileView.tsx
Normal file
32
src/components/views/rooms/OverflowTileView.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import Icon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
|
||||
interface Props {
|
||||
// The number of remaining items
|
||||
remaining: number;
|
||||
onClick(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Only used in ForwardDialog component; newer designs have moved away from this.
|
||||
*/
|
||||
export const OverflowTileView: React.FC<Props> = ({ remaining, onClick }) => {
|
||||
return (
|
||||
<AccessibleButton onClick={onClick} className="mx_OverflowTileView">
|
||||
<div className="mx_OverflowTileView_icon">
|
||||
<Icon height="36px" width="36px" />
|
||||
</div>
|
||||
<div className="mx_OverflowTileView_text">{_t("common|and_n_others", { count: remaining })}</div>
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
@@ -1579,9 +1579,14 @@
|
||||
"toggle_attribution": "Toggle attribution"
|
||||
},
|
||||
"member_list": {
|
||||
"count": {
|
||||
"one": "%(count)s Member",
|
||||
"other": "%(count)s Members"
|
||||
},
|
||||
"filter_placeholder": "Filter room members",
|
||||
"invite_button_no_perms_tooltip": "You do not have permission to invite users",
|
||||
"invited_list_heading": "Invited",
|
||||
"invited_label": "Invited",
|
||||
"no_matches": "No matches",
|
||||
"power_label": "%(userName)s (power %(powerLevelNumber)s)"
|
||||
},
|
||||
"member_list_back_action_label": "Room members",
|
||||
@@ -1734,7 +1739,6 @@
|
||||
"custom_level": "Custom level",
|
||||
"default": "Default",
|
||||
"label": "Power level",
|
||||
"mod": "Mod",
|
||||
"moderator": "Moderator",
|
||||
"restricted": "Restricted"
|
||||
},
|
||||
@@ -3066,7 +3070,6 @@
|
||||
"invite": "Invite people",
|
||||
"invite_description": "Invite with email or username",
|
||||
"invite_link": "Share invite link",
|
||||
"invite_this_space": "Invite to this space",
|
||||
"joining_space": "Joining",
|
||||
"landing_welcome": "Welcome to <name/>",
|
||||
"leave_dialog_action": "Leave space",
|
||||
|
||||
8
src/models/rooms/PresenceState.ts
Normal file
8
src/models/rooms/PresenceState.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export type PresenceState = "offline" | "online" | "unavailable" | "io.element.unreachable";
|
||||
22
src/models/rooms/RoomMember.ts
Normal file
22
src/models/rooms/RoomMember.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { PresenceState } from "./PresenceState";
|
||||
|
||||
export type RoomMember = {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
displayUserId: string;
|
||||
name: string;
|
||||
rawDisplayName?: string;
|
||||
disambiguate: boolean;
|
||||
avatarThumbnailUrl?: string;
|
||||
powerLevel: number;
|
||||
lastModifiedTime: number;
|
||||
presenceState?: PresenceState;
|
||||
isInvite: boolean;
|
||||
};
|
||||
12
src/models/rooms/ThreePIDInvite.ts
Normal file
12
src/models/rooms/ThreePIDInvite.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
export type ThreePIDInvite = {
|
||||
event: MatrixEvent;
|
||||
};
|
||||
Reference in New Issue
Block a user