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

DigitalTolk / ex / 25040414161

28 Apr 2026 07:43AM UTC coverage: 89.685% (-0.05%) from 89.738%
25040414161

Pull #27

github

web-flow
Merge dfaafbf6a into 542e2d60c
Pull Request #27: Fix thread emoji hover

1894 of 2283 branches covered (82.96%)

Branch coverage included in aggregate %.

11 of 15 new or added lines in 2 files covered. (73.33%)

36 existing lines in 9 files now uncovered.

8705 of 9535 relevant lines covered (91.3%)

25.2 hits per line

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

91.04
/frontend/src/components/threads/ThreadCard.tsx
1
import { useMemo, useRef, useState } from 'react';
2
import { Link } from 'react-router-dom';
3
import { Globe, MessageSquare } from 'lucide-react';
4
import { MessageItem } from '@/components/chat/MessageItem';
5
import { MessageInput, type MessageInputHandle } from '@/components/chat/MessageInput';
6
import { MessageDropZone } from '@/components/chat/MessageDropZone';
7
import { Skeleton } from '@/components/ui/skeleton';
8
import { useUsersBatch } from '@/hooks/useUsersBatch';
9
import { useSendMessage, type SendMessageInput } from '@/hooks/useMessages';
10
import { useInView } from '@/hooks/useInView';
11
import { usePresence } from '@/context/PresenceContext';
12
import { collectMessageUserIDs } from '@/lib/message-users';
13
import {
14
  markThreadSeen,
15
  useThreadMessages,
16
  type ThreadSummary,
17
} from '@/hooks/useThreads';
18

19
interface ThreadCardProps {
20
  summary: ThreadSummary;
21
  // Title text (e.g. "~general" or "Bob"). The page resolves it from
22
  // userChannels / userConversations and passes it in so each card
23
  // doesn't have to re-derive it.
24
  title: string;
25
  // URL the title links to — opens the thread in its parent view via the
26
  // existing `?thread=…` deep-link the channel/conversation pages handle.
27
  deepLink: string;
28
  currentUserId?: string;
29
}
30

31
// Cap the number of fully-rendered messages per thread before we collapse
32
// the middle. Threads with more than this many entries (root + replies)
33
// show the root, a "Show N more replies" toggle, and the last 2 replies.
34
const FULL_RENDER_CAP = 10;
3✔
35
const TAIL_LENGTH = 2;
3✔
36

