Skip to content

Commit f66174d

Browse files
authored
Merge pull request #122 from Resgrid/develop
RG-T117 Chat and Chatbot Entrypoints
2 parents 9bedcf5 + f389bd7 commit f66174d

41 files changed

Lines changed: 4699 additions & 35 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

env.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ const client = z.object({
8585
RESGRID_API_URL: z.string(),
8686
CHANNEL_HUB_NAME: z.string(),
8787
REALTIME_GEO_HUB_NAME: z.string(),
88+
CHAT_HUB_NAME: z.string(),
8889
LOGGING_KEY: z.string(),
8990
APP_KEY: z.string(),
9091
MAPBOX_PUBKEY: z.string(),
@@ -120,6 +121,7 @@ const _clientEnv = {
120121
RESGRID_API_URL: process.env.DISPATCH_RESGRID_API_URL || '/api/v4',
121122
CHANNEL_HUB_NAME: process.env.DISPATCH_CHANNEL_HUB_NAME || 'eventingHub',
122123
REALTIME_GEO_HUB_NAME: process.env.DISPATCH_REALTIME_GEO_HUB_NAME || 'geolocationHub',
124+
CHAT_HUB_NAME: process.env.DISPATCH_CHAT_HUB_NAME || 'chatHub',
123125
LOGGING_KEY: process.env.DISPATCH_LOGGING_KEY || '',
124126
APP_KEY: process.env.DISPATCH_APP_KEY || '',
125127
IS_MOBILE_APP: true, // or whatever default you want

pnpm-workspace.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
allowBuilds:
2+
'@react-buoy/shared-ui': set this to true or false
3+
'@sentry/cli': set this to true or false
4+
electron: set this to true or false
5+
electron-winstaller: set this to true or false
6+
postinstall-postinstall: set this to true or false

src/api/chat/chat.ts

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { getBaseApiUrl } from '@/lib/storage/app';
2+
import {
3+
type AddMembersInput,
4+
type AddReactionInput,
5+
type ChatAckResultData,
6+
type ChatActionResult,
7+
type ChatAttachmentUploadedResult,
8+
type ChatChannelResultData,
9+
type ChatMemberResultData,
10+
type ChatMessageResultData,
11+
type ChatV4Response,
12+
type CreateAdHocChannelInput,
13+
type CreateDirectMessageInput,
14+
type EditMessageInput,
15+
type FlagMessageInput,
16+
type GetChatPresenceResult,
17+
type GifResultData,
18+
type MarkReadInput,
19+
type SendChatMessageInput,
20+
type SetNotificationPreferenceInput,
21+
type UpdateChannelInput,
22+
} from '@/models/v4/chat';
23+
import useAuthStore from '@/stores/auth/store';
24+
25+
import { api } from '../common/client';
26+
27+
const CHAT = '/Chat';
28+
const MODERATION = '/ChatModeration';
29+
30+
// ---------------------------------------------------------------------------
31+
// Channels
32+
// ---------------------------------------------------------------------------
33+
34+
export const getChannels = async (activeUnitId?: number, signal?: AbortSignal) => {
35+
const response = await api.get<ChatV4Response<ChatChannelResultData[]>>(`${CHAT}/GetChannels`, {
36+
params: activeUnitId != null ? { activeUnitId } : undefined,
37+
signal,
38+
});
39+
return response.data;
40+
};
41+
42+
export const getChannel = async (channelId: string, signal?: AbortSignal) => {
43+
const response = await api.get<ChatV4Response<ChatChannelResultData>>(`${CHAT}/GetChannel`, { params: { channelId }, signal });
44+
return response.data;
45+
};
46+
47+
export const createDirectMessage = async (input: CreateDirectMessageInput) => {
48+
const response = await api.post<ChatV4Response<ChatChannelResultData>>(`${CHAT}/CreateDirectMessage`, input);
49+
return response.data;
50+
};
51+
52+
export const createAdHocChannel = async (input: CreateAdHocChannelInput) => {
53+
const response = await api.post<ChatV4Response<ChatChannelResultData>>(`${CHAT}/CreateAdHocChannel`, input);
54+
return response.data;
55+
};
56+
57+
export const updateChannel = async (channelId: string, input: UpdateChannelInput) => {
58+
const response = await api.put<ChatV4Response<ChatChannelResultData>>(`${CHAT}/UpdateChannel`, input, { params: { channelId } });
59+
return response.data;
60+
};
61+
62+
export const archiveChannel = async (channelId: string) => {
63+
const response = await api.delete<ChatActionResult>(`${CHAT}/ArchiveChannel`, { params: { channelId } });
64+
return response.data;
65+
};
66+
67+
// ---------------------------------------------------------------------------
68+
// Members
69+
// ---------------------------------------------------------------------------
70+
71+
export const getMembers = async (channelId: string, signal?: AbortSignal) => {
72+
const response = await api.get<ChatV4Response<ChatMemberResultData[]>>(`${CHAT}/GetMembers`, { params: { channelId }, signal });
73+
return response.data;
74+
};
75+
76+
export const addMembers = async (channelId: string, input: AddMembersInput) => {
77+
const response = await api.post<ChatV4Response<ChatMemberResultData[]>>(`${CHAT}/AddMembers`, input, { params: { channelId } });
78+
return response.data;
79+
};
80+
81+
export const removeMember = async (channelId: string, userId: string) => {
82+
const response = await api.delete<ChatActionResult>(`${CHAT}/RemoveMember`, { params: { channelId, userId } });
83+
return response.data;
84+
};
85+
86+
export const setNotificationPreference = async (channelId: string, input: SetNotificationPreferenceInput) => {
87+
const response = await api.put<ChatActionResult>(`${CHAT}/SetNotificationPreference`, input, { params: { channelId } });
88+
return response.data;
89+
};
90+
91+
// ---------------------------------------------------------------------------
92+
// Messages
93+
// ---------------------------------------------------------------------------
94+
95+
export const getMessages = async (channelId: string, beforeSeq?: number, limit = 50, signal?: AbortSignal) => {
96+
const response = await api.get<ChatV4Response<ChatMessageResultData[]>>(`${CHAT}/GetMessages`, {
97+
params: { channelId, beforeSeq, limit },
98+
signal,
99+
});
100+
return response.data;
101+
};
102+
103+
export const getMessagesAfter = async (channelId: string, afterSeq: number, limit = 50, signal?: AbortSignal) => {
104+
const response = await api.get<ChatV4Response<ChatMessageResultData[]>>(`${CHAT}/GetMessagesAfter`, {
105+
params: { channelId, afterSeq, limit },
106+
signal,
107+
});
108+
return response.data;
109+
};
110+
111+
export const getThread = async (messageId: string, beforeSeq?: number, limit = 50, signal?: AbortSignal) => {
112+
const response = await api.get<ChatV4Response<ChatMessageResultData[]>>(`${CHAT}/GetThread`, {
113+
params: { messageId, beforeSeq, limit },
114+
signal,
115+
});
116+
return response.data;
117+
};
118+
119+
export const sendMessage = async (channelId: string, input: SendChatMessageInput) => {
120+
const response = await api.post<ChatV4Response<ChatMessageResultData>>(`${CHAT}/SendMessage`, input, { params: { channelId } });
121+
return response.data;
122+
};
123+
124+
export const editMessage = async (messageId: string, input: EditMessageInput) => {
125+
const response = await api.put<ChatV4Response<ChatMessageResultData>>(`${CHAT}/EditMessage`, input, { params: { messageId } });
126+
return response.data;
127+
};
128+
129+
export const deleteMessage = async (messageId: string) => {
130+
const response = await api.delete<ChatActionResult>(`${CHAT}/DeleteMessage`, { params: { messageId } });
131+
return response.data;
132+
};
133+
134+
// ---------------------------------------------------------------------------
135+
// Reactions, acks, read pointers, pins
136+
// ---------------------------------------------------------------------------
137+
138+
export const addReaction = async (messageId: string, input: AddReactionInput) => {
139+
const response = await api.post<ChatActionResult>(`${CHAT}/AddReaction`, input, { params: { messageId } });
140+
return response.data;
141+
};
142+
143+
export const removeReaction = async (messageId: string, emoji: string) => {
144+
const response = await api.delete<ChatActionResult>(`${CHAT}/RemoveReaction`, { params: { messageId, emoji } });
145+
return response.data;
146+
};
147+
148+
export const ackMessage = async (messageId: string) => {
149+
const response = await api.post<ChatActionResult>(`${CHAT}/Ack`, {}, { params: { messageId } });
150+
return response.data;
151+
};
152+
153+
export const getMyPendingAcks = async (signal?: AbortSignal) => {
154+
const response = await api.get<ChatV4Response<ChatAckResultData[]>>(`${CHAT}/GetMyPendingAcks`, { signal });
155+
return response.data;
156+
};
157+
158+
export const markRead = async (channelId: string, input: MarkReadInput) => {
159+
const response = await api.put<ChatActionResult>(`${CHAT}/MarkRead`, input, { params: { channelId } });
160+
return response.data;
161+
};
162+
163+
export const pinMessage = async (messageId: string) => {
164+
const response = await api.post<ChatActionResult>(`${CHAT}/PinMessage`, {}, { params: { messageId } });
165+
return response.data;
166+
};
167+
168+
export const unpinMessage = async (messageId: string) => {
169+
const response = await api.delete<ChatActionResult>(`${CHAT}/UnpinMessage`, { params: { messageId } });
170+
return response.data;
171+
};
172+
173+
export const getPins = async (channelId: string, signal?: AbortSignal) => {
174+
const response = await api.get<ChatV4Response<ChatMessageResultData[]>>(`${CHAT}/GetPins`, { params: { channelId }, signal });
175+
return response.data;
176+
};
177+
178+
// ---------------------------------------------------------------------------
179+
// Attachments
180+
// ---------------------------------------------------------------------------
181+
182+
export interface ChatUploadFile {
183+
uri: string;
184+
name: string;
185+
type: string;
186+
}
187+
188+
export const uploadAttachment = async (channelId: string, messageId: string, file: ChatUploadFile) => {
189+
const form = new FormData();
190+
// React Native FormData accepts { uri, name, type } file objects.
191+
form.append('file', file as unknown as Blob);
192+
193+
const response = await api.post<ChatAttachmentUploadedResult>(`${CHAT}/UploadAttachment`, form, {
194+
params: { channelId, messageId },
195+
headers: { 'Content-Type': 'multipart/form-data' },
196+
});
197+
return response.data;
198+
};
199+
200+
/** Absolute URL for downloading an attachment's binary. */
201+
export const getChatAttachmentUrl = (attachmentId: string): string => `${getBaseApiUrl()}${CHAT}/GetAttachment?attachmentId=${encodeURIComponent(attachmentId)}`;
202+
203+
/** Absolute URL for downloading an attachment's thumbnail. */
204+
export const getChatAttachmentThumbnailUrl = (attachmentId: string): string => `${getBaseApiUrl()}${CHAT}/GetAttachmentThumbnail?attachmentId=${encodeURIComponent(attachmentId)}`;
205+
206+
/**
207+
* Image source (with bearer auth header) suitable for expo-image / RN Image
208+
* when rendering a chat attachment.
209+
*/
210+
export const getChatAttachmentImageSource = (attachmentId: string) => {
211+
const token = useAuthStore.getState().accessToken;
212+
return {
213+
uri: getChatAttachmentUrl(attachmentId),
214+
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
215+
};
216+
};
217+
218+
// ---------------------------------------------------------------------------
219+
// Search, GIFs, presence, flags, moderation
220+
// ---------------------------------------------------------------------------
221+
222+
export const searchMessages = async (q: string, channelId?: string, page = 0, signal?: AbortSignal) => {
223+
const response = await api.get<ChatV4Response<ChatMessageResultData[]>>(`${CHAT}/Search`, {
224+
params: { q, channelId, page },
225+
signal,
226+
});
227+
return response.data;
228+
};
229+
230+
export const searchGifs = async (q?: string, limit = 25, offset = 0, signal?: AbortSignal) => {
231+
const response = await api.get<ChatV4Response<GifResultData[]>>(`${CHAT}/SearchGifs`, {
232+
params: { q, limit, offset },
233+
signal,
234+
});
235+
return response.data;
236+
};
237+
238+
export const getPresence = async (userIds: string[], signal?: AbortSignal) => {
239+
const response = await api.get<GetChatPresenceResult>(`${CHAT}/GetPresence`, {
240+
params: { userIds: userIds.join(',') },
241+
signal,
242+
});
243+
return response.data;
244+
};
245+
246+
export const flagMessage = async (messageId: string, input: FlagMessageInput) => {
247+
const response = await api.post<ChatActionResult>(`${CHAT}/FlagMessage`, input, { params: { messageId } });
248+
return response.data;
249+
};
250+
251+
/** Department-admin / moderator hard delete of a message. */
252+
export const moderatorDeleteMessage = async (messageId: string, reason: string) => {
253+
const response = await api.post<ChatActionResult>(`${MODERATION}/DeleteMessage`, {}, { params: { messageId, reason } });
254+
return response.data;
255+
};

src/api/chat/chatbot.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { type ChatbotChannelResponse, type ChatbotSendResponse, type ChatbotSessionResponse } from '@/models/v4/chat';
2+
3+
import { api } from '../common/client';
4+
5+
const CHATBOT = '/Chatbot';
6+
7+
/** Gets (creating if needed) the caller's chatbot conversation channel. */
8+
export const getChatbotChannel = async (signal?: AbortSignal) => {
9+
const response = await api.get<ChatbotChannelResponse>(`${CHATBOT}/GetChatChannel`, { signal });
10+
return response.data;
11+
};
12+
13+
/**
14+
* Sends a message to the chatbot. The reply arrives asynchronously in the same
15+
* channel over SignalR (chatbotMessageReceived). Idempotent via clientMessageId.
16+
*/
17+
export const sendChatbotMessage = async (text: string, clientMessageId: string) => {
18+
const response = await api.post<ChatbotSendResponse>(`${CHATBOT}/SendChatMessage`, {
19+
Text: text,
20+
ClientMessageId: clientMessageId,
21+
});
22+
return response.data;
23+
};
24+
25+
/** Resets the chatbot conversational session (message history is retained). */
26+
export const newChatbotSession = async () => {
27+
const response = await api.post<ChatbotSessionResponse>(`${CHATBOT}/NewChatSession`, {});
28+
return response.data;
29+
};

src/app/(app)/_layout.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,20 @@ export default function TabLayout() {
169169
// Don't fail initialization if SignalR connection fails
170170
}
171171

172+
// Connect the realtime chat hub (best-effort; chat may be disabled per department)
173+
try {
174+
await useSignalRStore.getState().connectChatHub();
175+
logger.info({
176+
message: 'SignalR chat hub connected successfully',
177+
context: { platform: Platform.OS },
178+
});
179+
} catch (error) {
180+
logger.error({
181+
message: 'Failed to connect SignalR chat hub during initialization',
182+
context: { error, platform: Platform.OS },
183+
});
184+
}
185+
172186
// Initialize weather alerts
173187
try {
174188
await useWeatherAlertsStore.getState().fetchSettings();

0 commit comments

Comments
 (0)