diff --git a/src/api/chat/chatbot.ts b/src/api/chat/chatbot.ts index 383b9f5..8989d97 100644 --- a/src/api/chat/chatbot.ts +++ b/src/api/chat/chatbot.ts @@ -7,7 +7,7 @@ const CHATBOT = '/Chatbot'; /** Gets (creating if needed) the caller's chatbot conversation channel. */ export const getChatbotChannel = async (signal?: AbortSignal) => { const response = await api.get(`${CHATBOT}/GetChatChannel`, { signal }); - return response.data; + return response.data?.Data ?? null; }; /** @@ -19,7 +19,7 @@ export const sendChatbotMessage = async (text: string, clientMessageId: string) Text: text, ClientMessageId: clientMessageId, }); - return response.data; + return response.data?.Data ?? null; }; /** Resets the chatbot conversational session (message history is retained). */ diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx index 643a4b3..bdfae25 100644 --- a/src/app/(app)/chat.tsx +++ b/src/app/(app)/chat.tsx @@ -103,6 +103,13 @@ export default function ChatScreen() { const openChannel = useCallback( (channelId: string) => { + // The assistant conversation always opens in its dedicated restricted screen + // (text only, no reactions/threads/deletes) instead of the generic conversation. + const channel = useChatStore.getState().channels.find((c) => c.ChatChannelId === channelId); + if (channel?.ChannelType === ChatChannelType.Chatbot) { + router.push('/chatbot' as Href); + return; + } router.push(`/chat/${channelId}` as Href); }, [router] diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx index 9c99635..1905466 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -4,9 +4,13 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Platform } from 'react-native'; +import { copyToClipboard } from '@/components/chat/chat-utils'; +import { MessageActionsSheet } from '@/components/chat/message-actions-sheet'; import { MessageBubble } from '@/components/chat/message-bubble'; import { TypingDots } from '@/components/chat/typing-indicator'; +import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; import { Center } from '@/components/ui/center'; import { FlatList } from '@/components/ui/flat-list'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; @@ -16,11 +20,14 @@ import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; import { Pressable } from '@/components/ui/pressable'; import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; +import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { VStack } from '@/components/ui/vstack'; import { type ChatMessageResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; import { useChatSystemStatus } from '@/stores/feature-flags/store'; +import { securityStore } from '@/stores/security/store'; +import { useToastStore } from '@/stores/toast/store'; export default function ChatbotScreen() { const { t } = useTranslation(); @@ -30,7 +37,11 @@ export default function ChatbotScreen() { const chatbotChannelId = useChatStore((s) => s.chatbotChannelId); const chatbotTyping = useChatStore((s) => s.chatbotTyping); const messages = useChatStore((s) => (chatbotChannelId ? s.messagesByChannel[chatbotChannelId] : undefined)); + const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; const [text, setText] = useState(''); + const [actionsMessage, setActionsMessage] = useState(null); + const [editMessage, setEditMessage] = useState(null); + const [editText, setEditText] = useState(''); useFocusEffect( useCallback(() => { @@ -61,7 +72,7 @@ export default function ChatbotScreen() { const renderItem = useCallback( ({ item }: { item: ChatMessageResultData }) => ( - undefined} onToggleReaction={() => undefined} /> + undefined} /> ), [currentUserId] ); @@ -138,6 +149,57 @@ export default function ChatbotScreen() { + + {/* Restricted actions for assistant messages: copy, edit own, pin (moderator), flag. */} + setActionsMessage(null)} + isOwn={!!actionsMessage?.SenderUserId && actionsMessage.SenderUserId === currentUserId} + isModerator={isModerator} + assistant + onReact={() => undefined} + onReply={() => undefined} + onCopy={async (m) => { + const ok = await copyToClipboard(m.Body ?? ''); + useToastStore.getState().showToast(ok ? 'success' : 'info', ok ? t('chat.copied') : t('chat.copy_unavailable')); + }} + onEdit={(m) => { + setEditMessage(m); + setEditText(m.Body ?? ''); + }} + onDelete={() => undefined} + onFlag={(m, reason) => useChatStore.getState().flagMessage(m.ChatMessageId, reason)} + onTogglePin={(m, pinned) => chatbotChannelId && useChatStore.getState().togglePin(m.ChatMessageId, chatbotChannelId, pinned)} + onModeratorDelete={() => undefined} + /> + + {/* Edit own message */} + setEditMessage(null)}> + + + + + + + {t('chat.edit_message')} + + + + + ); } diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index 203ff03..423e539 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -59,18 +59,35 @@ export default function ChannelConversationScreen() { const [editText, setEditText] = useState(''); const [imageUri, setImageUri] = useState(null); const [presenceIds, setPresenceIds] = useState>(new Set()); + const [resolveAttempted, setResolveAttempted] = useState(false); const unsubscribeRef = useRef<(() => void) | null>(null); const isDm = channel?.ChannelType === ChatChannelType.DirectMessage; + const isChatbot = channel?.ChannelType === ChatChannelType.Chatbot; + // Deep links (push notifications, cold starts) can arrive before the channel + // list loads; the channel type is unknown until then. Treat a completed fetch + // with no match as resolved so unknown channels keep the generic screen. + const isResolved = !!channel || resolveAttempted; const showSender = !isDm; // Chronological order (oldest-first); FlashList renders bottom-anchored via maintainVisibleContentPosition. const ordered = useMemo(() => messages ?? [], [messages]); - // Mount: activate channel, join hub, load history and members. + // Resolve the channel identity for deep links before mounting the generic view. + useEffect(() => { + if (channel || resolveAttempted || !isChatEnabled) return; + void useChatStore + .getState() + .fetchChannels() + .finally(() => setResolveAttempted(true)); + }, [channel, resolveAttempted, isChatEnabled]); + + // Mount: activate channel, join hub, load history and members. Assistant + // conversations are handled by the dedicated chatbot screen — never join or + // load them here, and wait for unresolved deep links to identify first. useFocusEffect( useCallback(() => { - if (!channelId || !isChatEnabled) return; + if (!channelId || !isChatEnabled || !isResolved || isChatbot) return; const store = useChatStore.getState(); store.setActiveChannel(channelId); void store.joinChannel(channelId); @@ -79,7 +96,7 @@ export default function ChannelConversationScreen() { return () => { useChatStore.getState().setActiveChannel(null); }; - }, [channelId, isChatEnabled]) + }, [channelId, isChatEnabled, isResolved, isChatbot]) ); // Fetch presence for the channel members (for the header online dot). @@ -99,10 +116,10 @@ export default function ChannelConversationScreen() { // Mark read whenever the newest message changes while viewing. useEffect(() => { - if (channelId && ordered.length > 0) { + if (channelId && isResolved && !isChatbot && ordered.length > 0) { void useChatStore.getState().markChannelRead(channelId); } - }, [channelId, ordered.length]); + }, [channelId, isResolved, isChatbot, ordered.length]); const otherOnline = useMemo(() => { if (!isDm) return false; @@ -257,6 +274,23 @@ export default function ChannelConversationScreen() { return ; } + // Deep link to a channel that isn't loaded yet: wait for the channel list so + // assistant conversations never mount the full-featured view. + if (!isResolved) { + return ( + + + + + ); + } + + // Assistant conversations always use the dedicated restricted screen (text only, + // no reactions/threads/deletes) — catch deep links and stale routes here. + if (isChatbot) { + return ; + } + return ( - undefined} onSendLocation={handleSendLocation} onOpenGif={handleSendGif} onTyping={() => undefined} placeholder={t('chat.reply_placeholder')} /> + undefined} onSendLocation={handleSendLocation} onOpenGif={handleSendGif} onTyping={() => undefined} placeholder={t('chat.reply_placeholder')} allowUrgent={false} /> ); diff --git a/src/components/chat/__tests__/chat-utils.test.ts b/src/components/chat/__tests__/chat-utils.test.ts index 9fe5ba3..f9aff7e 100644 --- a/src/components/chat/__tests__/chat-utils.test.ts +++ b/src/components/chat/__tests__/chat-utils.test.ts @@ -1,8 +1,11 @@ +import * as Clipboard from 'expo-clipboard'; import { type TFunction } from 'i18next'; import { ChatChannelType, type ChatChannelResultData } from '@/models/v4/chat'; -import { getChannelDisplayName, getImageMimeType, hasLink, linkifySegments } from '../chat-utils'; +import { copyToClipboard, getChannelDisplayName, getImageMimeType, hasLink, linkifySegments } from '../chat-utils'; + +jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn() })); const mockT = ((key: string) => key) as TFunction; @@ -94,4 +97,54 @@ describe('chat-utils', () => { expect(linkifySegments('')).toEqual([]); }); }); + + describe('copyToClipboard', () => { + const globalWithNavigator = globalThis as unknown as { navigator?: { clipboard?: { writeText?: (value: string) => Promise } } }; + let originalNavigator: unknown; + + beforeEach(() => { + originalNavigator = globalWithNavigator.navigator; + jest.mocked(Clipboard.setStringAsync).mockReset(); + }); + + afterEach(() => { + if (originalNavigator === undefined) { + delete globalWithNavigator.navigator; + } else { + globalWithNavigator.navigator = originalNavigator as typeof globalWithNavigator.navigator; + } + }); + + it('uses the web clipboard API when available', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + globalWithNavigator.navigator = { clipboard: { writeText } }; + + await expect(copyToClipboard('hello')).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith('hello'); + expect(Clipboard.setStringAsync).not.toHaveBeenCalled(); + }); + + it('falls back to the native module when the web API is unavailable', async () => { + delete globalWithNavigator.navigator; + jest.mocked(Clipboard.setStringAsync).mockResolvedValue(true); + + await expect(copyToClipboard('hello')).resolves.toBe(true); + expect(Clipboard.setStringAsync).toHaveBeenCalledWith('hello'); + }); + + it('falls back to the native module when the web API write fails', async () => { + globalWithNavigator.navigator = { clipboard: { writeText: jest.fn().mockRejectedValue(new Error('denied')) } }; + jest.mocked(Clipboard.setStringAsync).mockResolvedValue(true); + + await expect(copyToClipboard('hello')).resolves.toBe(true); + expect(Clipboard.setStringAsync).toHaveBeenCalledWith('hello'); + }); + + it('returns false when the native write fails', async () => { + delete globalWithNavigator.navigator; + jest.mocked(Clipboard.setStringAsync).mockRejectedValue(new Error('unavailable')); + + await expect(copyToClipboard('hello')).resolves.toBe(false); + }); + }); }); diff --git a/src/components/chat/chat-utils.ts b/src/components/chat/chat-utils.ts index e6b2aca..ac54751 100644 --- a/src/components/chat/chat-utils.ts +++ b/src/components/chat/chat-utils.ts @@ -1,3 +1,4 @@ +import * as Clipboard from 'expo-clipboard'; import { type TFunction } from 'i18next'; import { getAvatarUrl } from '@/lib/utils'; @@ -105,9 +106,9 @@ export function hasLink(body?: string | null): boolean { } /** - * Copies text to the clipboard. Works on web/Electron via the async Clipboard - * API; native returns false (no clipboard native module is installed) so callers - * can surface an appropriate message. + * Copies text to the clipboard. Uses the async Clipboard API on web/Electron + * and expo-clipboard on native; returns false only when both are unavailable + * or the write fails, so callers can surface an appropriate message. */ export async function copyToClipboard(text: string): Promise { try { @@ -117,9 +118,13 @@ export async function copyToClipboard(text: string): Promise { return true; } } catch { - // ignore and fall through + // ignore and fall through to the native module + } + try { + return await Clipboard.setStringAsync(text); + } catch { + return false; } - return false; } const IMAGE_MIME_BY_EXTENSION: Record = { diff --git a/src/components/chat/message-actions-sheet.tsx b/src/components/chat/message-actions-sheet.tsx index 97fd764..1bc66c2 100644 --- a/src/components/chat/message-actions-sheet.tsx +++ b/src/components/chat/message-actions-sheet.tsx @@ -16,6 +16,8 @@ interface MessageActionsSheetProps { onClose: () => void; isOwn: boolean; isModerator: boolean; + /** Assistant conversations: no reactions, threads or deletes — copy, edit own, pin and flag stay. */ + assistant?: boolean; onReact: (message: ChatMessageResultData, emoji: string) => void; onReply: (message: ChatMessageResultData) => void; onCopy: (message: ChatMessageResultData) => void; @@ -26,7 +28,7 @@ interface MessageActionsSheetProps { onModeratorDelete: (message: ChatMessageResultData) => void; } -export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerator, onReact, onReply, onCopy, onEdit, onDelete, onFlag, onTogglePin, onModeratorDelete }: MessageActionsSheetProps) { +export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerator, assistant = false, onReact, onReply, onCopy, onEdit, onDelete, onFlag, onTogglePin, onModeratorDelete }: MessageActionsSheetProps) { const { t } = useTranslation(); const [mode, setMode] = useState<'actions' | 'flag'>('actions'); @@ -75,7 +77,7 @@ export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerat ) : ( <> - {!isDeleted ? ( + {!isDeleted && !assistant ? ( {QUICK_REACTIONS.map((emoji) => ( ) : null} - { - onReply(message); - close(); - }} - > - - {t('chat.reply_in_thread')} - + {!assistant ? ( + { + onReply(message); + close(); + }} + > + + {t('chat.reply_in_thread')} + + ) : null} {isText && !isDeleted ? ( ) : null} - {isOwn && !isDeleted ? ( + {isOwn && !isDeleted && !assistant ? ( { onDelete(message); @@ -157,7 +161,7 @@ export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerat ) : null} - {isModerator && !isDeleted ? ( + {isModerator && !isDeleted && !assistant ? ( { onModeratorDelete(message); diff --git a/src/components/chat/message-composer.tsx b/src/components/chat/message-composer.tsx index d7fb68b..45a4f14 100644 --- a/src/components/chat/message-composer.tsx +++ b/src/components/chat/message-composer.tsx @@ -25,9 +25,11 @@ interface MessageComposerProps { onTyping: (isTyping: boolean) => void; disabled?: boolean; placeholder?: string; + /** Urgent priority is channel-level only; thread replies pass false to hide the toggle. */ + allowUrgent?: boolean; } -export function MessageComposer({ onSendText, onSendImage, onSendLocation, onOpenGif, onTyping, disabled, placeholder }: MessageComposerProps) { +export function MessageComposer({ onSendText, onSendImage, onSendLocation, onOpenGif, onTyping, disabled, placeholder, allowUrgent = true }: MessageComposerProps) { const { t } = useTranslation(); const [text, setText] = useState(''); const [urgent, setUrgent] = useState(false); @@ -132,16 +134,18 @@ export function MessageComposer({ onSendText, onSendImage, onSendLocation, onOpe - setUrgent((prev) => !prev)} disabled={disabled} accessibilityLabel={t('chat.urgent')}> - - + {allowUrgent ? ( + setUrgent((prev) => !prev)} disabled={disabled} accessibilityLabel={t('chat.urgent')}> + + + ) : null} - {urgent ? ( + {allowUrgent && urgent ? ( {t('chat.urgent_will_send')} diff --git a/src/models/v4/chat/chatbotModels.ts b/src/models/v4/chat/chatbotModels.ts index 4f664c2..61daa56 100644 --- a/src/models/v4/chat/chatbotModels.ts +++ b/src/models/v4/chat/chatbotModels.ts @@ -1,21 +1,29 @@ /** - * Chatbot (assistant) API response shapes. Unlike the Chat controller, the - * Chatbot web-chat endpoints return plain objects (not the { Data } envelope). + * Chatbot (assistant) API response shapes. Like the Chat controller, the chatbot + * web-chat endpoints wrap their payload in the standard v4 { Data } envelope. */ -export interface ChatbotChannelResponse { +export interface ChatbotChannelData { ChatChannelId: string; Name?: string | null; LastMessageSeq: number; LastMessageOn?: string | null; } -export interface ChatbotSendResponse { +export interface ChatbotChannelResponse { + Data?: ChatbotChannelData | null; +} + +export interface ChatbotSendData { ChatMessageId: string; MessageSeq: number; SentOn: string; } +export interface ChatbotSendResponse { + Data?: ChatbotSendData | null; +} + export interface ChatbotSessionResponse { - success: boolean; + Success: boolean; } diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts index 51db613..dbbb2b3 100644 --- a/src/stores/chat/store.ts +++ b/src/stores/chat/store.ts @@ -752,8 +752,10 @@ export const useChatStore = create()( }, handleAckRequired: (raw: unknown) => { - const ack = parseEventData(raw); + const ack = parseEventData(raw); if (!ack || !ack.ChatMessageId) return; + // The sender never has to acknowledge their own urgent message. + if (ack.SenderUserId && ack.SenderUserId === currentUserId()) return; set((s) => (s.pendingAcks.some((a) => a.ChatMessageId === ack.ChatMessageId) ? {} : { pendingAcks: [...s.pendingAcks, ack] })); },