Compare commits
3 Commits
7f329e3b31
...
10f6544e2e
| Author | SHA1 | Date | |
|---|---|---|---|
| 10f6544e2e | |||
| 9c690fbdfb | |||
| 6f9bdc4d50 |
+35
-126
@@ -5,148 +5,57 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Critical Security & Privacy Regressions
|
||||
## ✅ Resolved Issues
|
||||
|
||||
### 1. E2EE Bypass in Media Gallery Downloads
|
||||
**File:** `src/app/features/room/MediaGallery.tsx` (Line 855)
|
||||
**Status:** **CRITICAL**
|
||||
|
||||
* **Issue:** The "Download" button in the Files tab uses `mxcUrlToHttp` directly and clicks an `<a>` link.
|
||||
* **Impact:** In encrypted rooms, this downloads the encrypted ciphertext rather than the decrypted file. Users cannot open the downloaded files.
|
||||
* **Recommended Fix:**
|
||||
1. Check if the event is encrypted.
|
||||
2. If encrypted, use the `decryptAttachment` logic (similar to `useDecryptedMediaUrl`) to decrypt the file in memory.
|
||||
3. Use `file-saver` or a Blob URL to trigger the download of the decrypted plaintext.
|
||||
|
||||
### 2. Privacy Leak in URL Previews
|
||||
**File:** `src/app/components/url-preview/UrlPreviewCard.tsx` (Line 1655)
|
||||
**Status:** **PRIVACY RISK**
|
||||
|
||||
* **Issue:** Generic URL preview cards fetch favicons directly from `https://www.google.com/s2/favicons?domain=...`.
|
||||
* **Impact:** This leaks the user's browsing/chat activity (domains of links they see) to Google. It bypasses the "proxied through Matrix" privacy standard.
|
||||
* **Recommended Fix:** Use the proxy URL returned by the Matrix `/_matrix/media/v3/preview_url` endpoint instead of contacting Google directly.
|
||||
- **Ringing Modal Fires in Voice Rooms**: Fixed in `CallEmbedProvider.tsx` — only `notification_type === 'ring'` events now trigger the modal.
|
||||
- **Avatar Decoration Displacement in Profile**: Fixed in `UserHero.tsx` — `UserAvatarContainer` (position:absolute) now wraps `AvatarDecoration` as the outermost element, keeping the positioning context relative to `UserHeroAvatarContainer`.
|
||||
- **Export History Broken for E2EE**: Fixed in `ExportRoomHistory.tsx` — `addEvents` is now async and calls `mx.decryptEventIfNeeded()` before inspecting event type/content.
|
||||
- **Privacy Leak in URL Previews (Google Favicon)**: Fixed in `UrlPreviewCard.tsx` — Google S2 favicon call removed; a generic folds `Icons.Link` icon is shown instead.
|
||||
- **Status Emoji Picker Doesn't Insert Emoji**: Fixed in `Profile.tsx` — `statusDirtyRef` prevents the server-presence sync `useEffect` from overwriting in-flight user input (cleared on save/clear).
|
||||
- **Encrypted Search Misses Stickers and Polls**: Fixed in `useLocalMessageSearch.ts` — `m.sticker`, `m.poll.start`, and `org.matrix.msc3381.poll.start` events now included; poll question and answer bodies are indexed.
|
||||
- **Seasonal Themes Display Behind Chat Background**: Fixed in `SeasonalEffect.tsx` — z-index bumped from 9997 to 9999.
|
||||
- **Windows Taskbar Badge Black Square**: Fixed in `src-tauri/src/lib.rs` — `std::ptr::write_bytes` zeros the DIB bits buffer immediately after `CreateDIBSection`; previously uninitialized bytes caused garbage pixels with alpha=255.
|
||||
|
||||
---
|
||||
|
||||
## 🚩 Functional & Logic Bugs
|
||||
## 🛡️ Pending — Critical Security & Logic
|
||||
|
||||
### 1. Presence Updater Reverts Status Updates
|
||||
**File:** `src/app/hooks/usePresenceUpdater.ts` (Line 20)
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
* **Issue:** The `storedStatus` variable was captured once when the `useEffect` started.
|
||||
* **Impact:** If a user updated their status message in Profile Settings, the hook would continue broadcasting the old message on every activity event, silently reverting the change.
|
||||
* **Fix Applied:** Replaced the single `localStorage.getItem` read with a `readStatus()` function called inside every `setOnline` and `setUnavailable` invocation, ensuring the current value is always used.
|
||||
|
||||
### 2. Audio Playback Rate Reset
|
||||
**File:** `src/app/components/message/content/AudioContent.tsx` (Line 97)
|
||||
**Status:** UX Bug
|
||||
|
||||
* **Issue:** The `playbackRate` is set in a `useEffect` that only depends on `[playbackSpeed]`.
|
||||
* **Impact:** If a user selects a playback speed *before* the audio blob has finished loading, the `<audio>` element may reset its rate to 1.0 once the `<source>` is added.
|
||||
* **Recommended Fix:** Add `srcState.data` to the `useEffect` dependencies or set the `playbackRate` in an `onCanPlay` handler on the audio element.
|
||||
|
||||
### 3. Room Insights are Static (No Live Updates)
|
||||
**File:** `src/app/features/room-settings/RoomInsights.tsx` (Line 60)
|
||||
**Status:** Medium Priority
|
||||
|
||||
* **Issue:** Stats are calculated in a `useMemo` that only depends on `[room]`.
|
||||
* **Impact:** If new messages arrive while the Insights page is open, the statistics (message counts, top participants, etc.) do not update.
|
||||
* **Recommended Fix:** Add a listener for `RoomEvent.Timeline` inside the component to trigger a recalculation when new events are added to the room.
|
||||
|
||||
### 4. Incorrect Ringing in Voice Rooms
|
||||
**File:** `src/app/components/CallEmbedProvider.tsx`
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
* **Issue:** Joining a static voice room (Public Space channel) triggered the "Incoming Call" ringing animation and sound.
|
||||
* **Root Cause:** `getStateEvent(room, StateEvent.SpaceParent)` always returned `undefined` because `m.space.parent` events use the parent space's room ID as the state key, not an empty string. The ringing suppression check silently failed.
|
||||
* **Fix Applied:** Replaced `getStateEvent` with `getStateEvents` (plural), which returns all events of a given type regardless of state key. A room with any `m.space.parent` event is correctly identified as a space channel and suppresses ringing.
|
||||
*(none currently open)*
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI/UX & Visual Consistency
|
||||
## 🚩 Pending — Functional & UI Bugs
|
||||
|
||||
### 1. Hardcoded Primary Color in Polls
|
||||
**File:** `src/app/components/message/content/PollContent.tsx` (Line 245)
|
||||
**Status:** TDS Violation
|
||||
### 1. Avatar Decoration Images Not Rendering
|
||||
**File:** `src/app/features/lotus/avatarDecorations.ts`
|
||||
**Status:** **OPEN — Blocked on infrastructure**
|
||||
|
||||
* **Issue:** Uses `rgba(var(--mx-primary-rgb, 0,132,255), ...)` for selected options and borders.
|
||||
* **Impact:** Polls look like standard Cinny and ignore the Lotus Terminal Design System (TDS) colors.
|
||||
* **Recommended Fix:** Use `var(--lt-accent-cyan)` or the primary theme accent color.
|
||||
* **Issue:** Decoration images in Settings do not load.
|
||||
* **Root Cause:** The Nextcloud WebDAV URL (`public.php/dav/files/…`) returns `Content-Disposition: attachment`, which browsers refuse to render in `<img>` tags.
|
||||
* **Required Fix:** Move decoration assets to a CDN or file host that returns `Content-Type: image/png` with `Content-Disposition: inline`. Options: Gitea LFS, Cloudflare R2, or a dedicated static file host. Alternatively, reconfigure the Nextcloud share to serve inline.
|
||||
* **Blocked by:** Infrastructure change (not a code fix).
|
||||
|
||||
### 2. Inaccessible Room Menu on Mobile
|
||||
**File:** `src/app/features/room-nav/RoomNavItem.tsx` (Line 643)
|
||||
**Status:** **MOBILE BUG**
|
||||
---
|
||||
|
||||
* **Issue:** The room menu icon (`VerticalDots`) is hidden on mobile because `hover` is never active.
|
||||
* **Impact:** Mobile users cannot access room settings, mark as read, or leave rooms from the sidebar.
|
||||
* **Recommended Fix:** Ensure the menu button is visible on mobile for the active room or provide an alternative trigger.
|
||||
## 🎨 Pending — UI/UX & Visual Consistency
|
||||
|
||||
### 3. Inconsistent Settings Dropdown Styling
|
||||
**File:** `src/app/features/settings/general/General.tsx`
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
### 1. Inconsistent Settings Dropdown Styling
|
||||
**Files:** `Profile.tsx`, `SystemNotification.tsx`
|
||||
**Status:** **OPEN — Low priority**
|
||||
|
||||
* **Issue:** The dropdowns for "Join & Leave Sounds", "UI Font", and "Seasonal Theme" used raw HTML `<select>` elements, which render differently from the custom-styled `Menu`+`PopOut` used for "Message Layout" and other settings.
|
||||
* **Fix Applied:** All three raw `<select>` elements replaced with a reusable `SettingsSelect` component using the Menu+PopOut+FocusTrap pattern consistent with the rest of the settings UI.
|
||||
* **Issue:** Dropdowns for Status Expiry and Notification Sounds use raw HTML `<select>` elements.
|
||||
* **Recommended Fix:** Replace with the custom-styled `Menu` + `PopOut` pattern used in `General.tsx`.
|
||||
|
||||
### 4. No Camera Focus During Screenshare
|
||||
**File:** `src/app/features/call/CallControls.tsx`
|
||||
**Status:** UX Bug — blocked by Element Call internals
|
||||
|
||||
* **Issue:** When someone is screensharing and another participant turns on their camera, there is no way to switch the primary display to the camera or go fullscreen on it.
|
||||
* **Root Cause:** Element Call's spotlight/layout is controlled internally by EC. Lotus Chat injects the call iframe and cannot easily override EC's participant tile behavior without forking the EC widget.
|
||||
* **Recommended Fix:** Implement a "Pin/Focus" toggle on participant tiles that overrides the automatic screenshare spotlight — requires EC upstream changes or a custom message bridge.
|
||||
|
||||
### 5. Ringing Modal Fires in Persistent Voice Rooms
|
||||
**File:** `src/app/components/CallEmbedProvider.tsx` (line 337–342)
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
* **Issue:** Joining a persistent voice room (not a DM or transient group call) showed the incoming call ringing modal and animation.
|
||||
* **Root Cause:** The `isPrivateGroup` condition included `JoinRule.Restricted` and `JoinRule.Knock` rooms. Lotus Guild voice rooms are `Restricted` join-rule rooms. Their `m.space.parent` state event was being checked but some rooms were set up with only the space-side `m.space.child` relationship, leaving no `m.space.parent` on the room itself — so they passed as `isPrivateGroup` and triggered ringing.
|
||||
* **Fix Applied:** Narrowed `isPrivateGroup` to only `JoinRule.Invite` to match the exact set of rooms where the call button is shown. Also added `room.isCallRoom()` early-exit so rooms with `m.join_rule.call` type never ring.
|
||||
|
||||
### 6. Animated Chat Backgrounds Affect Message Content
|
||||
### 2. Animations Flickering Content (Fireflies)
|
||||
**File:** `src/app/features/lotus/chatBackground.ts`
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
**Status:** **OPEN — Low priority**
|
||||
|
||||
* **Issue:** Animated backgrounds that use `filter: brightness()` or `opacity` animations (Digital Rain glow, Grid Pulse brightness, Fireflies glow/blink) applied those effects to the entire `<Page>` element, causing all message content and the composer to flash/flicker in sync with the animation.
|
||||
* **Root Cause:** `filter` and `opacity` CSS properties affect an element AND all its descendants. Applying these as part of the `animation` shorthand on the `Page` container made them "inherited" visually by everything inside the room view.
|
||||
* **Side Effect:** `filter` animation also created a CSS stacking context on Page, which pushed Seasonal Theme overlays (position:fixed; z-index:9997) behind the Page compositor layer.
|
||||
* **Fix Applied:** Removed `animRainGlowKeyframe`, `animGridBrightnessKeyframe`, `animFirefliesGlowKeyframe`, and `animFirefliesBlinkKeyframe` from `chatBackground.ts`. Only `backgroundPosition` / `backgroundSize` animations remain — these are safe and do not affect descendants or create stacking contexts.
|
||||
* **Follow-up (June 2026):** The initial fix only removed glow/brightness keyframes from the DARK variant definitions. The LIGHT variant `anim-rain` and `anim-pulse` entries still referenced `animRainGlowKeyframe` and `animGridBrightnessKeyframe` (which were no longer imported, causing a build error). Both references removed from LIGHT variants to complete the fix.
|
||||
* **Issue:** Complex radial-gradient animations cause flickering on some GPUs.
|
||||
* **Recommended Fix:** Scope animations strictly to background properties and simplify heavy gradients.
|
||||
|
||||
### 7. Seasonal Themes Display Behind Chat Background
|
||||
**File:** `src/app/components/seasonal/SeasonalEffect.tsx`
|
||||
**Status:** ✅ RESOLVED (June 2026) — root cause was Bug #6
|
||||
### 3. No Camera Focus During Screenshare
|
||||
**File:** `src/app/features/call/CallControls.tsx`
|
||||
**Status:** **OPEN — Blocked on Element Call internals**
|
||||
|
||||
* **Issue:** Seasonal theme overlays (position:fixed; z-index:9997) appeared behind animated chat backgrounds.
|
||||
* **Root Cause:** The `filter` animation on `<Page>` created a CSS stacking context, causing Page's GPU compositing layer to render above the fixed-position seasonal overlay in some browsers. Removing the filter animations (Bug #6 fix) resolves the stacking context issue.
|
||||
* **Fix Applied:** See Bug #6. No additional changes to SeasonalEffect required.
|
||||
|
||||
### 8. Avatar Decoration Images Not Rendering in Settings
|
||||
**File:** `src/app/features/settings/account/ProfileDecoration.tsx` / LXC 106 nginx
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
* **Issue:** Under Settings → Account → Avatar Decoration, no decoration images were visible.
|
||||
* **Initial (incorrect) diagnosis:** Suspected `loading="lazy"` in a nested scroll container. Changed to `loading="eager"` — images still did not render.
|
||||
* **Actual Root Cause:** The server nginx Content Security Policy (`img-src` directive) on LXC 106 did not include `https://drive.lotusguild.org`. The browser silently blocked all 99 APNG requests with CSP violation errors (206 violations visible in DevTools console). The lazy-loading change was a red herring.
|
||||
* **Fix Applied:** Added `https://drive.lotusguild.org` to the `img-src` directive in `/etc/nginx/sites-available/cinny` on LXC 106 (cinny-web-server) and reloaded nginx. Updated config is tracked in `pve-infra/containers/106-cinny-web-server/etc/nginx/sites-available/cinny`.
|
||||
|
||||
### 9. Avatar Decoration Grid Spacing Too Tight
|
||||
**File:** `src/app/features/settings/account/ProfileDecoration.tsx`
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
* **Issue:** Decoration preview cells in the settings picker appeared vertically and horizontally squished together with almost no gap between images.
|
||||
* **Root Cause:** The flex container used `gap: 20px`, but each `52×52` button renders its decoration image with `position: absolute; top: -8px; left: -8px` (INSET=8), so each image bleeds 8px outside the button on all sides. The visual gap between images was `20 - 8 - 8 = 4px` — nearly nothing. Additionally `paddingBottom: 4` clipped the bottom overflow of the last row, and `paddingRight` was absent so the rightmost column clipped.
|
||||
* **Fix Applied:** Increased `gap` from `20` to `36` (visual gap = 36 - 8 - 8 = 20px), changed `paddingBottom` from `4` to `INSET` (8px), added `paddingRight: INSET`.
|
||||
|
||||
### 10. Windows Taskbar Badge Has Black Square Background and Small Number
|
||||
**File:** `src-tauri/src/lib.rs` (cinny-desktop) — `set_badge_count` command
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
* **Issue:** The notification badge overlaid on the Windows taskbar icon had a solid black square background instead of a transparent circle, and the number was too small to read at a glance.
|
||||
* **Root Cause (black square):** `CreateDIBSection` initialises the pixel buffer to zero (BGRA `0x00000000`). GDI drawing functions (`Ellipse`, `DrawTextW`) paint RGB values but never touch the alpha channel — all pixels retain `A=0`. When every pixel has `A=0`, Windows cannot use per-pixel alpha compositing and falls back to the monochrome mask (`hbm_mask`, all zeros = fully opaque), so the entire 16×16 bitmap is drawn opaque. The corner pixels outside the ellipse are `RGB(0,0,0)` = black, producing the black square.
|
||||
* **Root Cause (small number):** Bitmap was 16×16 with an 11px font, giving very little room, especially for two-digit counts.
|
||||
* **Fix Applied:**
|
||||
1. After all GDI drawing, iterate the pixel buffer and set `alpha = 0xFF` for every non-zero pixel (`*pixel |= 0xFF00_0000`). Corner pixels (zero) retain `A=0` and composite as transparent.
|
||||
2. Increased bitmap size from `16` to `20` and font height from `11` to `14`.
|
||||
* **Issue:** There is no way to focus a camera while screenshare is active.
|
||||
* **Recommended Fix:** Implement a "Pin/Focus" toggle on participant tiles — requires Element Call iframe API support.
|
||||
|
||||
@@ -775,7 +775,7 @@ function SeasonalOverlay({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9997,
|
||||
zIndex: 9999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1652,7 +1652,6 @@ function GenericCard({
|
||||
const description = prev['og:description'] ?? '';
|
||||
const siteName = typeof prev['og:site_name'] === 'string' ? prev['og:site_name'] : undefined;
|
||||
const domain = getDomain(url);
|
||||
const faviconSrc = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=16`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1687,13 +1686,11 @@ function GenericCard({
|
||||
priority="300"
|
||||
>
|
||||
{!thumbUrl && (
|
||||
<img
|
||||
className={previewCss.GenericFaviconImg}
|
||||
src={faviconSrc}
|
||||
alt=""
|
||||
<Icon
|
||||
src={Icons.Link}
|
||||
size="50"
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
style={{ marginRight: '4px', verticalAlign: 'text-bottom' }}
|
||||
style={{ marginRight: '4px', verticalAlign: 'text-bottom', opacity: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
{siteName ? `${siteName} | ` : ''}
|
||||
|
||||
@@ -49,12 +49,12 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
||||
</div>
|
||||
<div className={css.UserHeroAvatarContainer}>
|
||||
<div className={css.UserAvatarContainer}>
|
||||
<AvatarDecoration userId={userId} inset={20}>
|
||||
<AvatarPresence
|
||||
badge={
|
||||
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
||||
}
|
||||
>
|
||||
<AvatarPresence
|
||||
badge={
|
||||
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
||||
}
|
||||
>
|
||||
<AvatarDecoration userId={userId} inset={20}>
|
||||
<Avatar
|
||||
as={avatarUrl ? 'button' : 'div'}
|
||||
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
|
||||
@@ -69,8 +69,8 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
||||
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</AvatarPresence>
|
||||
</AvatarDecoration>
|
||||
</AvatarDecoration>
|
||||
</AvatarPresence>
|
||||
</div>
|
||||
{viewAvatar && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
|
||||
@@ -213,12 +213,21 @@ export function MessageSearch({
|
||||
[msgSearchParams.rooms, searchPathSearchParams.global, allRooms, rooms],
|
||||
);
|
||||
|
||||
// Run synchronous client-side search over encrypted rooms immediately.
|
||||
// term === undefined → no search started
|
||||
// term === '' → sender-only search (from:user with no body text)
|
||||
// term === 'foo' → normal text search
|
||||
const hasActiveSearch =
|
||||
msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
|
||||
const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;
|
||||
|
||||
// Run synchronous client-side search immediately.
|
||||
// In text-search mode: covers encrypted rooms only (server handles plaintext).
|
||||
// In sender-only mode: covers all rooms (server has no sender-only search).
|
||||
// cacheVersion in deps so it re-runs after "Load more" paginates new events.
|
||||
const localResult = useMemo(() => {
|
||||
if (!msgSearchParams.term) return null;
|
||||
if (!hasActiveSearch) return null;
|
||||
return searchLocalMessages({
|
||||
term: msgSearchParams.term,
|
||||
term: msgSearchParams.term ?? '',
|
||||
roomIds: localSearchRooms,
|
||||
senders: msgSearchParams.senders,
|
||||
});
|
||||
@@ -404,19 +413,19 @@ export function MessageSearch({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!msgSearchParams.term && status === 'pending' && (
|
||||
{!hasActiveSearch && (
|
||||
<PageHeroEmpty>
|
||||
<PageHeroSection>
|
||||
<PageHero
|
||||
icon={<Icon size="600" src={Icons.Message} />}
|
||||
title="Search Messages"
|
||||
subTitle="Find helpful messages in your community by searching with related keywords."
|
||||
subTitle="Find helpful messages in your community by searching with related keywords, or type from:@user to see all messages from someone."
|
||||
/>
|
||||
</PageHeroSection>
|
||||
</PageHeroEmpty>
|
||||
)}
|
||||
|
||||
{msgSearchParams.term && groups.length === 0 && status === 'success' && (
|
||||
{hasActiveSearch && !senderOnlyMode && groups.length === 0 && status === 'success' && (
|
||||
<Box direction="Column" gap="200">
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Warning' })}
|
||||
@@ -450,7 +459,7 @@ export function MessageSearch({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{((msgSearchParams.term && status === 'pending') ||
|
||||
{((!senderOnlyMode && msgSearchParams.term && status === 'pending') ||
|
||||
(groups.length > 0 && vItems.length === 0)) && (
|
||||
<Box direction="Column" gap="100">
|
||||
{[...Array(8).keys()].map((key) => (
|
||||
@@ -460,6 +469,7 @@ export function MessageSearch({
|
||||
)}
|
||||
|
||||
{msgSearchParams.term &&
|
||||
!senderOnlyMode &&
|
||||
localResult &&
|
||||
localResult.encryptedRoomsCount > 0 &&
|
||||
vItems.length > 0 && (
|
||||
@@ -524,20 +534,24 @@ export function MessageSearch({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{localResult && localResult.encryptedRoomsCount > 0 && (
|
||||
{localResult && (senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && (
|
||||
<Box direction="Column" gap="300">
|
||||
<Box direction="Column" gap="200">
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Icon size="200" src={Icons.Lock} />
|
||||
<Text size="H5">Encrypted Rooms</Text>
|
||||
<Text size="T200" style={{ opacity: 0.55 }}>
|
||||
{`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
|
||||
</Text>
|
||||
<Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} />
|
||||
<Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text>
|
||||
{!senderOnlyMode && (
|
||||
<Text size="T200" style={{ opacity: 0.55 }}>
|
||||
{`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text size="T300" priority="300">
|
||||
{localResult.groups.length > 0
|
||||
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
|
||||
: `No matches in your local cache. Load messages below to search further back.`}
|
||||
{senderOnlyMode
|
||||
? `Showing locally cached messages from this user across all rooms. Open more rooms or load history below to extend coverage.`
|
||||
: localResult.groups.length > 0
|
||||
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
|
||||
: `No matches in your local cache. Load messages below to search further back.`}
|
||||
</Text>
|
||||
<Line size="300" variant="Surface" />
|
||||
</Box>
|
||||
|
||||
@@ -158,7 +158,10 @@ export function SearchInput({
|
||||
searchInputRef.current.value = searchTerm ?? '';
|
||||
}
|
||||
|
||||
if (searchTerm) onSearch(searchTerm);
|
||||
// Always trigger search when senders were extracted, even with no body text
|
||||
if (fromMatches.length > 0 || searchTerm) {
|
||||
onSearch(searchTerm ?? '');
|
||||
}
|
||||
closeAutocomplete();
|
||||
};
|
||||
|
||||
|
||||
@@ -31,12 +31,16 @@ export const useLocalMessageSearch = () => {
|
||||
const search = useCallback(
|
||||
({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => {
|
||||
const trimmedTerm = term.trim();
|
||||
if (!trimmedTerm) {
|
||||
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
|
||||
|
||||
// Sender-only mode: no text filter, search all rooms (server can't filter by sender alone)
|
||||
const senderOnlyMode = !trimmedTerm && !!senderSet;
|
||||
|
||||
if (!trimmedTerm && !senderSet) {
|
||||
return { groups: [], encryptedRoomsCount: 0, searchedRoomsCount: 0 };
|
||||
}
|
||||
|
||||
const termLower = trimmedTerm.toLowerCase();
|
||||
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
|
||||
const groups: ResultGroup[] = [];
|
||||
let encryptedRoomsCount = 0;
|
||||
let searchedRoomsCount = 0;
|
||||
@@ -46,9 +50,12 @@ export const useLocalMessageSearch = () => {
|
||||
if (!room) continue;
|
||||
|
||||
const isEncrypted = !!room.currentState.getStateEvents(EventType.RoomEncryption, '');
|
||||
if (!isEncrypted) continue;
|
||||
|
||||
encryptedRoomsCount += 1;
|
||||
// Text search: encrypted rooms only — server already covers plaintext rooms
|
||||
// Sender-only: all rooms — server has no sender-only search
|
||||
if (!senderOnlyMode && !isEncrypted) continue;
|
||||
|
||||
if (isEncrypted) encryptedRoomsCount += 1;
|
||||
|
||||
const events = room
|
||||
.getUnfilteredTimelineSet()
|
||||
@@ -63,21 +70,66 @@ export const useLocalMessageSearch = () => {
|
||||
for (let i = 0; i < events.length; i += 1) {
|
||||
const event = events[i];
|
||||
|
||||
if (event.getType() !== EventType.RoomMessage) continue;
|
||||
// In sender-only mode: include all message types; skip non-message events
|
||||
if (event.getType() !== EventType.RoomMessage) {
|
||||
if (senderOnlyMode) continue;
|
||||
const evType = event.getType();
|
||||
const isSticker = evType === 'm.sticker';
|
||||
const isPoll =
|
||||
evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
|
||||
if (!isSticker && !isPoll) continue;
|
||||
}
|
||||
|
||||
if (event.isDecryptionFailure()) continue;
|
||||
if (event.isRedacted()) continue;
|
||||
if (senderSet && !senderSet.has(event.getSender() ?? '')) continue;
|
||||
|
||||
// getContent() returns decrypted plaintext regardless of encryption
|
||||
const content = event.getContent();
|
||||
const body = (content.body as string | undefined) ?? '';
|
||||
const formattedBody = (content.formatted_body as string | undefined) ?? '';
|
||||
|
||||
if (
|
||||
!body.toLowerCase().includes(termLower) &&
|
||||
!formattedBody.toLowerCase().includes(termLower)
|
||||
)
|
||||
continue;
|
||||
// Sender-only mode: no text filter needed
|
||||
if (!senderOnlyMode) {
|
||||
const evType = event.getType();
|
||||
const isSticker = evType === 'm.sticker';
|
||||
const isPoll =
|
||||
evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
|
||||
|
||||
let body = '';
|
||||
let formattedBody = '';
|
||||
if (!isPoll) {
|
||||
body = (content.body as string | undefined) ?? '';
|
||||
formattedBody = (content.formatted_body as string | undefined) ?? '';
|
||||
} else {
|
||||
// Poll — index question text and all answer options
|
||||
const poll = (content['m.poll'] ??
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
content['org.matrix.msc3381.poll.start']) as any;
|
||||
if (poll) {
|
||||
const qBody =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
||||
(poll.question?.body as string | undefined) ??
|
||||
'';
|
||||
const answerBodies = ((poll.answers ?? []) as Array<Record<string, unknown>>)
|
||||
.map(
|
||||
(a) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
|
||||
'') as string,
|
||||
)
|
||||
.join(' ');
|
||||
body = `${qBody} ${answerBodies}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!body.toLowerCase().includes(termLower) &&
|
||||
!formattedBody.toLowerCase().includes(termLower)
|
||||
)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build a synthetic IEventWithRoomId using decrypted content so the
|
||||
// existing SearchResultGroup renderer works without modification.
|
||||
|
||||
@@ -56,11 +56,17 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
const timeline = room.getLiveTimeline();
|
||||
let canLoadMore = true;
|
||||
|
||||
const addEvents = (events: ReturnType<typeof timeline.getEvents>) => {
|
||||
const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => {
|
||||
for (const ev of events) {
|
||||
const evId = ev.getId();
|
||||
if (!evId || seen.has(evId)) continue;
|
||||
seen.add(evId);
|
||||
// Attempt decryption for events that haven't been decrypted yet
|
||||
// (paginateEventTimeline may fetch events before the SDK decrypts them)
|
||||
if (ev.isEncrypted() && !ev.getClearContent()) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await mx.decryptEventIfNeeded(ev).catch(() => undefined);
|
||||
}
|
||||
if (ev.getType() !== EventType.RoomMessage) continue;
|
||||
if (ev.isDecryptionFailure()) continue;
|
||||
const ts = ev.getTs();
|
||||
@@ -81,7 +87,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
setExportCount(collected.length);
|
||||
};
|
||||
|
||||
addEvents(timeline.getEvents());
|
||||
await addEvents(timeline.getEvents());
|
||||
|
||||
// Paginate backwards until start or date range exceeded
|
||||
while (canLoadMore) {
|
||||
@@ -98,7 +104,8 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
addEvents(timeline.getEvents());
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await addEvents(timeline.getEvents());
|
||||
}
|
||||
|
||||
// Sort chronologically (oldest first)
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
@@ -349,6 +350,9 @@ function ProfileStatus() {
|
||||
const [statusMsg, setStatusMsg] = useState<string>(
|
||||
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
|
||||
);
|
||||
// True while the user has unsaved local edits — prevents a server presence
|
||||
// echo from overwriting what the user is currently typing/inserting.
|
||||
const statusDirtyRef = useRef(false);
|
||||
const [clearAfter, setClearAfter] = useState('0');
|
||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||
|
||||
@@ -359,10 +363,10 @@ function ProfileStatus() {
|
||||
});
|
||||
|
||||
// Sync input when another device changes the status.
|
||||
// Only update if the server actually has a value — ignore empty sync events
|
||||
// caused by Synapse clearing status_msg on reconnect.
|
||||
// Skipped while the user has unsaved local edits to avoid clobbering
|
||||
// mid-flight input (e.g. an emoji being inserted).
|
||||
useEffect(() => {
|
||||
if (presence?.status) {
|
||||
if (!statusDirtyRef.current && presence?.status) {
|
||||
setStatusMsg(presence.status);
|
||||
localStorage.setItem(STATUS_MSG_KEY(userId), presence.status);
|
||||
}
|
||||
@@ -399,17 +403,20 @@ function ProfileStatus() {
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleEmojiSelect = useCallback((unicode: string) => {
|
||||
statusDirtyRef.current = true;
|
||||
setStatusMsg((prev) => prev + unicode);
|
||||
setEmojiAnchor(undefined);
|
||||
}, []);
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
statusDirtyRef.current = true;
|
||||
setStatusMsg(evt.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (saving) return;
|
||||
statusDirtyRef.current = false;
|
||||
const msg = statusMsg.trim();
|
||||
saveStatus(msg).catch(() => undefined);
|
||||
|
||||
@@ -431,6 +438,7 @@ function ProfileStatus() {
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
statusDirtyRef.current = false;
|
||||
setStatusMsg('');
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
|
||||
@@ -48,6 +48,7 @@ export function SidebarNav() {
|
||||
style.removeProperty('background-size');
|
||||
style.removeProperty('background-position');
|
||||
style.removeProperty('animation');
|
||||
style.removeProperty('will-change');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,6 +59,13 @@ export function SidebarNav() {
|
||||
style.backgroundSize = (bgStyle.backgroundSize as string | undefined) ?? '';
|
||||
style.backgroundPosition = (bgStyle.backgroundPosition as string | undefined) ?? '';
|
||||
style.animation = (bgStyle.animation as string | undefined) ?? '';
|
||||
// Promote animated backgrounds to their own compositor layer so the browser
|
||||
// doesn't repaint the overlaid text/UI content on every animation frame.
|
||||
if (bgStyle.animation) {
|
||||
style.willChange = 'background-position, background-size';
|
||||
} else {
|
||||
style.removeProperty('will-change');
|
||||
}
|
||||
|
||||
return () => {
|
||||
style.removeProperty('background-image');
|
||||
@@ -65,6 +73,7 @@ export function SidebarNav() {
|
||||
style.removeProperty('background-size');
|
||||
style.removeProperty('background-position');
|
||||
style.removeProperty('animation');
|
||||
style.removeProperty('will-change');
|
||||
};
|
||||
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations]);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { atom } from 'jotai';
|
||||
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
||||
import { IContent } from 'matrix-js-sdk';
|
||||
|
||||
export type ScheduledMessage = {
|
||||
@@ -8,9 +9,34 @@ export type ScheduledMessage = {
|
||||
sendAt: number; // Unix timestamp ms
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'cinny_scheduled_messages_v1';
|
||||
|
||||
// Internal atom persists as a plain Record (JSON-serializable).
|
||||
const internalAtom = atomWithStorage<Record<string, ScheduledMessage[]>>(
|
||||
STORAGE_KEY,
|
||||
{},
|
||||
createJSONStorage(() => localStorage),
|
||||
);
|
||||
|
||||
/**
|
||||
* Global atom: Map<roomId, ScheduledMessage[]>
|
||||
* Stores all locally-tracked scheduled messages across rooms.
|
||||
* MSC4140 has no list endpoint, so we track them ourselves.
|
||||
* Backed by localStorage so scheduled messages survive page refreshes.
|
||||
*/
|
||||
export const scheduledMessagesAtom = atom<Map<string, ScheduledMessage[]>>(new Map());
|
||||
export const scheduledMessagesAtom = atom(
|
||||
(get): Map<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))),
|
||||
(
|
||||
_get,
|
||||
set,
|
||||
updater:
|
||||
| Map<string, ScheduledMessage[]>
|
||||
| ((prev: Map<string, ScheduledMessage[]>) => Map<string, ScheduledMessage[]>),
|
||||
) => {
|
||||
set(internalAtom, (prevObj) => {
|
||||
const prevMap = new Map(Object.entries(prevObj));
|
||||
const nextMap = typeof updater === 'function' ? updater(prevMap) : updater;
|
||||
return Object.fromEntries(nextMap.entries());
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user