fix: permissions and room icon resolution (#2)

* Initialize call state upon room creation for call rooms, remove subsequent useless permission

* handle case of missing call permissions

* use call icon for room item summary when room is call room

* replace previous icon src resolution function with a more robust approach

* replace usages of previous icon resolution function with new implementation

* fix room name not updating for a while when changed

* set up framework for room power level overrides upon room creation

* override join call permission to all members upon room creation

* fix broken usages of RoomIcon

* remove unneeded import

* remove unnecessary logic

* format with prettier
This commit is contained in:
James 2026-02-12 19:03:46 -05:00 committed by GitHub
parent 9554b31c7d
commit efb3e115db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 190 additions and 100 deletions

View file

@ -11,6 +11,7 @@ import { CreateRoomKind } from './CreateRoomKindSelector';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { getMxIdServer } from '../../utils/matrix';
import { IPowerLevels } from '../../hooks/usePowerLevels';
export const createRoomCreationContent = (
type: RoomType | undefined,
@ -82,6 +83,44 @@ export const createRoomEncryptionState = () => ({
},
});
export const createRoomCallState = () => ({
type: 'org.matrix.msc3401.call',
state_key: '',
content: {},
});
export const createPowerLevelContentOverrides = (
base: IPowerLevels,
overrides: Partial<IPowerLevels>
): IPowerLevels => ({
...base,
...overrides,
...(base.events || overrides.events
? {
events: {
...base.events,
...overrides.events,
},
}
: {}),
...(base.users || overrides.users
? {
users: {
...base.users,
...overrides.users,
},
}
: {}),
...(base.notifications || overrides.notifications
? {
notifications: {
...base.notifications,
...overrides.notifications,
},
}
: {}),
});
export type CreateRoomData = {
version: string;
type?: RoomType;
@ -94,6 +133,7 @@ export type CreateRoomData = {
knock: boolean;
allowFederation: boolean;
additionalCreators?: string[];
powerLevelContentOverrides?: IPowerLevels;
};
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
const initialState: ICreateRoomStateEvent[] = [];
@ -106,6 +146,10 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
initialState.push(createRoomParentState(data.parent));
}
if (data.type === RoomType.Call) {
initialState.push(createRoomCallState());
}
initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
const options: ICreateRoomOpts = {
@ -136,5 +180,15 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
);
}
if (data.powerLevelContentOverrides) {
const roomPowers = await mx.getStateEvent(result.room_id, StateEvent.RoomPowerLevels, '');
const updatedPowers = createPowerLevelContentOverrides(
roomPowers,
data.powerLevelContentOverrides
);
await mx.sendStateEvent(result.room_id, StateEvent.RoomPowerLevels as any, updatedPowers, '');
}
return result.room_id;
};

View file

@ -174,7 +174,7 @@ export function RoomMentionAutocomplete({
)}
/>
) : (
<RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
<RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
</Avatar>
}

View file

