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

DigitalTolk / ex-mobile / 25401974848

05 May 2026 08:59PM UTC coverage: 98.125%. First build
25401974848

Pull #1

github

web-flow
Merge 3387c33c0 into 0fd8748c9
Pull Request #1: Init

129 of 134 branches covered (96.27%)

Branch coverage included in aggregate %.

185 of 186 new or added lines in 9 files covered. (99.46%)

185 of 186 relevant lines covered (99.46%)

11.6 hits per line

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

96.08
/src/components/ChatShell.tsx
1
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
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) {
16
  const [channels, setChannels] = useState<UserChannel[]>([]);
57✔
17
  const [conversations, setConversations] = useState<UserConversation[]>([]);
57✔
18
  const [selected, setSelected] = useState<Space | null>(null);
57✔
19
  const [messages, setMessages] = useState<Message[]>([]);
57✔
20
  const [draft, setDraft] = useState('');
57✔
21
  const [loading, setLoading] = useState(false);
57✔
22
  const [error, setError] = useState<string | null>(null);
57✔
23

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

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

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

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

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

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

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

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

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