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

DigitalTolk / ex-mobile / 25394140712

05 May 2026 06:16PM UTC coverage: 97.115%. First build
25394140712

Pull #1

github

web-flow
Merge 7473f811d into 0fd8748c9
Pull Request #1: Init

139 of 153 branches covered (90.85%)

Branch coverage included in aggregate %.

467 of 471 new or added lines in 9 files covered. (99.15%)

467 of 471 relevant lines covered (99.15%)

11.43 hits per line

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

97.21
/src/components/ChatShell.tsx
1
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
1✔
2
import { Hash, LogOut, MessageCircle, RefreshCcw, Send, Server, UsersRound } from 'lucide-react';
3
import { apiFetch } from '../lib/api';
4
import { formatMessageTime } from '../lib/format';
5
import type { Message, PaginatedResponse, Space, User, UserChannel, UserConversation } from '../types';
6

7
interface ChatShellProps {
8
  serverUrl: string;
9
  accessToken: string;
10
  user: User;
11
  onLogout: () => void;
12
  onChangeServer: () => void;
13
}
14

15
export function ChatShell({ serverUrl, accessToken, user, onLogout, onChangeServer }: ChatShellProps) {
1✔
16
  const [channels, setChannels] = useState<UserChannel[]>([]);
32✔
17
  const [conversations, setConversations] = useState<UserConversation[]>([]);
32✔
18
  const [selected, setSelected] = useState<Space | null>(null);
32✔
19
  const [messages, setMessages] = useState<Message[]>([]);
32✔
20
  const [draft, setDraft] = useState('');
32✔
21
  const [loading, setLoading] = useState(false);
32✔
22
  const [error, setError] = useState<string | null>(null);
32✔
23

24
  const spaces = useMemo<Space[]>(
32✔
25
    () => [
32✔
26
      ...channels.map((channel) => ({
12✔
27
        kind: 'channel' as const,
4✔
28
        id: channel.channelID,
4✔
29
        name: channel.channelName,
4✔
30
      })),
12✔
31
      ...conversations.map((conversation) => ({
12✔
32
        kind: 'conversation' as const,
3✔
33
        id: conversation.conversationID,
3✔
34
        name: conversation.displayName,
3✔
35
      })),
12✔
36
    ],
12✔
37
    [channels, conversations],
32✔
38
  );
32✔
39

40
  const refreshMessages = useCallback(
32✔
41
    async (space = selected) => {
32✔
42
      if (!space) return;
11✔
43
      setLoading(true);
5✔
44
      setError(null);
5✔
45
      try {
5✔
46
        const path =
5✔
47
          space.kind === 'channel'
5✔
48
            ? `/api/v1/channels/${encodeURIComponent(space.id)}/messages`
3✔
49
            : `/api/v1/conversations/${encodeURIComponent(space.id)}/messages`;
2✔
50
        const page = await apiFetch<PaginatedResponse<Message>>(serverUrl, accessToken, path);
11✔
51
        setMessages([...page.items].reverse());
4✔
52
      } catch (err) {
11✔
53
        setError(err instanceof Error ? err.message : 'Failed to load messages.');
1!
54
      } finally {
11✔
55
        setLoading(false);
5✔
56
      }
5✔
57
    },
11✔
58
    [accessToken, selected, serverUrl],
32✔
59
  );
32✔
60

61
  const refreshSpaces = useCallback(async () => {
32✔
62
    setError(null);
7✔
63
    const [nextChannels, nextConversations] = await Promise.all([
7✔
64
      apiFetch<UserChannel[]>(serverUrl, accessToken, '/api/v1/channels'),
7✔
65
      apiFetch<UserConversation[]>(serverUrl, accessToken, '/api/v1/conversations'),
7✔
66
    ]);
7✔
67
    setChannels(nextChannels);
6✔
68
    setConversations(nextConversations);
6✔
69
    setSelected((current) => current ?? spaceFromLists(nextChannels, nextConversations));
6✔
70
  }, [accessToken, serverUrl]);
32✔
71

72
  async function sendMessage(event: FormEvent<HTMLFormElement>) {
32✔
73
    event.preventDefault();
1✔
74
    if (!selected || !draft.trim()) return;
1!
75
    const path =
1✔
76
      selected.kind === 'channel'
1✔
77
        ? `/api/v1/channels/${encodeURIComponent(selected.id)}/messages`
1!
NEW
78
        : `/api/v1/conversations/${encodeURIComponent(selected.id)}/messages`;
×
79
    const optimisticBody = draft.trim();
1✔
80
    setDraft('');
1✔
81
    const message = await apiFetch<Message>(serverUrl, accessToken, path, {
1✔
82
      method: 'POST',
1✔
83
      body: JSON.stringify({ body: optimisticBody }),
1✔
84
    });
1✔
85
    setMessages((current) => [...current, message]);
1✔
86
  }
1✔
87

88
  useEffect(() => {
32✔
89
    refreshSpaces().catch((err) => {
6✔
90
      setError(err instanceof Error ? err.message : 'Failed to load workspace.');
1!
91
    });
6✔
92
  }, [refreshSpaces]);
32✔
93

94
  useEffect(() => {
32✔
95
    refreshMessages().catch((err) => {
11✔
NEW
96
      setError(err instanceof Error ? err.message : 'Failed to load messages.');
×
97
    });
11✔
98
  }, [refreshMessages]);
32✔
99

100
  return (
32✔
101
    <main className="chat-shell">
32✔
102
      <aside className="spaces">
32✔
103
        <div className="spaces-header">
32✔
104
          <div>
32✔
105
            <strong>{user.displayName}</strong>
32✔
106
            <span>{user.email}</span>
32✔
107
          </div>
32✔
108
          <button type="button" className="icon-button" aria-label="Refresh" onClick={() => void refreshSpaces()}>
32✔
109
            <RefreshCcw size={18} />
32✔
110
          </button>
32✔
111
        </div>
32✔
112
        <nav aria-label="Workspace">
32✔
113
          {spaces.map((space) => (
32✔
114
            <button
43✔
115
              type="button"
43✔
116
              key={`${space.kind}:${space.id}`}
43✔
117
              className={selected?.kind === space.kind && selected.id === space.id ? 'active' : ''}
43✔
118
              onClick={() => setSelected(space)}
43✔
119
            >
120
              {space.kind === 'channel' ? <Hash size={17} /> : <UsersRound size={17} />}
43✔
121
              <span>{space.name}</span>
43✔
122
            </button>
43✔
123
          ))}
32✔
124
        </nav>
32✔
125
        <div className="spaces-actions">
32✔
126
          <button type="button" className="secondary-button" onClick={onChangeServer}>
32✔
127
            <Server size={17} />
32✔
128
            Server
129
          </button>
32✔
130
          <button type="button" className="secondary-button" onClick={onLogout}>
32✔
131
            <LogOut size={17} />
32✔
132
            Sign out
133
          </button>
32✔
134
        </div>
32✔
135
      </aside>
32✔
136

137
      <section className="messages-pane">
32✔
138
        <header>
32✔
139
          <div>
32✔
140
            <span className="space-kicker">{selected?.kind === 'channel' ? 'Channel' : 'Conversation'}</span>
32✔
141
            <h1>{selected?.name ?? 'No conversations yet'}</h1>
32✔
142
          </div>
32✔
143
          <MessageCircle size={24} />
32✔
144
        </header>
32✔
145
        {error && <p className="form-error inline">{error}</p>}
32✔
146
        <div className="message-list" aria-live="polite">
32✔
147
          {loading && <p className="empty-state">Loading messages...</p>}
32✔
148
          {!loading && messages.length === 0 && <p className="empty-state">No messages.</p>}
32✔
149
          {messages.map((message) => (
32✔
150
            <article key={message.id} className={message.authorID === user.id ? 'message own' : 'message'}>
29✔
151
              <div className="message-meta">
29✔
152
                <strong>{message.authorID === user.id ? user.displayName : message.authorID}</strong>
29✔
153
                <time dateTime={message.createdAt}>{formatMessageTime(message.createdAt)}</time>
29✔
154
              </div>
29✔
155
              <p>{message.deleted ? 'Message deleted' : message.body}</p>
29✔
156
            </article>
29✔
157
          ))}
32✔
158
        </div>
32✔
159
        <form className="composer" onSubmit={sendMessage}>
32✔
160
          <input
32✔
161
            aria-label="Message"
32✔
162
            placeholder={selected ? `Message ${selected.name}` : 'Select a conversation'}
32✔
163
            value={draft}
32✔
164
            disabled={!selected}
32✔
165
            onChange={(event) => setDraft(event.target.value)}
32✔
166
          />
32✔
167
          <button type="submit" aria-label="Send message" disabled={!selected || !draft.trim()}>
32✔
168
            <Send size={19} />
32✔
169
          </button>
32✔
170
        </form>
32✔
171
      </section>
32✔
172
    </main>
32✔
173
  );
174
}
32✔
175

176
function spaceFromLists(channels: UserChannel[], conversations: UserConversation[]): Space | null {
5✔
177
  const firstChannel = channels[0];
5✔
178
  if (firstChannel) return { kind: 'channel', id: firstChannel.channelID, name: firstChannel.channelName };
5✔
179
  const firstConversation = conversations[0];
2✔
180
  if (firstConversation) {
5✔
181
    return { kind: 'conversation', id: firstConversation.conversationID, name: firstConversation.displayName };
1✔
182
  }
1✔
183
  return null;
1✔
184
}
1✔
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