@ -2,7 +2,7 @@ import { JoinRule } from 'matrix-js-sdk';
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
import * as css from './RoomAvatar.css';
import { joinRuleToIconSrc } from '../../utils/room';
import { getRoomIconSrc } from '../../utils/room';
import colorMXID from '../../../util/colorMXID';
type RoomAvatarProps = {
@ -44,14 +44,10 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
export const RoomIcon = forwardRef<
SVGSVGElement,
Omit<ComponentProps<typeof Icon>, 'src'> & {
joinRule: JoinRule;
space?: boolean;
call?: boolean;
joinRule?: JoinRule;
roomType?: string;
locked?: boolean;
}
>(({ joinRule, space, call, ...props }, ref) => (
<Icon
src={joinRuleToIconSrc(Icons, joinRule, space || false, call || false) ?? Icons.Hash}
{...props}
ref={ref}
/>
>(({ joinRule, roomType, locked, ...props }, ref) => (
<Icon src={getRoomIconSrc(Icons, roomType, joinRule, locked)} {...props} ref={ref} />
));

View file

@ -1,4 +1,4 @@
import { Room } from 'matrix-js-sdk';
import { EventType, Room } from 'matrix-js-sdk';
import React, {
useContext,
useCallback,
@ -21,6 +21,10 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './CallView.css';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useRoomName } from '../../hooks/useRoomMeta';
type OriginalStyles = {
position?: string;
@ -57,6 +61,13 @@ export function CallView({ room }: { room: Room }) {
const [visibleCallNames, setVisibleCallNames] = useState('');
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const roomName = useRoomName(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canJoin = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId());
const {
isActiveCallReady,
activeCallRoomId,
@ -160,6 +171,8 @@ export function CallView({ room }: { room: Room }) {
]);
const handleJoinVCClick: MouseEventHandler<HTMLElement> = (evt) => {
if (!canJoin) return;
if (isMobile) {
evt.stopPropagation();
setViewedCallRoomId(room.roomId);
@ -210,11 +223,7 @@ export function CallView({ room }: { room: Room }) {
>
<CallViewUserGrid>
{callMembers.slice(0, 6).map((callMember) => (
<CallViewUser
key={callMember.membershipID}
room={room}
callMembership={callMember}
/>
<CallViewUser key={callMember.membershipID} room={room} callMembership={callMember} />
))}
</CallViewUserGrid>
@ -231,21 +240,25 @@ export function CallView({ room }: { room: Room }) {
paddingBottom: config.space.S300,
}}
>
{room.name}
{roomName}
</Text>
<Text size="T200">
{visibleCallNames !== '' ? visibleCallNames : 'No one'}{' '}
{memberDisplayNames.length > 1 ? 'are' : 'is'} currently in voice
</Text>
</Box>
<Button variant="Secondary" disabled={isActiveCallRoom} onClick={handleJoinVCClick}>
<Button
variant="Secondary"
disabled={!canJoin || isActiveCallRoom}
onClick={handleJoinVCClick}
>
{isActiveCallRoom ? (
<Box justifyContent="Center" alignItems="Center" gap="200">
<Spinner />
<Text size="B500">{activeCallRoomId === room.roomId ? `Joining` : 'Join Voice'}</Text>
</Box>
) : (
<Text size="B500">Join Voice</Text>
<Text size="B500">{canJoin ? 'Join Voice' : 'Channel Locked'}</Text>
)}
</Button>
</Box>

View file

@ -199,7 +199,7 @@ export function RoomProfileEdit({
alt={name}
renderFallback={() => (
<RoomIcon
space={room.isSpaceRoom()}
roomType={room.getType()}
size="400"
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
filled
@ -342,7 +342,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
alt={name}
renderFallback={() => (
<RoomIcon
space={room.isSpaceRoom()}
roomType={room.getType()}
size="400"
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
filled

View file

@ -38,7 +38,8 @@ import {
RoomVersionSelector,
useAdditionalCreators,
} from '../../components/create-room';
import { RoomType } from '../../../types/matrix/room';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { IPowerLevels } from '../../hooks/usePowerLevels';
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
if (kind === CreateRoomKind.Private) return Icons.HashLock;
@ -118,9 +119,18 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
roomKnock = knock;
}
let roomType;
const powerOverrides: IPowerLevels = {
events: {},
};
if (callRoom) {
roomType = RoomType.Call;
powerOverrides.events![StateEvent.GroupCallMemberPrefix] = 0;
}
create({
version: selectedRoomVersion,
type: callRoom ? RoomType.Call : undefined,
type: roomType,
parent: space,
kind,
name: roomName,
@ -130,6 +140,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
knock: roomKnock,
allowFederation: federation,
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
powerLevelContentOverrides: powerOverrides,
}).then((roomId) => {
if (alive()) {
onCreate?.(roomId);
@ -183,12 +194,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
title="Call Room"
description="Enable this to create a room optimized for voice calls."
after={
<Switch
variant="Primary"
value={callRoom}
onChange={setCallRoom}
disabled={disabled}
/>
<Switch variant="Primary" value={callRoom} onChange={setCallRoom} disabled={disabled} />
}
/>
</SequenceCard>

View file

@ -30,7 +30,7 @@ import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader';
import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard';
import { Membership } from '../../../types/matrix/room';
import { Membership, RoomType } from '../../../types/matrix/room';
import * as css from './RoomItem.css';
import * as styleCss from './style.css';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@ -175,6 +175,7 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
type RoomProfileProps = {
roomId: string;
roomType?: string;
name: string;
topic?: string;
avatarUrl?: string;
@ -185,6 +186,7 @@ type RoomProfileProps = {
};
function RoomProfile({
roomId,
roomType,
name,
topic,
avatarUrl,
@ -200,9 +202,7 @@ function RoomProfile({
roomId={roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon size="300" joinRule={joinRule ?? JoinRule.Restricted} filled />
)}
renderFallback={() => <RoomIcon size="300" joinRule={joinRule} roomType={roomType} />}
/>
</Avatar>
<Box grow="Yes" direction="Column">
@ -338,6 +338,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
{(localSummary) => (
<RoomProfile
roomId={roomId}
roomType={localSummary.roomType}
name={localSummary.name}
topic={localSummary.topic}
avatarUrl={
@ -396,6 +397,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
{summary && (
<RoomProfile
roomId={roomId}
roomType={summary.room_type}
name={summary.name || summary.canonical_alias || roomId}
topic={summary.topic}
avatarUrl={

View file

@ -29,7 +29,7 @@ import { SearchOrderBy } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { joinRuleToIconSrc } from '../../utils/room';
import { getRoomIconSrc } from '../../utils/room';
import { factoryRoomIdByAtoZ } from '../../utils/sort';
import {
SearchItemStrGetter,
@ -274,9 +274,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
before={
<Icon
size="50"
src={
joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash
}
src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())}
/>
}
>
@ -392,10 +390,7 @@ export function SearchFilters({
onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
radii="Pill"
before={
<Icon
size="50"
src={joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash}
/>
<Icon size="50" src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())} />
}
after={<Icon size="50" src={Icons.Cross} />}
>

View file

@ -1,5 +1,5 @@
import React, { MouseEventHandler, forwardRef, useState, MouseEvent } from 'react';
import { Room } from 'matrix-js-sdk';
import { EventType, Room } from 'matrix-js-sdk';
import {
Avatar,
Box,
@ -21,7 +21,7 @@ import {
} from 'folds';
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { NavButton, NavItem, NavItemContent, NavItemOptions } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
@ -59,6 +59,7 @@ import { useCallMembers } from '../../hooks/useCallMemberships';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { RoomNavUser } from './RoomNavUser';
import { useRoomName } from '../../hooks/useRoomMeta';
type RoomNavItemMenuProps = {
room: Room;
@ -241,6 +242,10 @@ export function RoomNavItem({
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId()
);
const {
isActiveCallReady,
activeCallRoomId,
@ -250,14 +255,20 @@ export function RoomNavItem({
toggleChat,
hangUp,
} = useCallState();
const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId()
);
const isActiveCall = isActiveCallReady && activeCallRoomId === room.roomId;
const callMemberships = useCallMembers(mx, room.roomId);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const roomName = useRoomName(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canJoinCall = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId());
const { navigateRoom } = useRoomNavigate();
const navigate = useNavigate();
const { roomIdOrAlias: viewedRoomId } = useParams();
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
@ -278,10 +289,7 @@ export function RoomNavItem({
const handleNavItemClick: MouseEventHandler<HTMLElement> = (evt) => {
if (room.isCallRoom()) {
if (!isMobile) {
if (!isActiveCall) {
if (mx.getRoom(viewedRoomId)?.isCallRoom()) {
navigateRoom(room.roomId);
}
if (!isActiveCall && canJoinCall) {
hangUp();
setActiveCallRoomId(room.roomId);
} else {
@ -307,7 +315,7 @@ export function RoomNavItem({
const optionsVisible = hover || !!menuAnchor;
const ariaLabel = [
room.name,
roomName,
room.isCallRoom()
? [
'Call Room',
@ -345,10 +353,10 @@ export function RoomNavItem({
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
}
alt={room.name}
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
{nameInitials(roomName)}
</Text>
)}
/>
@ -360,7 +368,8 @@ export function RoomNavItem({
filled={selected || isActiveCall}
size="100"
joinRule={room.getJoinRule()}
call={room.isCallRoom()}
roomType={room.getType()}
locked={room.isCallRoom() && !canJoinCall}
/>
)}
</Avatar>
@ -371,7 +380,7 @@ export function RoomNavItem({
size="Inherit"
truncate
>
{room.name}
{roomName}
</Text>
</Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (

View file

@ -49,13 +49,6 @@ export const usePermissionGroups = (): PermissionGroup[] => {
const callSettingsGroup: PermissionGroup = {
name: 'Calls',
items: [
{
location: {
state: true,
key: StateEvent.GroupCallPrefix,
},
name: 'Start Call',
},
{
location: {
state: true,

View file

@ -23,7 +23,7 @@ import {
Spinner,
} from 'folds';
import { useNavigate } from 'react-router-dom';
import { JoinRule, Room } from 'matrix-js-sdk';
import { Room } from 'matrix-js-sdk';
import { useStateEvent } from '../../hooks/useStateEvent';
import { PageHeader } from '../../components/page';
@ -321,11 +321,7 @@ export function RoomViewHeader() {
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon
size="200"
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
filled
/>
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
/>
</Avatar>

View file

@ -373,7 +373,7 @@ export function Search({ requestClose }: SearchProps) {
<RoomIcon
size="100"
joinRule={room.getJoinRule()}
space={room.isSpaceRoom()}
roomType={room.getType()}
/>
)}
</Avatar>

View file

@ -103,7 +103,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
alt={roomName}
renderFallback={() => (
<RoomIcon
space
roomType={room.getType()}
size="50"
joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
filled

View file

@ -50,16 +50,8 @@ export function PersistentCallContainer({ children }: PersistentCallContainerPro
themeKind: ThemeKind | null
) => {
if (mx?.getUserId()) {
if (
(activeCallRoomId !== viewedCallRoomId && isActiveCallReady) ||
(activeCallRoomId && !isActiveCallReady) ||
(!activeCallRoomId && viewedCallRoomId && !isActiveCallReady)
) {
const roomIdToSet = (skipLobby ? activeCallRoomId : viewedCallRoomId) ?? '';
if (roomIdToSet === '') {
return;
}
if (activeCallRoomId && !isActiveCallReady) {
const roomIdToSet = activeCallRoomId;
const widgetId = `element-call-${roomIdToSet}-${Date.now()}`;
const newUrl = getWidgetUrl(
@ -125,7 +117,6 @@ export function PersistentCallContainer({ children }: PersistentCallContainerPro
[
mx,
activeCallRoomId,
viewedCallRoomId,
isActiveCallReady,
clientConfig.elementCallUrl,
activeClientWidget,

View file

@ -257,25 +257,60 @@ export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => {
return unreadInfos;
};
export const joinRuleToIconSrc = (
export const getRoomIconSrc = (
icons: Record<IconName, IconSrc>,
joinRule: JoinRule,
space: boolean,
call: boolean
): IconSrc | undefined => {
if (joinRule === JoinRule.Restricted) {
return space ? icons.Space : call ? icons.VolumeHigh : icons.Hash;
roomType?: string,
joinRule?: JoinRule,
locked?: boolean
): IconSrc => {
type RoomIcons = {
base: IconSrc;
locked: IconSrc;
public: IconSrc;
};
const roomTypeIcons: Record<string, RoomIcons> = {
[RoomType.Call]: {
base: icons.VolumeHigh,
locked: icons.Lock,
public: icons.VolumeHigh,
},
[RoomType.Space]: {
base: icons.Space,
locked: icons.SpaceLock,
public: icons.SpaceGlobe,
},
default: {
base: icons.Hash,
locked: icons.HashLock,
public: icons.HashGlobe,
},
};
const roomIcons = roomTypeIcons[roomType ?? 'default'] ?? roomTypeIcons.default;
let roomIcon = roomIcons.base;
if (locked) {
roomIcon = roomIcons.locked;
} else {
switch (joinRule) {
case JoinRule.Invite:
case JoinRule.Knock:
roomIcon = roomIcons.locked;
break;
case JoinRule.Restricted:
roomIcon = roomIcons.base;
break;
case JoinRule.Public:
roomIcon = roomIcons.public;
break;
default:
break;
}
if (joinRule === JoinRule.Knock) {
return space ? icons.SpaceLock : call ? icons.VolumeHigh : icons.HashLock;
}
if (joinRule === JoinRule.Invite) {
return space ? icons.SpaceLock : call ? icons.VolumeHigh : icons.HashLock;
}
if (joinRule === JoinRule.Public) {
return space ? icons.SpaceGlobe : call ? icons.VolumeHigh : icons.HashGlobe;
}
return undefined;
return roomIcon;
};
export const getRoomAvatarUrl = (