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

DigitalTolk / ex / 24971580987

27 Apr 2026 12:56AM UTC coverage: 89.909% (-0.2%) from 90.069%
24971580987

push

github

web-flow
Ux improvements Part 1 (#18)

1633 of 1947 branches covered (83.87%)

Branch coverage included in aggregate %.

406 of 463 new or added lines in 37 files covered. (87.69%)

6 existing lines in 2 files now uncovered.

8177 of 8964 relevant lines covered (91.22%)

22.85 hits per line

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

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

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

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

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

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

55
  const root = messages?.[0];
13✔
56
  const replies = messages?.slice(1) ?? [];
13✔
57
  const totalCount = messages?.length ?? 0;
13✔
58

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

69
  // Author lookup. Root + replies authors all appear in this card, so we
70
  // batch them into a single request.
71
  const authorIDs = useMemo(() => {
13✔
72
    const ids = new Set<string>();
11✔
73
    for (const m of messages ?? []) ids.add(m.authorID);
21✔
74
    return [...ids];
11✔
75
  }, [messages]);
76
  const { map: userMap } = useUsersBatch(authorIDs);
13✔
77
  const presence = usePresence();
13✔
78

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

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

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

120
      <div className="p-2">
121
        {isLoading && (
20✔
122
          <div className="space-y-2 p-2">
123
            <Skeleton className="h-12 w-full" />
124
            <Skeleton className="h-8 w-3/4" />
125
          </div>
126
        )}
127

128
        {!isLoading && root && (
23✔
129
          <MessageItem
130
            message={root}
131
            authorName={userMap.get(root.authorID)?.displayName ?? 'Unknown'}
8✔
132
            authorAvatarURL={userMap.get(root.authorID)?.avatarURL}
133
            authorOnline={presence.isOnline(root.authorID)}
134
            isOwn={root.authorID === currentUserId}
135
            channelId={channelId}
136
            conversationId={conversationId}
137
            currentUserId={currentUserId}
138
            inThread
139
          />
140
        )}
141

142
        {!isLoading && isLong && !expanded && (
22✔
143
          <button
144
            type="button"
145
            onClick={() => setExpanded(true)}
1✔
146
            data-testid="thread-card-expand"
147
            className="my-1 ml-12 block text-xs font-medium text-primary hover:underline"
148
          >
149
            Show {hiddenCount} more {hiddenCount === 1 ? 'reply' : 'replies'}
1!
150
          </button>
151
        )}
152

153
        {!isLoading &&
19✔
154
          visibleReplies.map((msg) => (
155
            <MessageItem
20✔
156
              key={msg.id}
157
              message={msg}
158
              authorName={userMap.get(msg.authorID)?.displayName ?? 'Unknown'}
40✔
159
              authorAvatarURL={userMap.get(msg.authorID)?.avatarURL}
160
              authorOnline={presence.isOnline(msg.authorID)}
161
              isOwn={msg.authorID === currentUserId}
162
              channelId={channelId}
163
              conversationId={conversationId}
164
              currentUserId={currentUserId}
165
              inThread
166
            />
167
          ))}
168
      </div>
169

170
      {/* Reply composer — sends with parentMessageID set so the post
171
          lands as a thread reply. Disabled while the previous reply is
172
          still in flight so a stuttering double-Enter can't double-post. */}
173
      <div className="border-t bg-background">
174
        <MessageInput
175
          onSend={handleReply}
176
          disabled={send.isPending}
177
          placeholder="Reply…"
178
        />
179
      </div>
180
    </article>
181
  );
182
}
183

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