37
// ThreadCard renders one thread on the Threads page as a self-contained
38
// chat snippet: clickable title → root message → some/all replies →
39
// reply composer. Each card fetches its own thread messages; React
40
// Query's keyed cache means clicking into the channel/conversation view
41
// doesn't re-fetch.
42
export function ThreadCard({ summary, title, deepLink, currentUserId }: ThreadCardProps) {
43
  const channelId = summary.parentType === 'channel' ? summary.parentID : undefined;
13!
44
  const conversationId = summary.parentType === 'conversation' ? summary.parentID : undefined;
13!
45

46
  // Defer fetching until the card is about to scroll into view —
47
  // /threads with 50+ entries would otherwise fan out 50 parallel
48
  // /thread requests on first render.
49
  const { ref, inView } = useInView<HTMLElement>();
13✔
50
  const inputRef = useRef<MessageInputHandle>(null);
13✔
51
  const { data: messages, isLoading } = useThreadMessages({
13✔
52
    channelId,
53
    conversationId,
54
    threadRootID: summary.threadRootID,
55
    enabled: inView,
56
  });
57

58
  const root = messages?.[0];
13✔
59
  const replies = messages?.slice(1) ?? [];
13✔
60
  const totalCount = messages?.length ?? 0;
13✔
61

62
  // Collapse the middle of a long thread. We keep the root visible at
63
  // the top and the last TAIL_LENGTH replies at the bottom, hiding the
64
  // ones in between behind a toggle. Threads under FULL_RENDER_CAP are
65
  // shown in full.
66
  const [expanded, setExpanded] = useState(false);
13✔
67
  const isLong = totalCount > FULL_RENDER_CAP;
13✔
68
  const tail = isLong ? replies.slice(-TAIL_LENGTH) : replies;
13✔
69
  const hiddenCount = isLong ? replies.length - TAIL_LENGTH : 0;
13✔
70
  const visibleReplies = expanded || !isLong ? replies : tail;
13✔
71

72
  // User lookup covering authors + reactors so the reaction tooltip
73
  // doesn't fall back to "Unknown" when someone reacts who isn't an
74
  // author in this thread.
75
  const userIDs = useMemo(
13✔
76
    () => collectMessageUserIDs(messages ?? []),
19✔
77
    [messages],
78
  );
79
  const { map: userMap } = useUsersBatch(userIDs);
13✔
80
  const presence = usePresence();
13✔
81

82
  // useSendMessage invalidates the same ['thread', parentPath, rootID]
83
  // key the hook above subscribes to, so a reply lands without an
84
  // extra fetch from us.
85
  const send = useSendMessage({ channelId, conversationId });
13✔
86

87
  function handleReply(input: SendMessageInput) {
88
    send.mutate({ ...input, parentMessageID: summary.threadRootID });
2✔
89
    // Treat sending as "seeing" — drops the unread dot in the sidebar
90
    // since the user is clearly engaged with this thread.
91
    markThreadSeen(summary.threadRootID);
2✔
92
  }
93

94
  return (
13✔
95
    <article
96
      ref={ref}
97
      data-testid="thread-card"
98
      data-thread-root-id={summary.threadRootID}
99
      data-in-view={inView ? 'true' : 'false'}
13✔
100
      className="rounded-lg border bg-card overflow-hidden"
101
    >
102
      {/* Title — same shape as a channel/conversation header. Clicking
103
          opens the thread in its parent view. */}
104
      <header className="flex items-center gap-2 border-b px-4 py-2.5">
105
        {summary.parentType === 'channel' ? (
13!
106
          <Globe className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
107
        ) : (
108
          <MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
109
        )}
110
        <Link
111
          to={deepLink}
112
          data-testid="thread-card-title"
113
          className="truncate text-sm font-semibold hover:underline"
114
          onClick={() => markThreadSeen(summary.threadRootID)}
×
115
        >
116
          {title}
117
        </Link>
118
        <span className="ml-auto text-xs text-muted-foreground">
119
          {summary.replyCount} {summary.replyCount === 1 ? 'reply' : 'replies'}
13✔
120
        </span>
121
      </header>
122

123
      <MessageDropZone
124
        className="relative"
UNCOV
125
        onFiles={(files) => void inputRef.current?.uploadFiles(files)}
×
126
      >
127
        <div className="p-2">
128
          {isLoading && (
20✔
129
            <div className="space-y-2 p-2">
130
              <Skeleton className="h-12 w-full" />
131
              <Skeleton className="h-8 w-3/4" />
132
            </div>
133
          )}
134

135
          {!isLoading && root && (
23✔
136
            <MessageItem
137
              message={root}
138
              authorName={userMap.get(root.authorID)?.displayName ?? 'Unknown'}
8✔
139
              authorAvatarURL={userMap.get(root.authorID)?.avatarURL}
140
              authorOnline={presence.isOnline(root.authorID)}
141
              isOwn={root.authorID === currentUserId}
142
              channelId={channelId}
143
              conversationId={conversationId}
144
              currentUserId={currentUserId}
145
              userMap={userMap}
146
              inThread
147
            />
148
          )}
149

150
          {!isLoading && isLong && !expanded && (
22✔
151
            <button
152
              type="button"
153
              onClick={() => setExpanded(true)}
1✔
154
              data-testid="thread-card-expand"
155
              className="my-1 ml-12 block text-xs font-medium text-primary hover:underline"
156
            >
157
              Show {hiddenCount} more {hiddenCount === 1 ? 'reply' : 'replies'}
1!
158
            </button>
159
          )}
160

161
          {!isLoading &&
19✔
162
            visibleReplies.map((msg) => (
163
              <MessageItem
20✔
164
                key={msg.id}
165
                message={msg}
166
                authorName={userMap.get(msg.authorID)?.displayName ?? 'Unknown'}
40✔
167
                authorAvatarURL={userMap.get(msg.authorID)?.avatarURL}
168
                authorOnline={presence.isOnline(msg.authorID)}
169
                isOwn={msg.authorID === currentUserId}
170
                channelId={channelId}
171
                conversationId={conversationId}
172
                currentUserId={currentUserId}
173
                userMap={userMap}
174
                inThread
175
              />
176
            ))}
177
        </div>
178

179
        {/* Reply composer — sends with parentMessageID set so the post
180
            lands as a thread reply. Disabled while the previous reply is
181
            still in flight so a stuttering double-Enter can't double-post. */}
182
        <div className="border-t bg-background">
183
          <MessageInput
184
            ref={inputRef}
185
            onSend={handleReply}
186
            disabled={send.isPending}
187
            placeholder="Reply…"
188
          />
189
        </div>
190
      </MessageDropZone>
191
    </article>
192
  );
193
}
194

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