Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 53ac87befe fix(ui): receipt pill, insights counts, settings dividers, decoration preview (#269-#273)
CI / Build & Quality Checks (pull_request) Successful in 2m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m45s
#269 read-receipt pill sits at the message row's right edge, not where
     the text ends (outside the shrink-to-fit content column; grows in
     Compact). Bubble layout unchanged.
#270 Most Active Members: the bar's % is of its own track, so every row
     shows its count.
#271 Noise Suppression description at T200 like the rest.
#272 decoration preview shows your own avatar under the decoration; the
     empty state is a visible dashed circle.
#273 grouped settings get a divider between tiles (SettingTileGroup, via
     globalStyle; SettingTile roots carry data-setting-tile), and on phones
     the Sync tile's buttons sit under its description.

Fixes #269
Fixes #270
Fixes #271
Fixes #272
Fixes #273

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:06:20 -04:00
16 changed files with 160 additions and 168 deletions
@@ -34,7 +34,8 @@ export function SettingTile({ title, description, before, after, children }: Set
});
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>}
<Box grow="Yes" direction="Column" gap="100">
{title && (
@@ -1,11 +1,10 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
import { config, toRem } from 'folds';
// Mirrors MembersDrawer: a side panel (shared width) on desktop that becomes a
// Mirrors MembersDrawer: a 266px side panel on desktop that becomes a
// full-screen fixed panel on narrow viewports — the app's canonical drawer.
export const BookmarksPanel = style({
width: SIDE_PANEL_WIDTH,
width: toRem(266),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
+13 -10
View File
@@ -401,16 +401,19 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
{displayName}
</Text>
<Box alignItems="Center" gap="200">
<div
style={{
height: 6,
width: barWidth,
background: color.Primary.Main,
borderRadius: config.radii.R300,
transition: 'width 0.3s ease',
flexShrink: 0,
}}
/>
{/* [Gitea #270] The bar's % width is of its own track,
not of the row, so the count always fits beside it. */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
height: 6,
width: barWidth,
background: color.Primary.Main,
borderRadius: config.radii.R300,
transition: 'width 0.3s ease',
}}
/>
</div>
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
{count}
</Text>
+5 -5
View File
@@ -1,13 +1,13 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
// Right-side drawer DOCKED into the room layout row (a flex sibling of the
// timeline), exactly like MembersDrawer — not a floating overlay. Same width as
// every other right-hand panel (styles/sidePanel.ts, #263). On narrow viewports
// it becomes a full-screen fixed panel, matching the app's other drawers.
// timeline), exactly like MembersDrawer — not a floating overlay. 320px is a
// little wider than the 266px member/bookmark drawers because it hosts a media
// grid. On narrow viewports it becomes a full-screen fixed panel, matching the
// app's other drawers.
export const MediaGalleryDrawer = style({
width: SIDE_PANEL_WIDTH,
width: toRem(320),
overflow: 'hidden',
'@media': {
'(max-width: 750px)': {
+2 -3
View File
@@ -1,9 +1,8 @@
import { keyframes, style } from '@vanilla-extract/css';
import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
import { config, toRem } from 'folds';
export const MembersDrawer = style({
width: SIDE_PANEL_WIDTH,
width: toRem(266),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
+24 -18
View File
@@ -1137,25 +1137,31 @@ export const Message = React.memo(
</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 = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
{edit && onEditId ? (
<MessageEditor
style={{
maxWidth: '100%',
width: '100vw',
}}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
) : (
children
)}
{reactions}
<Box direction="Column" style={{ maxWidth: '100%', flexGrow: 1, minWidth: 0 }}>
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
{edit && onEditId ? (
<MessageEditor
style={{
maxWidth: '100%',
width: '100vw',
}}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
) : (
children
)}
{reactions}
</Box>
{readReceiptUsers.length > 0 && (
<ReadReceiptAvatars
room={room}
@@ -1,9 +1,8 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
import { config, toRem } from 'folds';
export const ThreadPanel = style({
width: SIDE_PANEL_WIDTH,
width: toRem(360),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
@@ -1,9 +1,8 @@
import { style } from '@vanilla-extract/css';
import { color, config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
import { color, config, toRem } from 'folds';
export const ThreadsListPanel = style({
width: SIDE_PANEL_WIDTH,
width: toRem(340),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
@@ -1,9 +1,8 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
import { config, toRem } from 'folds';
export const WidgetsPanel = style({
width: SIDE_PANEL_WIDTH,
width: toRem(360),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
-47
View File
@@ -8,7 +8,6 @@ import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
import pkg from '../../../../../package.json';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { tauriInvoke } from '../../../hooks/useTauri';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
@@ -79,51 +78,6 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
type AboutProps = {
requestClose: () => void;
};
/**
* [Gitea #276] Which build someone is on: the web deploy's commit and build
* date (stamped in by vite.config.js), plus the desktop app's own version from
* Tauri, the number a desktop or updater bug report needs.
*/
function BuildInfo() {
const [desktopVersion, setDesktopVersion] = useState<string>();
useEffect(() => {
tauriInvoke()?.('plugin:app|version')
.then((v) => {
if (typeof v === 'string') setDesktopVersion(v);
})
.catch(() => undefined);
}, []);
const commit = __LOTUS_BUILD_COMMIT__;
const built = new Date(__LOTUS_BUILD_DATE__);
const builtText = Number.isNaN(built.getTime())
? undefined
: built.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
return (
<Box direction="Column" gap="0">
{desktopVersion && (
<Text size="T200" priority="300">
Desktop {desktopVersion}
</Text>
)}
<Text size="T200" priority="300">
Web build{' '}
{commit !== 'unknown' ? (
<a
href={`https://code.lotusguild.org/LotusGuild/cinny/commit/${commit}`}
target="_blank"
rel="noreferrer noopener"
>
{commit}
</a>
) : (
commit
)}
{builtText && ` · built ${builtText}`}
</Text>
</Box>
);
}
export function About({ requestClose }: AboutProps) {
const mx = useMatrixClient();
const { support: serverSupport, loading: supportLoading } = useServerSupport();
@@ -163,7 +117,6 @@ export function About({ requestClose }: AboutProps) {
<Text size="T200">v{pkg.version}</Text>
</Box>
<Text>A Matrix client for Lotus Guild.</Text>
<BuildInfo />
</Box>
<Box gap="200" wrap="Wrap">
@@ -1,7 +1,12 @@
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 { 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 { SettingTile } from '../../../components/setting-tile';
import {
@@ -14,6 +19,8 @@ import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
const CELL_SIZE = 72;
// Matches AvatarDecoration's default inset, so the preview looks like the timeline.
const PREVIEW_INSET = 8;
function DecorationPreviewCell({
slug,
@@ -86,6 +93,12 @@ function categoryOf(slug: string | null): string {
export function ProfileDecoration() {
const mx = useMatrixClient();
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 [selected, setSelected] = useState<string | null>(null);
@@ -198,6 +211,10 @@ export function ProfileDecoration() {
<Box direction="Column" gap="300">
{/* Current selection preview */}
<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
style={{
position: 'relative',
@@ -205,10 +222,29 @@ export function ProfileDecoration() {
height: CELL_SIZE,
flexShrink: 0,
borderRadius: '50%',
background: color.SurfaceVariant.Container,
overflow: 'hidden',
boxSizing: 'border-box',
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 && (
<img
src={decorationUrl(selected)}
@@ -220,6 +256,7 @@ export function ProfileDecoration() {
height: '100%',
objectFit: 'contain',
pointerEvents: 'none',
zIndex: 1,
}}
/>
)}
+51 -34
View File
@@ -111,7 +111,8 @@ import {
translationUnavailableText,
} from '../../../utils/translation/langUtils';
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 {
describeUpdateError,
@@ -1598,6 +1599,7 @@ function Privacy() {
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
// device-local (never synced) so switching it off here sticks.
function SettingsSyncSection() {
const isMobile = useScreenSize() === ScreenSize.Mobile;
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
const { pushNow, clearRemote } = useSettingsSyncActions();
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 (
<Box direction="Column" gap="100">
<Text size="L400">Sync</Text>
@@ -1636,34 +1666,12 @@ function SettingsSyncSection() {
<SettingTile
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."
after={
<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>
}
/>
// [Gitea #273] On phones the two buttons squeezed the text into a
// narrow column; put them under the description there.
after={isMobile ? undefined : syncButtons}
>
{isMobile && <Box style={{ paddingTop: config.space.S100 }}>{syncButtons}</Box>}
</SettingTile>
{note && (
<Text
size="T200"
@@ -1800,7 +1808,8 @@ function Calls() {
title="Noise Suppression"
description={
<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
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
</Text>
@@ -2033,7 +2042,7 @@ function Calls() {
)}
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
gap="400"
@@ -2130,7 +2139,7 @@ function Calls() {
)}
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
gap="400"
@@ -2264,7 +2273,11 @@ function Calls() {
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SequenceCard
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
>
<SettingTile
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."
@@ -2751,7 +2764,11 @@ function Messages() {
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SequenceCard
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
>
<SettingTile
title="Translate Messages Into"
description={
+14 -2
View File
@@ -1,6 +1,18 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config } from 'folds';
export const SequenceCardStyle = style({
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,
});
-9
View File
@@ -1,9 +0,0 @@
import { toRem } from 'folds';
/**
* One width for every right-hand room panel (members, bookmarks, threads, a
* thread, media gallery, widgets). Swapping one panel for another must not
* resize the room view: when the widths differed, the header icons slid
* sideways and a second click on the same spot hit a different button (#263).
*/
export const SIDE_PANEL_WIDTH = toRem(340);
-4
View File
@@ -1,9 +1,5 @@
/// <reference types="vite/client" />
// [Gitea #276] Set by vite.config.js `define`.
declare const __LOTUS_BUILD_COMMIT__: string;
declare const __LOTUS_BUILD_DATE__: string;
declare module 'browser-encrypt-attachment' {
export interface EncryptedAttachmentInfo {
v: string;
-19
View File
@@ -6,7 +6,6 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import { VitePWA } from 'vite-plugin-pwa';
import fs from 'fs';
import { execSync } from 'child_process';
import path from 'path';
import buildConfig from './build.config';
@@ -273,26 +272,8 @@ const vendorChunks = (id) => {
if (id.includes('node_modules/chroma-js')) return 'chroma';
};
// [Gitea #276] Which build is this? The short commit and build time are shown
// in Settings → About (web deploys change many times a day while the package
// version stays the same). LOTUS_BUILD_COMMIT overrides; no git → "unknown".
const buildCommit = (() => {
if (process.env.LOTUS_BUILD_COMMIT) return process.env.LOTUS_BUILD_COMMIT.slice(0, 12);
try {
return execSync('git rev-parse --short=8 HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
.toString()
.trim();
} catch {
return 'unknown';
}
})();
export default defineConfig({
appType: 'spa',
define: {
__LOTUS_BUILD_COMMIT__: JSON.stringify(buildCommit),
__LOTUS_BUILD_DATE__: JSON.stringify(new Date().toISOString()),
},
publicDir: false,
base: buildConfig.base,
server: {