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

DigitalTolk / ex / 25138047167

29 Apr 2026 10:53PM UTC coverage: 88.722% (-0.1%) from 88.844%
25138047167

push

github

web-flow
Add search (#36)

* Add search

* fix jump

* fix

* fix

* fixes

* aws

2577 of 3138 branches covered (82.12%)

Branch coverage included in aggregate %.

1645 of 1866 new or added lines in 44 files covered. (88.16%)

2 existing lines in 2 files now uncovered.

10765 of 11900 relevant lines covered (90.46%)

28.3 hits per line

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

64.97
/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 { Button } from '@/components/ui/button';
6
import { X } from 'lucide-react';
7
import { useAtBottomRef } from '@/hooks/useAtBottomRef';
8
import { useSendMessage, type SendMessageInput } from '@/hooks/useMessages';
9
import { useThreadMessages } from '@/hooks/useThreads';
10
import { useUsersBatch } from '@/hooks/useUsersBatch';
11
import { collectMessageUserIDs } from '@/lib/message-users';
12
import type { UserMapEntry } from './MessageList';
13

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

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

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

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

68
  const send = useSendMessage({ channelId, conversationId });
20✔
69
  const inputRef = useRef<MessageInputHandle>(null);
20✔
70
  const scrollRef = useRef<HTMLDivElement>(null);
20✔
71
  // Inner messages container — observed by the ResizeObservers below
72
  // (NOT scroller.lastElementChild, which can be a fixed-height
73
  // sentinel and would never report real content shifts).
74
  const innerRef = useRef<HTMLDivElement>(null);
20✔
75
  const wasAtBottomRef = useAtBottomRef(scrollRef);
20✔
76

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

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

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

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

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

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

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

238
  function handleReply(input: SendMessageInput) {
239
    send.mutate({ ...input, parentMessageID: threadRootID });
1✔
240
  }
241

242
  return (
20✔
243
    <aside className="w-[28rem] border-l flex flex-col" aria-label="Thread">
244
      <div className="flex items-center justify-between border-b px-4 py-3">
245
        <h2 className="text-sm font-semibold">Thread</h2>
246
        <Button
247
          variant="ghost"
248
          size="icon"
249
          className="h-7 w-7"
250
          onClick={onClose}
251
          aria-label="Close thread"
252
        >
253
          <X className="h-4 w-4" />
254
        </Button>
255
      </div>
256
      <MessageDropZone onFiles={(files) => void inputRef.current?.uploadFiles(files)}>
×
257
        <div ref={scrollRef} className="flex-1 overflow-y-auto">
258
          <div ref={innerRef} className="p-2 space-y-2">
259
            {isLoading && (
26✔
260
              <p className="text-xs text-muted-foreground p-2">Loading replies...</p>
261
            )}
262
            {data?.length === 0 && (
24✔
263
              <p className="text-xs text-muted-foreground p-2">No replies yet. Start the thread!</p>
264
            )}
265
            {data?.map((msg) => {
266
              const u = mergedUserMap[msg.authorID];
19✔
267
              return (
19✔
268
                <MessageItem
269
                  key={msg.id}
270
                  message={msg}
271
                  authorName={u?.displayName ?? 'Unknown'}
31✔
272
                  authorAvatarURL={u?.avatarURL}
273
                  authorOnline={u?.online}
274
                  isOwn={msg.authorID === currentUserId}
275
                  channelId={channelId}
276
                  conversationId={conversationId}
277
                  currentUserId={currentUserId}
278
                  userMap={userLookup}
279
                  inThread
280
                />
281
              );
282
            })}
283
          </div>
284
        </div>
285
        <MessageInput
286
          ref={inputRef}
287
          onSend={handleReply}
288
          disabled={send.isPending}
289
          placeholder="Reply..."
290
          focusKey={threadRootID}
291
        />
292
      </MessageDropZone>
293
    </aside>
294
  );
295
}
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