Compare commits

..

9 Commits

Author SHA1 Message Date
Krishan
2a498168ac Enable semantic check on PR title 2025-08-17 11:07:31 +00:00
Krishan
802357b7a0 Rename the PL 150 to Manager (#2443)
Manager seem more appropriate than Co-Founder. As Co-founder essentially have same power to Founder.
2025-08-17 16:20:17 +05:30
Ajay Bura
c5d4530947 Add new join with address prompt (#2442) 2025-08-16 21:40:39 +10:00
Ajay Bura
367397fdd4 Fix type error when accessing FileList (#2441) 2025-08-16 21:35:34 +10:00
Ajay Bura
63fa60e7f4 Open user profile at around mouse anchor (#2440) 2025-08-16 21:34:46 +10:00
Ajay Bura
544a06964d Hide block user button for own profile (#2439) 2025-08-16 21:32:09 +10:00
Ajay Bura
50583f9474 Fix room v12 mention pills (#2438) 2025-08-16 21:30:52 +10:00
Ajay Bura
1ad7fe8deb Fix missing creators support using via (#2431)
* add additional_creators in IRoomCreateContent type

* use creators in getViaServers

* consider creators in guessing perfect parent
2025-08-16 21:30:02 +10:00
Ajay Bura
752a19a4e7 Open tombstone space as space (#2428) 2025-08-16 21:27:37 +10:00
16 changed files with 301 additions and 56 deletions

17
.github/workflows/pr-title.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Check PR title
on:
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -23,6 +23,7 @@ import { UserAvatar } from '../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 EventReadersProps = {
room: Room;
@@ -83,7 +84,7 @@ export const EventReaders = as<'div', EventReadersProps>(
room.roomId,
space?.roomId,
readerId,
event.currentTarget.getBoundingClientRect(),
getMouseEventCords(event.nativeEvent),
'Bottom'
);
}}

View File

@@ -0,0 +1,131 @@
import React, { FormEventHandler, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
Icon,
Icons,
Button,
Input,
color,
} from 'folds';
import { stopPropagation } from '../../utils/keyboard';
import { isRoomAlias, isRoomId } from '../../utils/matrix';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom';
type JoinAddressProps = {
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
onCancel: () => void;
};
export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
const [invalid, setInvalid] = useState(false);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
setInvalid(false);
const target = evt.target as HTMLFormElement | undefined;
const addressInput = target?.addressInput as HTMLInputElement | undefined;
const address = addressInput?.value.trim();
if (!address) return;
if (isRoomId(address) || isRoomAlias(address)) {
onOpen(address);
return;
}
if (testMatrixTo(address)) {
const decodedAddress = tryDecodeURIComponent(address);
const toRoom = parseMatrixToRoom(decodedAddress);
if (toRoom) {
onOpen(toRoom.roomIdOrAlias, toRoom.viaServers);
return;
}
const toEvent = parseMatrixToRoomEvent(decodedAddress);
if (toEvent) {
onOpen(toEvent.roomIdOrAlias, toEvent.viaServers, toEvent.eventId);
return;
}
}
setInvalid(true);
};
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}`,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Join with Address</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleSubmit}
style={{ padding: config.space.S400, paddingTop: 0 }}
direction="Column"
gap="400"
>
<Box direction="Column" gap="200">
<Text priority="400" size="T300">
Enter public address to join the community. Addresses looks like:
</Text>
<Text as="ul" size="T200" priority="300" style={{ paddingLeft: config.space.S400 }}>
<li>#community:server</li>
<li>https://matrix.to/#/#community:server</li>
<li>https://matrix.to/#/!xYzAj?via=server</li>
</Text>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Address</Text>
<Input
size="500"
autoFocus
name="addressInput"
variant="Background"
placeholder="#community:server"
required
/>
{invalid && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Invalid Address</b>
</Text>
)}
</Box>
<Button type="submit" variant="Primary">
<Text size="B400">Open</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

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

View File

@@ -124,8 +124,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
{server && <ServerChip server={server} />}
<ShareChip userId={userId} />
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
<MutualRoomsChip userId={userId} />
<OptionsChip userId={userId} />
{userId !== myUserId && <MutualRoomsChip userId={userId} />}
{userId !== myUserId && <OptionsChip userId={userId} />}
</Box>
</Box>
{ignored && <IgnoredUserAlert />}

View File

@@ -55,6 +55,7 @@ import {
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,
@@ -145,7 +146,7 @@ export function Members({ requestClose }: MembersProps) {
const btn = evt.currentTarget as HTMLButtonElement;
const userId = btn.getAttribute('data-user-id');
if (userId) {
openProfile(room.roomId, space?.roomId, userId, btn.getBoundingClientRect());
openProfile(room.roomId, space?.roomId, userId, getMouseEventCords(evt.nativeEvent));
}
};

View File

@@ -27,6 +27,7 @@ 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;
@@ -136,7 +137,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
room.roomId,
space?.roomId,
senderId,
event.currentTarget.getBoundingClientRect(),
getMouseEventCords(event.nativeEvent),
'Bottom'
);
}}

View File

@@ -51,7 +51,7 @@ const DEFAULT_TAGS: PowerLevelTags = {
color: '#ff6a00',
},
150: {
name: 'Co-Founder',
name: 'Manager',
color: '#ff6a7f',
},
101: {

View File

@@ -30,10 +30,12 @@ import {
NavLink,
} from '../../../components/nav';
import {
encodeSearchParamValueArray,
getExplorePath,
getHomeCreatePath,
getHomeRoomPath,
getHomeSearchPath,
withSearchParam,
} from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
@@ -49,7 +51,6 @@ import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { openJoinAlias } from '../../../../client/action/navigation';
import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../../client/action/notifications';
@@ -61,6 +62,9 @@ import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { UseStateProvider } from '../../../components/UseStateProvider';
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { _RoomSearchParams } from '../../paths';
type HomeMenuProps = {
requestClose: () => void;
@@ -77,11 +81,6 @@ const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, re
requestClose();
};
const handleJoinAddress = () => {
openJoinAlias();
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
@@ -96,16 +95,6 @@ const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, re
Mark as Read
</Text>
</MenuItem>
<MenuItem
onClick={handleJoinAddress}
size="300"
radii="300"
after={<Icon size="100" src={Icons.Link} />}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Join with Address
</Text>
</MenuItem>
</Box>
</Menu>
);
@@ -268,22 +257,44 @@ export function Home() {
</NavItemContent>
</NavButton>
</NavItem>
<NavItem variant="Background" radii="400">
<NavButton onClick={() => openJoinAlias()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Link} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Join with Address
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
<UseStateProvider initial={false}>
{(open, setOpen) => (
<>
<NavItem variant="Background" radii="400">
<NavButton onClick={() => setOpen(true)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Link} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Join with Address
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
{open && (
<JoinAddressPrompt
onCancel={() => setOpen(false)}
onOpen={(roomIdOrAlias, viaServers, eventId) => {
setOpen(false);
const path = getHomeRoomPath(roomIdOrAlias, eventId);
navigate(
viaServers
? withSearchParam<_RoomSearchParams>(path, {
viaServers: encodeSearchParamValueArray(viaServers),
})
: path
);
}}
/>
)}
</>
)}
</UseStateProvider>
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<NavLink to={getHomeSearchPath()}>
<NavItemContent>

View File

@@ -7,15 +7,22 @@ import { stopPropagation } from '../../../utils/keyboard';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { openJoinAlias } from '../../../../client/action/navigation';
import { getCreatePath } from '../../pathUtils';
import {
encodeSearchParamValueArray,
getCreatePath,
getSpacePath,
withSearchParam,
} from '../../pathUtils';
import { useCreateSelected } from '../../../hooks/router/useCreateSelected';
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { _RoomSearchParams } from '../../paths';
export function CreateTab() {
const createSelected = useCreateSelected();
const navigate = useNavigate();
const [menuCords, setMenuCords] = useState<RectCords>();
const [joinAddress, setJoinAddress] = useState(false);
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect());
@@ -27,7 +34,7 @@ export function CreateTab() {
};
const handleJoinWithAddress = () => {
openJoinAlias();
setJoinAddress(true);
setMenuCords(undefined);
};
@@ -103,6 +110,22 @@ export function CreateTab() {
>
<Icon src={Icons.Plus} />
</SidebarAvatar>
{joinAddress && (
<JoinAddressPrompt
onCancel={() => setJoinAddress(false)}
onOpen={(roomIdOrAlias, viaServers) => {
setJoinAddress(false);
const path = getSpacePath(roomIdOrAlias);
navigate(
viaServers
? withSearchParam<_RoomSearchParams>(path, {
viaServers: encodeSearchParamValueArray(viaServers),
})
: path
);
}}
/>
)}
</PopOut>
)}
</SidebarItemTooltip>

View File

@@ -297,7 +297,7 @@ function SpaceHeader() {
type SpaceTombstoneProps = { roomId: string; replacementRoomId: string };
export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) {
const mx = useMatrixClient();
const { navigateRoom } = useRoomNavigate();
const { navigateSpace } = useRoomNavigate();
const [joinState, handleJoin] = useAsyncCallback(
useCallback(() => {
@@ -311,8 +311,8 @@ export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProp
const replacementRoom = mx.getRoom(replacementRoomId);
const handleOpen = () => {
if (replacementRoom) navigateRoom(replacementRoom.roomId);
if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId);
if (replacementRoom) navigateSpace(replacementRoom.roomId);
if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId);
};
return (

View File

@@ -42,9 +42,9 @@ const MATRIX_TO = /^https?:\/\/matrix\.to\S*$/;
export const testMatrixTo = (href: string): boolean => MATRIX_TO.test(href);
const MATRIX_TO_USER = /^https?:\/\/matrix\.to\/#\/(@[^:\s]+:[^?/\s]+)\/?$/;
const MATRIX_TO_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^:\s]+:[^?/\s]+)\/?(\?[\S]*)?$/;
const MATRIX_TO_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/?(\?[\S]*)?$/;
const MATRIX_TO_ROOM_EVENT =
/^https?:\/\/matrix\.to\/#\/([#!][^:\s]+:[^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/;
/^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/;
export const parseMatrixToUser = (href: string): string | undefined => {
const match = href.match(MATRIX_TO_USER);

View File

@@ -1,11 +1,19 @@
import { Room } from 'matrix-js-sdk';
import { IPowerLevels } from '../hooks/usePowerLevels';
import { getMxIdServer } from '../utils/matrix';
import { StateEvent } from '../../types/matrix/room';
import { creatorsSupported, getMxIdServer } from '../utils/matrix';
import { IRoomCreateContent, StateEvent } from '../../types/matrix/room';
import { getStateEvent } from '../utils/room';
export const getViaServers = (room: Room): string[] => {
const getHighestPowerUserId = (): string | undefined => {
const creatorEvent = getStateEvent(room, StateEvent.RoomCreate);
if (
creatorEvent &&
creatorsSupported(creatorEvent.getContent<IRoomCreateContent>().room_version)
) {
return creatorEvent.getSender();
}
const powerLevels = getStateEvent(room, StateEvent.RoomPowerLevels)?.getContent<IPowerLevels>();
if (!powerLevels) return undefined;

View File

@@ -43,6 +43,17 @@ export const canFitInScrollView = (
export type FilesOrFile<T extends boolean | undefined = undefined> = T extends true ? File[] : File;
export const getFilesFromFileList = (fileList: FileList): File[] => {
const files: File[] = [];
for (let i = 0; i < fileList.length; i += 1) {
const file: File | undefined = fileList[i];
if (file instanceof File) files.push(file);
}
return files;
};
export const selectFile = <M extends boolean | undefined = undefined>(
accept: string,
multiple?: M
@@ -58,7 +69,7 @@ export const selectFile = <M extends boolean | undefined = undefined>(
if (!fileList) {
resolve(undefined);
} else {
const files: File[] = [...fileList].filter((file) => file);
const files: File[] = getFilesFromFileList(fileList);
resolve((multiple ? files : files[0]) as FilesOrFile<M>);
}
input.removeEventListener('change', changeHandler);
@@ -70,7 +81,7 @@ export const selectFile = <M extends boolean | undefined = undefined>(
export const getDataTransferFiles = (dataTransfer: DataTransfer): File[] | undefined => {
const fileList = dataTransfer.files;
const files = [...fileList].filter((file) => file);
const files: File[] = getFilesFromFileList(fileList);
if (files.length === 0) return undefined;
return files;
};
@@ -224,3 +235,10 @@ export const notificationPermission = (permission: NotificationPermission) => {
}
return false;
};
export const getMouseEventCords = (event: MouseEvent) => ({
x: event.clientX,
y: event.clientY,
width: 0,
height: 0,
});

View File

@@ -20,6 +20,7 @@ import {
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { AccountDataEvent } from '../../types/matrix/accountData';
import {
IRoomCreateContent,
Membership,
MessageEvent,
NotificationType,
@@ -43,7 +44,7 @@ export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[]
export const getAccountData = (
mx: MatrixClient,
eventType: AccountDataEvent
): MatrixEvent | undefined => mx.getAccountData(eventType);
): MatrixEvent | undefined => mx.getAccountData(eventType as any);
export const getMDirects = (mDirectEvent: MatrixEvent): Set<string> => {
const roomIds = new Set<string>();
@@ -480,6 +481,23 @@ export const bannedInRooms = (mx: MatrixClient, rooms: string[], otherUserId: st
return banned;
});
export const getAllVersionsRoomCreator = (room: Room): Set<string> => {
const creators = new Set<string>();
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
const createContent = createEvent?.getContent<IRoomCreateContent>();
const creator = createEvent?.getSender();
if (typeof creator === 'string') creators.add(creator);
if (createContent && Array.isArray(createContent.additional_creators)) {
createContent.additional_creators.forEach((c) => {
if (typeof c === 'string') creators.add(c);
});
}
return creators;
};
export const guessPerfectParent = (
mx: MatrixClient,
roomId: string,
@@ -490,15 +508,29 @@ export const guessPerfectParent = (
}
const getSpecialUsers = (rId: string): string[] => {
const specialUsers: Set<string> = new Set();
const r = mx.getRoom(rId);
const powerLevels =
r && getStateEvent(r, StateEvent.RoomPowerLevels)?.getContent<IPowerLevelsContent>();
if (!r) return [];
getAllVersionsRoomCreator(r).forEach((c) => specialUsers.add(c));
const powerLevels = getStateEvent(
r,
StateEvent.RoomPowerLevels
)?.getContent<IPowerLevelsContent>();
const { users_default: usersDefault, users } = powerLevels ?? {};
if (typeof users !== 'object') return [];
const defaultPower = typeof usersDefault === 'number' ? usersDefault : 0;
return Object.keys(users).filter((userId) => users[userId] > defaultPower);
if (typeof users === 'object')
Object.keys(users).forEach((userId) => {
if (users[userId] > defaultPower) {
specialUsers.add(userId);
}
});
return Array.from(specialUsers);
};
let perfectParent: string | undefined;

View File

@@ -70,6 +70,7 @@ export type IRoomCreateContent = {
['m.federate']?: boolean;
room_version: string;
type?: string;
additional_creators?: string[];
predecessor?: {
event_id?: string;
room_id: string;