• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

DigitalTolk / ex / 25250212322

02 May 2026 10:47AM UTC coverage: 90.469% (-0.5%) from 91.013%
25250212322

push

github

web-flow
Remove deprecated domexception (#48)

* Remove deprecated domexception

* switch to virtuoso

* simplify

2895 of 3463 branches covered (83.6%)

Branch coverage included in aggregate %.

302 of 366 new or added lines in 20 files covered. (82.51%)

5 existing lines in 1 file now uncovered.

11809 of 12790 relevant lines covered (92.33%)

30.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.24
/frontend/src/components/chat/ThreadPanel.tsx
1
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
2
import { MessageItem } from './MessageItem';
3
import { MessageInput, type MessageInputHandle } from './MessageInput';
4
import { MessageDropZone } from './MessageDropZone';
5
import { ThreadTypingIndicator } from './TypingIndicator';
6
import { Button } from '@/components/ui/button';
7
import { X } from 'lucide-react';
8
import { useAtBottomRef } from '@/hooks/useAtBottomRef';
9
import { useSendMessage, type SendMessageInput } from '@/hooks/useMessages';
10
import { useThreadMessages } from '@/hooks/useThreads';
11
import { useUsersBatch } from '@/hooks/useUsersBatch';
12
import { collectMessageUserIDs } from '@/lib/message-users';
13
import type { UserMapEntry } from './MessageList';
14

15
const ANCHOR_HIGHLIGHT_CLASSES = ['ring-1', 'ring-amber-400/50', 'rounded-md'];
14✔
16
const ANCHOR_HIGHLIGHT_MS = 2200;
14✔
17

18
interface ThreadPanelProps {
19
  channelId?: string;
20
  conversationId?: string;
21
  threadRootID: string;
22
  onClose: () => void;
23
  userMap: Record<string, UserMapEntry>;
24
  currentUserId?: string;
25
  // Deep-link target inside the thread — when set, the panel scrolls
26
  // to and highlights this reply instead of snapping to the bottom.
27
  // Used for search/threads-page links of the form
28
  // /channel/x?thread=root#msg-replyId.
29
  anchorMsgId?: string;
30
  // Per-navigation revision token; same role as in MessageList.
31
  anchorRevision?: string;
32
}
33

34
export function ThreadPanel({
35
  channelId,
36
  conversationId,
37
  threadRootID,
38
  onClose,
39
  userMap,
40
  currentUserId,
41
  anchorMsgId,
42
  anchorRevision,
43
}: ThreadPanelProps) {
44
  const { data, isLoading } = useThreadMessages({ channelId, conversationId, threadRootID });
26✔
45

46
  // Authors + reactors of thread messages may not be in the parent
47
  // userMap (which was built from the channel page, not the thread).
48
  // Fetch any missing IDs so reaction tooltips don't show "Unknown".
49
  const missingUserIDs = useMemo(() => {
26✔
50
    const ids = collectMessageUserIDs(data ?? []);
26✔
51
    return ids.filter((id) => !userMap[id]);
26✔
52
  }, [data, userMap]);
53
  const { data: extras } = useUsersBatch(missingUserIDs);
26✔
54
  const mergedUserMap = useMemo(() => {
26✔
55
    if (!extras || extras.length === 0) return userMap;
19!
56
    const m: Record<string, UserMapEntry> = { ...userMap };
×
57
    for (const u of extras) {
×
58
      m[u.id] = { displayName: u.displayName || 'Unknown', avatarURL: u.avatarURL };
×
59
    }
60
    return m;
×
61
  }, [userMap, extras]);
62
  // Adapter for MessageItem — its userMap prop is the .get-style lookup
63
  // ThreadActionBar / reaction tooltip both consume.
64
  const userLookup = useMemo(
26✔
65
    () => ({ get: (id: string) => mergedUserMap[id] }),
19✔
66
    [mergedUserMap],
67
  );
68

69
  const send = useSendMessage({ channelId, conversationId });
26✔
70
  const inputRef = useRef<MessageInputHandle>(null);
26✔
71

72
  // Most recent own reply for the ArrowUp-edit-last shortcut. Thread
73
  // data is oldest-first; walk from the end to find the newest reply
74
  // matching the current user. Skip the root — that's editable from
75
  // the main composer, not the thread panel.
76
  const lastOwnMessageId = useMemo(() => {
26✔
77
    if (!currentUserId || !data) return undefined;
26✔
78
    for (let i = data.length - 1; i >= 0; i--) {
16✔
79
      const m = data[i];
20✔
80
      if (m.parentMessageID !== threadRootID) continue;
20✔
81
      if (m.authorID !== currentUserId) continue;
8!
NEW
82
      if (m.deleted || m.system) continue;
×
NEW
83
      return m.id;
×
84
    }
85
    return undefined;
16✔
86
  }, [data, currentUserId, threadRootID]);
87
  const scrollRef = useRef<HTMLDivElement>(null);
26✔
88
  // Inner messages container — observed by the ResizeObservers below
89
  // (NOT scroller.lastElementChild, which can be a fixed-height
90
  // sentinel and would never report real content shifts).
91
  const innerRef = useRef<HTMLDivElement>(null);
26✔
92
  const wasAtBottomRef = useAtBottomRef(scrollRef);
26✔
93

94
  // userHasScrolledRef is shared with the anchor effect below. Once
95
  // the user takes control of the scroll, the bottom-stick RO can
96
  // engage even in deep-link mode — but until then it stays a no-op
97
  // so an anchor that landed near the bottom doesn't get yanked by
98
  // settling avatars / attachments.
99
  const userHasScrolledRef = useRef(false);
26✔
100
  // Snap to the bottom on open, follow new replies while at the
101
  // bottom, and keep re-pinning while async content settles. The
102
  // ResizeObserver lives for the duration of the open thread (gated
103
  // by wasAtBottomRef so it doesn't yank a reader who has scrolled
104
  // up). Re-arms when the user opens a different thread.
105
  const stickyDoneRef = useRef(false);
26✔
106
  const prevLenRef = useRef(0);
26✔
107
  const stickyROrRef = useRef<ResizeObserver | null>(null);
26✔
108
  useLayoutEffect(() => {
26✔
109
    stickyDoneRef.current = false;
17✔
110
    prevLenRef.current = 0;
17✔
111
    if (stickyROrRef.current) {
17✔
112
      stickyROrRef.current.disconnect();
1✔
113
      stickyROrRef.current = null;
1✔
114
    }
115
  }, [threadRootID, anchorMsgId]);
116
  useLayoutEffect(() => {
26✔
117
    const len = data?.length ?? 0;
26✔
118
    if (len === 0) return;
26✔
119
    const el = scrollRef.current;
11✔
120
    if (!el) return;
11!
121
    const stick = () => {
11✔
122
      el.scrollTop = el.scrollHeight;
8✔
123
      wasAtBottomRef.current = true;
8✔
124
    };
125

126
    if (!stickyDoneRef.current) {
11✔
127
      // Initial open. Skip the snap-to-newest if a deep-link anchor
128
      // is set — the anchor effect below controls position. The RO
129
      // is still installed so live-following resumes if/when the
130
      // user reaches the bottom themselves.
131
      if (!anchorMsgId) {
9✔
132
        stick();
7✔
133
      }
134
      stickyDoneRef.current = true;
9✔
135
      prevLenRef.current = len;
9✔
136
      if (typeof ResizeObserver !== 'undefined') {
9!
137
        const inner = innerRef.current;
9✔
138
        if (inner) {
9!
139
          // See MessageList: in deep-link mode (anchor set) we never
140
          // auto-stick — the reader went to a specific reply and
141
          // didn't opt into live-tail follow. In live-tail mode we
142
          // follow growth while the reader is at the bottom.
143
          let lastHeight = el.scrollHeight;
9✔
144
          const ro = new ResizeObserver(() => {
9✔
145
            const height = el.scrollHeight;
9✔
146
            const grew = height > lastHeight + 0.5;
9✔
147
            lastHeight = height;
9✔
148
            if (anchorMsgId) return;
9✔
149
            if (!grew) return;
7!
150
            if (wasAtBottomRef.current) stick();
×
151
          });
152
          ro.observe(inner);
9✔
153
          stickyROrRef.current = ro;
9✔
154
        }
155
      }
156
      return;
9✔
157
    }
158

159
    // New reply on a thread already open — follow only if the user
160
    // hasn't scrolled away. Compute the distance synchronously
161
    // rather than reading wasAtBottomRef so a programmatic
162
    // scrollTop set (with no accompanying scroll event) is handled
163
    // correctly.
164
    if (len > prevLenRef.current) {
2!
165
      const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
2✔
166
      if (distanceFromBottom < 120) stick();
2✔
167
    }
168
    prevLenRef.current = len;
2✔
169
  }, [data?.length, wasAtBottomRef, anchorMsgId]);
170

171
  // Deep-link landing inside the thread panel: scroll the matching
172
  // reply into view + highlight, exactly once per (threadRootID,
173
  // anchor). Mirrors MessageList's anchor logic, including a short-
174
  // lived follow-anchor RO so async reply content (avatars, attachments)
175
  // settling above the anchor doesn't push it off-screen. See
176
  // MessageList.tsx for the StrictMode/page-fetch invariants this
177
  // shape preserves.
178
  const anchorAppliedRef = useRef<string | null>(null);
26✔
179
  const followDeadlineRef = useRef<number>(0);
26✔
180
  useLayoutEffect(() => {
26✔
181
    if (!anchorMsgId) {
26✔
182
      anchorAppliedRef.current = null;
22✔
183
      userHasScrolledRef.current = false;
22✔
184
      followDeadlineRef.current = 0;
22✔
185
      return;
22✔
186
    }
187
    if ((data?.length ?? 0) === 0) return;
4✔
188
    const scroller = scrollRef.current;
2✔
189
    if (!scroller) return;
2!
190
    const el = document.getElementById(`msg-${anchorMsgId}`);
2✔
191
    if (!el) return;
2!
192

193
    const dedupKey = anchorRevision ? `${anchorMsgId}@${anchorRevision}` : anchorMsgId;
2!
194
    if (anchorAppliedRef.current !== dedupKey) {
26✔
195
      el.scrollIntoView({ block: 'center' });
2✔
196
      anchorAppliedRef.current = dedupKey;
2✔
197
      wasAtBottomRef.current = false;
2✔
198
      userHasScrolledRef.current = false;
2✔
199
      followDeadlineRef.current = Date.now() + 1500;
2✔
200
    }
201

202
    if (userHasScrolledRef.current) return;
2!
203
    if (Date.now() >= followDeadlineRef.current) return;
2!
204
    if (typeof ResizeObserver === 'undefined') return;
2!
205
    const inner = innerRef.current;
2✔
206
    if (!inner) return;
2!
207
    let expectedScrollTop = scroller.scrollTop;
2✔
208
    const stopFollowing = () => {
2✔
209
      ro.disconnect();
2✔
210
      scroller.removeEventListener('scroll', onScroll);
2✔
211
      window.clearTimeout(timeoutId);
2✔
212
    };
213
    const onScroll = () => {
2✔
214
      if (Math.abs(scroller.scrollTop - expectedScrollTop) > 5) {
×
215
        userHasScrolledRef.current = true;
×
216
        stopFollowing();
×
217
      }
218
    };
219
    const ro = new ResizeObserver(() => {
2✔
220
      el.scrollIntoView({ block: 'center' });
2✔
221
      expectedScrollTop = scroller.scrollTop;
2✔
222
    });
223
    ro.observe(inner);
2✔
224
    scroller.addEventListener('scroll', onScroll, { passive: true });
2✔
225
    const remaining = Math.max(0, followDeadlineRef.current - Date.now());
2✔
226
    const timeoutId = window.setTimeout(stopFollowing, remaining);
2✔
227
    return stopFollowing;
2✔
228
  }, [anchorMsgId, anchorRevision, data?.length, wasAtBottomRef]);
229

230
  // Cosmetic highlight ring on the in-thread anchor.
231
  const repliesHaveLoaded = (data?.length ?? 0) > 0;
26✔
232
  useEffect(() => {
26✔
233
    if (!anchorMsgId || !repliesHaveLoaded) return;
20✔
234
    const el = document.getElementById(`msg-${anchorMsgId}`);
2✔
235
    if (!el) return;
2!
236
    el.classList.add(...ANCHOR_HIGHLIGHT_CLASSES);
2✔
237
    const t = window.setTimeout(() => {
2✔
238
      el.classList.remove(...ANCHOR_HIGHLIGHT_CLASSES);
×
239
    }, ANCHOR_HIGHLIGHT_MS);
240
    return () => {
2✔
241
      window.clearTimeout(t);
2✔
242
      el.classList.remove(...ANCHOR_HIGHLIGHT_CLASSES);
2✔
243
    };
244
  }, [anchorMsgId, anchorRevision, repliesHaveLoaded]);
245
  useEffect(
26✔
246
    () => () => {
247
      if (stickyROrRef.current) {
16✔
248
        stickyROrRef.current.disconnect();
8✔
249
        stickyROrRef.current = null;
8✔
250
      }
251
    },
252
    [],
253
  );
254

255
  // Backup for the bottom-stick RO: late-loading <img> elements
256
  // (avatars, attachments, unfurl thumbs) finish at unpredictable
257
  // moments. Listening for delegated load events on the inner
258
  // container is the most reliable signal that "this image just
259
  // grew its box" — gated by wasAtBottomRef and skipped in deep-link
260
  // mode. Mirrors MessageList; see that file for the full rationale.
261
  useEffect(() => {
26✔
262
    const el = scrollRef.current;
16✔
263
    const inner = innerRef.current;
16✔
264
    if (!el || !inner) return;
16!
265
    if (anchorMsgId) return;
16✔
266
    const onLoad = (e: Event) => {
14✔
267
      const target = e.target as HTMLElement | null;
×
268
      if (!target || target.tagName !== 'IMG') return;
×
269
      if (!wasAtBottomRef.current) return;
×
270
      el.scrollTop = el.scrollHeight;
×
271
    };
272
    inner.addEventListener('load', onLoad, true);
14✔
273
    return () => inner.removeEventListener('load', onLoad, true);
14✔
274
  }, [anchorMsgId, wasAtBottomRef]);
275

276
  function handleReply(input: SendMessageInput) {
277
    send.mutate({ ...input, parentMessageID: threadRootID });
1✔
278
  }
279

280
  return (
26✔
281
    <aside className="w-[28rem] border-l flex flex-col" aria-label="Thread">
282
      <div className="flex items-center justify-between border-b px-4 py-3">
283
        <h2 className="text-sm font-semibold">Thread</h2>
284
        <Button
285
          variant="ghost"
286
          size="icon"
287
          className="h-7 w-7"
288
          onClick={onClose}
289
          aria-label="Close thread"
290
        >
291
          <X className="h-4 w-4" />
292
        </Button>
293
      </div>
294
      <MessageDropZone onFiles={(files) => void inputRef.current?.uploadFiles(files)}>
×
295
        <div ref={scrollRef} className="flex-1 overflow-y-auto">
296
          <div ref={innerRef} className="p-2 space-y-2">
297
            {isLoading && (
36✔
298
              <p className="text-xs text-muted-foreground p-2">Loading replies...</p>
299
            )}
300
            {data?.length === 0 && (
31✔
301
              <p className="text-xs text-muted-foreground p-2">No replies yet. Start the thread!</p>
302
            )}
303
            {data?.map((msg) => {
304
              const u = mergedUserMap[msg.authorID];
20✔
305
              return (
20✔
306
                <MessageItem
307
                  key={msg.id}
308
                  message={msg}
309
                  authorName={u?.displayName ?? 'Unknown'}
32✔
310
                  authorAvatarURL={u?.avatarURL}
311
                  authorOnline={u?.online}
312
                  isOwn={msg.authorID === currentUserId}
313
                  channelId={channelId}
314
                  conversationId={conversationId}
315
                  currentUserId={currentUserId}
316
                  userMap={userLookup}
317
                  inThread
318
                />
319
              );
320
            })}
321
          </div>
322
        </div>
323
        <ThreadTypingIndicator
324
          parentID={channelId ?? conversationId}
28✔
325
          threadRootID={threadRootID}
326
          userMap={mergedUserMap}
327
        />
328
        <MessageInput
329
          ref={inputRef}
330
          onSend={handleReply}
331
          disabled={send.isPending}
332
          placeholder="Reply..."
333
          focusKey={threadRootID}
334
          typingParentID={channelId ?? conversationId}
28✔
335
          typingParentType={channelId ? 'channel' : 'conversation'}
26✔
336
          typingThreadRootID={threadRootID}
337
          lastOwnMessageId={lastOwnMessageId}
338
        />
339
      </MessageDropZone>
340
    </aside>
341
  );
342
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc