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

DigitalTolk / ex / 25224638231

01 May 2026 05:20PM UTC coverage: 91.013% (-0.2%) from 91.201%
25224638231

push

github

web-flow
Bug fixes w18 (#46)

* Bug fixes w18

* fix warnings

* switch to Lexical

* test

* fix warnings

* simplify

2917 of 3442 branches covered (84.75%)

Branch coverage included in aggregate %.

960 of 1082 new or added lines in 57 files covered. (88.72%)

5 existing lines in 3 files now uncovered.

11767 of 12692 relevant lines covered (92.71%)

33.66 hits per line

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

77.09
/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
  const scrollRef = useRef<HTMLDivElement>(null);
26✔
72
  // Inner messages container — observed by the ResizeObservers below
73
  // (NOT scroller.lastElementChild, which can be a fixed-height
74
  // sentinel and would never report real content shifts).
75
  const innerRef = useRef<HTMLDivElement>(null);
26✔
76
  const wasAtBottomRef = useAtBottomRef(scrollRef);
26✔
77

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

110
    if (!stickyDoneRef.current) {
11✔
111
      // Initial open. Skip the snap-to-newest if a deep-link anchor
112
      // is set — the anchor effect below controls position. The RO
113
      // is still installed so live-following resumes if/when the
114
      // user reaches the bottom themselves.
115
      if (!anchorMsgId) {
9✔
116
        stick();
7✔
117
      }
118
      stickyDoneRef.current = true;
9✔
119
      prevLenRef.current = len;
9✔
120
      if (typeof ResizeObserver !== 'undefined') {
9!
121
        const inner = innerRef.current;
9✔
122
        if (inner) {
9!
123
          // See MessageList: in deep-link mode (anchor set) we never
124
          // auto-stick — the reader went to a specific reply and
125
          // didn't opt into live-tail follow. In live-tail mode we
126
          // follow growth while the reader is at the bottom.
127
          let lastHeight = el.scrollHeight;
9✔
128
          const ro = new ResizeObserver(() => {
9✔
129
            const height = el.scrollHeight;
×
130
            const grew = height > lastHeight + 0.5;
×
131
            lastHeight = height;
×
132
            if (anchorMsgId) return;
×
133
            if (!grew) return;
×
134
            if (wasAtBottomRef.current) stick();
×
135
          });
136
          ro.observe(inner);
9✔
137
          stickyROrRef.current = ro;
9✔
138
        }
139
      }
140
      return;
9✔
141
    }
142

143
    // New reply on a thread already open — follow only if the user
144
    // hasn't scrolled away. Compute the distance synchronously
145
    // rather than reading wasAtBottomRef so a programmatic
146
    // scrollTop set (with no accompanying scroll event) is handled
147
    // correctly.
148
    if (len > prevLenRef.current) {
2!
149
      const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
2✔
150
      if (distanceFromBottom < 120) stick();
2✔
151
    }
152
    prevLenRef.current = len;
2✔
153
  }, [data?.length, wasAtBottomRef, anchorMsgId]);
154

155
  // Deep-link landing inside the thread panel: scroll the matching
156
  // reply into view + highlight, exactly once per (threadRootID,
157
  // anchor). Mirrors MessageList's anchor logic, including a short-
158
  // lived follow-anchor RO so async reply content (avatars, attachments)
159
  // settling above the anchor doesn't push it off-screen. See
160
  // MessageList.tsx for the StrictMode/page-fetch invariants this
161
  // shape preserves.
162
  const anchorAppliedRef = useRef<string | null>(null);
26✔
163
  const followDeadlineRef = useRef<number>(0);
26✔
164
  useLayoutEffect(() => {
26✔
165
    if (!anchorMsgId) {
26✔
166
      anchorAppliedRef.current = null;
22✔
167
      userHasScrolledRef.current = false;
22✔
168
      followDeadlineRef.current = 0;
22✔
169
      return;
22✔
170
    }
171
    if ((data?.length ?? 0) === 0) return;
4✔
172
    const scroller = scrollRef.current;
2✔
173
    if (!scroller) return;
2!
174
    const el = document.getElementById(`msg-${anchorMsgId}`);
2✔
175
    if (!el) return;
2!
176

177
    const dedupKey = anchorRevision ? `${anchorMsgId}@${anchorRevision}` : anchorMsgId;
2!
178
    if (anchorAppliedRef.current !== dedupKey) {
26✔
179
      el.scrollIntoView({ block: 'center' });
2✔
180
      anchorAppliedRef.current = dedupKey;
2✔
181
      wasAtBottomRef.current = false;
2✔
182
      userHasScrolledRef.current = false;
2✔
183
      followDeadlineRef.current = Date.now() + 1500;
2✔
184
    }
185

186
    if (userHasScrolledRef.current) return;
2!
187
    if (Date.now() >= followDeadlineRef.current) return;
2!
188
    if (typeof ResizeObserver === 'undefined') return;
2!
189
    const inner = innerRef.current;
2✔
190
    if (!inner) return;
2!
191
    let expectedScrollTop = scroller.scrollTop;
2✔
192
    const stopFollowing = () => {
2✔
193
      ro.disconnect();
2✔
194
      scroller.removeEventListener('scroll', onScroll);
2✔
195
      window.clearTimeout(timeoutId);
2✔
196
    };
197
    const onScroll = () => {
2✔
198
      if (Math.abs(scroller.scrollTop - expectedScrollTop) > 5) {
×
199
        userHasScrolledRef.current = true;
×
200
        stopFollowing();
×
201
      }
202
    };
203
    const ro = new ResizeObserver(() => {
2✔
204
      el.scrollIntoView({ block: 'center' });
×
205
      expectedScrollTop = scroller.scrollTop;
×
206
    });
207
    ro.observe(inner);
2✔
208
    scroller.addEventListener('scroll', onScroll, { passive: true });
2✔
209
    const remaining = Math.max(0, followDeadlineRef.current - Date.now());
2✔
210
    const timeoutId = window.setTimeout(stopFollowing, remaining);
2✔
211
    return stopFollowing;
2✔
212
  }, [anchorMsgId, anchorRevision, data?.length, wasAtBottomRef]);
