update profile layout and add pronouns

This commit is contained in:
Ajay Bura 2026-07-23 09:09:04 +05:30
commit f167780d15
6 changed files with 319 additions and 241 deletions

View file

@ -1,4 +1,4 @@
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
import React, { MouseEventHandler, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
@ -19,13 +19,12 @@ import {
Scroll,
Avatar,
} from 'folds';
import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user';
import { getMxIdServer } from '../../utils/matrix';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { stopPropagation } from '../../utils/keyboard';
import { copyToClipboard } from '../../utils/dom';
import { getExploreServerPath } from '../../pages/pathUtils';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { factoryRoomIdByAtoZ } from '../../utils/sort';
import { useMutualRooms } from '../../hooks/useMutualRooms';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
@ -35,11 +34,9 @@ import { RoomAvatar, RoomIcon } from '../room-avatar';
import { nameInitials } from '../../utils/common';
import { getMatrixToUser } from '../../plugins/matrix-to';
import { useTimeoutToggle } from '../../hooks/useTimeoutToggle';
import { getIgnoredUsersContent, useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { CutoutCard } from '../cutout-card';
import { SettingTile } from '../setting-tile';
import { useClient } from '../../hooks/useClient';
import { AccountDataType } from '../../../types/matrix/accountData';
import { useRoomHeroes } from '../../hooks/storage/useRoomHeros';
import { useGetRoomName } from '../../hooks/storage/useGetRoomName';
import { useGetRoomAvatar } from '../../hooks/storage/useGetRoomAvatar';
@ -447,86 +444,3 @@ export function IgnoredUserAlert() {
</CutoutCard>
);
}
export function OptionsChip({ userId }: { userId: string }) {
const ct = useClient();
const [cords, setCords] = useState<RectCords>();
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const close = () => setCords(undefined);
const ignoredUsers = useIgnoredUsers();
const ignored = ignoredUsers.includes(userId);
const [ignoreState, toggleIgnore] = useAsyncCallback(
useCallback(async () => {
const users = ignoredUsers.filter((u) => u !== userId);
if (!ignored) users.push(userId);
return ct.requestAuthJSON(
putAccountData({
pathParams: { userId: ct.session.userId, type: AccountDataType.IgnoredUserList },
body: getIgnoredUsersContent(users),
})
);
}, [ct, ignoredUsers, userId, ignored])
);
const ignoring = ignoreState.status === AsyncStatus.Loading;
return (
<PopOut
anchor={cords}
position="Bottom"
align="Start"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu>
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Critical"
fill="None"
size="300"
radii="300"
onClick={() => {
toggleIgnore();
close();
}}
before={
ignoring ? (
<Spinner variant="Critical" size="50" />
) : (
<Icon size="50" src={Icons.Prohibited} />
)
}
disabled={ignoring}
>
<Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text>
</MenuItem>
</div>
</Menu>
</FocusTrap>
}
>
<Chip variant="SurfaceVariant" radii="Pill" onClick={open} aria-pressed={!!cords}>
{ignoring ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon size="50" src={Icons.HorizontalDots} />
)}
</Chip>
</PopOut>
);
}

View file

