diff --git a/src/app/components/UserRoomProfileRenderer.tsx b/src/app/components/UserRoomProfileRenderer.tsx new file mode 100644 index 00000000..ca7aa837 --- /dev/null +++ b/src/app/components/UserRoomProfileRenderer.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Menu, PopOut, toRem } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile'; +import { UserRoomProfile } from './user-profile'; +import { UserRoomProfileState } from '../state/userRoomProfile'; +import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom'; +import { stopPropagation } from '../utils/keyboard'; +import { SpaceProvider } from '../hooks/useSpace'; +import { RoomProvider } from '../hooks/useRoom'; + +function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) { + const { roomId, spaceId, userId, cords, position } = state; + const allJoinedRooms = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allJoinedRooms); + const room = getRoom(roomId); + const space = spaceId ? getRoom(spaceId) : undefined; + + const close = useCloseUserRoomProfile(); + + if (!room) return null; + + return ( + + + + + + + + + + } + /> + ); +} + +export function UserRoomProfileRenderer() { + const state = useUserRoomProfileState(); + + if (!state) return null; + return ; +} diff --git a/src/app/components/create-room/AdditionalCreatorInput.tsx b/src/app/components/create-room/AdditionalCreatorInput.tsx new file mode 100644 index 00000000..51334b49 --- /dev/null +++ b/src/app/components/create-room/AdditionalCreatorInput.tsx @@ -0,0 +1,306 @@ +import { + Box, + Button, + Chip, + config, + Icon, + Icons, + Input, + Line, + Menu, + MenuItem, + PopOut, + RectCords, + Scroll, + Text, + toRem, +} from 'folds'; +import { isKeyHotkey } from 'is-hotkey'; +import FocusTrap from 'focus-trap-react'; +import React, { + ChangeEventHandler, + KeyboardEventHandler, + MouseEventHandler, + useMemo, + useState, +} from 'react'; +import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix'; +import { useDirectUsers } from '../../hooks/useDirectUsers'; +import { SettingTile } from '../setting-tile'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { stopPropagation } from '../../utils/keyboard'; +import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; +import { findAndReplace } from '../../utils/findAndReplace'; +import { highlightText } from '../../styles/CustomHtml.css'; +import { makeHighlightRegex } from '../../plugins/react-custom-html-parser'; + +export const useAdditionalCreators = (defaultCreators?: string[]) => { + const mx = useMatrixClient(); + const [additionalCreators, setAdditionalCreators] = useState( + () => defaultCreators?.filter((id) => id !== mx.getSafeUserId()) ?? [] + ); + + const addAdditionalCreator = (userId: string) => { + if (userId === mx.getSafeUserId()) return; + + setAdditionalCreators((creators) => { + const creatorsSet = new Set(creators); + creatorsSet.add(userId); + return Array.from(creatorsSet); + }); + }; + + const removeAdditionalCreator = (userId: string) => { + setAdditionalCreators((creators) => { + const creatorsSet = new Set(creators); + creatorsSet.delete(userId); + return Array.from(creatorsSet); + }); + }; + + return { + additionalCreators, + addAdditionalCreator, + removeAdditionalCreator, + }; +}; + +const SEARCH_OPTIONS: UseAsyncSearchOptions = { + limit: 1000, + matchOptions: { + contain: true, + }, +}; +const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId; + +type AdditionalCreatorInputProps = { + additionalCreators: string[]; + onSelect: (userId: string) => void; + onRemove: (userId: string) => void; + disabled?: boolean; +}; +export function AdditionalCreatorInput({ + additionalCreators, + onSelect, + onRemove, + disabled, +}: AdditionalCreatorInputProps) { + const mx = useMatrixClient(); + const [menuCords, setMenuCords] = useState(); + const directUsers = useDirectUsers(); + + const [validUserId, setValidUserId] = useState(); + const filteredUsers = useMemo( + () => directUsers.filter((userId) => !additionalCreators.includes(userId)), + [directUsers, additionalCreators] + ); + const [result, search, resetSearch] = useAsyncSearch( + filteredUsers, + getUserIdString, + SEARCH_OPTIONS + ); + const queryHighlighRegex = result?.query ? makeHighlightRegex([result.query]) : undefined; + + const suggestionUsers = result + ? result.items + : filteredUsers.sort((a, b) => (a.toLocaleLowerCase() >= b.toLocaleLowerCase() ? 1 : -1)); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + const handleCloseMenu = () => { + setMenuCords(undefined); + setValidUserId(undefined); + resetSearch(); + }; + + const handleCreatorChange: ChangeEventHandler = (evt) => { + const creatorInput = evt.currentTarget; + const creator = creatorInput.value.trim(); + if (isUserId(creator)) { + setValidUserId(creator); + } else { + setValidUserId(undefined); + const term = + getMxIdLocalPart(creator) ?? (creator.startsWith('@') ? creator.slice(1) : creator); + if (term) { + search(term); + } else { + resetSearch(); + } + } + }; + + const handleSelectUserId = (userId?: string) => { + if (userId && isUserId(userId)) { + onSelect(userId); + handleCloseMenu(); + } + }; + + const handleCreatorKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('enter', evt)) { + evt.preventDefault(); + const creator = evt.currentTarget.value.trim(); + handleSelectUserId(isUserId(creator) ? creator : suggestionUsers[0]); + } + }; + + const handleEnterClick = () => { + handleSelectUserId(validUserId); + }; + + return ( + + + + + {mx.getSafeUserId()} + + {additionalCreators.map((creator) => ( + } + onClick={() => onRemove(creator)} + disabled={disabled} + > + {creator} + + ))} + evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + + + + + + + + + {!validUserId && suggestionUsers.length > 0 ? ( + + + {suggestionUsers.map((userId) => ( + handleSelectUserId(userId)} + after={ + + {getMxIdServer(userId)} + + } + > + + + + {queryHighlighRegex + ? findAndReplace( + getMxIdLocalPart(userId) ?? userId, + queryHighlighRegex, + (match, pushIndex) => ( + + {match[0]} + + ), + (txt) => txt + ) + : getMxIdLocalPart(userId)} + + + + + ))} + + + ) : ( + + + No Suggestions + + + Please provide the user ID and hit Enter. + + + )} + + + + + } + > + + + + + + + + ); +} diff --git a/src/app/components/create-room/RoomVersionSelector.tsx b/src/app/components/create-room/RoomVersionSelector.tsx index 281f520a..219ded0c 100644 --- a/src/app/components/create-room/RoomVersionSelector.tsx +++ b/src/app/components/create-room/RoomVersionSelector.tsx @@ -47,7 +47,7 @@ export function RoomVersionSelector({ gap="500" > { const content: Record = {}; if (typeof type === 'string') { @@ -23,6 +24,9 @@ export const createRoomCreationContent = ( if (allowFederation === false) { content['m.federate'] = false; } + if (Array.isArray(additionalCreators)) { + content.additional_creators = additionalCreators; + } return content; }; @@ -89,6 +93,7 @@ export type CreateRoomData = { encryption?: boolean; knock: boolean; allowFederation: boolean; + additionalCreators?: string[]; }; export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise => { const initialState: ICreateRoomStateEvent[] = []; @@ -108,7 +113,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis name: data.name, topic: data.topic, room_alias_name: data.aliasLocalPart, - creation_content: createRoomCreationContent(data.type, data.allowFederation), + creation_content: createRoomCreationContent( + data.type, + data.allowFederation, + data.additionalCreators + ), initial_state: initialState, }; diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index de1416b6..75fdf0da 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -19,9 +19,10 @@ import { getMemberDisplayName } from '../../utils/room'; import { getMxIdLocalPart } from '../../utils/matrix'; import * as css from './EventReaders.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { openProfileViewer } from '../../../client/action/navigation'; import { UserAvatar } from '../user-avatar'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../hooks/useSpace'; export type EventReadersProps = { room: Room; @@ -33,6 +34,8 @@ export const EventReaders = as<'div', EventReadersProps>( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const latestEventReaders = useRoomEventReaders(room, eventId); + const openProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); const getName = (userId: string) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; @@ -57,19 +60,32 @@ export const EventReaders = as<'div', EventReadersProps>( {latestEventReaders.map((readerId) => { const name = getName(readerId); - const avatarMxcUrl = room - .getMember(readerId) - ?.getMxcAvatarUrl(); - const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication) : undefined; + const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl(); + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp( + avatarMxcUrl, + 100, + 100, + 'crop', + undefined, + false, + useAuthentication + ) + : undefined; return ( { - requestClose(); - openProfileViewer(readerId, room.roomId); + onClick={(event) => { + openProfile( + room.roomId, + space?.roomId, + readerId, + event.currentTarget.getBoundingClientRect(), + 'Bottom' + ); }} before={ diff --git a/src/app/components/image-pack-view/RoomImagePack.tsx b/src/app/components/image-pack-view/RoomImagePack.tsx index 9dd45c1f..92b4ff21 100644 --- a/src/app/components/image-pack-view/RoomImagePack.tsx +++ b/src/app/components/image-pack-view/RoomImagePack.tsx @@ -1,12 +1,14 @@ import React, { useCallback, useMemo } from 'react'; import { Room } from 'matrix-js-sdk'; -import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels'; +import { usePowerLevels } from '../../hooks/usePowerLevels'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { ImagePackContent } from './ImagePackContent'; import { ImagePack, PackContent } from '../../plugins/custom-emoji'; import { StateEvent } from '../../../types/matrix/room'; import { useRoomImagePack } from '../../hooks/useImagePacks'; import { randomStr } from '../../utils/common'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; type RoomImagePackProps = { room: Room; @@ -17,9 +19,10 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) { const mx = useMatrixClient(); const userId = mx.getUserId()!; const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); - const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels); - const canEditImagePack = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(userId)); + const permissions = useRoomPermissions(creators, powerLevels); + const canEditImagePack = permissions.stateEvent(StateEvent.PoniesRoomEmotes, userId); const fallbackPack = useMemo(() => { const fakePackId = randomStr(4); diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index dc92bf83..57bf2af9 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -10,8 +10,8 @@ import * as css from './Reply.css'; import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content'; import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; import { useRoomEvent } from '../../hooks/useRoomEvent'; -import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags'; import colorMXID from '../../../util/colorMXID'; +import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag'; type ReplyLayoutProps = { userColor?: string; @@ -57,8 +57,7 @@ type ReplyProps = { replyEventId: string; threadRootId?: string | undefined; onClick?: MouseEventHandler | undefined; - getPowerLevel?: (userId: string) => number; - getPowerLevelTag?: GetPowerLevelTag; + getMemberPowerTag?: GetMemberPowerTag; accessibleTagColors?: Map; legacyUsernameColor?: boolean; }; @@ -71,8 +70,7 @@ export const Reply = as<'div', ReplyProps>( replyEventId, threadRootId, onClick, - getPowerLevel, - getPowerLevelTag, + getMemberPowerTag, accessibleTagColors, legacyUsernameColor, ...props @@ -88,8 +86,7 @@ export const Reply = as<'div', ReplyProps>( const { body } = replyEvent?.getContent() ?? {}; const sender = replyEvent?.getSender(); - const senderPL = sender && getPowerLevel?.(sender); - const powerTag = typeof senderPL === 'number' ? getPowerLevelTag?.(senderPL) : undefined; + const powerTag = sender ? getMemberPowerTag?.(sender) : undefined; const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined; const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor; diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index a9b3f35f..43949cef 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -124,7 +124,7 @@ export const AvatarBase = style({ selectors: { '&:hover': { - transform: `translateY(${toRem(-4)})`, + transform: `translateY(${toRem(-2)})`, }, }, }); diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx new file mode 100644 index 00000000..108852f2 --- /dev/null +++ b/src/app/components/presence/Presence.tsx @@ -0,0 +1,80 @@ +import { + as, + Badge, + Box, + color, + ContainerColor, + MainColor, + Text, + Tooltip, + TooltipProvider, + toRem, +} from 'folds'; +import React, { ReactNode, useId } from 'react'; +import * as css from './styles.css'; +import { Presence, usePresenceLabel } from '../../hooks/useUserPresence'; + +const PresenceToColor: Record = { + [Presence.Online]: 'Success', + [Presence.Unavailable]: 'Warning', + [Presence.Offline]: 'Secondary', +}; + +type PresenceBadgeProps = { + presence: Presence; + status?: string; + size?: '200' | '300' | '400' | '500'; +}; +export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) { + const label = usePresenceLabel(); + const badgeLabelId = useId(); + + return ( + + + {label[presence]} + {status && } + {status && {status}} + + + } + > + {(triggerRef) => ( + + )} + + ); +} + +type AvatarPresenceProps = { + badge: ReactNode; + variant?: ContainerColor; +}; +export const AvatarPresence = as<'div', AvatarPresenceProps>( + ({ as: AsAvatarPresence, badge, variant = 'Surface', children, ...props }, ref) => ( + + {badge && ( +
+ {badge} +
+ )} + {children} +
+ ) +); diff --git a/src/app/components/presence/index.ts b/src/app/components/presence/index.ts new file mode 100644 index 00000000..88fcdf78 --- /dev/null +++ b/src/app/components/presence/index.ts @@ -0,0 +1 @@ +export * from './Presence'; diff --git a/src/app/components/presence/styles.css.ts b/src/app/components/presence/styles.css.ts new file mode 100644 index 00000000..12ea7f18 --- /dev/null +++ b/src/app/components/presence/styles.css.ts @@ -0,0 +1,22 @@ +import { style } from '@vanilla-extract/css'; +import { config } from 'folds'; + +export const AvatarPresence = style({ + display: 'flex', + position: 'relative', + flexShrink: 0, +}); + +export const AvatarPresenceBadge = style({ + position: 'absolute', + bottom: 0, + right: 0, + transform: 'translate(25%, 25%)', + zIndex: 1, + + display: 'flex', + padding: config.borderWidth.B600, + backgroundColor: 'inherit', + borderRadius: config.radii.Pill, + overflow: 'hidden', +}); diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index b02d9f5a..c388efd4 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -87,7 +87,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {typeof prevRoomId === 'string' && (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? ( +
+ + + + + + ); +} + +type SharedPowerAlertProps = { + power: number; + onCancel: () => void; + onChange: (power: number) => void; +}; +function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) { + return ( + }> + + + +
+ + Shared Power + + + + +
+ + + + You are promoting the user to have the same power as yourself! You will not be + able to change their power afterward. Are you sure? + + + + + + +
+
+
+
+ ); +} + +export function PowerChip({ userId }: { userId: string }) { + const mx = useMatrixClient(); + const room = useRoom(); + const space = useSpaceOptionally(); + const useAuthentication = useMediaAuthentication(); + const openRoomSettings = useOpenRoomSettings(); + const openSpaceSettings = useOpenSpaceSettings(); + + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const getMemberPowerLevel = useGetMemberPowerLevel(powerLevels); + const { hasMorePower } = useMemberPowerCompare(creators, powerLevels); + + const powerLevelTags = usePowerLevelTags(room, powerLevels); + const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + + const myUserId = mx.getSafeUserId(); + const canChangePowers = + permissions.stateEvent(StateEvent.RoomPowerLevels, myUserId) && + (myUserId === userId ? true : hasMorePower(myUserId, userId)); + + const tag = getMemberPowerTag(userId); + const tagIconSrc = tag.icon && getPowerTagIconSrc(mx, useAuthentication, tag.icon); + + const [cords, setCords] = useState(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const close = () => setCords(undefined); + + const [powerState, changePower] = useAsyncCallback( + useCallback( + async (power: number) => { + await mx.setPowerLevel(room.roomId, userId, power); + }, + [mx, userId, room] + ) + ); + const changing = powerState.status === AsyncStatus.Loading; + const error = powerState.status === AsyncStatus.Error; + const [selfDemote, setSelfDemote] = useState(); + const [sharedPower, setSharedPower] = useState(); + + const handlePowerSelect = (power: number): void => { + close(); + if (!canChangePowers) return; + if (power === getMemberPowerLevel(userId)) return; + + if (userId === mx.getSafeUserId()) { + setSelfDemote(power); + return; + } + if (!creators.has(myUserId) && power === getMemberPowerLevel(myUserId)) { + setSharedPower(power); + return; + } + + changePower(power); + }; + + const handleSelfDemote = (power: number) => { + setSelfDemote(undefined); + changePower(power); + }; + const handleSharedPower = (power: number) => { + setSharedPower(undefined); + changePower(power); + }; + + return ( + <> + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + + + {error && ( + + Error: {powerState.error.name} + + {powerState.error.message} + + + )} + {getPowers(powerLevelTags).map((power) => { + const powerTag = powerLevelTags[power]; + const powerTagIconSrc = + powerTag.icon && getPowerTagIconSrc(mx, useAuthentication, powerTag.icon); + + const selected = getMemberPowerLevel(userId) === power; + const canAssignPower = creators.has(myUserId) + ? true + : power <= getMemberPowerLevel(myUserId); + + return ( + } + after={ + powerTagIconSrc ? ( + + ) : undefined + } + onClick={ + canChangePowers && canAssignPower + ? () => handlePowerSelect(power) + : undefined + } + > + {powerTag.name} + + ); + })} + + +
+ { + if (room.isSpaceRoom()) { + openSpaceSettings( + room.roomId, + space?.roomId, + SpaceSettingsPage.PermissionsPage + ); + } else { + openRoomSettings( + room.roomId, + space?.roomId, + RoomSettingsPage.PermissionsPage + ); + } + close(); + }} + > + Manage Powers + +
+
+ + } + > + + ) : ( + <> + {!changing && } + {changing && } + + ) + } + after={tagIconSrc ? : undefined} + onClick={open} + aria-pressed={!!cords} + > + + {tag.name} + + +
+ {typeof selfDemote === 'number' ? ( + setSelfDemote(undefined)} + onChange={handleSelfDemote} + /> + ) : null} + {typeof sharedPower === 'number' ? ( + setSharedPower(undefined)} + onChange={handleSharedPower} + /> + ) : null} + + ); +} diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx new file mode 100644 index 00000000..53e6618b --- /dev/null +++ b/src/app/components/user-profile/UserChips.tsx @@ -0,0 +1,514 @@ +import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import FocusTrap from 'focus-trap-react'; +import { isKeyHotkey } from 'is-hotkey'; +import { Room } from 'matrix-js-sdk'; +import { + PopOut, + Menu, + MenuItem, + config, + Text, + Line, + Chip, + Icon, + Icons, + RectCords, + Spinner, + toRem, + Box, + Scroll, + Avatar, +} from 'folds'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getMxIdServer } from '../../utils/matrix'; +import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { stopPropagation } from '../../utils/keyboard'; +import { copyToClipboard } from '../../utils/dom'; +import { getExploreServerPath } from '../../pages/pathUtils'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { factoryRoomIdByAtoZ } from '../../utils/sort'; +import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { useDirectRooms } from '../../pages/client/direct/useDirectRooms'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; +import { RoomAvatar, RoomIcon } from '../room-avatar'; +import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; +import { nameInitials } from '../../utils/common'; +import { getMatrixToUser } from '../../plugins/matrix-to'; +import { useTimeoutToggle } from '../../hooks/useTimeoutToggle'; +import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; +import { CutoutCard } from '../cutout-card'; +import { SettingTile } from '../setting-tile'; + +export function ServerChip({ server }: { server: string }) { + const mx = useMatrixClient(); + const myServer = getMxIdServer(mx.getSafeUserId()); + const navigate = useNavigate(); + const closeProfile = useCloseUserRoomProfile(); + const [copied, setCopied] = useTimeoutToggle(); + + const [cords, setCords] = useState(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const close = () => setCords(undefined); + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + +
+ { + copyToClipboard(server); + setCopied(); + close(); + }} + > + Copy Server + + { + navigate(getExploreServerPath(server)); + closeProfile(); + }} + > + Explore Community + +
+ +
+ { + window.open(`https://${server}`, '_blank'); + close(); + }} + > + Open in Browser + +
+
+ + } + > + + ) : ( + + ) + } + onClick={open} + aria-pressed={!!cords} + > + + {server} + + +
+ ); +} + +export function ShareChip({ userId }: { userId: string }) { + const [cords, setCords] = useState(); + + const [copied, setCopied] = useTimeoutToggle(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const close = () => setCords(undefined); + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + +
+ { + copyToClipboard(userId); + setCopied(); + close(); + }} + > + Copy User ID + + { + copyToClipboard(getMatrixToUser(userId)); + setCopied(); + close(); + }} + > + Copy User Link + +
+
+ + } + > + + ) : ( + + ) + } + onClick={open} + aria-pressed={!!cords} + > + + Share + + +
+ ); +} + +type MutualRoomsData = { + rooms: Room[]; + spaces: Room[]; + directs: Room[]; +}; + +export function MutualRoomsChip({ userId }: { userId: string }) { + const mx = useMatrixClient(); + const mutualRoomSupported = useMutualRoomsSupport(); + const mutualRoomsState = useMutualRooms(userId); + const { navigateRoom, navigateSpace } = useRoomNavigate(); + const closeUserRoomProfile = useCloseUserRoomProfile(); + const directs = useDirectRooms(); + const useAuthentication = useMediaAuthentication(); + + const allJoinedRooms = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allJoinedRooms); + + const [cords, setCords] = useState(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const close = () => setCords(undefined); + + const mutual: MutualRoomsData = useMemo(() => { + const data: MutualRoomsData = { + rooms: [], + spaces: [], + directs: [], + }; + + if (mutualRoomsState.status === AsyncStatus.Success) { + const mutualRooms = mutualRoomsState.data + .sort(factoryRoomIdByAtoZ(mx)) + .map(getRoom) + .filter((room) => !!room); + mutualRooms.forEach((room) => { + if (room.isSpaceRoom()) { + data.spaces.push(room); + return; + } + if (directs.includes(room.roomId)) { + data.directs.push(room); + return; + } + data.rooms.push(room); + }); + } + return data; + }, [mutualRoomsState, getRoom, directs, mx]); + + if ( + userId === mx.getSafeUserId() || + !mutualRoomSupported || + mutualRoomsState.status === AsyncStatus.Error + ) { + return null; + } + + const renderItem = (room: Room) => { + const { roomId } = room; + const dm = directs.includes(roomId); + + return ( + { + if (room.isSpaceRoom()) { + navigateSpace(roomId); + } else { + navigateRoom(roomId); + } + closeUserRoomProfile(); + }} + before={ + + {dm || room.isSpaceRoom() ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + } + > + + {room.name} + + + ); + }; + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + + + + + {mutual.spaces.length > 0 && ( + + + Spaces + + {mutual.spaces.map(renderItem)} + + )} + {mutual.rooms.length > 0 && ( + + + Rooms + + {mutual.rooms.map(renderItem)} + + )} + {mutual.directs.length > 0 && ( + + + Direct Messages + + {mutual.directs.map(renderItem)} + + )} + + + + + + ) : null + } + > + } + disabled={ + mutualRoomsState.status !== AsyncStatus.Success || mutualRoomsState.data.length === 0 + } + onClick={open} + aria-pressed={!!cords} + > + + {mutualRoomsState.status === AsyncStatus.Success && + `${mutualRoomsState.data.length} Mutual Rooms`} + {mutualRoomsState.status === AsyncStatus.Loading && 'Mutual Rooms'} + + + + ); +} + +export function IgnoredUserAlert() { + return ( + + + + + Blocked User + + + You do not receive any messages or invites from this user. + + + + + ); +} + +export function OptionsChip({ userId }: { userId: string }) { + const mx = useMatrixClient(); + const [cords, setCords] = useState(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const close = () => setCords(undefined); + + const ignoredUsers = useIgnoredUsers(); + const ignored = ignoredUsers.includes(userId); + + const [ignoreState, toggleIgnore] = useAsyncCallback( + useCallback(async () => { + const users = ignoredUsers.filter((u) => u !== userId); + if (!ignored) users.push(userId); + await mx.setIgnoredUsers(users); + }, [mx, ignoredUsers, userId, ignored]) + ); + const ignoring = ignoreState.status === AsyncStatus.Loading; + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + +
+ { + toggleIgnore(); + close(); + }} + before={ + ignoring ? ( + + ) : ( + + ) + } + disabled={ignoring} + > + {ignored ? 'Unblock User' : 'Block User'} + +
+
+ + } + > + + {ignoring ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx new file mode 100644 index 00000000..cf4c815f --- /dev/null +++ b/src/app/components/user-profile/UserHero.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Avatar, Box, Icon, Icons, Text } from 'folds'; +import classNames from 'classnames'; +import * as css from './styles.css'; +import { UserAvatar } from '../user-avatar'; +import colorMXID from '../../../util/colorMXID'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { BreakWord, LineClamp3 } from '../../styles/Text.css'; +import { UserPresence } from '../../hooks/useUserPresence'; +import { AvatarPresence, PresenceBadge } from '../presence'; + +type UserHeroProps = { + userId: string; + avatarUrl?: string; + presence?: UserPresence; +}; +export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { + return ( + +
+ {avatarUrl && {userId}} +
+
+ + } + > + + } + /> + + +
+
+ ); +} + +type UserHeroNameProps = { + displayName?: string; + userId: string; +}; +export function UserHeroName({ displayName, userId }: UserHeroNameProps) { + const username = getMxIdLocalPart(userId); + + return ( + + + + {displayName ?? username ?? userId} + + + + + @{username} + + + + ); +} diff --git a/src/app/components/user-profile/UserModeration.tsx b/src/app/components/user-profile/UserModeration.tsx new file mode 100644 index 00000000..814bb5ba --- /dev/null +++ b/src/app/components/user-profile/UserModeration.tsx @@ -0,0 +1,349 @@ +import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds'; +import React, { useCallback, useRef } from 'react'; +import { useRoom } from '../../hooks/useRoom'; +import { CutoutCard } from '../cutout-card'; +import { SettingTile } from '../setting-tile'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { BreakWord } from '../../styles/Text.css'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { timeDayMonYear, timeHourMinute } from '../../utils/time'; + +type UserKickAlertProps = { + reason?: string; + kickedBy?: string; + ts?: number; +}; +export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) { + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const time = ts ? timeHourMinute(ts, hour24Clock) : undefined; + const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined; + + return ( + + + + + Kicked User + {time && date && ( + + {date} {time} + + )} + + + {kickedBy && ( + + Kicked by: {kickedBy} + + )} + + {reason ? ( + <> + Reason: {reason} + + ) : ( + No Reason Provided. + )} + + + + + + ); +} + +type UserBanAlertProps = { + userId: string; + reason?: string; + canUnban?: boolean; + bannedBy?: string; + ts?: number; +}; +export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const time = ts ? timeHourMinute(ts, hour24Clock) : undefined; + const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined; + + const [unbanState, unban] = useAsyncCallback( + useCallback(async () => { + await mx.unban(room.roomId, userId); + }, [mx, room, userId]) + ); + const banning = unbanState.status === AsyncStatus.Loading; + const error = unbanState.status === AsyncStatus.Error; + + return ( + + + + + Banned User + {time && date && ( + + {date} {time} + + )} + + + {bannedBy && ( + + Banned by: {bannedBy} + + )} + + {reason ? ( + <> + Reason: {reason} + + ) : ( + No Reason Provided. + )} + + + {error && ( + + {unbanState.error.message} + + )} + {canUnban && ( + + )} + + + + ); +} + +type UserInviteAlertProps = { + userId: string; + reason?: string; + canKick?: boolean; + invitedBy?: string; + ts?: number; +}; +export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const time = ts ? timeHourMinute(ts, hour24Clock) : undefined; + const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined; + + const [kickState, kick] = useAsyncCallback( + useCallback(async () => { + await mx.kick(room.roomId, userId); + }, [mx, room, userId]) + ); + const kicking = kickState.status === AsyncStatus.Loading; + const error = kickState.status === AsyncStatus.Error; + + return ( + + + + + Invited User + {time && date && ( + + {date} {time} + + )} + + + {invitedBy && ( + + Invited by: {invitedBy} + + )} + + {reason ? ( + <> + Reason: {reason} + + ) : ( + No Reason Provided. + )} + + + {error && ( + + {kickState.error.message} + + )} + {canKick && ( + + )} + + + + ); +} + +type UserModerationProps = { + userId: string; + canKick: boolean; + canBan: boolean; + canInvite: boolean; +}; +export function UserModeration({ userId, canKick, canBan, canInvite }: UserModerationProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const reasonInputRef = useRef(null); + + const getReason = useCallback((): string | undefined => { + const reason = reasonInputRef.current?.value.trim() || undefined; + if (reasonInputRef.current) { + reasonInputRef.current.value = ''; + } + return reason; + }, []); + + const [kickState, kick] = useAsyncCallback( + useCallback(async () => { + await mx.kick(room.roomId, userId, getReason()); + }, [mx, room, userId, getReason]) + ); + + const [banState, ban] = useAsyncCallback( + useCallback(async () => { + await mx.ban(room.roomId, userId, getReason()); + }, [mx, room, userId, getReason]) + ); + + const [inviteState, invite] = useAsyncCallback( + useCallback(async () => { + await mx.invite(room.roomId, userId, getReason()); + }, [mx, room, userId, getReason]) + ); + + const disabled = + kickState.status === AsyncStatus.Loading || + banState.status === AsyncStatus.Loading || + inviteState.status === AsyncStatus.Loading; + + if (!canBan && !canKick && !canInvite) return null; + + return ( + + + + Moderation + + {kickState.status === AsyncStatus.Error && ( + + {kickState.error.message} + + )} + {banState.status === AsyncStatus.Error && ( + + {banState.error.message} + + )} + {inviteState.status === AsyncStatus.Error && ( + + {inviteState.error.message} + + )} + + + {canInvite && ( + + )} + {canKick && ( + + )} + {canBan && ( + + )} + + + + ); +} diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx new file mode 100644 index 00000000..9f8985af --- /dev/null +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -0,0 +1,169 @@ +import { Box, Button, color, config, Icon, Icons, Spinner, Text } from 'folds'; +import React, { useCallback } from 'react'; +import { UserHero, UserHeroName } from './UserHero'; +import { getDMRoomFor, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { usePowerLevels } from '../../hooks/usePowerLevels'; +import { useRoom } from '../../hooks/useRoom'; +import { useUserPresence } from '../../hooks/useUserPresence'; +import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { createDM } from '../../../client/action/room'; +import { hasDevices } from '../../../util/matrixUtil'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { useAlive } from '../../hooks/useAlive'; +import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { PowerChip } from './PowerChip'; +import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration'; +import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; +import { useMembership } from '../../hooks/useMembership'; +import { Membership } from '../../../types/matrix/room'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; +import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; +import { CreatorChip } from './CreatorChip'; + +type UserRoomProfileProps = { + userId: string; +}; +export function UserRoomProfile({ userId }: UserRoomProfileProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const { navigateRoom } = useRoomNavigate(); + const alive = useAlive(); + const closeUserRoomProfile = useCloseUserRoomProfile(); + const ignoredUsers = useIgnoredUsers(); + const ignored = ignoredUsers.includes(userId); + + const room = useRoom(); + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const { hasMorePower } = useMemberPowerCompare(creators, powerLevels); + + const myUserId = mx.getSafeUserId(); + const creator = creators.has(userId); + + const canKickUser = permissions.action('kick', myUserId) && hasMorePower(myUserId, userId); + const canBanUser = permissions.action('ban', myUserId) && hasMorePower(myUserId, userId); + const canUnban = permissions.action('ban', myUserId); + const canInvite = permissions.action('invite', myUserId); + + const member = room.getMember(userId); + const membership = useMembership(room, userId); + + const server = getMxIdServer(userId); + const displayName = getMemberDisplayName(room, userId); + const avatarMxc = getMemberAvatarMxc(room, userId); + const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + + const presence = useUserPresence(userId); + + const [directMessageState, directMessage] = useAsyncCallback( + useCallback(async () => { + const result = await createDM(mx, userId, await hasDevices(mx, userId)); + return result.room_id as string; + }, [userId, mx]) + ); + + const handleMessage = () => { + const dmRoomId = getDMRoomFor(mx, userId)?.roomId; + if (dmRoomId) { + navigateRoom(dmRoomId); + closeUserRoomProfile(); + return; + } + directMessage().then((rId) => { + if (alive()) { + navigateRoom(rId); + closeUserRoomProfile(); + } + }); + }; + + return ( + + + + + + + + + + + {directMessageState.status === AsyncStatus.Error && ( + + {directMessageState.error.message} + + )} + + {server && } + + {creator ? : } + + + + + {ignored && } + {member && membership === Membership.Ban && ( + + )} + {member && + membership === Membership.Leave && + member.events.member && + member.events.member.getSender() !== userId && ( + + )} + {member && membership === Membership.Invite && ( + + )} + + + + ); +} diff --git a/src/app/components/user-profile/index.ts b/src/app/components/user-profile/index.ts new file mode 100644 index 00000000..54542c76 --- /dev/null +++ b/src/app/components/user-profile/index.ts @@ -0,0 +1 @@ +export * from './UserRoomProfile'; diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts new file mode 100644 index 00000000..ad6d5a95 --- /dev/null +++ b/src/app/components/user-profile/styles.css.ts @@ -0,0 +1,42 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const UserHeader = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 1, + padding: config.space.S200, +}); + +export const UserHero = style({ + position: 'relative', +}); + +export const UserHeroCoverContainer = style({ + height: toRem(96), + overflow: 'hidden', +}); +export const UserHeroCover = style({ + height: '100%', + width: '100%', + objectFit: 'cover', + filter: 'blur(16px)', + transform: 'scale(2)', +}); + +export const UserHeroAvatarContainer = style({ + position: 'relative', + height: toRem(29), +}); +export const UserAvatarContainer = style({ + position: 'absolute', + left: config.space.S400, + top: 0, + transform: 'translateY(-50%)', + backgroundColor: color.Surface.Container, +}); +export const UserHeroAvatar = style({ + outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`, +}); diff --git a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx index 6ee19be9..0ea96901 100644 --- a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx +++ b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx @@ -27,8 +27,10 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { syntaxErrorPosition } from '../../../utils/dom'; import { SettingTile } from '../../../components/setting-tile'; import { SequenceCardStyle } from '../styles.css'; -import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; const EDITOR_INTENT_SPACE_COUNT = 2; @@ -244,8 +246,10 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi const stateEvent = useStateEvent(room, type as unknown as StateEvent, stateKey); const [editContent, setEditContent] = useState(); const powerLevels = usePowerLevels(room); - const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels); - const canEdit = canSendStateEvent(type, getPowerLevel(mx.getSafeUserId())); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canEdit = permissions.stateEvent(type, mx.getSafeUserId()); const eventJSONStr = useMemo(() => { if (!stateEvent) return ''; diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx index 56dda548..fdbe546e 100644 --- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx +++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx @@ -33,11 +33,13 @@ import { SequenceCardStyle } from '../styles.css'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { StateEvent } from '../../../../types/matrix/room'; import { suffixRename } from '../../../utils/common'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useAlive } from '../../../hooks/useAlive'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; type CreatePackTileProps = { packs: ImagePack[]; @@ -146,8 +148,10 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) { const alive = useAlive(); const powerLevels = usePowerLevels(room); - const { canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels); - const canEdit = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(mx.getSafeUserId())); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canEdit = permissions.stateEvent(StateEvent.PoniesRoomEmotes, mx.getSafeUserId()); const unfilteredPacks = useRoomImagePacks(room); const packs = useMemo(() => unfilteredPacks.filter((pack) => !pack.deleted), [unfilteredPacks]); diff --git a/src/app/features/common-settings/general/RoomAddress.tsx b/src/app/features/common-settings/general/RoomAddress.tsx index 9e1f1a97..400e73ae 100644 --- a/src/app/features/common-settings/general/RoomAddress.tsx +++ b/src/app/features/common-settings/general/RoomAddress.tsx @@ -15,7 +15,6 @@ import { toRem, } from 'folds'; import { MatrixError } from 'matrix-js-sdk'; -import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; import { SettingTile } from '../../../components/setting-tile'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; @@ -33,19 +32,19 @@ import { getIdServer } from '../../../../util/matrixUtil'; import { replaceSpaceWithDash } from '../../../utils/common'; import { useAlive } from '../../../hooks/useAlive'; import { StateEvent } from '../../../../types/matrix/room'; +import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; type RoomPublishedAddressesProps = { - powerLevels: IPowerLevels; + permissions: RoomPermissionsAPI; }; -export function RoomPublishedAddresses({ powerLevels }: RoomPublishedAddressesProps) { +export function RoomPublishedAddresses({ permissions }: RoomPublishedAddressesProps) { const mx = useMatrixClient(); const room = useRoom(); - const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); - const canEditCanonical = powerLevelAPI.canSendStateEvent( - powerLevels, + + const canEditCanonical = permissions.stateEvent( StateEvent.RoomCanonicalAlias, - userPowerLevel + mx.getSafeUserId() ); const [canonicalAlias, publishedAliases] = usePublishedAliases(room); @@ -360,14 +359,13 @@ function LocalAddressesList({ ); } -export function RoomLocalAddresses({ powerLevels }: { powerLevels: IPowerLevels }) { +export function RoomLocalAddresses({ permissions }: { permissions: RoomPermissionsAPI }) { const mx = useMatrixClient(); const room = useRoom(); - const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); - const canEditCanonical = powerLevelAPI.canSendStateEvent( - powerLevels, + + const canEditCanonical = permissions.stateEvent( StateEvent.RoomCanonicalAlias, - userPowerLevel + mx.getSafeUserId() ); const [expand, setExpand] = useState(false); diff --git a/src/app/features/common-settings/general/RoomEncryption.tsx b/src/app/features/common-settings/general/RoomEncryption.tsx index 1bb73399..15b1f156 100644 --- a/src/app/features/common-settings/general/RoomEncryption.tsx +++ b/src/app/features/common-settings/general/RoomEncryption.tsx @@ -21,28 +21,24 @@ import FocusTrap from 'focus-trap-react'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { StateEvent } from '../../../../types/matrix/room'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useRoom } from '../../../hooks/useRoom'; import { useStateEvent } from '../../../hooks/useStateEvent'; import { stopPropagation } from '../../../utils/keyboard'; +import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2'; type RoomEncryptionProps = { - powerLevels: IPowerLevels; + permissions: RoomPermissionsAPI; }; -export function RoomEncryption({ powerLevels }: RoomEncryptionProps) { +export function RoomEncryption({ permissions }: RoomEncryptionProps) { const mx = useMatrixClient(); const room = useRoom(); - const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); - const canEnable = powerLevelAPI.canSendStateEvent( - powerLevels, - StateEvent.RoomEncryption, - userPowerLevel - ); + + const canEnable = permissions.stateEvent(StateEvent.RoomEncryption, mx.getSafeUserId()); const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{ algorithm: string; }>(); diff --git a/src/app/features/common-settings/general/RoomHistoryVisibility.tsx b/src/app/features/common-settings/general/RoomHistoryVisibility.tsx index 7b329b13..2e42785f 100644 --- a/src/app/features/common-settings/general/RoomHistoryVisibility.tsx +++ b/src/app/features/common-settings/general/RoomHistoryVisibility.tsx @@ -18,13 +18,13 @@ import FocusTrap from 'focus-trap-react'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRoom } from '../../../hooks/useRoom'; import { StateEvent } from '../../../../types/matrix/room'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useStateEvent } from '../../../hooks/useStateEvent'; import { stopPropagation } from '../../../utils/keyboard'; +import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; const useVisibilityStr = () => useMemo( @@ -49,17 +49,13 @@ const useVisibilityMenu = () => ); type RoomHistoryVisibilityProps = { - powerLevels: IPowerLevels; + permissions: RoomPermissionsAPI; }; -export function RoomHistoryVisibility({ powerLevels }: RoomHistoryVisibilityProps) { +export function RoomHistoryVisibility({ permissions }: RoomHistoryVisibilityProps) { const mx = useMatrixClient(); const room = useRoom(); - const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); - const canEdit = powerLevelAPI.canSendStateEvent( - powerLevels, - StateEvent.RoomHistoryVisibility, - userPowerLevel - ); + + const canEdit = permissions.stateEvent(StateEvent.RoomHistoryVisibility, mx.getSafeUserId()); const visibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility); const historyVisibility: HistoryVisibility = diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx index f47ff757..b9e75499 100644 --- a/src/app/features/common-settings/general/RoomJoinRules.tsx +++ b/src/app/features/common-settings/general/RoomJoinRules.tsx @@ -3,7 +3,6 @@ import { color, Text } from 'folds'; import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { useAtomValue } from 'jotai'; -import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; import { ExtendedJoinRules, JoinRulesSwitcher, @@ -32,6 +31,7 @@ import { knockSupported, restrictedSupported, } from '../../../utils/matrix'; +import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; type RestrictedRoomAllowContent = { room_id: string; @@ -39,9 +39,9 @@ type RestrictedRoomAllowContent = { }; type RoomJoinRulesProps = { - powerLevels: IPowerLevels; + permissions: RoomPermissionsAPI; }; -export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { +export function RoomJoinRules({ permissions }: RoomJoinRulesProps) { const mx = useMatrixClient(); const room = useRoom(); const allowKnockRestricted = knockRestrictedSupported(room.getVersion()); @@ -53,12 +53,7 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { const subspacesScope = useRecursiveChildSpaceScopeFactory(mx, roomIdToParents); const subspaces = useSpaceChildren(allRoomsAtom, space?.roomId ?? '', subspacesScope); - const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); - const canEdit = powerLevelAPI.canSendStateEvent( - powerLevels, - StateEvent.RoomHistoryVisibility, - userPowerLevel - ); + const canEdit = permissions.stateEvent(StateEvent.RoomHistoryVisibility, mx.getSafeUserId()); const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules); const content = joinRuleEvent?.getContent(); diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index a3a62e1c..0f515c39 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -32,7 +32,6 @@ import { RoomAvatar, RoomIcon } from '../../../components/room-avatar'; import { mxcUrlToHttp } from '../../../utils/matrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { IPowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; import { StateEvent } from '../../../../types/matrix/room'; import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { useObjectURL } from '../../../hooks/useObjectURL'; @@ -40,6 +39,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { useFilePicker } from '../../../hooks/useFilePicker'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useAlive } from '../../../hooks/useAlive'; +import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; type RoomProfileEditProps = { canEditAvatar: boolean; @@ -261,24 +261,22 @@ export function RoomProfileEdit({ } type RoomProfileProps = { - powerLevels: IPowerLevels; + permissions: RoomPermissionsAPI; }; -export function RoomProfile({ powerLevels }: RoomProfileProps) { +export function RoomProfile({ permissions }: RoomProfileProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const room = useRoom(); const directs = useAtomValue(mDirectAtom); - const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels); - const userPowerLevel = getPowerLevel(mx.getSafeUserId()); const avatar = useRoomAvatar(room, directs.has(room.roomId)); const name = useRoomName(room); const topic = useRoomTopic(room); const joinRule = useRoomJoinRule(room); - const canEditAvatar = canSendStateEvent(StateEvent.RoomAvatar, userPowerLevel); - const canEditName = canSendStateEvent(StateEvent.RoomName, userPowerLevel); - const canEditTopic = canSendStateEvent(StateEvent.RoomTopic, userPowerLevel); + const canEditAvatar = permissions.stateEvent(StateEvent.RoomAvatar, mx.getSafeUserId()); + const canEditName = permissions.stateEvent(StateEvent.RoomName, mx.getSafeUserId()); + const canEditTopic = permissions.stateEvent(StateEvent.RoomTopic, mx.getSafeUserId()); const canEdit = canEditAvatar || canEditName || canEditTopic; const avatarUrl = avatar diff --git a/src/app/features/common-settings/general/RoomPublish.tsx b/src/app/features/common-settings/general/RoomPublish.tsx index 9edfe89b..ce014210 100644 --- a/src/app/features/common-settings/general/RoomPublish.tsx +++ b/src/app/features/common-settings/general/RoomPublish.tsx @@ -8,23 +8,22 @@ import { SettingTile } from '../../../components/setting-tile'; import { useRoom } from '../../../hooks/useRoom'; import { useRoomDirectoryVisibility } from '../../../hooks/useRoomDirectoryVisibility'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; import { StateEvent } from '../../../../types/matrix/room'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useStateEvent } from '../../../hooks/useStateEvent'; import { ExtendedJoinRules } from '../../../components/JoinRulesSwitcher'; +import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; type RoomPublishProps = { - powerLevels: IPowerLevels; + permissions: RoomPermissionsAPI; }; -export function RoomPublish({ powerLevels }: RoomPublishProps) { +export function RoomPublish({ permissions }: RoomPublishProps) { const mx = useMatrixClient(); const room = useRoom(); - const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); - const canEditCanonical = powerLevelAPI.canSendStateEvent( - powerLevels, + + const canEditCanonical = permissions.stateEvent( StateEvent.RoomCanonicalAlias, - userPowerLevel + mx.getSafeUserId() ); const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules); const content = joinRuleEvent?.getContent(); diff --git a/src/app/features/common-settings/general/RoomUpgrade.tsx b/src/app/features/common-settings/general/RoomUpgrade.tsx index 5d6bc5e2..45a480aa 100644 --- a/src/app/features/common-settings/general/RoomUpgrade.tsx +++ b/src/app/features/common-settings/general/RoomUpgrade.tsx @@ -1,4 +1,4 @@ -import React, { FormEventHandler, useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Button, color, @@ -14,54 +14,172 @@ import { IconButton, Icon, Icons, - Input, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { MatrixError } from 'matrix-js-sdk'; -import { RoomCreateEventContent, RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types'; +import { MatrixError, Method } from 'matrix-js-sdk'; +import { RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; import { SettingTile } from '../../../components/setting-tile'; import { useRoom } from '../../../hooks/useRoom'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; -import { StateEvent } from '../../../../types/matrix/room'; +import { IRoomCreateContent, StateEvent } from '../../../../types/matrix/room'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useStateEvent } from '../../../hooks/useStateEvent'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useCapabilities } from '../../../hooks/useCapabilities'; import { stopPropagation } from '../../../utils/keyboard'; +import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; +import { + AdditionalCreatorInput, + RoomVersionSelector, + useAdditionalCreators, +} from '../../../components/create-room'; +import { useAlive } from '../../../hooks/useAlive'; +import { creatorsSupported } from '../../../utils/matrix'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { BreakWord } from '../../../styles/Text.css'; + +function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { + const mx = useMatrixClient(); + const room = useRoom(); + const alive = useAlive(); + const creators = useRoomCreators(room); + + const capabilities = useCapabilities(); + const roomVersions = capabilities['m.room_versions']; + const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1'); + useEffect(() => { + // capabilities load async + selectRoomVersion(roomVersions?.default ?? '1'); + }, [roomVersions?.default]); + + const allowAdditionalCreators = creatorsSupported(selectedRoomVersion); + const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } = + useAdditionalCreators(Array.from(creators)); + + const [upgradeState, upgrade] = useAsyncCallback( + useCallback( + async (version: string, newAdditionalCreators?: string[]) => { + await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, { + new_version: version, + additional_creators: newAdditionalCreators, + }); + }, + [mx, room] + ) + ); + + const upgrading = upgradeState.status === AsyncStatus.Loading; + + const handleUpgradeRoom = () => { + const version = selectedRoomVersion; + + upgrade(version, allowAdditionalCreators ? additionalCreators : undefined).then(() => { + if (alive()) { + requestClose(); + } + }); + }; + + return ( + }> + + + +
+ + {room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'} + + + + +
+ + + This action is irreversible! + + + Options + + {allowAdditionalCreators && ( + + + + )} + + {upgradeState.status === AsyncStatus.Error && ( + + {(upgradeState.error as MatrixError).message} + + )} + + +
+
+
+
+ ); +} type RoomUpgradeProps = { - powerLevels: IPowerLevels; + permissions: RoomPermissionsAPI; requestClose: () => void; }; -export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) { +export function RoomUpgrade({ permissions, requestClose }: RoomUpgradeProps) { const mx = useMatrixClient(); const room = useRoom(); const { navigateRoom, navigateSpace } = useRoomNavigate(); const createContent = useStateEvent( room, StateEvent.RoomCreate - )?.getContent(); - const roomVersion = createContent?.room_version ?? 1; + )?.getContent(); + const roomVersion = createContent?.room_version ?? '1'; const predecessorRoomId = createContent?.predecessor?.room_id; - const capabilities = useCapabilities(); - const defaultRoomVersion = capabilities['m.room_versions']?.default; - const tombstoneContent = useStateEvent( room, StateEvent.RoomTombstone )?.getContent(); const replacementRoom = tombstoneContent?.replacement_room; - const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); - const canUpgrade = powerLevelAPI.canSendStateEvent( - powerLevels, - StateEvent.RoomTombstone, - userPowerLevel - ); + const canUpgrade = permissions.stateEvent(StateEvent.RoomTombstone, mx.getSafeUserId()); const handleOpenRoom = () => { if (replacementRoom) { @@ -85,31 +203,8 @@ export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) { } }; - const [upgradeState, upgrade] = useAsyncCallback( - useCallback( - async (version: string) => { - await mx.upgradeRoom(room.roomId, version); - }, - [mx, room] - ) - ); - - const upgrading = upgradeState.status === AsyncStatus.Loading; - const [prompt, setPrompt] = useState(false); - const handleSubmitUpgrade: FormEventHandler = (evt) => { - evt.preventDefault(); - - const target = evt.target as HTMLFormElement | undefined; - const versionInput = target?.versionInput as HTMLInputElement | undefined; - const version = versionInput?.value.trim(); - if (!version) return; - - upgrade(version); - setPrompt(false); - }; - return ( @@ -155,8 +250,7 @@ export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) { variant="Secondary" fill="Solid" radii="300" - disabled={upgrading || !canUpgrade} - before={upgrading && } + disabled={!canUpgrade} onClick={() => setPrompt(true)} > Upgrade @@ -165,63 +259,7 @@ export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) { } > - {upgradeState.status === AsyncStatus.Error && ( - - {(upgradeState.error as MatrixError).message} - - )} - - {prompt && ( - }> - - setPrompt(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
- - {room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'} - - setPrompt(false)} radii="300"> - - -
- - - This action is irreversible! - - - Version - - - - -
-
-
-
- )} + {prompt && setPrompt(false)} />}
); diff --git a/src/app/features/common-settings/members/Members.tsx b/src/app/features/common-settings/members/Members.tsx index 8d7f89fd..156f4f63 100644 --- a/src/app/features/common-settings/members/Members.tsx +++ b/src/app/features/common-settings/members/Members.tsx @@ -27,17 +27,12 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { useRoom } from '../../../hooks/useRoom'; import { useRoomMembers } from '../../../hooks/useRoomMembers'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; -import { - useFlattenPowerLevelTagMembers, - usePowerLevelTags, -} from '../../../hooks/usePowerLevelTags'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { VirtualTile } from '../../../components/virtualizer'; import { MemberTile } from '../../../components/member-tile'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix'; import { ServerBadge } from '../../../components/server-badge'; -import { openProfileViewer } from '../../../../client/action/navigation'; import { useDebounce } from '../../../hooks/useDebounce'; import { SearchItemStrGetter, @@ -46,13 +41,20 @@ import { } from '../../../hooks/useAsyncSearch'; import { getMemberSearchStr } from '../../../utils/room'; import { useMembershipFilter, useMembershipFilterMenu } from '../../../hooks/useMemberFilter'; -import { useMemberSort, useMemberSortMenu } from '../../../hooks/useMemberSort'; +import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../../hooks/useMemberSort'; import { settingsAtom } from '../../../state/settings'; import { useSetting } from '../../../state/hooks/settings'; import { UseStateProvider } from '../../../components/UseStateProvider'; import { MembershipFilterMenu } from '../../../components/MembershipFilterMenu'; import { MemberSortMenu } from '../../../components/MemberSortMenu'; import { ScrollTopContainer } from '../../../components/scroll-top-container'; +import { + useOpenUserRoomProfile, + useUserRoomProfileState, +} from '../../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../../hooks/useSpace'; +import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -77,15 +79,19 @@ export function Members({ requestClose }: MembersProps) { const room = useRoom(); const members = useRoomMembers(mx, room.roomId); const fetchingMembers = members.length < room.getJoinedMemberCount(); + const openProfile = useOpenUserRoomProfile(); + const profileUser = useUserRoomProfileState(); + const space = useSpaceOptionally(); const powerLevels = usePowerLevels(room); - const { getPowerLevel } = usePowerLevelsAPI(powerLevels); - const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels); + const creators = useRoomCreators(room); + const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels); const [membershipFilterIndex, setMembershipFilterIndex] = useState(0); const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex'); const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu()); const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu()); + const memberPowerSort = useMemberPowerSort(creators); const scrollRef = useRef(null); const searchInputRef = useRef(null); @@ -96,8 +102,8 @@ export function Members({ requestClose }: MembersProps) { Array.from(members) .filter(membershipFilter.filterFn) .sort(memberSort.sortFn) - .sort((a, b) => b.powerLevel - a.powerLevel), - [members, membershipFilter, memberSort] + .sort(memberPowerSort), + [members, membershipFilter, memberSort, memberPowerSort] ); const [result, search, resetSearch] = useAsyncSearch( @@ -107,11 +113,7 @@ export function Members({ requestClose }: MembersProps) { ); if (!result && searchInputRef.current?.value) search(searchInputRef.current.value); - const flattenTagMembers = useFlattenPowerLevelTagMembers( - result?.items ?? sortedMembers, - getPowerLevel, - getPowerLevelTag - ); + const flattenTagMembers = useFlattenPowerTagMembers(result?.items ?? sortedMembers, getPowerTag); const virtualizer = useVirtualizer({ count: flattenTagMembers.length, @@ -142,8 +144,9 @@ export function Members({ requestClose }: MembersProps) { const handleMemberClick: MouseEventHandler = (evt) => { const btn = evt.currentTarget as HTMLButtonElement; const userId = btn.getAttribute('data-user-id'); - openProfileViewer(userId, room.roomId); - requestClose(); + if (userId) { + openProfile(room.roomId, space?.roomId, userId, btn.getBoundingClientRect()); + } }; return ( @@ -317,6 +320,7 @@ export function Members({ requestClose }: MembersProps) { Math.max(...getPowers(powerLevelTags)), [powerLevelTags]); const [permissionUpdate, setPermissionUpdate] = useState>( @@ -82,6 +82,7 @@ export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGr permissionUpdate.forEach((power, location) => applyPermissionPower(draftPowerLevels, location, power) ); + return draftPowerLevels; }); await mx.sendStateEvent(room.roomId, StateEvent.RoomPowerLevels as any, editedPowerLevels); @@ -108,7 +109,7 @@ export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGr const powerUpdate = permissionUpdate.get(USER_DEFAULT_LOCATION); const value = powerUpdate ?? power; - const tag = getPowerLevelTag(value); + const tag = getPowerLevelTag(powerLevelTags, value); const powerChanges = value !== power; return ( @@ -136,14 +137,14 @@ export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGr fill="Soft" radii="Pill" aria-selected={opened} - disabled={!canChangePermission || applyingChanges} + disabled={!canEdit || applyingChanges} after={ powerChanges && ( ) } before={ - canChangePermission && ( + canEdit && ( ) } @@ -173,7 +174,7 @@ export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGr const powerUpdate = permissionUpdate.get(item.location); const value = powerUpdate ?? power; - const tag = getPowerLevelTag(value); + const tag = getPowerLevelTag(powerLevelTags, value); const powerChanges = value !== power; return ( @@ -200,14 +201,14 @@ export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGr fill="Soft" radii="Pill" aria-selected={opened} - disabled={!canChangePermission || applyingChanges} + disabled={!canEdit || applyingChanges} after={ powerChanges && ( ) } before={ - canChangePermission && ( + canEdit && ( + {creators.size > 0 && ( + + + + + + } + after={creatorTagIconSrc && } + > + + {creatorsTag.name} + + + + + + )} {getPowers(powerLevelTags).map((power) => { const tag = powerLevelTags[power]; - const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon); + const tagIconSrc = tag.icon && getPowerTagIconSrc(mx, useAuthentication, tag.icon); return ( void; + tag?: MemberPowerTag; + onSave: (power: number, tag: MemberPowerTag) => void; onClose: () => void; }; function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) { @@ -63,6 +62,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) { const room = useRoom(); const roomToParents = useAtomValue(roomToParentsAtom); const useAuthentication = useMediaAuthentication(); + const supportCreators = creatorsSupported(room.getVersion()); const imagePackRooms = useImagePackRooms(room.roomId, roomToParents); @@ -70,9 +70,9 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) { const pickFile = useFilePicker(setIconFile, false); const [tagColor, setTagColor] = useState(tag?.color); - const [tagIcon, setTagIcon] = useState(tag?.icon); + const [tagIcon, setTagIcon] = useState(tag?.icon); const uploadingIcon = iconFile && !tagIcon; - const tagIconSrc = tagIcon && getTagIconSrc(mx, useAuthentication, tagIcon); + const tagIconSrc = tagIcon && getPowerTagIconSrc(mx, useAuthentication, tagIcon); const iconUploadAtom = useMemo(() => { if (iconFile) return createUploadAtom(iconFile); @@ -101,11 +101,11 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) { const tagPower = parseInt(powerInput.value, 10); if (Number.isNaN(tagPower)) return; - if (tagPower > maxPower) return; + const tagName = nameInput.value.trim(); if (!tagName) return; - const editedTag: PowerLevelTag = { + const editedTag: MemberPowerTag = { name: tagName, color: tagColor, icon: tagIcon, @@ -165,7 +165,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) { radii="300" type="number" placeholder="75" - max={maxPower} + max={supportCreators ? undefined : maxPower} outlined={typeof power === 'number'} readOnly={typeof power === 'number'} required @@ -298,7 +298,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) { return [up, Math.max(...Array.from(up))]; }, [powerLevels]); - const [powerLevelTags] = usePowerLevelTags(room, powerLevels); + const powerLevelTags = usePowerLevelTags(room, powerLevels); const [editedPowerTags, setEditedPowerTags] = useState(); const [deleted, setDeleted] = useState>(new Set()); @@ -317,7 +317,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) { }, []); const handleSaveTag = useCallback( - (power: number, tag: PowerLevelTag) => { + (power: number, tag: MemberPowerTag) => { setEditedPowerTags((tags) => { const editedTags = { ...(tags ?? powerLevelTags) }; editedTags[power] = tag; @@ -419,7 +419,8 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) { {getPowers(powerTags).map((power) => { const tag = powerTags[power]; - const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon); + const tagIconSrc = + tag.icon && getPowerTagIconSrc(mx, useAuthentication, tag.icon); return ( { @@ -50,12 +57,19 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP const capabilities = useCapabilities(); const roomVersions = capabilities['m.room_versions']; const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1'); + useEffect(() => { + // capabilities load async + selectRoomVersion(roomVersions?.default ?? '1'); + }, [roomVersions?.default]); const allowRestricted = space && restrictedSupported(selectedRoomVersion); const [kind, setKind] = useState( defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private ); + const allowAdditionalCreators = creatorsSupported(selectedRoomVersion); + const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } = + useAdditionalCreators(); const [federation, setFederation] = useState(true); const [encryption, setEncryption] = useState(false); const [knock, setKnock] = useState(false); @@ -112,6 +126,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP encryption: publicRoom ? false : encryption, knock: roomKnock, allowFederation: federation, + additionalCreators: allowAdditionalCreators ? additionalCreators : undefined, }).then((roomId) => { if (alive()) { onCreate?.(roomId); @@ -172,6 +187,20 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP + {allowAdditionalCreators && ( + + + + )} {kind !== CreateRoomKind.Public && ( <> { + // capabilities load async + selectRoomVersion(roomVersions?.default ?? '1'); + }, [roomVersions?.default]); const allowRestricted = space && restrictedSupported(selectedRoomVersion); const [kind, setKind] = useState( defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private ); + + const allowAdditionalCreators = creatorsSupported(selectedRoomVersion); + const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } = + useAdditionalCreators(); const [federation, setFederation] = useState(true); const [knock, setKnock] = useState(false); const [advance, setAdvance] = useState(false); @@ -112,6 +127,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor aliasLocalPart: publicRoom ? aliasLocalPart : undefined, knock: roomKnock, allowFederation: federation, + additionalCreators: allowAdditionalCreators ? additionalCreators : undefined, }).then((roomId) => { if (alive()) { onCreate?.(roomId); @@ -172,6 +188,20 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor + {allowAdditionalCreators && ( + + + + )} {kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && ( { const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested }; - return mx.sendStateEvent(parentId, StateEvent.SpaceChild, newContent, roomId); + return mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, newContent, roomId); }, [mx, parentId, roomId, content]) ); @@ -82,7 +85,7 @@ function RemoveMenuItem({ const [removeState, handleRemove] = useAsyncCallback( useCallback( - () => mx.sendStateEvent(parentId, StateEvent.SpaceChild, {}, roomId), + () => mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId), [mx, parentId, roomId] ) ); @@ -180,7 +183,7 @@ type HierarchyItemMenuProps = { parentId: string; }; joined: boolean; - canInvite: boolean; + powerLevels?: IPowerLevels; canEditChild: boolean; pinned?: boolean; onTogglePin?: (roomId: string) => void; @@ -188,13 +191,22 @@ type HierarchyItemMenuProps = { export function HierarchyItemMenu({ item, joined, - canInvite, + powerLevels, canEditChild, pinned, onTogglePin, }: HierarchyItemMenuProps) { + const mx = useMatrixClient(); const [menuAnchor, setMenuAnchor] = useState(); + const canInvite = (): boolean => { + if (!powerLevels) return false; + const creators = getRoomCreatorsForRoomId(mx, item.roomId); + const permissions = getRoomPermissionsAPI(creators, powerLevels); + + return permissions.action('invite', mx.getSafeUserId()); + }; + const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; @@ -254,7 +266,7 @@ export function HierarchyItemMenu({ diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 45610ff3..4b19e516 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -27,7 +27,6 @@ import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { IPowerLevels, PowerLevelsContextProvider, - powerLevelAPI, usePowerLevels, useRoomsPowerLevels, } from '../../hooks/usePowerLevels'; @@ -55,12 +54,13 @@ import { useRoomMembers } from '../../hooks/useRoomMembers'; import { SpaceHierarchy } from './SpaceHierarchy'; import { useGetRoom } from '../../hooks/useGetRoom'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; +import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; const useCanDropLobbyItem = ( space: Room, roomsPowerLevels: Map, - getRoom: (roomId: string) => Room | undefined, - canEditSpaceChild: (powerLevels: IPowerLevels) => boolean + getRoom: (roomId: string) => Room | undefined ): CanDropCallback => { const mx = useMatrixClient(); @@ -74,16 +74,20 @@ const useCanDropLobbyItem = ( const containerSpaceId = space.roomId; + const powerLevels = roomsPowerLevels.get(containerSpaceId) ?? {}; + const creators = getRoomCreatorsForRoomId(mx, containerSpaceId); + const permissions = getRoomPermissionsAPI(creators, powerLevels); + if ( getRoom(containerSpaceId) === undefined || - !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {}) + !permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) ) { return false; } return true; }, - [space, roomsPowerLevels, getRoom, canEditSpaceChild] + [space, roomsPowerLevels, getRoom, mx] ); const canDropRoom: CanDropCallback = useCallback( @@ -97,30 +101,31 @@ const useCanDropLobbyItem = ( // check and do not allow restricted room to be dragged outside // current space if can't change `m.room.join_rules` `content.allow` if (draggingOutsideSpace && restrictedItem) { - const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {}; - const userPLInItem = powerLevelAPI.getPowerLevel( - itemPowerLevel, - mx.getUserId() ?? undefined - ); - const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent( - itemPowerLevel, + const itemPowerLevels = roomsPowerLevels.get(item.roomId) ?? {}; + const itemCreators = getRoomCreatorsForRoomId(mx, item.roomId); + const itemPermissions = getRoomPermissionsAPI(itemCreators, itemPowerLevels); + + const canChangeJoinRuleAllow = itemPermissions.stateEvent( StateEvent.RoomJoinRules, - userPLInItem + mx.getSafeUserId() ); if (!canChangeJoinRuleAllow) { return false; } } + const powerLevels = roomsPowerLevels.get(containerSpaceId) ?? {}; + const creators = getRoomCreatorsForRoomId(mx, containerSpaceId); + const permissions = getRoomPermissionsAPI(creators, powerLevels); if ( getRoom(containerSpaceId) === undefined || - !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {}) + !permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) ) { return false; } return true; }, - [mx, getRoom, canEditSpaceChild, roomsPowerLevels] + [mx, getRoom, roomsPowerLevels] ); const canDrop: CanDropCallback = useCallback( @@ -183,16 +188,6 @@ export function Lobby() { const getRoom = useGetRoom(allJoinedRooms); - const canEditSpaceChild = useCallback( - (powerLevels: IPowerLevels) => - powerLevelAPI.canSendStateEvent( - powerLevels, - StateEvent.SpaceChild, - powerLevelAPI.getPowerLevel(powerLevels, mx.getUserId() ?? undefined) - ), - [mx] - ); - const [draggingItem, setDraggingItem] = useState(); const hierarchy = useSpaceHierarchy( space.roomId, @@ -229,12 +224,7 @@ export function Lobby() { ) ); - const canDrop: CanDropCallback = useCanDropLobbyItem( - space, - roomsPowerLevels, - getRoom, - canEditSpaceChild - ); + const canDrop: CanDropCallback = useCanDropLobbyItem(space, roomsPowerLevels, getRoom); const [reorderSpaceState, reorderSpace] = useAsyncCallback( useCallback( @@ -270,7 +260,11 @@ export function Lobby() { .filter((reorder, index) => { if (!reorder.item.parentId) return false; const parentPL = roomsPowerLevels.get(reorder.item.parentId); - const canEdit = parentPL && canEditSpaceChild(parentPL); + if (!parentPL) return false; + + const creators = getRoomCreatorsForRoomId(mx, reorder.item.parentId); + const permissions = getRoomPermissionsAPI(creators, parentPL); + const canEdit = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()); return canEdit && reorder.orderKey !== currentOrders[index]; }); @@ -286,7 +280,7 @@ export function Lobby() { }); } }, - [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild] + [mx, hierarchy, lex, roomsPowerLevels] ) ); const reorderingSpace = reorderSpaceState.status === AsyncStatus.Loading; @@ -428,7 +422,7 @@ export function Lobby() { newItems.push(rId); } const newSpacesContent = makeCinnySpacesContent(mx, newItems); - mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); + mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any); }, [mx, sidebarItems, sidebarSpaces] ); @@ -493,7 +487,6 @@ export function Lobby() { allJoinedRooms={allJoinedRooms} mDirects={mDirects} roomsPowerLevels={roomsPowerLevels} - canEditSpaceChild={canEditSpaceChild} categoryId={categoryId} closed={ closedCategories.has(categoryId) || diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index bc4c46fe..77287123 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -27,7 +27,7 @@ import { RoomAvatar } from '../../components/room-avatar'; import { nameInitials } from '../../utils/common'; import * as css from './LobbyHeader.css'; import { openInviteUser } from '../../../client/action/navigation'; -import { IPowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels'; +import { IPowerLevels } from '../../hooks/usePowerLevels'; import { UseStateProvider } from '../../components/UseStateProvider'; import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; import { stopPropagation } from '../../utils/keyboard'; @@ -36,26 +36,30 @@ import { BackRouteHandler } from '../../components/BackRouteHandler'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; type LobbyMenuProps = { - roomId: string; powerLevels: IPowerLevels; requestClose: () => void; }; const LobbyMenu = forwardRef( - ({ roomId, powerLevels, requestClose }, ref) => { + ({ powerLevels, requestClose }, ref) => { const mx = useMatrixClient(); - const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); - const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); + const space = useSpace(); + const creators = useRoomCreators(space); + + const permissions = useRoomPermissions(creators, powerLevels); + const canInvite = permissions.action('invite', mx.getSafeUserId()); const openSpaceSettings = useOpenSpaceSettings(); const handleInvite = () => { - openInviteUser(roomId); + openInviteUser(space.roomId); requestClose(); }; const handleRoomSettings = () => { - openSpaceSettings(roomId); + openSpaceSettings(space.roomId); requestClose(); }; @@ -106,7 +110,7 @@ const LobbyMenu = forwardRef( {promptLeave && ( setPromptLeave(false)} /> @@ -242,7 +246,6 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { }} > setMenuAnchor(undefined)} /> diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index a152bc19..280b8a5a 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -8,14 +8,16 @@ import { HierarchyItemSpace, useFetchSpaceHierarchyLevel, } from '../../hooks/useSpaceHierarchy'; -import { IPowerLevels, powerLevelAPI } from '../../hooks/usePowerLevels'; +import { IPowerLevels } from '../../hooks/usePowerLevels'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { SpaceItemCard } from './SpaceItem'; import { AfterItemDropTarget, CanDropCallback } from './DnD'; import { HierarchyItemMenu } from './HierarchyItemMenu'; import { RoomItemCard } from './RoomItem'; -import { RoomType } from '../../../types/matrix/room'; +import { RoomType, StateEvent } from '../../../types/matrix/room'; import { SequenceCard } from '../../components/sequence-card'; +import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; +import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; type SpaceHierarchyProps = { summary: IHierarchyRoom | undefined; @@ -24,7 +26,6 @@ type SpaceHierarchyProps = { allJoinedRooms: Set; mDirects: Set; roomsPowerLevels: Map; - canEditSpaceChild: (powerLevels: IPowerLevels) => boolean; categoryId: string; closed: boolean; handleClose: MouseEventHandler; @@ -48,7 +49,6 @@ export const SpaceHierarchy = forwardRef( allJoinedRooms, mDirects, roomsPowerLevels, - canEditSpaceChild, categoryId, closed, handleClose, @@ -79,25 +79,28 @@ export const SpaceHierarchy = forwardRef( return s; }, [rooms]); - const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId) ?? {}; - const userPLInSpace = powerLevelAPI.getPowerLevel( - spacePowerLevels, - mx.getUserId() ?? undefined - ); - const canInviteInSpace = powerLevelAPI.canDoAction(spacePowerLevels, 'invite', userPLInSpace); + const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId); + const spaceCreators = getRoomCreatorsForRoomId(mx, spaceItem.roomId); + const spacePermissions = + spacePowerLevels && getRoomPermissionsAPI(spaceCreators, spacePowerLevels); const draggingSpace = draggingItem?.roomId === spaceItem.roomId && draggingItem.parentId === spaceItem.parentId; const { parentId } = spaceItem; - const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) ?? {} : undefined; + const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) : undefined; + const parentCreators = parentId ? getRoomCreatorsForRoomId(mx, parentId) : undefined; + const parentPermissions = + parentCreators && + parentPowerLevels && + getRoomPermissionsAPI(parentCreators, parentPowerLevels); useEffect(() => { onSpacesFound(Array.from(subspaces.values())); }, [subspaces, onSpacesFound]); let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId)); - if (!canEditSpaceChild(spacePowerLevels)) { + if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())) { // hide unknown rooms for normal user childItems = childItems?.filter((i) => { const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false; @@ -117,18 +120,22 @@ export const SpaceHierarchy = forwardRef( closed={closed} handleClose={handleClose} getRoom={getRoom} - canEditChild={canEditSpaceChild(spacePowerLevels)} + canEditChild={!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())} canReorder={ - parentPowerLevels && !disabledReorder ? canEditSpaceChild(parentPowerLevels) : false + parentPowerLevels && !disabledReorder && parentPermissions + ? parentPermissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) + : false } options={ parentId && parentPowerLevels && ( @@ -151,15 +158,6 @@ export const SpaceHierarchy = forwardRef( const roomSummary = rooms.get(roomItem.roomId); const roomPowerLevels = roomsPowerLevels.get(roomItem.roomId) ?? {}; - const userPLInRoom = powerLevelAPI.getPowerLevel( - roomPowerLevels, - mx.getUserId() ?? undefined - ); - const canInviteInRoom = powerLevelAPI.canDoAction( - roomPowerLevels, - 'invite', - userPLInRoom - ); const lastItem = index === childItems.length; const nextRoomId = lastItem ? nextSpaceId : childItems[index + 1]?.roomId; @@ -178,13 +176,18 @@ export const SpaceHierarchy = forwardRef( dm={mDirects.has(roomItem.roomId)} onOpen={onOpenRoom} getRoom={getRoom} - canReorder={canEditSpaceChild(spacePowerLevels) && !disabledReorder} + canReorder={ + !!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) && + !disabledReorder + } options={ } after={ diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx index bc94092b..62ef9c4b 100644 --- a/src/app/features/message-search/SearchResultGroup.tsx +++ b/src/app/features/message-search/SearchResultGroup.tsx @@ -39,15 +39,18 @@ import { UserAvatar } from '../../components/user-avatar'; import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels'; -import { - getTagIconSrc, - useAccessibleTagColors, - usePowerLevelTags, -} from '../../hooks/usePowerLevelTags'; +import { usePowerLevels } from '../../hooks/usePowerLevels'; +import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useTheme } from '../../hooks/useTheme'; import { PowerIcon } from '../../components/power'; import colorMXID from '../../../util/colorMXID'; +import { + getPowerTagIconSrc, + useAccessiblePowerTagColors, + useGetMemberPowerTag, +} from '../../hooks/useMemberPowerTag'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; type SearchResultGroupProps = { room: Room; @@ -76,10 +79,14 @@ export function SearchResultGroup({ const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]); const powerLevels = usePowerLevels(room); - const { getPowerLevel } = usePowerLevelsAPI(powerLevels); - const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels); + const creators = useRoomCreators(room); + + const creatorsTag = useRoomCreatorsTag(); + const powerLevelTags = usePowerLevelTags(room, powerLevels); + const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + const theme = useTheme(); - const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags); + const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags); const mentionClickHandler = useMentionClickHandler(room.roomId); const spoilerClickHandler = useSpoilerClickHandler(); @@ -226,13 +233,12 @@ export function SearchResultGroup({ const threadRootId = relation?.rel_type === RelationType.Thread ? relation.event_id : undefined; - const senderPowerLevel = getPowerLevel(event.sender); - const powerLevelTag = getPowerLevelTag(senderPowerLevel); - const tagColor = powerLevelTag?.color - ? accessibleTagColors?.get(powerLevelTag.color) + const memberPowerTag = getMemberPowerTag(event.sender); + const tagColor = memberPowerTag?.color + ? accessibleTagColors?.get(memberPowerTag.color) : undefined; - const tagIconSrc = powerLevelTag?.icon - ? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon) + const tagIconSrc = memberPowerTag?.icon + ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) : undefined; const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor; @@ -302,8 +308,7 @@ export function SearchResultGroup({ replyEventId={replyEventId} threadRootId={threadRootId} onClick={handleOpenClick} - getPowerLevel={getPowerLevel} - getPowerLevelTag={getPowerLevelTag} + getMemberPowerTag={getMemberPowerTag} accessibleTagColors={accessibleTagColors} legacyUsernameColor={legacyUsernameColor} /> diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index bdb81418..ee8b6787 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -27,7 +27,7 @@ import { nameInitials } from '../../utils/common'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomUnread } from '../../state/hooks/unread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; -import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels'; +import { usePowerLevels } from '../../hooks/usePowerLevels'; import { copyToClipboard } from '../../utils/dom'; import { markAsRead } from '../../../client/action/notifications'; import { openInviteUser } from '../../../client/action/navigation'; @@ -49,6 +49,8 @@ import { RoomNotificationMode, } from '../../hooks/useRoomsNotificationPreferences'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; type RoomNavItemMenuProps = { room: Room; @@ -61,8 +63,10 @@ const RoomNavItemMenu = forwardRef( const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const powerLevels = usePowerLevels(room); - const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); - const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canInvite = permissions.action('invite', mx.getSafeUserId()); const openRoomSettings = useOpenRoomSettings(); const space = useSpaceOptionally(); diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx index 0c3152c0..d9c16c90 100644 --- a/src/app/features/room-settings/general/General.tsx +++ b/src/app/features/room-settings/general/General.tsx @@ -13,6 +13,8 @@ import { RoomPublish, RoomUpgrade, } from '../../common-settings/general'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; type GeneralProps = { requestClose: () => void; @@ -20,6 +22,8 @@ type GeneralProps = { export function General({ requestClose }: GeneralProps) { const room = useRoom(); const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); return ( @@ -41,22 +45,22 @@ export function General({ requestClose }: GeneralProps) { - + Options - - - - + + + + Addresses - - + + Advance Options - + diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx index ae3769bf..7572a71b 100644 --- a/src/app/features/room-settings/permissions/Permissions.tsx +++ b/src/app/features/room-settings/permissions/Permissions.tsx @@ -2,11 +2,13 @@ import React, { useState } from 'react'; import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { useRoom } from '../../../hooks/useRoom'; -import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { StateEvent } from '../../../../types/matrix/room'; import { usePermissionGroups } from './usePermissionItems'; import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; type PermissionsProps = { requestClose: () => void; @@ -15,11 +17,12 @@ export function Permissions({ requestClose }: PermissionsProps) { const mx = useMatrixClient(); const room = useRoom(); const powerLevels = usePowerLevels(room); - const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels); - const canEditPowers = canSendStateEvent( - StateEvent.PowerLevelTags, - getPowerLevel(mx.getSafeUserId()) - ); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + + const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId()); + const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId()); const permissionGroups = usePermissionGroups(); const [powerEditor, setPowerEditor] = useState(false); @@ -57,7 +60,11 @@ export function Permissions({ requestClose }: PermissionsProps) { onEdit={canEditPowers ? handleEditPowers : undefined} permissionGroups={permissionGroups} /> - + diff --git a/src/app/features/room/MembersDrawer.css.ts b/src/app/features/room/MembersDrawer.css.ts index a1f4153e..860ceda0 100644 --- a/src/app/features/room/MembersDrawer.css.ts +++ b/src/app/features/room/MembersDrawer.css.ts @@ -1,10 +1,8 @@ import { keyframes, style } from '@vanilla-extract/css'; -import { color, config, toRem } from 'folds'; +import { config, toRem } from 'folds'; export const MembersDrawer = style({ width: toRem(266), - backgroundColor: color.Background.Container, - color: color.Background.OnContainer, }); export const MembersDrawerHeader = style({ diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 5edb4f2b..46d2238a 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -26,11 +26,10 @@ import { TooltipProvider, config, } from 'folds'; -import { Room, RoomMember } from 'matrix-js-sdk'; +import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; import { useVirtualizer } from '@tanstack/react-virtual'; import classNames from 'classnames'; -import { openProfileViewer } from '../../../client/action/navigation'; import * as css from './MembersDrawer.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { UseStateProvider } from '../../components/UseStateProvider'; @@ -40,7 +39,6 @@ import { useAsyncSearch, } from '../../hooks/useAsyncSearch'; import { useDebounce } from '../../hooks/useDebounce'; -import { usePowerLevelTags, useFlattenPowerLevelTagMembers } from '../../hooks/usePowerLevelTags'; import { TypingIndicator } from '../../components/typing-indicator'; import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room'; import { getMxIdLocalPart } from '../../utils/matrix'; @@ -52,10 +50,116 @@ import { UserAvatar } from '../../components/user-avatar'; import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter'; -import { useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort'; -import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels'; +import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort'; +import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { MembershipFilterMenu } from '../../components/MembershipFilterMenu'; import { MemberSortMenu } from '../../components/MemberSortMenu'; +import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../hooks/useSpace'; +import { ContainerColor } from '../../styles/ContainerColor.css'; +import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; + +type MemberDrawerHeaderProps = { + room: Room; +}; +function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) { + const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); + + return ( +
+ + + + {`${millify(room.getJoinedMemberCount())} Members`} + + + + + Close + + } + > + {(triggerRef) => ( + setPeopleDrawer(false)} + > + + + )} + + + +
+ ); +} + +type MemberItemProps = { + mx: MatrixClient; + useAuthentication: boolean; + room: Room; + member: RoomMember; + onClick: MouseEventHandler; + pressed?: boolean; + typing?: boolean; +}; +function MemberItem({ + mx, + useAuthentication, + room, + member, + onClick, + pressed, + typing, +}: MemberItemProps) { + const name = + getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId; + const avatarMxcUrl = member.getMxcAvatarUrl(); + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication) + : undefined; + + return ( + + } + /> + + } + after={ + typing && ( + + + + ) + } + > + + + {name} + + + + ); +} const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -79,28 +183,28 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { const searchInputRef = useRef(null); const scrollTopAnchorRef = useRef(null); const powerLevels = usePowerLevelsContext(); - const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels); + const creators = useRoomCreators(room); + const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + const fetchingMembers = members.length < room.getJoinedMemberCount(); - const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); + const openUserRoomProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); + const openProfileUserId = useUserRoomProfileState()?.userId; const membershipFilterMenu = useMembershipFilterMenu(); const sortFilterMenu = useMemberSortMenu(); const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex'); const [membershipFilterIndex, setMembershipFilterIndex] = useState(0); - const { getPowerLevel } = usePowerLevelsAPI(powerLevels); const membershipFilter = useMembershipFilter(membershipFilterIndex, membershipFilterMenu); const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu); + const memberPowerSort = useMemberPowerSort(creators); const typingMembers = useRoomTypingMember(room.roomId); const filteredMembers = useMemo( - () => - members - .filter(membershipFilter.filterFn) - .sort(memberSort.sortFn) - .sort((a, b) => b.powerLevel - a.powerLevel), - [members, membershipFilter, memberSort] + () => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort), + [members, membershipFilter, memberSort, memberPowerSort] ); const [result, search, resetSearch] = useAsyncSearch( @@ -112,11 +216,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { const processMembers = result ? result.items : filteredMembers; - const PLTagOrRoomMember = useFlattenPowerLevelTagMembers( - processMembers, - getPowerLevel, - getPowerLevelTag - ); + const PLTagOrRoomMember = useFlattenPowerTagMembers(processMembers, getPowerTag); const virtualizer = useVirtualizer({ count: PLTagOrRoomMember.length, @@ -136,48 +236,20 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { { wait: 200 } ); - const getName = (member: RoomMember) => - getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId; - const handleMemberClick: MouseEventHandler = (evt) => { const btn = evt.currentTarget as HTMLButtonElement; const userId = btn.getAttribute('data-user-id'); - openProfileViewer(userId, room.roomId); + if (!userId) return; + openUserRoomProfile(room.roomId, space?.roomId, userId, btn.getBoundingClientRect(), 'Left'); }; return ( - -
- - - - {`${millify(room.getJoinedMemberCount())} Members`} - - - - - Close - - } - > - {(triggerRef) => ( - setPeopleDrawer(false)} - > - - - )} - - - -
+ + @@ -329,59 +401,28 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { ); } - const member = tagOrMember; - const name = getName(member); - const avatarMxcUrl = member.getMxcAvatarUrl(); - const avatarUrl = avatarMxcUrl - ? mx.mxcUrlToHttp( - avatarMxcUrl, - 100, - 100, - 'crop', - undefined, - false, - useAuthentication - ) - : undefined; - return ( - - } - /> - - } - after={ - typingMembers.find((receipt) => receipt.userId === member.userId) && ( - - - - ) - } + data-index={vItem.index} + key={`${room.roomId}-${tagOrMember.userId}`} + ref={virtualizer.measureElement} > - - - {name} - - - + receipt.userId === tagOrMember.userId + )} + /> + ); })} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 1399ec15..76bafc9e 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -108,21 +108,23 @@ import { ReplyLayout, ThreadIndicator } from '../../components/message'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; -import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags'; -import { powerLevelAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels'; +import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import colorMXID from '../../../util/colorMXID'; import { useIsDirectRoom } from '../../hooks/useRoom'; +import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useTheme } from '../../hooks/useTheme'; +import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; +import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; interface RoomInputProps { editor: Editor; fileDropContainerRef: RefObject; roomId: string; room: Room; - getPowerLevelTag: GetPowerLevelTag; - accessibleTagColors: Map; } export const RoomInput = forwardRef( - ({ editor, fileDropContainerRef, roomId, room, getPowerLevelTag, accessibleTagColors }, ref) => { + ({ editor, fileDropContainerRef, roomId, room }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); @@ -134,13 +136,24 @@ export const RoomInput = forwardRef( const emojiBtnRef = useRef(null); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevelsContext(); + const creators = useRoomCreators(room); const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); const replyUserID = replyDraft?.userId; - const replyPowerTag = getPowerLevelTag(powerLevelAPI.getPowerLevel(powerLevels, replyUserID)); - const replyPowerColor = replyPowerTag.color + const powerLevelTags = usePowerLevelTags(room, powerLevels); + const creatorsTag = useRoomCreatorsTag(); + const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + const theme = useTheme(); + const accessibleTagColors = useAccessiblePowerTagColors( + theme.kind, + creatorsTag, + powerLevelTags + ); + + const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined; + const replyPowerColor = replyPowerTag?.color ? accessibleTagColors.get(replyPowerTag.color) : undefined; const replyUsernameColor = @@ -277,7 +290,7 @@ export const RoomInput = forwardRef( }); handleCancelUpload(uploads); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); - contents.forEach((content) => mx.sendMessage(roomId, content)); + contents.forEach((content) => mx.sendMessage(roomId, content as any)); }; const submit = useCallback(() => { @@ -356,7 +369,7 @@ export const RoomInput = forwardRef( content['m.relates_to'].is_falling_back = false; } } - mx.sendMessage(roomId, content); + mx.sendMessage(roomId, content as any); resetEditor(editor); resetEditorHistory(editor); setReplyDraft(undefined); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 244eb327..7a012e42 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -85,7 +85,6 @@ import { } from '../../utils/room'; import { useSetting } from '../../state/hooks/settings'; import { MessageLayout, settingsAtom } from '../../state/settings'; -import { openProfileViewer } from '../../../client/action/navigation'; import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer'; import { Reactions, Message, Event, EncryptedContent } from './message'; import { useMemberEventParser } from '../../hooks/useMemberEventParser'; @@ -102,7 +101,7 @@ import * as css from './RoomTimeline.css'; import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time'; import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor'; import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; -import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels'; +import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; import { useKeyDown } from '../../hooks/useKeyDown'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; @@ -118,8 +117,15 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; -import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags'; import { useIsDirectRoom } from '../../hooks/useRoom'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../hooks/useSpace'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; +import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; +import { useTheme } from '../../hooks/useTheme'; +import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; +import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( @@ -222,8 +228,6 @@ type RoomTimelineProps = { eventId?: string; roomInputRef: RefObject; editor: Editor; - getPowerLevelTag: GetPowerLevelTag; - accessibleTagColors: Map; }; const PAGINATION_LIMIT = 80; @@ -426,14 +430,7 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => { }; }; -export function RoomTimeline({ - room, - eventId, - roomInputRef, - editor, - getPowerLevelTag, - accessibleTagColors, -}: RoomTimelineProps) { +export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); @@ -458,13 +455,24 @@ export function RoomTimeline({ const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); const powerLevels = usePowerLevelsContext(); - const { canDoAction, canSendEvent, canSendStateEvent, getPowerLevel } = - usePowerLevelsAPI(powerLevels); + const creators = useRoomCreators(room); - const myPowerLevel = getPowerLevel(mx.getUserId() ?? ''); - const canRedact = canDoAction('redact', myPowerLevel); - const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel); - const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, myPowerLevel); + const creatorsTag = useRoomCreatorsTag(); + const powerLevelTags = usePowerLevelTags(room, powerLevels); + const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + + const theme = useTheme(); + const accessiblePowerTagColors = useAccessiblePowerTagColors( + theme.kind, + creatorsTag, + powerLevelTags + ); + + const permissions = useRoomPermissions(creators, powerLevels); + + const canRedact = permissions.action('redact', mx.getSafeUserId()); + const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); + const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); const [editId, setEditId] = useState(); const roomToParents = useAtomValue(roomToParentsAtom); @@ -472,6 +480,8 @@ export function RoomTimeline({ const { navigateRoom } = useRoomNavigate(); const mentionClickHandler = useMentionClickHandler(room.roomId); const spoilerClickHandler = useSpoilerClickHandler(); + const openUserRoomProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); @@ -909,9 +919,14 @@ export function RoomTimeline({ console.warn('Button should have "data-user-id" attribute!'); return; } - openProfileViewer(userId, room.roomId); + openUserRoomProfile( + room.roomId, + space?.roomId, + userId, + evt.currentTarget.getBoundingClientRect() + ); }, - [room] + [room, space, openUserRoomProfile] ); const handleUsernameClick: MouseEventHandler = useCallback( (evt) => { @@ -982,7 +997,7 @@ export function RoomTimeline({ (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); mx.sendEvent( room.roomId, - MessageEvent.Reaction, + MessageEvent.Reaction as any, getReactionContent(targetEventId, key, rShortcode) ); }, @@ -1017,7 +1032,6 @@ export function RoomTimeline({ editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; const senderId = mEvent.getSender() ?? ''; - const senderPowerLevel = getPowerLevel(mEvent.getSender()); const senderDisplayName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; @@ -1051,9 +1065,8 @@ export function RoomTimeline({ replyEventId={replyEventId} threadRootId={threadRootId} onClick={handleOpenReply} - getPowerLevel={getPowerLevel} - getPowerLevelTag={getPowerLevelTag} - accessibleTagColors={accessibleTagColors} + getMemberPowerTag={getMemberPowerTag} + accessibleTagColors={accessiblePowerTagColors} legacyUsernameColor={legacyUsernameColor || direct} /> ) @@ -1072,8 +1085,8 @@ export function RoomTimeline({ } hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools} - powerLevelTag={getPowerLevelTag(senderPowerLevel)} - accessibleTagColors={accessibleTagColors} + memberPowerTag={getMemberPowerTag(senderId)} + accessibleTagColors={accessiblePowerTagColors} legacyUsernameColor={legacyUsernameColor || direct} hour24Clock={hour24Clock} dateFormatString={dateFormatString} @@ -1103,7 +1116,6 @@ export function RoomTimeline({ const hasReactions = reactions && reactions.length > 0; const { replyEventId, threadRootId } = mEvent; const highlighted = focusItem?.index === item && focusItem.highlight; - const senderPowerLevel = getPowerLevel(mEvent.getSender()); return ( ) @@ -1156,8 +1167,8 @@ export function RoomTimeline({ } hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools} - powerLevelTag={getPowerLevelTag(senderPowerLevel)} - accessibleTagColors={accessibleTagColors} + memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} + accessibleTagColors={accessiblePowerTagColors} legacyUsernameColor={legacyUsernameColor || direct} hour24Clock={hour24Clock} dateFormatString={dateFormatString} @@ -1224,7 +1235,6 @@ export function RoomTimeline({ const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const hasReactions = reactions && reactions.length > 0; const highlighted = focusItem?.index === item && focusItem.highlight; - const senderPowerLevel = getPowerLevel(mEvent.getSender()); return ( { @@ -70,15 +70,10 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone); const powerLevels = usePowerLevelsContext(); - const { getPowerLevel, canSendEvent } = usePowerLevelsAPI(powerLevels); - const myUserId = mx.getUserId(); - const canMessage = myUserId - ? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId)) - : false; + const creators = useRoomCreators(room); - const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels); - const theme = useTheme(); - const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags); + const permissions = useRoomPermissions(creators, powerLevels); + const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId()); useKeyDown( window, @@ -109,8 +104,6 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { eventId={eventId} roomInputRef={roomInputRef} editor={editor} - getPowerLevelTag={getPowerLevelTag} - accessibleTagColors={accessibleTagColors} /> @@ -131,8 +124,6 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { roomId={roomId} fileDropContainerRef={roomViewRef} ref={roomInputRef} - getPowerLevelTag={getPowerLevelTag} - accessibleTagColors={accessibleTagColors} /> )} {!canMessage && ( diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index d8e2e8b9..291c21c0 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -42,7 +42,7 @@ import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../util import { _SearchPathSearchParams } from '../../pages/paths'; import * as css from './RoomViewHeader.css'; import { useRoomUnread } from '../../state/hooks/unread'; -import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels'; +import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { markAsRead } from '../../../client/action/notifications'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { openInviteUser } from '../../../client/action/navigation'; @@ -67,6 +67,8 @@ import { } from '../../hooks/useRoomsNotificationPreferences'; import { JumpToTime } from './jump-to-time'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; type RoomMenuProps = { room: Room; @@ -77,8 +79,10 @@ const RoomMenu = forwardRef(({ room, requestClose const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const powerLevels = usePowerLevelsContext(); - const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); - const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canInvite = permissions.action('invite', mx.getSafeUserId()); const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const { navigateRoom } = useRoomNavigate(); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index e906a024..fbe35770 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -75,10 +75,10 @@ import { getMatrixToRoomEvent } from '../../../plugins/matrix-to'; import { getViaServers } from '../../../plugins/via-servers'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents'; -import { StateEvent } from '../../../../types/matrix/room'; -import { getTagIconSrc, PowerLevelTag } from '../../../hooks/usePowerLevelTags'; +import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room'; import { PowerIcon } from '../../../components/power'; import colorMXID from '../../../../util/colorMXID'; +import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -371,7 +371,7 @@ export const MessagePinItem = as< if (!isPinned && eventId) { pinContent.pinned.push(eventId); } - mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents, pinContent); + mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as any, pinContent); onClose?.(); }; @@ -679,7 +679,7 @@ export type MessageProps = { reactions?: ReactNode; hideReadReceipts?: boolean; showDeveloperTools?: boolean; - powerLevelTag?: PowerLevelTag; + memberPowerTag?: MemberPowerTag; accessibleTagColors?: Map; legacyUsernameColor?: boolean; hour24Clock: boolean; @@ -710,7 +710,7 @@ export const Message = as<'div', MessageProps>( reactions, hideReadReceipts, showDeveloperTools, - powerLevelTag, + memberPowerTag, accessibleTagColors, legacyUsernameColor, hour24Clock, @@ -733,11 +733,11 @@ export const Message = as<'div', MessageProps>( getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; const senderAvatarMxc = getMemberAvatarMxc(room, senderId); - const tagColor = powerLevelTag?.color - ? accessibleTagColors?.get(powerLevelTag.color) + const tagColor = memberPowerTag?.color + ? accessibleTagColors?.get(memberPowerTag.color) : undefined; - const tagIconSrc = powerLevelTag?.icon - ? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon) + const tagIconSrc = memberPowerTag?.icon + ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) : undefined; const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; diff --git a/src/app/features/room/reaction-viewer/ReactionViewer.tsx b/src/app/features/room/reaction-viewer/ReactionViewer.tsx index d4b39845..0e7ca833 100644 --- a/src/app/features/room/reaction-viewer/ReactionViewer.tsx +++ b/src/app/features/room/reaction-viewer/ReactionViewer.tsx @@ -20,12 +20,13 @@ import { getMemberDisplayName } from '../../../utils/room'; import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix'; import * as css from './ReactionViewer.css'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { openProfileViewer } from '../../../../client/action/navigation'; import { useRelations } from '../../../hooks/useRelations'; import { Reaction } from '../../../components/message'; import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji'; import { UserAvatar } from '../../../components/user-avatar'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../../hooks/useSpace'; export type ReactionViewerProps = { room: Room; @@ -41,6 +42,8 @@ export const ReactionViewer = as<'div', ReactionViewerProps>( relations, useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], []) ); + const space = useSpaceOptionally(); + const openProfile = useOpenUserRoomProfile(); const [selectedKey, setSelectedKey] = useState(() => { if (initialKey) return initialKey; @@ -111,24 +114,31 @@ export const ReactionViewer = as<'div', ReactionViewerProps>( const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId; const avatarMxcUrl = member?.getMxcAvatarUrl(); - const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp( - avatarMxcUrl, - 100, - 100, - 'crop', - undefined, - false, - useAuthentication - ) : undefined; + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp( + avatarMxcUrl, + 100, + 100, + 'crop', + undefined, + false, + useAuthentication + ) + : undefined; return ( { - requestClose(); - openProfileViewer(senderId, room.roomId); + onClick={(event) => { + openProfile( + room.roomId, + space?.roomId, + senderId, + event.currentTarget.getBoundingClientRect(), + 'Bottom' + ); }} before={ diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 8e73e66e..9986849f 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -69,18 +69,23 @@ import { Image } from '../../../components/media'; import { ImageViewer } from '../../../components/image-viewer'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { VirtualTile } from '../../../components/virtualizer'; -import { usePowerLevelsAPI, usePowerLevelsContext } from '../../../hooks/usePowerLevels'; +import { usePowerLevelsContext } from '../../../hooks/usePowerLevels'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { ContainerColor } from '../../../styles/ContainerColor.css'; -import { - getTagIconSrc, - useAccessibleTagColors, - usePowerLevelTags, -} from '../../../hooks/usePowerLevelTags'; +import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags'; import { useTheme } from '../../../hooks/useTheme'; import { PowerIcon } from '../../../components/power'; import colorMXID from '../../../../util/colorMXID'; import { useIsDirectRoom } from '../../../hooks/useRoom'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { + GetMemberPowerTag, + getPowerTagIconSrc, + useAccessiblePowerTagColors, + useGetMemberPowerTag, +} from '../../../hooks/useMemberPowerTag'; +import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; type PinnedMessageProps = { room: Room; @@ -88,22 +93,27 @@ type PinnedMessageProps = { renderContent: RenderMatrixEvent<[MatrixEvent, string, GetContentCallback]>; onOpen: (roomId: string, eventId: string) => void; canPinEvent: boolean; + getMemberPowerTag: GetMemberPowerTag; + accessibleTagColors: Map; + legacyUsernameColor: boolean; + hour24Clock: boolean; + dateFormatString: string; }; -function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: PinnedMessageProps) { +function PinnedMessage({ + room, + eventId, + renderContent, + onOpen, + canPinEvent, + getMemberPowerTag, + accessibleTagColors, + legacyUsernameColor, + hour24Clock, + dateFormatString, +}: PinnedMessageProps) { const pinnedEvent = useRoomEvent(room, eventId); const useAuthentication = useMediaAuthentication(); const mx = useMatrixClient(); - const direct = useIsDirectRoom(); - const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); - - const powerLevels = usePowerLevelsContext(); - const { getPowerLevel } = usePowerLevelsAPI(powerLevels); - const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels); - const theme = useTheme(); - const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags); - - const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); - const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const [unpinState, unpin] = useAsyncCallback( useCallback(() => { @@ -169,14 +179,15 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi const senderAvatarMxc = getMemberAvatarMxc(room, sender); const getContent = (() => pinnedEvent.getContent()) as GetContentCallback; - const senderPowerLevel = getPowerLevel(sender); - const powerLevelTag = getPowerLevelTag(senderPowerLevel); - const tagColor = powerLevelTag?.color ? accessibleTagColors?.get(powerLevelTag.color) : undefined; - const tagIconSrc = powerLevelTag?.icon - ? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon) + const memberPowerTag = getMemberPowerTag(sender); + const tagColor = memberPowerTag?.color + ? accessibleTagColors?.get(memberPowerTag.color) + : undefined; + const tagIconSrc = memberPowerTag?.icon + ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) : undefined; - const usernameColor = legacyUsernameColor || direct ? colorMXID(sender) : tagColor; + const usernameColor = legacyUsernameColor ? colorMXID(sender) : tagColor; return ( @@ -242,14 +252,34 @@ export const RoomPinMenu = forwardRef( const mx = useMatrixClient(); const userId = mx.getUserId()!; const powerLevels = usePowerLevelsContext(); - const { canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels); - const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, getPowerLevel(userId)); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, userId); + + const creatorsTag = useRoomCreatorsTag(); + const powerLevelTags = usePowerLevelTags(room, powerLevels); + const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + + const theme = useTheme(); + const accessibleTagColors = useAccessiblePowerTagColors( + theme.kind, + creatorsTag, + powerLevelTags + ); const pinnedEvents = useRoomPinnedEvents(room); const sortedPinnedEvent = useMemo(() => Array.from(pinnedEvents).reverse(), [pinnedEvents]); const useAuthentication = useMediaAuthentication(); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + + const direct = useIsDirectRoom(); + const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); + + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + const { navigateRoom } = useRoomNavigate(); const scrollRef = useRef(null); @@ -464,6 +494,11 @@ export const RoomPinMenu = forwardRef( renderContent={renderMatrixEvent} onOpen={handleOpen} canPinEvent={canPinEvent} + getMemberPowerTag={getMemberPowerTag} + accessibleTagColors={accessibleTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} />
diff --git a/src/app/features/space-settings/SpaceSettingsRenderer.tsx b/src/app/features/space-settings/SpaceSettingsRenderer.tsx index 085c5e2b..79947e0c 100644 --- a/src/app/features/space-settings/SpaceSettingsRenderer.tsx +++ b/src/app/features/space-settings/SpaceSettingsRenderer.tsx @@ -16,7 +16,7 @@ function RenderSettings({ state }: RenderSettingsProps) { const allJoinedRooms = useAllJoinedRoomsSet(); const getRoom = useGetRoom(allJoinedRooms); const room = getRoom(roomId); - const space = spaceId ? getRoom(spaceId) : undefined; + const space = spaceId && spaceId !== roomId ? getRoom(spaceId) : undefined; if (!room) return null; diff --git a/src/app/features/space-settings/general/General.tsx b/src/app/features/space-settings/general/General.tsx index 6f4d8d38..641bfa7a 100644 --- a/src/app/features/space-settings/general/General.tsx +++ b/src/app/features/space-settings/general/General.tsx @@ -11,6 +11,8 @@ import { RoomPublish, RoomUpgrade, } from '../../common-settings/general'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; type GeneralProps = { requestClose: () => void; @@ -18,6 +20,8 @@ type GeneralProps = { export function General({ requestClose }: GeneralProps) { const room = useRoom(); const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); return ( @@ -39,20 +43,20 @@ export function General({ requestClose }: GeneralProps) { - + Options - - + + Addresses - - + + Advance Options - + diff --git a/src/app/features/space-settings/permissions/Permissions.tsx b/src/app/features/space-settings/permissions/Permissions.tsx index ae3769bf..7572a71b 100644 --- a/src/app/features/space-settings/permissions/Permissions.tsx +++ b/src/app/features/space-settings/permissions/Permissions.tsx @@ -2,11 +2,13 @@ import React, { useState } from 'react'; import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { useRoom } from '../../../hooks/useRoom'; -import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { StateEvent } from '../../../../types/matrix/room'; import { usePermissionGroups } from './usePermissionItems'; import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; type PermissionsProps = { requestClose: () => void; @@ -15,11 +17,12 @@ export function Permissions({ requestClose }: PermissionsProps) { const mx = useMatrixClient(); const room = useRoom(); const powerLevels = usePowerLevels(room); - const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels); - const canEditPowers = canSendStateEvent( - StateEvent.PowerLevelTags, - getPowerLevel(mx.getSafeUserId()) - ); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + + const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId()); + const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId()); const permissionGroups = usePermissionGroups(); const [powerEditor, setPowerEditor] = useState(false); @@ -57,7 +60,11 @@ export function Permissions({ requestClose }: PermissionsProps) { onEdit={canEditPowers ? handleEditPowers : undefined} permissionGroups={permissionGroups} /> - + diff --git a/src/app/hooks/useDirectUsers.ts b/src/app/hooks/useDirectUsers.ts new file mode 100644 index 00000000..3aa18928 --- /dev/null +++ b/src/app/hooks/useDirectUsers.ts @@ -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(); + + 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; +}; diff --git a/src/app/hooks/useMemberPowerCompare.ts b/src/app/hooks/useMemberPowerCompare.ts new file mode 100644 index 00000000..72163edf --- /dev/null +++ b/src/app/hooks/useMemberPowerCompare.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; +import { IPowerLevels, readPowerLevel } from './usePowerLevels'; + +export const useMemberPowerCompare = (creators: Set, 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, + }; +}; diff --git a/src/app/hooks/useMemberPowerTag.ts b/src/app/hooks/useMemberPowerTag.ts new file mode 100644 index 00000000..31e52aa0 --- /dev/null +++ b/src/app/hooks/useMemberPowerTag.ts @@ -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, + 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 => { + const accessibleColors: Map = useMemo(() => { + const colors: Map = 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 => { + const PLTagOrRoomMember = useMemo(() => { + let prevTag: MemberPowerTag | undefined; + const tagOrMember: Array = []; + 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; +}; diff --git a/src/app/hooks/useMemberSort.ts b/src/app/hooks/useMemberSort.ts index da955701..d8e403c5 100644 --- a/src/app/hooks/useMemberSort.ts +++ b/src/app/hooks/useMemberSort.ts @@ -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): 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; +}; diff --git a/src/app/hooks/useMembership.ts b/src/app/hooks/useMembership.ts new file mode 100644 index 00000000..dbdd527e --- /dev/null +++ b/src/app/hooks/useMembership.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; +import { Room, RoomMemberEvent, RoomMemberEventHandlerMap } from 'matrix-js-sdk'; +import { Membership } from '../../types/matrix/room'; + +export const useMembership = (room: Room, userId: string): Membership => { + const member = room.getMember(userId); + + const [membership, setMembership] = useState( + () => (member?.membership as Membership | undefined) ?? Membership.Leave + ); + + useEffect(() => { + const handleMembershipChange: RoomMemberEventHandlerMap[RoomMemberEvent.Membership] = ( + event, + m + ) => { + if (event.getRoomId() === room.roomId && m.userId === userId) { + setMembership((m.membership as Membership | undefined) ?? Membership.Leave); + } + }; + member?.on(RoomMemberEvent.Membership, handleMembershipChange); + return () => { + member?.removeListener(RoomMemberEvent.Membership, handleMembershipChange); + }; + }, [room, member, userId]); + + return membership; +}; diff --git a/src/app/hooks/useMentionClickHandler.ts b/src/app/hooks/useMentionClickHandler.ts index 49c291c7..9dc81d4e 100644 --- a/src/app/hooks/useMentionClickHandler.ts +++ b/src/app/hooks/useMentionClickHandler.ts @@ -3,14 +3,17 @@ import { useNavigate } from 'react-router-dom'; import { useRoomNavigate } from './useRoomNavigate'; import { useMatrixClient } from './useMatrixClient'; import { isRoomId, isUserId } from '../utils/matrix'; -import { openProfileViewer } from '../../client/action/navigation'; import { getHomeRoomPath, withSearchParam } from '../pages/pathUtils'; import { _RoomSearchParams } from '../pages/paths'; +import { useOpenUserRoomProfile } from '../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from './useSpace'; export const useMentionClickHandler = (roomId: string): ReactEventHandler => { const mx = useMatrixClient(); const { navigateRoom, navigateSpace } = useRoomNavigate(); const navigate = useNavigate(); + const openProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); const handleClick: ReactEventHandler = useCallback( (evt) => { @@ -21,7 +24,7 @@ export const useMentionClickHandler = (roomId: string): ReactEventHandler(path, { viaServers }) : path); }, - [mx, navigate, navigateRoom, navigateSpace, roomId] + [mx, navigate, navigateRoom, navigateSpace, roomId, space, openProfile] ); return handleClick; diff --git a/src/app/hooks/useMutualRooms.ts b/src/app/hooks/useMutualRooms.ts new file mode 100644 index 00000000..a7b38938 --- /dev/null +++ b/src/app/hooks/useMutualRooms.ts @@ -0,0 +1,30 @@ +import { useCallback } from 'react'; +import { useMatrixClient } from './useMatrixClient'; +import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback'; +import { useSpecVersions } from './useSpecVersions'; + +export const useMutualRoomsSupport = (): boolean => { + const { unstable_features: unstableFeatures } = useSpecVersions(); + + const supported = + unstableFeatures?.['uk.half-shot.msc2666'] || + unstableFeatures?.['uk.half-shot.msc2666.mutual_rooms'] || + unstableFeatures?.['uk.half-shot.msc2666.query_mutual_rooms']; + + return !!supported; +}; + +export const useMutualRooms = (userId: string): AsyncState => { + const mx = useMatrixClient(); + + const supported = useMutualRoomsSupport(); + + const [mutualRoomsState] = useAsyncCallbackValue( + useCallback( + () => (supported ? mx._unstable_getSharedRooms(userId) : Promise.resolve([])), + [mx, userId, supported] + ) + ); + + return mutualRoomsState; +}; diff --git a/src/app/hooks/usePowerLevelTags.ts b/src/app/hooks/usePowerLevelTags.ts index bdcb9bcc..10235b52 100644 --- a/src/app/hooks/usePowerLevelTags.ts +++ b/src/app/hooks/usePowerLevelTags.ts @@ -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; -export type PowerLevelTags = Record; - -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 => { - const PLTagOrRoomMember = useMemo(() => { - let prevTag: PowerLevelTag | undefined; - const tagOrMember: Array = []; - 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 => { - const accessibleColors: Map = useMemo(() => { - const colors: Map = 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); }; diff --git a/src/app/hooks/usePowerLevels.ts b/src/app/hooks/usePowerLevels.ts index 8bf8b747..0281b23c 100644 --- a/src/app/hooks/usePowerLevels.ts +++ b/src/app/hooks/usePowerLevels.ts @@ -58,10 +58,11 @@ const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels => }); const getPowersLevelFromMatrixEvent = (mEvent?: MatrixEvent): IPowerLevels => { - const pl = mEvent?.getContent(); - if (!pl) return DEFAULT_POWER_LEVELS; + const plContent = mEvent?.getContent(); - 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 => 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; }; /** diff --git a/src/app/hooks/useRoomCreators.ts b/src/app/hooks/useRoomCreators.ts new file mode 100644 index 00000000..269d11af --- /dev/null +++ b/src/app/hooks/useRoomCreators.ts @@ -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 => { + const createContent = createEvent.getContent(); + + const creators: Set = 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 => { + const createEvent = useStateEvent(room, StateEvent.RoomCreate); + + const creators = useMemo( + () => (createEvent ? getRoomCreators(createEvent) : new Set()), + [createEvent] + ); + + return creators; +}; + +export const getRoomCreatorsForRoomId = (mx: MatrixClient, roomId: string): Set => { + const room = mx.getRoom(roomId); + if (!room) return new Set(); + + const createEvent = getStateEvent(room, StateEvent.RoomCreate); + if (!createEvent) return new Set(); + + return getRoomCreators(createEvent); +}; diff --git a/src/app/hooks/useRoomCreatorsTag.ts b/src/app/hooks/useRoomCreatorsTag.ts new file mode 100644 index 00000000..2d6db0ec --- /dev/null +++ b/src/app/hooks/useRoomCreatorsTag.ts @@ -0,0 +1,8 @@ +import { MemberPowerTag } from '../../types/matrix/room'; + +const DEFAULT_TAG: MemberPowerTag = { + name: 'Founder', + color: '#0000ff', +}; + +export const useRoomCreatorsTag = (): MemberPowerTag => DEFAULT_TAG; diff --git a/src/app/hooks/useRoomPermissions.ts b/src/app/hooks/useRoomPermissions.ts new file mode 100644 index 00000000..cb6f69a2 --- /dev/null +++ b/src/app/hooks/useRoomPermissions.ts @@ -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, + 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, + powerLevels: IPowerLevels +): RoomPermissionsAPI => { + const api: RoomPermissionsAPI = useMemo( + () => getRoomPermissionsAPI(creators, powerLevels), + [creators, powerLevels] + ); + + return api; +}; diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts new file mode 100644 index 00000000..5137950e --- /dev/null +++ b/src/app/hooks/useUserPresence.ts @@ -0,0 +1,58 @@ +import { useEffect, useMemo, useState } from 'react'; +import { User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; +import { useMatrixClient } from './useMatrixClient'; + +export enum Presence { + Online = 'online', + Unavailable = 'unavailable', + Offline = 'offline', +} + +export type UserPresence = { + presence: Presence; + status?: string; + active: boolean; + lastActiveTs?: number; +}; + +const getUserPresence = (user: User): UserPresence => ({ + presence: user.presence as Presence, + status: user.presenceStatusMsg, + active: user.currentlyActive, + lastActiveTs: user.getLastActiveTs(), +}); + +export const useUserPresence = (userId: string): UserPresence | undefined => { + const mx = useMatrixClient(); + const user = mx.getUser(userId); + + const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); + + useEffect(() => { + const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { + if (u.userId === user?.userId) { + setPresence(getUserPresence(user)); + } + }; + user?.on(UserEvent.Presence, updatePresence); + user?.on(UserEvent.CurrentlyActive, updatePresence); + user?.on(UserEvent.LastPresenceTs, updatePresence); + return () => { + user?.removeListener(UserEvent.Presence, updatePresence); + user?.removeListener(UserEvent.CurrentlyActive, updatePresence); + user?.removeListener(UserEvent.LastPresenceTs, updatePresence); + }; + }, [user]); + + return presence; +}; + +export const usePresenceLabel = (): Record => + useMemo( + () => ({ + [Presence.Online]: 'Active', + [Presence.Unavailable]: 'Busy', + [Presence.Offline]: 'Away', + }), + [] + ); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index d6d93aa4..247885c0 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -62,6 +62,7 @@ import { AutoRestoreBackupOnVerification } from '../components/BackupRestore'; import { RoomSettingsRenderer } from '../features/room-settings'; import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences'; import { SpaceSettingsRenderer } from '../features/space-settings'; +import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer'; import { CreateRoomModalRenderer } from '../features/create-room'; import { HomeCreateRoom } from './client/home/CreateRoom'; import { Create } from './client/create'; @@ -130,6 +131,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) > + diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index a4957743..afdfec6d 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -84,16 +84,19 @@ import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; -import { - getTagIconSrc, - useAccessibleTagColors, - usePowerLevelTags, -} from '../../../hooks/usePowerLevelTags'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; +import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags'; import { useTheme } from '../../../hooks/useTheme'; import { PowerIcon } from '../../../components/power'; import colorMXID from '../../../../util/colorMXID'; import { mDirectAtom } from '../../../state/mDirectList'; +import { + getPowerTagIconSrc, + useAccessiblePowerTagColors, + useGetMemberPowerTag, +} from '../../../hooks/useMemberPowerTag'; +import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; type RoomNotificationsGroup = { roomId: string; @@ -224,10 +227,14 @@ function RoomNotificationsGroupComp({ const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const powerLevels = usePowerLevels(room); - const { getPowerLevel } = usePowerLevelsAPI(powerLevels); - const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels); + const creators = useRoomCreators(room); + + const creatorsTag = useRoomCreatorsTag(); + const powerLevelTags = usePowerLevelTags(room, powerLevels); + const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + const theme = useTheme(); - const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags); + const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags); const mentionClickHandler = useMentionClickHandler(room.roomId); const spoilerClickHandler = useSpoilerClickHandler(); @@ -447,13 +454,12 @@ function RoomNotificationsGroupComp({ const threadRootId = relation?.rel_type === RelationType.Thread ? relation.event_id : undefined; - const senderPowerLevel = getPowerLevel(event.sender); - const powerLevelTag = getPowerLevelTag(senderPowerLevel); - const tagColor = powerLevelTag?.color - ? accessibleTagColors?.get(powerLevelTag.color) + const memberPowerTag = getMemberPowerTag(event.sender); + const tagColor = memberPowerTag?.color + ? accessibleTagColors?.get(memberPowerTag.color) : undefined; - const tagIconSrc = powerLevelTag?.icon - ? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon) + const tagIconSrc = memberPowerTag?.icon + ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) : undefined; const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor; @@ -523,8 +529,7 @@ function RoomNotificationsGroupComp({ replyEventId={replyEventId} threadRootId={threadRootId} onClick={handleOpenClick} - getPowerLevel={getPowerLevel} - getPowerLevelTag={getPowerLevelTag} + getMemberPowerTag={getMemberPowerTag} accessibleTagColors={accessibleTagColors} legacyUsernameColor={legacyUsernameColor} /> diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 011741ee..3ee6c725 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -77,7 +77,7 @@ import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder'; -import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { useRoomsUnread } from '../../../state/hooks/unread'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { markAsRead } from '../../../../client/action/notifications'; @@ -91,6 +91,8 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; type SpaceMenuProps = { room: Room; @@ -103,8 +105,10 @@ const SpaceMenu = forwardRef( const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); - const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); - const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canInvite = permissions.action('invite', mx.getSafeUserId()); const openSpaceSettings = useOpenSpaceSettings(); const allChild = useSpaceChildren( diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index d1009464..b657f73e 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -10,6 +10,7 @@ import { useAtom, useAtomValue } from 'jotai'; import { Avatar, Box, + Button, Icon, IconButton, Icons, @@ -18,7 +19,9 @@ import { MenuItem, PopOut, RectCords, + Spinner, Text, + color, config, toRem, } from 'folds'; @@ -53,7 +56,7 @@ import { useRoomName } from '../../../hooks/useRoomMeta'; import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; -import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { openInviteUser } from '../../../../client/action/navigation'; import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; @@ -64,7 +67,7 @@ import { LeaveSpacePrompt } from '../../../components/leave-space-prompt'; import { copyToClipboard } from '../../../utils/dom'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { useStateEvent } from '../../../hooks/useStateEvent'; -import { StateEvent } from '../../../../types/matrix/room'; +import { Membership, StateEvent } from '../../../../types/matrix/room'; import { stopPropagation } from '../../../utils/keyboard'; import { getMatrixToRoom } from '../../../plugins/matrix-to'; import { getViaServers } from '../../../plugins/via-servers'; @@ -76,6 +79,11 @@ import { } from '../../../hooks/useRoomsNotificationPreferences'; import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { ContainerColor } from '../../../styles/ContainerColor.css'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { BreakWord } from '../../../styles/Text.css'; type SpaceMenuProps = { room: Room; @@ -87,8 +95,10 @@ const SpaceMenu = forwardRef(({ room, requestClo const [developerTools] = useSetting(settingsAtom, 'developerTools'); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); - const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); - const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canInvite = permissions.action('invite', mx.getSafeUserId()); const openSpaceSettings = useOpenSpaceSettings(); const { navigateRoom } = useRoomNavigate(); @@ -284,6 +294,75 @@ function SpaceHeader() { ); } +type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; +export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { + const mx = useMatrixClient(); + const { navigateRoom } = useRoomNavigate(); + + const [joinState, handleJoin] = useAsyncCallback( + useCallback(() => { + const currentRoom = mx.getRoom(roomId); + const via = currentRoom ? getViaServers(currentRoom) : []; + return mx.joinRoom(replacementRoomId, { + viaServers: via, + }); + }, [mx, roomId, replacementRoomId]) + ); + const replacementRoom = mx.getRoom(replacementRoomId); + + const handleOpen = () => { + if (replacementRoom) navigateRoom(replacementRoom.roomId); + if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId); + }; + + return ( + + + Space Upgraded + This space has been replaced and is no longer active. + {joinState.status === AsyncStatus.Error && ( + + {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} + + )} + + + {replacementRoom?.getMyMembership() === Membership.Join || + joinState.status === AsyncStatus.Success ? ( + + ) : ( + + )} + + + ); +} + export function Space() { const mx = useMatrixClient(); const space = useSpace(); @@ -296,6 +375,8 @@ export function Space() { const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); const notificationPreferences = useRoomsNotificationPreferencesContext(); + const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); + const selectedRoomId = useSelectedRoom(); const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); @@ -351,6 +432,12 @@ export function Space() { + {tombstoneEvent && ( + + )} diff --git a/src/app/state/hooks/userRoomProfile.ts b/src/app/state/hooks/userRoomProfile.ts new file mode 100644 index 00000000..7fed0c08 --- /dev/null +++ b/src/app/state/hooks/userRoomProfile.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { Position, RectCords } from 'folds'; +import { userRoomProfileAtom, UserRoomProfileState } from '../userRoomProfile'; + +export const useUserRoomProfileState = (): UserRoomProfileState | undefined => { + const data = useAtomValue(userRoomProfileAtom); + + return data; +}; + +type CloseCallback = () => void; +export const useCloseUserRoomProfile = (): CloseCallback => { + const setUserRoomProfile = useSetAtom(userRoomProfileAtom); + + const close: CloseCallback = useCallback(() => { + setUserRoomProfile(undefined); + }, [setUserRoomProfile]); + + return close; +}; + +type OpenCallback = ( + roomId: string, + spaceId: string | undefined, + userId: string, + cords: RectCords, + position?: Position +) => void; +export const useOpenUserRoomProfile = (): OpenCallback => { + const setUserRoomProfile = useSetAtom(userRoomProfileAtom); + + const open: OpenCallback = useCallback( + (roomId, spaceId, userId, cords, position) => { + setUserRoomProfile({ roomId, spaceId, userId, cords, position }); + }, + [setUserRoomProfile] + ); + + return open; +}; diff --git a/src/app/state/userRoomProfile.ts b/src/app/state/userRoomProfile.ts new file mode 100644 index 00000000..cf4e403a --- /dev/null +++ b/src/app/state/userRoomProfile.ts @@ -0,0 +1,12 @@ +import { Position, RectCords } from 'folds'; +import { atom } from 'jotai'; + +export type UserRoomProfileState = { + userId: string; + roomId: string; + spaceId?: string; + cords: RectCords; + position?: Position; +}; + +export const userRoomProfileAtom = atom(undefined); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index b31677a0..c8b104d9 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -357,3 +357,7 @@ export const knockRestrictedSupported = (version: string): boolean => { const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; return !unsupportedVersion.includes(version); }; +export const creatorsSupported = (version: string): boolean => { + const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; + return !unsupportedVersion.includes(version); +}; diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 20ce9419..98715996 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -18,6 +18,8 @@ export enum AccountDataEvent { MegolmBackupV1 = 'm.megolm_backup.v1', } +export type MDirectContent = Record; + export type SecretStorageDefaultKeyContent = { key: string; }; diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 65dc35f4..f0927b3c 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -1,3 +1,5 @@ +import { IImageInfo } from './common'; + export enum Membership { Invite = 'invite', Knock = 'knock', @@ -69,7 +71,7 @@ export type IRoomCreateContent = { room_version: string; type?: string; predecessor?: { - event_id: string; + event_id?: string; room_id: string; }; }; @@ -93,3 +95,13 @@ export type MuteChanges = { added: string[]; removed: string[]; }; + +export type MemberPowerTagIcon = { + key?: string; + info?: IImageInfo; +}; +export type MemberPowerTag = { + name: string; + color?: string; + icon?: MemberPowerTagIcon; +};