Compare commits
2 Commits
4af07109c3
...
cb848be0b6
| Author | SHA1 | Date | |
|---|---|---|---|
| cb848be0b6 | |||
| 205fbe3b0d |
+36
-73
@@ -327,28 +327,14 @@ Core features that meaningfully expand what users can do every day.
|
||||
|
||||
---
|
||||
|
||||
### [ ] P1-1 · Quick Room Switcher (Ctrl+K / Cmd+K)
|
||||
### [UPSTREAM — REMOVED] P1-1 · Quick Room Switcher (Ctrl+K / Cmd+K)
|
||||
|
||||
**What:** Global keyboard shortcut opens a floating search modal above all content. Features:
|
||||
|
||||
- Fuzzy-search across ALL rooms and DMs by display name in real time
|
||||
- Keyboard navigable: `↑`/`↓` to move, `Enter` to open room, `Esc` to close
|
||||
- Shows unread badge count inline next to each result
|
||||
- Pressing Ctrl+K again while open closes it
|
||||
- Recent rooms shown immediately before typing
|
||||
**Architecture:**
|
||||
- New component: `src/app/components/QuickSwitcher.tsx`
|
||||
- Register global hotkey via `useEffect` + `window.addEventListener('keydown')` in `src/app/pages/client/ClientRoot.tsx`
|
||||
- Use the existing `OverlayContainerProvider` portal (from `App.tsx`) so the modal floats above everything
|
||||
- Room data: `allRoomsAtom` + `mDirectAtom` from `src/app/state/roomList.ts`
|
||||
- Style with `folds` `Menu`, `Box`, `Input` components matching existing patterns
|
||||
- Use `is-hotkey` library (already in the project) for the keybind
|
||||
**[AUDIT REQUIRED]** — Confirm upstream Cinny does NOT already have a quick switcher. Check for any existing Ctrl+K handler in `ClientRoot.tsx` or `App.tsx`.
|
||||
**Complexity:** Medium.
|
||||
**Audit result:** CONFIRMED UPSTREAM. `SearchModalRenderer` (Ctrl+K) in `src/app/features/search/Search.tsx` is already a full room/DM switcher with avatars, unread badges, fuzzy search across all rooms and DMs, and keyboard navigation. It is more polished than anything we would build. A custom `QuickSwitcher.tsx` was built and then deleted — Ctrl+K was left to the existing upstream implementation.
|
||||
**No action needed.**
|
||||
|
||||
---
|
||||
|
||||
### [ ] P1-2 · Media Gallery
|
||||
### [x] P1-2 · Media Gallery
|
||||
|
||||
**What:** A scrollable grid of all shared images, videos, and files in a room. Accessible via a new icon in the room header (picture/grid icon). Features:
|
||||
|
||||
@@ -358,60 +344,36 @@ Core features that meaningfully expand what users can do every day.
|
||||
- Click file → download
|
||||
- Shows sender + timestamp tooltip on hover
|
||||
- TDS-aware grid styling
|
||||
**Architecture:**
|
||||
- New component: `src/app/features/room/media-gallery/MediaGallery.tsx`
|
||||
- Renders as a right-side drawer (similar to the members drawer pattern)
|
||||
- Add icon button to `src/app/features/room/RoomViewHeader.tsx`
|
||||
- Paginate backwards from latest event using `/messages?dir=b`
|
||||
**[AUDIT REQUIRED]** — Confirm upstream Cinny has no existing media gallery or file browser. Check room header icons and room settings panels.
|
||||
**Complexity:** Medium.
|
||||
**Complexity:** Medium.
|
||||
**COMPLETED June 2026.** `MediaGallery.tsx` renders as a fixed right-side drawer (320px). Three tabs: Images | Videos | Files. Reads already-decrypted events from `room.getLiveTimeline().getEvents()` (required for E2EE rooms — raw API returns undecryptable blobs). For encrypted images, shows a lock icon + filename placeholder (server can't thumbnail encrypted content). Unencrypted thumbnails use `mxcUrlToHttp(mx, url, false, 120, 120, 'crop')` — `useAuthentication=false` avoids the v1 authenticated endpoint that adds `allow_redirect=true`, which Synapse rejects with 400. Load More uses `mx.paginateEventTimeline()`. Gallery icon added to room header (Desktop only).
|
||||
|
||||
---
|
||||
|
||||
### [ ] P1-3 · Sidebar Room Filter / Search
|
||||
### [x] P1-3 · Sidebar Room Filter / Search
|
||||
|
||||
**What:** A text input at the top of each room list tab (Home, DMs, Spaces) that filters visible rooms in real time by display name. Ephemeral — clears when you switch tabs. No server calls — pure client-side filter over the loaded room list.
|
||||
**Architecture:**
|
||||
|
||||
- Add filter `useState<string>('')` to each tab component
|
||||
- Filter the room array before rendering: `rooms.filter(id => displayName(id).toLowerCase().includes(filter))`
|
||||
- Show a small `×` clear button when filter is non-empty
|
||||
- TDS: mono font, dim border input matching the design system
|
||||
**Where:** `src/app/pages/client/home/Home.tsx` (Home tab), DMs equivalent, Space room list.
|
||||
**[AUDIT REQUIRED]** — Confirm upstream Cinny does NOT already have a filter input in room lists.
|
||||
**Complexity:** Low-Medium.
|
||||
**Complexity:** Low-Medium.
|
||||
**COMPLETED June 2026.** Filter inputs added to `Home.tsx` (rooms) and `Direct.tsx` (DMs). Styled with `size="400"`, `radii="400"`, search icon prefix — matches the members-drawer search bar style. Clear `×` button appears when non-empty. Filtering respects local room name overrides (`getLocalRoomNamesContent`). Favorites section always shows unfiltered; only the main "Rooms" list is filtered. Input fills full panel width (`grow="Yes"` on container Box).
|
||||
|
||||
---
|
||||
|
||||
### [ ] P1-4 · Enhanced DM List (last message preview + timestamp)
|
||||
### [x] P1-4 · Enhanced DM List (last message preview + timestamp)
|
||||
|
||||
**What:** Show the last message text and relative timestamp ("2 min ago", "Yesterday") next to each DM in the sidebar, like iMessage or WhatsApp. Currently only the room name and unread badge are shown.
|
||||
**Architecture:**
|
||||
|
||||
- For each DM room: get `room.getLastActiveTimestamp()` and `room.timeline[room.timeline.length - 1]` for the last event
|
||||
- Render the event body (truncated to ~60 chars), stripping any HTML
|
||||
- Format timestamp: "just now" / "X min ago" / "Yesterday" / date
|
||||
- Falls back gracefully if the room has no messages yet
|
||||
**[AUDIT REQUIRED]** — Check if upstream Cinny already shows message previews in DM list. Check `src/app/pages/client/direct-messages/` or `DirectTab.tsx` for existing DM list item rendering.
|
||||
**Complexity:** Medium — need to extract last event body safely across encrypted/non-encrypted rooms.
|
||||
**Complexity:** Medium.
|
||||
**COMPLETED June 2026.** Implemented in `RoomNavItem.tsx` via `useRoomLatestRenderedEvent(room)` hook (reactive — re-renders on new events). When `direct=true`: shows truncated body (48 chars) + relative timestamp below the room name. Handles: encrypted events (shows "Encrypted message" only on actual decryption failure — `isDecryptionFailure()` not `isEncrypted()`), stickers ("Sticker"), membership events (skipped entirely), missing timestamps. Timestamp formatting: `Xm` / `Xhr` / `Yesterday` / `D MMM`.
|
||||
|
||||
---
|
||||
|
||||
### [ ] P1-5 · Voice Message Playback Speed Control
|
||||
### [x] P1-5 · Voice Message Playback Speed Control
|
||||
|
||||
**What:** Add a speed toggle to the voice message audio player in the room timeline: `0.75×` → `1×` → `1.5×` → `2×`. Clicking the current speed label cycles to the next. Uses the HTML `<audio>` element's `playbackRate` property.
|
||||
**Architecture:**
|
||||
|
||||
- `const [speed, setSpeed] = useState(1)`
|
||||
- On click: `audioRef.current.playbackRate = nextSpeed`
|
||||
- Render a small clickable pill: `1×` etc., next to the audio controls
|
||||
- Persist speed preference in `settingsAtom` so it carries across messages
|
||||
**[AUDIT REQUIRED]** — Locate exactly where voice messages render in the timeline. Search for `m.audio` / `MSC3245` / `VoiceMessage` in `src/app/features/room/`. The recorder is in `VoiceMessageRecorder.tsx` but the _player_ for received voice messages is elsewhere.
|
||||
**Complexity:** Low-Medium.
|
||||
**Complexity:** Low-Medium.
|
||||
**COMPLETED June 2026.** `AudioContent.tsx` — `useState<0.75|1|1.5|2>(1)` with `SPEED_STEPS = [0.75, 1, 1.5, 2]`. `useEffect` sets `audioRef.current.playbackRate` when speed changes (persists across pause/resume). Speed pill rendered as a `Chip` in the left controls area. Local state only (no settingsAtom persistence — speed resets per-message, which is the standard behavior).
|
||||
|
||||
---
|
||||
|
||||
### [ ] P1-6 · Poll Creation
|
||||
### [x] P1-6 · Poll Creation
|
||||
|
||||
**What:** Users can create polls from the message composer. Features:
|
||||
|
||||
@@ -436,11 +398,12 @@ Core features that meaningfully expand what users can do every day.
|
||||
**Architecture:**
|
||||
- New component: `src/app/features/room/PollCreator.tsx`
|
||||
- Wire toolbar button into `src/app/features/room/RoomInput.tsx` (follow GIF picker pattern)
|
||||
**Complexity:** Medium.
|
||||
**Complexity:** Medium.
|
||||
**COMPLETED June 2026.** `PollCreator.tsx` — modal with question input, 2–10 option inputs (add/remove), Single/Multiple choice toggle buttons. `isMultiple: boolean` state with `max_selections` computed at submit time from filled options length. Sends stable `m.poll.start` event. `Icons.OrderList` button in RoomInput `after` prop opens the modal. Sentry issue JAVASCRIPT-REACT-N fixed: `PollContent.tsx` vote handler now `.catch(() => undefined)` to suppress `Cannot call getPendingEvents with pendingEventOrdering == chronological` SDK error.
|
||||
|
||||
---
|
||||
|
||||
### [ ] P1-7 · Code Syntax Highlighting in Messages
|
||||
### [x] P1-7 · Code Syntax Highlighting in Messages
|
||||
|
||||
**What:** Color-code fenced code blocks in rendered messages. Language auto-detected from the code fence hint (` ```python `, ` ```js `, etc.).
|
||||
**⚠️ MUST match `/root/code/web_template/base.css` exactly.** The web_template defines the canonical token structure for all Lotus apps:
|
||||
@@ -457,13 +420,14 @@ Core features that meaningfully expand what users can do every day.
|
||||
- `.tok-cmt` → `var(--color-tok-cmt)` italic (comments)
|
||||
- `.tok-fn` → `var(--accent-purple)` (function names)
|
||||
See `/root/code/tinker_tickets/assets/js/markdown.js` for the reference tokenizer implementation.
|
||||
**[AUDIT REQUIRED]** — Check the TDS CSS variables (`--accent-cyan`, `--accent-green`, etc.) are available in Cinny's `lotus-terminal.css.ts` vanilla-extract theme. Map `var(--accent-*)` to the correct folds/vanilla-extract tokens. Also verify the tokenizer approach from tinker_tickets is language-agnostic enough for chat (it may need extension for more languages).
|
||||
**Where:** Code block renderer in `src/app/components/message/` or wherever `<pre><code>` is rendered.
|
||||
**Complexity:** Medium.
|
||||
**[AUDIT REQUIRED]** — Check the TDS CSS variables (`--accent-cyan`, `--accent-green`, etc.) are available in Cinny's `lotus-terminal.css.ts` vanilla-extract theme. Map `var(--accent-*)` to the correct folds/vanilla-extract tokens. Also verify the tokenizer approach from tinker_tickets is language-agnostic enough for chat (it may need extension for more languages).
|
||||
**Where:** Code block renderer in `src/app/components/message/` or wherever `<pre><code>` is rendered.
|
||||
**Complexity:** Medium.
|
||||
**COMPLETED June 2026.** `src/app/utils/syntaxHighlight.ts` — custom tokenizer returning `SyntaxToken[]` with inline styles from `--lt-accent-*` CSS variables. Supports: JS/TS/JSX/TSX, Python, Rust. Tokens: `kw` (cyan), `str` (green), `num` (orange), `cmt` (italic, opacity 0.5), `fn` (purple). Integrated in `react-custom-html-parser.tsx` via `TDS_TOKENIZER_LANGS` set — falls back to ReactPrism for unsupported languages. `ReactPrism.css` adds `.prism-tds-dark` / `.prism-tds-light` classes. `useTheme.ts` sets `prism-tds-dark/light` on the LotusTerminal themes.
|
||||
|
||||
---
|
||||
|
||||
### [ ] P1-8 · Favorite / Starred Rooms
|
||||
### [x] P1-8 · Favorite / Starred Rooms
|
||||
|
||||
**What:** Star any room from its context menu. Starred rooms appear in a dedicated "Favorites" section at the top of the sidebar room list. Uses Matrix's built-in `m.favourite` room tag (account data), so favorites sync across devices and clients automatically.
|
||||
|
||||
@@ -475,12 +439,12 @@ Body: { "order": 0.5 }
|
||||
- Right-click room → "Add to Favorites" / "Remove from Favorites"
|
||||
- Favorites section appears above the regular room list in the Home tab
|
||||
- Star icon shown on favorited rooms in the list
|
||||
**[AUDIT REQUIRED]** — Confirm upstream Cinny does NOT already render `m.favourite` tagged rooms in a special section. Check `src/app/pages/client/home/` for any tag-based room grouping. Some versions of Cinny may already do this.
|
||||
**Complexity:** Medium.
|
||||
**Complexity:** Medium.
|
||||
**COMPLETED June 2026.** `RoomNavItem.tsx` context menu: "Add to Favorites" / "Remove from Favorites" calls `mx.setRoomTag(roomId, 'm.favourite', { order: 0.5 })` / `mx.deleteRoomTag(roomId, 'm.favourite')`. Star indicator shown on favorited rooms. `Home.tsx`: rooms split into `favoriteRooms` / `otherRooms` by `room.tags?.['m.favourite']`. Favorites section (with its own `NavCategory` + `favVirtualizer`) rendered above the main Rooms list when any favorites exist. `FAVORITES_CATEGORY_ID` is collapsible.
|
||||
|
||||
---
|
||||
|
||||
### [ ] P1-9 · Invite Link Generator (with QR code)
|
||||
### [x] P1-9 · Invite Link Generator (with QR code)
|
||||
|
||||
**What:** In room settings (or via right-click on room), a "Copy Invite Link" button that:
|
||||
|
||||
@@ -488,13 +452,13 @@ Body: { "order": 0.5 }
|
||||
2. Copies it to clipboard via `navigator.clipboard.writeText()`
|
||||
3. Shows a QR code of the link (use a lightweight QR library e.g. `qrcode` npm package)
|
||||
4. "Share" button that opens the native Web Share API if supported
|
||||
**[AUDIT REQUIRED]** — Check if upstream Cinny already has a "copy invite link" button in room settings. Also check if the room settings panel already shows the room address/alias.
|
||||
**Where:** Room settings panel `src/app/features/room-settings/` or room header context menu.
|
||||
**Complexity:** Low-Medium.
|
||||
**Where:** Room settings panel `src/app/features/room-settings/` or room header context menu.
|
||||
**Complexity:** Low-Medium.
|
||||
**COMPLETED June 2026.** `RoomShareInvite.tsx` — new component in `common-settings/general/`. Shows invite URL as copyable text + 160×160 QR code (`api.qrserver.com`). "Copy Link" button with 2s "Copied!" state. Added to room settings General tab. **Also** added to the Invite User modal (`InviteUserPrompt.tsx`): `⊞` toggle button in header shows/hides a 180×180 QR panel between the header and user search form. CSP on LXC 106 updated to add `https://api.qrserver.com` to `img-src` (nginx reloaded).
|
||||
|
||||
---
|
||||
|
||||
### [ ] P1-10 · Private Read Receipts toggle
|
||||
### [x] P1-10 · Private Read Receipts toggle
|
||||
|
||||
**Spec:** CS-API stable — `m.read.private` vs `m.read`.
|
||||
**What:** Add a toggle in Settings → Privacy: "Send private read receipts" — when enabled, read receipts are sent as `m.read.private` (only you and the server see them) instead of `m.read` (everyone in the room sees when you read). Default: public (current behavior, unchanged).
|
||||
@@ -503,21 +467,20 @@ Body: { "order": 0.5 }
|
||||
- New `settingsAtom` field: `privateReadReceipts: boolean` (default `false`)
|
||||
- When sending a read receipt, check the setting and use the appropriate type
|
||||
- Find `mx.sendReadReceipt(...)` call sites and add the `receiptType` parameter
|
||||
**[AUDIT REQUIRED]** — Verify the `matrix-js-sdk` version in use supports the `receiptType` parameter on `sendReadReceipt`. Check the SDK's type definitions for this method.
|
||||
**Complexity:** Low.
|
||||
**Complexity:** Low.
|
||||
**COMPLETED June 2026.** `privateReadReceipts: boolean` (default `false`) in `settingsAtom`. Toggle in Settings → General → Privacy section. `notifications.ts`: `markAsRead()` already had `privateReceipt` parameter; added `getSettings().privateReadReceipts` read so either flag independently triggers `ReceiptType.ReadPrivate`. `getSettings()` reads from localStorage which is always kept in sync with the atom via `setSettings()` on every atom write — no staleness issue.
|
||||
|
||||
---
|
||||
|
||||
### [ ] P1-11 · Knock-to-join UX (Room version 7)
|
||||
### [x] P1-11 · Knock-to-join UX (Room version 7)
|
||||
|
||||
**Spec:** Room version 7 (stable Matrix spec). Join rule: `knock`.
|
||||
**What:** Full knock UX for both sides:
|
||||
|
||||
- **Knocking:** When a room's join rule is `knock`, show a "Request to Join" button instead of "Join Room". Sends `POST /join` which triggers a knock state event. Show pending state ("Request sent") while waiting.
|
||||
- **Approving knocks:** Room admins/mods see a notification of pending knocks. In the members drawer or room settings, show a "Pending Requests" section listing knockers with "Approve" (`/invite`) and "Deny" (`/kick`) buttons.
|
||||
**[SERVER CHECK]** — Verify `matrix.lotusguild.org` Synapse supports room version 7 and the knock join rule.
|
||||
**[AUDIT REQUIRED]** — Check if upstream Cinny handles the `knock` join rule at all. If it does, only the approvals UI on the admin side may be missing.
|
||||
**Complexity:** Medium.
|
||||
**Complexity:** Medium.
|
||||
**COMPLETED June 2026.** `RoomIntro.tsx`: when `room.getJoinRule() === JoinRule.Knock`, shows "Request to Join" button instead of "Join Room". On click: `mx.knockRoom(room.roomId)` (SDK method confirmed). After knocking: shows "Request sent — waiting for room admin approval" state. State resets on `room.roomId` change via `useEffect`. `MembersDrawer.tsx`: "Pending Requests" section visible to users with invite power level — lists `room.getMembersWithMembership(Membership.Knock)` with Approve (`mx.invite`) / Deny (`mx.kick`) buttons.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -135,6 +135,29 @@ Emoji reaction buttons styled for terminal mode via `button[data-reaction-key]`
|
||||
- **Presence badges on members**: Online/busy/away dots shown next to users in the room members drawer and settings members panel (`PresenceBadge` component from `src/app/components/presence/Presence.tsx`).
|
||||
- **Document title unread count**: Tab title updates to `(N) Lotus Chat` for mentions, `· Lotus Chat` for unreads, `Lotus Chat` when clear.
|
||||
|
||||
### UX & Composer
|
||||
|
||||
- **Message length counter**: A muted character counter appears just left of the send button while typing, disappearing when the composer is empty. Resets on room switch.
|
||||
- **Sidebar room filter**: A search-icon input at the top of the Home and DMs sidebar tabs filters rooms by display name in real time. Clears on tab switch. Styled to match the members-drawer search bar (`size="400"`, search prefix icon).
|
||||
- **DM last message preview**: Each DM row in the sidebar shows a truncated message body (48 chars) and relative timestamp (`Xm`, `Xhr`, `Yesterday`, `D MMM`) below the room name, sourced reactively from `useRoomLatestRenderedEvent`. Encrypted rooms show "Encrypted message" only on actual decryption failure.
|
||||
- **Favorite rooms**: Right-click any room → "Add to Favorites" / "Remove from Favorites". Favorited rooms (using the standard Matrix `m.favourite` tag) appear in a collapsible "Favorites" section above the main room list on the Home tab. Syncs across devices via account data.
|
||||
- **Poll creation**: Polls can be created directly from the composer — `Icons.OrderList` button opens a modal with question field, 2–10 answer options (add/remove), and Single/Multiple choice toggle. Sends a stable `m.poll.start` event. (Poll display & voting were already supported.)
|
||||
- **Voice message playback speed**: `0.75×` → `1×` → `1.5×` → `2×` speed toggle pill on voice message player — cycles on click via `playbackRate` on the `<audio>` element.
|
||||
- **Invite link + QR code**: Room settings → General shows a "Share Room" tile with the `matrix.to` invite URL and a QR code. The Invite modal also has a `⊞` toggle button showing a QR panel when clicked. Both use `api.qrserver.com` (added to CSP on LXC 106).
|
||||
- **Private read receipts**: Settings → General → Privacy — "Private Read Receipts" toggle. When on, sends `m.read.private` instead of `m.read` so other room members can't see when you've read messages.
|
||||
- **Media gallery**: A right-side drawer (photo icon in room header, Desktop only) showing Images | Videos | Files tabs. Reads already-decrypted timeline events — works in E2EE rooms. Encrypted-blob images show a lock-icon placeholder. Load More paginates backwards via `mx.paginateEventTimeline()`.
|
||||
- **Knock-to-join**: When a room's join rule is `knock`, RoomIntro shows "Request to Join" (calls `mx.knockRoom()`) with "Request sent" pending state. Room admins see a "Pending Requests" section in the members drawer with Approve / Deny buttons.
|
||||
- **Code syntax highlighting** (TDS mode): Fenced code blocks in messages highlight keywords (cyan), strings (green), numbers (orange), comments (italic dim), function names (purple) using inline `--lt-accent-*` CSS variables. Custom tokenizer in `syntaxHighlight.ts` — supports JS/TS/JSX/TSX, Python, Rust. Falls back to ReactPrism for other languages.
|
||||
|
||||
### Settings (Appearance)
|
||||
|
||||
- **Night Light / Blue Light Filter**: Warm orange overlay (`rgba(255,140,0,N%)`) across the entire UI. Toggle + intensity slider (5–80%) in Settings → Appearance. `position:fixed; pointer-events:none; z-index:9998`. Persists across sessions.
|
||||
|
||||
### Calls (Extended)
|
||||
|
||||
- **Push-to-Deafen**: Press `M` during a call to toggle speaker mute (deafen). Configurable in Settings → General → Calls alongside the PTT key. Skips editable elements; guards `e.repeat`; uses `el.ownerDocument.body` for iframe safety.
|
||||
- **TDS typing indicator dots**: When Lotus Terminal mode is active, the animated typing indicator dots turn TDS orange (`var(--lt-accent-orange)`) via `color: currentColor` inheritance.
|
||||
|
||||
### Server Integration
|
||||
|
||||
- **Server support contact (MSC1929)**: Settings → Help & About displays the homeserver admin contact fetched from `/.well-known/matrix/support`. Shows the admin's Matrix ID and a link to the support page when the homeserver has configured this endpoint. Degrades gracefully when not configured (section is hidden on 404 or network error). In TDS mode the contact text and link render in `--lt-accent-cyan`. Implemented in `src/app/features/settings/about/About.tsx`.
|
||||
@@ -206,3 +229,7 @@ Built files are served from `/var/www/html/` on LXC 106 (nginx). Config lives at
|
||||
| `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) |
|
||||
| `src/app/features/room/MediaGallery.tsx` | Right-side media gallery drawer (images/videos/files) |
|
||||
| `src/app/features/room/PollCreator.tsx` | Poll creation modal (single/multiple choice, 2–10 options) |
|
||||
| `src/app/features/common-settings/general/RoomShareInvite.tsx` | Invite link + QR code tile for room settings |
|
||||
| `src/app/utils/syntaxHighlight.ts` | TDS code syntax tokenizer (JS/TS/Python/Rust → inline CSS vars) |
|
||||
|
||||
@@ -23,11 +23,12 @@ type PollAnswer = {
|
||||
type PollData = {
|
||||
question?: { body?: string; 'm.text'?: PollTextValue };
|
||||
answers?: PollAnswer[];
|
||||
max_selections?: number;
|
||||
};
|
||||
|
||||
type VoteState = {
|
||||
counts: Map<string, number>;
|
||||
myVote: string | null;
|
||||
myVotes: Set<string>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
@@ -37,7 +38,7 @@ function computeVotes(
|
||||
eventId: string,
|
||||
_isStable: boolean,
|
||||
): VoteState {
|
||||
const empty: VoteState = { counts: new Map(), myVote: null, total: 0 };
|
||||
const empty: VoteState = { counts: new Map(), myVotes: new Set(), total: 0 };
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return empty;
|
||||
|
||||
@@ -53,8 +54,8 @@ function computeVotes(
|
||||
'org.matrix.msc3381.poll.response',
|
||||
);
|
||||
|
||||
// Collect all response events; per-sender keep only latest
|
||||
const latestBySender = new Map<string, { ts: number; answerId: string }>();
|
||||
// Per-sender keep only the latest response (which may include multiple selections)
|
||||
const latestBySender = new Map<string, { ts: number; answerIds: string[] }>();
|
||||
const myUserId = mx.getSafeUserId();
|
||||
|
||||
const processRelations = (rels: typeof stableRels, stable: boolean) => {
|
||||
@@ -64,19 +65,20 @@ function computeVotes(
|
||||
const sender = ev.getSender();
|
||||
if (!sender) continue;
|
||||
const content = ev.getContent();
|
||||
let answerId: string | undefined;
|
||||
let answerIds: string[] = [];
|
||||
if (stable) {
|
||||
answerId = (content['m.selections'] as string[] | undefined)?.[0];
|
||||
answerIds = (content['m.selections'] as string[] | undefined) ?? [];
|
||||
} else {
|
||||
answerId = (
|
||||
(content['org.matrix.msc3381.poll.response'] as any)?.answers as string[] | undefined
|
||||
)?.[0];
|
||||
answerIds =
|
||||
((content['org.matrix.msc3381.poll.response'] as any)?.answers as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
}
|
||||
if (!answerId) continue;
|
||||
if (answerIds.length === 0) continue;
|
||||
const ts = ev.getTs();
|
||||
const existing = latestBySender.get(sender);
|
||||
if (!existing || ts > existing.ts) {
|
||||
latestBySender.set(sender, { ts, answerId });
|
||||
latestBySender.set(sender, { ts, answerIds });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -85,13 +87,15 @@ function computeVotes(
|
||||
processRelations(unstableRels, false);
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
let myVote: string | null = null;
|
||||
for (const [sender, { answerId }] of latestBySender) {
|
||||
counts.set(answerId, (counts.get(answerId) ?? 0) + 1);
|
||||
if (sender === myUserId) myVote = answerId;
|
||||
const myVotes = new Set<string>();
|
||||
for (const [sender, { answerIds }] of latestBySender) {
|
||||
for (const id of answerIds) {
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
if (sender === myUserId) myVotes.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return { counts, myVote, total: latestBySender.size };
|
||||
return { counts, myVotes, total: latestBySender.size };
|
||||
}
|
||||
|
||||
export function PollContent({
|
||||
@@ -111,7 +115,7 @@ export function PollContent({
|
||||
| undefined;
|
||||
|
||||
const [votes, setVotes] = useState<VoteState>(() => {
|
||||
if (!roomId || !eventId) return { counts: new Map(), myVote: null, total: 0 };
|
||||
if (!roomId || !eventId) return { counts: new Map(), myVotes: new Set(), total: 0 };
|
||||
return computeVotes(mx, roomId, eventId, _isStable);
|
||||
});
|
||||
|
||||
@@ -184,30 +188,50 @@ export function PollContent({
|
||||
'Untitled poll';
|
||||
|
||||
const canVote = !!roomId && !!eventId;
|
||||
const { counts, myVote, total } = votes;
|
||||
const maxSelections = (poll as any).max_selections ?? 1;
|
||||
const isMultiple = maxSelections > 1;
|
||||
const { counts, myVotes, total } = votes;
|
||||
|
||||
const handleVote = (answerId: string) => {
|
||||
if (!roomId || !eventId) return;
|
||||
|
||||
const newVotes = new Set(myVotes);
|
||||
if (newVotes.has(answerId)) {
|
||||
newVotes.delete(answerId);
|
||||
} else {
|
||||
if (!isMultiple) newVotes.clear();
|
||||
newVotes.add(answerId);
|
||||
}
|
||||
|
||||
// Optimistic local update
|
||||
setVotes((prev) => {
|
||||
const next = new Map(prev.counts);
|
||||
if (prev.myVote) {
|
||||
const prevCount = next.get(prev.myVote) ?? 1;
|
||||
if (prevCount <= 1) next.delete(prev.myVote);
|
||||
else next.set(prev.myVote, prevCount - 1);
|
||||
// Remove all old vote counts for this user
|
||||
for (const id of prev.myVotes) {
|
||||
const c = next.get(id) ?? 1;
|
||||
if (c <= 1) next.delete(id);
|
||||
else next.set(id, c - 1);
|
||||
}
|
||||
next.set(answerId, (next.get(answerId) ?? 0) + 1);
|
||||
return { counts: next, myVote: answerId, total: prev.myVote ? prev.total : prev.total + 1 };
|
||||
// Add new vote counts
|
||||
for (const id of newVotes) {
|
||||
next.set(id, (next.get(id) ?? 0) + 1);
|
||||
}
|
||||
const hadVotes = prev.myVotes.size > 0;
|
||||
const hasVotes = newVotes.size > 0;
|
||||
const newTotal = prev.total + (hasVotes && !hadVotes ? 1 : !hasVotes && hadVotes ? -1 : 0);
|
||||
return { counts: next, myVotes: newVotes, total: newTotal };
|
||||
});
|
||||
|
||||
const selectionsArr = Array.from(newVotes);
|
||||
if (_isStable) {
|
||||
mx.sendEvent(roomId, 'm.poll.response' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': [answerId],
|
||||
'm.selections': selectionsArr,
|
||||
}).catch(() => undefined);
|
||||
} else {
|
||||
mx.sendEvent(roomId, 'org.matrix.msc3381.poll.response' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'org.matrix.msc3381.poll.response': { answers: [answerId] },
|
||||
'org.matrix.msc3381.poll.response': { answers: selectionsArr },
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
@@ -234,7 +258,7 @@ export function PollContent({
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
◉ Poll
|
||||
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
|
||||
</Box>
|
||||
<Text size="T400" style={{ fontWeight: 600 }}>
|
||||
{questionText}
|
||||
@@ -246,7 +270,7 @@ export function PollContent({
|
||||
(answer as any)['org.matrix.msc3381.poll.answer']?.body ||
|
||||
`Option ${i + 1}`;
|
||||
const id = answer['m.id'] ?? answer.id ?? String(i);
|
||||
const selected = myVote === id;
|
||||
const selected = myVotes.has(id);
|
||||
const voteCount = counts.get(id) ?? 0;
|
||||
const pct = total > 0 ? Math.round((voteCount / total) * 100) : 0;
|
||||
return (
|
||||
@@ -259,46 +283,79 @@ export function PollContent({
|
||||
style={{
|
||||
padding: '7px 12px',
|
||||
borderRadius: '8px',
|
||||
background: selected ? 'var(--bg-surface-active)' : 'var(--bg-surface-low)',
|
||||
border: `1px solid ${
|
||||
selected ? 'var(--text-primary)' : 'var(--bg-surface-border)'
|
||||
}`,
|
||||
background: selected
|
||||
? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.12)'
|
||||
: 'rgba(255,255,255,0.04)',
|
||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.7)' : 'rgba(255,255,255,0.12)'}`,
|
||||
fontSize: '0.88rem',
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'left',
|
||||
cursor: canVote ? 'pointer' : 'default',
|
||||
color: 'var(--text-primary)',
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
{/* vote progress bar */}
|
||||
{total > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
right: 'auto',
|
||||
width: `${pct}%`,
|
||||
background: selected ? 'var(--bg-surface-active)' : 'var(--bg-surface-low)',
|
||||
borderRadius: '8px',
|
||||
background: selected
|
||||
? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.08)'
|
||||
: 'rgba(255,255,255,0.03)',
|
||||
pointerEvents: 'none',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}
|
||||
>
|
||||
<span style={{ flexGrow: 1 }}>{text}</span>
|
||||
{selected && (
|
||||
<span style={{ opacity: 0.8, fontSize: '1rem', flexShrink: 0 }}>✓</span>
|
||||
{isMultiple && (
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
|
||||
borderRadius: '3px',
|
||||
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
color: '#fff',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{selected ? '✓' : ''}
|
||||
</span>
|
||||
)}
|
||||
{!isMultiple && (
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
|
||||
borderRadius: '50%',
|
||||
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span style={{ flexGrow: 1 }}>{text}</span>
|
||||
{total > 0 && (
|
||||
<span style={{ opacity: 0.6, fontSize: '0.78rem', flexShrink: 0 }}>{pct}%</span>
|
||||
<span style={{ opacity: 0.55, fontSize: '0.78rem', flexShrink: 0 }}>{pct}%</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
@@ -307,12 +364,14 @@ export function PollContent({
|
||||
</Box>
|
||||
<Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}>
|
||||
<i>
|
||||
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} — ` : ''}
|
||||
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
|
||||
{canVote
|
||||
? myVote
|
||||
? 'click another to change'
|
||||
: 'click an option to vote'
|
||||
: 'voting not available'}
|
||||
? isMultiple
|
||||
? 'Select all that apply'
|
||||
: myVotes.size > 0
|
||||
? 'Click to change'
|
||||
: 'Click to vote'
|
||||
: 'Voting not available'}
|
||||
</i>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { EventType, MsgType, Room } from 'matrix-js-sdk';
|
||||
@@ -38,6 +39,82 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
||||
file: MsgType.File,
|
||||
};
|
||||
|
||||
type ThumbState = 'loading' | 'error' | 'ok';
|
||||
|
||||
function ImageTile({
|
||||
thumbUrl,
|
||||
fullUrl,
|
||||
body,
|
||||
isEncrypted,
|
||||
}: {
|
||||
thumbUrl: string | null;
|
||||
fullUrl: string;
|
||||
body: string;
|
||||
isEncrypted: boolean;
|
||||
}) {
|
||||
const [thumbState, setThumbState] = useState<ThumbState>(thumbUrl ? 'loading' : 'error');
|
||||
|
||||
return (
|
||||
<a
|
||||
href={isEncrypted ? undefined : fullUrl}
|
||||
target={isEncrypted ? undefined : '_blank'}
|
||||
rel="noreferrer"
|
||||
title={body}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
aspectRatio: '1',
|
||||
overflow: 'hidden',
|
||||
borderRadius: config.radii.R300,
|
||||
background: color.SurfaceVariant.Container,
|
||||
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
cursor: isEncrypted ? 'default' : 'pointer',
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{thumbUrl && thumbState !== 'error' && (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={body}
|
||||
onLoad={() => setThumbState('ok')}
|
||||
onError={() => setThumbState('error')}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
opacity: thumbState === 'ok' ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(thumbState === 'error' || !thumbUrl) && (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: config.space.S100,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon src={isEncrypted ? Icons.Lock : Icons.Photo} size="400" />
|
||||
<Text
|
||||
size="T200"
|
||||
truncate
|
||||
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.7 }}
|
||||
>
|
||||
{body || (isEncrypted ? 'Encrypted' : 'Image')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
active,
|
||||
@@ -70,7 +147,6 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
|
||||
const msgtype = TAB_MSGTYPES[tab];
|
||||
|
||||
// Read already-decrypted events from the live timeline (works for E2EE rooms)
|
||||
const getFilteredEvents = useCallback(() => {
|
||||
const timeline = room.getLiveTimeline();
|
||||
return timeline
|
||||
@@ -81,7 +157,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
return ev.getType() === EventType.RoomMessage && content.msgtype === msgtype;
|
||||
})
|
||||
.slice()
|
||||
.reverse(); // newest first
|
||||
.reverse();
|
||||
}, [room, msgtype]);
|
||||
|
||||
const [events, setEvents] = useState(() => getFilteredEvents());
|
||||
@@ -116,74 +192,62 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
bottom: 0,
|
||||
width: '320px',
|
||||
zIndex: 500,
|
||||
background: 'var(--bg-surface)',
|
||||
borderLeft: `1px solid ${color.Surface.ContainerLine}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Header
|
||||
variant="Background"
|
||||
variant="Surface"
|
||||
size="600"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
paddingRight: config.space.S200,
|
||||
paddingLeft: config.space.S300,
|
||||
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon size="200" src={Icons.Photo} />
|
||||
<Icon size="200" src={Icons.Photo} />
|
||||
<Box grow="Yes">
|
||||
<Text size="H5" truncate>
|
||||
Media
|
||||
Media Gallery
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Close</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Background"
|
||||
aria-label="Close media gallery"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Close</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Background"
|
||||
aria-label="Close media gallery"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
{/* Tab bar */}
|
||||
<Box
|
||||
shrink="No"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
}}
|
||||
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
|
||||
>
|
||||
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
|
||||
<TabButton key={t} label={TAB_LABELS[t]} active={tab === t} onClick={() => setTab(t)} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box
|
||||
grow="Yes"
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box grow="Yes" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
|
||||
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
||||
{loading && events.length === 0 && (
|
||||
@@ -195,12 +259,11 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
{!loading && events.length === 0 && (
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S400 }}>
|
||||
<Text size="T300" priority="300" align="Center">
|
||||
{`No ${TAB_LABELS[tab].toLowerCase()} found in this room.`}
|
||||
{`No ${TAB_LABELS[tab].toLowerCase()} in loaded history. Use Load More to search further back.`}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Image/Video grid */}
|
||||
{(tab === 'image' || tab === 'video') && events.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
@@ -215,68 +278,23 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
const mxcUrl: string | undefined = content.url ?? content.file?.url;
|
||||
if (!mxcUrl) return null;
|
||||
const body: string = content.body ?? '';
|
||||
// Use unauthenticated thumbnail URL — the v1 authenticated endpoint adds
|
||||
// allow_redirect=true which Synapse rejects with 400.
|
||||
const thumbUrl = isEncrypted
|
||||
? null
|
||||
: (mxcUrlToHttp(mx, mxcUrl, false, 120, 120, 'crop') ?? null);
|
||||
: (mxcUrlToHttp(mx, mxcUrl, useAuthentication, 120, 120, 'crop') ?? null);
|
||||
const fullUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#';
|
||||
return (
|
||||
<a
|
||||
<ImageTile
|
||||
key={mEvent.getId()}
|
||||
href={isEncrypted ? '#' : fullUrl}
|
||||
target={isEncrypted ? undefined : '_blank'}
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
aspectRatio: '1',
|
||||
overflow: 'hidden',
|
||||
borderRadius: config.radii.R300,
|
||||
background: 'var(--bg-surface-low)',
|
||||
cursor: isEncrypted ? 'default' : 'pointer',
|
||||
}}
|
||||
title={body}
|
||||
>
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={body}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
style={{ padding: config.space.S100 }}
|
||||
>
|
||||
<Icon src={isEncrypted ? Icons.Lock : Icons.Photo} size="400" />
|
||||
<Text
|
||||
size="T200"
|
||||
truncate
|
||||
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.7 }}
|
||||
>
|
||||
{body || (isEncrypted ? 'Encrypted' : 'Image')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</a>
|
||||
thumbUrl={thumbUrl}
|
||||
fullUrl={fullUrl}
|
||||
body={body}
|
||||
isEncrypted={isEncrypted}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
{tab === 'file' && events.length > 0 && (
|
||||
<Box direction="Column" gap="100">
|
||||
{events.map((mEvent) => {
|
||||
@@ -292,10 +310,10 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
padding: `${config.space.S200} ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
background: 'var(--bg-surface)',
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
background: color.Surface.Container,
|
||||
}}
|
||||
>
|
||||
<Icon size="200" src={Icons.File} />
|
||||
@@ -326,9 +344,8 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{canLoadMore && !loading && events.length > 0 && (
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
|
||||
{canLoadMore && !loading && (
|
||||
<Box justifyContent="Center" style={{ padding: `${config.space.S100} 0` }}>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
@@ -336,12 +353,11 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
radii="300"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
<Text size="B300">Load more</Text>
|
||||
<Text size="B300">Load More History</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Loading more spinner */}
|
||||
{loading && events.length > 0 && (
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
|
||||
<Spinner />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Box, Icon, IconButton, Icons, Text, config } from 'folds';
|
||||
import { Box, Button, Icon, IconButton, Icons, Text, config } from 'folds';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
interface PollCreatorProps {
|
||||
@@ -193,34 +193,26 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: config.space.S100 }}>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Selection Type</Text>
|
||||
<div style={{ display: 'flex', gap: config.space.S200 }}>
|
||||
<Box gap="200">
|
||||
{(['single', 'multiple'] as const).map((type) => {
|
||||
const active = type === 'multiple' ? isMultiple : !isMultiple;
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={type}
|
||||
type="button"
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill={active ? 'Solid' : 'None'}
|
||||
radii="300"
|
||||
onClick={() => setIsMultiple(type === 'multiple')}
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R300,
|
||||
border: `1px solid ${active ? 'var(--bg-primary-main)' : 'var(--bg-surface-border)'}`,
|
||||
background: active ? 'var(--bg-primary-main)' : 'transparent',
|
||||
color: active ? 'var(--tc-primary-on-primary)' : 'var(--tc-surface-high)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: active ? 600 : 400,
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{type === 'single' ? 'Single choice' : 'Multiple choice'}
|
||||
</button>
|
||||
<Text size="B300">{type === 'single' ? 'Single choice' : 'Multiple choice'}</Text>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: 'var(--tc-danger-normal)' }}>
|
||||
@@ -229,38 +221,27 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
)}
|
||||
|
||||
<Box direction="Row" justifyContent="End" gap="200">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
size="400"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'var(--bg-surface-low)',
|
||||
border: '1px solid var(--bg-surface-border)',
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${config.space.S200} ${config.space.S400}`,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tc-surface-high)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
style={{
|
||||
background: 'var(--bg-primary-main)',
|
||||
border: 'none',
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${config.space.S200} ${config.space.S400}`,
|
||||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
color: 'var(--tc-primary-on-primary)',
|
||||
fontSize: '14px',
|
||||
opacity: submitting ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create Poll'}
|
||||
</button>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="400"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
>
|
||||
<Text size="B400">{submitting ? 'Creating…' : 'Create Poll'}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||
@@ -265,9 +265,21 @@ type ClientNonUIFeaturesProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function SuppressPrintShortcut() {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === 'KeyP') e.preventDefault();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
return (
|
||||
<>
|
||||
<SuppressPrintShortcut />
|
||||
<SystemEmojiFeature />
|
||||
<PageZoomFeature />
|
||||
<FaviconUpdater />
|
||||
|
||||
+24
-12
@@ -284,18 +284,30 @@ export const mxcUrlToHttp = (
|
||||
height?: number,
|
||||
resizeMethod?: string,
|
||||
allowDirectLinks?: boolean,
|
||||
// Synapse's thumbnail endpoint returns 400 for allow_redirect=true; keep false everywhere.
|
||||
allowRedirects = false,
|
||||
): string | null =>
|
||||
mx.mxcUrlToHttp(
|
||||
mxcUrl,
|
||||
width,
|
||||
height,
|
||||
resizeMethod,
|
||||
allowDirectLinks,
|
||||
allowRedirects,
|
||||
useAuthentication,
|
||||
);
|
||||
): string | null => {
|
||||
// Build the URL manually so we never add allow_redirect.
|
||||
// The SDK forces allow_redirect=true when useAuthentication=true, but Synapse's
|
||||
// /_matrix/client/v1/media/thumbnail endpoint rejects that parameter with 400.
|
||||
if (!mxcUrl) return null;
|
||||
if (!mxcUrl.startsWith('mxc://')) {
|
||||
return allowDirectLinks ? mxcUrl : null;
|
||||
}
|
||||
const parts = mxcUrl.slice(6).split('/');
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
|
||||
const [serverName, mediaId] = parts;
|
||||
|
||||
const isThumbnail = !!(width || height || resizeMethod);
|
||||
const verb = isThumbnail ? 'thumbnail' : 'download';
|
||||
const prefix = useAuthentication
|
||||
? `/_matrix/client/v1/media/${verb}`
|
||||
: `/_matrix/media/v3/${verb}`;
|
||||
|
||||
const url = new URL(`${prefix}/${serverName}/${mediaId}`, mx.getHomeserverUrl());
|
||||
if (width) url.searchParams.set('width', String(Math.round(width)));
|
||||
if (height) url.searchParams.set('height', String(Math.round(height)));
|
||||
if (resizeMethod) url.searchParams.set('method', resizeMethod);
|
||||
return url.href;
|
||||
};
|
||||
|
||||
export const downloadMedia = async (src: string): Promise<Blob> => {
|
||||
// this request is authenticated by service worker
|
||||
|
||||
Reference in New Issue
Block a user