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

DigitalTolk / ex / 25038846045

28 Apr 2026 07:04AM UTC coverage: 89.734% (-0.04%) from 89.773%
25038846045

Pull #26

github

web-flow
Merge 9e0b08f2b into ad7912da8
Pull Request #26: Scroll fixes

1891 of 2277 branches covered (83.05%)

Branch coverage included in aggregate %.

58 of 60 new or added lines in 16 files covered. (96.67%)

40 existing lines in 3 files now uncovered.

8686 of 9510 relevant lines covered (91.34%)

25.23 hits per line

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

91.43
/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 {
13
  markThreadSeen,
14
  useThreadMessages,
15
  type ThreadSummary,
16
} from '@/hooks/useThreads';
17

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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