Files
cinny/README.md
T

183 lines
15 KiB
Markdown
Raw Normal View History

# Lotus Chat
A Matrix client for [Lotus Guild](https://lotusguild.org) — forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1.
Deployed at [chat.lotusguild.org](https://chat.lotusguild.org).
---
## Changes from upstream Cinny
### Branding & Identity
- Package renamed to `lotus-chat`, description updated to "Lotus Chat — Matrix client for Lotus Guild"
- App title changed from "Cinny" to "Lotus Chat" throughout
- Favicon, PWA icons, and all icon sizes (57×57 → 180×180 Apple touch icons) replaced with Lotus.png variants
- Logo in About dialog and Auth page replaced with official Lotus.png
- Auth footer rewritten: shows dynamic version from `package.json`, links to lotusguild.org, chat.lotusguild.org, and matrix.lotusguild.org
- Welcome page tagline changed from "Yet another matrix client" to "A Matrix client for Lotus Guild"
- Encryption key export filename changed from `cinny-keys.txt` to `lotus-keys.txt`
- `manifest.json` updated with Lotus name, description, and branding colors
### LotusGuild Terminal Design System (TDS) v1.2
A full custom theme engine layered on top of Cinny's vanilla-extract theming:
**Dark mode** (`LotusTerminalTheme`):
- CRT terminal aesthetic: scanline overlay, vignette, phosphor glow
- Palette: bg `#030508`, orange `#FF6B00`, cyan `#00D4FF`, green `#00FF88`, text `#c4d9ee`
- Monospace font stack, terminal-style scrollbars
- Custom hex-grid and circuit-board CSS background patterns
- Matrix-style boot messages on the welcome page (press Escape to skip)
- CSS variables: `--lt-*` family covering colors, glow effects, borders, animations
**Light mode** (`LotusTerminalLightTheme`):
- Full light palette: bg `#edf0f5`, orange `#c44e00`, cyan `#0062b8`, green `#006d35`, text `#111827`
- No CRT effects (scanlines, vignette disabled)
- Light-mode scrollbars, adjusted code block colors, semantic color overrides
- Scoped to `html[data-theme="light"] body.lotusTerminalBodyClass`
- `ThemeManager.tsx` sets `data-theme` attribute based on active theme kind
**Chat Backgrounds** (20+ custom patterns, all TDS-aware):
- Blueprint grid, carbon fiber, starfield, topographic contours, herringbone, crosshatch
- Chevron, polka dots, triangles, plaid
- All patterns use CSS custom properties — adapt to both TDS dark and light themes
- Settings toggle for showing per-message sender profiles
### Voice / Video Call Improvements
- **Element Call 0.19.4**: Upgraded from 0.16.3. Dist copied to `public/element-call/` by vite at build time.
- **Camera default OFF**: Camera no longer persists across sessions via localStorage. Always starts disabled. Optional `cameraOnJoin` setting for explicit opt-in.
- **Deafen button**: Tooltip corrected to "Deafen" / "Undeafen" (was "Turn Off Sound" / "Turn On Sound")
- **Screenshare confirmation**: A confirm dialog appears before screenshare is broadcast to call participants
- **Auto-revert spotlight on screenshare**: When someone starts screensharing, EC normally forces all participants into spotlight view. Patched in `CallControl.ts` `onControlMutation()` — detects the screenshare button going `primary` and clicks `gridButton` after 600ms to revert to grid layout. Participants choose to watch screenshare manually.
- **Push to Talk (PTT)**:
- Configurable keybind (default: Space) via Settings > General > Calls
- Mic activates on keydown, deactivates on keyup; mic muted on tab blur/focus to prevent stuck-on mic
- Visual indicator: plain folds `Chip` by default; when LotusGuild TDS is active: orange `PTT — Hold SPACE` / green `● LIVE` in JetBrains Mono
- Listens on both main window and EC iframe `contentWindow` for reliable key capture
- Implemented via `CallControl.setMicrophone()` public method on the widget bridge
- **Mic state preservation**: when enabling PTT mode mid-call, the user's previous mic state is saved and restored when PTT is disabled — prevents unwanted unmute if the user had manually muted before switching to PTT.
- **Noise suppression toggle**: Settings > General > Calls — passes `noiseSuppression` URL parameter to the embedded Element Call widget
- **Call button scoping**: The upstream Cinny 4.12.1 call button (voice + video dropdown) is restricted to DMs and private group chats only. Specifically: direct messages, or invite-only rooms that have no `m.space.parent` state event (i.e. not a space/guild text channel). Public rooms and space channels are excluded to prevent accidental mass-notifications. `Room.tsx` switches to CallView layout when a call embed is active in the current room.
- **Poll display**: `m.poll.start` events (both stable Matrix 1.7 `m.poll` content key and MSC3381 unstable `org.matrix.msc3381.poll.start`) render as read-only poll cards inside the standard message bubble — question and answer options shown. Registered as top-level event renderers AND inside the `EncryptedContent` callback so encrypted polls also display after decryption. "Open in Element to vote" note displayed. Implemented in `PollContent.tsx`.
- **Deleted message placeholder**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events no longer disappear from the timeline. Instead they reach the existing `RedactedContent` component (trash icon + italic "This message has been deleted" with reason if provided), matching Element, FluffyChat, Commet, and Nheko behaviour. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
2026-05-14 22:50:20 -04:00
- **Picture-in-picture (PiP)**: When navigating away from a call room while in an active call, the call embed shrinks to a 280x158px floating window in the bottom-right corner. The PiP window is **draggable** — drag it anywhere on screen to move it out of the way. Clicking (without dragging) navigates back to the call room. Drag vs click distinguished by a 5px movement threshold; touch drag supported. Imperative style overrides on `callEmbedRef.current` via `useEffect` — a wrapper div cannot be used because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element.
- **Call embed positioning**: `useCallEmbedPlacementSync` uses `getBoundingClientRect()` (not `offsetTop/Left`) for accurate viewport-relative coordinates on the `position:fixed` container. Position is synced immediately on mount via `useEffect` in addition to the ResizeObserver, so the embed is placed correctly the instant the call view renders. The `[pipMode, callVisible]` effect in `CallEmbedProvider` only clears pip-specific styles when actually exiting pip mode — no longer clobbers the position set by `syncCallEmbedPlacement` on every `callVisible` toggle.
- **Dark mode in element-call**: After joining, `CallEmbed.applyStyles()` injects `:root { color-scheme: dark|light }` into the iframe document so `@media (prefers-color-scheme)` rules inside element-call resolve to the correct Cinny theme regardless of the OS system preference. `themeKind` is stored on the `CallEmbed` instance and updated on every `setTheme()` call, so live theme switching also re-injects the CSS. Without this, users with OS light mode would see a white background even when Cinny is in dark mode.
- **Call embed wallpaper**: The user's `chatBackground` pattern (Blueprint, Carbon, Stars…) is applied as the `backgroundImage`/`backgroundColor` of `div[data-call-embed-container]` when the call is in full view (not PiP). The iframe `html, body` is forced to `background: none !important` so the pattern shows through. When `chatBackground` is `none`, behaviour is unchanged.
### Messaging Enhancements
- **GIF picker**: Giphy-powered GIF search and send. Button appears in the message composer only when `gifApiKey` is set in `config.json`. Sends GIF as `m.image` — fetches blob, uploads via `mx.uploadContent`, sends with `mx.sendMessage`. `FocusTrap` handles click-outside / Escape to close. When TDS is active: dark navy background (`#060c14`), orange dim border, `// GIF_SEARCH` header, CSS overrides for Giphy SDK search bar (dark bg, orange border/focus ring, JetBrains Mono), custom orange scrollbar. All TDS styles live in `lotus-terminal.css.ts` — no runtime `<style>` injection, eliminating flash of unstyled content.
- **Message forwarding**: Forward any message to any room from the message context menu.
- **Image/video captions**: Caption text field on image and video upload — sent as a single event with the media.
- **Location sharing**: Map embed view for incoming location events + static share button. Renders `m.location` events inline with a map tile.
- **Deleted message placeholders**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events render as "This message has been deleted" with reason (if provided) rather than disappearing. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
### Per-Message Read Receipts
Full per-message read receipt system — shows who has read each message directly in the timeline.
**Architecture:**
- `useRoomReadPositions(room)` hook — computes a `Map<eventId, userId[]>` from all joined members' `room.getEventReadUpTo()` positions. Subscribes to `RoomEvent.Receipt` for live updates (debounced at 150ms to batch burst updates from mass-read events).
- `nearestRenderableId(liveEvents, evtId)` — receipts can land on reaction/edit events that `RoomTimeline` skips (renders `null`). This walks backwards from the receipt event through the live timeline until it finds a non-reaction/non-edit event to attach to.
- `ReadPositionsContext` — React context providing the positions map from `RoomTimeline` down to all `Message` instances without prop drilling.
- `ReadReceiptAvatars` component — renders a pill-shaped row of overlapping `StackedAvatar` circles (24px, `SurfaceVariant` outline) below messages with readers. Pill uses `color.SurfaceVariant.Container` background for visibility on any wallpaper. Max 5 avatars shown + `+N` overflow count. Avatar fallback uses `colorMXID(userId)` for distinctive per-user color.
- Clicking the pill opens the **"Seen by" modal** (`EventReaders`) listing all readers with their avatar, display name, and a formatted read timestamp ("Today at 3:42 PM", "Yesterday at 10:15 AM", "May 14 at 9:00 AM"). Timestamps use `room.getReadReceiptForUserId(userId)?.data.ts` and respect the user's 24-hour clock setting.
- Authenticated media (`mxcUrlToHttp` utility) used for all avatar loads, matching the correct Lotus utility signature.
### Delivery Status Indicators
Own messages display a small status marker below the message content (when no read receipts are visible yet):
- `⟳` — message is being sent / encrypting
- `✓` — message confirmed sent (local echo)
- `✕` — message failed to send (shown in red; orange glow in TDS mode)
- Status hidden once the server confirms receipt (`status === null`) — read receipts take over at that point
### URL Preview Cards (TDS)
URL preview cards (`UrlPreviewCard`) styled for terminal mode:
- Dark transparent background with cyan border-left accent (Anduril Orange)
- Link text in cyan, hover switches to orange with glow
- Light TDS variant: off-white background with blue accent
### Reaction Chips (TDS)
Emoji reaction buttons styled for terminal mode via `button[data-reaction-key]` selector:
- Unselected: `rgba(0,212,255,0.06)` background, cyan border
- Hover: brighter background + box-shadow glow
- Own reaction (aria-pressed=true): orange tint `rgba(255,107,0,0.12)`, orange border
- Light TDS: equivalent blue/orange variants
### DM Call Improvements
- **Incoming call ring**: DM calls trigger a ring tone with Answer/Decline UI. 30-second auto-dismiss if unanswered. Implemented in `Room.tsx` and `RoomViewHeader.tsx`.
### Infrastructure
- **Authenticated media**: All avatar/media loads use `mxcUrlToHttp(mx, mxcUrl, useAuthentication, w, h, 'crop')` from `../../utils/matrix` — the Lotus utility that handles MSC3916 authenticated media. (Upstream Cinny uses the SDK method with incorrect argument order for authenticated endpoints.)
- **Upstream tracking**: `git remote add upstream https://github.com/cinnyapp/cinny.git`. Merge strategy: `git fetch upstream && git merge upstream/main`. Daily check via `cinny-upstream-check.sh` on LXC 106 — notifies Matrix on new upstream commits.
- **Rolldown CJS interop — millify**: `src/app/plugins/millify.ts` uses a named import (`import { millify as millifyPlugin } from 'millify'`) instead of a default import. Rolldown's `__toESM` helper with `mode=1` sets `a.default = module_object` (not the function itself) when `hasOwnProperty` prevents the copy — calling `millifyPlugin()` would throw `(0, zc.default) is not a function`. Named import bypasses the interop entirely.
- **Sentry noise filter**: `ignoreErrors: ['Request timed out']` added to `Sentry.init` in `src/index.tsx` to suppress unhandled rejections from the matrixRTC delayed-event heartbeat (matrix-sdk) and the widget PostmessageTransport initial-load race (matrix-widget-api). Neither is actionable from client code.
---
## Build
```bash
npm ci
npm run build # outputs to dist/
```
Vite's render-chunks phase requires ~6 GB Node heap. If OOM killed, set:
```bash
NODE_OPTIONS=--max_old_space_size=6144 npm run build
```
## Development workflow
All code changes should be made in the local clone at `/root/code/cinny` on the dev box, then committed and pushed to `origin/lotus`. The CI/CD pipeline handles everything from there — no manual build or deploy steps needed.
```
edit → commit → git push # ~11 minutes → auto-deployed to chat.lotusguild.org
```
Pipeline (`.gitea/workflows/ci.yml` + `lotus_deploy.sh` on LXC 106):
1. Push triggers a Gitea Actions build — TypeScript check, ESLint, Prettier, bundle size report
2. Build must pass as the CI gate; quality checks are informational (`continue-on-error`)
3. A Gitea webhook fires `lotus_deploy.sh` on LXC 106, which polls the API until CI passes (up to 15 min), then pulls `origin/lotus`, runs `npm ci && npm run build`, and rsyncs to `/var/www/html/`
LXC 106's stored Gitea credential is **read-only** — it can only pull. Pushes must be done from the dev box with your personal credentials (entered manually, never cached).
## Deployment
Built files are served from `/var/www/html/` on LXC 106 (nginx). Config lives at `/opt/lotus-cinny/config.json` (vite copies it to `dist/`):
```json
{
"defaultHomeserver": 0,
"homeserverList": ["matrix.lotusguild.org"],
"allowCustomHomeservers": false,
"gifApiKey": "<giphy_key>"
}
```
## Key Custom Files
| File | Purpose |
|------|---------|
| `src/lotus-terminal.css.ts` | All TDS CSS tokens, global styles, light/dark variants |
| `src/lotus-boot.ts` | Boot sequence animation (runs once per session) |
| `src/app/hooks/useRoomReadPositions.ts` | Per-message read receipt position map |
| `src/app/features/room/ReadPositionsContext.ts` | React context for read positions |
| `src/app/components/read-receipt-avatars/` | Read receipt avatar pill component |
| `src/app/components/event-readers/EventReaders.tsx` | "Seen by" modal with timestamps |
| `src/app/components/GifPicker.tsx` | GIF search + send |
| `src/app/features/call/CallControls.tsx` | PTT badge + keybind logic |
| `src/app/plugins/call/CallControl.ts` | EC widget bridge (screenshare revert, PTT mic) |
| `src/app/components/CallEmbedProvider.tsx` | PiP + draggable call embed, call wallpaper carry-over |
| `src/app/plugins/call/CallEmbed.ts` | EC widget bridge: iframe setup, `color-scheme` dark/light injection, built-in control hiding, theme sync |
| `src/app/plugins/millify.ts` | Named import fix for Rolldown CJS interop (prevents `zc.default is not a function` crash) |