Merge remote-tracking branch 'upstream/dev' into feat/element-call

This commit is contained in:
hazre
2026-02-11 14:26:40 +01:00
401 changed files with 10981 additions and 10935 deletions

View File

@@ -0,0 +1,375 @@
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Button,
config,
Header,
Icon,
IconButton,
Icons,
Input,
Menu,
MenuItem,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Text,
} from 'folds';
import React, {
ChangeEventHandler,
MouseEventHandler,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { useAtomValue } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Room } from 'matrix-js-sdk';
import { stopPropagation } from '../../utils/keyboard';
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { VirtualTile } from '../../components/virtualizer';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { nameInitials } from '../../utils/common';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { factoryRoomIdByAtoZ } from '../../utils/sort';
import {
SearchItemStrGetter,
useAsyncSearch,
UseAsyncSearchOptions,
} from '../../hooks/useAsyncSearch';
import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { rateLimitedActions } from '../../utils/matrix';
import { useAlive } from '../../hooks/useAlive';
const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 500,
matchOptions: {
contain: true,
},
normalizeOptions: {
ignoreWhitespace: false,
},
};
type AddExistingModalProps = {
parentId: string;
space?: boolean;
requestClose: () => void;
};
export function AddExistingModal({ parentId, space, requestClose }: AddExistingModalProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const alive = useAlive();
const mDirects = useAtomValue(mDirectAtom);
const spaces = useSpaces(mx, allRoomsAtom);
const rooms = useRooms(mx, allRoomsAtom, mDirects);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const roomIdToParents = useAtomValue(roomToParentsAtom);
const scrollRef = useRef<HTMLDivElement>(null);
const [selected, setSelected] = useState<string[]>([]);
const allRoomsSet = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allRoomsSet);
const allItems: string[] = useMemo(() => {
const rIds = space ? [...spaces] : [...rooms, ...directs];
return rIds
.filter((rId) => rId !== parentId && !roomIdToParents.get(rId)?.has(parentId))
.sort(factoryRoomIdByAtoZ(mx));
}, [spaces, rooms, directs, space, parentId, roomIdToParents, mx]);
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
(rId) => getRoom(rId)?.name ?? rId,
[getRoom]
);
const [searchResult, searchRoom, resetSearch] = useAsyncSearch(
allItems,
getRoomNameStr,
SEARCH_OPTS
);
const queryHighlighRegex = searchResult?.query
? makeHighlightRegex(searchResult.query.split(' '))
: undefined;
const items = searchResult ? searchResult.items : allItems;
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 32,
overscan: 5,
});
const vItems = virtualizer.getVirtualItems();
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const value = evt.currentTarget.value.trim();
if (!value) {
resetSearch();
return;
}
searchRoom(value);
};
const [applyState, applyChanges] = useAsyncCallback<undefined, Error, [Room[]]>(
useCallback(
async (selectedRooms) => {
await rateLimitedActions(selectedRooms, async (room) => {
const via = getViaServers(room);
await mx.sendStateEvent(
parentId,
StateEvent.SpaceChild as any,
{
auto_join: false,
suggested: false,
via,
},
room.roomId
);
});
},
[mx, parentId]
)
);
const applyingChanges = applyState.status === AsyncStatus.Loading;
const handleRoomClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const roomId = evt.currentTarget.getAttribute('data-room-id');
if (!roomId) return;
if (selected?.includes(roomId)) {
setSelected(selected?.filter((rId) => rId !== roomId));
return;
}
const addedRooms = [...(selected ?? [])];
addedRooms.push(roomId);
setSelected(addedRooms);
};
const handleApplyChanges = () => {
const selectedRooms = selected.map((rId) => getRoom(rId)).filter((room) => room !== undefined);
applyChanges(selectedRooms).then(() => {
if (alive()) {
setSelected([]);
requestClose();
}
});
};
const resetChanges = () => {
setSelected([]);
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: requestClose,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300">
<Box grow="Yes" direction="Column">
<Header
size="500"
style={{
padding: config.space.S200,
paddingLeft: config.space.S400,
}}
>
<Box grow="Yes">
<Text size="H4">Add Existing</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Box grow="Yes">
<Scroll ref={scrollRef} size="300" hideTrack>
<Box
style={{ padding: config.space.S300, paddingRight: 0 }}
direction="Column"
gap="500"
>
<Box
direction="Column"
style={{ position: 'sticky', top: config.space.S300, zIndex: 1 }}
>
<Input
onChange={handleSearchChange}
before={<Icon size="200" src={Icons.Search} />}
placeholder="Search"
size="400"
variant="Background"
outlined
/>
</Box>
{vItems.length === 0 && (
<Box
style={{ paddingTop: config.space.S700 }}
grow="Yes"
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
{searchResult ? 'No Match Found' : `No ${space ? 'Spaces' : 'Rooms'}`}
</Text>
<Text size="T200" align="Center">
{searchResult
? `No match found for "${searchResult.query}".`
: `You do not have any ${space ? 'Spaces' : 'Rooms'} to display yet.`}
</Text>
</Box>
)}
<Box
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{vItems.map((vItem) => {
const roomId = items[vItem.index];
const room = getRoom(roomId);
if (!room) return null;
const selectedItem = selected?.includes(roomId);
const dm = mDirects.has(room.roomId);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<MenuItem
data-room-id={roomId}
onClick={handleRoomClick}
variant={selectedItem ? 'Success' : 'Surface'}
size="400"
radii="400"
disabled={applyingChanges}
aria-pressed={selectedItem}
before={
<Avatar size="200" radii={dm ? '400' : '300'}>
{dm || room.isSpaceRoom() ? (
<RoomAvatar
roomId={room.roomId}
src={
dm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon size="200" joinRule={room.getJoinRule()} />
)}
</Avatar>
}
after={selectedItem && <Icon size="200" src={Icons.Check} />}
>
<Box grow="Yes">
<Text truncate size="T400">
{queryHighlighRegex
? highlightText(queryHighlighRegex, [room.name])
: room.name}
</Text>
</Box>
</MenuItem>
</VirtualTile>
);
})}
</Box>
{selected.length > 0 && (
<Menu
style={{
position: 'sticky',
padding: config.space.S200,
paddingLeft: config.space.S400,
bottom: config.space.S400,
left: config.space.S400,
right: 0,
zIndex: 1,
}}
variant="Success"
>
<Box alignItems="Center" gap="400">
<Box grow="Yes" direction="Column">
{applyState.status === AsyncStatus.Error ? (
<Text size="T200">
<b>Failed to apply changes! Please try again.</b>
</Text>
) : (
<Text size="T200">
<b>Apply when ready. ({selected.length} Selected)</b>
</Text>
)}
</Box>
<Box shrink="No" gap="200">
<Button
size="300"
variant="Success"
fill="None"
radii="300"
disabled={applyingChanges}
onClick={resetChanges}
>
<Text size="B300">Reset</Text>
</Button>
<Button
size="300"
variant="Success"
radii="300"
disabled={applyingChanges}
before={
applyingChanges && (
<Spinner variant="Success" fill="Solid" size="100" />
)
}
onClick={handleApplyChanges}
>
<Text size="B300">Apply Changes</Text>
</Button>
</Box>
</Box>
</Menu>
)}
</Box>
</Scroll>
</Box>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

@@ -0,0 +1 @@
export * from './AddExisting';

View File

@@ -8,7 +8,8 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { openProfileViewer } from '../../../client/action/navigation';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import * as css from './CallView.css';
type CallViewUserProps = {
@@ -35,6 +36,8 @@ export const CallViewUserBase = as<'div'>(({ className, ...props }, ref) => (
export function CallViewUser({ room, callMembership }: CallViewUserProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const userId = callMembership.sender ?? '';
const avatarMxcUrl = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxcUrl
@@ -42,8 +45,8 @@ export function CallViewUser({ room, callMembership }: CallViewUserProps) {
: undefined;
const getName = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId);
const handleUserClick = () => {
openProfileViewer(userId, room.roomId);
const handleUserClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect());
};
return (

View File

@@ -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<object>();
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 '';

View File

@@ -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]);

View File

@@ -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';
@@ -29,23 +28,23 @@ import {
} from '../../../hooks/useRoomAliases';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { CutoutCard } from '../../../components/cutout-card';
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';
import { getMxIdServer } from '../../../utils/matrix';
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);
@@ -134,7 +133,7 @@ export function RoomPublishedAddresses({ powerLevels }: RoomPublishedAddressesPr
function LocalAddressInput({ addLocalAlias }: { addLocalAlias: (alias: string) => Promise<void> }) {
const mx = useMatrixClient();
const userId = mx.getSafeUserId();
const server = getIdServer(userId);
const server = getMxIdServer(userId);
const alive = useAlive();
const [addState, addAlias] = useAsyncCallback(addLocalAlias);
@@ -330,7 +329,7 @@ function LocalAddressesList({
<Box shrink="No">
<Checkbox
checked={selected}
onChange={() => toggleSelect(alias)}
onClick={() => toggleSelect(alias)}
size="50"
variant="Primary"
disabled={loading}
@@ -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);

View File

@@ -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;
}>();

View File

@@ -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 =

View File

@@ -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,
@@ -27,6 +26,12 @@ import {
} from '../../../state/hooks/roomList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import {
knockRestrictedSupported,
knockSupported,
restrictedSupported,
} from '../../../utils/matrix';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
type RestrictedRoomAllowContent = {
room_id: string;
@@ -34,27 +39,21 @@ 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 roomVersion = parseInt(room.getVersion(), 10);
const allowKnockRestricted = roomVersion >= 10;
const allowRestricted = roomVersion >= 8;
const allowKnock = roomVersion >= 7;
const allowKnockRestricted = knockRestrictedSupported(room.getVersion());
const allowRestricted = restrictedSupported(room.getVersion());
const allowKnock = knockSupported(room.getVersion());
const roomIdToParents = useAtomValue(roomToParentsAtom);
const space = useSpaceOptionally();
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<RoomJoinRulesEventContent>();

View File

@@ -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

View File

@@ -1,28 +1,33 @@
import React from 'react';
import { Box, color, Spinner, Switch, Text } from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { JoinRule, MatrixError } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } 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 { 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<RoomJoinRulesEventContent>();
const rule: ExtendedJoinRules = (content?.join_rule as ExtendedJoinRules) ?? JoinRule.Invite;
const { visibilityState, setVisibility } = useRoomDirectoryVisibility(room.roomId);
@@ -30,6 +35,8 @@ export function RoomPublish({ powerLevels }: RoomPublishProps) {
const loading =
visibilityState.status === AsyncStatus.Loading || toggleState.status === AsyncStatus.Loading;
const validRule =
rule === JoinRule.Public || rule === JoinRule.Knock || rule === 'knock_restricted';
return (
<SequenceCard
@@ -39,7 +46,12 @@ export function RoomPublish({ powerLevels }: RoomPublishProps) {
gap="400"
>
<SettingTile
title="Publish To Directory"
title="Publish to Directory"
description={
room.isSpaceRoom()
? 'List the space in the public directory to make it discoverable by others.'
: 'List the room in the public directory to make it discoverable by others.'
}
after={
<Box gap="200" alignItems="Center">
{loading && <Spinner variant="Secondary" />}
@@ -47,7 +59,7 @@ export function RoomPublish({ powerLevels }: RoomPublishProps) {
<Switch
value={visibilityState.data}
onChange={toggleVisibility}
disabled={!canEditCanonical}
disabled={!canEditCanonical || !validRule}
/>
)}
</Box>

View File

@@ -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 (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
</Box>
<IconButton size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" style={{ color: color.Critical.Main }}>
<b>This action is irreversible!</b>
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<RoomVersionSelector
versions={roomVersions?.available ? Object.keys(roomVersions.available) : ['1']}
value={selectedRoomVersion}
onChange={selectRoomVersion}
disabled={upgrading}
/>
{allowAdditionalCreators && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<AdditionalCreatorInput
additionalCreators={additionalCreators}
onSelect={addAdditionalCreator}
onRemove={removeAdditionalCreator}
disabled={upgrading}
/>
</SequenceCard>
)}
</Box>
{upgradeState.status === AsyncStatus.Error && (
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
{(upgradeState.error as MatrixError).message}
</Text>
)}
<Button
onClick={handleUpgradeRoom}
variant="Secondary"
disabled={upgrading}
before={upgrading && <Spinner size="200" variant="Secondary" fill="Solid" />}
>
<Text size="B400">{room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
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<RoomCreateEventContent>();
const roomVersion = createContent?.room_version ?? 1;
)?.getContent<IRoomCreateContent>();
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<RoomTombstoneEventContent>();
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<HTMLFormElement> = (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 (
<SequenceCard
className={SequenceCardStyle}
@@ -123,7 +218,7 @@ export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
replacementRoom
? tombstoneContent.body ||
`This ${room.isSpaceRoom() ? 'space' : 'room'} has been replaced!`
: `Current room version: ${roomVersion}.`
: `Current version: ${roomVersion}.`
}
after={
<Box alignItems="Center" gap="200">
@@ -155,8 +250,7 @@ export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
variant="Secondary"
fill="Solid"
radii="300"
disabled={upgrading || !canUpgrade}
before={upgrading && <Spinner size="100" variant="Secondary" fill="Solid" />}
disabled={!canUpgrade}
onClick={() => setPrompt(true)}
>
<Text size="B300">Upgrade</Text>
@@ -165,63 +259,7 @@ export function RoomUpgrade({ powerLevels, requestClose }: RoomUpgradeProps) {
</Box>
}
>
{upgradeState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(upgradeState.error as MatrixError).message}
</Text>
)}
{prompt && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setPrompt(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" as="form" onSubmit={handleSubmitUpgrade}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
</Box>
<IconButton size="300" onClick={() => setPrompt(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" style={{ color: color.Critical.Main }}>
<b>This action is irreversible!</b>
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Version</Text>
<Input
defaultValue={defaultRoomVersion}
name="versionInput"
variant="Background"
required
/>
</Box>
<Button type="submit" variant="Secondary">
<Text size="B400">
{room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}
</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{prompt && <RoomUpgradeDialog requestClose={() => setPrompt(false)} />}
</SettingTile>
</SequenceCard>
);

View File

@@ -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 { useGetMemberPowerLevel, 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,21 @@ 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';
import { getMouseEventCords } from '../../../utils/dom';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@@ -77,15 +80,20 @@ 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 getPowerLevel = useGetMemberPowerLevel(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, getPowerLevel);
const scrollRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -96,8 +104,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 +115,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 +146,9 @@ export function Members({ requestClose }: MembersProps) {
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (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, getMouseEventCords(evt.nativeEvent));
}
};
return (
@@ -317,6 +322,7 @@ export function Members({ requestClose }: MembersProps) {
<MemberTile
data-user-id={tagOrMember.userId}
onClick={handleMemberClick}
aria-pressed={profileUser?.userId === tagOrMember.userId}
mx={mx}
room={room}
member={tagOrMember}

View File

@@ -10,10 +10,9 @@ import {
getPermissionPower,
IPowerLevels,
PermissionLocation,
usePowerLevelsAPI,
} from '../../../hooks/usePowerLevels';
import { PermissionGroup } from './types';
import { getPowers, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { getPowerLevelTag, getPowers, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { useRoom } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { StateEvent } from '../../../../types/matrix/room';
@@ -26,19 +25,20 @@ const USER_DEFAULT_LOCATION: PermissionLocation = {
};
type PermissionGroupsProps = {
canEdit: boolean;
powerLevels: IPowerLevels;
permissionGroups: PermissionGroup[];
};
export function PermissionGroups({ powerLevels, permissionGroups }: PermissionGroupsProps) {
export function PermissionGroups({
powerLevels,
permissionGroups,
canEdit,
}: PermissionGroupsProps) {
const mx = useMatrixClient();
const room = useRoom();
const alive = useAlive();
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
const canChangePermission = canSendStateEvent(
StateEvent.RoomPowerLevels,
getPowerLevel(mx.getSafeUserId())
);
const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const maxPower = useMemo(() => Math.max(...getPowers(powerLevelTags)), [powerLevelTags]);
const [permissionUpdate, setPermissionUpdate] = useState<Map<PermissionLocation, number>>(
@@ -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 && (
<Badge size="200" variant="Success" fill="Solid" radii="Pill" />
)
}
before={
canChangePermission && (
canEdit && (
<Icon size="50" src={opened ? Icons.ChevronTop : Icons.ChevronBottom} />
)
}
@@ -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 && (
<Badge size="200" variant="Success" fill="Solid" radii="Pill" />
)
}
before={
canChangePermission && (
canEdit && (
<Icon
size="50"
src={opened ? Icons.ChevronTop : Icons.ChevronBottom}

View File

@@ -16,7 +16,7 @@ import {
} from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { getPowers, getTagIconSrc, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { getPowers, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { SettingTile } from '../../../components/setting-tile';
import { getPermissionPower, IPowerLevels } from '../../../hooks/usePowerLevels';
import { useRoom } from '../../../hooks/useRoom';
@@ -25,6 +25,9 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { stopPropagation } from '../../../utils/keyboard';
import { PermissionGroup } from './types';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
type PeekPermissionsProps = {
powerLevels: IPowerLevels;
@@ -108,10 +111,43 @@ export function Powers({ powerLevels, permissionGroups, onEdit }: PowersProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const [powerLevelTags] = usePowerLevelTags(room, powerLevels);
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const creators = useRoomCreators(room);
const creatorsTag = useRoomCreatorsTag();
const creatorTagIconSrc =
creatorsTag.icon && getPowerTagIconSrc(mx, useAuthentication, creatorsTag.icon);
return (
<Box direction="Column" gap="100">
{creators.size > 0 && (
<SequenceCard
variant="SurfaceVariant"
className={SequenceCardStyle}
direction="Column"
gap="400"
>
<SettingTile
title="Founders"
description="Founding members has all permissions and can only be changed during upgrade."
/>
<SettingTile>
<Box gap="200" wrap="Wrap">
<Chip
disabled
variant="Secondary"
radii="300"
before={<PowerColorBadge color={creatorsTag.color} />}
after={creatorTagIconSrc && <PowerIcon size="50" iconSrc={creatorTagIconSrc} />}
>
<Text size="T300" truncate>
<b>{creatorsTag.name}</b>
</Text>
</Chip>
</Box>
</SettingTile>
</SequenceCard>
)}
<SequenceCard
variant="SurfaceVariant"
className={SequenceCardStyle}
@@ -142,7 +178,7 @@ export function Powers({ powerLevels, permissionGroups, onEdit }: PowersProps) {
<Box gap="200" wrap="Wrap">
{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 (
<PeekPermissions

View File

@@ -27,10 +27,7 @@ import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import {
getPowers,
getTagIconSrc,
getUsedPowers,
PowerLevelTag,
PowerLevelTagIcon,
PowerLevelTags,
usePowerLevelTags,
} from '../../../hooks/usePowerLevelTags';
@@ -47,15 +44,17 @@ import { useFilePicker } from '../../../hooks/useFilePicker';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { StateEvent } from '../../../../types/matrix/room';
import { MemberPowerTag, MemberPowerTagIcon, StateEvent } from '../../../../types/matrix/room';
import { useAlive } from '../../../hooks/useAlive';
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { creatorsSupported } from '../../../utils/matrix';
type EditPowerProps = {
maxPower: number;
power?: number;
tag?: PowerLevelTag;
onSave: (power: number, tag: PowerLevelTag) => 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<string | undefined>(tag?.color);
const [tagIcon, setTagIcon] = useState<PowerLevelTagIcon | undefined>(tag?.icon);
const [tagIcon, setTagIcon] = useState<MemberPowerTagIcon | undefined>(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<PowerLevelTags>();
const [deleted, setDeleted] = useState<Set<number>>(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) {
</SequenceCard>
{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 (
<SequenceCard

View File

@@ -0,0 +1,150 @@
import { Box, Button, color, config, Icon, Icons, Input, Spinner, Switch, Text } from 'folds';
import React, { FormEventHandler, useCallback, useState } from 'react';
import { ICreateRoomStateEvent, MatrixError, Preset, Visibility } from 'matrix-js-sdk';
import { useNavigate } from 'react-router-dom';
import { SettingTile } from '../../components/setting-tile';
import { SequenceCard } from '../../components/sequence-card';
import { addRoomIdToMDirect, isUserId } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ErrorCode } from '../../cs-errorcode';
import { millisecondsToMinutes } from '../../utils/common';
import { createRoomEncryptionState } from '../../components/create-room';
import { useAlive } from '../../hooks/useAlive';
import { getDirectRoomPath } from '../../pages/pathUtils';
type CreateChatProps = {
defaultUserId?: string;
};
export function CreateChat({ defaultUserId }: CreateChatProps) {
const mx = useMatrixClient();
const alive = useAlive();
const navigate = useNavigate();
const [encryption, setEncryption] = useState(true);
const [invalidUserId, setInvalidUserId] = useState(false);
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [string, boolean]>(
useCallback(
async (userId, encrypted) => {
const initialState: ICreateRoomStateEvent[] = [];
if (encrypted) initialState.push(createRoomEncryptionState());
const result = await mx.createRoom({
is_direct: true,
invite: [userId],
visibility: Visibility.Private,
preset: Preset.TrustedPrivateChat,
initial_state: initialState,
});
addRoomIdToMDirect(mx, result.room_id, userId);
return result.room_id;
},
[mx]
)
);
const loading = createState.status === AsyncStatus.Loading;
const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
const disabled = createState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
setInvalidUserId(false);
const target = evt.target as HTMLFormElement | undefined;
const userIdInput = target?.userIdInput as HTMLInputElement | undefined;
const userId = userIdInput?.value.trim();
if (!userIdInput || !userId) return;
if (!isUserId(userId)) {
setInvalidUserId(true);
return;
}
create(userId, encryption).then((roomId) => {
if (alive()) {
userIdInput.value = '';
navigate(getDirectRoomPath(roomId));
}
});
};
return (
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
<Box direction="Column" gap="100">
<Text size="L400">User ID</Text>
<Input
defaultValue={defaultUserId}
placeholder="@username:server"
name="userIdInput"
variant="SurfaceVariant"
size="500"
radii="400"
required
autoFocus
autoComplete="off"
disabled={disabled}
/>
{invalidUserId && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100">
<Icon src={Icons.Warning} filled size="50" />
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Please enter a valid User ID.</b>
</Text>
</Box>
)}
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="End-to-End Encryption"
description="Once this feature is enabled, it can't be disabled after the room is created."
after={
<Switch
variant="Primary"
value={encryption}
onChange={setEncryption}
disabled={disabled}
/>
}
/>
</SequenceCard>
</Box>
{error && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
<Icon src={Icons.Warning} filled size="100" />
<Text size="T300" style={{ color: color.Critical.Main }}>
<b>
{error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
? `Server rate-limited your request for ${millisecondsToMinutes(
(error.data.retry_after_ms as number | undefined) ?? 0
)} minutes!`
: error.message}
</b>
</Text>
</Box>
)}
<Box shrink="No" direction="Column" gap="200">
<Button
type="submit"
size="500"
variant="Primary"
radii="400"
disabled={disabled}
before={loading && <Spinner variant="Primary" fill="Solid" size="200" />}
>
<Text size="B500">Create</Text>
</Button>
</Box>
</Box>
);
}

View File

@@ -0,0 +1 @@
export * from './CreateChat';

View File

@@ -0,0 +1,328 @@
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk';
import {
Box,
Button,
Chip,
color,
config,
Icon,
Icons,
Input,
Spinner,
Switch,
Text,
TextArea,
} from 'folds';
import { SettingTile } from '../../components/setting-tile';
import { SequenceCard } from '../../components/sequence-card';
import {
creatorsSupported,
knockRestrictedSupported,
knockSupported,
restrictedSupported,
} from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCapabilities } from '../../hooks/useCapabilities';
import { useAlive } from '../../hooks/useAlive';
import { ErrorCode } from '../../cs-errorcode';
import {
AdditionalCreatorInput,
createRoom,
CreateRoomAliasInput,
CreateRoomData,
CreateRoomKind,
CreateRoomKindSelector,
RoomVersionSelector,
useAdditionalCreators,
} from '../../components/create-room';
import { RoomType } from '../../../types/matrix/room';
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
if (kind === CreateRoomKind.Private) return Icons.HashLock;
if (kind === CreateRoomKind.Restricted) return Icons.Hash;
return Icons.HashGlobe;
};
type CreateRoomFormProps = {
defaultKind?: CreateRoomKind;
space?: Room;
onCreate?: (roomId: string) => void;
};
export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormProps) {
const mx = useMatrixClient();
const alive = useAlive();
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 [callRoom, setCallRoom] = useState(false);
const [knock, setKnock] = useState(false);
const [advance, setAdvance] = useState(false);
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
const allowKnockRestricted =
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
const handleRoomVersionChange = (version: string) => {
if (!restrictedSupported(version)) {
setKind(CreateRoomKind.Private);
}
selectRoomVersion(version);
};
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [CreateRoomData]>(
useCallback((data) => createRoom(mx, data), [mx])
);
const loading = createState.status === AsyncStatus.Loading;
const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
const disabled = createState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (disabled) return;
const form = evt.currentTarget;
const nameInput = form.nameInput as HTMLInputElement | undefined;
const topicTextArea = form.topicTextAria as HTMLTextAreaElement | undefined;
const aliasInput = form.aliasInput as HTMLInputElement | undefined;
const roomName = nameInput?.value.trim();
const roomTopic = topicTextArea?.value.trim();
const aliasLocalPart =
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
if (!roomName) return;
const publicRoom = kind === CreateRoomKind.Public;
let roomKnock = false;
if (allowKnock && kind === CreateRoomKind.Private) {
roomKnock = knock;
}
if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
roomKnock = knock;
}
create({
version: selectedRoomVersion,
type: callRoom ? RoomType.Call : undefined,
parent: space,
kind,
name: roomName,
topic: roomTopic || undefined,
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
encryption: publicRoom ? false : encryption,
knock: roomKnock,
allowFederation: federation,
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
}).then((roomId) => {
if (alive()) {
onCreate?.(roomId);
}
});
};
return (
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
<Box direction="Column" gap="100">
<Text size="L400">Access</Text>
<CreateRoomKindSelector
value={kind}
onSelect={setKind}
canRestrict={allowRestricted}
disabled={disabled}
getIcon={getCreateRoomKindToIcon}
/>
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Name</Text>
<Input
required
before={<Icon size="100" src={getCreateRoomKindToIcon(kind)} />}
name="nameInput"
autoFocus
size="500"
variant="SurfaceVariant"
radii="400"
autoComplete="off"
disabled={disabled}
/>
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Topic (Optional)</Text>
<TextArea
name="topicTextAria"
size="500"
variant="SurfaceVariant"
radii="400"
disabled={disabled}
/>
</Box>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="Call Room"
description="Enable this to create a room optimized for voice calls."
after={
<Switch
variant="Primary"
value={callRoom}
onChange={setCallRoom}
disabled={disabled}
/>
}
/>
</SequenceCard>
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
<Box shrink="No" direction="Column" gap="100">
<Box gap="200" alignItems="End">
<Text size="L400">Options</Text>
<Box grow="Yes" justifyContent="End">
<Chip
radii="Pill"
before={<Icon src={advance ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
onClick={() => setAdvance(!advance)}
type="button"
>
<Text size="T200">Advanced Options</Text>
</Chip>
</Box>
</Box>
{allowAdditionalCreators && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<AdditionalCreatorInput
additionalCreators={additionalCreators}
onSelect={addAdditionalCreator}
onRemove={removeAdditionalCreator}
/>
</SequenceCard>
)}
{kind !== CreateRoomKind.Public && (
<>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="End-to-End Encryption"
description="Once this feature is enabled, it can't be disabled after the room is created."
after={
<Switch
variant="Primary"
value={encryption}
onChange={setEncryption}
disabled={disabled}
/>
}
/>
</SequenceCard>
{advance && (allowKnock || allowKnockRestricted) && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="Knock to Join"
description="Anyone can send request to join this room."
after={
<Switch
variant="Primary"
value={knock}
onChange={setKnock}
disabled={disabled}
/>
}
/>
</SequenceCard>
)}
</>
)}
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="Allow Federation"
description="Users from other servers can join."
after={
<Switch
variant="Primary"
value={federation}
onChange={setFederation}
disabled={disabled}
/>
}
/>
</SequenceCard>
{advance && (
<RoomVersionSelector
versions={roomVersions?.available ? Object.keys(roomVersions.available) : ['1']}
value={selectedRoomVersion}
onChange={handleRoomVersionChange}
disabled={disabled}
/>
)}
</Box>
{error && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
<Icon src={Icons.Warning} filled size="100" />
<Text size="T300" style={{ color: color.Critical.Main }}>
<b>
{error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
? `Server rate-limited your request for ${millisecondsToMinutes(
(error.data.retry_after_ms as number | undefined) ?? 0
)} minutes!`
: error.message}
</b>
</Text>
</Box>
)}
<Box shrink="No" direction="Column" gap="200">
<Button
type="submit"
size="500"
variant="Primary"
radii="400"
disabled={disabled}
before={loading && <Spinner variant="Primary" fill="Solid" size="200" />}
>
<Text size="B500">Create</Text>
</Button>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,94 @@
import React from 'react';
import {
Box,
config,
Header,
Icon,
IconButton,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { SpaceProvider } from '../../hooks/useSpace';
import { CreateRoomForm } from './CreateRoom';
import {
useCloseCreateRoomModal,
useCreateRoomModalState,
} from '../../state/hooks/createRoomModal';
import { CreateRoomModalState } from '../../state/createRoomModal';
import { stopPropagation } from '../../utils/keyboard';
type CreateRoomModalProps = {
state: CreateRoomModalState;
};
function CreateRoomModal({ state }: CreateRoomModalProps) {
const { spaceId } = state;
const closeDialog = useCloseCreateRoomModal();
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const space = spaceId ? getRoom(spaceId) : undefined;
return (
<SpaceProvider value={space ?? null}>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: closeDialog,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" flexHeight>
<Box direction="Column">
<Header
size="500"
style={{
padding: config.space.S200,
paddingLeft: config.space.S400,
}}
>
<Box grow="Yes">
<Text size="H4">New Room</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={closeDialog}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Scroll size="300" hideTrack>
<Box
style={{
padding: config.space.S400,
paddingRight: config.space.S200,
}}
direction="Column"
gap="500"
>
<CreateRoomForm space={space} onCreate={closeDialog} />
</Box>
</Scroll>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
</SpaceProvider>
);
}
export function CreateRoomModalRenderer() {
const state = useCreateRoomModalState();
if (!state) return null;
return <CreateRoomModal state={state} />;
}

View File

@@ -0,0 +1,2 @@
export * from './CreateRoom';
export * from './CreateRoomModal';

View File

@@ -0,0 +1,279 @@
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk';
import {
Box,
Button,
Chip,
color,
config,
Icon,
Icons,
Input,
Spinner,
Switch,
Text,
TextArea,
} from 'folds';
import { SettingTile } from '../../components/setting-tile';
import { SequenceCard } from '../../components/sequence-card';
import {
creatorsSupported,
knockRestrictedSupported,
knockSupported,
restrictedSupported,
} from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCapabilities } from '../../hooks/useCapabilities';
import { useAlive } from '../../hooks/useAlive';
import { ErrorCode } from '../../cs-errorcode';
import {
AdditionalCreatorInput,
createRoom,
CreateRoomAliasInput,
CreateRoomData,
CreateRoomKind,
CreateRoomKindSelector,
RoomVersionSelector,
useAdditionalCreators,
} from '../../components/create-room';
import { RoomType } from '../../../types/matrix/room';
const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
if (kind === CreateRoomKind.Private) return Icons.SpaceLock;
if (kind === CreateRoomKind.Restricted) return Icons.Space;
return Icons.SpaceGlobe;
};
type CreateSpaceFormProps = {
defaultKind?: CreateRoomKind;
space?: Room;
onCreate?: (roomId: string) => void;
};
export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFormProps) {
const mx = useMatrixClient();
const alive = useAlive();
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 [knock, setKnock] = useState(false);
const [advance, setAdvance] = useState(false);
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
const allowKnockRestricted =
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
const handleRoomVersionChange = (version: string) => {
if (!restrictedSupported(version)) {
setKind(CreateRoomKind.Private);
}
selectRoomVersion(version);
};
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [CreateRoomData]>(
useCallback((data) => createRoom(mx, data), [mx])
);
const loading = createState.status === AsyncStatus.Loading;
const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
const disabled = createState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (disabled) return;
const form = evt.currentTarget;
const nameInput = form.nameInput as HTMLInputElement | undefined;
const topicTextArea = form.topicTextAria as HTMLTextAreaElement | undefined;
const aliasInput = form.aliasInput as HTMLInputElement | undefined;
const roomName = nameInput?.value.trim();
const roomTopic = topicTextArea?.value.trim();
const aliasLocalPart =
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
if (!roomName) return;
const publicRoom = kind === CreateRoomKind.Public;
let roomKnock = false;
if (allowKnock && kind === CreateRoomKind.Private) {
roomKnock = knock;
}
if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
roomKnock = knock;
}
create({
version: selectedRoomVersion,
type: RoomType.Space,
parent: space,
kind,
name: roomName,
topic: roomTopic || undefined,
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
knock: roomKnock,
allowFederation: federation,
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
}).then((roomId) => {
if (alive()) {
onCreate?.(roomId);
}
});
};
return (
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
<Box direction="Column" gap="100">
<Text size="L400">Access</Text>
<CreateRoomKindSelector
value={kind}
onSelect={setKind}
canRestrict={allowRestricted}
disabled={disabled}
getIcon={getCreateSpaceKindToIcon}
/>
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Name</Text>
<Input
required
before={<Icon size="100" src={getCreateSpaceKindToIcon(kind)} />}
name="nameInput"
autoFocus
size="500"
variant="SurfaceVariant"
radii="400"
autoComplete="off"
disabled={disabled}
/>
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Topic (Optional)</Text>
<TextArea
name="topicTextAria"
size="500"
variant="SurfaceVariant"
radii="400"
disabled={disabled}
/>
</Box>
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
<Box shrink="No" direction="Column" gap="100">
<Box gap="200" alignItems="End">
<Text size="L400">Options</Text>
<Box grow="Yes" justifyContent="End">
<Chip
radii="Pill"
before={<Icon src={advance ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
onClick={() => setAdvance(!advance)}
type="button"
>
<Text size="T200">Advanced Options</Text>
</Chip>
</Box>
</Box>
{allowAdditionalCreators && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<AdditionalCreatorInput
additionalCreators={additionalCreators}
onSelect={addAdditionalCreator}
onRemove={removeAdditionalCreator}
/>
</SequenceCard>
)}
{kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="Knock to Join"
description="Anyone can send request to join this space."
after={
<Switch variant="Primary" value={knock} onChange={setKnock} disabled={disabled} />
}
/>
</SequenceCard>
)}
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="Allow Federation"
description="Users from other servers can join."
after={
<Switch
variant="Primary"
value={federation}
onChange={setFederation}
disabled={disabled}
/>
}
/>
</SequenceCard>
{advance && (
<RoomVersionSelector
versions={roomVersions?.available ? Object.keys(roomVersions.available) : ['1']}
value={selectedRoomVersion}
onChange={handleRoomVersionChange}
disabled={disabled}
/>
)}
</Box>
{error && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
<Icon src={Icons.Warning} filled size="100" />
<Text size="T300" style={{ color: color.Critical.Main }}>
<b>
{error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
? `Server rate-limited your request for ${millisecondsToMinutes(
(error.data.retry_after_ms as number | undefined) ?? 0
)} minutes!`
: error.message}
</b>
</Text>
</Box>
)}
<Box shrink="No" direction="Column" gap="200">
<Button
type="submit"
size="500"
variant="Primary"
radii="400"
disabled={disabled}
before={loading && <Spinner variant="Primary" fill="Solid" size="200" />}
>
<Text size="B500">Create</Text>
</Button>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,95 @@
import React from 'react';
import {
Box,
config,
Header,
Icon,
IconButton,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { SpaceProvider } from '../../hooks/useSpace';
import { CreateSpaceForm } from './CreateSpace';
import {
useCloseCreateSpaceModal,
useCreateSpaceModalState,
} from '../../state/hooks/createSpaceModal';
import { CreateSpaceModalState } from '../../state/createSpaceModal';
import { stopPropagation } from '../../utils/keyboard';
type CreateSpaceModalProps = {
state: CreateSpaceModalState;
};
function CreateSpaceModal({ state }: CreateSpaceModalProps) {
const { spaceId } = state;
const closeDialog = useCloseCreateSpaceModal();
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const space = spaceId ? getRoom(spaceId) : undefined;
return (
<SpaceProvider value={space ?? null}>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: closeDialog,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" flexHeight>
<Box direction="Column">
<Header
size="500"
style={{
padding: config.space.S200,
paddingLeft: config.space.S400,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Box grow="Yes">
<Text size="H4">New Space</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={closeDialog}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Scroll size="300" hideTrack>
<Box
style={{
padding: config.space.S400,
paddingRight: config.space.S200,
}}
direction="Column"
gap="500"
>
<CreateSpaceForm space={space} onCreate={closeDialog} />
</Box>
</Scroll>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
</SpaceProvider>
);
}
export function CreateSpaceModalRenderer() {
const state = useCreateSpaceModalState();
if (!state) return null;
return <CreateSpaceModal state={state} />;
}

View File

@@ -0,0 +1,2 @@
export * from './CreateSpace';
export * from './CreateSpaceModal';

View File

@@ -18,7 +18,6 @@ import {
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
import { openInviteUser } from '../../../client/action/navigation';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
@@ -27,6 +26,10 @@ import { stopPropagation } from '../../utils/keyboard';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { IPowerLevels } from '../../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
type HierarchyItemWithParent = HierarchyItem & {
parentId: string;
@@ -45,7 +48,7 @@ function SuggestMenuItem({
const [toggleState, handleToggleSuggested] = useAsyncCallback(
useCallback(() => {
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]
)
);
@@ -123,24 +126,39 @@ function InviteMenuItem({
requestClose: () => void;
disabled?: boolean;
}) {
const mx = useMatrixClient();
const room = mx.getRoom(item.roomId);
const [invitePrompt, setInvitePrompt] = useState(false);
const handleInvite = () => {
openInviteUser(item.roomId);
requestClose();
setInvitePrompt(true);
};
return (
<MenuItem
onClick={handleInvite}
size="300"
radii="300"
variant="Primary"
fill="None"
disabled={disabled}
>
<Text as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
<>
<MenuItem
onClick={handleInvite}
size="300"
radii="300"
variant="Primary"
fill="None"
aria-pressed={invitePrompt}
disabled={disabled || !room}
>
<Text as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
</>
);
}
@@ -180,7 +198,7 @@ type HierarchyItemMenuProps = {
parentId: string;
};
joined: boolean;
canInvite: boolean;
powerLevels?: IPowerLevels;
canEditChild: boolean;
pinned?: boolean;
onTogglePin?: (roomId: string) => void;
@@ -188,13 +206,22 @@ type HierarchyItemMenuProps = {
export function HierarchyItemMenu({
item,
joined,
canInvite,
powerLevels,
canEditChild,
pinned,
onTogglePin,
}: HierarchyItemMenuProps) {
const mx = useMatrixClient();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
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<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
@@ -254,7 +281,7 @@ export function HierarchyItemMenu({
<InviteMenuItem
item={item}
requestClose={handleRequestClose}
disabled={!canInvite}
disabled={!canInvite()}
/>
<SettingsMenuItem item={item} requestClose={handleRequestClose} />
<UseStateProvider initial={false}>

View File

@@ -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<string, IPowerLevels>,
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<HierarchyItem>();
const hierarchy = useSpaceHierarchy(
space.roomId,
@@ -220,23 +215,16 @@ export function Lobby() {
() =>
hierarchy
.flatMap((i) => {
const childRooms = Array.isArray(i.rooms)
? i.rooms.map((r) => mx.getRoom(r.roomId))
: [];
const childRooms = Array.isArray(i.rooms) ? i.rooms.map((r) => getRoom(r.roomId)) : [];
return [mx.getRoom(i.space.roomId), ...childRooms];
return [getRoom(i.space.roomId), ...childRooms];
})
.filter((r) => !!r) as Room[],
[mx, hierarchy]
[hierarchy, getRoom]
)
);
const canDrop: CanDropCallback = useCanDropLobbyItem(
space,
roomsPowerLevels,
getRoom,
canEditSpaceChild
);
const canDrop: CanDropCallback = useCanDropLobbyItem(space, roomsPowerLevels, getRoom);
const [reorderSpaceState, reorderSpace] = useAsyncCallback(
useCallback(
@@ -272,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];
});
@@ -288,7 +280,7 @@ export function Lobby() {
});
}
},
[mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
[mx, hierarchy, lex, roomsPowerLevels]
)
);
const reorderingSpace = reorderSpaceState.status === AsyncStatus.Loading;
@@ -430,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]
);
@@ -495,7 +487,6 @@ export function Lobby() {
allJoinedRooms={allJoinedRooms}
mDirects={mDirects}
roomsPowerLevels={roomsPowerLevels}
canEditSpaceChild={canEditSpaceChild}
categoryId={categoryId}
closed={
closedCategories.has(categoryId) ||

View File

@@ -26,8 +26,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
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,31 +35,46 @@ 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';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
type LobbyMenuProps = {
roomId: string;
powerLevels: IPowerLevels;
requestClose: () => void;
};
const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
({ 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 [invitePrompt, setInvitePrompt] = useState(false);
const handleInvite = () => {
openInviteUser(roomId);
requestClose();
setInvitePrompt(true);
};
const handleRoomSettings = () => {
openSpaceSettings(roomId);
openSpaceSettings(space.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && (
<InviteUserPrompt
room={space}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
@@ -69,6 +83,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
@@ -106,7 +121,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
</MenuItem>
{promptLeave && (
<LeaveSpacePrompt
roomId={roomId}
roomId={space.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
@@ -242,7 +257,6 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
}}
>
<LobbyMenu
roomId={space.roomId}
powerLevels={powerLevels}
requestClose={() => setMenuAnchor(undefined)}
/>

View File

@@ -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<string>;
mDirects: Set<string>;
roomsPowerLevels: Map<string, IPowerLevels>;
canEditSpaceChild: (powerLevels: IPowerLevels) => boolean;
categoryId: string;
closed: boolean;
handleClose: MouseEventHandler<HTMLButtonElement>;
@@ -48,7 +49,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
allJoinedRooms,
mDirects,
roomsPowerLevels,
canEditSpaceChild,
categoryId,
closed,
handleClose,
@@ -79,25 +79,28 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
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<HTMLDivElement, SpaceHierarchyProps>(
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 && (
<HierarchyItemMenu
item={{ ...spaceItem, parentId }}
canInvite={canInviteInSpace}
powerLevels={spacePowerLevels}
joined={allJoinedRooms.has(spaceItem.roomId)}
canEditChild={canEditSpaceChild(parentPowerLevels)}
canEditChild={
!!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
}
pinned={pinned}
onTogglePin={togglePinToSidebar}
/>
@@ -151,15 +158,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
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<HTMLDivElement, SpaceHierarchyProps>(
dm={mDirects.has(roomItem.roomId)}
onOpen={onOpenRoom}
getRoom={getRoom}
canReorder={canEditSpaceChild(spacePowerLevels) && !disabledReorder}
canReorder={
!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) &&
!disabledReorder
}
options={
<HierarchyItemMenu
item={roomItem}
canInvite={canInviteInRoom}
powerLevels={roomPowerLevels}
joined={allJoinedRooms.has(roomItem.roomId)}
canEditChild={canEditSpaceChild(spacePowerLevels)}
canEditChild={
!!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
}
/>
}
after={

View File

@@ -30,10 +30,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import * as css from './SpaceItem.css';
import * as styleCss from './style.css';
import { useDraggableItem } from './DnD';
import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
import { stopPropagation } from '../../utils/keyboard';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
import { AddExistingModal } from '../add-existing';
function SpaceProfileLoading() {
return (
@@ -240,18 +242,20 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP
function AddRoomButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>();
const openCreateRoomModal = useOpenCreateRoomModal();
const [addExisting, setAddExisting] = useState(false);
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateRoom = () => {
openCreateRoom(false, item.roomId as any);
openCreateRoomModal(item.roomId);
setCords(undefined);
};
const handleAddExisting = () => {
openSpaceAddExisting(item.roomId);
setAddExisting(true);
setCords(undefined);
};
@@ -297,24 +301,29 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
>
<Text size="B300">Add Room</Text>
</Chip>
{addExisting && (
<AddExistingModal parentId={item.roomId} requestClose={() => setAddExisting(false)} />
)}
</PopOut>
);
}
function AddSpaceButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>();
const openCreateSpaceModal = useOpenCreateSpaceModal();
const [addExisting, setAddExisting] = useState(false);
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateSpace = () => {
openCreateRoom(true, item.roomId as any);
openCreateSpaceModal(item.roomId as any);
setCords(undefined);
};
const handleAddExisting = () => {
openSpaceAddExisting(item.roomId, true);
setAddExisting(true);
setCords(undefined);
};
return (
@@ -359,6 +368,9 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
>
<Text size="B300">Add Space</Text>
</Chip>
{addExisting && (
<AddExistingModal space parentId={item.roomId} requestClose={() => setAddExisting(false)} />
)}
</PopOut>
);
}
@@ -470,7 +482,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
</>
)}
</Box>
{canEditChild && (
{space && canEditChild && (
<Box shrink="No" alignItems="Inherit" gap="200">
<AddRoomButton item={item} />
{item.parentId === undefined && <AddSpaceButton item={item} />}

View File

@@ -57,6 +57,9 @@ export function MessageSearch({
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const searchInputRef = useRef<HTMLInputElement>(null);
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
const [searchParams, setSearchParams] = useSearchParams();
@@ -289,6 +292,8 @@ export function MessageSearch({
urlPreview={urlPreview}
onOpen={navigateRoom}
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</VirtualTile>
);

View File

@@ -29,6 +29,7 @@ export function SearchInput({ active, loading, searchInputRef, onSearch, onReset
ref={searchInputRef}
style={{ paddingRight: config.space.S300 }}
name="searchInput"
autoFocus
size="500"
variant="Background"
placeholder="Search for keyword"

View File

@@ -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;
@@ -57,6 +60,8 @@ type SearchResultGroupProps = {
urlPreview?: boolean;
onOpen: (roomId: string, eventId: string) => void;
legacyUsernameColor?: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
export function SearchResultGroup({
room,
@@ -66,16 +71,22 @@ export function SearchResultGroup({
urlPreview,
onOpen,
legacyUsernameColor,
hour24Clock,
dateFormatString,
}: SearchResultGroupProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
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();
@@ -222,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;
@@ -275,7 +285,11 @@ export function SearchResultGroup({
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Time ts={event.origin_server_ts} />
<Time
ts={event.origin_server_ts}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</Box>
<Box shrink="No" gap="200" alignItems="Center">
<Chip
@@ -294,8 +308,7 @@ export function SearchResultGroup({
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenClick}
getPowerLevel={getPowerLevel}
getPowerLevelTag={getPowerLevelTag}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor}
/>

View File

@@ -30,10 +30,9 @@ 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';
import { markAsRead } from '../../utils/notifications';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
@@ -52,6 +51,9 @@ import {
RoomNotificationMode,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { useCallState } from '../../pages/client/call/CallProvider';
import { useCallMembers } from '../../hooks/useCallMemberships';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
@@ -69,19 +71,22 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
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();
const [invitePrompt, setInvitePrompt] = useState(false);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
const handleInvite = () => {
openInviteUser(room.roomId);
requestClose();
setInvitePrompt(true);
};
const handleCopyLink = () => {
@@ -98,6 +103,15 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
@@ -141,6 +155,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
@@ -273,7 +288,7 @@ export function RoomNavItem({
if (mx.getRoom(viewedRoomId)?.isCallRoom()) {
navigateRoom(room.roomId);
}
hangUp(room.roomId);
hangUp();
setActiveCallRoomId(room.roomId);
} else {
navigateRoom(room.roomId);

View File

@@ -9,7 +9,8 @@ import { useCallState } from '../../pages/client/call/CallProvider';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { openProfileViewer } from '../../../client/action/navigation';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
type RoomNavUserProps = {
room: Room;
@@ -18,6 +19,8 @@ type RoomNavUserProps = {
export function RoomNavUser({ room, callMembership }: RoomNavUserProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const { isActiveCallReady, activeCallRoomId } = useCallState();
const isActiveCall = isActiveCallReady && activeCallRoomId === room.roomId;
const userId = callMembership.sender ?? '';
@@ -28,8 +31,8 @@ export function RoomNavUser({ room, callMembership }: RoomNavUserProps) {
const getName = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId);
const isCallParticipant = isActiveCall && userId !== mx.getUserId();
const handleNavUserClick = () => {
openProfileViewer(userId, room.roomId);
const handleNavUserClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect());
};
const ariaLabel = isCallParticipant ? `Call Participant: ${getName}` : getName;

View File

@@ -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 (
<Page>
@@ -41,22 +45,22 @@ export function General({ requestClose }: GeneralProps) {
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<RoomProfile powerLevels={powerLevels} />
<RoomProfile permissions={permissions} />
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<RoomJoinRules powerLevels={powerLevels} />
<RoomHistoryVisibility powerLevels={powerLevels} />
<RoomEncryption powerLevels={powerLevels} />
<RoomPublish powerLevels={powerLevels} />
<RoomJoinRules permissions={permissions} />
<RoomHistoryVisibility permissions={permissions} />
<RoomEncryption permissions={permissions} />
<RoomPublish permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Addresses</Text>
<RoomPublishedAddresses powerLevels={powerLevels} />
<RoomLocalAddresses powerLevels={powerLevels} />
<RoomPublishedAddresses permissions={permissions} />
<RoomLocalAddresses permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Advance Options</Text>
<RoomUpgrade powerLevels={powerLevels} requestClose={requestClose} />
<Text size="L400">Advanced Options</Text>
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
</Box>
</Box>
</PageContent>

View File

@@ -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}
/>
<PermissionGroups powerLevels={powerLevels} permissionGroups={permissionGroups} />
<PermissionGroups
canEdit={canEditPermissions}
powerLevels={powerLevels}
permissionGroups={permissionGroups}
/>
</Box>
</PageContent>
</Scroll>

View File

@@ -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({

View File

@@ -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 { useGetMemberPowerLevel, 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 (
<Header className={css.MembersDrawerHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text title={`${room.getJoinedMemberCount()} Members`} size="H5" truncate>
{`${millify(room.getJoinedMemberCount())} Members`}
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
onClick={() => setPeopleDrawer(false)}
>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</Header>
);
}
type MemberItemProps = {
mx: MatrixClient;
useAuthentication: boolean;
room: Room;
member: RoomMember;
onClick: MouseEventHandler<HTMLButtonElement>;
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 (
<MenuItem
style={{ padding: `0 ${config.space.S200}` }}
aria-pressed={pressed}
data-user-id={member.userId}
variant="Background"
radii="400"
onClick={onClick}
before={
<Avatar size="200">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
after={
typing && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
<TypingIndicator size="300" />
</Badge>
)
}
>
<Box grow="Yes">
<Text size="T400" truncate>
{name}
</Text>
</Box>
</MenuItem>
);
}
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@@ -79,28 +183,29 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const searchInputRef = useRef<HTMLInputElement>(null);
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
const powerLevels = usePowerLevelsContext();
const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const creators = useRoomCreators(room);
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const getPowerLevel = useGetMemberPowerLevel(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, getPowerLevel);
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 +217,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 +237,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<HTMLButtonElement> = (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 (
<Box className={css.MembersDrawer} shrink="No" direction="Column">
<Header className={css.MembersDrawerHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text title={`${room.getJoinedMemberCount()} Members`} size="H5" truncate>
{`${millify(room.getJoinedMemberCount())} Members`}
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
onClick={() => setPeopleDrawer(false)}
>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</Header>
<Box
className={classNames(css.MembersDrawer, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
>
<MemberDrawerHeader room={room} />
<Box className={css.MemberDrawerContentBase} grow="Yes">
<Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
<Box className={css.MemberDrawerContent} direction="Column" gap="200">
@@ -329,59 +402,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 (
<MenuItem
<div
style={{
padding: `0 ${config.space.S200}`,
transform: `translateY(${vItem.start}px)`,
}}
data-index={vItem.index}
data-user-id={member.userId}
ref={virtualizer.measureElement}
key={`${room.roomId}-${member.userId}`}
className={css.DrawerVirtualItem}
variant="Background"
radii="400"
onClick={handleMemberClick}
before={
<Avatar size="200">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
after={
typingMembers.find((receipt) => receipt.userId === member.userId) && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
<TypingIndicator size="300" />
</Badge>
)
}
data-index={vItem.index}
key={`${room.roomId}-${tagOrMember.userId}`}
ref={virtualizer.measureElement}
>
<Box grow="Yes">
<Text size="T400" truncate>
{name}
</Text>
</Box>
</MenuItem>
<MemberItem
mx={mx}
useAuthentication={useAuthentication}
room={room}
member={tagOrMember}
onClick={handleMemberClick}
pressed={openProfileUserId === tagOrMember.userId}
typing={typingMembers.some(
(receipt) => receipt.userId === tagOrMember.userId
)}
/>
</div>
);
})}
</div>

View File

@@ -10,7 +10,7 @@ import { settingsAtom } from '../../state/settings';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
import { useKeyDown } from '../../hooks/useKeyDown';
import { markAsRead } from '../../../client/action/notifications';
import { markAsRead } from '../../utils/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { CallView } from '../call/CallView';

View File

@@ -108,21 +108,24 @@ 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';
import { useComposingCheck } from '../../hooks/useComposingCheck';
interface RoomInputProps {
editor: Editor;
fileDropContainerRef: RefObject<HTMLElement>;
roomId: string;
room: Room;
getPowerLevelTag: GetPowerLevelTag;
accessibleTagColors: Map<string, string>;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ 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 +137,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const emojiBtnRef = useRef<HTMLButtonElement>(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 =
@@ -204,6 +218,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
const isComposing = useComposingCheck();
useElementSizeObserver(
useCallback(() => document.body, []),
useCallback((width) => setHideStickerBtn(width < 500), [])
@@ -277,7 +293,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
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 +372,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false;
}
}
mx.sendMessage(roomId, content);
mx.sendMessage(roomId, content as any);
resetEditor(editor);
resetEditorHistory(editor);
setReplyDraft(undefined);
@@ -367,7 +383,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
(evt) => {
if (
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
!evt.nativeEvent.isComposing
!isComposing(evt)
) {
evt.preventDefault();
submit();
@@ -381,7 +397,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setReplyDraft(undefined);
}
},
[submit, setReplyDraft, enterForNewline, autocompleteQuery]
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing]
);
const handleKeyUp: KeyboardEventHandler = useCallback(
@@ -543,7 +559,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Column">
<Box direction="Row" gap="200" alignItems="Center">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
userColor={replyUsernameColor}

View File

@@ -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';
@@ -95,14 +94,14 @@ import {
getIntersectionObserverEntry,
useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import { markAsRead } from '../../../client/action/notifications';
import { markAsRead } from '../../utils/notifications';
import { useDebounce } from '../../hooks/useDebounce';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
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<HTMLElement>;
editor: Editor;
getPowerLevelTag: GetPowerLevelTag;
accessibleTagColors: Map<string, string>;
};
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');
@@ -448,19 +445,34 @@ export function RoomTimeline({
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const ignoredUsersList = useIgnoredUsers();
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
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<string>();
const roomToParents = useAtomValue(roomToParentsAtom);
@@ -468,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);
@@ -905,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<HTMLButtonElement> = useCallback(
(evt) => {
@@ -932,7 +951,7 @@ export function RoomTimeline({
);
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
(evt, startThread = false) => {
const replyId = evt.currentTarget.getAttribute('data-event-id');
if (!replyId) {
console.warn('Button should have "data-event-id" attribute!');
@@ -943,7 +962,9 @@ export function RoomTimeline({
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = replyEvt.getWireContent();
const { 'm.relates_to': relation } = startThread
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
: replyEvt.getWireContent();
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
@@ -976,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)
);
},
@@ -1011,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;
@@ -1045,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}
/>
)
@@ -1065,9 +1084,12 @@ export function RoomTimeline({
)
}
hideReadReceipts={hideActivity}
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
accessibleTagColors={accessibleTagColors}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@@ -1094,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 (
<Message
@@ -1126,9 +1147,8 @@ export function RoomTimeline({
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
getPowerLevel={getPowerLevel}
getPowerLevelTag={getPowerLevelTag}
accessibleTagColors={accessibleTagColors}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
/>
)
@@ -1146,9 +1166,12 @@ export function RoomTimeline({
)
}
hideReadReceipts={hideActivity}
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
accessibleTagColors={accessibleTagColors}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
<EncryptedContent mEvent={mEvent}>
{() => {
@@ -1212,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 (
<Message
@@ -1247,9 +1269,12 @@ export function RoomTimeline({
)
}
hideReadReceipts={hideActivity}
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
accessibleTagColors={accessibleTagColors}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@@ -1278,7 +1303,12 @@ export function RoomTimeline({
const parsed = parseMemberEvent(mEvent);
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
@@ -1292,6 +1322,7 @@ export function RoomTimeline({
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
@@ -1314,7 +1345,12 @@ export function RoomTimeline({
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
@@ -1328,6 +1364,7 @@ export function RoomTimeline({
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
@@ -1351,7 +1388,12 @@ export function RoomTimeline({
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
@@ -1365,6 +1407,7 @@ export function RoomTimeline({
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
@@ -1388,7 +1431,12 @@ export function RoomTimeline({
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
@@ -1402,6 +1450,7 @@ export function RoomTimeline({
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
@@ -1427,7 +1476,12 @@ export function RoomTimeline({
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
@@ -1441,6 +1495,7 @@ export function RoomTimeline({
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
@@ -1471,7 +1526,12 @@ export function RoomTimeline({
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
@@ -1485,6 +1545,7 @@ export function RoomTimeline({
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}

View File

@@ -5,7 +5,7 @@ import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useEditor } from '../../components/editor';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
@@ -17,21 +17,14 @@ import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollo
import { Page } from '../../components/page';
import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom';
import navigation from '../../../client/state/navigation';
import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings';
import { useAccessibleTagColors, usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useTheme } from '../../hooks/useTheme';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useCallState } from '../../pages/client/call/CallProvider';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
const FN_KEYS_REGEX = /^F\d+$/;
/**
* Determines if a keyboard event should trigger focusing the message input field.
* @param evt - The KeyboardEvent.
* @returns True if the input should be focused, false otherwise.
*/
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
const { code } = evt;
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
@@ -58,42 +51,41 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
) {
return false;
}
return true;
};
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const { isChatOpen } = useCallState();
const { roomId } = room;
const editor = useEditor();
const mx = useMatrixClient();
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 [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const theme = useTheme();
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
useKeyDown(
window,
useCallback(
(evt) => {
if (editableActiveElement()) return;
if (document.querySelector('.ReactModalPortal > *') || navigation.isRawModalVisible) {
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
return;
}
if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) {
if (editor) {
ReactEditor.focus(editor);
}
ReactEditor.focus(editor);
}
},
[editor]
@@ -117,14 +109,11 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
getPowerLevelTag={getPowerLevelTag}
accessibleTagColors={accessibleTagColors}
/>
<RoomViewTyping room={room} />
</Box>
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
{' '}
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
@@ -132,19 +121,17 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
/* eslint-disable-next-line react/jsx-no-useless-fragment */
<>
{canMessage ? (
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
getPowerLevelTag={getPowerLevelTag}
accessibleTagColors={accessibleTagColors}
/>
) : (
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"

View File

@@ -33,7 +33,7 @@ import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useSetSetting, useSetting } from '../../state/hooks/settings';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
@@ -41,10 +41,9 @@ 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 { markAsRead } from '../../../client/action/notifications';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { markAsRead } from '../../utils/notifications';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { openInviteUser } from '../../../client/action/navigation';
import { copyToClipboard } from '../../utils/dom';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
@@ -63,6 +62,11 @@ import {
getRoomNotificationModeIcon,
useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences';
import { JumpToTime } from './jump-to-time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { useCallState } from '../../pages/client/call/CallProvider';
type RoomMenuProps = {
@@ -74,10 +78,15 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ 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();
const [invitePrompt, setInvitePrompt] = useState(false);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
@@ -85,8 +94,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
};
const handleInvite = () => {
openInviteUser(room.roomId);
requestClose();
setInvitePrompt(true);
};
const handleCopyLink = () => {
@@ -105,6 +113,15 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
@@ -148,6 +165,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
@@ -174,6 +192,33 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
Room Settings
</Text>
</MenuItem>
<UseStateProvider initial={false}>
{(promptJump, setPromptJump) => (
<>
<MenuItem
onClick={() => setPromptJump(true)}
size="300"
after={<Icon size="100" src={Icons.RecentClock} />}
radii="300"
aria-pressed={promptJump}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Jump to Time
</Text>
</MenuItem>
{promptJump && (
<JumpToTime
onSubmit={(eventId) => {
setPromptJump(false);
navigateRoom(room.roomId, eventId);
requestClose();
}}
onCancel={() => setPromptJump(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
@@ -230,7 +275,7 @@ export function RoomViewHeader() {
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const handleSearchClick = () => {
const searchParams: _SearchPathSearchParams = {
@@ -410,7 +455,7 @@ export function RoomViewHeader() {
offset={4}
tooltip={
<Tooltip>
<Text>Members</Text>
<Text>{peopleDrawer ? 'Hide Members' : 'Show Members'}</Text>
</Tooltip>
}
>

View File

@@ -0,0 +1,260 @@
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
Icon,
Icons,
color,
Button,
Spinner,
Chip,
PopOut,
RectCords,
} from 'folds';
import { Direction, MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { stopPropagation } from '../../../utils/keyboard';
import { useAlive } from '../../../hooks/useAlive';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time';
import { DatePicker, TimePicker } from '../../../components/time-date';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
type JumpToTimeProps = {
onCancel: () => void;
onSubmit: (eventId: string) => void;
};
export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
const mx = useMatrixClient();
const room = useRoom();
const alive = useAlive();
const createStateEvent = useStateEvent(room, StateEvent.RoomCreate);
const todayTs = getToday();
const yesterdayTs = getYesterday();
const createTs = useMemo(() => createStateEvent?.getTs() ?? 0, [createStateEvent]);
const [ts, setTs] = useState(() => Date.now());
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [timePickerCords, setTimePickerCords] = useState<RectCords>();
const [datePickerCords, setDatePickerCords] = useState<RectCords>();
const handleTimePicker: MouseEventHandler<HTMLButtonElement> = (evt) => {
setTimePickerCords(evt.currentTarget.getBoundingClientRect());
};
const handleDatePicker: MouseEventHandler<HTMLButtonElement> = (evt) => {
setDatePickerCords(evt.currentTarget.getBoundingClientRect());
};
const handleToday = () => {
setTs(todayTs < createTs ? createTs : todayTs);
};
const handleYesterday = () => {
setTs(yesterdayTs < createTs ? createTs : yesterdayTs);
};
const handleBeginning = () => setTs(createTs);
const [timestampState, timestampToEvent] = useAsyncCallback<string, MatrixError, [number]>(
useCallback(
async (newTs) => {
const result = await mx.timestampToEvent(room.roomId, newTs, Direction.Forward);
return result.event_id;
},
[mx, room]
)
);
const handleSubmit = () => {
timestampToEvent(ts).then((eventId) => {
if (alive()) {
onSubmit(eventId);
}
});
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Jump to Time</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="500">
<Box direction="Row" gap="300">
<Box direction="Column" gap="100">
<Text size="L400" priority="400">
Time
</Text>
<Box gap="100" alignItems="Center">
<Chip
size="500"
variant="Surface"
fill="None"
outlined
radii="300"
aria-pressed={!!timePickerCords}
after={<Icon size="50" src={Icons.ChevronBottom} />}
onClick={handleTimePicker}
>
<Text size="B300">{timeHourMinute(ts, hour24Clock)}</Text>
</Chip>
<PopOut
anchor={timePickerCords}
offset={5}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setTimePickerCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<TimePicker min={createTs} max={Date.now()} value={ts} onChange={setTs} />
</FocusTrap>
}
/>
</Box>
</Box>
<Box direction="Column" gap="100">
<Text size="L400" priority="400">
Date
</Text>
<Box gap="100" alignItems="Center">
<Chip
size="500"
variant="Surface"
fill="None"
outlined
radii="300"
aria-pressed={!!datePickerCords}
after={<Icon size="50" src={Icons.ChevronBottom} />}
onClick={handleDatePicker}
>
<Text size="B300">{timeDayMonthYear(ts)}</Text>
</Chip>
<PopOut
anchor={datePickerCords}
offset={5}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setDatePickerCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<DatePicker min={createTs} max={Date.now()} value={ts} onChange={setTs} />
</FocusTrap>
}
/>
</Box>
</Box>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Preset</Text>
<Box gap="200">
{createTs < todayTs && (
<Chip
variant={ts === todayTs ? 'Success' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={ts === todayTs}
onClick={handleToday}
>
<Text size="B300">Today</Text>
</Chip>
)}
{createTs < yesterdayTs && (
<Chip
variant={ts === yesterdayTs ? 'Success' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={ts === yesterdayTs}
onClick={handleYesterday}
>
<Text size="B300">Yesterday</Text>
</Chip>
)}
<Chip
variant={ts === createTs ? 'Success' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={ts === createTs}
onClick={handleBeginning}
>
<Text size="B300">Beginning</Text>
</Chip>
</Box>
</Box>
{timestampState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
{timestampState.error.message}
</Text>
)}
<Button
type="submit"
variant="Primary"
before={
timestampState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" variant="Primary" size="200" />
) : undefined
}
aria-disabled={
timestampState.status === AsyncStatus.Loading ||
timestampState.status === AsyncStatus.Success
}
onClick={handleSubmit}
>
<Text size="B400">Open Timeline</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

@@ -0,0 +1 @@
export * from './JumpToTime';

View File

@@ -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?.();
};
@@ -669,15 +669,21 @@ export type MessageProps = {
messageSpacing: MessageSpacing;
onUserClick: MouseEventHandler<HTMLButtonElement>;
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
onReplyClick: MouseEventHandler<HTMLButtonElement>;
onReplyClick: (
ev: Parameters<MouseEventHandler<HTMLButtonElement>>[0],
startThread?: boolean
) => void;
onEditId?: (eventId?: string) => void;
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
reply?: ReactNode;
reactions?: ReactNode;
hideReadReceipts?: boolean;
powerLevelTag?: PowerLevelTag;
showDeveloperTools?: boolean;
memberPowerTag?: MemberPowerTag;
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
export const Message = as<'div', MessageProps>(
(
@@ -703,9 +709,12 @@ export const Message = as<'div', MessageProps>(
reply,
reactions,
hideReadReceipts,
powerLevelTag,
showDeveloperTools,
memberPowerTag,
accessibleTagColors,
legacyUsernameColor,
hour24Clock,
dateFormatString,
children,
...props
},
@@ -714,6 +723,7 @@ export const Message = as<'div', MessageProps>(
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const senderId = mEvent.getSender() ?? '';
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
@@ -724,11 +734,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;
@@ -770,13 +780,20 @@ export const Message = as<'div', MessageProps>(
</Text>
</>
)}
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</Box>
</Box>
);
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
<AvatarBase>
<AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
>
<Avatar
className={css.MessageAvatar}
as="button"
@@ -857,9 +874,13 @@ export const Message = as<'div', MessageProps>(
}, 100);
};
const isThreadedMessage = mEvent.threadRootId !== undefined;
return (
<MessageBase
className={classNames(css.MessageBase, className)}
className={classNames(css.MessageBase, className, {
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
})}
tabIndex={0}
space={messageSpacing}
collapse={collapse}
@@ -919,6 +940,17 @@ export const Message = as<'div', MessageProps>(
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
{!isThreadedMessage && (
<IconButton
onClick={(ev) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.ThreadPlus} size="100" />
</IconButton>
)}
{canEditEvent(mx, mEvent) && onEditId && (
<IconButton
onClick={() => onEditId(mEvent.getId())}
@@ -998,6 +1030,27 @@ export const Message = as<'div', MessageProps>(
Reply
</Text>
</MenuItem>
{!isThreadedMessage && (
<MenuItem
size="300"
after={<Icon src={Icons.ThreadPlus} size="100" />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onReplyClick(evt, true);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Reply in Thread
</Text>
</MenuItem>
)}
{canEditEvent(mx, mEvent) && onEditId && (
<MenuItem
size="300"
@@ -1026,7 +1079,13 @@ export const Message = as<'div', MessageProps>(
onClose={closeMenu}
/>
)}
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
{showDeveloperTools && (
<MessageSourceCodeItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
@@ -1078,8 +1137,7 @@ export const Message = as<'div', MessageProps>(
</CompactLayout>
)}
{messageLayout === MessageLayout.Bubble && (
<BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX}
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX}
</BubbleLayout>
)}
@@ -1101,6 +1159,7 @@ export type EventProps = {
canDelete?: boolean;
messageSpacing: MessageSpacing;
hideReadReceipts?: boolean;
showDeveloperTools?: boolean;
};
export const Event = as<'div', EventProps>(
(
@@ -1112,6 +1171,7 @@ export const Event = as<'div', EventProps>(
canDelete,
messageSpacing,
hideReadReceipts,
showDeveloperTools,
children,
...props
},
@@ -1188,7 +1248,13 @@ export const Event = as<'div', EventProps>(
onClose={closeMenu}
/>
)}
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
{showDeveloperTools && (
<MessageSourceCodeItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
</Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||

View File

@@ -53,6 +53,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
import { mobileOrTablet } from '../../../utils/user-agent';
import { useComposingCheck } from '../../../hooks/useComposingCheck';
type MessageEditorProps = {
roomId: string;
@@ -69,6 +70,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [toolbar, setToolbar] = useState(globalToolbar);
const isComposing = useComposingCheck();
const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>();
@@ -163,7 +165,10 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
if ((isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && !evt.nativeEvent.isComposing) {
if (
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
!isComposing(evt)
) {
evt.preventDefault();
handleSave();
}
@@ -172,7 +177,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
onCancel();
}
},
[onCancel, handleSave, enterForNewline]
[onCancel, handleSave, enterForNewline, isComposing]
);
const handleKeyUp: KeyboardEventHandler = useCallback(

View File

@@ -4,6 +4,9 @@ import { DefaultReset, config, toRem } from 'folds';
export const MessageBase = style({
position: 'relative',
});
export const MessageBaseBubbleCollapsed = style({
paddingTop: 0,
});
export const MessageOptionsBase = style([
DefaultReset,
@@ -21,6 +24,10 @@ export const MessageOptionsBar = style([
},
]);
export const BubbleAvatarBase = style({
paddingTop: 0,
});
export const MessageAvatar = style({
cursor: 'pointer',
});

View File

@@ -82,7 +82,7 @@ export const getVideoMsgContent = async (
item: TUploadItem,
mxc: string
): Promise<IContent> => {
const { file, originalFile, encInfo } = item;
const { file, originalFile, encInfo, metadata } = item;
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
if (videoError) console.warn(videoError);
@@ -91,6 +91,7 @@ export const getVideoMsgContent = async (
msgtype: MsgType.Video,
filename: file.name,
body: file.name,
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
};
if (videoEl) {
const [thumbError, thumbContent] = await to(

View File

@@ -20,12 +20,14 @@ 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';
import { getMouseEventCords } from '../../../utils/dom';
export type ReactionViewerProps = {
room: Room;
@@ -41,6 +43,8 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
relations,
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
);
const space = useSpaceOptionally();
const openProfile = useOpenUserRoomProfile();
const [selectedKey, setSelectedKey] = useState<string>(() => {
if (initialKey) return initialKey;
@@ -111,24 +115,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 (
<MenuItem
key={senderId}
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
onClick={() => {
requestClose();
openProfileViewer(senderId, room.roomId);
onClick={(event) => {
openProfile(
room.roomId,
space?.roomId,
senderId,
getMouseEventCords(event.nativeEvent),
'Bottom'
);
}}
before={
<Avatar size="200">

View File

@@ -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,19 +93,27 @@ type PinnedMessageProps = {
renderContent: RenderMatrixEvent<[MatrixEvent, string, GetContentCallback]>;
onOpen: (roomId: string, eventId: string) => void;
canPinEvent: boolean;
getMemberPowerTag: GetMemberPowerTag;
accessibleTagColors: Map<string, string>;
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 [unpinState, unpin] = useAsyncCallback(
useCallback(() => {
@@ -166,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 (
<ModernLayout
@@ -205,7 +219,11 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Time ts={pinnedEvent.getTs()} />
<Time
ts={pinnedEvent.getTs()}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</Box>
{renderOptions()}
</Box>
@@ -215,8 +233,7 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
replyEventId={pinnedEvent.replyEventId}
threadRootId={pinnedEvent.threadRootId}
onClick={handleOpenClick}
getPowerLevel={getPowerLevel}
getPowerLevelTag={getPowerLevelTag}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor}
/>
@@ -235,14 +252,34 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
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<HTMLDivElement>(null);
@@ -457,6 +494,11 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
renderContent={renderMatrixEvent}
onOpen={handleOpen}
canPinEvent={canPinEvent}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</SequenceCard>
</VirtualTile>

View File

@@ -0,0 +1,449 @@
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
config,
Icon,
Icons,
Input,
Line,
MenuItem,
Modal,
Overlay,
OverlayCenter,
Scroll,
Text,
toRem,
} from 'folds';
import React, {
ChangeEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { isKeyHotkey } from 'is-hotkey';
import { useAtom, useAtomValue } from 'jotai';
import { Room } from 'matrix-js-sdk';
import { useDirects, useOrphanSpaces, useRooms, useSpaces } from '../../state/hooks/roomList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { mDirectAtom } from '../../state/mDirectList';
import { allRoomsAtom } from '../../state/room-list/roomList';
import {
SearchItemStrGetter,
useAsyncSearch,
UseAsyncSearchOptions,
} from '../../hooks/useAsyncSearch';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import {
getAllParents,
getDirectRoomAvatarUrl,
getRoomAvatarUrl,
guessPerfectParent,
} from '../../utils/room';
import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
import { factoryRoomIdByActivity } from '../../utils/sort';
import { nameInitials } from '../../utils/common';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useListFocusIndex } from '../../hooks/useListFocusIndex';
import { getMxIdLocalPart, getMxIdServer, guessDmRoomUserId } from '../../utils/matrix';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { searchModalAtom } from '../../state/searchModal';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { KeySymbol } from '../../utils/key-symbol';
import { isMacOS } from '../../utils/user-agent';
enum SearchRoomType {
Rooms = '#',
Spaces = '*',
Directs = '@',
}
const getSearchPrefixToRoomType = (prefix: string): SearchRoomType | undefined => {
if (prefix === '#') return SearchRoomType.Rooms;
if (prefix === '*') return SearchRoomType.Spaces;
if (prefix === '@') return SearchRoomType.Directs;
return undefined;
};
const useTopActiveRooms = (
searchRoomType: SearchRoomType | undefined,
rooms: string[],
directs: string[],
spaces: string[]
) => {
const mx = useMatrixClient();
return useMemo(() => {
if (searchRoomType === SearchRoomType.Spaces) {
return spaces;
}
if (searchRoomType === SearchRoomType.Directs) {
return [...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20);
}
if (searchRoomType === SearchRoomType.Rooms) {
return [...rooms].sort(factoryRoomIdByActivity(mx)).slice(0, 20);
}
return [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20);
}, [mx, rooms, directs, spaces, searchRoomType]);
};
const getDmUserId = (
roomId: string,
getRoom: (roomId: string) => Room | undefined,
myUserId: string
): string | undefined => {
const room = getRoom(roomId);
const targetUserId = room && guessDmRoomUserId(room, myUserId);
return targetUserId;
};
const useSearchTargetRooms = (
searchRoomType: SearchRoomType | undefined,
rooms: string[],
directs: string[],
spaces: string[]
) =>
useMemo(() => {
if (searchRoomType === undefined) {
return [...rooms, ...directs, ...spaces];
}
if (searchRoomType === SearchRoomType.Rooms) return rooms;
if (searchRoomType === SearchRoomType.Spaces) return spaces;
if (searchRoomType === SearchRoomType.Directs) return directs;
return [];
}, [rooms, spaces, directs, searchRoomType]);
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
matchOptions: {
contain: true,
},
normalizeOptions: {
ignoreWhitespace: false,
},
};
type SearchProps = {
requestClose: () => void;
};
export function Search({ requestClose }: SearchProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { navigateRoom, navigateSpace } = useRoomNavigate();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const [searchRoomType, setSearchRoomType] = useState<SearchRoomType>();
const allRoomsSet = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allRoomsSet);
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const mDirects = useAtomValue(mDirectAtom);
const rooms = useRooms(mx, allRoomsAtom, mDirects);
const spaces = useSpaces(mx, allRoomsAtom);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const topActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces);
const targetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces);
const getTargetStr: SearchItemStrGetter<string> = useCallback(
(roomId: string) => {
const roomName = getRoom(roomId)?.name ?? roomId;
if (mDirects.has(roomId)) {
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
if (targetUsername) return [roomName, targetUsername];
}
return roomName;
},
[getRoom, mDirects, mx]
);
const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS);
const roomsToRender = result ? result.items : topActiveRooms;
const listFocus = useListFocusIndex(roomsToRender.length, 0);
const queryHighlighRegex = result?.query
? makeHighlightRegex(result.query.split(' '))
: undefined;
const openRoomId = (roomId: string, isSpace: boolean) => {
if (isSpace) navigateSpace(roomId);
else navigateRoom(roomId);
requestClose();
};
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
listFocus.reset();
const target = evt.currentTarget;
let value = target.value.trim();
const prefix = value.match(/^[#@*]/)?.[0];
const searchType = typeof prefix === 'string' && getSearchPrefixToRoomType(prefix);
if (searchType) {
value = value.slice(1);
setSearchRoomType(searchType);
} else {
setSearchRoomType(undefined);
}
if (value === '') {
resetSearch();
return;
}
search(value);
};
const handleInputKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
const roomId = roomsToRender[listFocus.index];
if (isKeyHotkey('enter', evt) && roomId) {
openRoomId(roomId, spaces.includes(roomId));
return;
}
if (isKeyHotkey('arrowdown', evt)) {
evt.preventDefault();
listFocus.next();
return;
}
if (isKeyHotkey('arrowup', evt)) {
evt.preventDefault();
listFocus.previous();
}
};
const handleRoomClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget;
const roomId = target.getAttribute('data-room-id');
const isSpace = target.getAttribute('data-space') === 'true';
if (!roomId) return;
openRoomId(roomId, isSpace);
};
useEffect(() => {
const scrollView = scrollRef.current;
const focusedItem = scrollView?.querySelector(`[data-focus-index="${listFocus.index}"]`);
if (focusedItem && scrollView) {
focusedItem.scrollIntoView({
block: 'center',
});
}
}, [listFocus.index]);
return (
<Overlay open>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: () => inputRef.current,
returnFocusOnDeactivate: false,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: requestClose,
escapeDeactivates: (evt) => {
evt.stopPropagation();
return true;
},
}}
>
<Modal size="400" style={{ maxHeight: toRem(400), borderRadius: config.radii.R500 }}>
<Box
shrink="No"
style={{ padding: config.space.S400, paddingBottom: 0 }}
direction="Column"
>
<Input
ref={inputRef}
size="500"
variant="Background"
radii="400"
outlined
placeholder="Search"
before={<Icon size="200" src={Icons.Search} />}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
/>
</Box>
<Box grow="Yes">
{roomsToRender.length === 0 && (
<Box
style={{ paddingTop: config.space.S700 }}
grow="Yes"
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
{result ? 'No Match Found' : `No Rooms'}`}
</Text>
<Text size="T200" align="Center">
{result
? `No match found for "${result.query}".`
: `You do not have any Rooms to display yet.`}
</Text>
</Box>
)}
{roomsToRender.length > 0 && (
<Scroll ref={scrollRef} size="300" hideTrack>
<div style={{ padding: config.space.S400, paddingRight: config.space.S200 }}>
{roomsToRender.map((roomId, index) => {
const room = getRoom(roomId);
if (!room) return null;
const dm = mDirects.has(roomId);
const dmUserId = dm && getDmUserId(roomId, getRoom, mx.getSafeUserId());
const dmUsername = dmUserId && getMxIdLocalPart(dmUserId);
const dmUserServer = dmUserId && getMxIdServer(dmUserId);
const allParents = getAllParents(roomToParents, roomId);
const orphanParents =
allParents && orphanSpaces.filter((o) => allParents.has(o));
const perfectOrphanParent =
orphanParents && guessPerfectParent(mx, roomId, orphanParents);
const exactParents = roomToParents.get(roomId);
const perfectParent =
exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents));
const unread = roomToUnread.get(roomId);
return (
<MenuItem
key={roomId}
as="button"
data-focus-index={index}
data-room-id={roomId}
data-space={room.isSpaceRoom()}
onClick={handleRoomClick}
variant={listFocus.index === index ? 'Primary' : 'Surface'}
aria-pressed={listFocus.index === index}
radii="400"
after={
<Box gap="100">
{dmUserServer && (
<Text size="T200" priority="300" truncate>
<b>{dmUserServer}</b>
</Text>
)}
{!dm && perfectOrphanParent && (
<Text size="T200" priority="300" truncate>
<b>{getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent}</b>
</Text>
)}
{unread && (
<UnreadBadgeCenter>
<UnreadBadge
highlight={unread.highlight > 0}
count={unread.total}
/>
</UnreadBadgeCenter>
)}
</Box>
}
before={
<Avatar size="200" radii={dm ? '400' : '300'}>
{dm || room.isSpaceRoom() ? (
<RoomAvatar
roomId={room.roomId}
src={
dm
? getDirectRoomAvatarUrl(mx, room, 32, useAuthentication)
: getRoomAvatarUrl(mx, room, 32, useAuthentication)
}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon
size="100"
joinRule={room.getJoinRule()}
space={room.isSpaceRoom()}
/>
)}
</Avatar>
}
>
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="T400" truncate>
{queryHighlighRegex
? highlightText(queryHighlighRegex, [room.name])
: room.name}
</Text>
{dmUsername && (
<Text as="span" size="T200" priority="300" truncate>
@
{queryHighlighRegex
? highlightText(queryHighlighRegex, [dmUsername])
: dmUsername}
</Text>
)}
{!dm && perfectParent && perfectParent !== perfectOrphanParent && (
<Text size="T200" priority="300" truncate>
{getRoom(perfectParent)?.name ?? perfectParent}
</Text>
)}
</Box>
</MenuItem>
);
})}
</div>
</Scroll>
)}
</Box>
<Line size="300" />
<Box shrink="No" justifyContent="Center" style={{ padding: config.space.S200 }}>
<Text size="T200" priority="300">
Type <b>#</b> for rooms, <b>@</b> for DMs and <b>*</b> for spaces. Hotkey:{' '}
<b>{isMacOS() ? KeySymbol.Command : 'Ctrl'} + k</b>
</Text>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
export function SearchModalRenderer() {
const [opened, setOpen] = useAtom(searchModalAtom);
useKeyDown(
window,
useCallback(
(event) => {
if (isKeyHotkey('mod+k', event)) {
event.preventDefault();
if (opened) {
setOpen(false);
return;
}
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
return;
}
setOpen(true);
}
},
[opened, setOpen]
)
);
return opened && <Search requestClose={() => setOpen(false)} />;
}

View File

@@ -0,0 +1 @@
export * from './Search';

View File

@@ -5,7 +5,6 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import CinnySVG from '../../../../../public/res/svg/cinny.svg';
import cons from '../../../../client/state/cons';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -47,7 +46,7 @@ export function About({ requestClose }: AboutProps) {
<Box direction="Column" gap="100">
<Box gap="100" alignItems="End">
<Text size="H3">Cinny</Text>
<Text size="T200">v{cons.version}</Text>
<Text size="T200">v4.10.2</Text>
</Box>
<Text>Yet another matrix client.</Text>
</Box>

View File

@@ -4,7 +4,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { copyToClipboard } from '../../../../util/common';
import { copyToClipboard } from '../../../utils/dom';
export function MatrixId() {
const mx = useMatrixClient();

View File

@@ -27,6 +27,8 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { LogoutDialog } from '../../../components/LogoutDialog';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
export function DeviceTilePlaceholder() {
return (
@@ -41,6 +43,9 @@ export function DeviceTilePlaceholder() {
}
function DeviceActiveTime({ ts }: { ts: number }) {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
return (
<Text className={BreakWord} size="T200">
<Text size="Inherit" as="span" priority="300">
@@ -49,7 +54,8 @@ function DeviceActiveTime({ ts }: { ts: number }) {
<>
{today(ts) && 'Today'}
{yesterday(ts) && 'Yesterday'}
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts)} {timeHourMinute(ts)}
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts, dateFormatString)}{' '}
{timeHourMinute(ts, hour24Clock)}
</>
</Text>
);

View File

@@ -11,6 +11,10 @@ import { useUIAMatrixError } from '../../../hooks/useUIAFlows';
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
import { VerifyOtherDeviceTile } from './Verification';
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { withSearchParam } from '../../../pages/pathUtils';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
import { SettingTile } from '../../../components/setting-tile';
type OtherDevicesProps = {
devices: IMyDevice[];
@@ -20,8 +24,39 @@ type OtherDevicesProps = {
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
const mx = useMatrixClient();
const crypto = mx.getCrypto();
const authMetadata = useAuthMetadata();
const accountManagementActions = useAccountManagementActions();
const [deleted, setDeleted] = useState<Set<string>>(new Set());
const handleDashboardOIDC = useCallback(() => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
window.open(
withSearchParam(authUrl, {
action: accountManagementActions.sessionsList,
}),
'_blank'
);
}, [authMetadata, accountManagementActions]);
const handleDeleteOIDC = useCallback(
(deviceId: string) => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
window.open(
withSearchParam(authUrl, {
action: accountManagementActions.sessionEnd,
device_id: deviceId,
}),
'_blank'
);
},
[authMetadata, accountManagementActions]
);
const handleToggleDelete = useCallback((deviceId: string) => {
setDeleted((deviceIds) => {
const newIds = new Set(deviceIds);
@@ -70,6 +105,31 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
<>
<Box direction="Column" gap="100">
<Text size="L400">Others</Text>
{authMetadata && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Device Dashboard"
description="Manage your devices on OIDC dashboard."
after={
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
onClick={handleDashboardOIDC}
>
<Text size="B300">Open</Text>
</Button>
}
/>
</SequenceCard>
)}
{devices
.sort((d1, d2) => {
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
@@ -89,12 +149,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
refreshDeviceList={refreshDeviceList}
disabled={deleting}
options={
<DeviceDeleteBtn
deviceId={device.device_id}
deleted={deleted.has(device.device_id)}
onDeleteToggle={handleToggleDelete}
disabled={deleting}
/>
authMetadata ? (
<DeviceDeleteBtn
deviceId={device.device_id}
deleted={false}
onDeleteToggle={handleDeleteOIDC}
/>
) : (
<DeviceDeleteBtn
deviceId={device.device_id}
deleted={deleted.has(device.device_id)}
onDeleteToggle={handleToggleDelete}
disabled={deleting}
/>
)
}
/>
{showVerification && crypto && (

View File

@@ -32,6 +32,9 @@ import {
DeviceVerificationSetup,
} from '../../../components/DeviceVerificationSetup';
import { stopPropagation } from '../../../utils/keyboard';
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { withSearchParam } from '../../../pages/pathUtils';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
type VerificationStatusBadgeProps = {
verificationStatus: VerificationStatus;
@@ -252,6 +255,8 @@ export function EnableVerification({ visible }: EnableVerificationProps) {
export function DeviceVerificationOptions() {
const [menuCords, setMenuCords] = useState<RectCords>();
const authMetadata = useAuthMetadata();
const accountManagementActions = useAccountManagementActions();
const [reset, setReset] = useState(false);
@@ -265,6 +270,18 @@ export function DeviceVerificationOptions() {
const handleReset = () => {
setMenuCords(undefined);
if (authMetadata) {
const authUrl = authMetadata.account_management_uri ?? authMetadata.issuer;
window.open(
withSearchParam(authUrl, {
action: accountManagementActions.crossSigningReset,
}),
'_blank'
);
return;
}
setReset(true);
};

View File

@@ -1,15 +1,19 @@
import React, {
ChangeEventHandler,
FormEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useEffect,
useState,
} from 'react';
import dayjs from 'dayjs';
import {
as,
Box,
Button,
Chip,
config,
Header,
Icon,
IconButton,
Icons,
@@ -28,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings';
import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
@@ -44,6 +48,7 @@ import {
import { stopPropagation } from '../../../utils/keyboard';
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { SequenceCardStyle } from '../styles.css';
type ThemeSelectorProps = {
@@ -300,6 +305,7 @@ function PageZoomInput() {
function Appearance() {
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
return (
@@ -327,6 +333,13 @@ function Appearance() {
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Monochrome Mode"
after={<Switch variant="Primary" value={monochromeMode} onChange={setMonochromeMode} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Twitter Emoji"
@@ -341,6 +354,359 @@ function Appearance() {
);
}
type DateHintProps = {
hasChanges: boolean;
handleReset: () => void;
};
function DateHint({ hasChanges, handleReset }: DateHintProps) {
const [anchor, setAnchor] = useState<RectCords>();
const categoryPadding = { padding: config.space.S200, paddingTop: 0 };
const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
setAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<PopOut
anchor={anchor}
position="Top"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxHeight: '85vh', overflowY: 'auto' }}>
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
<Text size="L400">Formatting</Text>
</Header>
<Box direction="Column">
<Box style={categoryPadding} direction="Column">
<Header size="300">
<Text size="L400">Year</Text>
</Header>
<Box direction="Column" tabIndex={0} gap="100">
<Text size="T300">
YY
<Text as="span" size="Inherit" priority="300">
{': '}
Two-digit year
</Text>{' '}
</Text>
<Text size="T300">
YYYY
<Text as="span" size="Inherit" priority="300">
{': '}Four-digit year
</Text>
</Text>
</Box>
</Box>
<Box style={categoryPadding} direction="Column">
<Header size="300">
<Text size="L400">Month</Text>
</Header>
<Box direction="Column" tabIndex={0} gap="100">
<Text size="T300">
M
<Text as="span" size="Inherit" priority="300">
{': '}The month
</Text>
</Text>
<Text size="T300">
MM
<Text as="span" size="Inherit" priority="300">
{': '}Two-digit month
</Text>{' '}
</Text>
<Text size="T300">
MMM
<Text as="span" size="Inherit" priority="300">
{': '}Short month name
</Text>
</Text>
<Text size="T300">
MMMM
<Text as="span" size="Inherit" priority="300">
{': '}Full month name
</Text>
</Text>
</Box>
</Box>
<Box style={categoryPadding} direction="Column">
<Header size="300">
<Text size="L400">Day of the Month</Text>
</Header>
<Box direction="Column" tabIndex={0} gap="100">
<Text size="T300">
D
<Text as="span" size="Inherit" priority="300">
{': '}Day of the month
</Text>
</Text>
<Text size="T300">
DD
<Text as="span" size="Inherit" priority="300">
{': '}Two-digit day of the month
</Text>
</Text>
</Box>
</Box>
<Box style={categoryPadding} direction="Column">
<Header size="300">
<Text size="L400">Day of the Week</Text>
</Header>
<Box direction="Column" tabIndex={0} gap="100">
<Text size="T300">
d
<Text as="span" size="Inherit" priority="300">
{': '}Day of the week (Sunday = 0)
</Text>
</Text>
<Text size="T300">
dd
<Text as="span" size="Inherit" priority="300">
{': '}Two-letter day name
</Text>
</Text>
<Text size="T300">
ddd
<Text as="span" size="Inherit" priority="300">
{': '}Short day name
</Text>
</Text>
<Text size="T300">
dddd
<Text as="span" size="Inherit" priority="300">
{': '}Full day name
</Text>
</Text>
</Box>
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
{hasChanges ? (
<IconButton
tabIndex={-1}
onClick={handleReset}
type="reset"
variant="Secondary"
size="300"
radii="300"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
) : (
<IconButton
tabIndex={-1}
onClick={handleOpenMenu}
type="button"
variant="Secondary"
size="300"
radii="300"
aria-pressed={!!anchor}
>
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
</IconButton>
)}
</PopOut>
);
}
type CustomDateFormatProps = {
value: string;
onChange: (format: string) => void;
};
function CustomDateFormat({ value, onChange }: CustomDateFormatProps) {
const [dateFormatCustom, setDateFormatCustom] = useState(value);
useEffect(() => {
setDateFormatCustom(value);
}, [value]);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const format = evt.currentTarget.value;
setDateFormatCustom(format);
};
const handleReset = () => {
setDateFormatCustom(value);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const target = evt.target as HTMLFormElement | undefined;
const customDateFormatInput = target?.customDateFormatInput as HTMLInputElement | undefined;
const format = customDateFormatInput?.value;
if (!format) return;
onChange(format);
};
const hasChanges = dateFormatCustom !== value;
return (
<SettingTile>
<Box as="form" onSubmit={handleSubmit} gap="200">
<Box grow="Yes" direction="Column">
<Input
required
name="customDateFormatInput"
value={dateFormatCustom}
onChange={handleChange}
maxLength={16}
autoComplete="off"
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
after={<DateHint hasChanges={hasChanges} handleReset={handleReset} />}
/>
</Box>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges}
type="submit"
>
<Text size="B400">Save</Text>
</Button>
</Box>
</SettingTile>
);
}
type PresetDateFormatProps = {
value: string;
onChange: (format: string) => void;
};
function PresetDateFormat({ value, onChange }: PresetDateFormatProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const dateFormatItems = useDateFormatItems();
const getDisplayDate = (format: string): string =>
format !== '' ? dayjs().format(format) : 'Custom';
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (format: DateFormat) => {
onChange(format);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">
{getDisplayDate(dateFormatItems.find((i) => i.format === value)?.format ?? value)}
</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{dateFormatItems.map((item) => (
<MenuItem
key={item.format}
size="300"
variant={value === item.format ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(item.format)}
>
<Text size="T300">{getDisplayDate(item.format)}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function SelectDateFormat() {
const [dateFormatString, setDateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [selectedDateFormat, setSelectedDateFormat] = useState(dateFormatString);
const customDateFormat = selectedDateFormat === '';
const handlePresetChange = (format: string) => {
setSelectedDateFormat(format);
if (format !== '') {
setDateFormatString(format);
}
};
return (
<>
<SettingTile
title="Date Format"
description={customDateFormat ? dayjs().format(dateFormatString) : ''}
after={<PresetDateFormat value={selectedDateFormat} onChange={handlePresetChange} />}
/>
{customDateFormat && (
<CustomDateFormat value={dateFormatString} onChange={setDateFormatString} />
)}
</>
);
}
function DateAndTime() {
const [hour24Clock, setHour24Clock] = useSetting(settingsAtom, 'hour24Clock');
return (
<Box direction="Column" gap="100">
<Text size="L400">Date & Time</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="24-Hour Time Format"
after={<Switch variant="Primary" value={hour24Clock} onChange={setHour24Clock} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SelectDateFormat />
</SequenceCard>
</Box>
);
}
function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
@@ -637,6 +1003,7 @@ export function General({ requestClose }: GeneralProps) {
<PageContent>
<Box direction="Column" gap="700">
<Appearance />
<DateAndTime />
<Editor />
<Messages />
</Box>

View File

@@ -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;

View File

@@ -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 (
<Page>
@@ -39,20 +43,20 @@ export function General({ requestClose }: GeneralProps) {
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<RoomProfile powerLevels={powerLevels} />
<RoomProfile permissions={permissions} />
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<RoomJoinRules powerLevels={powerLevels} />
<RoomPublish powerLevels={powerLevels} />
<RoomJoinRules permissions={permissions} />
<RoomPublish permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Addresses</Text>
<RoomPublishedAddresses powerLevels={powerLevels} />
<RoomLocalAddresses powerLevels={powerLevels} />
<RoomPublishedAddresses permissions={permissions} />
<RoomLocalAddresses permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Advance Options</Text>
<RoomUpgrade powerLevels={powerLevels} requestClose={requestClose} />
<Text size="L400">Advanced Options</Text>
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
</Box>
</Box>
</PageContent>

View File

@@ -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}
/>
<PermissionGroups powerLevels={powerLevels} permissionGroups={permissionGroups} />
<PermissionGroups
canEdit={canEditPermissions}
powerLevels={powerLevels}
permissionGroups={permissionGroups}
/>
</Box>
</PageContent>
</Scroll>