@ -1,7 +1,9 @@
import React, { ReactNode, useState } from 'react';
import {
Avatar,
Badge,
Box,
config,
Icon,
Icons,
Modal,
@ -9,8 +11,6 @@ import {
OverlayBackdrop,
OverlayCenter,
Text,
Tooltip,
TooltipProvider,
} from 'folds';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
@ -19,7 +19,7 @@ import * as css from './styles.css';
import { UserAvatar } from '../user-avatar';
import colorMXID from '../../../util/colorMXID';
import { getMxIdLocalPart } from '../../utils/matrix';
import { BreakWord, DotDivider, HoverUnderline, LineClamp3 } from '../../styles/Text.css';
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence';
import { ImageViewer } from '../image-viewer';
@ -27,6 +27,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { UserPronoun } from '../../hooks/useUserProfile';
export function UserHero({ children }: { children: ReactNode }) {
return (
@ -139,47 +140,49 @@ export function UserTimezone({ timezone }: { timezone: string }) {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
let time: string | undefined;
let offset: string | undefined;
try {
time = new Intl.DateTimeFormat('en-US', {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
hour12: !hour24Clock,
}).format(new Date());
timeZoneName: 'shortOffset',
}).formatToParts(new Date());
time = parts
.filter((p) => (p.type !== 'timeZoneName' && p.type !== 'literal') || p.type === 'literal')
.map((p) => p.value)
.join('')
.trim();
offset = parts.find((p) => p.type === 'timeZoneName')?.value; // e.g. "GMT+5:30"
} catch (e) {
console.error(e);
}
return (
<TooltipProvider
position="Top"
offset={0}
tooltip={
<Tooltip>
<Text size="L400">{timezone}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Text as="span" ref={triggerRef} className={HoverUnderline} size="T200" priority="300">
{time}
</Text>
)}
</TooltipProvider>
<Box as="span" alignItems="Center" gap="100">
<Icon style={{ opacity: config.opacity.P300 }} size="50" src={Icons.Clock} />
<Text as="span" size="T200" priority="300">
{time} local time{offset ? ` · ${offset}` : ` · ${timezone}`}
</Text>
</Box>
);
}
type UserHeroNameProps = {
displayName?: string;
userId: string;
timezone?: string;
pronouns?: UserPronoun[];
};
export function UserHeroName({ displayName, userId, timezone }: UserHeroNameProps) {
export function UserHeroName({ displayName, userId, pronouns }: UserHeroNameProps) {
const username = getMxIdLocalPart(userId);
return (
<Box grow="Yes" direction="Column" gap="100">
<Box alignItems="Baseline" gap="100" wrap="Wrap">
<Box alignItems="Center" gap="100" wrap="Wrap">
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
@ -188,12 +191,17 @@ export function UserHeroName({ displayName, userId, timezone }: UserHeroNameProp
{displayName ?? username ?? userId}
</Text>
</Box>
<Box alignItems="Center" gap="100" wrap="Wrap">
<Box alignItems="Center" gap="200" wrap="Wrap">
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
@{username}
</Text>
{timezone && <span className={DotDivider} />}
{timezone && <UserTimezone timezone={timezone} />}
{pronouns?.map((pronoun) =>
pronoun.summary ? (
<Badge key={pronoun.summary} variant="Primary" fill="Soft" size="400">
<Text size="L400">{pronoun.summary}</Text>
</Badge>
) : undefined
)}
</Box>
</Box>
);

View file

@ -1,7 +1,27 @@
import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds';
import React, { useCallback, useRef } from 'react';
import {
Box,
Button,
color,
config,
Icon,
Icons,
Spinner,
Text,
Input,
RectCords,
PopOut,
Menu,
MenuItem,
toRem,
Line,
IconButton,
} from 'folds';
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
import { postBan, postInvite, postKick, postUnban } from 'neurone/lib/api/matrix/client/v3/rooms';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { useRoom } from '../../hooks/useRoom';
import { CutoutCard } from '../cutout-card';
import { SettingTile } from '../setting-tile';
@ -11,6 +31,9 @@ import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { timeDayMonYear, timeHourMinute } from '../../utils/time';
import { useClient } from '../../hooks/useClient';
import { getIgnoredUsersContent, useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { AccountDataType } from '../../../types/matrix/accountData';
import { stopPropagation } from '../../utils/keyboard';
type UserKickAlertProps = {
reason?: string;
@ -293,97 +316,206 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
if (!canBan && !canKick && !canInvite) return null;
return (
<Box direction="Column" gap="400">
<Box direction="Column" gap="200">
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Moderation</Text>
<Input
ref={reasonInputRef}
placeholder="Reason"
<Box direction="Column" gap="100">
<Box grow="Yes" direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<Text size="L400">Moderation</Text>
<Input
ref={reasonInputRef}
placeholder="Reason (Optional)"
size="300"
variant="Background"
outlined
radii="300"
disabled={disabled}
/>
{kickState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
<b>{kickState.error.message}</b>
</Text>
)}
{banState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
<b>{banState.error.message}</b>
</Text>
)}
{inviteState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
<b>{inviteState.error.message}</b>
</Text>
)}
</Box>
<Box shrink="No" gap="100" direction="Column">
{canInvite && (
<MenuItem
style={{ flexGrow: 1 }}
size="300"
variant="Background"
variant="Secondary"
fill="None"
radii="300"
after={
inviteState.status === AsyncStatus.Loading ? (
<Spinner size="50" variant="Secondary" fill="Soft" />
) : (
<Icon size="50" src={Icons.Mail} />
)
}
onClick={invite}
disabled={disabled}
/>
{kickState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
<b>{kickState.error.message}</b>
>
<Text style={{ flexGrow: 1 }} size="B300">
Invite to Room
</Text>
)}
{banState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
<b>{banState.error.message}</b>
</MenuItem>
)}
{canKick && (
<MenuItem
style={{ flexGrow: 1 }}
size="300"
variant="Warning"
fill="None"
radii="300"
after={
kickState.status === AsyncStatus.Loading ? (
<Spinner size="50" variant="Critical" fill="Soft" />
) : (
<Icon size="50" src={Icons.ArrowLeft} />
)
}
onClick={kick}
disabled={disabled}
>
<Text style={{ flexGrow: 1 }} size="B300">
Kick from Room
</Text>
)}
{inviteState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
<b>{inviteState.error.message}</b>
</MenuItem>
)}
{canBan && (
<MenuItem
style={{ flexGrow: 1 }}
size="300"
variant="Critical"
fill="None"
radii="300"
after={
banState.status === AsyncStatus.Loading ? (
<Spinner size="50" variant="Critical" fill="Solid" />
) : (
<Icon size="50" src={Icons.NoEntry} />
)
}
onClick={ban}
disabled={disabled}
>
<Text style={{ flexGrow: 1 }} size="B300">
Ban from Room
</Text>
)}
</Box>
<Box shrink="No" gap="200">
{canInvite && (
<Button
style={{ flexGrow: 1 }}
size="300"
variant="Secondary"
fill="Soft"
radii="300"
before={
inviteState.status === AsyncStatus.Loading ? (
<Spinner size="50" variant="Secondary" fill="Soft" />
) : (
<Icon size="50" src={Icons.ArrowRight} />
)
}
onClick={invite}
disabled={disabled}
>
<Text size="B300">Invite</Text>
</Button>
)}
{canKick && (
<Button
style={{ flexGrow: 1 }}
size="300"
variant="Critical"
fill="Soft"
radii="300"
before={
kickState.status === AsyncStatus.Loading ? (
<Spinner size="50" variant="Critical" fill="Soft" />
) : (
<Icon size="50" src={Icons.ArrowLeft} />
)
}
onClick={kick}
disabled={disabled}
>
<Text size="B300">Kick</Text>
</Button>
)}
{canBan && (
<Button
style={{ flexGrow: 1 }}
size="300"
variant="Critical"
fill="Solid"
radii="300"
before={
banState.status === AsyncStatus.Loading ? (
<Spinner size="50" variant="Critical" fill="Solid" />
) : (
<Icon size="50" src={Icons.Prohibited} />
)
}
onClick={ban}
disabled={disabled}
>
<Text size="B300">Ban</Text>
</Button>
)}
</Box>
</MenuItem>
)}
</Box>
</Box>
);
}
export function OptionsButton({ userId, canBan, canInvite, canKick }: UserModerationProps) {
const ct = useClient();
const [cords, setCords] = useState<RectCords>();
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const close = () => setCords(undefined);
const ignoredUsers = useIgnoredUsers();
const ignored = ignoredUsers.includes(userId);
const [ignoreState, toggleIgnore] = useAsyncCallback(
useCallback(async () => {
const users = ignoredUsers.filter((u) => u !== userId);
if (!ignored) users.push(userId);
return ct.requestAuthJSON(
putAccountData({
pathParams: { userId: ct.session.userId, type: AccountDataType.IgnoredUserList },
body: getIgnoredUsersContent(users),
})
);
}, [ct, ignoredUsers, userId, ignored])
);
const ignoring = ignoreState.status === AsyncStatus.Loading;
return (
<PopOut
anchor={cords}
position="Bottom"
align="Center"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu style={{ minWidth: toRem(175) }}>
<Box direction="Column" gap="0">
{(canInvite || canBan || canKick) && (
<>
<div style={{ padding: config.space.S100 }}>
<UserModeration
userId={userId}
canInvite={canInvite}
canKick={canKick}
canBan={canBan}
/>
</div>
<Line size="300" />
</>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
variant="Surface"
fill="Soft"
size="300"
radii="300"
onClick={() => {
toggleIgnore();
close();
}}
after={
ignoring ? (
<Spinner variant="Critical" size="50" />
) : (
<Icon size="50" src={Icons.Prohibited} />
)
}
disabled={ignoring}
>
<Text style={{ flexGrow: 1 }} size="B300">
{ignored ? 'Unblock User' : 'Block User'}
</Text>
</MenuItem>
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
<IconButton
style={{ flexShrink: 0 }}
variant="SurfaceVariant"
fill="Soft"
size="400"
radii="400"
onClick={open}
aria-pressed={!!cords}
>
<Icon size="50" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
);
}

View file

@ -2,7 +2,7 @@ import { Box, Button, config, Icon, Icons, Text } from 'folds';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ObjectReader } from 'neurone/lib/utils';
import { UserHero, UserHeroAvatar, UserHeroCover, UserHeroName } from './UserHero';
import { UserHero, UserHeroAvatar, UserHeroCover, UserHeroName, UserTimezone } from './UserHero';
import { getMxIdServer } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import {
@ -12,10 +12,10 @@ import {
} from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
import { useUserPresence } from '../../hooks/useUserPresence';
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
import { IgnoredUserAlert, MutualRoomsChip, ServerChip, ShareChip } from './UserChips';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { PowerChip } from './PowerChip';
import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration';
import { UserInviteAlert, UserBanAlert, UserKickAlert, OptionsButton } from './UserModeration';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { Membership } from '../../../types/matrix/room';
import { useRoomCreators } from '../../hooks/useRoomCreators';
@ -33,7 +33,6 @@ import {
usePowerLevelTagsState,
} from '../../hooks/usePowerLevelTags';
import { useUserProfile } from '../../hooks/useUserProfile';
import { UserActionButton } from './styles.css';
type UserRoomProfileProps = {
userId: string;
@ -101,21 +100,6 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
bannerUrl={bannerUrl}
hasStatus={!!presence?.status}
/>
{userId !== myUserId && (
<Box grow="Yes" style={{ position: 'relative' }}>
<Button
className={UserActionButton}
size="300"
variant="Primary"
fill="Solid"
radii="300"
before={<Icon size="50" src={Icons.Message} filled />}
onClick={handleMessage}
>
<Text size="B300">Message</Text>
</Button>
</Box>
)}
<UserHeroAvatar
userId={userId}
avatarUrl={avatarUrl}
@ -124,17 +108,31 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
</UserHero>
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
<Box direction="Column" gap="400">
<UserHeroName
displayName={displayName}
userId={userId}
timezone={profile?.timezone}
/>
<Box alignItems="Start" gap="200" grow="Yes">
<UserHeroName
displayName={displayName}
userId={userId}
pronouns={profile?.pronouns}
/>
{userId !== myUserId && (
<OptionsButton
userId={userId}
canInvite={canInvite && membership === 'leave'}
canKick={canKickUser && membership === 'join'}
canBan={canBanUser && membership !== 'ban'}
/>
)}
</Box>
{profile?.timezone && <UserTimezone timezone={profile.timezone} />}
<Text size="T200">
now hierarchy does not have room summary info we need to load it when we will be
rendering it
</Text>
<Box alignItems="Center" gap="200" wrap="Wrap">
{server && <ServerChip server={server} />}
<ShareChip userId={userId} />
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
{userId !== myUserId && <MutualRoomsChip userId={userId} />}
{userId !== myUserId && <OptionsChip userId={userId} />}
</Box>
</Box>
{ignored && <IgnoredUserAlert />}
@ -165,12 +163,21 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
ts={memberEventResult.value.event.origin_server_ts}
/>
)}
<UserModeration
userId={userId}
canInvite={canInvite && membership === Membership.Leave}
canKick={canKickUser && membership === Membership.Join}
canBan={canBanUser && membership !== Membership.Ban}
/>
{userId !== myUserId && (
<Box direction="Row" gap="200">
<Button
style={{ flexGrow: 1 }}
size="300"
variant="Primary"
fill="Solid"
radii="300"
before={<Icon size="50" src={Icons.Message} />}
onClick={handleMessage}
>
<Text size="B300">Message</Text>
</Button>
</Box>
)}
</Box>
</Box>
</PowerLevelTagsContextProvider>

View file

@ -106,11 +106,3 @@ export const UserHeroStatusContent = style({
color: color.Surface.OnContainer,
borderRadius: config.radii.R500,
});
export const UserActionButton = style({
position: 'absolute',
right: config.space.S400,
zIndex: 1,
transform: 'translateY(-10%)',
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
});

View file

@ -2,13 +2,27 @@ import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getProfile, GetProfileOK } from 'neurone/lib/api/matrix/client/v3/profile';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { ObjectReader } from 'neurone/lib/utils';
import { useClient } from './useClient';
type PronounResponse = {
grammatical_gender?: string;
language?: string;
summary?: string;
};
export type UserPronoun = {
grammaticalGender?: string;
language?: string;
summary?: string;
};
export type UserProfile = {
avatarUrl?: string;
displayName?: string;
bannerUrl?: string;
timezone?: string;
pronouns?: UserPronoun[];
};
export const useUserProfile = (userId: string): UserProfile | undefined => {
const ct = useClient();
@ -25,14 +39,25 @@ export const useUserProfile = (userId: string): UserProfile | undefined => {
'chat.commet.profile_banner'?: string;
'm.banner_url'?: string;
'm.tz'?: string;
'io.fsky.nyx.pronouns': PronounResponse[];
'm.pronouns': PronounResponse[];
}
>(response);
const pronouns = profile?.['m.pronouns'] ?? profile?.['io.fsky.nyx.pronouns'];
return {
avatarUrl: profile?.avatar_url || undefined,
displayName: profile?.displayname || undefined,
bannerUrl: profile?.['m.banner_url'] ?? profile?.['chat.commet.profile_banner'] ?? undefined,
timezone: profile?.['m.tz'] ?? undefined,
bannerUrl:
ObjectReader.string(profile, 'm.banner_url') ??
ObjectReader.string(profile, 'chat.commet.profile_banner'),
timezone: ObjectReader.string(profile, 'm.tz'),
pronouns: pronouns?.map((p) => ({
grammaticalGender: ObjectReader.string(p, 'grammatical_gender'),
language: ObjectReader.string(p, 'language'),
summary: ObjectReader.string(p, 'summary'),
})),
};
}, [ct, userId]);