Support room version 12 (#2399)
Some checks are pending
Deploy to Netlify (dev) / Deploy to Netlify (push) Waiting to run

* WIP - support room version 12

* add room creators hook

* revert changes from powerlevels

* improve use room creators hook

* add hook to get dm users

* add options to add creators in create room/space

* add member item component in member drawer

* remove unused import

* extract member drawer header component

* get room creators as set only if room version support them

* add room permissions hook

* support room v12 creators power

* make predecessor event id optional

* add info about founders in permissions

* allow to create infinite powers to room creators

* allow everyone with permission to create infinite power

* handle additional creators in room upgrade

* add option to follow space tombstone
This commit is contained in:
Ajay Bura 2025-08-12 19:42:30 +05:30 committed by GitHub
parent 4d1ae4eafd
commit f82cfead46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1717 additions and 783 deletions

View file

@ -0,0 +1,27 @@
import { useMemo } from 'react';
import { AccountDataEvent, MDirectContent } from '../../types/matrix/accountData';
import { useAccountData } from './useAccountData';
import { useAllJoinedRoomsSet, useGetRoom } from './useGetRoom';
export const useDirectUsers = (): string[] => {
const directEvent = useAccountData(AccountDataEvent.Direct);
const content = directEvent?.getContent<MDirectContent>();
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const users = useMemo(() => {
if (typeof content !== 'object') return [];
const u = Object.keys(content).filter((userId) => {
const rooms = content[userId];
if (!Array.isArray(rooms)) return false;
const hasDM = rooms.some((roomId) => typeof roomId === 'string' && !!getRoom(roomId));
return hasDM;
});
return u;
}, [content, getRoom]);
return users;
};

View file

@ -0,0 +1,28 @@
import { useCallback } from 'react';
import { IPowerLevels, readPowerLevel } from './usePowerLevels';
export const useMemberPowerCompare = (creators: Set<string>, powerLevels: IPowerLevels) => {
/**
* returns `true` if `userIdA` has more power than `userIdB`
* returns `false` otherwise
*/
const hasMorePower = useCallback(
(userIdA: string, userIdB: string): boolean => {
const aIsCreator = creators.has(userIdA);
const bIsCreator = creators.has(userIdB);
if (aIsCreator && bIsCreator) return false;
if (aIsCreator) return true;
if (bIsCreator) return false;
const aPower = readPowerLevel.user(powerLevels, userIdA);
const bPower = readPowerLevel.user(powerLevels, userIdB);
return aPower > bPower;
},
[creators, powerLevels]
);
return {
hasMorePower,
};
};

View file

@ -0,0 +1,87 @@
import { useCallback, useMemo } from 'react';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { getPowerLevelTag, PowerLevelTags, usePowerLevelTags } from './usePowerLevelTags';
import { IPowerLevels, readPowerLevel } from './usePowerLevels';
import { MemberPowerTag, MemberPowerTagIcon } from '../../types/matrix/room';
import { useRoomCreatorsTag } from './useRoomCreatorsTag';
import { ThemeKind } from './useTheme';
import { accessibleColor } from '../plugins/color';
export type GetMemberPowerTag = (userId: string) => MemberPowerTag;
export const useGetMemberPowerTag = (
room: Room,
creators: Set<string>,
powerLevels: IPowerLevels
) => {
const creatorsTag = useRoomCreatorsTag();
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const getMemberPowerTag: GetMemberPowerTag = useCallback(
(userId) => {
if (creators.has(userId)) {
return creatorsTag;
}
const power = readPowerLevel.user(powerLevels, userId);
return getPowerLevelTag(powerLevelTags, power);
},
[creators, creatorsTag, powerLevels, powerLevelTags]
);
return getMemberPowerTag;
};
export const getPowerTagIconSrc = (
mx: MatrixClient,
useAuthentication: boolean,
icon: MemberPowerTagIcon
): string | undefined =>
icon?.key?.startsWith('mxc://')
? mx.mxcUrlToHttp(icon.key, 96, 96, 'scale', undefined, undefined, useAuthentication) ?? '🌻'
: icon?.key;
export const useAccessiblePowerTagColors = (
themeKind: ThemeKind,
creatorsTag: MemberPowerTag,
powerLevelTags: PowerLevelTags
): Map<string, string> => {
const accessibleColors: Map<string, string> = useMemo(() => {
const colors: Map<string, string> = new Map();
if (creatorsTag.color) {
colors.set(creatorsTag.color, accessibleColor(themeKind, creatorsTag.color));
}
Object.values(powerLevelTags).forEach((tag) => {
const { color } = tag;
if (!color) return;
colors.set(color, accessibleColor(themeKind, color));
});
return colors;
}, [powerLevelTags, creatorsTag, themeKind]);
return accessibleColors;
};
export const useFlattenPowerTagMembers = (
members: RoomMember[],
getTag: GetMemberPowerTag
): Array<MemberPowerTag | RoomMember> => {
const PLTagOrRoomMember = useMemo(() => {
let prevTag: MemberPowerTag | undefined;
const tagOrMember: Array<MemberPowerTag | RoomMember> = [];
members.forEach((member) => {
const tag = getTag(member.userId);
if (tag !== prevTag) {
prevTag = tag;
tagOrMember.push(tag);
}
tagOrMember.push(member);
});
return tagOrMember;
}, [members, getTag]);
return PLTagOrRoomMember;
};

View file

@ -1,5 +1,5 @@
import { RoomMember } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
export const MemberSort = {
Ascending: (a: RoomMember, b: RoomMember) =>
@ -46,3 +46,20 @@ export const useMemberSort = (index: number, memberSort: MemberSortItem[]): Memb
const item = memberSort[index] ?? memberSort[0];
return item;
};
export const useMemberPowerSort = (creators: Set<string>): MemberSortFn => {
const sort: MemberSortFn = useCallback(
(a, b) => {
if (creators.has(a.userId) && creators.has(b.userId)) {
return 0;
}
if (creators.has(a.userId)) return -1;
if (creators.has(b.userId)) return 1;
return b.powerLevel - a.powerLevel;
},
[creators]
);
return sort;
};

View file

@ -1,29 +1,24 @@
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { useCallback, useMemo } from 'react';
import { Room } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { IPowerLevels } from './usePowerLevels';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
import { IImageInfo } from '../../types/matrix/common';
import { ThemeKind } from './useTheme';
import { accessibleColor } from '../plugins/color';
import { MemberPowerTag, StateEvent } from '../../types/matrix/room';
export type PowerLevelTagIcon = {
key?: string;
info?: IImageInfo;
};
export type PowerLevelTag = {
name: string;
color?: string;
icon?: PowerLevelTagIcon;
};
export type PowerLevelTags = Record<number, MemberPowerTag>;
export type PowerLevelTags = Record<number, PowerLevelTag>;
export const powerSortFn = (a: number, b: number) => b - a;
export const sortPowers = (powers: number[]): number[] => powers.sort(powerSortFn);
const powerSortFn = (a: number, b: number) => b - a;
const sortPowers = (powers: number[]): number[] => powers.sort(powerSortFn);
export const getPowers = (tags: PowerLevelTags): number[] => {
const powers: number[] = Object.keys(tags).map((p) => parseInt(p, 10));
const powers: number[] = Object.keys(tags)
.map((p) => {
const power = parseInt(p, 10);
if (Number.isNaN(power)) {
return undefined;
}
return power;
})
.filter((power) => typeof power === 'number');
return sortPowers(powers);
};
@ -55,8 +50,8 @@ const DEFAULT_TAGS: PowerLevelTags = {
name: 'Goku',
color: '#ff6a00',
},
102: {
name: 'Goku Reborn',
150: {
name: 'Co-Founder',
color: '#ff6a7f',
},
101: {
@ -81,7 +76,7 @@ const DEFAULT_TAGS: PowerLevelTags = {
},
};
const generateFallbackTag = (powerLevelTags: PowerLevelTags, power: number): PowerLevelTag => {
const generateFallbackTag = (powerLevelTags: PowerLevelTags, power: number): MemberPowerTag => {
const highToLow = sortPowers(getPowers(powerLevelTags));
const tagPower = highToLow.find((p) => p < power);
@ -92,12 +87,7 @@ const generateFallbackTag = (powerLevelTags: PowerLevelTags, power: number): Pow
};
};
export type GetPowerLevelTag = (powerLevel: number) => PowerLevelTag;
export const usePowerLevelTags = (
room: Room,
powerLevels: IPowerLevels
): [PowerLevelTags, GetPowerLevelTag] => {
export const usePowerLevelTags = (room: Room, powerLevels: IPowerLevels): PowerLevelTags => {
const tagsEvent = useStateEvent(room, StateEvent.PowerLevelTags);
const powerLevelTags: PowerLevelTags = useMemo(() => {
@ -114,66 +104,13 @@ export const usePowerLevelTags = (
return powerToTags;
}, [powerLevels, tagsEvent]);
const getTag: GetPowerLevelTag = useCallback(
(power) => {
const tag: PowerLevelTag | undefined = powerLevelTags[power];
return tag ?? generateFallbackTag(DEFAULT_TAGS, power);
},
[powerLevelTags]
);
return [powerLevelTags, getTag];
return powerLevelTags;
};
export const useFlattenPowerLevelTagMembers = (
members: RoomMember[],
getPowerLevel: (userId: string) => number,
getTag: GetPowerLevelTag
): Array<PowerLevelTag | RoomMember> => {
const PLTagOrRoomMember = useMemo(() => {
let prevTag: PowerLevelTag | undefined;
const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
members.forEach((member) => {
const memberPL = getPowerLevel(member.userId);
const tag = getTag(memberPL);
if (tag !== prevTag) {
prevTag = tag;
tagOrMember.push(tag);
}
tagOrMember.push(member);
});
return tagOrMember;
}, [members, getTag, getPowerLevel]);
return PLTagOrRoomMember;
};
export const getTagIconSrc = (
mx: MatrixClient,
useAuthentication: boolean,
icon: PowerLevelTagIcon
): string | undefined =>
icon?.key?.startsWith('mxc://')
? mx.mxcUrlToHttp(icon.key, 96, 96, 'scale', undefined, undefined, useAuthentication) ?? '🌻'
: icon?.key;
export const useAccessibleTagColors = (
themeKind: ThemeKind,
powerLevelTags: PowerLevelTags
): Map<string, string> => {
const accessibleColors: Map<string, string> = useMemo(() => {
const colors: Map<string, string> = new Map();
getPowers(powerLevelTags).forEach((power) => {
const tag = powerLevelTags[power];
const { color } = tag;
if (!color) return;
colors.set(color, accessibleColor(themeKind, color));
});
return colors;
}, [powerLevelTags, themeKind]);
return accessibleColors;
export const getPowerLevelTag = (
powerLevelTags: PowerLevelTags,
powerLevel: number
): MemberPowerTag => {
const tag: MemberPowerTag | undefined = powerLevelTags[powerLevel];
return tag ?? generateFallbackTag(powerLevelTags, powerLevel);
};

View file

@ -58,10 +58,11 @@ const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
});
const getPowersLevelFromMatrixEvent = (mEvent?: MatrixEvent): IPowerLevels => {
const pl = mEvent?.getContent<IPowerLevels>();
if (!pl) return DEFAULT_POWER_LEVELS;
const plContent = mEvent?.getContent<IPowerLevels>();
return fillMissingPowers(pl);
const powerLevels = !plContent ? DEFAULT_POWER_LEVELS : fillMissingPowers(plContent);
return powerLevels;
};
export function usePowerLevels(room: Room): IPowerLevels {
@ -120,33 +121,8 @@ export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> =>
return roomToPowerLevels;
};
export type GetPowerLevel = (powerLevels: IPowerLevels, userId: string | undefined) => number;
export type CanSend = (
powerLevels: IPowerLevels,
eventType: string | undefined,
powerLevel: number
) => boolean;
export type CanDoAction = (
powerLevels: IPowerLevels,
action: PowerLevelActions,
powerLevel: number
) => boolean;
export type CanDoNotificationAction = (
powerLevels: IPowerLevels,
action: PowerLevelNotificationsAction,
powerLevel: number
) => boolean;
export type PowerLevelsAPI = {
getPowerLevel: GetPowerLevel;
canSendEvent: CanSend;
canSendStateEvent: CanSend;
canDoAction: CanDoAction;
canDoNotificationAction: CanDoNotificationAction;
};
export type ReadPowerLevelAPI = {
user: GetPowerLevel;
user: (powerLevels: IPowerLevels, userId: string | undefined) => number;
event: (powerLevels: IPowerLevels, eventType: string | undefined) => number;
state: (powerLevels: IPowerLevels, eventType: string | undefined) => number;
action: (powerLevels: IPowerLevels, action: PowerLevelActions) => number;
@ -156,6 +132,7 @@ export type ReadPowerLevelAPI = {
export const readPowerLevel: ReadPowerLevelAPI = {
user: (powerLevels, userId) => {
const { users_default: usersDefault, users } = powerLevels;
if (userId && users && typeof users[userId] === 'number') {
return users[userId];
}
@ -191,63 +168,13 @@ export const readPowerLevel: ReadPowerLevelAPI = {
},
};
export const powerLevelAPI: PowerLevelsAPI = {
getPowerLevel: (powerLevels, userId) => readPowerLevel.user(powerLevels, userId),
canSendEvent: (powerLevels, eventType, powerLevel) => {
const requiredPL = readPowerLevel.event(powerLevels, eventType);
return powerLevel >= requiredPL;
},
canSendStateEvent: (powerLevels, eventType, powerLevel) => {
const requiredPL = readPowerLevel.state(powerLevels, eventType);
return powerLevel >= requiredPL;
},
canDoAction: (powerLevels, action, powerLevel) => {
const requiredPL = readPowerLevel.action(powerLevels, action);
return powerLevel >= requiredPL;
},
canDoNotificationAction: (powerLevels, action, powerLevel) => {
const requiredPL = readPowerLevel.notification(powerLevels, action);
return powerLevel >= requiredPL;
},
};
export const usePowerLevelsAPI = (powerLevels: IPowerLevels) => {
const getPowerLevel = useCallback(
(userId: string | undefined) => powerLevelAPI.getPowerLevel(powerLevels, userId),
export const useGetMemberPowerLevel = (powerLevels: IPowerLevels) => {
const callback = useCallback(
(userId?: string): number => readPowerLevel.user(powerLevels, userId),
[powerLevels]
);
const canSendEvent = useCallback(
(eventType: string | undefined, powerLevel: number) =>
powerLevelAPI.canSendEvent(powerLevels, eventType, powerLevel),
[powerLevels]
);
const canSendStateEvent = useCallback(
(eventType: string | undefined, powerLevel: number) =>
powerLevelAPI.canSendStateEvent(powerLevels, eventType, powerLevel),
[powerLevels]
);
const canDoAction = useCallback(
(action: PowerLevelActions, powerLevel: number) =>
powerLevelAPI.canDoAction(powerLevels, action, powerLevel),
[powerLevels]
);
const canDoNotificationAction = useCallback(
(action: PowerLevelNotificationsAction, powerLevel: number) =>
powerLevelAPI.canDoNotificationAction(powerLevels, action, powerLevel),
[powerLevels]
);
return {
getPowerLevel,
canSendEvent,
canSendStateEvent,
canDoAction,
canDoNotificationAction,
};
return callback;
};
/**

View file

@ -0,0 +1,49 @@
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { useStateEvent } from './useStateEvent';
import { IRoomCreateContent, StateEvent } from '../../types/matrix/room';
import { creatorsSupported } from '../utils/matrix';
import { getStateEvent } from '../utils/room';
export const getRoomCreators = (createEvent: MatrixEvent): Set<string> => {
const createContent = createEvent.getContent<IRoomCreateContent>();
const creators: Set<string> = new Set();
if (!creatorsSupported(createContent.room_version)) return creators;
if (createEvent.event.sender) {
creators.add(createEvent.event.sender);
}
if ('additional_creators' in createContent && Array.isArray(createContent.additional_creators)) {
createContent.additional_creators.forEach((creator) => {
if (typeof creator === 'string') {
creators.add(creator);
}
});
}
return creators;
};
export const useRoomCreators = (room: Room): Set<string> => {
const createEvent = useStateEvent(room, StateEvent.RoomCreate);
const creators = useMemo(
() => (createEvent ? getRoomCreators(createEvent) : new Set<string>()),
[createEvent]
);
return creators;
};
export const getRoomCreatorsForRoomId = (mx: MatrixClient, roomId: string): Set<string> => {
const room = mx.getRoom(roomId);
if (!room) return new Set();
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
if (!createEvent) return new Set();
return getRoomCreators(createEvent);
};

View file

@ -0,0 +1,8 @@
import { MemberPowerTag } from '../../types/matrix/room';
const DEFAULT_TAG: MemberPowerTag = {
name: 'Founder',
color: '#0000ff',
};
export const useRoomCreatorsTag = (): MemberPowerTag => DEFAULT_TAG;

View file

@ -0,0 +1,60 @@
import { useMemo } from 'react';
import {
IPowerLevels,
PowerLevelActions,
PowerLevelNotificationsAction,
readPowerLevel,
} from './usePowerLevels';
export type RoomPermissionsAPI = {
event: (type: string, userId: string) => boolean;
stateEvent: (type: string, userId: string) => boolean;
action: (action: PowerLevelActions, userId: string) => boolean;
notificationAction: (action: PowerLevelNotificationsAction, userId: string) => boolean;
};
export const getRoomPermissionsAPI = (
creators: Set<string>,
powerLevels: IPowerLevels
): RoomPermissionsAPI => {
const api: RoomPermissionsAPI = {
event: (type, userId) => {
if (creators.has(userId)) return true;
const userPower = readPowerLevel.user(powerLevels, userId);
const requiredPL = readPowerLevel.event(powerLevels, type);
return userPower >= requiredPL;
},
stateEvent: (type, userId) => {
if (creators.has(userId)) return true;
const userPower = readPowerLevel.user(powerLevels, userId);
const requiredPL = readPowerLevel.state(powerLevels, type);
return userPower >= requiredPL;
},
action: (action, userId) => {
if (creators.has(userId)) return true;
const userPower = readPowerLevel.user(powerLevels, userId);
const requiredPL = readPowerLevel.action(powerLevels, action);
return userPower >= requiredPL;
},
notificationAction: (action, userId) => {
if (creators.has(userId)) return true;
const userPower = readPowerLevel.user(powerLevels, userId);
const requiredPL = readPowerLevel.notification(powerLevels, action);
return userPower >= requiredPL;
},
};
return api;
};
export const useRoomPermissions = (
creators: Set<string>,
powerLevels: IPowerLevels
): RoomPermissionsAPI => {
const api: RoomPermissionsAPI = useMemo(
() => getRoomPermissionsAPI(creators, powerLevels),
[creators, powerLevels]
);
return api;
};