Skip to content
Open
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(),
UNIT_MAPBOX_PUBKEY: z.string(),
Expand Down Expand Up @@ -118,6 +119,7 @@ const _clientEnv = {
RESGRID_API_URL: process.env.UNIT_RESGRID_API_URL || '/api/v4',
CHANNEL_HUB_NAME: process.env.UNIT_CHANNEL_HUB_NAME || 'eventingHub',
REALTIME_GEO_HUB_NAME: process.env.UNIT_REALTIME_GEO_HUB_NAME || 'geolocationHub',
CHAT_HUB_NAME: process.env.UNIT_CHAT_HUB_NAME || 'chatHub',
LOGGING_KEY: process.env.UNIT_LOGGING_KEY || '',
APP_KEY: process.env.UNIT_APP_KEY || '',
IS_MOBILE_APP: 'true',
Expand Down
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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the createApiEndpoint/createCachedApiEndpoint pattern and cacheManager usage elsewhere.
set -euo pipefail

echo "--- common client / endpoint factory ---"
fd -t f . src/api/common

echo "--- other API modules using createApiEndpoint/createCachedApiEndpoint ---"
rg -n -C3 'createApiEndpoint|createCachedApiEndpoint' src/api

echo "--- cacheManager.remove usage in other API modules ---"
rg -n -C3 'cacheManager\.remove' src/api

Repository: Resgrid/Unit

Length of output: 39843


🏁 Script executed:

#!/bin/bash
# Inspect src/api/chat/chat.ts structure, endpoint definitions, and raw api call/mutation/cache usage.
set -euo pipefail

echo "--- file size ---"
wc -l src/api/chat/chat.ts

echo "--- outline ---"
ast-grep outline src/api/chat/chat.ts --view compact || true

echo "--- imports and endpoint/client lines ---"
sed -n '1,80p' src/api/chat/chat.ts

echo "--- direct api calls and cacheManager references ---"
rg -n -C3 '(api\.(get|post|put|delete)<|cacheManager\.remove|uploadAttachment|const .*Api|\.delete\(|\.put\(|\.post\()' src/api/chat/chat.ts

echo "--- mutation definitions ---"
sed -n '180,280p' src/api/chat/chat.ts

Repository: Resgrid/Unit

Length of output: 15524


🌐 Web query:

axios multipart/form-data React Native set Content-Type header FormData boundary

💡 Result:

When using FormData with Axios in React Native, you should not manually set the Content-Type header [1][2]. The React Native runtime environment, specifically its networking layer, is responsible for generating the multipart boundary string required for successful multipart/form-data requests [3][4]. If you manually set the Content-Type header to multipart/form-data, you overwrite the internal boundary generation process, which often leads to errors—particularly on Android, where the native networking layer strictly requires the boundary to be present in the header [5][3][4]. Best Practices: 1. Do not set headers manually: Simply pass your FormData object directly to the data property of your Axios request [1][2]. 2. Ensure you are using a recent version of Axios: Updates (notably those around May 2026) have improved how Axios handles FormData in React Native by ensuring default headers like application/x-www-form-urlencoded are properly cleared when FormData is detected, allowing the React Native runtime to set the correct Content-Type with the boundary [6][5]. 3. Troubleshooting: If you encounter issues where the request is sent with an incorrect header (e.g., application/x-www-form-urlencoded) despite using FormData, ensure your environment's FormData is being correctly identified by Axios [5]. As a last-resort workaround, you can explicitly unset the header in your request config to allow the runtime to take over: javascript // Only use this if you are encountering issues with automatic header resolution axios.post(url, formData, { headers: { 'Content-Type': false // This prevents axios from applying a default type } }); Setting the header to false instructs Axios to omit the Content-Type header entirely, which forces the React Native NetworkingModule to handle the request correctly [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- common api client snippet ---"
sed -n '1,180p' src/api/common/client.tsx

echo "--- cached api client snippet ---"
sed -n '1,120p' src/api/common/cached-client.ts

echo "--- cache remover implementation ---"
fd -t f cache-manager src | xargs -r -I{} sh -c 'echo ";; {}"; sed -n "1,220p" "{}"'

Repository: Resgrid/Unit

Length of output: 12790


Use the endpoint factory for all chat APIs and invalidate chat caches after mutations.

createApiEndpoint/createCachedApiEndpoint provide the shared path wrapper used by other src/api/** modules, while direct api.get/api.post/api.put/api.delete calls bypass that pattern. Cacheable reads such as channel/message lists should use createCachedApiEndpoint; create/update/delete chat operations should call cacheManager.remove() for the affected endpoints after a successful mutation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/chat/chat.ts` at line 25, Update the chat API methods in the module
to use createApiEndpoint for mutations and createCachedApiEndpoint for cacheable
channel and message reads instead of direct api calls. After each successful
create, update, or delete operation, call cacheManager.remove() for every
affected chat endpoint so cached lists are invalidated.

Source: Coding guidelines


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', { uri: file.uri, name: file.name, type: file.type } 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;
};
Comment on lines +188 to +198

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate chat.ts and dependency files"
fd -a 'chat\.ts$|package\.json$|axios|react-native' . | sed 's#^\./##' | head -200

echo
echo "Inspect src/api/chat/chat.ts around uploadAttachment"
if [ -f src/api/chat/chat.ts ]; then
  nl -ba src/api/chat/chat.ts | sed -n '1,240p'
fi

echo
echo "Search api client creation"
rg -n "createApiEndpoint|createCachedApiEndpoint|axios|headers|Content-Type|multipart/form-data" -S --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' . | head -300

Repository: Resgrid/Unit

Length of output: 1142


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Package references"
python3 - <<'PY'
import json, pathlib
for p in pathlib.Path('.').rglob('package.json'):
    if 'node_modules' in p.parts or p.parts[:2]==['__mocks__']:
        continue
    try:
        data=json.loads(p.read_text())
    except Exception as e:
        continue
    deps={}
    for sec in ('dependencies','devDependencies','peerDependencies','optionalDependencies'):
        deps.update(data.get(sec,{}))
    hits={k,v for k,v in deps.items() if 'react-native' in k.lower() or k.lower() in ('axios','axios-cache-interceptor')}
    if hits:
        print(p)
        for k,v in hits:
            print(f"  {k}: {v}")
PY

echo
echo "src/api/chat/chat.ts relevant sections"
python3 - <<'PY'
from pathlib import Path
p=Path('src/api/chat/chat.ts')
lines=p.read_text().splitlines()
for start,end in [(1,260)]:
    print(f"---- {p} lines {start}-{end} ----")
    for i in range(start,end+1):
        if i <= len(lines):
            print(f"{i:4d}\t{lines[i-1]}")
PY

echo
echo "Search api client creation and axios interceptors"
python3 - <<'PY'
from pathlib import Path
import re
pattern=re.compile(r'(axios|createApiEndpoint|createCachedApiEndpoint|Content-Type|multipart/form-data)')
for p in Path('.').rglob('*'):
    if p.is_file():
        tree=('/'.join(p.parts))
        if 'node_modules' in tree or p.parts[:2]==['__mocks__'] or str(p).startswith('./.git'):
            continue
        try:
            text=p.read_text(errors='ignore')
        except Exception:
            continue
        mlines=[]
        for n,line in enumerate(text.splitlines(),1):
            if pattern.search(line):
                mlines.append((n,line))
        if mlines:
            print(f"---- {p} hits ----")
            for n,line in mlines[:80]:
                print(f"{n:4d}\t{line}")
PY

Repository: Resgrid/Unit

Length of output: 361


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Package references"
python3 - <<'PY'
import json, pathlib
for p in pathlib.Path('.').rglob('package.json'):
    if 'node_modules' in p.parts or p.parts[:2]==('__mocks__',):
        continue
    try:
        data=json.loads(p.read_text())
    except Exception as e:
        continue
    deps={}
    for sec in ('dependencies','devDependencies','peerDependencies','optionalDependencies'):
        deps.update(data.get(sec,{}))
    hits=[(k,v) for k,v in deps.items() if 'react-native' in k.lower() or k.lower() in ('axios','axios-cache-interceptor')]
    if hits:
        print(p)
        for k,v in hits:
            print(f"  {k}: {v}")
PY

echo
echo "src/api/chat/chat.ts relevant sections"
python3 - <<'PY'
from pathlib import Path
p=Path('src/api/chat/chat.ts')
lines=p.read_text().splitlines()
for start,end in [(1,260)]:
    print(f"---- {p} lines {start}-{end} ----")
    for i in range(start,end+1):
        if i <= len(lines):
            print(f"{i:4d}\t{lines[i-1]}")
PY

echo
echo "Search api client creation and axios interceptors"
python3 - <<'PY'
import re, pathlib
pattern=re.compile(r'(axios|createApiEndpoint|createCachedApiEndpoint|Content-Type|multipart/form-data)')
for p in pathlib.Path('.').rglob('*'):
    if p.is_file():
        tree='/'.join(p.parts)
        if 'node_modules' in tree or p.parts[:2]==('__mocks__',) or str(p).startswith('./.git'):
            continue
        try:
            text=p.read_text(errors='ignore')
        except Exception:
            continue
        mlines=[]
        for n,line in enumerate(text.splitlines(),1):
            if pattern.search(line):
                mlines.append((n,line))
        if mlines:
            print(f"---- {p} hits ----")
            for n,line in mlines[:80]:
                print(f"{n:4d}\t{line}")
PY

Repository: Resgrid/Unit

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "src/api/chat/chat.ts relevant sections"
python3 - <<'PY'
from pathlib import Path
p=Path('src/api/chat/chat.ts')
lines=p.read_text().splitlines()
for start,end in [(1,260)]:
    for i in range(start,end+1):
        if i <= len(lines):
            print(f"{i:4d}\t{lines[i-1]}")
PY

echo
echo "Focused search in tracked source files"
python3 - <<'PY'
import subprocess, pathlib, re
files=[]
repo_files=subprocess.check_output(['git','ls-files']).decode().splitlines()
for raw in repo_files:
    p=pathlib.Path(raw)
    if p.is_file() and 'node_modules' not in p.parts and p.parts[:2]!=['__mocks__'] and not str(p).startswith('.git'):
        files.append(p)
pattern=re.compile(r'(axios|createApiEndpoint|createCachedApiEndpoint|Content-Type|multipart/form-data|UploadAttachment|uploadAttachment)')
for p in files:
    try:
        text=p.read_text(errors='ignore')
    except Exception:
        continue
    mlines=[]
    for n,line in enumerate(text.splitlines(),1):
        if pattern.search(line):
            mlines.append((n,line))
    if mlines:
        print(f"---- {p} hits ----")
        for n,line in mlines[:120]:
            print(f"{n:4d}\t{line}")
PY

Repository: Resgrid/Unit

Length of output: 35135


🌐 Web query:

React Native axios FormData Content-Type multipart/form-data boundary issue automatically set Content-Type headers

💡 Result:

When using FormData with Axios in React Native, explicitly setting the Content-Type header to multipart/form-data often causes issues because the networking layer (especially on Android) expects the runtime to automatically generate the required boundary parameter [1][2]. If you manually set 'Content-Type': 'multipart/form-data', you may encounter errors where the server or the React Native networking module rejects the request because the boundary is missing or incorrect [1][3]. Best Practices and Solutions: 1. Do Not Set Content-Type Manually: In many cases, it is recommended to avoid manually setting the Content-Type header for FormData requests. React Native's underlying networking layer is designed to detect FormData and handle the Content-Type and boundary generation automatically [4]. 2. Use transformRequest: If Axios is incorrectly interfering with your FormData object or applying an unwanted default header (such as application/x-www-form-urlencoded), you can use the transformRequest option to ensure the raw FormData object is passed through [5][6]: axios.post(url, formData, { transformRequest: (data, headers) => { return data; } }); 3. Header Clearing Workaround: If you are forced to use a configuration where Axios is applying an incorrect header, you can set the header to false. This prevents Axios from appending a default value while allowing the underlying native networking stack to fill in the correct multipart/form-data header with the necessary boundary [1]: const config = { headers: { 'Content-Type': false, }, }; Recent Axios updates (as of mid-2026) have introduced fixes to better handle React Native environments by clearing default Content-Type headers when FormData is detected, aligning behavior more closely with standard web environments [7][2]. Always ensure you are using a recent version of Axios if you are experiencing these issues.

Citations:


🌐 Web query:

React Native network module FormData Content-Type multipart/form-data boundary without headers axios

💡 Result:

In React Native, you should not manually set the Content-Type header when sending FormData with Axios [1]. React Native's underlying networking layer (OkHttp on Android) requires the Content-Type to be absent so that it can automatically generate the multipart boundary required for the request [2][3][4]. If you explicitly set 'Content-Type': 'multipart/form-data', Axios may send this incomplete header to the React Native networking module, which often leads to errors—particularly on Android—because it lacks the necessary boundary string [2][3]. To correctly send FormData in React Native: 1. Do not include Content-Type in your request headers [1]. 2. Pass the FormData object directly as the data payload [1]. If you are encountering issues where Axios is forcing an incorrect Content-Type (such as application/x-www-form-urlencoded), you can override this behavior by explicitly setting the header to false in your request configuration [2]: const response = await axios.post(url, formData, { headers: { 'Content-Type': false, }, }); Setting 'Content-Type': false signals Axios to drop the default header, allowing React Native to handle the request correctly and append the boundary automatically [2]. This approach is generally preferred over manual workarounds as it aligns with Axios' internal logic for handling FormData across different environments [2][4].

Citations:


🌐 Web query:

site:github.com/axios/axios docs advanced multipart-form-data-format React Native FormData Content-Type headers false

💡 Result:

When working with FormData in React Native, you should not manually set the Content-Type header [1]. If you encounter issues where the request fails or is malformed, setting Content-Type to false is a common workaround to force the underlying network layer to handle the header correctly [2][3]. Here is a summary of the best practices and technical context: 1. Automatic Handling: React Native's networking layer (via the native FormData implementation) is designed to automatically generate the Content-Type header, including the necessary multipart boundary (e.g., boundary=----RNGeneratedBoundary...) [4]. 2. The Problem with Manual Headers: If you manually set Content-Type: multipart/form-data, you often omit the required boundary [4][5]. React Native's Android implementation, in particular, is sensitive to this and will reject requests that have a bare multipart/form-data header without a boundary, often resulting in a Network Error or a crash [2][4]. 3. The "Content-Type: false" Workaround: Axios automatically applies a default Content-Type (like application/x-www-form-urlencoded) to certain requests [3]. If you pass FormData, this default header can conflict with React Native's requirements [3]. Setting "Content-Type": false in your headers object tells Axios to omit the header entirely, allowing React Native to inject the correct Content-Type with the required boundary automatically [3]. 4. Recent Updates: As of May 2026, improvements have been made to Axios to treat React Native FormData similarly to browser FormData, meaning the library should now automatically clear the default Content-Type in most cases [6]. You should verify your library version to see if this manual override is still necessary for your specific project [6]. If you are using an older version of Axios and experiencing issues on Android, using { "Content-Type": false } is the recommended approach to resolve conflicts between Axios's default headers and React Native's native network module [2][3].

Citations:


Remove the manual Content-Type header on the multipart upload.

uploadAttachment sets headers: { 'Content-Type': 'multipart/form-data' } for a FormData body without a boundary. React Native’s network layer supplies the compliant multipart header; the request should omit this header so the platform can add the boundary automatically.

🐛 Proposed fix to let React Native set the multipart boundary
   const response = await api.post<ChatAttachmentUploadedResult>(`${CHAT}/UploadAttachment`, form, {
     params: { channelId, messageId },
-    headers: { 'Content-Type': 'multipart/form-data' },
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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', { uri: file.uri, name: file.name, type: file.type } 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;
};
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', { uri: file.uri, name: file.name, type: file.type } as unknown as Blob);
const response = await api.post<ChatAttachmentUploadedResult>(`${CHAT}/UploadAttachment`, form, {
params: { channelId, messageId },
});
return response.data;
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/chat/chat.ts` around lines 188 - 198, Remove the manually specified
Content-Type header from the api.post options in uploadAttachment, leaving the
FormData body and other request options unchanged so React Native supplies the
multipart boundary automatically.


/** 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) => {
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`, {
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;
};
19 changes: 19 additions & 0 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,16 @@ export default function TabLayout() {
// available now. The two hub connects are independent.
await Promise.all([useSignalRStore.getState().connectUpdateHub(), useSignalRStore.getState().connectGeolocationHub()]);

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

hasInitialized.current = true;

// Evict expired/capped API cache entries once per cold start.
Expand Down Expand Up @@ -478,6 +488,11 @@ export default function TabLayout() {
[t, settingsIcon]
);

// Chat and the assistant are reached from the sidebar, not the bottom tab bar,
// so they are registered as hidden routes (href: null) that render their own header.
const chatOptions = useMemo(() => ({ href: null, headerShown: false as const }), []);
const chatbotOptions = useMemo(() => ({ href: null, headerShown: false as const }), []);

if (isFirstTime) {
return <Redirect href="/onboarding" />;
}
Expand Down Expand Up @@ -535,6 +550,10 @@ export default function TabLayout() {
<Tabs.Screen name="protocols" options={protocolsOptions} />

<Tabs.Screen name="settings" options={settingsOptions} />

<Tabs.Screen name="chat" options={chatOptions} />

<Tabs.Screen name="chatbot" options={chatbotOptions} />
</Tabs>

{/* NotificationInbox positioned within the tab content area — only after init and Novu is ready */}
Expand Down
Loading
Loading