Compare commits

..

11 Commits

Author SHA1 Message Date
Ajay Bura
3ed8260877 Release v4.8.1 (#2360) 2025-06-10 23:48:55 +10:00
Ajay Bura
44347db6e4 Add allow from currently selected space if no m.space.parent found (#2359) 2025-06-10 23:47:46 +10:00
Ajay Bura
91632aa193 Fix space navigation & view space timeline dev-option (#2358)
* fix inaccessible space on alias change

* fix new room in space open in home

* allow opening space timeline

* hide event timeline feature behind dev tool

* add navToActivePath to clear cache function
2025-06-10 14:44:17 +10:00
Ajay Bura
e6f4eeca8e Update folds to v2.2.0 (#2341) 2025-05-27 14:10:27 +05:30
Ajay Bura
a23279e633 Fix rate limit when reordering in space lobby (#2254)
* move can drop lobby item logic to hook

* add comment

* resolve rate limit when reordering space children
2025-05-26 14:21:27 +05:30
Krishan
83057ebbd4 Fix additional spam string matching (#2339) 2025-05-25 15:51:19 +05:30
Ajay Bura
c51ba9670e Release v4.8.0 (#2337) 2025-05-24 21:22:39 +05:30
Ajay Bura
59a007419f hide decline all public invite button when no invite 2025-05-24 21:19:35 +05:30
Ajay Bura
206ed33516 Better invites management (#2336)
* move block users to account settings

* filter invites and add more options

* add better rate limit recovery in rateLimitedActions util function
2025-05-24 20:07:56 +05:30
Ajay Bura
0d27bde33e Release v4.7.1 (#2332) 2025-05-21 17:28:38 +05:30
Ajay Bura
df391968d8 Fix crash on malformed blurhash (#2331) 2025-05-21 17:28:13 +05:30
36 changed files with 1504 additions and 756 deletions

30
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "cinny",
"version": "4.7.0",
"version": "4.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cinny",
"version": "4.7.0",
"version": "4.8.1",
"license": "AGPL-3.0-only",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
@@ -21,6 +21,7 @@
"@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1",
"await-to-js": "3.0.0",
"badwords-list": "2.0.1-4",
"blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
@@ -33,7 +34,7 @@
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
"folds": "2.1.0",
"folds": "2.2.0",
"formik": "2.4.6",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
@@ -5436,6 +5437,12 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
"node_modules/badwords-list": {
"version": "2.0.1-4",
"resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-2.0.1-4.tgz",
"integrity": "sha512-FxfZUp7B9yCnesNtFQS9v6PvZdxTYa14Q60JR6vhjdQdWI4naTjJIyx22JzoER8ooeT8SAAKoHLjKfCV7XgYUQ==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -7258,15 +7265,16 @@
}
},
"node_modules/folds": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.1.0.tgz",
"integrity": "sha512-KwAG8bH3jsyZ9FKPMg+6ABV2YOcpp4nL0cCelsalnaPeRThkc5fgG1Xj5mhmdffYKjEXpEbERi5qmGbepgJryg==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.2.0.tgz",
"integrity": "sha512-uOfck5eWEIK11rhOAEdSoPIvMXwv+D1Go03pxSAKezWVb+uRoBdmE6LEqiLOF+ac4DGmZRMPvpdDsXCg7EVNIg==",
"license": "Apache-2.0",
"peerDependencies": {
"@vanilla-extract/css": "^1.9.2",
"@vanilla-extract/recipes": "^0.3.0",
"classnames": "^2.3.2",
"react": "^17.0.0",
"react-dom": "^17.0.0"
"@vanilla-extract/css": "1.9.2",
"@vanilla-extract/recipes": "0.3.0",
"classnames": "2.3.2",
"react": "17.0.0",
"react-dom": "17.0.0"
}
},
"node_modules/for-each": {

View File

@@ -1,6 +1,6 @@
{
"name": "cinny",
"version": "4.7.0",
"version": "4.8.1",
"description": "Yet another matrix client",
"main": "index.js",
"type": "module",
@@ -32,6 +32,7 @@
"@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1",
"await-to-js": "3.0.0",
"badwords-list": "2.0.1-4",
"blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
@@ -44,7 +45,7 @@
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
"folds": "2.1.0",
"folds": "2.2.0",
"formik": "2.4.6",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",

View File

@@ -30,6 +30,7 @@ import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
type RenderViewerProps = {
src: string;
@@ -77,7 +78,7 @@ export const ImageContent = as<'div', ImageContentProps>(
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);

View File

@@ -31,6 +31,7 @@ import {
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash';
type RenderVideoProps = {
title: string;
@@ -68,7 +69,7 @@ export const VideoContent = as<'div', VideoContentProps>(
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);

View File

@@ -105,6 +105,20 @@ export const PageContent = as<'div'>(({ className, ...props }, ref) => (
<div className={classNames(css.PageContent, className)} {...props} ref={ref} />
));
export function PageHeroEmpty({ children }: { children: ReactNode }) {
return (
<Box
className={classNames(ContainerColor({ variant: 'SurfaceVariant' }), css.PageHeroEmpty)}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="200"
>
{children}
</Box>
);
}
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
({ className, ...props }, ref) => (
<Box

View File

@@ -92,6 +92,15 @@ export const PageContent = style([
},
]);
export const PageHeroEmpty = style([
DefaultReset,
{
padding: config.space.S400,
borderRadius: config.radii.R400,
minHeight: toRem(450),
},
]);
export const PageHeroSection = style([
DefaultReset,
{

View File

@@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react';
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,
@@ -20,6 +21,12 @@ import { useStateEvent } from '../../../hooks/useStateEvent';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { getStateEvents } from '../../../utils/room';
import {
useRecursiveChildSpaceScopeFactory,
useSpaceChildren,
} from '../../../state/hooks/roomList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
type RestrictedRoomAllowContent = {
room_id: string;
@@ -36,7 +43,11 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
const allowKnockRestricted = roomVersion >= 10;
const allowRestricted = roomVersion >= 8;
const allowKnock = roomVersion >= 7;
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(
@@ -74,9 +85,22 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
async (joinRule: ExtendedJoinRules) => {
const allow: RestrictedRoomAllowContent[] = [];
if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') {
const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) =>
event.getStateKey()
);
const roomParents = roomIdToParents.get(room.roomId);
const parents = getStateEvents(room, StateEvent.SpaceParent)
.map((event) => event.getStateKey())
.filter((parentId) => typeof parentId === 'string')
.filter((parentId) => roomParents?.has(parentId));
if (parents.length === 0 && space && roomParents) {
// if no m.space.parent found
// find parent in current space
const selectedParents = subspaces.filter((rId) => roomParents.has(rId));
if (roomParents.has(space.roomId)) {
selectedParents.push(space.roomId);
}
selectedParents.forEach((pId) => parents.push(pId));
}
parents.forEach((parentRoomId) => {
if (!parentRoomId) return;
allow.push({
@@ -92,7 +116,7 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
if (allow.length > 0) c.allow = allow;
await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
},
[mx, room]
[mx, room, space, subspaces, roomIdToParents]
)
);

View File

@@ -1,5 +1,5 @@
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
@@ -36,7 +36,7 @@ import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { getCanonicalAliasOrRoomId, rateLimitedActions } from '../../utils/matrix';
import { getSpaceRoomPath } from '../../pages/pathUtils';
import { StateEvent } from '../../../types/matrix/room';
import { CanDropCallback, useDnDMonitor } from './DnD';
@@ -53,6 +53,95 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { SpaceHierarchy } from './SpaceHierarchy';
import { useGetRoom } from '../../hooks/useGetRoom';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
const useCanDropLobbyItem = (
space: Room,
roomsPowerLevels: Map<string, IPowerLevels>,
getRoom: (roomId: string) => Room | undefined,
canEditSpaceChild: (powerLevels: IPowerLevels) => boolean
): CanDropCallback => {
const mx = useMatrixClient();
const canDropSpace: CanDropCallback = useCallback(
(item, container) => {
if (!('space' in container.item)) {
// can not drop around rooms.
// space can only be drop around other spaces
return false;
}
const containerSpaceId = space.roomId;
if (
getRoom(containerSpaceId) === undefined ||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
) {
return false;
}
return true;
},
[space, roomsPowerLevels, getRoom, canEditSpaceChild]
);
const canDropRoom: CanDropCallback = useCallback(
(item, container) => {
const containerSpaceId =
'space' in container.item ? container.item.roomId : container.item.parentId;
const draggingOutsideSpace = item.parentId !== containerSpaceId;
const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
// 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,
StateEvent.RoomJoinRules,
userPLInItem
);
if (!canChangeJoinRuleAllow) {
return false;
}
}
if (
getRoom(containerSpaceId) === undefined ||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
) {
return false;
}
return true;
},
[mx, getRoom, canEditSpaceChild, roomsPowerLevels]
);
const canDrop: CanDropCallback = useCallback(
(item, container): boolean => {
if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
// can not drop before or after itself
return false;
}
// if we are dragging a space
if ('space' in item) {
return canDropSpace(item, container);
}
return canDropRoom(item, container);
},
[canDropSpace, canDropRoom]
);
return canDrop;
};
export function Lobby() {
const navigate = useNavigate();
@@ -92,15 +181,7 @@ export function Lobby() {
useCallback((w, height) => setHeroSectionHeight(height), [])
);
const getRoom = useCallback(
(rId: string) => {
if (allJoinedRooms.has(rId)) {
return mx.getRoom(rId) ?? undefined;
}
return undefined;
},
[mx, allJoinedRooms]
);
const getRoom = useGetRoom(allJoinedRooms);
const canEditSpaceChild = useCallback(
(powerLevels: IPowerLevels) =>
@@ -150,180 +231,155 @@ export function Lobby() {
)
);
const canDrop: CanDropCallback = useCallback(
(item, container): boolean => {
const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
// can not drop before or after itself
return false;
}
if ('space' in item) {
if (!('space' in container.item)) return false;
const containerSpaceId = space.roomId;
if (
getRoom(containerSpaceId) === undefined ||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
) {
return false;
}
return true;
}
const containerSpaceId =
'space' in container.item ? container.item.roomId : container.item.parentId;
const dropOutsideSpace = item.parentId !== containerSpaceId;
if (dropOutsideSpace && restrictedItem) {
// do not allow restricted room to drop outside
// current space if can't change join rule allow
const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
const userPLInItem = powerLevelAPI.getPowerLevel(
itemPowerLevel,
mx.getUserId() ?? undefined
);
const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
itemPowerLevel,
StateEvent.RoomJoinRules,
userPLInItem
);
if (!canChangeJoinRuleAllow) {
return false;
}
}
if (
getRoom(containerSpaceId) === undefined ||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
) {
return false;
}
return true;
},
[getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx]
const canDrop: CanDropCallback = useCanDropLobbyItem(
space,
roomsPowerLevels,
getRoom,
canEditSpaceChild
);
const reorderSpace = useCallback(
(item: HierarchyItemSpace, containerItem: HierarchyItem) => {
if (!item.parentId) return;
const [reorderSpaceState, reorderSpace] = useAsyncCallback(
useCallback(
async (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
if (!item.parentId) return;
const itemSpaces: HierarchyItemSpace[] = hierarchy
.map((i) => i.space)
.filter((i) => i.roomId !== item.roomId);
const itemSpaces: HierarchyItemSpace[] = hierarchy
.map((i) => i.space)
.filter((i) => i.roomId !== item.roomId);
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
const insertIndex = beforeIndex + 1;
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
const insertIndex = beforeIndex + 1;
itemSpaces.splice(insertIndex, 0, {
...item,
content: { ...item.content, order: undefined },
});
itemSpaces.splice(insertIndex, 0, {
...item,
content: { ...item.content, order: undefined },
});
const currentOrders = itemSpaces.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order;
}
return undefined;
});
const currentOrders = itemSpaces.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order;
}
return undefined;
});
const newOrders = orderKeys(lex, currentOrders);
const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => {
const itm = itemSpaces[index];
if (!itm || !itm.parentId) return;
const parentPL = roomsPowerLevels.get(itm.parentId);
const canEdit = parentPL && canEditSpaceChild(parentPL);
if (canEdit && orderKey !== currentOrders[index]) {
mx.sendStateEvent(
itm.parentId,
StateEvent.SpaceChild as any,
{ ...itm.content, order: orderKey },
itm.roomId
);
}
});
},
[mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
);
const reorders = newOrders
?.map((orderKey, index) => ({
item: itemSpaces[index],
orderKey,
}))
.filter((reorder, index) => {
if (!reorder.item.parentId) return false;
const parentPL = roomsPowerLevels.get(reorder.item.parentId);
const canEdit = parentPL && canEditSpaceChild(parentPL);
return canEdit && reorder.orderKey !== currentOrders[index];
});
const reorderRoom = useCallback(
(item: HierarchyItem, containerItem: HierarchyItem): void => {
const itemRoom = mx.getRoom(item.roomId);
if (!item.parentId) {
return;
}
const containerParentId: string =
'space' in containerItem ? containerItem.roomId : containerItem.parentId;
const itemContent = item.content;
if (item.parentId !== containerParentId) {
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
}
if (
itemRoom &&
itemRoom.getJoinRule() === JoinRule.Restricted &&
item.parentId !== containerParentId
) {
// change join rule allow parameter when dragging
// restricted room from one space to another
const joinRuleContent = getStateEvent(
itemRoom,
StateEvent.RoomJoinRules
)?.getContent<RoomJoinRulesEventContent>();
if (joinRuleContent) {
const allow =
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
...joinRuleContent,
allow,
if (reorders) {
await rateLimitedActions(reorders, async (reorder) => {
if (!reorder.item.parentId) return;
await mx.sendStateEvent(
reorder.item.parentId,
StateEvent.SpaceChild as any,
{ ...reorder.item.content, order: reorder.orderKey },
reorder.item.roomId
);
});
}
}
const itemSpaces = Array.from(
hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
);
const beforeItem: HierarchyItem | undefined =
'space' in containerItem ? undefined : containerItem;
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
const insertIndex = beforeIndex + 1;
itemSpaces.splice(insertIndex, 0, {
...item,
parentId: containerParentId,
content: { ...itemContent, order: undefined },
});
const currentOrders = itemSpaces.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order;
}
return undefined;
});
const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => {
const itm = itemSpaces[index];
if (itm && orderKey !== currentOrders[index]) {
mx.sendStateEvent(
containerParentId,
StateEvent.SpaceChild as any,
{ ...itm.content, order: orderKey },
itm.roomId
);
}
});
},
[mx, hierarchy, lex]
},
[mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
)
);
const reorderingSpace = reorderSpaceState.status === AsyncStatus.Loading;
const [reorderRoomState, reorderRoom] = useAsyncCallback(
useCallback(
async (item: HierarchyItem, containerItem: HierarchyItem) => {
const itemRoom = mx.getRoom(item.roomId);
if (!item.parentId) {
return;
}
const containerParentId: string =
'space' in containerItem ? containerItem.roomId : containerItem.parentId;
const itemContent = item.content;
// remove from current space
if (item.parentId !== containerParentId) {
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
}
if (
itemRoom &&
itemRoom.getJoinRule() === JoinRule.Restricted &&
item.parentId !== containerParentId
) {
// change join rule allow parameter when dragging
// restricted room from one space to another
const joinRuleContent = getStateEvent(
itemRoom,
StateEvent.RoomJoinRules
)?.getContent<RoomJoinRulesEventContent>();
if (joinRuleContent) {
const allow =
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ??
[];
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
...joinRuleContent,
allow,
});
}
}
const itemSpaces = Array.from(
hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
);
const beforeItem: HierarchyItem | undefined =
'space' in containerItem ? undefined : containerItem;
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
const insertIndex = beforeIndex + 1;
itemSpaces.splice(insertIndex, 0, {
...item,
parentId: containerParentId,
content: { ...itemContent, order: undefined },
});
const currentOrders = itemSpaces.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order;
}
return undefined;
});
const newOrders = orderKeys(lex, currentOrders);
const reorders = newOrders
?.map((orderKey, index) => ({
item: itemSpaces[index],
orderKey,
}))
.filter((reorder, index) => reorder.item && reorder.orderKey !== currentOrders[index]);
if (reorders) {
await rateLimitedActions(reorders, async (reorder) => {
await mx.sendStateEvent(
containerParentId,
StateEvent.SpaceChild as any,
{ ...reorder.item.content, order: reorder.orderKey },
reorder.item.roomId
);
});
}
},
[mx, hierarchy, lex]
)
);
const reorderingRoom = reorderRoomState.status === AsyncStatus.Loading;
const reordering = reorderingRoom || reorderingSpace;
useDnDMonitor(
scrollRef,
@@ -449,6 +505,7 @@ export function Lobby() {
draggingItem={draggingItem}
onDragging={setDraggingItem}
canDrop={canDrop}
disabledReorder={reordering}
nextSpaceId={nextSpaceId}
getRoom={getRoom}
pinned={sidebarSpaces.has(item.space.roomId)}
@@ -460,6 +517,28 @@ export function Lobby() {
);
})}
</div>
{reordering && (
<Box
style={{
position: 'absolute',
bottom: config.space.S400,
left: 0,
right: 0,
zIndex: 2,
pointerEvents: 'none',
}}
justifyContent="Center"
>
<Chip
variant="Secondary"
outlined
radii="Pill"
before={<Spinner variant="Secondary" fill="Soft" size="100" />}
>
<Text size="L400">Reordering</Text>
</Chip>
</Box>
)}
</PageContentCenter>
</PageContent>
</Scroll>

View File

@@ -31,6 +31,7 @@ type SpaceHierarchyProps = {
draggingItem?: HierarchyItem;
onDragging: (item?: HierarchyItem) => void;
canDrop: CanDropCallback;
disabledReorder?: boolean;
nextSpaceId?: string;
getRoom: (roomId: string) => Room | undefined;
pinned: boolean;
@@ -54,6 +55,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
draggingItem,
onDragging,
canDrop,
disabledReorder,
nextSpaceId,
getRoom,
pinned,
@@ -116,7 +118,9 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
handleClose={handleClose}
getRoom={getRoom}
canEditChild={canEditSpaceChild(spacePowerLevels)}
canReorder={parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false}
canReorder={
parentPowerLevels && !disabledReorder ? canEditSpaceChild(parentPowerLevels) : false
}
options={
parentId &&
parentPowerLevels && (
@@ -174,7 +178,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
dm={mDirects.has(roomItem.roomId)}
onOpen={onOpenRoom}
getRoom={getRoom}
canReorder={canEditSpaceChild(spacePowerLevels)}
canReorder={canEditSpaceChild(spacePowerLevels) && !disabledReorder}
options={
<HierarchyItemMenu
item={roomItem}

View File

@@ -5,7 +5,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { SearchOrderBy } from 'matrix-js-sdk';
import { PageHero, PageHeroSection } from '../../components/page';
import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { _SearchPathSearchParams } from '../../pages/paths';
import { useSetting } from '../../state/hooks/settings';
@@ -222,18 +222,7 @@ export function MessageSearch({
</Box>
{!msgSearchParams.term && status === 'pending' && (
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
padding: config.space.S400,
borderRadius: config.radii.R400,
minHeight: toRem(450),
}}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="200"
>
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Message} />}
@@ -241,7 +230,7 @@ export function MessageSearch({
subTitle="Find helpful messages in your community by searching with related keywords."
/>
</PageHeroSection>
</Box>
</PageHeroEmpty>
)}
{msgSearchParams.term && groups.length === 0 && status === 'success' && (

View File

@@ -71,7 +71,7 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
{
page: SettingsPages.DevicesPage,
name: 'Devices',
icon: Icons.Category,
icon: Icons.Monitor,
},
{
page: SettingsPages.EmojisStickersPage,

View File

@@ -1,396 +1,10 @@
import React, {
ChangeEventHandler,
FormEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Scroll,
Input,
Avatar,
Button,
Chip,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
Dialog,
Header,
config,
Spinner,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import React from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { nameInitials } from '../../../utils/common';
import { copyToClipboard } from '../../../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { stopPropagation } from '../../../utils/keyboard';
import { ImageEditor } from '../../../components/image-editor';
import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
function MatrixId() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
return (
<Box direction="Column" gap="100">
<Text size="L400">Matrix ID</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={userId}
after={
<Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
<Text size="T200">Copy</Text>
</Chip>
}
/>
</SequenceCard>
</Box>
);
}
type ProfileProps = {
profile: UserProfile;
userId: string;
};
function ProfileAvatar({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [alertRemove, setAlertRemove] = useState(false);
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile);
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
mx.setAvatarUrl(mxc);
handleRemoveUpload();
},
[mx, handleRemoveUpload]
);
const handleRemoveAvatar = () => {
mx.setAvatarUrl('');
setAlertRemove(false);
};
return (
<SettingTile
title={
<Text as="span" size="L400">
Avatar
</Text>
}
after={
<Avatar size="500" radii="300">
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
/>
</Avatar>
}
>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
) : (
<Box gap="200">
<Button
onClick={() => pickFile('image/*')}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={disableSetAvatar}
>
<Text size="B300">Upload</Text>
</Button>
{avatarUrl && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disableSetAvatar}
onClick={() => setAlertRemove(true)}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
)}
{imageFileURL && (
<Overlay open={false} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
requestClose={handleRemoveUpload}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
<Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAlertRemove(false),
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">Remove Avatar</Text>
</Box>
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">Are you sure you want to remove profile avatar?</Text>
</Box>
<Button variant="Critical" onClick={handleRemoveAvatar}>
<Text size="B400">Remove</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
</SettingTile>
);
}
function ProfileDisplayName({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const capabilities = useCapabilities();
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
const [changeState, changeDisplayName] = useAsyncCallback(
useCallback((name: string) => mx.setDisplayName(name), [mx])
);
const changingDisplayName = changeState.status === AsyncStatus.Loading;
useEffect(() => {
setDisplayName(defaultDisplayName);
}, [defaultDisplayName]);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const name = evt.currentTarget.value;
setDisplayName(name);
};
const handleReset = () => {
setDisplayName(defaultDisplayName);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (changingDisplayName) return;
const target = evt.target as HTMLFormElement | undefined;
const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
const name = displayNameInput?.value;
if (!name) return;
changeDisplayName(name);
};
const hasChanges = displayName !== defaultDisplayName;
return (
<SettingTile
title={
<Text as="span" size="L400">
Display Name
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box
as="form"
onSubmit={handleSubmit}
gap="200"
aria-disabled={changingDisplayName || disableSetDisplayname}
>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
value={displayName}
onChange={handleChange}
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
readOnly={changingDisplayName || disableSetDisplayname}
after={
hasChanges &&
!changingDisplayName && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)
}
/>
</Box>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || changingDisplayName}
type="submit"
>
{changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
</Box>
</SettingTile>
);
}
function Profile() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
return (
<Box direction="Column" gap="100">
<Text size="L400">Profile</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ProfileAvatar userId={userId} profile={profile} />
<ProfileDisplayName userId={userId} profile={profile} />
</SequenceCard>
</Box>
);
}
function ContactInformation() {
const mx = useMatrixClient();
const [threePIdsState, loadThreePIds] = useAsyncCallback(
useCallback(() => mx.getThreePids(), [mx])
);
const threePIds =
threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
const emailIds = threePIds?.filter((id) => id.medium === 'email');
useEffect(() => {
loadThreePIds();
}, [loadThreePIds]);
return (
<Box direction="Column" gap="100">
<Text size="L400">Contact Information</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile title="Email Address" description="Email address attached to your account.">
<Box>
{emailIds?.map((email) => (
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
<Text size="T200">{email.address}</Text>
</Chip>
))}
</Box>
{/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
</SettingTile>
</SequenceCard>
</Box>
);
}
import { MatrixId } from './MatrixId';
import { Profile } from './Profile';
import { ContactInformation } from './ContactInfo';
import { IgnoredUserList } from './IgnoredUserList';
type AccountProps = {
requestClose: () => void;
@@ -419,6 +33,7 @@ export function Account({ requestClose }: AccountProps) {
<Profile />
<MatrixId />
<ContactInformation />
<IgnoredUserList />
</Box>
</PageContent>
</Scroll>

View File

@@ -0,0 +1,45 @@
import React, { useCallback, useEffect } from 'react';
import { Box, Text, Chip } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
export function ContactInformation() {
const mx = useMatrixClient();
const [threePIdsState, loadThreePIds] = useAsyncCallback(
useCallback(() => mx.getThreePids(), [mx])
);
const threePIds =
threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
const emailIds = threePIds?.filter((id) => id.medium === 'email');
useEffect(() => {
loadThreePIds();
}, [loadThreePIds]);
return (
<Box direction="Column" gap="100">
<Text size="L400">Contact Information</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile title="Email Address" description="Email address attached to your account.">
<Box>
{emailIds?.map((email) => (
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
<Text size="T200">{email.address}</Text>
</Chip>
))}
</Box>
{/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
</SettingTile>
</SequenceCard>
</Box>
);
}

View File

@@ -7,16 +7,17 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { isUserId } from '../../../utils/matrix';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useAlive } from '../../../hooks/useAlive';
function IgnoreUserInput({ userList }: { userList: string[] }) {
const mx = useMatrixClient();
const [userId, setUserId] = useState<string>('');
const alive = useAlive();
const [ignoreState, ignore] = useAsyncCallback(
useCallback(
async (uId: string) => {
mx.setIgnoredUsers([...userList, uId]);
setUserId('');
await mx.setIgnoredUsers([...userList, uId]);
},
[mx, userList]
)
@@ -43,7 +44,11 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
if (!isUserId(uId)) return;
ignore(uId);
ignore(uId).then(() => {
if (alive()) {
setUserId('');
}
});
};
return (
@@ -129,7 +134,7 @@ export function IgnoredUserList() {
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Block Messages</Text>
<Text size="L400">Blocked Users</Text>
</Box>
<SequenceCard
className={SequenceCardStyle}
@@ -139,13 +144,13 @@ export function IgnoredUserList() {
>
<SettingTile
title="Select User"
description="Prevent receiving message by adding userId into blocklist."
description="Prevent receiving messages or invites from user by adding their userId."
>
<Box direction="Column" gap="300">
<IgnoreUserInput userList={ignoredUsers} />
{ignoredUsers.length > 0 && (
<Box direction="Inherit" gap="100">
<Text size="L400">Blocklist</Text>
<Text size="L400">Users</Text>
<Box wrap="Wrap" gap="200">
{ignoredUsers.map((userId) => (
<IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Box, Text, Chip } from 'folds';
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';
export function MatrixId() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
return (
<Box direction="Column" gap="100">
<Text size="L400">Matrix ID</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={userId}
after={
<Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
<Text size="T200">Copy</Text>
</Chip>
}
/>
</SequenceCard>
</Box>
);
}

View File

@@ -0,0 +1,325 @@
import React, {
ChangeEventHandler,
FormEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Input,
Avatar,
Button,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
Dialog,
Header,
config,
Spinner,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { nameInitials } from '../../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { stopPropagation } from '../../../utils/keyboard';
import { ImageEditor } from '../../../components/image-editor';
import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
type ProfileProps = {
profile: UserProfile;
userId: string;
};
function ProfileAvatar({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [alertRemove, setAlertRemove] = useState(false);
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile);
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
mx.setAvatarUrl(mxc);
handleRemoveUpload();
},
[mx, handleRemoveUpload]
);
const handleRemoveAvatar = () => {
mx.setAvatarUrl('');
setAlertRemove(false);
};
return (
<SettingTile
title={
<Text as="span" size="L400">
Avatar
</Text>
}
after={
<Avatar size="500" radii="300">
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
/>
</Avatar>
}
>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
) : (
<Box gap="200">
<Button
onClick={() => pickFile('image/*')}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={disableSetAvatar}
>
<Text size="B300">Upload</Text>
</Button>
{avatarUrl && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disableSetAvatar}
onClick={() => setAlertRemove(true)}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
)}
{imageFileURL && (
<Overlay open={false} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
requestClose={handleRemoveUpload}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
<Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAlertRemove(false),
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">Remove Avatar</Text>
</Box>
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">Are you sure you want to remove profile avatar?</Text>
</Box>
<Button variant="Critical" onClick={handleRemoveAvatar}>
<Text size="B400">Remove</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
</SettingTile>
);
}
function ProfileDisplayName({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const capabilities = useCapabilities();
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
const [changeState, changeDisplayName] = useAsyncCallback(
useCallback((name: string) => mx.setDisplayName(name), [mx])
);
const changingDisplayName = changeState.status === AsyncStatus.Loading;
useEffect(() => {
setDisplayName(defaultDisplayName);
}, [defaultDisplayName]);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const name = evt.currentTarget.value;
setDisplayName(name);
};
const handleReset = () => {
setDisplayName(defaultDisplayName);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (changingDisplayName) return;
const target = evt.target as HTMLFormElement | undefined;
const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
const name = displayNameInput?.value;
if (!name) return;
changeDisplayName(name);
};
const hasChanges = displayName !== defaultDisplayName;
return (
<SettingTile
title={
<Text as="span" size="L400">
Display Name
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box
as="form"
onSubmit={handleSubmit}
gap="200"
aria-disabled={changingDisplayName || disableSetDisplayname}
>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
value={displayName}
onChange={handleChange}
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
readOnly={changingDisplayName || disableSetDisplayname}
after={
hasChanges &&
!changingDisplayName && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)
}
/>
</Box>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || changingDisplayName}
type="submit"
>
{changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
</Box>
</SettingTile>
);
}
export function Profile() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
return (
<Box direction="Column" gap="100">
<Text size="L400">Profile</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ProfileAvatar userId={userId} profile={profile} />
<ProfileDisplayName userId={userId} profile={profile} />
</SequenceCard>
</Box>
);
}

View File

@@ -5,7 +5,9 @@ import { SystemNotification } from './SystemNotification';
import { AllMessagesNotifications } from './AllMessages';
import { SpecialMessagesNotifications } from './SpecialMessages';
import { KeywordMessagesNotifications } from './KeywordMessages';
import { IgnoredUserList } from './IgnoredUserList';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
type NotificationsProps = {
requestClose: () => void;
@@ -35,7 +37,19 @@ export function Notifications({ requestClose }: NotificationsProps) {
<AllMessagesNotifications />
<SpecialMessagesNotifications />
<KeywordMessagesNotifications />
<IgnoredUserList />
<Box direction="Column" gap="100">
<Text size="L400">Block Messages</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
description={'This option has been moved to "Account > Block Users" section.'}
/>
</SequenceCard>
</Box>
</Box>
</PageContent>
</Scroll>

View File

@@ -0,0 +1,10 @@
import { useSpecVersions } from './useSpecVersions';
export const useReportRoomSupported = (): boolean => {
const { versions, unstable_features: unstableFeatures } = useSpecVersions();
// report room is introduced in spec version 1.13
const supported = unstableFeatures?.['org.matrix.msc4151'] || versions.includes('v1.13');
return supported;
};

View File

@@ -13,6 +13,8 @@ import { getOrphanParents } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents';
import { mDirectAtom } from '../state/mDirectList';
import { useSelectedSpace } from './router/useSelectedSpace';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
export const useRoomNavigate = () => {
const navigate = useNavigate();
@@ -20,6 +22,7 @@ export const useRoomNavigate = () => {
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
const spaceSelectedId = useSelectedSpace();
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const navigateSpace = useCallback(
(roomId: string) => {
@@ -32,15 +35,22 @@ export const useRoomNavigate = () => {
const navigateRoom = useCallback(
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
const orphanParents = getOrphanParents(roomToParents, roomId);
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
if (orphanParents.length > 0) {
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(
mx,
spaceSelectedId && orphanParents.includes(spaceSelectedId)
? spaceSelectedId
: orphanParents[0]
: orphanParents[0] // TODO: better orphan parent selection.
);
if (openSpaceTimeline) {
navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomId, eventId), opts);
return;
}
navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts);
return;
}
@@ -52,7 +62,7 @@ export const useRoomNavigate = () => {
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
},
[mx, navigate, spaceSelectedId, roomToParents, mDirects]
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
);
return {

View File

@@ -28,9 +28,11 @@ import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getViaServers } from '../../plugins/via-servers';
import { rateLimitedActions } from '../../utils/matrix';
import { useAlive } from '../../hooks/useAlive';
function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
const mountStore = useStore(roomId);
const alive = useAlive();
const [debounce] = useState(new Debounce());
const [process, setProcess] = useState(null);
const [allRoomIds, setAllRoomIds] = useState([]);
@@ -68,14 +70,14 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
const handleAdd = async () => {
setProcess(`Adding ${selected.length} items...`);
const promises = selected.map((rId) => {
await rateLimitedActions(selected, async (rId) => {
const room = mx.getRoom(rId);
const via = getViaServers(room);
if (via.length === 0) {
via.push(getIdServer(rId));
}
return mx.sendStateEvent(
await mx.sendStateEvent(
roomId,
'm.space.child',
{
@@ -87,9 +89,7 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
);
});
mountStore.setItem(true);
await Promise.allSettled(promises);
if (mountStore.getItem() !== true) return;
if (!alive()) return;
const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
const allIds = roomIds.filter(

View File

@@ -15,7 +15,7 @@ export function AuthFooter() {
target="_blank"
rel="noreferrer"
>
v4.7.0
v4.8.1
</Text>
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
Twitter

View File

@@ -24,7 +24,7 @@ export function WelcomePage() {
target="_blank"
rel="noreferrer noopener"
>
v4.7.0
v4.8.1
</a>
</span>
}

View File

@@ -209,7 +209,7 @@ export function Explore() {
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon
src={Icons.Category}
src={Icons.Server}
size="100"
filled={selectedServer === userServer}
/>
@@ -243,11 +243,7 @@ export function Explore() {
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon
src={Icons.Category}
size="100"
filled={server === selectedServer}
/>
<Icon src={Icons.Server} size="100" filled={server === selectedServer} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>

View File

@@ -507,7 +507,7 @@ export function PublicRooms() {
)}
</Box>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Category} />}
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Server} />}
<Text size="H3" truncate>
{server}
</Text>

View File

@@ -32,7 +32,7 @@ function InvitesNavItem() {
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Invitations
Invites
</Text>
</Box>
{inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}

View File

@@ -1,8 +1,10 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
Avatar,
Badge,
Box,
Button,
Chip,
Icon,
IconButton,
Icons,
@@ -16,56 +18,129 @@ import {
config,
} from 'folds';
import { useAtomValue } from 'jotai';
import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react';
import { MatrixError, Room } from 'matrix-js-sdk';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { useDirectInvites, useRoomInvites, useSpaceInvites } from '../../../state/hooks/inviteList';
import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk';
import {
Page,
PageContent,
PageContentCenter,
PageHeader,
PageHero,
PageHeroEmpty,
PageHeroSection,
} from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
import { mDirectAtom } from '../../../state/mDirectList';
import { SequenceCard } from '../../../components/sequence-card';
import {
bannedInRooms,
getCommonRooms,
getDirectRoomAvatarUrl,
getMemberDisplayName,
getRoomAvatarUrl,
getStateEvent,
isDirectInvite,
isSpace,
} from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import { RoomAvatar } from '../../../components/room-avatar';
import { addRoomIdToMDirect, getMxIdLocalPart, guessDmRoomUserId } from '../../../utils/matrix';
import {
addRoomIdToMDirect,
getMxIdLocalPart,
guessDmRoomUserId,
rateLimitedActions,
} from '../../../utils/matrix';
import { Time } from '../../../components/message';
import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard';
import { RoomTopicViewer } from '../../../components/room-topic-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { useRoomTopic } from '../../../hooks/useRoomMeta';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { StateEvent } from '../../../../types/matrix/room';
import { testBadWords } from '../../../plugins/bad-words';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported';
const COMPACT_CARD_WIDTH = 548;
type InviteCardProps = {
type InviteData = {
room: Room;
userId: string;
direct?: boolean;
compact?: boolean;
onNavigate: (roomId: string) => void;
roomId: string;
roomName: string;
roomAvatar?: string;
roomTopic?: string;
roomAlias?: string;
senderId: string;
senderName: string;
inviteTs?: number;
isSpace: boolean;
isDirect: boolean;
isEncrypted: boolean;
};
function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean): InviteData => {
const userId = mx.getSafeUserId();
const direct = isDirectInvite(room, userId);
const roomAvatar = direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
const roomName = room.name || room.getCanonicalAlias() || room.roomId;
const roomTopic =
getStateEvent(room, StateEvent.RoomTopic)?.getContent<RoomTopicEventContent>()?.topic ??
undefined;
const member = room.getMember(userId);
const memberEvent = member?.events.member;
const memberTs = memberEvent?.getTs() ?? 0;
const senderId = memberEvent?.getSender();
const senderName = senderId
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
: undefined;
const inviteTs = memberEvent?.getTs() ?? 0;
const topic = useRoomTopic(room);
return {
room,
roomId: room.roomId,
roomAvatar,
roomName,
roomTopic,
roomAlias: room.getCanonicalAlias() ?? undefined,
senderId: senderId ?? 'Unknown',
senderName: senderName ?? 'Unknown',
inviteTs,
isSpace: isSpace(room),
isDirect: direct,
isEncrypted: !!getStateEvent(room, StateEvent.RoomEncryption),
};
};
const hasBadWords = (invite: InviteData): boolean =>
testBadWords(invite.roomName) ||
testBadWords(invite.roomTopic ?? '') ||
testBadWords(invite.senderName) ||
testBadWords(invite.senderId);
type NavigateHandler = (roomId: string, space: boolean) => void;
type InviteCardProps = {
invite: InviteData;
compact?: boolean;
onNavigate: NavigateHandler;
hideAvatar: boolean;
};
function InviteCard({ invite, compact, onNavigate, hideAvatar }: InviteCardProps) {
const mx = useMatrixClient();
const userId = mx.getSafeUserId();
const [viewTopic, setViewTopic] = useState(false);
const closeTopic = () => setViewTopic(false);
@@ -73,17 +148,19 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
useCallback(async () => {
const dmUserId = isDirectInvite(room, userId) ? guessDmRoomUserId(room, userId) : undefined;
const dmUserId = isDirectInvite(invite.room, userId)
? guessDmRoomUserId(invite.room, userId)
: undefined;
await mx.joinRoom(room.roomId);
await mx.joinRoom(invite.roomId);
if (dmUserId) {
await addRoomIdToMDirect(mx, room.roomId, dmUserId);
await addRoomIdToMDirect(mx, invite.roomId, dmUserId);
}
onNavigate(room.roomId);
}, [mx, room, userId, onNavigate])
onNavigate(invite.roomId, invite.isSpace);
}, [mx, invite, userId, onNavigate])
);
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
useCallback(() => mx.leave(room.roomId), [mx, room])
useCallback(() => mx.leave(invite.roomId), [mx, invite])
);
const joining =
@@ -95,28 +172,43 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
<SequenceCard
variant="SurfaceVariant"
direction="Column"
gap="200"
style={{ padding: config.space.S400, paddingTop: config.space.S200 }}
gap="300"
style={{ padding: `${config.space.S400} ${config.space.S400} ${config.space.S200}` }}
>
<Box gap="200" alignItems="Baseline">
<Box grow="Yes">
<Text size="T200" priority="300" truncate>
Invited by <b>{senderName}</b>
</Text>
{(invite.isEncrypted || invite.isDirect || invite.isSpace) && (
<Box gap="200" alignItems="Center">
{invite.isEncrypted && (
<Box shrink="No" alignItems="Center" justifyContent="Center">
<Badge variant="Success" fill="Solid" size="400" radii="300">
<Text size="L400">Encrypted</Text>
</Badge>
</Box>
)}
{invite.isDirect && (
<Box shrink="No" alignItems="Center" justifyContent="Center">
<Badge variant="Primary" fill="Solid" size="400" radii="300">
<Text size="L400">Direct Message</Text>
</Badge>
</Box>
)}
{invite.isSpace && (
<Box shrink="No" alignItems="Center" justifyContent="Center">
<Badge variant="Secondary" fill="Soft" size="400" radii="300">
<Text size="L400">Space</Text>
</Badge>
</Box>
)}
</Box>
<Box shrink="No">
<Time size="T200" ts={memberTs} priority="300" />
</Box>
</Box>
)}
<Box gap="300">
<Avatar size="300">
<RoomAvatar
roomId={room.roomId}
src={direct ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication)}
alt={roomName}
roomId={invite.roomId}
src={hideAvatar ? undefined : invite.roomAvatar}
alt={invite.roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(roomName)}
{nameInitials(hideAvatar && invite.roomAvatar ? undefined : invite.roomName)}
</Text>
)}
/>
@@ -125,9 +217,9 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
<Box grow="Yes" direction="Column" gap="200">
<Box direction="Column">
<Text size="T300" truncate>
<b>{roomName}</b>
<b>{invite.roomName}</b>
</Text>
{topic && (
{invite.roomTopic && (
<Text
size="T200"
onClick={openTopic}
@@ -135,7 +227,7 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
tabIndex={0}
truncate
>
{topic}
{invite.roomTopic}
</Text>
)}
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
@@ -149,8 +241,8 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
}}
>
<RoomTopicViewer
name={roomName}
topic={topic ?? ''}
name={invite.roomName}
topic={invite.roomTopic ?? ''}
requestClose={closeTopic}
/>
</FocusTrap>
@@ -173,6 +265,7 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
onClick={leave}
size="300"
variant="Secondary"
radii="300"
fill="Soft"
disabled={joining || leaving}
before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
@@ -182,28 +275,394 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
<Button
onClick={join}
size="300"
variant="Primary"
variant="Success"
fill="Soft"
radii="300"
outlined
disabled={joining || leaving}
before={joining ? <Spinner variant="Primary" fill="Soft" size="100" /> : undefined}
before={joining ? <Spinner variant="Success" fill="Soft" size="100" /> : undefined}
>
<Text size="B300">Accept</Text>
</Button>
</Box>
</Box>
</Box>
<Box gap="200" alignItems="Baseline">
<Box grow="Yes">
<Text size="T200" priority="300">
From: <b>{invite.senderId}</b>
</Text>
</Box>
{invite.inviteTs && (
<Box shrink="No">
<Time size="T200" ts={invite.inviteTs} priority="300" />
</Box>
)}
</Box>
</SequenceCard>
);
}
enum InviteFilter {
Known,
Unknown,
Spam,
}
type InviteFiltersProps = {
filter: InviteFilter;
onFilter: (filter: InviteFilter) => void;
knownInvites: InviteData[];
unknownInvites: InviteData[];
spamInvites: InviteData[];
};
function InviteFilters({
filter,
onFilter,
knownInvites,
unknownInvites,
spamInvites,
}: InviteFiltersProps) {
const isKnown = filter === InviteFilter.Known;
const isUnknown = filter === InviteFilter.Unknown;
const isSpam = filter === InviteFilter.Spam;
return (
<Box gap="200">
<Chip
variant={isKnown ? 'Success' : 'Surface'}
aria-selected={isKnown}
outlined={!isKnown}
onClick={() => onFilter(InviteFilter.Known)}
before={isKnown && <Icon size="100" src={Icons.Check} />}
after={
knownInvites.length > 0 && (
<Badge variant={isKnown ? 'Success' : 'Secondary'} fill="Solid" radii="Pill">
<Text size="L400">{knownInvites.length}</Text>
</Badge>
)
}
>
<Text size="T200">Primary</Text>
</Chip>
<Chip
variant={isUnknown ? 'Warning' : 'Surface'}
aria-selected={isUnknown}
outlined={!isUnknown}
onClick={() => onFilter(InviteFilter.Unknown)}
before={isUnknown && <Icon size="100" src={Icons.Check} />}
after={
unknownInvites.length > 0 && (
<Badge variant={isUnknown ? 'Warning' : 'Secondary'} fill="Solid" radii="Pill">
<Text size="L400">{unknownInvites.length}</Text>
</Badge>
)
}
>
<Text size="T200">Public</Text>
</Chip>
<Chip
variant={isSpam ? 'Critical' : 'Surface'}
aria-selected={isSpam}
outlined={!isSpam}
onClick={() => onFilter(InviteFilter.Spam)}
before={isSpam && <Icon size="100" src={Icons.Check} />}
after={
spamInvites.length > 0 && (
<Badge variant={isSpam ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
<Text size="L400">{spamInvites.length}</Text>
</Badge>
)
}
>
<Text size="T200">Spam</Text>
</Chip>
</Box>
);
}
type KnownInvitesProps = {
invites: InviteData[];
handleNavigate: NavigateHandler;
compact: boolean;
};
function KnownInvites({ invites, handleNavigate, compact }: KnownInvitesProps) {
return (
<Box direction="Column" gap="200">
<Text size="H4">Primary</Text>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
{invites.map((invite) => (
<InviteCard
key={invite.roomId}
invite={invite}
compact={compact}
onNavigate={handleNavigate}
hideAvatar={false}
/>
))}
</Box>
) : (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Mail} />}
title="No Invites"
subTitle="When someone you share a room with sends you an invite, itll show up here."
/>
</PageHeroSection>
</PageHeroEmpty>
)}
</Box>
);
}
type UnknownInvitesProps = {
invites: InviteData[];
handleNavigate: NavigateHandler;
compact: boolean;
};
function UnknownInvites({ invites, handleNavigate, compact }: UnknownInvitesProps) {
const mx = useMatrixClient();
const [declineAllStatus, declineAll] = useAsyncCallback(
useCallback(async () => {
const roomIds = invites.map((invite) => invite.roomId);
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
}, [mx, invites])
);
const declining = declineAllStatus.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween" alignItems="Center">
<Text size="H4">Public</Text>
<Box>
{invites.length > 0 && (
<Chip
variant="SurfaceVariant"
onClick={declineAll}
before={declining && <Spinner size="50" variant="Secondary" fill="Soft" />}
disabled={declining}
radii="Pill"
>
<Text size="T200">Decline All</Text>
</Chip>
)}
</Box>
</Box>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
{invites.map((invite) => (
<InviteCard
key={invite.roomId}
invite={invite}
compact={compact}
onNavigate={handleNavigate}
hideAvatar
/>
))}
</Box>
) : (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Info} />}
title="No Invites"
subTitle="Invites from people outside your rooms will appear here."
/>
</PageHeroSection>
</PageHeroEmpty>
)}
</Box>
);
}
type SpamInvitesProps = {
invites: InviteData[];
handleNavigate: NavigateHandler;
compact: boolean;
};
function SpamInvites({ invites, handleNavigate, compact }: SpamInvitesProps) {
const mx = useMatrixClient();
const [showInvites, setShowInvites] = useState(false);
const reportRoomSupported = useReportRoomSupported();
const [declineAllStatus, declineAll] = useAsyncCallback(
useCallback(async () => {
const roomIds = invites.map((invite) => invite.roomId);
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
}, [mx, invites])
);
const [reportAllStatus, reportAll] = useAsyncCallback(
useCallback(async () => {
const roomIds = invites.map((invite) => invite.roomId);
await rateLimitedActions(roomIds, (roomId) => mx.reportRoom(roomId, 'Spam Invite'));
}, [mx, invites])
);
const ignoredUsers = useIgnoredUsers();
const unignoredUsers = Array.from(new Set(invites.map((invite) => invite.senderId))).filter(
(user) => !ignoredUsers.includes(user)
);
const [blockAllStatus, blockAll] = useAsyncCallback(
useCallback(
() => mx.setIgnoredUsers([...ignoredUsers, ...unignoredUsers]),
[mx, ignoredUsers, unignoredUsers]
)
);
const declining = declineAllStatus.status === AsyncStatus.Loading;
const reporting = reportAllStatus.status === AsyncStatus.Loading;
const blocking = blockAllStatus.status === AsyncStatus.Loading;
const loading = blocking || reporting || declining;
return (
<Box direction="Column" gap="200">
<Text size="H4">Spam</Text>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
<SequenceCard
variant="SurfaceVariant"
direction="Column"
gap="300"
style={{ padding: `${config.space.S400} ${config.space.S400} 0` }}
>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Warning} />}
title={`${invites.length} Spam Invites`}
subTitle="Some of the following invites may contain harmful content or have been sent by banned users."
>
<Box direction="Row" gap="200" justifyContent="Center" wrap="Wrap">
<Button
size="300"
variant="Critical"
fill="Solid"
radii="300"
onClick={declineAll}
before={declining && <Spinner size="100" variant="Critical" fill="Solid" />}
disabled={loading}
>
<Text size="B300" truncate>
Decline All
</Text>
</Button>
{reportRoomSupported && reportAllStatus.status !== AsyncStatus.Success && (
<Button
size="300"
variant="Secondary"
fill="Solid"
radii="300"
onClick={reportAll}
before={reporting && <Spinner size="100" variant="Secondary" fill="Solid" />}
disabled={loading}
>
<Text size="B300" truncate>
Report All
</Text>
</Button>
)}
{unignoredUsers.length > 0 && (
<Button
size="300"
variant="Secondary"
fill="Solid"
radii="300"
disabled={loading}
onClick={blockAll}
before={blocking && <Spinner size="100" variant="Secondary" fill="Solid" />}
>
<Text size="B300" truncate>
Block All
</Text>
</Button>
)}
</Box>
<span data-spacing-node />
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="Pill"
before={
<Icon size="100" src={showInvites ? Icons.ChevronTop : Icons.ChevronBottom} />
}
onClick={() => setShowInvites(!showInvites)}
>
<Text size="B300">{showInvites ? 'Hide All' : 'View All'}</Text>
</Button>
</PageHero>
</PageHeroSection>
</SequenceCard>
{showInvites &&
invites.map((invite) => (
<InviteCard
key={invite.roomId}
invite={invite}
compact={compact}
onNavigate={handleNavigate}
hideAvatar
/>
))}
</Box>
) : (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Warning} />}
title="No Spam Invites"
subTitle="Invites detected as spam appear here."
/>
</PageHeroSection>
</PageHeroEmpty>
)}
</Box>
);
}
export function Invites() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const mDirects = useAtomValue(mDirectAtom);
const directInvites = useDirectInvites(mx, allInvitesAtom, mDirects);
const spaceInvites = useSpaceInvites(mx, allInvitesAtom);
const roomInvites = useRoomInvites(mx, allInvitesAtom, mDirects);
const useAuthentication = useMediaAuthentication();
const { navigateRoom, navigateSpace } = useRoomNavigate();
const allRooms = useAtomValue(allRoomsAtom);
const allInviteIds = useAtomValue(allInvitesAtom);
const [filter, setFilter] = useState(InviteFilter.Known);
const invitesData = allInviteIds
.map((inviteId) => mx.getRoom(inviteId))
.filter((inviteRoom) => !!inviteRoom)
.map((inviteRoom) => makeInviteData(mx, inviteRoom, useAuthentication));
const [knownInvites, unknownInvites, spamInvites] = useMemo(() => {
const known: InviteData[] = [];
const unknown: InviteData[] = [];
const spam: InviteData[] = [];
invitesData.forEach((invite) => {
if (hasBadWords(invite) || bannedInRooms(mx, allRooms, invite.senderId)) {
spam.push(invite);
return;
}
if (getCommonRooms(mx, allRooms, invite.senderId).length === 0) {
unknown.push(invite);
return;
}
known.push(invite);
});
return [known, unknown, spam];
}, [mx, allRooms, invitesData]);
const containerRef = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
useElementSizeObserver(
@@ -212,21 +671,12 @@ export function Invites() {
);
const screenSize = useScreenSizeContext();
const { navigateRoom, navigateSpace } = useRoomNavigate();
const renderInvite = (roomId: string, direct: boolean, handleNavigate: (rId: string) => void) => {
const room = mx.getRoom(roomId);
if (!room) return null;
return (
<InviteCard
key={roomId}
room={room}
userId={userId}
compact={compact}
direct={direct}
onNavigate={handleNavigate}
/>
);
const handleNavigate = (roomId: string, space: boolean) => {
if (space) {
navigateSpace(roomId);
return;
}
navigateRoom(roomId);
};
return (
@@ -247,7 +697,7 @@ export function Invites() {
<Box alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Mail} />}
<Text size="H3" truncate>
Invitations
Invites
</Text>
</Box>
<Box grow="Yes" basis="No" />
@@ -258,47 +708,40 @@ export function Invites() {
<PageContent>
<PageContentCenter>
<Box ref={containerRef} direction="Column" gap="600">
{directInvites.length > 0 && (
<Box direction="Column" gap="200">
<Text size="H4">Direct Messages</Text>
<Box direction="Column" gap="100">
{directInvites.map((roomId) => renderInvite(roomId, true, navigateRoom))}
</Box>
</Box>
<Box direction="Column" gap="100">
<span data-spacing-node />
<Text size="L400">Filter</Text>
<InviteFilters
filter={filter}
onFilter={setFilter}
knownInvites={knownInvites}
unknownInvites={unknownInvites}
spamInvites={spamInvites}
/>
</Box>
{filter === InviteFilter.Known && (
<KnownInvites
invites={knownInvites}
compact={compact}
handleNavigate={handleNavigate}
/>
)}
{spaceInvites.length > 0 && (
<Box direction="Column" gap="200">
<Text size="H4">Spaces</Text>
<Box direction="Column" gap="100">
{spaceInvites.map((roomId) => renderInvite(roomId, false, navigateSpace))}
</Box>
</Box>
{filter === InviteFilter.Unknown && (
<UnknownInvites
invites={unknownInvites}
compact={compact}
handleNavigate={handleNavigate}
/>
)}
{roomInvites.length > 0 && (
<Box direction="Column" gap="200">
<Text size="H4">Rooms</Text>
<Box direction="Column" gap="100">
{roomInvites.map((roomId) => renderInvite(roomId, false, navigateRoom))}
</Box>
</Box>
{filter === InviteFilter.Spam && (
<SpamInvites
invites={spamInvites}
compact={compact}
handleNavigate={handleNavigate}
/>
)}
{directInvites.length === 0 &&
spaceInvites.length === 0 &&
roomInvites.length === 0 && (
<div>
<SequenceCard
variant="SurfaceVariant"
style={{ padding: config.space.S400 }}
direction="Column"
gap="200"
>
<Text>No Pending Invitations</Text>
<Text size="T200">
You don&apos;t have any new pending invitations to display yet.
</Text>
</SequenceCard>
</div>
)}
</Box>
</PageContentCenter>
</PageContent>

View File

@@ -744,13 +744,14 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
const targetSpaceId = target.getAttribute('data-id');
if (!targetSpaceId) return;
const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId));
if (screenSize === ScreenSize.Mobile) {
navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
navigate(spacePath);
return;
}
const activePath = navToActivePath.get(targetSpaceId);
if (activePath) {
if (activePath && activePath.pathname.startsWith(spacePath)) {
navigate(joinPathComponent(activePath));
return;
}

View File

@@ -1,21 +1,24 @@
import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useSpace } from '../../../hooks/useSpace';
import { getAllParents } from '../../../utils/room';
import { getAllParents, getSpaceChildren } from '../../../utils/room';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
import { mDirectAtom } from '../../../state/mDirectList';
import { settingsAtom } from '../../../state/settings';
import { useSetting } from '../../../state/hooks/settings';
export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient();
const space = useSpace();
const roomToParents = useAtomValue(roomToParentsAtom);
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
const allRooms = useAtomValue(allRoomsAtom);
@@ -24,12 +27,36 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
if (
!room ||
room.isSpaceRoom() ||
!allRooms.includes(room.roomId) ||
!getAllParents(roomToParents, room.roomId).has(space.roomId)
) {
if (!room || !allRooms.includes(room.roomId)) {
// room is not joined
return (
<JoinBeforeNavigate
roomIdOrAlias={roomIdOrAlias!}
eventId={eventId}
viaServers={viaServers}
/>
);
}
if (developerTools && room.isSpaceRoom() && room.roomId === space.roomId) {
// allow to view space timeline
return (
<RoomProvider key={room.roomId} value={room}>
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
</RoomProvider>
);
}
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
if (getSpaceChildren(space).includes(room.roomId)) {
// fill missing roomToParent mapping
setRoomToParents({
type: 'PUT',
parent: space.roomId,
children: [room.roomId],
});
}
return (
<JoinBeforeNavigate
roomIdOrAlias={roomIdOrAlias!}

View File

@@ -75,6 +75,7 @@ import {
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
type SpaceMenuProps = {
room: Room;
@@ -83,11 +84,13 @@ type SpaceMenuProps = {
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
const openSpaceSettings = useOpenSpaceSettings();
const { navigateRoom } = useRoomNavigate();
const allChild = useSpaceChildren(
allRoomsAtom,
@@ -118,6 +121,11 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
requestClose();
};
const handleOpenTimeline = () => {
navigateRoom(room.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
@@ -168,6 +176,18 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
Space Settings
</Text>
</MenuItem>
{developerTools && (
<MenuItem
onClick={handleOpenTimeline}
size="300"
after={<Icon size="100" src={Icons.Terminal} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Event Timeline
</Text>
</MenuItem>
)}
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>

View File

@@ -0,0 +1,15 @@
import * as badWords from 'badwords-list';
import { sanitizeForRegex } from '../utils/regex';
const additionalBadWords: string[] = ['torture', 't0rture'];
const fullBadWordList = additionalBadWords.concat(
badWords.array.filter((word) => !additionalBadWords.includes(word))
);
export const BAD_WORDS_REGEX = new RegExp(
`(\\b|_)(${fullBadWordList.map((word) => sanitizeForRegex(word)).join('|')})(\\b|_)`,
'g'
);
export const testBadWords = (str: string): boolean => !!str.toLowerCase().match(BAD_WORDS_REGEX);

View File

@@ -9,6 +9,8 @@ import {
const NAV_TO_ACTIVE_PATH = 'navToActivePath';
const getStoreKey = (userId: string): string => `${NAV_TO_ACTIVE_PATH}${userId}`;
type NavToActivePath = Map<string, Path>;
type NavToActivePathAction =
@@ -25,7 +27,7 @@ type NavToActivePathAction =
export type NavToActivePathAtom = WritableAtom<NavToActivePath, [NavToActivePathAction], undefined>;
export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom => {
const storeKey = `${NAV_TO_ACTIVE_PATH}${userId}`;
const storeKey = getStoreKey(userId);
const baseNavToActivePathAtom = atomWithLocalStorage<NavToActivePath>(
storeKey,
@@ -64,3 +66,7 @@ export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom =>
return navToActivePathAtom;
};
export const clearNavToActivePathStore = (userId: string) => {
localStorage.removeItem(getStoreKey(userId));
};

View File

@@ -1,4 +1,4 @@
import { encode } from 'blurhash';
import { encode, isBlurhashValid } from 'blurhash';
export const encodeBlurHash = (
img: HTMLImageElement | HTMLVideoElement,
@@ -17,3 +17,13 @@ export const encodeBlurHash = (
const data = context.getImageData(0, 0, canvas.width, canvas.height);
return encode(data.data, data.width, data.height, 4, 4);
};
export const validBlurHash = (hash?: string): string | undefined => {
if (typeof hash === 'string') {
const validity = isBlurhashValid(hash);
return validity.result ? hash : undefined;
}
return undefined;
};

View File

@@ -300,25 +300,32 @@ export const downloadEncryptedMedia = async (
export const rateLimitedActions = async <T, R = void>(
data: T[],
callback: (item: T) => Promise<R>,
callback: (item: T, index: number) => Promise<R>,
maxRetryCount?: number
) => {
let retryCount = 0;
const performAction = async (dataItem: T) => {
const [err] = await to<R, MatrixError>(callback(dataItem));
let actionInterval = 0;
const sleepForMs = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const performAction = async (dataItem: T, index: number) => {
const [err] = await to<R, MatrixError>(callback(dataItem, index));
if (err?.httpStatus === 429) {
if (retryCount === maxRetryCount) {
return;
}
const waitMS = err.getRetryAfterMs() ?? 200;
await new Promise((resolve) => {
setTimeout(resolve, waitMS);
});
const waitMS = err.getRetryAfterMs() ?? 3000;
actionInterval = waitMS * 1.5;
await sleepForMs(waitMS);
retryCount += 1;
await performAction(dataItem);
await performAction(dataItem, index);
}
};
@@ -326,6 +333,10 @@ export const rateLimitedActions = async <T, R = void>(
const dataItem = data[i];
retryCount = 0;
// eslint-disable-next-line no-await-in-loop
await performAction(dataItem);
await performAction(dataItem, i);
if (actionInterval > 0) {
// eslint-disable-next-line no-await-in-loop
await sleepForMs(actionInterval);
}
}
};

View File

@@ -19,6 +19,7 @@ import {
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { AccountDataEvent } from '../../types/matrix/accountData';
import {
Membership,
MessageEvent,
NotificationType,
RoomToParents,
@@ -171,7 +172,7 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat
}
if (!roomPushRule) {
const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
const overrideRules = mx.getAccountData(EventType.PushRules)?.getContent<IPushRules>()
?.global?.override;
if (!overrideRules) return NotificationType.Default;
@@ -443,3 +444,32 @@ export const getMentionContent = (userIds: string[], room: boolean): IMentions =
return mMentions;
};
export const getCommonRooms = (
mx: MatrixClient,
rooms: string[],
otherUserId: string
): string[] => {
const commonRooms: string[] = [];
rooms.forEach((roomId) => {
const room = mx.getRoom(roomId);
if (!room || room.getMyMembership() !== Membership.Join) return;
const common = room.hasMembershipState(otherUserId, Membership.Join);
if (common) {
commonRooms.push(roomId);
}
});
return commonRooms;
};
export const bannedInRooms = (mx: MatrixClient, rooms: string[], otherUserId: string): boolean =>
rooms.some((roomId) => {
const room = mx.getRoom(roomId);
if (!room || room.getMyMembership() !== Membership.Join) return false;
const banned = room.hasMembershipState(otherUserId, Membership.Ban);
return banned;
});

View File

@@ -1,6 +1,7 @@
import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk';
import { cryptoCallbacks } from './state/secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
type Session = {
baseUrl: string;
@@ -46,6 +47,7 @@ export const startClient = async (mx: MatrixClient) => {
export const clearCacheAndReload = async (mx: MatrixClient) => {
mx.stopClient();
clearNavToActivePathStore(mx.getSafeUserId());
await mx.store.deleteAllData();
window.location.reload();
};

View File

@@ -1,5 +1,5 @@
const cons = {
version: '4.7.0',
version: '4.8.1',
secretKey: {
ACCESS_TOKEN: 'cinny_access_token',
DEVICE_ID: 'cinny_device_id',