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

DigitalTolk / ex / 25339076788

04 May 2026 07:32PM UTC coverage: 92.457% (+0.03%) from 92.429%
25339076788

push

github

web-flow
Notification counters (#63)

* Notification counters

* fix

* fixes

* fixes

* fixes

* fixes

2280 of 2532 branches covered (90.05%)

Branch coverage included in aggregate %.

639 of 699 new or added lines in 33 files covered. (91.42%)

1 existing line in 1 file now uncovered.

12820 of 13800 relevant lines covered (92.9%)

1123.87 hits per line

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

84.17
/frontend/src/components/threads/ThreadCard.tsx
1
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
import { Link } from 'react-router-dom';
3
import { BellOff, Globe, MessageSquare } from 'lucide-react';
4
import { Button } from '@/components/ui/button';
5
import { MessageItem } from '@/components/chat/MessageItem';
6
import { MessageInput, type MessageInputHandle } from '@/components/chat/MessageInput';
7
import { MessageDropZone } from '@/components/chat/MessageDropZone';
8
import { Skeleton } from '@/components/ui/skeleton';
9
import { useUsersBatch } from '@/hooks/useUsersBatch';
10
import { useSendMessage, type SendMessageInput } from '@/hooks/useMessages';
11
import { useInView } from '@/hooks/useInView';
12
import {
13
  restoreDraftScope,
14
  restoreDraftScopeForContent,
15
  suppressSentDraft,
16
  useDeleteDraft,
17
  useDraftAttachmentChips,
18
  useDraftForScope,
19
  useSaveDraft,
20
} from '@/hooks/useDrafts';
21
import { usePresence } from '@/context/PresenceContext';
22
import { collectMessageUserIDs } from '@/lib/message-users';
23
import {
24
  markThreadSeen,
25
  useThreadMessages,
26
  useUnfollowThread,
27
  type ThreadSummary,
28
} from '@/hooks/useThreads';
29

30
interface ThreadCardProps {
31
  summary: ThreadSummary;
32
  // Title text (e.g. "~general" or "Bob"). The page resolves it from
33
  // userChannels / userConversations and passes it in so each card
34
  // doesn't have to re-derive it.
35
  title: string;
36
  // URL the title links to — opens the thread in its parent view via the
37
  // existing `?thread=…` deep-link the channel/conversation pages handle.
38
  deepLink: string;
39
  currentUserId?: string;
40
  unread?: boolean;
41
}
42

43
function markSummaryThreadSeen(summary: ThreadSummary) {
44
  markThreadSeen(summary.threadRootID, summary.latestActivityAt, {
4✔
45
    parentID: summary.parentID,
46
    parentType: summary.parentType,
47
  });
48
}
49

50
// Cap the number of fully-rendered messages per thread before we collapse
51
// the middle. Threads with more than this many entries (root + replies)
52
// show the root, a "Show N more replies" toggle, and the last 2 replies.
53
const FULL_RENDER_CAP = 10;
3✔
54
const TAIL_LENGTH = 2;
3✔
55

56
// ThreadCard renders one thread on the Threads page as a self-contained
57
// chat snippet: clickable title → root message → some/all replies →
58
// reply composer. Each card fetches its own thread messages; React
59
// Query's keyed cache means clicking into the channel/conversation view
60
// doesn't re-fetch.
61
export function ThreadCard({ summary, title, deepLink, currentUserId, unread = false }: ThreadCardProps) {
21✔
62
  const channelId = summary.parentType === 'channel' ? summary.parentID : undefined;
21!
63
  const conversationId = summary.parentType === 'conversation' ? summary.parentID : undefined;
21!
64

65
  // Defer fetching until the card is about to scroll into view —
66
  // /threads with 50+ entries would otherwise fan out 50 parallel
67
  // /thread requests on first render.
68
  const { ref, inView } = useInView<HTMLElement>();
21✔
69
  const inputRef = useRef<MessageInputHandle>(null);
21✔
70

71
  useEffect(() => {
21✔
72
    if (!inView || !unread) return;
16✔
73
    markSummaryThreadSeen(summary);
2✔
74
  }, [inView, summary, unread]);
75

76
  const { data: messages, isLoading } = useThreadMessages({
21✔
77
    channelId,
78
    conversationId,
79
    threadRootID: summary.threadRootID,
80
    enabled: inView,
81
  });
82

83
  const root = messages?.[0];
21✔
84
  const replies = messages?.slice(1) ?? [];
21✔
85
  const totalCount = messages?.length ?? 0;
21✔
86

87
  // Collapse the middle of a long thread. We keep the root visible at
88
  // the top and the last TAIL_LENGTH replies at the bottom, hiding the
89
  // ones in between behind a toggle. Threads under FULL_RENDER_CAP are
90
  // shown in full.
91
  const [expanded, setExpanded] = useState(false);
21✔
92
  const isLong = totalCount > FULL_RENDER_CAP;
21✔
93
  const tail = isLong ? replies.slice(-TAIL_LENGTH) : replies;
21✔
94
  const hiddenCount = isLong ? replies.length - TAIL_LENGTH : 0;
21✔
95
  const visibleReplies = expanded || !isLong ? replies : tail;
21✔
96

97
  // User lookup covering authors + reactors so the reaction tooltip
98
  // doesn't fall back to "Unknown" when someone reacts who isn't an
99
  // author in this thread.
100
  const userIDs = useMemo(
21✔
101
    () => collectMessageUserIDs(messages ?? []),
30✔
102
    [messages],
103
  );
104
  const { map: userMap } = useUsersBatch(userIDs);
21✔
105
  const presence = usePresence();
21✔
106
  const unfollowThread = useUnfollowThread();
21✔
107

108
  // useSendMessage invalidates the same ['thread', parentPath, rootID]
109
  // key the hook above subscribes to, so a reply lands without an
110
  // extra fetch from us.
111
  const send = useSendMessage({ channelId, conversationId });
21✔
112
  const parentID = channelId ?? conversationId;
21!
113
  const parentType: 'channel' | 'conversation' = channelId ? 'channel' : 'conversation';
21!
114
  const draftScope = useMemo(
21✔
115
    () => ({ parentID, parentType, parentMessageID: summary.threadRootID }),
13✔
116
    [parentID, parentType, summary.threadRootID],
117
  );
118
  const { data: draft } = useDraftForScope(draftScope);
21✔
119
  const draftAttachments = useDraftAttachmentChips(draft?.attachmentIDs);
21✔
120
  const draftID = draft?.id;
21✔
121
  const saveDraft = useSaveDraft();
21✔
122
  const deleteDraft = useDeleteDraft();
21✔
123
  const saveDraftMutate = saveDraft.mutate;
21✔
124
  const deleteDraftMutate = deleteDraft.mutate;
21✔
125

126
  const handleDraftChange = useCallback(
21✔
127
    (input: SendMessageInput) => {
128
      if (!parentID) return;
×
NEW
129
      restoreDraftScopeForContent(draftScope, input);
×
UNCOV
130
      saveDraftMutate({
×
131
        parentID,
132
        parentType,
133
        parentMessageID: summary.threadRootID,
134
        body: input.body,
135
        attachmentIDs: input.attachmentIDs ?? [],
×
136
      });
137
    },
138
    [parentID, parentType, summary.threadRootID, draftScope, saveDraftMutate],
139
  );
140

141
  const handleReply = useCallback(
21✔
142
    (input: SendMessageInput) => {
143
      const payload = { ...input, parentMessageID: summary.threadRootID };
2✔
144
      suppressSentDraft(draftScope);
2✔
145
      if (draftID) {
2!
NEW
146
        send.mutate(payload, {
×
NEW
147
          onSuccess: () => deleteDraftMutate(draftID),
×
NEW
148
          onError: () => restoreDraftScope(draftScope),
×
149
        });
150
      } else {
151
        send.mutate(payload, { onError: () => restoreDraftScope(draftScope) });
2✔
152
      }
153
      // Treat sending as "seeing" — drops the unread dot in the sidebar
154
      // since the user is clearly engaged with this thread.
155
      markSummaryThreadSeen(summary);
2✔
156
    },
157
    [send, summary, draftScope, draftID, deleteDraftMutate],
158
  );
159

160
  return (
21✔
161
    <article
162
      ref={ref}
163
      data-testid="thread-card"
164
      data-thread-root-id={summary.threadRootID}
165
      data-in-view={inView ? 'true' : 'false'}
21✔
166
      data-unread={unread ? 'true' : 'false'}
21✔
167
      className={
168
        'rounded-lg border bg-card overflow-hidden ' +
169
        (unread ? 'border-primary bg-primary/10 shadow-sm ring-1 ring-primary/30' : '')
21✔
170
      }
171
    >
172
      {/* Title — same shape as a channel/conversation header. Clicking
173
          opens the thread in its parent view. */}
174
      <header className={'flex items-center gap-2 border-b px-4 py-2.5 ' + (unread ? 'bg-primary/10' : '')}>
21✔
175
        {summary.parentType === 'channel' ? (
21!
176
          <Globe className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
177
        ) : (
178
          <MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
179
        )}
180
        <Link
181
          to={deepLink}
182
          data-testid="thread-card-title"
183
          className="truncate text-sm font-semibold"
NEW
184
          onClick={() => markSummaryThreadSeen(summary)}
×
185
        >
186
          {title}
187
        </Link>
188
        {unread && (
24✔
189
          <span
190
            data-testid="thread-card-unread"
191
            className="rounded-full bg-primary px-2 py-0.5 text-[11px] font-semibold text-primary-foreground"
192
          >
193
            Unread
194
          </span>
195
        )}
196
        <Button
197
          type="button"
198
          variant="ghost"
199
          size="sm"
200
          className="ml-auto h-7 gap-1 px-2 text-xs"
201
          disabled={unfollowThread.isPending}
202
          onClick={() => unfollowThread.mutate({
1✔
203
            parentID: summary.parentID,
204
            parentType: summary.parentType,
205
            threadRootID: summary.threadRootID,
206
          })}
207
          aria-label="Unfollow thread"
208
        >
209
          <BellOff className="h-3.5 w-3.5" />
210
          Unfollow
211
        </Button>
212
        <span className="text-xs text-muted-foreground">
213
          {summary.replyCount} {summary.replyCount === 1 ? 'reply' : 'replies'}
21✔
214
        </span>
215
      </header>
216

217
      <MessageDropZone
218
        className="relative"
219
        onFiles={(files) => void inputRef.current?.uploadFiles(files)}
×
220
      >
221
        <div className="p-2">
222
          {isLoading && (
33✔
223
            <div className="space-y-2 p-2">
224
              <Skeleton className="h-12 w-full" />
225
              <Skeleton className="h-8 w-3/4" />
226
            </div>
227
          )}
228

229
          {!isLoading && root && (
35✔
230
            <MessageItem
231
              message={root}
232
              authorName={userMap.get(root.authorID)?.displayName ?? 'Unknown'}
10✔
233
              authorAvatarURL={userMap.get(root.authorID)?.avatarURL}
234
              authorOnline={presence.isOnline(root.authorID)}
235
              isOwn={root.authorID === currentUserId}
236
              channelId={channelId}
237
              conversationId={conversationId}
238
              currentUserId={currentUserId}
239
              userMap={userMap}
240
              inThread
241
            />
242
          )}
243

244
          {!isLoading && isLong && !expanded && (
33✔
245
            <button
246
              type="button"
247
              onClick={() => setExpanded(true)}
1✔
248
              data-testid="thread-card-expand"
249
              className="my-1 ml-12 block text-xs font-medium text-link transition-colors hover:text-link/80"
250
            >
251
              Show {hiddenCount} more {hiddenCount === 1 ? 'reply' : 'replies'}
1!
252
            </button>
253
          )}
254

255
          {!isLoading &&
30✔
256
            visibleReplies.map((msg) => (
257
              <MessageItem
20✔
258
                key={msg.id}
259
                message={msg}
260
                authorName={userMap.get(msg.authorID)?.displayName ?? 'Unknown'}
40✔
261
                authorAvatarURL={userMap.get(msg.authorID)?.avatarURL}
262
                authorOnline={presence.isOnline(msg.authorID)}
263
                isOwn={msg.authorID === currentUserId}
264
                channelId={channelId}
265
                conversationId={conversationId}
266
                currentUserId={currentUserId}
267
                userMap={userMap}
268
                inThread
269
              />
270
            ))}
271
        </div>
272

273
        {/* Reply composer — sends with parentMessageID set so the post
274
            lands as a thread reply. Disabled while the previous reply is
275
            still in flight so a stuttering double-Enter can't double-post. */}
276
        <div className="border-t bg-background">
277
          <MessageInput
278
            ref={inputRef}
279
            onSend={handleReply}
280
            disabled={send.isPending}
281
            placeholder="Reply…"
282
            initialBody={draft?.body ?? ''}
42✔
283
            initialDrafts={draftAttachments}
284
            onDraftChange={handleDraftChange}
285
          />
286
        </div>
287
      </MessageDropZone>
288
    </article>
289
  );
290
}
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