fix(ui): receipt pill, insights counts, settings dividers, decoration preview (#269-#273) #431

Merged
jared merged 1 commits from fix/ui-polish-batch into lotus 2026-10-19 15:59:47 -04:00
6 changed files with 144 additions and 68 deletions
Showing only changes of commit 53ac87befe - Show all commits
@@ -34,7 +34,8 @@ export function SettingTile({ title, description, before, after, children }: Set
}); });
return ( return (
<Box alignItems="Center" gap="300"> // data-setting-tile: lets a group card draw dividers between tiles only (#273).
<Box alignItems="Center" gap="300" data-setting-tile="">
{before && <Box shrink="No">{before}</Box>} {before && <Box shrink="No">{before}</Box>}
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">
{title && ( {title && (
+13 -10
View File
@@ -401,16 +401,19 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
{displayName} {displayName}
</Text> </Text>
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
<div {/* [Gitea #270] The bar's % width is of its own track,
style={{ not of the row, so the count always fits beside it. */}
height: 6, <div style={{ flex: 1, minWidth: 0 }}>
width: barWidth, <div
background: color.Primary.Main, style={{
borderRadius: config.radii.R300, height: 6,
transition: 'width 0.3s ease', width: barWidth,
flexShrink: 0, background: color.Primary.Main,
}} borderRadius: config.radii.R300,
/> transition: 'width 0.3s ease',
}}
/>
</div>
<Text size="T200" priority="300" style={{ flexShrink: 0 }}> <Text size="T200" priority="300" style={{ flexShrink: 0 }}>
{count} {count}
</Text> </Text>
+24 -18
View File
@@ -1137,25 +1137,31 @@ export const Message = React.memo(
</AvatarBase> </AvatarBase>
); );
// [Gitea #269] The content column shrinks to the message's width; the
// receipt pill / delivery status sit OUTSIDE it, in a full-width column,
// so their `margin-left: auto` reaches the row's right edge instead of
// wherever the text happens to end.
const msgContentJSX = ( const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}> <Box direction="Column" style={{ maxWidth: '100%', flexGrow: 1, minWidth: 0 }}>
{reply} <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{edit && onEditId ? ( {reply}
<MessageEditor {edit && onEditId ? (
style={{ <MessageEditor
maxWidth: '100%', style={{
width: '100vw', maxWidth: '100%',
}} width: '100vw',
roomId={room.roomId} }}
room={room} roomId={room.roomId}
mEvent={mEvent} room={room}
imagePackRooms={imagePackRooms} mEvent={mEvent}
onCancel={() => onEditId()} imagePackRooms={imagePackRooms}
/> onCancel={() => onEditId()}
) : ( />
children ) : (
)} children
{reactions} )}
{reactions}
</Box>
{readReceiptUsers.length > 0 && ( {readReceiptUsers.length > 0 && (
<ReadReceiptAvatars <ReadReceiptAvatars
room={room} room={room}
@@ -1,7 +1,12 @@
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds'; import { Avatar, Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
import { Method } from 'matrix-js-sdk'; import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
import { UserAvatar } from '../../../components/user-avatar';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { import {
@@ -14,6 +19,8 @@ import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const PROFILE_FIELD = 'io.lotus.avatar_decoration'; const PROFILE_FIELD = 'io.lotus.avatar_decoration';
const CELL_SIZE = 72; const CELL_SIZE = 72;
// Matches AvatarDecoration's default inset, so the preview looks like the timeline.
const PREVIEW_INSET = 8;
function DecorationPreviewCell({ function DecorationPreviewCell({
slug, slug,
@@ -86,6 +93,12 @@ function categoryOf(slug: string | null): string {
export function ProfileDecoration() { export function ProfileDecoration() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const userId = mx.getUserId()!; const userId = mx.getUserId()!;
const useAuthentication = useMediaAuthentication();
const profile = useUserProfile(userId);
const myName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const myAvatarUrl = profile.avatarUrl
? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
const [current, setCurrent] = useState<string | null>(null); const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
@@ -198,6 +211,10 @@ export function ProfileDecoration() {
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
{/* Current selection preview */} {/* Current selection preview */}
<Box alignItems="Center" gap="300"> <Box alignItems="Center" gap="300">
{/* [Gitea #272] Your own avatar with the decoration overlaid, as the
timeline draws it (AvatarDecoration: the image is the avatar plus
an 8px margin on every side). With no decoration the circle gets
a visible dashed outline instead of vanishing into the card. */}
<div <div
style={{ style={{
position: 'relative', position: 'relative',
@@ -205,10 +222,29 @@ export function ProfileDecoration() {
height: CELL_SIZE, height: CELL_SIZE,
flexShrink: 0, flexShrink: 0,
borderRadius: '50%', borderRadius: '50%',
background: color.SurfaceVariant.Container, boxSizing: 'border-box',
overflow: 'hidden', border: selected
? 'none'
: `2px dashed color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 45%, transparent)`,
}} }}
> >
<Avatar
size="500"
radii="Pill"
style={{
position: 'absolute',
inset: PREVIEW_INSET,
width: CELL_SIZE - PREVIEW_INSET * 2,
height: CELL_SIZE - PREVIEW_INSET * 2,
}}
>
<UserAvatar
userId={userId}
src={myAvatarUrl}
alt=""
renderFallback={() => <Text size="H5">{nameInitials(myName)}</Text>}
/>
</Avatar>
{selected && ( {selected && (
<img <img
src={decorationUrl(selected)} src={decorationUrl(selected)}
@@ -220,6 +256,7 @@ export function ProfileDecoration() {
height: '100%', height: '100%',
objectFit: 'contain', objectFit: 'contain',
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 1,
}} }}
/> />
)} )}
+51 -34
View File
@@ -111,7 +111,8 @@ import {
translationUnavailableText, translationUnavailableText,
} from '../../../utils/translation/langUtils'; } from '../../../utils/translation/langUtils';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine'; import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle, SettingTileGroup } from '../styles.css';
import { ScreenSize, useScreenSize } from '../../../hooks/useScreenSize';
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater'; import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { import {
describeUpdateError, describeUpdateError,
@@ -1598,6 +1599,7 @@ function Privacy() {
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself // [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
// device-local (never synced) so switching it off here sticks. // device-local (never synced) so switching it off here sticks.
function SettingsSyncSection() { function SettingsSyncSection() {
const isMobile = useScreenSize() === ScreenSize.Mobile;
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync'); const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
const { pushNow, clearRemote } = useSettingsSyncActions(); const { pushNow, clearRemote } = useSettingsSyncActions();
const [busy, setBusy] = useState<'push' | 'clear' | null>(null); const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
@@ -1621,6 +1623,34 @@ function SettingsSyncSection() {
} }
}; };
const syncButtons = (
<Box gap="200">
<Button
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={busy !== null}
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
onClick={() => run('push')}
>
<Text size="B300">Push now</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={busy !== null}
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
onClick={() => run('clear')}
>
<Text size="B300">Clear synced copy</Text>
</Button>
</Box>
);
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Sync</Text> <Text size="L400">Sync</Text>
@@ -1636,34 +1666,12 @@ function SettingsSyncSection() {
<SettingTile <SettingTile
title="Use This Device's Settings Everywhere" title="Use This Device's Settings Everywhere"
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently." description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
after={ // [Gitea #273] On phones the two buttons squeezed the text into a
<Box gap="200"> // narrow column; put them under the description there.
<Button after={isMobile ? undefined : syncButtons}
size="300" >
variant="Secondary" {isMobile && <Box style={{ paddingTop: config.space.S100 }}>{syncButtons}</Box>}
fill="Soft" </SettingTile>
outlined
radii="300"
disabled={busy !== null}
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
onClick={() => run('push')}
>
<Text size="B300">Push now</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={busy !== null}
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
onClick={() => run('clear')}
>
<Text size="B300">Clear synced copy</Text>
</Button>
</Box>
}
/>
{note && ( {note && (
<Text <Text
size="T200" size="T200"
@@ -1800,7 +1808,8 @@ function Calls() {
title="Noise Suppression" title="Noise Suppression"
description={ description={
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
<Text> {/* [Gitea #271] T200 like every other setting description. */}
<Text size="T200">
Filter background noise from your mic during calls. Browser-native uses the built-in Filter background noise from your mic during calls. Browser-native uses the built-in
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal. WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
</Text> </Text>
@@ -2033,7 +2042,7 @@ function Calls() {
)} )}
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant" variant="SurfaceVariant"
direction="Column" direction="Column"
gap="400" gap="400"
@@ -2130,7 +2139,7 @@ function Calls() {
)} )}
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant" variant="SurfaceVariant"
direction="Column" direction="Column"
gap="400" gap="400"
@@ -2264,7 +2273,11 @@ function Calls() {
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
>
<SettingTile <SettingTile
title="Soundboard" title="Soundboard"
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices." description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
@@ -2751,7 +2764,11 @@ function Messages() {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
>
<SettingTile <SettingTile
title="Translate Messages Into" title="Translate Messages Into"
description={ description={
+14 -2
View File
@@ -1,6 +1,18 @@
import { style } from '@vanilla-extract/css'; import { globalStyle, style } from '@vanilla-extract/css';
import { config } from 'folds'; import { color, config } from 'folds';
export const SequenceCardStyle = style({ export const SequenceCardStyle = style({
padding: config.space.S300, padding: config.space.S300,
}); });
/**
* [Gitea #273] Several settings share one card; without a separator the next
* tile's title read like part of the previous description. A thin line goes
* between tiles only (not between a tile and its own slider or note).
*/
export const SettingTileGroup = style({});
// globalStyle: vanilla-extract's `selectors` may only target the element itself.
globalStyle(`${SettingTileGroup} > [data-setting-tile] ~ [data-setting-tile]`, {
borderTop: `1px solid color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 14%, transparent)`,
paddingTop: config.space.S400,
});