Skip to content
Draft
1 change: 1 addition & 0 deletions src/app/components/editor/Editor.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { color, config, DefaultReset, toRem } from 'folds';
export const Editor = style([
DefaultReset,
{
position: 'relative',
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
Expand Down
70 changes: 68 additions & 2 deletions src/app/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const singleLineWidthOffsetRef = useRef(0);
const latestValueRef = useRef<Descendant[]>(editor.children);
const isMultilineRef = useRef(false);
const hiddenInputRef = useRef<HTMLInputElement>(null);
// Set while the autocapitalize trick is briefly stealing focus from the Slate editor
// to the hidden native input. Prevents the onBlur guard from re-focusing Slate early.
const suppressBlurFocusRef = useRef(false);
// Set when the trick is returning focus to Slate, to prevent onFocus from re-triggering.
const justRestoredFocusRef = useRef(false);
const [isMultiline, setIsMultiline] = useState(false);
const [measurementVersion, setMeasurementVersion] = useState(0);
const hasBefore = Boolean(before);
Expand Down Expand Up @@ -348,8 +354,29 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
updateMultilineLayout(latestValueRef.current);
}, [measurementVersion, updateMultilineLayout]);

// iOS does not reliably capitalise the first letter in an empty contenteditable because
// it uses its native input pipeline (only fully wired up for <input>/<textarea>) to detect
// "start of sentence". Briefly focusing a hidden native <input autocapitalize="sentences">
// before returning focus to Slate gives iOS the native signal it needs.
const triggerAutoCapitalize = useCallback(() => {
if (!mobileOrTablet()) return;
const hiddenInput = hiddenInputRef.current;
if (!hiddenInput) return;
suppressBlurFocusRef.current = true;
hiddenInput.focus();
requestAnimationFrame(() => {
suppressBlurFocusRef.current = false;
justRestoredFocusRef.current = true;
ReactEditor.focus(editor);
requestAnimationFrame(() => {
justRestoredFocusRef.current = false;
});
});
}, [editor]);

const handleChange = useCallback(
(value: Descendant[]) => {
const prevText = latestValueRef.current.map((node) => Node.string(node)).join('');
latestValueRef.current = value;
measurementCacheRef.current = null;
if (multilineMeasureFrameRef.current !== null) {
Expand All @@ -358,8 +385,14 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
}
setMeasurementVersion((version) => version + 1);
onChange?.(value);
// After a send, content goes from non-empty to empty while the editor stays focused.
// Trigger the hidden-input trick so the next message starts capitalised.
const nextText = value.map((node) => Node.string(node)).join('');
if (prevText.length > 0 && nextText.length === 0 && ReactEditor.isFocused(editor)) {
triggerAutoCapitalize();
}
},
[onChange]
[onChange, editor, triggerAutoCapitalize]
);

const renderElement = useCallback(
Expand Down Expand Up @@ -398,6 +431,31 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(

return (
<div className={`${css.Editor} ${className || ''}`} ref={setRootRef}>
{/*
* Hidden native <input> used solely to prime iOS autocapitalize.
* iOS only reliably detects "start of sentence" on native inputs, not
* contenteditable. We briefly focus this input (opacity:0, 1×1px, contained
* by the overflow:hidden parent) before returning focus to Slate so iOS
* sets its keyboard to sentence-case for the next keystroke.
*/}
<input
ref={hiddenInputRef}
tabIndex={-1}
aria-hidden="true"
autoCapitalize="sentences"
readOnly
style={{
position: 'absolute',
top: 0,
left: 0,
width: '1px',
height: '1px',
opacity: 0,
pointerEvents: 'none',
border: 'none',
padding: 0,
}}
/>
<Slate editor={editor} initialValue={slateInitialValue} onChange={handleChange}>
{top}
<Box
Expand Down Expand Up @@ -440,9 +498,17 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
onPaste={onPaste}
// Defer to OS capitalization setting (respects iOS sentence-case toggle).
autoCapitalize="sentences"
// Enables autocorrect on iOS, which also helps autocapitalization work.
autoCorrect="on"
// Trigger the hidden-input trick when focusing an empty editor (e.g. on app resume).
onFocus={() => {
if (!justRestoredFocusRef.current && Node.string(editor).length === 0) {
triggerAutoCapitalize();
}
}}
// keeps focus after pressing send.
onBlur={() => {
if (mobileOrTablet()) ReactEditor.focus(editor);
if (mobileOrTablet() && !suppressBlurFocusRef.current) ReactEditor.focus(editor);
}}
/>
</Scroll>
Expand Down
12 changes: 5 additions & 7 deletions src/app/features/lobby/RoomItem.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { MouseEventHandler, ReactNode } from 'react';
import { useCallback, useRef } from 'react';
import { MouseEventHandler, ReactNode, useCallback, useRef } from 'react';
import {
Avatar,
Badge,
Expand All @@ -20,18 +19,17 @@ import {
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import type { MatrixError, Room, IHierarchyRoom } from '$types/matrix-sdk';
import { JoinRule, KnownMembership } from '$types/matrix-sdk';
import { JoinRule, MatrixError, Room, IHierarchyRoom } from '$types/matrix-sdk';
import { RoomAvatar, RoomIcon } from '$components/room-avatar';
import { SequenceCard } from '$components/sequence-card';
import { useMatrixClient } from '$hooks/useMatrixClient';
import type { HierarchyItem } from '$hooks/useSpaceHierarchy';
import { HierarchyItem } from '$hooks/useSpaceHierarchy';
import { KnockRoomPrompt } from '$components/knock-room-prompt';
import { LocalRoomSummaryLoader } from '$components/RoomSummaryLoader';
import { UseStateProvider } from '$components/UseStateProvider';
import { RoomTopicViewer } from '$components/room-topic-viewer';
import { onEnterOrSpace, stopPropagation } from '$utils/keyboard';

import { Membership } from '$types/matrix/room';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '$utils/room';
import { mxcUrlToHttp } from '$utils/matrix';
Expand Down Expand Up @@ -364,7 +362,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
const targetHandleRef = useRef<HTMLDivElement>(null);
useDraggableItem(item, targetRef, onDragging, targetHandleRef);

const joined = room?.getMyMembership() === KnownMembership.Join;
const joined = room?.getMyMembership() === Membership.Join;

return (
<SequenceCard
Expand Down
129 changes: 81 additions & 48 deletions src/app/features/room-nav/RoomNavItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { useRoomTypingMember } from '$hooks/useRoomTypingMembers';
import { TypingIndicator } from '$components/typing-indicator';
import { stopPropagation } from '$utils/keyboard';
import { getMatrixToRoom } from '$plugins/matrix-to';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '$utils/matrix';
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '$utils/matrix';
import { getViaServers } from '$plugins/via-servers';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useSetting } from '$state/hooks/settings';
Expand Down Expand Up @@ -72,6 +72,9 @@ import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '$hooks/useLivekitSupport';
import { Presence, useUserPresence } from '$hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '$components/presence';
import { useGroupDMMembers } from '$hooks/useGroupDMMembers';
import { UserAvatar } from '$components/user-avatar';
import * as css from './styles.css';
import { RoomNavUser } from './RoomNavUser';
import { SidebarUnreadBadge } from '$components/sidebar';

Expand Down Expand Up @@ -292,6 +295,10 @@ export function RoomNavItem({
(receipt) => receipt.userId !== mx.getUserId()
);

const isGroupDM = direct === true && room.getJoinedMemberCount() > 2;
// Keep hook call unconditional; pass undefined when not a group DM so the hook no-ops.
const groupMembers = useGroupDMMembers(mx, isGroupDM ? room : undefined, 3);

const nicknames = useAtomValue(nicknamesAtom);
const dmUserId = direct ? room.getAvatarFallbackMember()?.userId : undefined;
const matrixRoomName = useRoomName(room);
Expand Down Expand Up @@ -420,55 +427,81 @@ export function RoomNavItem({
>
<NavItemContent style={hideTextStyling(hideText)}>
<Box as="span" grow="Yes" alignItems="Center" style={hideTextStyling(hideText)}>
<AvatarPresence
badge={
presence &&
presence.presence !== Presence.Offline && (
<PresenceBadge
presence={presence.presence}
size={hideText ? '300' : '200'}
/>
)
}
style={hideTextStyling(hideText)}
>
<Avatar
size={hideText ? undefined : '200'}
radii="400"
{isGroupDM && showAvatar && groupMembers.length > 1 ? (
// Group DM: triangle layout of mini avatars
<div className={css.GroupAvatarRow}>
{groupMembers.map((member) => {
const avatarSrc = member.avatarUrl
? (mxcUrlToHttp(mx, member.avatarUrl, useAuthentication, 32, 32, 'crop') ??
undefined)
: undefined;
return (
<Avatar key={member.userId} className={css.GroupAvatarMini}>
<UserAvatar
userId={member.userId}
src={avatarSrc}
alt={member.displayName ?? member.userId}
renderFallback={() => (
<Text as="span" size="T200">
{nameInitials(member.displayName ?? member.userId)}
</Text>
)}
/>
</Avatar>
);
})}
</div>
) : (
<AvatarPresence
badge={
presence &&
presence.presence !== Presence.Offline && (
<PresenceBadge
presence={presence.presence}
size={hideText ? '300' : '200'}
/>
)
}
style={hideTextStyling(hideText)}
>
{showAvatar ? (
<RoomAvatar
roomId={room.roomId}
src={
((!direct || customDMCards) &&
getRoomAvatarUrl(mx, room, 96, useAuthentication)) ||
getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
}
uniformIcons
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(roomName)}
</Text>
)}
/>
) : (
<RoomIcon
style={{
opacity:
unread || hasRoomUnread || isActiveCall
? config.opacity.P500
: config.opacity.P300,
}}
filled={selected || isActiveCall}
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
</AvatarPresence>
<Avatar
size={hideText ? undefined : '200'}
radii="400"
style={hideTextStyling(hideText)}
>
{showAvatar ? (
<RoomAvatar
roomId={room.roomId}
src={
((!direct || customDMCards) &&
getRoomAvatarUrl(mx, room, 96, useAuthentication)) ||
getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
}
uniformIcons
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(roomName)}
</Text>
)}
/>
) : (
<RoomIcon
style={{
opacity:
unread || hasRoomUnread || isActiveCall
? config.opacity.P500
: config.opacity.P300,
}}
filled={selected || isActiveCall}
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
</AvatarPresence>
)}
{unread && hideText && (
<SidebarUnreadBadge
highlight={unread.highlight > 0}
Expand Down
41 changes: 40 additions & 1 deletion src/app/features/room-nav/styles.css.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,48 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { color, config } from 'folds';

export const CategoryButton = style({
flexGrow: 1,
});
export const CategoryButtonIcon = style({
opacity: config.opacity.P400,
});

/**
* Group DM multi-avatar layout for the nav item's Avatar size="200" (24 px) slot.
* Three mini avatars are stacked in a triangle: top-centre, bottom-left, bottom-right.
*/
export const GroupAvatarRow = style({
position: 'relative',
// Match the Avatar size="200" footprint so layout is not disrupted.
width: '24px',
height: '24px',
flexShrink: 0,
});

export const GroupAvatarMini = style({
position: 'absolute',
width: '14px',
height: '14px',
border: `1.5px solid ${color.Surface.Container}`,
borderRadius: '50%',
overflow: 'hidden',
selectors: {
'&:nth-child(1)': {
top: '0',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 3,
},
'&:nth-child(2)': {
bottom: '0',
left: '0',
zIndex: 2,
},
'&:nth-child(3)': {
bottom: '0',
right: '0',
zIndex: 1,
},
},
});
Loading