Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions env.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const client = z.object({
RESGRID_API_URL: z.string(),
CHANNEL_HUB_NAME: z.string(),
REALTIME_GEO_HUB_NAME: z.string(),
CHAT_HUB_NAME: z.string(),
LOGGING_KEY: z.string(),
APP_KEY: z.string(),
MAPBOX_PUBKEY: z.string(),
Expand Down Expand Up @@ -120,6 +121,7 @@ const _clientEnv = {
RESGRID_API_URL: process.env.DISPATCH_RESGRID_API_URL || '/api/v4',
CHANNEL_HUB_NAME: process.env.DISPATCH_CHANNEL_HUB_NAME || 'eventingHub',
REALTIME_GEO_HUB_NAME: process.env.DISPATCH_REALTIME_GEO_HUB_NAME || 'geolocationHub',
CHAT_HUB_NAME: process.env.DISPATCH_CHAT_HUB_NAME || 'chatHub',
LOGGING_KEY: process.env.DISPATCH_LOGGING_KEY || '',
APP_KEY: process.env.DISPATCH_APP_KEY || '',
IS_MOBILE_APP: true, // or whatever default you want
Expand Down
6 changes: 6 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
allowBuilds:
'@react-buoy/shared-ui': set this to true or false
'@sentry/cli': set this to true or false
electron: set this to true or false
electron-winstaller: set this to true or false
postinstall-postinstall: set this to true or false
255 changes: 255 additions & 0 deletions src/api/chat/chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import { getBaseApiUrl } from '@/lib/storage/app';
import {
type AddMembersInput,
type AddReactionInput,
type ChatAckResultData,
type ChatActionResult,
type ChatAttachmentUploadedResult,
type ChatChannelResultData,
type ChatMemberResultData,
type ChatMessageResultData,
type ChatV4Response,
type CreateAdHocChannelInput,
type CreateDirectMessageInput,
type EditMessageInput,
type FlagMessageInput,
type GetChatPresenceResult,
type GifResultData,
type MarkReadInput,
type SendChatMessageInput,
type SetNotificationPreferenceInput,
type UpdateChannelInput,
} from '@/models/v4/chat';
import useAuthStore from '@/stores/auth/store';

import { api } from '../common/client';

const CHAT = '/Chat';
const MODERATION = '/ChatModeration';

// ---------------------------------------------------------------------------
// Channels
// ---------------------------------------------------------------------------

export const getChannels = async (activeUnitId?: number, signal?: AbortSignal) => {
const response = await api.get<ChatV4Response<ChatChannelResultData[]>>(`${CHAT}/GetChannels`, {
params: activeUnitId != null ? { activeUnitId } : undefined,
signal,
});
return response.data;
};

export const getChannel = async (channelId: string, signal?: AbortSignal) => {
const response = await api.get<ChatV4Response<ChatChannelResultData>>(`${CHAT}/GetChannel`, { params: { channelId }, signal });
return response.data;
};

export const createDirectMessage = async (input: CreateDirectMessageInput) => {
const response = await api.post<ChatV4Response<ChatChannelResultData>>(`${CHAT}/CreateDirectMessage`, input);
return response.data;
};

export const createAdHocChannel = async (input: CreateAdHocChannelInput) => {
const response = await api.post<ChatV4Response<ChatChannelResultData>>(`${CHAT}/CreateAdHocChannel`, input);
return response.data;
};

export const updateChannel = async (channelId: string, input: UpdateChannelInput) => {
const response = await api.put<ChatV4Response<ChatChannelResultData>>(`${CHAT}/UpdateChannel`, input, { params: { channelId } });
return response.data;
};

export const archiveChannel = async (channelId: string) => {
const response = await api.delete<ChatActionResult>(`${CHAT}/ArchiveChannel`, { params: { channelId } });
return response.data;
};

// ---------------------------------------------------------------------------
// Members
// ---------------------------------------------------------------------------

export const getMembers = async (channelId: string, signal?: AbortSignal) => {
const response = await api.get<ChatV4Response<ChatMemberResultData[]>>(`${CHAT}/GetMembers`, { params: { channelId }, signal });
return response.data;
};

export const addMembers = async (channelId: string, input: AddMembersInput) => {
const response = await api.post<ChatV4Response<ChatMemberResultData[]>>(`${CHAT}/AddMembers`, input, { params: { channelId } });
return response.data;
};

export const removeMember = async (channelId: string, userId: string) => {
const response = await api.delete<ChatActionResult>(`${CHAT}/RemoveMember`, { params: { channelId, userId } });
return response.data;
};

export const setNotificationPreference = async (channelId: string, input: SetNotificationPreferenceInput) => {
const response = await api.put<ChatActionResult>(`${CHAT}/SetNotificationPreference`, input, { params: { channelId } });
return response.data;
};

// ---------------------------------------------------------------------------
// Messages
// ---------------------------------------------------------------------------

export const getMessages = async (channelId: string, beforeSeq?: number, limit = 50, signal?: AbortSignal) => {
const response = await api.get<ChatV4Response<ChatMessageResultData[]>>(`${CHAT}/GetMessages`, {
params: { channelId, beforeSeq, limit },
signal,
});
return response.data;
};

export const getMessagesAfter = async (channelId: string, afterSeq: number, limit = 50, signal?: AbortSignal) => {
const response = await api.get<ChatV4Response<ChatMessageResultData[]>>(`${CHAT}/GetMessagesAfter`, {
params: { channelId, afterSeq, limit },
signal,
});
return response.data;
};

export const getThread = async (messageId: string, beforeSeq?: number, limit = 50, signal?: AbortSignal) => {
const response = await api.get<ChatV4Response<ChatMessageResultData[]>>(`${CHAT}/GetThread`, {
params: { messageId, beforeSeq, limit },
signal,
});
return response.data;
};

export const sendMessage = async (channelId: string, input: SendChatMessageInput) => {
const response = await api.post<ChatV4Response<ChatMessageResultData>>(`${CHAT}/SendMessage`, input, { params: { channelId } });
return response.data;
};

export const editMessage = async (messageId: string, input: EditMessageInput) => {
const response = await api.put<ChatV4Response<ChatMessageResultData>>(`${CHAT}/EditMessage`, input, { params: { messageId } });
return response.data;
};

export const deleteMessage = async (messageId: string) => {
const response = await api.delete<ChatActionResult>(`${CHAT}/DeleteMessage`, { params: { messageId } });
return response.data;
};

// ---------------------------------------------------------------------------
// Reactions, acks, read pointers, pins
// ---------------------------------------------------------------------------

export const addReaction = async (messageId: string, input: AddReactionInput) => {
const response = await api.post<ChatActionResult>(`${CHAT}/AddReaction`, input, { params: { messageId } });
return response.data;
};

export const removeReaction = async (messageId: string, emoji: string) => {
const response = await api.delete<ChatActionResult>(`${CHAT}/RemoveReaction`, { params: { messageId, emoji } });
return response.data;
};

export const ackMessage = async (messageId: string) => {
const response = await api.post<ChatActionResult>(`${CHAT}/Ack`, {}, { params: { messageId } });
return response.data;
};

export const getMyPendingAcks = async (signal?: AbortSignal) => {
const response = await api.get<ChatV4Response<ChatAckResultData[]>>(`${CHAT}/GetMyPendingAcks`, { signal });
return response.data;
};

export const markRead = async (channelId: string, input: MarkReadInput) => {
const response = await api.put<ChatActionResult>(`${CHAT}/MarkRead`, input, { params: { channelId } });
return response.data;
};

export const pinMessage = async (messageId: string) => {
const response = await api.post<ChatActionResult>(`${CHAT}/PinMessage`, {}, { params: { messageId } });
return response.data;
};

export const unpinMessage = async (messageId: string) => {
const response = await api.delete<ChatActionResult>(`${CHAT}/UnpinMessage`, { params: { messageId } });
return response.data;
};

export const getPins = async (channelId: string, signal?: AbortSignal) => {
const response = await api.get<ChatV4Response<ChatMessageResultData[]>>(`${CHAT}/GetPins`, { params: { channelId }, signal });
return response.data;
};

// ---------------------------------------------------------------------------
// Attachments
// ---------------------------------------------------------------------------

export interface ChatUploadFile {
uri: string;
name: string;
type: string;
}

export const uploadAttachment = async (channelId: string, messageId: string, file: ChatUploadFile) => {
const form = new FormData();
// React Native FormData accepts { uri, name, type } file objects.
form.append('file', file as unknown as Blob);

const response = await api.post<ChatAttachmentUploadedResult>(`${CHAT}/UploadAttachment`, form, {
params: { channelId, messageId },
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
};

/** Absolute URL for downloading an attachment's binary. */
export const getChatAttachmentUrl = (attachmentId: string): string => `${getBaseApiUrl()}${CHAT}/GetAttachment?attachmentId=${encodeURIComponent(attachmentId)}`;

/** Absolute URL for downloading an attachment's thumbnail. */
export const getChatAttachmentThumbnailUrl = (attachmentId: string): string => `${getBaseApiUrl()}${CHAT}/GetAttachmentThumbnail?attachmentId=${encodeURIComponent(attachmentId)}`;

/**
* Image source (with bearer auth header) suitable for expo-image / RN Image
* when rendering a chat attachment.
*/
export const getChatAttachmentImageSource = (attachmentId: string) => {
const token = useAuthStore.getState().accessToken;
return {
uri: getChatAttachmentUrl(attachmentId),
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
};
};

// ---------------------------------------------------------------------------
// Search, GIFs, presence, flags, moderation
// ---------------------------------------------------------------------------

export const searchMessages = async (q: string, channelId?: string, page = 0, signal?: AbortSignal) => {
const response = await api.get<ChatV4Response<ChatMessageResultData[]>>(`${CHAT}/Search`, {
params: { q, channelId, page },
signal,
});
return response.data;
};

export const searchGifs = async (q?: string, limit = 25, offset = 0, signal?: AbortSignal) => {
const response = await api.get<ChatV4Response<GifResultData[]>>(`${CHAT}/SearchGifs`, {
params: { q, limit, offset },
signal,
});
return response.data;
};

export const getPresence = async (userIds: string[], signal?: AbortSignal) => {
const response = await api.get<GetChatPresenceResult>(`${CHAT}/GetPresence`, {
params: { userIds: userIds.join(',') },
signal,
});
return response.data;
};

export const flagMessage = async (messageId: string, input: FlagMessageInput) => {
const response = await api.post<ChatActionResult>(`${CHAT}/FlagMessage`, input, { params: { messageId } });
return response.data;
};

/** Department-admin / moderator hard delete of a message. */
export const moderatorDeleteMessage = async (messageId: string, reason: string) => {
const response = await api.post<ChatActionResult>(`${MODERATION}/DeleteMessage`, {}, { params: { messageId, reason } });
return response.data;
};
29 changes: 29 additions & 0 deletions src/api/chat/chatbot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { type ChatbotChannelResponse, type ChatbotSendResponse, type ChatbotSessionResponse } from '@/models/v4/chat';

import { api } from '../common/client';

const CHATBOT = '/Chatbot';

/** Gets (creating if needed) the caller's chatbot conversation channel. */
export const getChatbotChannel = async (signal?: AbortSignal) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Missing JSDoc on getChatbotChannel omits Promise return type and rejection conditions, violating documentation rules. Add @returns {Promise<ChatbotChannelResponse>} and document network/auth rejection conditions.

Kody rule violation: Document async/Promise behavior and errors

Prompt for LLM

File src/api/chat/chatbot.ts:

Line 8:

Missing JSDoc on `getChatbotChannel` omits Promise return type and rejection conditions, violating documentation rules. Add `@returns {Promise<ChatbotChannelResponse>}` and document network/auth rejection conditions.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

const response = await api.get<ChatbotChannelResponse>(`${CHATBOT}/GetChatChannel`, { signal });
return response.data;
};

/**
* Sends a message to the chatbot. The reply arrives asynchronously in the same
* channel over SignalR (chatbotMessageReceived). Idempotent via clientMessageId.
*/
export const sendChatbotMessage = async (text: string, clientMessageId: string) => {
const response = await api.post<ChatbotSendResponse>(`${CHATBOT}/SendChatMessage`, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled external HTTP POST call in chatbot.ts violates team rules requiring network calls to be wrapped in try/catch. Wrap the call in a try/catch block, add structured context like op and clientMessageId, and map the error to a domain-level exception.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File src/api/chat/chatbot.ts:

Line 18:

Unhandled external HTTP POST call in `chatbot.ts` violates team rules requiring network calls to be wrapped in try/catch. Wrap the call in a try/catch block, add structured context like `op` and `clientMessageId`, and map the error to a domain-level exception.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Text: text,
ClientMessageId: clientMessageId,
});
return response.data;
};

/** Resets the chatbot conversational session (message history is retained). */
export const newChatbotSession = async () => {
const response = await api.post<ChatbotSessionResponse>(`${CHATBOT}/NewChatSession`, {});
return response.data;
};
14 changes: 14 additions & 0 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,20 @@ export default function TabLayout() {
// Don't fail initialization if SignalR connection fails
}

// Connect the realtime chat hub (best-effort; chat may be disabled per department)
try {
await useSignalRStore.getState().connectChatHub();
logger.info({
message: 'SignalR chat hub connected successfully',
context: { platform: Platform.OS },
});
} catch (error) {
logger.error({
message: 'Failed to connect SignalR chat hub during initialization',
context: { error, platform: Platform.OS },
});
}

// Initialize weather alerts
try {
await useWeatherAlertsStore.getState().fetchSettings();
Expand Down
Loading
Loading