213

214
  // Cosmetic highlight ring on the in-thread anchor.
215
  const repliesHaveLoaded = (data?.length ?? 0) > 0;
26✔
216
  useEffect(() => {
26✔
217
    if (!anchorMsgId || !repliesHaveLoaded) return;
20✔
218
    const el = document.getElementById(`msg-${anchorMsgId}`);
2✔
219
    if (!el) return;
2!
220
    el.classList.add(...ANCHOR_HIGHLIGHT_CLASSES);
2✔
221
    const t = window.setTimeout(() => {
2✔
222
      el.classList.remove(...ANCHOR_HIGHLIGHT_CLASSES);
×
223
    }, ANCHOR_HIGHLIGHT_MS);
224
    return () => {
2✔
225
      window.clearTimeout(t);
2✔
226
      el.classList.remove(...ANCHOR_HIGHLIGHT_CLASSES);
2✔
227
    };
228
  }, [anchorMsgId, anchorRevision, repliesHaveLoaded]);
229
  useEffect(
26✔
230
    () => () => {
231
      if (stickyROrRef.current) {
16✔
232
        stickyROrRef.current.disconnect();
8✔
233
        stickyROrRef.current = null;
8✔
234
      }
235
    },
236
    [],
237
  );
238

239
  // Backup for the bottom-stick RO: late-loading <img> elements
240
  // (avatars, attachments, unfurl thumbs) finish at unpredictable
241
  // moments. Listening for delegated load events on the inner
242
  // container is the most reliable signal that "this image just
243
  // grew its box" — gated by wasAtBottomRef and skipped in deep-link
244
  // mode. Mirrors MessageList; see that file for the full rationale.
245
  useEffect(() => {
26✔
246
    const el = scrollRef.current;
16✔
247
    const inner = innerRef.current;
16✔
248
    if (!el || !inner) return;
16!
249
    if (anchorMsgId) return;
16✔
250
    const onLoad = (e: Event) => {
14✔
NEW
251
      const target = e.target as HTMLElement | null;
×
NEW
252
      if (!target || target.tagName !== 'IMG') return;
×
NEW
253
      if (!wasAtBottomRef.current) return;
×
NEW
254
      el.scrollTop = el.scrollHeight;
×
255
    };
256
    inner.addEventListener('load', onLoad, true);
14✔
257
    return () => inner.removeEventListener('load', onLoad, true);
14✔
258
  }, [anchorMsgId, wasAtBottomRef]);
259

260
  function handleReply(input: SendMessageInput) {
261
    send.mutate({ ...input, parentMessageID: threadRootID });
1✔
262
  }
263

264
  return (
26✔
265
    <aside className="w-[28rem] border-l flex flex-col" aria-label="Thread">
266
      <div className="flex items-center justify-between border-b px-4 py-3">
267
        <h2 className="text-sm font-semibold">Thread</h2>
268
        <Button
269
          variant="ghost"
270
          size="icon"
271
          className="h-7 w-7"
272
          onClick={onClose}
273
          aria-label="Close thread"
274
        >
275
          <X className="h-4 w-4" />
276
        </Button>
277
      </div>
278
      <MessageDropZone onFiles={(files) => void inputRef.current?.uploadFiles(files)}>
×
279
        <div ref={scrollRef} className="flex-1 overflow-y-auto">
280
          <div ref={innerRef} className="p-2 space-y-2">
281
            {isLoading && (
36✔
282
              <p className="text-xs text-muted-foreground p-2">Loading replies...</p>
283
            )}
284
            {data?.length === 0 && (
31✔
285
              <p className="text-xs text-muted-foreground p-2">No replies yet. Start the thread!</p>
286
            )}
287
            {data?.map((msg) => {
288
              const u = mergedUserMap[msg.authorID];
20✔
289
              return (
20✔
290
                <MessageItem
291
                  key={msg.id}
292
                  message={msg}
293
                  authorName={u?.displayName ?? 'Unknown'}
32✔
294
                  authorAvatarURL={u?.avatarURL}
295
                  authorOnline={u?.online}
296
                  isOwn={msg.authorID === currentUserId}
297
                  channelId={channelId}
298
                  conversationId={conversationId}
299
                  currentUserId={currentUserId}
300
                  userMap={userLookup}
301
                  inThread
302
                />
303
              );
304
            })}
305
          </div>
306
        </div>
307
        <ThreadTypingIndicator
308
          parentID={channelId ?? conversationId}
28✔
309
          threadRootID={threadRootID}
310
          userMap={mergedUserMap}
311
        />
312
        <MessageInput
313
          ref={inputRef}
314
          onSend={handleReply}
315
          disabled={send.isPending}
316
          placeholder="Reply..."
317
          focusKey={threadRootID}
318
          typingParentID={channelId ?? conversationId}
28✔
319
          typingParentType={channelId ? 'channel' : 'conversation'}
26✔
320
          typingThreadRootID={threadRootID}
321
        />
322
      </MessageDropZone>
323
    </aside>
324
  );
325
}
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