Compare commits
4 Commits
39cfc23ebe
...
ffb934fce6
| Author | SHA1 | Date | |
|---|---|---|---|
| ffb934fce6 | |||
| 440c1fe948 | |||
| aa62df9c75 | |||
| 15ac538a4b |
@@ -32,6 +32,10 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
|
|||||||
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
|
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
|
||||||
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
||||||
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
||||||
|
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
|
||||||
|
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
|
||||||
|
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
|
||||||
|
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
|
||||||
|
|
||||||
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
|
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
|
||||||
|
|
||||||
|
|||||||
+61
-9
@@ -18,15 +18,16 @@ Last updated: June 2026.
|
|||||||
9. [Per-Message Read Receipts](#per-message-read-receipts)
|
9. [Per-Message Read Receipts](#per-message-read-receipts)
|
||||||
10. [Delivery Status Indicators](#delivery-status-indicators)
|
10. [Delivery Status Indicators](#delivery-status-indicators)
|
||||||
11. [Messaging Enhancements](#messaging-enhancements)
|
11. [Messaging Enhancements](#messaging-enhancements)
|
||||||
12. [Presence](#presence)
|
12. [Threads (P3-8)](#threads-p3-8)
|
||||||
13. [UX & Composer](#ux--composer)
|
13. [Presence](#presence)
|
||||||
14. [Room Customization](#room-customization)
|
14. [UX & Composer](#ux--composer)
|
||||||
15. [Moderation](#moderation)
|
15. [Room Customization](#room-customization)
|
||||||
16. [Notifications](#notifications)
|
16. [Moderation](#moderation)
|
||||||
17. [Server Integration](#server-integration)
|
17. [Notifications](#notifications)
|
||||||
18. [Infrastructure](#infrastructure)
|
18. [Server Integration](#server-integration)
|
||||||
19. [Desktop App Features](#desktop-app-features)
|
19. [Infrastructure](#infrastructure)
|
||||||
20. [Key Custom Files](#key-custom-files)
|
20. [Desktop App Features](#desktop-app-features)
|
||||||
|
21. [Key Custom Files](#key-custom-files)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -690,6 +691,24 @@ Context menu → **Forward** allows forwarding a message to any room the user is
|
|||||||
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
|
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
|
||||||
- A chip shows the active date range with an **×** button to clear it
|
- A chip shows the active date range with an **×** button to clear it
|
||||||
|
|
||||||
|
### Encrypted Search Cache (P4-8, opt-in)
|
||||||
|
|
||||||
|
Persistent local index for encrypted-room search, so coverage survives page reloads instead of requiring re-pagination + re-decryption every session.
|
||||||
|
|
||||||
|
- Raw IndexedDB (`lotus-search-cache`): message rows keyed `[roomId, eventId]` + per-room coverage markers; merged into local search results with in-memory-wins dedupe
|
||||||
|
- **Opt-in, default OFF** (it stores decrypted text at rest): toggle + "Clear cached index" live in the search panel's Encrypted Rooms section, with the privacy note "Stores decrypted text on this device"
|
||||||
|
- Always wiped on logout; any IndexedDB error degrades to a cache-miss (never breaks search)
|
||||||
|
- Files: `src/app/utils/searchCache.ts`, `src/app/state/searchCacheEnabled.ts`, `features/message-search/useLocalMessageSearch.ts`
|
||||||
|
|
||||||
|
### Math / LaTeX Rendering (P4-4)
|
||||||
|
|
||||||
|
KaTeX-rendered math in messages, two paths:
|
||||||
|
|
||||||
|
- **Spec path (CS-API §11.5):** `<span/div data-mx-maths="…">` in `formatted_body` renders the attribute's LaTeX (block for div, inline for span); on render failure the element's child fallback content shows instead
|
||||||
|
- **Plain-text path:** `$…$` (inline) and `$$…$$` (block) with conservative rules — escape-aware (`\$`), currency-guarded (`$5 and $10` stays text), never inside `code`/`pre`
|
||||||
|
- KaTeX + its CSS load lazily on first math encountered — zero cost to the main bundle
|
||||||
|
- Files: `src/app/utils/mathParse.ts` (+14 tests), `components/math/KaTeX.tsx`, `plugins/react-custom-html-parser.tsx`
|
||||||
|
|
||||||
### Image / Video Captions
|
### Image / Video Captions
|
||||||
|
|
||||||
Images and videos can be sent with a caption. The caption and media are sent as a single event.
|
Images and videos can be sent with a caption. The caption and media are sent as a single event.
|
||||||
@@ -765,6 +784,31 @@ Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unpars
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Threads (P3-8)
|
||||||
|
|
||||||
|
Full threaded-conversation support (`m.thread`, matrix-js-sdk `threadSupport`), Element-consistent.
|
||||||
|
|
||||||
|
### Thread Panel
|
||||||
|
|
||||||
|
A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via `/relations`, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from **Reply in Thread** in the message menu, a reply's thread indicator, or a summary chip; close with **×** or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear.
|
||||||
|
|
||||||
|
### Summary Chips
|
||||||
|
|
||||||
|
Root messages in the main timeline show a **"N replies · time"** chip (server-aggregated `m.thread` bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable.
|
||||||
|
|
||||||
|
### Thread Composer
|
||||||
|
|
||||||
|
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).
|
||||||
|
|
||||||
|
### Under the Hood
|
||||||
|
|
||||||
|
- `threadSupport: true` (startClient) partitions thread events into SDK `Thread` timelines; markAsRead sends **unthreaded** receipts so room badges keep clearing
|
||||||
|
- Pending sends render via a `LocalEchoUpdated` strip (chronological local echo never enters thread timelineSets)
|
||||||
|
- Deep links to thread events redirect into the panel
|
||||||
|
- Files: `features/room/thread/*`, `state/room/thread.ts`, `hooks/useThreadSummary.ts` (+35 tests across the stack)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Presence
|
## Presence
|
||||||
|
|
||||||
### Discord-Style Presence Selector
|
### Discord-Style Presence Selector
|
||||||
@@ -1160,6 +1204,14 @@ The `useAuthentication` parameter was previously mispositioned, causing unauthen
|
|||||||
|
|
||||||
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
|
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
|
||||||
|
|
||||||
|
### Hardened Session Storage (N97 partial, 2026-07)
|
||||||
|
|
||||||
|
The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10 separate localStorage keys written non-atomically). Reads prefer the blob with transparent migration from the legacy keys (dual-written one release for rollback). Cross-tab sync: logging out or in from one tab reloads the others so no tab runs with stale credentials. `state/sessions.ts` (22 tests), `hooks/useSessionSync.ts`.
|
||||||
|
|
||||||
|
### Crypto Diagnostics (E2EE investigation kit)
|
||||||
|
|
||||||
|
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion runbook: [`LOTUS_E2EE_INVESTIGATION.md`](./LOTUS_E2EE_INVESTIGATION.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Desktop App Features
|
## Desktop App Features
|
||||||
|
|||||||
+11
-3
@@ -162,9 +162,17 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P3-8 · Thread Panel (full side drawer)
|
### [~] P3-8 · Thread Panel (full side drawer) — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
|
||||||
|
|
||||||
**⚠️ LARGEST FEATURE — 🟢 DESIGN COMPLETE (2026-07), READY FOR ITS OWN EXECUTION SESSION.** The full architecture (SDK-evidence-backed decisions, file inventory, 4-agent partition, risks, verification checklist) is in the Implementation Reference section below — no further planning needed, just a dedicated build session.
|
Built per the design below (4-agent build + 2-agent review). Gates green (tsc/eslint/build/tests). **Release note: threaded replies no longer render inline in the main timeline — roots show a "N replies" chip that opens the panel.**
|
||||||
|
|
||||||
|
**Manual QA checklist (post-deploy):**
|
||||||
|
1. Reply in Thread (message menu) → panel opens; send text/upload/emoji into it (appears pending → confirmed)
|
||||||
|
2. Reply to a reply inside the panel → event carries `m.thread` + `m.in_reply_to` with `is_falling_back:false`
|
||||||
|
3. Main timeline: root + chip only (replies absent); chip count/time updates live; unread badge appears for others' thread replies and clears after viewing the panel
|
||||||
|
4. Room badge clears via normal markAsRead even with unread threads (unthreaded receipt)
|
||||||
|
5. Reload: partitioning persists; encrypted-room threads decrypt (back-pagination too)
|
||||||
|
6. Escape / × closes; mobile = fullscreen panel; switching rooms and back restores the open thread
|
||||||
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
|
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
@@ -208,7 +216,7 @@ Features:
|
|||||||
|
|
||||||
### [ ] P4-1 · Thread Notification Mode Per-Thread (MSC3771)
|
### [ ] P4-1 · Thread Notification Mode Per-Thread (MSC3771)
|
||||||
|
|
||||||
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8).
|
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8) — **NOW UNBLOCKED (P3-8 implemented 2026-07)**.
|
||||||
**What:** Per-thread notification toggle: "All messages" vs "Mentions only". Accessible from the thread panel header. Tracks unread counts separately per thread.
|
**What:** Per-thread notification toggle: "All messages" vs "Mentions only". Accessible from the thread panel header. Tracks unread counts separately per thread.
|
||||||
**[AUDIT REQUIRED]** — Implement after Thread Panel. Requires understanding how the SDK tracks per-thread unread counts.
|
**[AUDIT REQUIRED]** — Implement after Thread Panel. Requires understanding how the SDK tracks per-thread unread counts.
|
||||||
**Complexity:** Medium (after thread panel exists).
|
**Complexity:** Medium (after thread panel exists).
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
|
|||||||
|
|
||||||
### Messaging
|
### Messaging
|
||||||
|
|
||||||
|
- Threads: reply in a thread and read/write the whole conversation in a side panel — root messages show a "N replies" chip with an unread badge (threaded replies live in the panel now, not inline in the room)
|
||||||
- See who has read each message, and track delivery status (sending / sent / failed)
|
- See who has read each message, and track delivery status (sending / sent / failed)
|
||||||
- Bookmark any message and revisit saved messages from the sidebar
|
- Bookmark any message and revisit saved messages from the sidebar
|
||||||
- Schedule messages to send at a specific time
|
- Schedule messages to send at a specific time
|
||||||
@@ -33,6 +34,8 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
|
|||||||
- Search for and send GIFs from a built-in GIF picker
|
- Search for and send GIFs from a built-in GIF picker
|
||||||
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
||||||
- Search messages with a date range filter
|
- Search messages with a date range filter
|
||||||
|
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
|
||||||
|
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
|
||||||
- Room topics support rich formatting (bold, links, italics)
|
- Room topics support rich formatting (bold, links, italics)
|
||||||
- Deleted messages show a placeholder instead of disappearing
|
- Deleted messages show a placeholder instead of disappearing
|
||||||
- Code blocks highlight syntax for JS/TS, Python, and Rust
|
- Code blocks highlight syntax for JS/TS, Python, and Rust
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type ReplyProps = {
|
|||||||
replyEventId: string;
|
replyEventId: string;
|
||||||
threadRootId?: string | undefined;
|
threadRootId?: string | undefined;
|
||||||
onClick?: MouseEventHandler | undefined;
|
onClick?: MouseEventHandler | undefined;
|
||||||
|
onThreadClick?: ((threadRootId: string) => void) | undefined;
|
||||||
getMemberPowerTag?: GetMemberPowerTag;
|
getMemberPowerTag?: GetMemberPowerTag;
|
||||||
accessibleTagColors?: Map<string, string>;
|
accessibleTagColors?: Map<string, string>;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
@@ -74,6 +75,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
replyEventId,
|
replyEventId,
|
||||||
threadRootId,
|
threadRootId,
|
||||||
onClick,
|
onClick,
|
||||||
|
onThreadClick,
|
||||||
getMemberPowerTag,
|
getMemberPowerTag,
|
||||||
accessibleTagColors,
|
accessibleTagColors,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
@@ -110,7 +112,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
<ThreadIndicator
|
<ThreadIndicator
|
||||||
as="button"
|
as="button"
|
||||||
data-event-id={threadRootId}
|
data-event-id={threadRootId}
|
||||||
onClick={onClick}
|
onClick={onThreadClick ? () => onThreadClick(threadRootId) : onClick}
|
||||||
aria-label="View thread"
|
aria-label="View thread"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { callChatAtom } from '../../state/callEmbed';
|
|||||||
import { CallChatView } from './CallChatView';
|
import { CallChatView } from './CallChatView';
|
||||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
||||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
|
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||||
|
import { ThreadPanel } from './thread';
|
||||||
|
|
||||||
export function Room() {
|
export function Room() {
|
||||||
const { eventId } = useParams();
|
const { eventId } = useParams();
|
||||||
@@ -33,6 +35,8 @@ export function Room() {
|
|||||||
const callEmbed = useCallEmbed();
|
const callEmbed = useCallEmbed();
|
||||||
|
|
||||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||||
|
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||||
const galleryOpen = useAtomValue(mediaGalleryAtom);
|
const galleryOpen = useAtomValue(mediaGalleryAtom);
|
||||||
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
|
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
@@ -90,6 +94,19 @@ export function Room() {
|
|||||||
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!callView && activeThreadId && (
|
||||||
|
<>
|
||||||
|
{screenSize === ScreenSize.Desktop && (
|
||||||
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
)}
|
||||||
|
<ThreadPanel
|
||||||
|
key={`${room.roomId}${activeThreadId}`}
|
||||||
|
room={room}
|
||||||
|
threadId={activeThreadId}
|
||||||
|
requestClose={() => setActiveThreadId(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!callView && isDrawer && (
|
{!callView && isDrawer && (
|
||||||
<>
|
<>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ import { ScheduleMessageModal } from './ScheduleMessageModal';
|
|||||||
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||||||
import { DraftIndicator } from './DraftIndicator';
|
import { DraftIndicator } from './DraftIndicator';
|
||||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||||
|
import { getThreadDraftKey } from '../../state/room/thread';
|
||||||
|
|
||||||
const GifPicker = React.lazy(() =>
|
const GifPicker = React.lazy(() =>
|
||||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||||
@@ -149,9 +150,10 @@ interface RoomInputProps {
|
|||||||
fileDropContainerRef: RefObject<HTMLElement>;
|
fileDropContainerRef: RefObject<HTMLElement>;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
room: Room;
|
room: Room;
|
||||||
|
threadRootId?: string;
|
||||||
}
|
}
|
||||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
({ editor, fileDropContainerRef, roomId, room }, ref) => {
|
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
@@ -184,8 +186,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
||||||
|
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
// Scope drafts/replies/uploads by thread so a thread composer stays fully
|
||||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
// isolated from the main room composer (and from other threads).
|
||||||
|
const draftKey = threadRootId ? getThreadDraftKey(roomId, threadRootId) : roomId;
|
||||||
|
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
|
||||||
|
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
|
||||||
const replyUserID = replyDraft?.userId;
|
const replyUserID = replyDraft?.userId;
|
||||||
|
|
||||||
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||||
@@ -206,7 +211,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
||||||
|
|
||||||
const [uploadBoard, setUploadBoard] = useState(true);
|
const [uploadBoard, setUploadBoard] = useState(true);
|
||||||
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
|
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey));
|
||||||
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
|
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
|
||||||
roomUploadAtomFamily,
|
roomUploadAtomFamily,
|
||||||
selectedFiles.map((f) => f.file),
|
selectedFiles.map((f) => f.file),
|
||||||
@@ -225,7 +230,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||||
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||||||
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
// Schedule-send is hidden in thread mode (v1 reduction).
|
||||||
|
const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
|
||||||
const composerButtonOrder = useMemo(
|
const composerButtonOrder = useMemo(
|
||||||
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||||
[composerToolbarButtons?.order],
|
[composerToolbarButtons?.order],
|
||||||
@@ -244,7 +250,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
setLocating(false);
|
setLocating(false);
|
||||||
const { latitude, longitude } = pos.coords;
|
const { latitude, longitude } = pos.coords;
|
||||||
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
|
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||||
msgtype: 'm.location',
|
msgtype: 'm.location',
|
||||||
body: `Location: ${geoUri}`,
|
body: `Location: ${geoUri}`,
|
||||||
geo_uri: geoUri,
|
geo_uri: geoUri,
|
||||||
@@ -263,7 +269,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
},
|
},
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
);
|
);
|
||||||
}, [mx, roomId]);
|
}, [mx, roomId, threadRootId]);
|
||||||
|
|
||||||
const handleVoiceSend = useCallback(
|
const handleVoiceSend = useCallback(
|
||||||
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
||||||
@@ -279,7 +285,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
if (room.hasEncryptionStateEvent()) {
|
if (room.hasEncryptionStateEvent()) {
|
||||||
const { encInfo, file: encBlob } = await encryptFile(blob);
|
const { encInfo, file: encBlob } = await encryptFile(blob);
|
||||||
const uploadResult = await mx.uploadContent(encBlob);
|
const uploadResult = await mx.uploadContent(encBlob);
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||||
...baseContent,
|
...baseContent,
|
||||||
file: { ...encInfo, url: uploadResult.content_uri },
|
file: { ...encInfo, url: uploadResult.content_uri },
|
||||||
} as any);
|
} as any);
|
||||||
@@ -288,13 +294,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
name: 'voice-message.ogg',
|
name: 'voice-message.ogg',
|
||||||
type: mimeType,
|
type: mimeType,
|
||||||
});
|
});
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||||
...baseContent,
|
...baseContent,
|
||||||
url: uploadResult.content_uri,
|
url: uploadResult.content_uri,
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, room, roomId],
|
[mx, room, roomId, threadRootId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [autocompleteQuery, setAutocompleteQuery] =
|
const [autocompleteQuery, setAutocompleteQuery] =
|
||||||
@@ -364,7 +370,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
} else {
|
} else {
|
||||||
// Jotai draft is empty (page reload) — try localStorage fallback
|
// Jotai draft is empty (page reload) — try localStorage fallback
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(`draft-msg-${roomId}`);
|
const stored = localStorage.getItem(`draft-msg-${draftKey}`);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const nodes = JSON.parse(stored);
|
const nodes = JSON.parse(stored);
|
||||||
if (Array.isArray(nodes) && nodes.length > 0) {
|
if (Array.isArray(nodes) && nodes.length > 0) {
|
||||||
@@ -379,22 +385,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
// Ignore malformed stored draft
|
// Ignore malformed stored draft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editor, msgDraft, roomId, setMsgDraft]);
|
}, [editor, msgDraft, draftKey, setMsgDraft]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (!isEmptyEditor(editor)) {
|
if (!isEmptyEditor(editor)) {
|
||||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||||
setMsgDraft(parsedDraft);
|
setMsgDraft(parsedDraft);
|
||||||
localStorage.setItem(`draft-msg-${roomId}`, JSON.stringify(parsedDraft));
|
localStorage.setItem(`draft-msg-${draftKey}`, JSON.stringify(parsedDraft));
|
||||||
} else {
|
} else {
|
||||||
setMsgDraft([]);
|
setMsgDraft([]);
|
||||||
localStorage.removeItem(`draft-msg-${roomId}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
}
|
}
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
},
|
},
|
||||||
[roomId, editor, setMsgDraft],
|
[draftKey, editor, setMsgDraft],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileMetadata = useCallback(
|
const handleFileMetadata = useCallback(
|
||||||
@@ -487,15 +493,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
});
|
});
|
||||||
handleCancelUpload(uploads);
|
handleCancelUpload(uploads);
|
||||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||||
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
contents.forEach((content) => mx.sendMessage(roomId, threadRootId ?? null, content as any));
|
||||||
},
|
},
|
||||||
[mx, roomId, selectedFiles, handleCancelUpload],
|
[mx, roomId, threadRootId, selectedFiles, handleCancelUpload],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
uploadBoardHandlers.current?.handleSend();
|
uploadBoardHandlers.current?.handleSend();
|
||||||
|
|
||||||
const commandName = getBeginCommand(editor);
|
// Slash-command interpretation is disabled in thread mode (v1): "/foo"
|
||||||
|
// sends literally rather than being parsed as a command.
|
||||||
|
const commandName = threadRootId ? undefined : getBeginCommand(editor);
|
||||||
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||||
let customHtml = trimCustomHtml(
|
let customHtml = trimCustomHtml(
|
||||||
toMatrixCustomHTML(editor.children, {
|
toMatrixCustomHTML(editor.children, {
|
||||||
@@ -568,13 +576,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
content['m.relates_to'].is_falling_back = false;
|
content['m.relates_to'].is_falling_back = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mx.sendMessage(roomId, content as any);
|
mx.sendMessage(roomId, threadRootId ?? null, content as any);
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
localStorage.removeItem(`draft-msg-${roomId}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
}, [
|
||||||
|
mx,
|
||||||
|
roomId,
|
||||||
|
threadRootId,
|
||||||
|
draftKey,
|
||||||
|
editor,
|
||||||
|
replyDraft,
|
||||||
|
sendTypingStatus,
|
||||||
|
setReplyDraft,
|
||||||
|
isMarkdown,
|
||||||
|
commands,
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a text message content object from the current editor state.
|
* Build a text message content object from the current editor state.
|
||||||
@@ -643,11 +662,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
});
|
});
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
localStorage.removeItem(`draft-msg-${roomId}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
},
|
},
|
||||||
[setScheduledMessages, roomId, editor, setReplyDraft, sendTypingStatus],
|
[setScheduledMessages, roomId, draftKey, editor, setReplyDraft, sendTypingStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
@@ -742,7 +761,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
);
|
);
|
||||||
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
||||||
if (!mxcUrl) return;
|
if (!mxcUrl) return;
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||||
msgtype: MsgType.Image,
|
msgtype: MsgType.Image,
|
||||||
body: 'image.gif',
|
body: 'image.gif',
|
||||||
url: mxcUrl,
|
url: mxcUrl,
|
||||||
@@ -757,7 +776,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
if (alive()) setGifUploading(false);
|
if (alive()) setGifUploading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, roomId, alive],
|
[mx, roomId, threadRootId, alive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStickerSelect = useCallback(
|
const handleStickerSelect = useCallback(
|
||||||
@@ -770,13 +789,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
await getImageUrlBlob(stickerUrl),
|
await getImageUrlBlob(stickerUrl),
|
||||||
);
|
);
|
||||||
|
|
||||||
mx.sendEvent(roomId, EventType.Sticker, {
|
mx.sendEvent(roomId, threadRootId ?? null, EventType.Sticker, {
|
||||||
body: label,
|
body: label,
|
||||||
url: mxc,
|
url: mxc,
|
||||||
info,
|
info,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[mx, roomId, useAuthentication],
|
[mx, roomId, threadRootId, useAuthentication],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (room.getType() === 'm.server_notice') {
|
if (room.getType() === 'm.server_notice') {
|
||||||
@@ -1258,7 +1277,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
{locationError}
|
{locationError}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<DraftIndicator roomId={roomId} />
|
<DraftIndicator roomId={draftKey} />
|
||||||
{charCount > 0 && (
|
{charCount > 0 && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import {
|
|||||||
IContent,
|
IContent,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
|
RelationType,
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
RoomEventHandlerMap,
|
RoomEventHandlerMap,
|
||||||
|
ThreadEvent,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -103,6 +105,8 @@ import * as css from './RoomTimeline.css';
|
|||||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
||||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
|
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||||
|
import { ThreadSummary } from './thread/ThreadSummary';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
@@ -245,13 +249,26 @@ const useEventTimelineLoader = (
|
|||||||
room: Room,
|
room: Room,
|
||||||
onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
|
onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
|
||||||
onError: (err: Error | null) => void,
|
onError: (err: Error | null) => void,
|
||||||
|
onThreadRedirect: (threadRootId: string) => void,
|
||||||
) => {
|
) => {
|
||||||
const loadEventTimeline = useCallback(
|
const loadEventTimeline = useCallback(
|
||||||
async (eventId: string) => {
|
async (eventId: string) => {
|
||||||
const [err, replyEvtTimeline] = await to(
|
const [err, replyEvtTimeline] = await to(
|
||||||
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId),
|
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId),
|
||||||
);
|
);
|
||||||
|
// Thread events aren't locatable in the main timeline set (getEventTimeline
|
||||||
|
// returns undefined / no abs index). Best-effort: redirect to the thread panel
|
||||||
|
// when the fetched event belongs to a thread instead of surfacing an error.
|
||||||
|
const redirectToThread = () => {
|
||||||
|
const threadRootId = room.findEventById(eventId)?.threadRootId;
|
||||||
|
if (threadRootId) {
|
||||||
|
onThreadRedirect(threadRootId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
if (!replyEvtTimeline) {
|
if (!replyEvtTimeline) {
|
||||||
|
if (redirectToThread()) return;
|
||||||
onError(err ?? null);
|
onError(err ?? null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -259,13 +276,14 @@ const useEventTimelineLoader = (
|
|||||||
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
|
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
|
||||||
|
|
||||||
if (absIndex === undefined) {
|
if (absIndex === undefined) {
|
||||||
|
if (redirectToThread()) return;
|
||||||
onError(err ?? null);
|
onError(err ?? null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad(eventId, linkedTimelines, absIndex);
|
onLoad(eventId, linkedTimelines, absIndex);
|
||||||
},
|
},
|
||||||
[mx, room, onLoad, onError],
|
[mx, room, onLoad, onError, onThreadRedirect],
|
||||||
);
|
);
|
||||||
|
|
||||||
return loadEventTimeline;
|
return loadEventTimeline;
|
||||||
@@ -460,6 +478,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||||
|
|
||||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||||
|
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||||
|
// Thread summary chips only mount for events that already carry thread data
|
||||||
|
// (perf: a chip subscribes room-level listeners, so mounting one per rendered
|
||||||
|
// message would exceed the SDK's emitter cap). This single room-level
|
||||||
|
// ThreadEvent.New subscription re-renders the timeline once when a brand-new
|
||||||
|
// thread appears, so the root's chip shows up without unrelated activity.
|
||||||
|
const [, setThreadNewTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const handleThreadNew = () => setThreadNewTick((c) => c + 1);
|
||||||
|
room.on(ThreadEvent.New, handleThreadNew);
|
||||||
|
return () => {
|
||||||
|
room.removeListener(ThreadEvent.New, handleThreadNew);
|
||||||
|
};
|
||||||
|
}, [room]);
|
||||||
const powerLevels = usePowerLevelsContext();
|
const powerLevels = usePowerLevelsContext();
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
@@ -622,6 +654,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
}, [alive, room]),
|
}, [alive, room]),
|
||||||
|
useCallback(
|
||||||
|
(threadRootId: string) => {
|
||||||
|
if (!alive()) return;
|
||||||
|
setActiveThreadId(threadRootId);
|
||||||
|
},
|
||||||
|
[alive, setActiveThreadId],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
useLiveEventArrive(
|
useLiveEventArrive(
|
||||||
@@ -982,14 +1021,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
console.warn('Button should have "data-event-id" attribute!');
|
console.warn('Button should have "data-event-id" attribute!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (startThread) {
|
||||||
|
// Open the thread panel instead of arming an m.thread reply in the main composer.
|
||||||
|
setActiveThreadId(replyId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const replyEvt = room.findEventById(replyId);
|
const replyEvt = room.findEventById(replyId);
|
||||||
if (!replyEvt) return;
|
if (!replyEvt) return;
|
||||||
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
||||||
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||||
const { body, formatted_body: formattedBody } = content;
|
const { body, formatted_body: formattedBody } = content;
|
||||||
const { 'm.relates_to': relation } = startThread
|
const { 'm.relates_to': relation } = replyEvt.getWireContent();
|
||||||
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
|
|
||||||
: replyEvt.getWireContent();
|
|
||||||
const senderId = replyEvt.getSender();
|
const senderId = replyEvt.getSender();
|
||||||
if (senderId && typeof body === 'string') {
|
if (senderId && typeof body === 'string') {
|
||||||
setReplyDraft({
|
setReplyDraft({
|
||||||
@@ -1002,7 +1044,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
setTimeout(() => ReactEditor.focus(editor), 100);
|
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[room, setReplyDraft, editor],
|
[room, setReplyDraft, setActiveThreadId, editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReactionToggle = useCallback(
|
const handleReactionToggle = useCallback(
|
||||||
@@ -1090,6 +1132,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
replyEventId={replyEventId}
|
replyEventId={replyEventId}
|
||||||
threadRootId={threadRootId}
|
threadRootId={threadRootId}
|
||||||
onClick={handleOpenReply}
|
onClick={handleOpenReply}
|
||||||
|
onThreadClick={setActiveThreadId}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
@@ -1097,16 +1140,23 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
reactions={
|
reactions={
|
||||||
reactionRelations && (
|
<>
|
||||||
<Reactions
|
{reactionRelations && (
|
||||||
style={{ marginTop: config.space.S200 }}
|
<Reactions
|
||||||
room={room}
|
style={{ marginTop: config.space.S200 }}
|
||||||
relations={reactionRelations}
|
room={room}
|
||||||
mEventId={mEventId}
|
relations={reactionRelations}
|
||||||
canSendReaction={canSendReaction}
|
mEventId={mEventId}
|
||||||
onReactionToggle={handleReactionToggle}
|
canSendReaction={canSendReaction}
|
||||||
/>
|
onReactionToggle={handleReactionToggle}
|
||||||
)
|
/>
|
||||||
|
)}
|
||||||
|
{(!threadRootId || threadRootId === mEventId) &&
|
||||||
|
(mEvent.getThread() !== undefined ||
|
||||||
|
mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && (
|
||||||
|
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
@@ -1175,6 +1225,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
replyEventId={replyEventId}
|
replyEventId={replyEventId}
|
||||||
threadRootId={threadRootId}
|
threadRootId={threadRootId}
|
||||||
onClick={handleOpenReply}
|
onClick={handleOpenReply}
|
||||||
|
onThreadClick={setActiveThreadId}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
@@ -1182,16 +1233,23 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
reactions={
|
reactions={
|
||||||
reactionRelations && (
|
<>
|
||||||
<Reactions
|
{reactionRelations && (
|
||||||
style={{ marginTop: config.space.S200 }}
|
<Reactions
|
||||||
room={room}
|
style={{ marginTop: config.space.S200 }}
|
||||||
relations={reactionRelations}
|
room={room}
|
||||||
mEventId={mEventId}
|
relations={reactionRelations}
|
||||||
canSendReaction={canSendReaction}
|
mEventId={mEventId}
|
||||||
onReactionToggle={handleReactionToggle}
|
canSendReaction={canSendReaction}
|
||||||
/>
|
onReactionToggle={handleReactionToggle}
|
||||||
)
|
/>
|
||||||
|
)}
|
||||||
|
{(!threadRootId || threadRootId === mEventId) &&
|
||||||
|
(mEvent.getThread() !== undefined ||
|
||||||
|
mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && (
|
||||||
|
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const ThreadPanel = style({
|
||||||
|
width: toRem(360),
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadPanelHeader = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadPanelContent = style({
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadPanelInput = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
borderTopWidth: config.borderWidth.B300,
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
} from 'folds';
|
||||||
|
import { Room, RoomEvent, ThreadEvent } from 'matrix-js-sdk';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './ThreadPanel.css';
|
||||||
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||||
|
import { ThreadTimeline } from './ThreadTimeline';
|
||||||
|
import { markThreadAsRead, useThreadInstance } from './useThread';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useEditor } from '../../../components/editor';
|
||||||
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { RoomInput } from '../RoomInput';
|
||||||
|
|
||||||
|
type ThreadPanelHeaderProps = {
|
||||||
|
room: Room;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
function ThreadPanelHeader({ room, requestClose }: ThreadPanelHeaderProps) {
|
||||||
|
return (
|
||||||
|
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Text size="H5" truncate>
|
||||||
|
Thread
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||||
|
{room.name}
|
||||||
|
</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 thread"
|
||||||
|
onClick={requestClose}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThreadPanelProps = {
|
||||||
|
room: Room;
|
||||||
|
threadId: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const editor = useEditor();
|
||||||
|
const thread = useThreadInstance(room, threadId);
|
||||||
|
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
||||||
|
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||||
|
|
||||||
|
useKeyDown(
|
||||||
|
window,
|
||||||
|
useCallback(
|
||||||
|
(evt) => {
|
||||||
|
if (isKeyHotkey('escape', evt)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[requestClose],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark the thread read when the panel is open and on each new thread event.
|
||||||
|
// Deduped on the latest event id: RoomEvent.Timeline re-emits per event during
|
||||||
|
// backfill and for every edit/reaction, and sendReadReceipt POSTs
|
||||||
|
// unconditionally — without the guard, opening a thread with N replies would
|
||||||
|
// fire up to N receipt requests at the same event.
|
||||||
|
const lastReadEventIdRef = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
lastReadEventIdRef.current = undefined;
|
||||||
|
if (!thread) return undefined;
|
||||||
|
const markRead = () => {
|
||||||
|
const events = thread.liveTimeline.getEvents();
|
||||||
|
let latestId: string | undefined;
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const evt = events[i];
|
||||||
|
if (evt && !evt.isSending()) {
|
||||||
|
latestId = evt.getId() ?? undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!latestId || latestId === lastReadEventIdRef.current) return;
|
||||||
|
lastReadEventIdRef.current = latestId;
|
||||||
|
markThreadAsRead(mx, thread, privateReadReceipts).catch(() => {
|
||||||
|
// Allow a retry on the next event if the receipt POST failed.
|
||||||
|
if (lastReadEventIdRef.current === latestId) {
|
||||||
|
lastReadEventIdRef.current = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
markRead();
|
||||||
|
thread.on(ThreadEvent.NewReply, markRead);
|
||||||
|
thread.on(RoomEvent.Timeline, markRead);
|
||||||
|
return () => {
|
||||||
|
thread.off(ThreadEvent.NewReply, markRead);
|
||||||
|
thread.off(RoomEvent.Timeline, markRead);
|
||||||
|
};
|
||||||
|
}, [mx, thread, privateReadReceipts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={classNames(css.ThreadPanel, ContainerColor({ variant: 'Background' }))}
|
||||||
|
shrink="No"
|
||||||
|
direction="Column"
|
||||||
|
>
|
||||||
|
<ThreadPanelHeader room={room} requestClose={requestClose} />
|
||||||
|
{!thread ? (
|
||||||
|
<Box grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||||
|
<Spinner size="400" variant="Secondary" />
|
||||||
|
<Text size="T300">Loading thread…</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box grow="Yes" className={css.ThreadPanelContent} direction="Column">
|
||||||
|
<ThreadTimeline room={room} thread={thread} editor={editor} />
|
||||||
|
</Box>
|
||||||
|
<Box className={css.ThreadPanelInput} shrink="No" direction="Column">
|
||||||
|
<RoomInput
|
||||||
|
room={room}
|
||||||
|
roomId={room.roomId}
|
||||||
|
threadRootId={threadId}
|
||||||
|
editor={editor}
|
||||||
|
fileDropContainerRef={fileDropContainerRef}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
|
||||||
|
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||||
|
import { useThreadSummary } from '../../../hooks/useThreadSummary';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time';
|
||||||
|
|
||||||
|
type ThreadSummaryProps = {
|
||||||
|
rootEvent: MatrixEvent;
|
||||||
|
room: Room;
|
||||||
|
onOpen: (threadId: string) => void;
|
||||||
|
};
|
||||||
|
export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
|
||||||
|
const { summary, unread } = useThreadSummary(rootEvent, room);
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
|
if (!summary || summary.count === 0) return null;
|
||||||
|
|
||||||
|
const { count, latestTs } = summary;
|
||||||
|
const latestStr =
|
||||||
|
latestTs !== undefined
|
||||||
|
? today(latestTs)
|
||||||
|
? timeHourMinute(latestTs, hour24Clock)
|
||||||
|
: timeDayMonthYear(latestTs)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ marginTop: config.space.S200 }}>
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="300"
|
||||||
|
before={<Icon size="50" src={Icons.Thread} />}
|
||||||
|
after={
|
||||||
|
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const threadId = rootEvent.getId();
|
||||||
|
if (threadId) onOpen(threadId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="T200">
|
||||||
|
{count === 1 ? '1 reply' : `${count} replies`}
|
||||||
|
{latestStr ? ` · ${latestStr}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config } from 'folds';
|
||||||
|
|
||||||
|
export const ThreadTimeline = style({
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadTimelineContent = style({
|
||||||
|
minHeight: '100%',
|
||||||
|
padding: `${config.space.S400} 0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThreadCentered = style({
|
||||||
|
height: '100%',
|
||||||
|
padding: config.space.S700,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RootMessage = style({
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
marginBottom: config.space.S100,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RepliesDivider = style({
|
||||||
|
padding: `${config.space.S200} ${config.space.S400}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NoReplies = style({
|
||||||
|
padding: config.space.S400,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PendingMessage = style({
|
||||||
|
opacity: 0.6,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PendingFailed = style({
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
@@ -0,0 +1,961 @@
|
|||||||
|
import React, {
|
||||||
|
Dispatch,
|
||||||
|
MouseEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
Direction,
|
||||||
|
EventStatus,
|
||||||
|
EventTimeline,
|
||||||
|
EventTimelineSet,
|
||||||
|
EventTimelineSetHandlerMap,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
RelationType,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
Thread,
|
||||||
|
ThreadEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
|
import { Editor } from 'slate';
|
||||||
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import to from 'await-to-js';
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { Badge, Box, Line, Scroll, Spinner, Text, color, config } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
|
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
import { scrollToBottom } from '../../../utils/dom';
|
||||||
|
import {
|
||||||
|
DefaultPlaceholder,
|
||||||
|
MessageBase,
|
||||||
|
Reply,
|
||||||
|
RedactedContent,
|
||||||
|
MSticker,
|
||||||
|
MessageUnsupportedContent,
|
||||||
|
MessageNotDecryptedContent,
|
||||||
|
ImageContent,
|
||||||
|
} from '../../../components/message';
|
||||||
|
import {
|
||||||
|
factoryRenderLinkifyWithMention,
|
||||||
|
getReactCustomHtmlParser,
|
||||||
|
LINKIFY_OPTS,
|
||||||
|
makeMentionCustomProps,
|
||||||
|
renderMatrixMention,
|
||||||
|
} from '../../../plugins/react-custom-html-parser';
|
||||||
|
import {
|
||||||
|
decryptAllTimelineEvent,
|
||||||
|
getEditedEvent,
|
||||||
|
getEventReactions,
|
||||||
|
getMemberDisplayName,
|
||||||
|
getReactionContent,
|
||||||
|
reactionOrEditEvent,
|
||||||
|
} from '../../../utils/room';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { MessageLayout, settingsAtom } from '../../../state/settings';
|
||||||
|
import { Message, Reactions, EncryptedContent } from '../message';
|
||||||
|
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
||||||
|
import { Image } from '../../../components/media';
|
||||||
|
import { ImageViewer } from '../../../components/image-viewer';
|
||||||
|
import * as css from './ThreadTimeline.css';
|
||||||
|
import {
|
||||||
|
inSameDay,
|
||||||
|
minuteDifference,
|
||||||
|
timeDayMonthYear,
|
||||||
|
today,
|
||||||
|
yesterday,
|
||||||
|
} from '../../../utils/time';
|
||||||
|
import { createMentionElement, moveCursor } from '../../../components/editor';
|
||||||
|
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
|
||||||
|
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
|
||||||
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import {
|
||||||
|
getIntersectionObserverEntry,
|
||||||
|
useIntersectionObserver,
|
||||||
|
} from '../../../hooks/useIntersectionObserver';
|
||||||
|
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
|
||||||
|
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
||||||
|
import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
|
||||||
|
import { useIsDirectRoom } from '../../../hooks/useRoom';
|
||||||
|
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
|
||||||
|
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||||
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
|
import {
|
||||||
|
useAccessiblePowerTagColors,
|
||||||
|
useGetMemberPowerTag,
|
||||||
|
} from '../../../hooks/useMemberPowerTag';
|
||||||
|
import { useTheme } from '../../../hooks/useTheme';
|
||||||
|
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||||
|
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
|
||||||
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
|
import { EditHistoryModal } from '../message/EditHistoryModal';
|
||||||
|
import {
|
||||||
|
getLinkedTimelines,
|
||||||
|
getTimelineAndBaseIndex,
|
||||||
|
getTimelineEvent,
|
||||||
|
getTimelineRelativeIndex,
|
||||||
|
getTimelinesEventsCount,
|
||||||
|
timelineToEventsCount,
|
||||||
|
} from '../RoomTimeline';
|
||||||
|
import { getThreadDraftKey } from '../../../state/room/thread';
|
||||||
|
import { useThreadLinkedTimelines, useThreadPendingEvents } from './useThread';
|
||||||
|
|
||||||
|
// Virtual window size (how many items render around the viewport).
|
||||||
|
const PAGINATION_LIMIT = 50;
|
||||||
|
// Network page size for backward /relations pagination of the thread timeline.
|
||||||
|
const THREAD_PAGE_LIMIT = 30;
|
||||||
|
|
||||||
|
type Timeline = {
|
||||||
|
linkedTimelines: EventTimeline[];
|
||||||
|
range: ItemRange;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmptyTimeline = (): Timeline => ({
|
||||||
|
linkedTimelines: [],
|
||||||
|
range: { start: 0, end: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getInitialThreadTimeline = (thread: Thread, timelines?: EventTimeline[]): Timeline => {
|
||||||
|
const linkedTimelines =
|
||||||
|
timelines && timelines.length > 0 ? timelines : getLinkedTimelines(thread.liveTimeline);
|
||||||
|
const evLength = getTimelinesEventsCount(linkedTimelines);
|
||||||
|
return {
|
||||||
|
linkedTimelines,
|
||||||
|
range: {
|
||||||
|
start: Math.max(evLength - PAGINATION_LIMIT, 0),
|
||||||
|
end: evLength,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy of RoomTimeline's `useTimelinePagination` pattern (not exported from RoomTimeline
|
||||||
|
* as its ~35 hooks are hardwired to the room live timeline). Works transparently against
|
||||||
|
* the thread timeline's /relations pagination.
|
||||||
|
*/
|
||||||
|
const useThreadTimelinePagination = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
timeline: Timeline,
|
||||||
|
setTimeline: Dispatch<SetStateAction<Timeline>>,
|
||||||
|
limit: number,
|
||||||
|
) => {
|
||||||
|
const timelineRef = useRef(timeline);
|
||||||
|
timelineRef.current = timeline;
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const handleTimelinePagination = useMemo(() => {
|
||||||
|
let fetching = false;
|
||||||
|
|
||||||
|
const recalibratePagination = (
|
||||||
|
linkedTimelines: EventTimeline[],
|
||||||
|
timelinesEventsCount: number[],
|
||||||
|
backwards: boolean,
|
||||||
|
) => {
|
||||||
|
const topTimeline = linkedTimelines[0];
|
||||||
|
const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
|
||||||
|
|
||||||
|
const newLTimelines = getLinkedTimelines(topTimeline);
|
||||||
|
const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
|
||||||
|
const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
|
||||||
|
|
||||||
|
const topTmAddedEvt =
|
||||||
|
timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
|
||||||
|
const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
|
||||||
|
|
||||||
|
setTimeline((currentTimeline) => ({
|
||||||
|
linkedTimelines: newLTimelines,
|
||||||
|
range:
|
||||||
|
offsetRange > 0
|
||||||
|
? {
|
||||||
|
start: currentTimeline.range.start + offsetRange,
|
||||||
|
end: currentTimeline.range.end + offsetRange,
|
||||||
|
}
|
||||||
|
: { ...currentTimeline.range },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return async (backwards: boolean) => {
|
||||||
|
if (fetching) return;
|
||||||
|
const { linkedTimelines: lTimelines } = timelineRef.current;
|
||||||
|
const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
|
||||||
|
|
||||||
|
const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
|
||||||
|
if (!timelineToPaginate) return;
|
||||||
|
|
||||||
|
const paginationToken = timelineToPaginate.getPaginationToken(
|
||||||
|
backwards ? Direction.Backward : Direction.Forward,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!paginationToken &&
|
||||||
|
getTimelinesEventsCount(lTimelines) !==
|
||||||
|
getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
|
||||||
|
) {
|
||||||
|
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetching = true;
|
||||||
|
const [err] = await to(
|
||||||
|
mx.paginateEventTimeline(timelineToPaginate, {
|
||||||
|
backwards,
|
||||||
|
limit,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (err) {
|
||||||
|
fetching = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fetchedTimeline =
|
||||||
|
timelineToPaginate.getNeighbouringTimeline(
|
||||||
|
backwards ? Direction.Backward : Direction.Forward,
|
||||||
|
) ?? timelineToPaginate;
|
||||||
|
// Decrypt all event ahead of render cycle
|
||||||
|
const roomId = fetchedTimeline.getRoomId();
|
||||||
|
const room = roomId ? mx.getRoom(roomId) : null;
|
||||||
|
|
||||||
|
if (room?.hasEncryptionStateEvent()) {
|
||||||
|
await to(decryptAllTimelineEvent(mx, fetchedTimeline));
|
||||||
|
}
|
||||||
|
|
||||||
|
fetching = false;
|
||||||
|
if (alive()) {
|
||||||
|
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [mx, alive, setTimeline, limit]);
|
||||||
|
return handleTimelinePagination;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThreadTimelineProps = {
|
||||||
|
room: Room;
|
||||||
|
thread: Thread;
|
||||||
|
editor: Editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const alive = useAlive();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||||
|
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||||
|
const [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
|
||||||
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
|
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||||
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
|
const direct = useIsDirectRoom();
|
||||||
|
const ignoredUsersList = useIgnoredUsers();
|
||||||
|
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||||
|
|
||||||
|
const setReplyDraft = useSetAtom(
|
||||||
|
roomIdToReplyDraftAtomFamily(getThreadDraftKey(room.roomId, thread.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const powerLevels = usePowerLevelsContext();
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
const creatorsTag = useRoomCreatorsTag();
|
||||||
|
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||||
|
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const accessiblePowerTagColors = useAccessiblePowerTagColors(
|
||||||
|
theme.kind,
|
||||||
|
creatorsTag,
|
||||||
|
powerLevelTags,
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const canRedact = permissions.action('redact', mx.getSafeUserId());
|
||||||
|
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
|
||||||
|
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
||||||
|
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
||||||
|
const spoilerClickHandler = useSpoilerClickHandler();
|
||||||
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
|
||||||
|
const [editId, setEditId] = useState<string>();
|
||||||
|
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
|
||||||
|
|
||||||
|
const linkifyOpts = useMemo<LinkifyOpts>(
|
||||||
|
() => ({
|
||||||
|
...LINKIFY_OPTS,
|
||||||
|
render: factoryRenderLinkifyWithMention((href) =>
|
||||||
|
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[mx, room, mentionClickHandler],
|
||||||
|
);
|
||||||
|
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
|
||||||
|
() =>
|
||||||
|
getReactCustomHtmlParser(mx, room.roomId, {
|
||||||
|
linkifyOpts,
|
||||||
|
useAuthentication,
|
||||||
|
handleSpoilerClick: spoilerClickHandler,
|
||||||
|
handleMentionClick: mentionClickHandler,
|
||||||
|
}),
|
||||||
|
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { timelines, ready } = useThreadLinkedTimelines(mx, thread);
|
||||||
|
const pendingEvents = useThreadPendingEvents(room, thread.id, thread);
|
||||||
|
|
||||||
|
const [timeline, setTimeline] = useState<Timeline>(() =>
|
||||||
|
ready ? getInitialThreadTimeline(thread, timelines) : getEmptyTimeline(),
|
||||||
|
);
|
||||||
|
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
|
||||||
|
|
||||||
|
const canPaginateBack =
|
||||||
|
typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
|
||||||
|
const rangeAtStart = timeline.range.start === 0;
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const atBottomAnchorRef = useRef<HTMLElement>(null);
|
||||||
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
|
const atBottomRef = useRef(atBottom);
|
||||||
|
atBottomRef.current = atBottom;
|
||||||
|
const scrollToBottomRef = useRef({ count: 0, smooth: true });
|
||||||
|
|
||||||
|
const handleTimelinePagination = useThreadTimelinePagination(
|
||||||
|
mx,
|
||||||
|
timeline,
|
||||||
|
setTimeline,
|
||||||
|
THREAD_PAGE_LIMIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const getScrollElement = useCallback(() => scrollRef.current, []);
|
||||||
|
|
||||||
|
const { getItems, scrollToItem, observeBackAnchor } = useVirtualPaginator({
|
||||||
|
count: eventsLength,
|
||||||
|
limit: PAGINATION_LIMIT,
|
||||||
|
range: timeline.range,
|
||||||
|
onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
|
||||||
|
getScrollElement,
|
||||||
|
getItemElement: useCallback(
|
||||||
|
(index: number) =>
|
||||||
|
(scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
|
||||||
|
undefined,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
onEnd: handleTimelinePagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed local timeline once the thread has fetched its initial events.
|
||||||
|
const seededRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || seededRef.current) return;
|
||||||
|
seededRef.current = true;
|
||||||
|
setTimeline(getInitialThreadTimeline(thread, timelines));
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
if (room.hasEncryptionStateEvent()) {
|
||||||
|
to(decryptAllTimelineEvent(mx, thread.liveTimeline)).then(() => {
|
||||||
|
if (alive()) setTimeline((ct) => ({ ...ct }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once when ready flips
|
||||||
|
}, [ready, thread]);
|
||||||
|
|
||||||
|
// Re-render / stick-to-bottom on live thread activity.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
||||||
|
mEvent,
|
||||||
|
eventRoom,
|
||||||
|
toStartOfTimeline,
|
||||||
|
removed,
|
||||||
|
data,
|
||||||
|
) => {
|
||||||
|
if (!data?.liveEvent) return;
|
||||||
|
if (atBottomRef.current) {
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = true;
|
||||||
|
setTimeline((ct) => ({
|
||||||
|
...ct,
|
||||||
|
range: {
|
||||||
|
start: ct.range.start + 1,
|
||||||
|
end: ct.range.end + 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeline((ct) => ({ ...ct }));
|
||||||
|
};
|
||||||
|
const handleUpdate = () => setTimeline((ct) => ({ ...ct }));
|
||||||
|
// A gappy sync / updateThreadMetadata resets the thread's live timeline —
|
||||||
|
// the stored linkedTimelines would then point at a detached timeline, so
|
||||||
|
// reseed the window from the fresh liveTimeline.
|
||||||
|
const handleReset = () => {
|
||||||
|
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
thread.on(RoomEvent.Timeline, handleTimeline);
|
||||||
|
thread.on(ThreadEvent.Update, handleUpdate);
|
||||||
|
thread.on(RoomEvent.TimelineReset, handleReset);
|
||||||
|
return () => {
|
||||||
|
thread.removeListener(RoomEvent.Timeline, handleTimeline);
|
||||||
|
thread.removeListener(ThreadEvent.Update, handleUpdate);
|
||||||
|
thread.removeListener(RoomEvent.TimelineReset, handleReset);
|
||||||
|
};
|
||||||
|
}, [thread]);
|
||||||
|
|
||||||
|
// atBottom detection
|
||||||
|
useIntersectionObserver(
|
||||||
|
useCallback((entries) => {
|
||||||
|
const target = atBottomAnchorRef.current;
|
||||||
|
if (!target) return;
|
||||||
|
const entry = getIntersectionObserverEntry(target, entries);
|
||||||
|
if (entry) setAtBottom(entry.isIntersecting);
|
||||||
|
}, []),
|
||||||
|
useCallback(
|
||||||
|
() => ({
|
||||||
|
root: getScrollElement(),
|
||||||
|
rootMargin: '100px',
|
||||||
|
}),
|
||||||
|
[getScrollElement],
|
||||||
|
),
|
||||||
|
useCallback(() => atBottomAnchorRef.current, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial scroll to bottom on mount.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl) scrollToBottom(scrollEl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scroll to bottom when requested.
|
||||||
|
const scrollToBottomCount = scrollToBottomRef.current.count;
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (scrollToBottomCount > 0) {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl)
|
||||||
|
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
|
||||||
|
}
|
||||||
|
}, [scrollToBottomCount]);
|
||||||
|
|
||||||
|
// Scroll in-place editor into view.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editId) {
|
||||||
|
const editMsgElement =
|
||||||
|
(scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
|
||||||
|
undefined;
|
||||||
|
editMsgElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [editId]);
|
||||||
|
|
||||||
|
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
const userId = evt.currentTarget.getAttribute('data-user-id');
|
||||||
|
if (!userId) return;
|
||||||
|
openUserRoomProfile(
|
||||||
|
room.roomId,
|
||||||
|
space?.roomId,
|
||||||
|
userId,
|
||||||
|
evt.currentTarget.getBoundingClientRect(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[room, space, openUserRoomProfile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const userId = evt.currentTarget.getAttribute('data-user-id');
|
||||||
|
if (!userId) return;
|
||||||
|
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
|
editor.insertNode(
|
||||||
|
createMentionElement(
|
||||||
|
userId,
|
||||||
|
name.startsWith('@') ? name : `@${name}`,
|
||||||
|
userId === mx.getUserId(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
moveCursor(editor);
|
||||||
|
},
|
||||||
|
[mx, room, editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
const replyId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
|
if (!replyId) return;
|
||||||
|
const replyEvt = thread.findEventById(replyId) ?? room.findEventById(replyId);
|
||||||
|
if (!replyEvt) return;
|
||||||
|
const editedReply = getEditedEvent(replyId, replyEvt, thread.getUnfilteredTimelineSet());
|
||||||
|
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||||
|
const { body, formatted_body: formattedBody } = content;
|
||||||
|
const senderId = replyEvt.getSender();
|
||||||
|
if (senderId && typeof body === 'string') {
|
||||||
|
setReplyDraft({
|
||||||
|
userId: senderId,
|
||||||
|
eventId: replyId,
|
||||||
|
body,
|
||||||
|
formattedBody,
|
||||||
|
relation: { rel_type: RelationType.Thread, event_id: thread.id },
|
||||||
|
});
|
||||||
|
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[room, thread, setReplyDraft, editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReactionToggle = useCallback(
|
||||||
|
(targetEventId: string, key: string, shortcode?: string) => {
|
||||||
|
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||||
|
const relations = getEventReactions(timelineSet, targetEventId);
|
||||||
|
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
|
||||||
|
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
|
||||||
|
const reactions = reactionsSet ? Array.from(reactionsSet) : [];
|
||||||
|
const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
|
||||||
|
|
||||||
|
if (myReaction && !!myReaction.isRelation()) {
|
||||||
|
mx.redactEvent(room.roomId, myReaction.getId()!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rShortcode =
|
||||||
|
shortcode ||
|
||||||
|
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||||
|
mx.sendEvent(
|
||||||
|
room.roomId,
|
||||||
|
thread.id,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
MessageEvent.Reaction as any,
|
||||||
|
getReactionContent(targetEventId, key, rShortcode),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx, room, thread],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(editEvtId?: string) => {
|
||||||
|
if (editEvtId) {
|
||||||
|
setEditId(editEvtId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditId(undefined);
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenReply: MouseEventHandler = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
|
if (!targetId) return;
|
||||||
|
// best-effort: scroll to referenced event if it is inside the loaded thread window
|
||||||
|
let absIndex = -1;
|
||||||
|
let acc = 0;
|
||||||
|
timeline.linkedTimelines.some((tl) => {
|
||||||
|
const idx = tl.getEvents().findIndex((e) => e.getId() === targetId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
absIndex = acc + idx;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
acc += tl.getEvents().length;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (absIndex >= 0) {
|
||||||
|
scrollToItem(absIndex, {
|
||||||
|
behavior: 'smooth',
|
||||||
|
align: 'center',
|
||||||
|
stopInView: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[timeline.linkedTimelines, scrollToItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderMessageContent = useCallback(
|
||||||
|
(mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => {
|
||||||
|
// Evaluated lazily so EncryptedContent can re-run it (re-reading getType())
|
||||||
|
// after MatrixEventEvent.Decrypted fires — decryption re-emits NEITHER
|
||||||
|
// RoomEvent.Timeline nor ThreadEvent.Update, so without this wrapper a
|
||||||
|
// live-arriving encrypted reply would show "Unable to decrypt" forever.
|
||||||
|
const renderByType = (): ReactNode => {
|
||||||
|
if (mEvent.isRedacted()) {
|
||||||
|
return <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />;
|
||||||
|
}
|
||||||
|
const type = mEvent.getType();
|
||||||
|
if (type === MessageEvent.Sticker) {
|
||||||
|
return (
|
||||||
|
<MSticker
|
||||||
|
content={mEvent.getContent()}
|
||||||
|
renderImageContent={(props) => (
|
||||||
|
<ImageContent
|
||||||
|
{...props}
|
||||||
|
autoPlay={mediaAutoLoad}
|
||||||
|
renderImage={(p) => <Image {...p} loading="lazy" />}
|
||||||
|
renderViewer={(p) => <ImageViewer {...p} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === MessageEvent.RoomMessageEncrypted) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
<MessageNotDecryptedContent />
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type !== MessageEvent.RoomMessage) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
<MessageUnsupportedContent />
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
||||||
|
const getContent = (() =>
|
||||||
|
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
|
||||||
|
const senderId = mEvent.getSender() ?? '';
|
||||||
|
const senderDisplayName =
|
||||||
|
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||||
|
return (
|
||||||
|
<RenderMessageContent
|
||||||
|
displayName={senderDisplayName}
|
||||||
|
msgType={mEvent.getContent().msgtype ?? ''}
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
edited={!!editedEvent}
|
||||||
|
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
||||||
|
getContent={getContent}
|
||||||
|
mediaAutoLoad={mediaAutoLoad}
|
||||||
|
urlPreview={showUrlPreview}
|
||||||
|
htmlReactParserOptions={htmlReactParserOptions}
|
||||||
|
linkifyOpts={linkifyOpts}
|
||||||
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||||
|
eventId={mEventId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) {
|
||||||
|
return <EncryptedContent mEvent={mEvent}>{renderByType}</EncryptedContent>;
|
||||||
|
}
|
||||||
|
return renderByType();
|
||||||
|
},
|
||||||
|
[room, mediaAutoLoad, showUrlPreview, htmlReactParserOptions, linkifyOpts, messageLayout],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderMessage = useCallback(
|
||||||
|
(
|
||||||
|
mEvent: MatrixEvent,
|
||||||
|
opts: { item?: number; collapse: boolean; highlight: boolean; editable: boolean },
|
||||||
|
): ReactNode => {
|
||||||
|
const mEventId = mEvent.getId();
|
||||||
|
if (!mEventId) return null;
|
||||||
|
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||||
|
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||||
|
const reactions = reactionRelations?.getSortedAnnotationsByKey();
|
||||||
|
const hasReactions = !!reactions && reactions.length > 0;
|
||||||
|
const { replyEventId, threadRootId } = mEvent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Message
|
||||||
|
key={mEventId}
|
||||||
|
data-message-item={opts.item}
|
||||||
|
data-message-id={mEventId}
|
||||||
|
room={room}
|
||||||
|
mEvent={mEvent}
|
||||||
|
messageSpacing={messageSpacing}
|
||||||
|
messageLayout={messageLayout}
|
||||||
|
collapse={opts.collapse}
|
||||||
|
highlight={opts.highlight}
|
||||||
|
edit={opts.editable && editId === mEventId}
|
||||||
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||||
|
canSendReaction={canSendReaction}
|
||||||
|
canPinEvent={canPinEvent}
|
||||||
|
imagePackRooms={imagePackRooms}
|
||||||
|
relations={hasReactions ? reactionRelations : undefined}
|
||||||
|
onUserClick={handleUserClick}
|
||||||
|
onUsernameClick={handleUsernameClick}
|
||||||
|
onReplyClick={handleReplyClick}
|
||||||
|
onReactionToggle={handleReactionToggle}
|
||||||
|
onEditId={opts.editable ? handleEdit : undefined}
|
||||||
|
reply={
|
||||||
|
replyEventId && (
|
||||||
|
<Reply
|
||||||
|
room={room}
|
||||||
|
timelineSet={timelineSet}
|
||||||
|
replyEventId={replyEventId}
|
||||||
|
threadRootId={threadRootId}
|
||||||
|
onClick={handleOpenReply}
|
||||||
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reactions={
|
||||||
|
reactionRelations && (
|
||||||
|
<Reactions
|
||||||
|
style={{ marginTop: config.space.S200 }}
|
||||||
|
room={room}
|
||||||
|
relations={reactionRelations}
|
||||||
|
mEventId={mEventId}
|
||||||
|
canSendReaction={canSendReaction}
|
||||||
|
onReactionToggle={handleReactionToggle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
||||||
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
lotusTerminal={!!lotusTerminal}
|
||||||
|
>
|
||||||
|
{renderMessageContent(mEvent, mEventId, timelineSet)}
|
||||||
|
</Message>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
thread,
|
||||||
|
room,
|
||||||
|
messageSpacing,
|
||||||
|
messageLayout,
|
||||||
|
editId,
|
||||||
|
canRedact,
|
||||||
|
canDeleteOwn,
|
||||||
|
canSendReaction,
|
||||||
|
canPinEvent,
|
||||||
|
imagePackRooms,
|
||||||
|
handleUserClick,
|
||||||
|
handleUsernameClick,
|
||||||
|
handleReplyClick,
|
||||||
|
handleReactionToggle,
|
||||||
|
handleEdit,
|
||||||
|
handleOpenReply,
|
||||||
|
getMemberPowerTag,
|
||||||
|
accessiblePowerTagColors,
|
||||||
|
legacyUsernameColor,
|
||||||
|
direct,
|
||||||
|
hideActivity,
|
||||||
|
showDeveloperTools,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
|
lotusTerminal,
|
||||||
|
mx,
|
||||||
|
renderMessageContent,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let prevEvent: MatrixEvent | undefined;
|
||||||
|
let isPrevRendered = false;
|
||||||
|
let dayDivider = false;
|
||||||
|
const eventRenderer = (item: number) => {
|
||||||
|
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
||||||
|
if (!eventTimeline) return null;
|
||||||
|
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
|
||||||
|
const mEventId = mEvent?.getId();
|
||||||
|
if (!mEvent || !mEventId) return null;
|
||||||
|
|
||||||
|
// Skip annotations, edits, and any state/membership events (they can't be threaded).
|
||||||
|
if (reactionOrEditEvent(mEvent) || typeof mEvent.getStateKey() === 'string') {
|
||||||
|
prevEvent = mEvent;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const eventSender = mEvent.getSender();
|
||||||
|
if (eventSender && ignoredUsersSet.has(eventSender)) {
|
||||||
|
prevEvent = mEvent;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRoot = mEventId === thread.id;
|
||||||
|
|
||||||
|
if (!dayDivider) {
|
||||||
|
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapsed =
|
||||||
|
!isRoot &&
|
||||||
|
!perMessageProfiles &&
|
||||||
|
isPrevRendered &&
|
||||||
|
!dayDivider &&
|
||||||
|
prevEvent !== undefined &&
|
||||||
|
prevEvent.getId() !== thread.id &&
|
||||||
|
prevEvent.getSender() === eventSender &&
|
||||||
|
prevEvent.getType() === mEvent.getType() &&
|
||||||
|
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 5;
|
||||||
|
|
||||||
|
const eventJSX = renderMessage(mEvent, {
|
||||||
|
item,
|
||||||
|
collapse: collapsed,
|
||||||
|
highlight: false,
|
||||||
|
editable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayDividerJSX =
|
||||||
|
dayDivider && eventJSX && !isRoot ? (
|
||||||
|
<MessageBase space={messageSpacing}>
|
||||||
|
<Box gap="100" justifyContent="Center" alignItems="Center">
|
||||||
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||||
|
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
||||||
|
<Text size="L400">
|
||||||
|
{(() => {
|
||||||
|
if (today(mEvent.getTs())) return 'Today';
|
||||||
|
if (yesterday(mEvent.getTs())) return 'Yesterday';
|
||||||
|
return timeDayMonthYear(mEvent.getTs());
|
||||||
|
})()}
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||||
|
</Box>
|
||||||
|
</MessageBase>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
prevEvent = mEvent;
|
||||||
|
isPrevRendered = !!eventJSX;
|
||||||
|
if (dayDividerJSX) dayDivider = false;
|
||||||
|
|
||||||
|
// Root gets an emphasized container + a "N replies" divider under it.
|
||||||
|
if (isRoot && eventJSX) {
|
||||||
|
const replyCount = thread.length;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={mEventId}>
|
||||||
|
<div className={css.RootMessage}>{eventJSX}</div>
|
||||||
|
{replyCount > 0 && (
|
||||||
|
<Box
|
||||||
|
className={css.RepliesDivider}
|
||||||
|
gap="100"
|
||||||
|
justifyContent="Center"
|
||||||
|
alignItems="Center"
|
||||||
|
>
|
||||||
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||||
|
<Text size="L400" priority="300">
|
||||||
|
{replyCount === 1 ? '1 reply' : `${replyCount} replies`}
|
||||||
|
</Text>
|
||||||
|
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventJSX && dayDividerJSX) {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={mEventId}>
|
||||||
|
{dayDividerJSX}
|
||||||
|
{eventJSX}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventJSX;
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = getItems();
|
||||||
|
const showEmptyReplies = ready && thread.length === 0;
|
||||||
|
|
||||||
|
const renderPendingEvent = (mEvent: MatrixEvent) => {
|
||||||
|
const failed =
|
||||||
|
mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={mEvent.getId() ?? mEvent.getTxnId()}
|
||||||
|
className={classNames(failed ? css.PendingFailed : css.PendingMessage)}
|
||||||
|
>
|
||||||
|
{renderMessage(mEvent, { collapse: false, highlight: false, editable: false })}
|
||||||
|
{failed && (
|
||||||
|
<Box style={{ padding: `0 ${config.space.S400}` }}>
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
Failed to send
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={css.ThreadCentered}
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
justifyContent="Center"
|
||||||
|
alignItems="Center"
|
||||||
|
>
|
||||||
|
<Spinner variant="Secondary" size="600" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={css.ThreadTimeline} grow="Yes">
|
||||||
|
<Scroll ref={scrollRef} visibility="Hover">
|
||||||
|
<Box
|
||||||
|
className={css.ThreadTimelineContent}
|
||||||
|
direction="Column"
|
||||||
|
justifyContent="End"
|
||||||
|
role="log"
|
||||||
|
aria-label="Thread timeline"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{(canPaginateBack || !rangeAtStart) && (
|
||||||
|
<>
|
||||||
|
<MessageBase>
|
||||||
|
<DefaultPlaceholder />
|
||||||
|
</MessageBase>
|
||||||
|
<MessageBase ref={observeBackAnchor}>
|
||||||
|
<DefaultPlaceholder />
|
||||||
|
</MessageBase>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.map(eventRenderer)}
|
||||||
|
|
||||||
|
{showEmptyReplies && (
|
||||||
|
<Box className={css.NoReplies} justifyContent="Center">
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
No replies yet — say something
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingEvents.map(renderPendingEvent)}
|
||||||
|
|
||||||
|
<span ref={atBottomAnchorRef} />
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
{editHistoryEvent && (
|
||||||
|
<EditHistoryModal
|
||||||
|
room={room}
|
||||||
|
mEvent={editHistoryEvent}
|
||||||
|
onClose={() => setEditHistoryEvent(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ThreadPanel';
|
||||||
|
export * from './ThreadSummary';
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk';
|
||||||
|
import { getThreadSummary, isPendingThreadReply } from './threadSummary';
|
||||||
|
|
||||||
|
// getThreadSummary reads either the live Thread (preferred) or the
|
||||||
|
// server-aggregated `m.thread` bundle. We stub only the members it touches and
|
||||||
|
// cast through `unknown` to MatrixEvent, mirroring the light mocking used in
|
||||||
|
// the state tests.
|
||||||
|
|
||||||
|
type ThreadStub = { length: number; lastReplyTs?: number };
|
||||||
|
type BundleStub = { count: number; latestTs?: number };
|
||||||
|
|
||||||
|
const makeRootEvent = (opts: { thread?: ThreadStub; bundle?: BundleStub }): MatrixEvent => {
|
||||||
|
const thread = opts.thread
|
||||||
|
? {
|
||||||
|
length: opts.thread.length,
|
||||||
|
lastReply: () =>
|
||||||
|
opts.thread?.lastReplyTs === undefined
|
||||||
|
? null
|
||||||
|
: ({ getTs: () => opts.thread?.lastReplyTs } as unknown as MatrixEvent),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getThread: () => thread,
|
||||||
|
getServerAggregatedRelation: (relType: string) => {
|
||||||
|
if (relType !== RelationType.Thread || !opts.bundle) return undefined;
|
||||||
|
return {
|
||||||
|
count: opts.bundle.count,
|
||||||
|
latest_event:
|
||||||
|
opts.bundle.latestTs === undefined
|
||||||
|
? undefined
|
||||||
|
: { origin_server_ts: opts.bundle.latestTs },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as unknown as MatrixEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getThreadSummary
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('prefers the live thread: count from length, latestTs from lastReply', () => {
|
||||||
|
const rootEvent = makeRootEvent({
|
||||||
|
thread: { length: 3, lastReplyTs: 1700 },
|
||||||
|
bundle: { count: 99, latestTs: 1 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 3, latestTs: 1700 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live thread with no replies yields undefined latestTs', () => {
|
||||||
|
const rootEvent = makeRootEvent({ thread: { length: 0 } });
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 0, latestTs: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the server bundle when no live thread', () => {
|
||||||
|
const rootEvent = makeRootEvent({ bundle: { count: 5, latestTs: 1234 } });
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 5, latestTs: 1234 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundle without latest_event yields undefined latestTs', () => {
|
||||||
|
const rootEvent = makeRootEvent({ bundle: { count: 2 } });
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 2, latestTs: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined when there is neither a thread nor a bundle', () => {
|
||||||
|
const rootEvent = makeRootEvent({});
|
||||||
|
assert.equal(getThreadSummary(rootEvent), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// isPendingThreadReply
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ROOT = '$root:server';
|
||||||
|
|
||||||
|
const makeReply = (opts: {
|
||||||
|
status: EventStatus | null;
|
||||||
|
threadRootId?: string;
|
||||||
|
relation?: { rel_type?: string; event_id?: string } | null;
|
||||||
|
}): MatrixEvent =>
|
||||||
|
({
|
||||||
|
status: opts.status,
|
||||||
|
threadRootId: opts.threadRootId,
|
||||||
|
getRelation: () => opts.relation ?? null,
|
||||||
|
}) as unknown as MatrixEvent;
|
||||||
|
|
||||||
|
test('SENDING with matching threadRootId is pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENDING, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NOT_SENT with matching threadRootId is pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.NOT_SENT, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING resolved via the m.thread relation content is pending', () => {
|
||||||
|
const event = makeReply({
|
||||||
|
status: EventStatus.SENDING,
|
||||||
|
relation: { rel_type: RelationType.Thread, event_id: ROOT },
|
||||||
|
});
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENT (confirmed) event is not pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENT, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null status is not pending', () => {
|
||||||
|
const event = makeReply({ status: null, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING but for a different thread is not pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENDING, threadRootId: '$other:server' });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING with a non-thread relation is not pending', () => {
|
||||||
|
const event = makeReply({
|
||||||
|
status: EventStatus.SENDING,
|
||||||
|
relation: { rel_type: RelationType.Reference, event_id: ROOT },
|
||||||
|
});
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING with no relation and no threadRootId is not pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENDING });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { EventStatus, IThreadBundledRelationship, MatrixEvent, RelationType } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
export type ThreadSummaryData = {
|
||||||
|
count: number;
|
||||||
|
latestTs: number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary data for a thread root's "N replies" chip.
|
||||||
|
*
|
||||||
|
* Prefers the live {@link Thread} object when it exists (it reflects local
|
||||||
|
* echo + pagination), otherwise falls back to the server-aggregated bundle
|
||||||
|
* (`unsigned['m.relations']['m.thread']`) so the chip renders before any
|
||||||
|
* Thread object has been created. Returns `undefined` when the root has no
|
||||||
|
* thread at all.
|
||||||
|
*/
|
||||||
|
export const getThreadSummary = (rootEvent: MatrixEvent): ThreadSummaryData | undefined => {
|
||||||
|
const thread = rootEvent.getThread();
|
||||||
|
if (thread) {
|
||||||
|
const lastReply = thread.lastReply();
|
||||||
|
return {
|
||||||
|
count: thread.length,
|
||||||
|
latestTs: lastReply?.getTs(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>(
|
||||||
|
RelationType.Thread,
|
||||||
|
);
|
||||||
|
if (bundle) {
|
||||||
|
return {
|
||||||
|
count: bundle.count,
|
||||||
|
latestTs: bundle.latest_event?.origin_server_ts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `event` is a still-in-flight (local echo) reply belonging to the
|
||||||
|
* given thread root. Used to render the pending strip, since pending thread
|
||||||
|
* sends never enter the thread's timelineSet.
|
||||||
|
*/
|
||||||
|
export const isPendingThreadReply = (event: MatrixEvent, threadRootId: string): boolean => {
|
||||||
|
const { status } = event;
|
||||||
|
if (status !== EventStatus.SENDING && status !== EventStatus.NOT_SENT) return false;
|
||||||
|
|
||||||
|
// Prefer the SDK's resolved thread root id; fall back to the raw relation
|
||||||
|
// content for events the SDK hasn't associated with a thread yet.
|
||||||
|
if (event.threadRootId === threadRootId) return true;
|
||||||
|
|
||||||
|
const relation = event.getRelation();
|
||||||
|
return relation?.rel_type === RelationType.Thread && relation.event_id === threadRootId;
|
||||||
|
};
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
EventStatus,
|
||||||
|
EventTimeline,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
ReceiptType,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomEventHandlerMap,
|
||||||
|
Thread,
|
||||||
|
ThreadEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { getLinkedTimelines } from '../RoomTimeline';
|
||||||
|
import { isPendingThreadReply } from './threadSummary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve (or bootstrap) the live {@link Thread} for a root event.
|
||||||
|
*
|
||||||
|
* Uses the existing thread when present, otherwise creates one via
|
||||||
|
* `room.createThread` — the SDK then auto-fetches the thread's events via
|
||||||
|
* `/relations` and inserts the root at the top. If the root event isn't loaded
|
||||||
|
* locally the Thread handles the root fetch itself, so passing `undefined` is
|
||||||
|
* safe. Re-resolves when a matching thread later appears/updates on the room.
|
||||||
|
*/
|
||||||
|
export const useThreadInstance = (room: Room, threadRootId: string): Thread | undefined => {
|
||||||
|
const getInstance = useCallback((): Thread | undefined => {
|
||||||
|
const existing = room.getThread(threadRootId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const rootEvent = room.findEventById(threadRootId);
|
||||||
|
return room.createThread(threadRootId, rootEvent, [], false) ?? undefined;
|
||||||
|
}, [room, threadRootId]);
|
||||||
|
|
||||||
|
const [thread, setThread] = useState<Thread | undefined>(getInstance);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setThread(getInstance());
|
||||||
|
|
||||||
|
const handleThread: RoomEventHandlerMap[ThreadEvent.New] = (newThread) => {
|
||||||
|
if (newThread.id === threadRootId) setThread(newThread);
|
||||||
|
};
|
||||||
|
const handleThreadUpdate: RoomEventHandlerMap[ThreadEvent.Update] = (updatedThread) => {
|
||||||
|
if (updatedThread.id === threadRootId) setThread(updatedThread);
|
||||||
|
};
|
||||||
|
|
||||||
|
room.on(ThreadEvent.New, handleThread);
|
||||||
|
room.on(ThreadEvent.Update, handleThreadUpdate);
|
||||||
|
return () => {
|
||||||
|
room.removeListener(ThreadEvent.New, handleThread);
|
||||||
|
room.removeListener(ThreadEvent.Update, handleThreadUpdate);
|
||||||
|
};
|
||||||
|
}, [room, threadRootId, getInstance]);
|
||||||
|
|
||||||
|
return thread;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the ordered list of linked {@link EventTimeline}s for a thread's live
|
||||||
|
* timeline and track readiness (`thread.initialEventsFetched`). Subscribes to
|
||||||
|
* the Thread's re-emitted timeline events so callers repaginate/re-render as
|
||||||
|
* the thread fills in.
|
||||||
|
*/
|
||||||
|
export const useThreadLinkedTimelines = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
thread: Thread,
|
||||||
|
): { timelines: EventTimeline[]; ready: boolean; refresh: () => void } => {
|
||||||
|
const [timelines, setTimelines] = useState<EventTimeline[]>(() =>
|
||||||
|
getLinkedTimelines(thread.liveTimeline),
|
||||||
|
);
|
||||||
|
const [ready, setReady] = useState<boolean>(() => thread.initialEventsFetched);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setTimelines(getLinkedTimelines(thread.liveTimeline));
|
||||||
|
setReady(thread.initialEventsFetched);
|
||||||
|
}, [thread]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
const handleTimeline = () => refresh();
|
||||||
|
// Thread re-emits RoomEvent.Timeline / RoomEvent.TimelineReset from its
|
||||||
|
// timelineSet, and fires ThreadEvent.Update as it (re)populates.
|
||||||
|
thread.on(RoomEvent.Timeline, handleTimeline);
|
||||||
|
thread.on(RoomEvent.TimelineReset, handleTimeline);
|
||||||
|
thread.on(ThreadEvent.Update, handleTimeline);
|
||||||
|
return () => {
|
||||||
|
thread.removeListener(RoomEvent.Timeline, handleTimeline);
|
||||||
|
thread.removeListener(RoomEvent.TimelineReset, handleTimeline);
|
||||||
|
thread.removeListener(ThreadEvent.Update, handleTimeline);
|
||||||
|
};
|
||||||
|
}, [thread, refresh]);
|
||||||
|
|
||||||
|
return { timelines, ready, refresh };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track in-flight (local echo) replies for a thread.
|
||||||
|
*
|
||||||
|
* Pending thread sends never enter the thread's timelineSet (chronological
|
||||||
|
* pending ordering rejects them; `room.getPendingEvents()` THROWS in this
|
||||||
|
* mode). We instead watch `RoomEvent.LocalEchoUpdated` on the room and keep our
|
||||||
|
* own list of events that are pending replies to this thread and not yet in the
|
||||||
|
* thread timeline. When an event's remote echo arrives (status flips to SENT,
|
||||||
|
* or it lands in the thread) it drops out of the list.
|
||||||
|
*/
|
||||||
|
export const useThreadPendingEvents = (
|
||||||
|
room: Room,
|
||||||
|
threadRootId: string,
|
||||||
|
thread: Thread | undefined,
|
||||||
|
): MatrixEvent[] => {
|
||||||
|
const [pending, setPending] = useState<MatrixEvent[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPending([]);
|
||||||
|
|
||||||
|
const handleLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event) => {
|
||||||
|
const eventId = event.getId();
|
||||||
|
setPending((prev) => {
|
||||||
|
// Drop any previous entry for this event (same instance across the
|
||||||
|
// temp-id -> real-id transition, or matched by id).
|
||||||
|
const without = prev.filter((e) => e !== event && e.getId() !== eventId);
|
||||||
|
|
||||||
|
const alreadyInThread =
|
||||||
|
eventId !== undefined && thread?.findEventById(eventId) !== undefined;
|
||||||
|
// Keep a tracked event through the SENT window too: the /send response
|
||||||
|
// flips status to SENT before /sync delivers the event into the thread
|
||||||
|
// timeline — dropping it there would make the message flash out of view.
|
||||||
|
// It falls out on the next LocalEchoUpdated once findEventById sees it.
|
||||||
|
const trackedAndAwaitingSync =
|
||||||
|
event.status === EventStatus.SENT &&
|
||||||
|
prev.some((e) => e === event || (eventId !== undefined && e.getId() === eventId));
|
||||||
|
const stillPending =
|
||||||
|
!alreadyInThread && (isPendingThreadReply(event, threadRootId) || trackedAndAwaitingSync);
|
||||||
|
|
||||||
|
if (stillPending) return [...without, event];
|
||||||
|
return without.length === prev.length ? prev : without;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
room.on(RoomEvent.LocalEchoUpdated, handleLocalEcho);
|
||||||
|
return () => {
|
||||||
|
room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEcho);
|
||||||
|
};
|
||||||
|
}, [room, threadRootId, thread]);
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a threaded read receipt up to the latest confirmed event in the thread.
|
||||||
|
*
|
||||||
|
* The receipt is threaded by default (scoped to this thread), which clears the
|
||||||
|
* per-thread unread count. Mirrors the latest-valid-event scan in
|
||||||
|
* `utils/notifications.ts`.
|
||||||
|
*/
|
||||||
|
export const markThreadAsRead = async (
|
||||||
|
mx: MatrixClient,
|
||||||
|
thread: Thread,
|
||||||
|
privateReceipt: boolean,
|
||||||
|
): Promise<void> => {
|
||||||
|
const events = thread.liveTimeline.getEvents();
|
||||||
|
|
||||||
|
let latestEvent: MatrixEvent | undefined;
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const evt = events[i];
|
||||||
|
if (evt && !evt.isSending()) {
|
||||||
|
latestEvent = evt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!latestEvent) return;
|
||||||
|
|
||||||
|
await mx.sendReadReceipt(
|
||||||
|
latestEvent,
|
||||||
|
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
MatrixEvent,
|
||||||
|
NotificationCountType,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomEventHandlerMap,
|
||||||
|
ThreadEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive thread summary + unread count for a root event's "N replies" chip.
|
||||||
|
*
|
||||||
|
* Re-computes the summary on `ThreadEvent.Update` (the SDK re-emits this on the
|
||||||
|
* root MatrixEvent) and the unread count on `RoomEvent.UnreadNotifications`.
|
||||||
|
*/
|
||||||
|
export const useThreadSummary = (
|
||||||
|
rootEvent: MatrixEvent,
|
||||||
|
room: Room,
|
||||||
|
): { summary: ThreadSummaryData | undefined; unread: number } => {
|
||||||
|
const threadId = rootEvent.getId();
|
||||||
|
|
||||||
|
const [summary, setSummary] = useState<ThreadSummaryData | undefined>(() =>
|
||||||
|
getThreadSummary(rootEvent),
|
||||||
|
);
|
||||||
|
const [unread, setUnread] = useState<number>(() =>
|
||||||
|
threadId
|
||||||
|
? (room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Total) ?? 0)
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const refreshSummary = () => setSummary(getThreadSummary(rootEvent));
|
||||||
|
const refreshUnread = () => {
|
||||||
|
if (!threadId) return;
|
||||||
|
setUnread(room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Total) ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshSummary();
|
||||||
|
refreshUnread();
|
||||||
|
|
||||||
|
const handleUnread: RoomEventHandlerMap[RoomEvent.UnreadNotifications] = (_counts, tId) => {
|
||||||
|
if (tId && tId !== threadId) return;
|
||||||
|
refreshUnread();
|
||||||
|
};
|
||||||
|
|
||||||
|
rootEvent.on(ThreadEvent.Update, refreshSummary);
|
||||||
|
room.on(RoomEvent.UnreadNotifications, handleUnread);
|
||||||
|
return () => {
|
||||||
|
rootEvent.removeListener(ThreadEvent.Update, refreshSummary);
|
||||||
|
room.removeListener(RoomEvent.UnreadNotifications, handleUnread);
|
||||||
|
};
|
||||||
|
}, [rootEvent, room, threadId]);
|
||||||
|
|
||||||
|
return { summary, unread };
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createStore } from 'jotai';
|
||||||
|
import { getThreadDraftKey, roomIdToActiveThreadIdAtomFamily } from './thread';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getThreadDraftKey
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('getThreadDraftKey joins roomId and threadRootId with "::"', () => {
|
||||||
|
assert.equal(getThreadDraftKey('!room:server', '$root'), '!room:server::$root');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getThreadDraftKey keeps the two ids distinguishable', () => {
|
||||||
|
assert.notEqual(getThreadDraftKey('!a:server', '$b'), getThreadDraftKey('!a:server', '$c'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// roomIdToActiveThreadIdAtomFamily
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('returns the same atom instance for the same roomId', () => {
|
||||||
|
const a = roomIdToActiveThreadIdAtomFamily('!room:server');
|
||||||
|
const b = roomIdToActiveThreadIdAtomFamily('!room:server');
|
||||||
|
assert.equal(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns different atoms for different roomIds', () => {
|
||||||
|
const a = roomIdToActiveThreadIdAtomFamily('!a:server');
|
||||||
|
const b = roomIdToActiveThreadIdAtomFamily('!b:server');
|
||||||
|
assert.notEqual(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the active-thread atom defaults to null and is writable', () => {
|
||||||
|
const store = createStore();
|
||||||
|
const activeThreadIdAtom = roomIdToActiveThreadIdAtomFamily('!store:server');
|
||||||
|
|
||||||
|
assert.equal(store.get(activeThreadIdAtom), null);
|
||||||
|
store.set(activeThreadIdAtom, '$root');
|
||||||
|
assert.equal(store.get(activeThreadIdAtom), '$root');
|
||||||
|
store.set(activeThreadIdAtom, null);
|
||||||
|
assert.equal(store.get(activeThreadIdAtom), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { atom, PrimitiveAtom } from 'jotai';
|
||||||
|
import { atomFamily } from 'jotai/utils';
|
||||||
|
|
||||||
|
const createActiveThreadIdAtom = () => atom<string | null>(null);
|
||||||
|
export type TActiveThreadIdAtom = PrimitiveAtom<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-room "which thread is open in the panel" state. Mirrors
|
||||||
|
* `roomIdToReplyDraftAtomFamily` in `roomInputDrafts.ts` — the same atom
|
||||||
|
* instance is returned for the same roomId, so a room's panel state survives
|
||||||
|
* remounts.
|
||||||
|
*/
|
||||||
|
export const roomIdToActiveThreadIdAtomFamily = atomFamily<string, TActiveThreadIdAtom>(() =>
|
||||||
|
createActiveThreadIdAtom(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key used to scope a thread's composer drafts (message/reply/upload) away from
|
||||||
|
* the main room composer, e.g. `"!room:server::$rootEventId"`.
|
||||||
|
*/
|
||||||
|
export const getThreadDraftKey = (roomId: string, threadRootId: string): string =>
|
||||||
|
`${roomId}::${threadRootId}`;
|
||||||
@@ -21,8 +21,13 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
|
|||||||
const latestEvent = getLatestValidEvent();
|
const latestEvent = getLatestValidEvent();
|
||||||
if (latestEvent === null) return;
|
if (latestEvent === null) return;
|
||||||
|
|
||||||
|
// Unthreaded receipt: with client threadSupport enabled the SDK would
|
||||||
|
// otherwise scope this to the main timeline (thread_id: "main"), leaving
|
||||||
|
// per-thread notification counts permanently unread. Unthreaded preserves
|
||||||
|
// the pre-threads wire behavior — one receipt clears everything.
|
||||||
await mx.sendReadReceipt(
|
await mx.sendReadReceipt(
|
||||||
latestEvent,
|
latestEvent,
|
||||||
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
|||||||
export const startClient = async (mx: MatrixClient) => {
|
export const startClient = async (mx: MatrixClient) => {
|
||||||
await mx.startClient({
|
await mx.startClient({
|
||||||
lazyLoadMembers: true,
|
lazyLoadMembers: true,
|
||||||
|
// P3-8: partition m.thread relations into Thread objects/timelines. Thread
|
||||||
|
// replies leave the main timeline (roots stay + get a summary chip); the
|
||||||
|
// thread panel renders them. markAsRead sends UNTHREADED receipts
|
||||||
|
// (utils/notifications.ts) so room badges keep clearing.
|
||||||
|
threadSupport: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user