diff --git a/examples/ExpoChatGPTSample/contexts/AppContext.tsx b/examples/ExpoChatGPTSample/contexts/AppContext.tsx index eeef3e9846..14ee62f117 100644 --- a/examples/ExpoChatGPTSample/contexts/AppContext.tsx +++ b/examples/ExpoChatGPTSample/contexts/AppContext.tsx @@ -1,24 +1,47 @@ import type { PropsWithChildren } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react'; -import type { ImageSource, Session } from '@stream-io/calypso'; -import type { Channel, StreamChat } from 'stream-chat'; +import { + type ConversationSnapshot, + type ImageSource, + LiveReasoning, + type Session, +} from '@stream-io/calypso'; +import type { Channel, Event, StreamChat } from 'stream-chat'; import { useStableCallback } from 'stream-chat-expo'; +import type { AIToolApprover } from 'stream-chat-expo/ai-components'; import { agents, agentSessions, streamToken } from '../agent/agents'; import { AgentConfig } from '../agent/config'; -type Conversation = { channel: Channel; session: Session }; +type Conversation = { channel: Channel; reasoning: LiveReasoning; session: Session }; + +const IDLE: ConversationSnapshot = { kind: 'idle', skills: [] }; + +// This install, for the tool calls addressed to it. Kept for the app's lifetime. +const clientId = `rn-sample-${(globalThis as { crypto: { randomUUID: () => string } }).crypto.randomUUID()}`; export type AppContextValue = { + /** What the agent is doing in the open conversation, from the session's socket. */ + agentState: ConversationSnapshot; + /** Answers the open conversation's tool calls that ask first. */ + approver: AIToolApprover | undefined; /** The channel the router writes the open conversation into. */ channel: Channel | undefined; + /** Bumped when the drawer's list of conversations may have changed. */ + conversationsVersion: number; /** Why the last conversation couldn't be opened, or what asking failed with. */ failure: string | undefined; + /** The open conversation's session id. */ + sessionId: string | undefined; /** Asks the agent, with photos as data URIs. Its reply arrives in the channel. */ ask: (text: string, images?: ImageSource[]) => Promise; - /** Carries on a conversation from the drawer. */ - openConversation: (channel: Channel) => void; + /** Carries on a conversation from the drawer, by its session id. */ + openConversation: (sessionId: string) => void; + /** The whole of a reply's reasoning step so far, from its live updates. */ + reasoningText: (messageId: string, stepId: string) => string | undefined; + /** Bumped as reasoning arrives, so the steps showing it render again. */ + reasoningVersion: number; /** Starts a new conversation. */ startConversation: () => void; /** Stops the reply being written. What it wrote so far stays. */ @@ -26,10 +49,16 @@ export type AppContextValue = { }; export const AppContext = React.createContext({ + agentState: IDLE, + approver: undefined, ask: async () => {}, channel: undefined, + conversationsVersion: 0, failure: undefined, openConversation: () => {}, + reasoningText: () => undefined, + reasoningVersion: 0, + sessionId: undefined, startConversation: () => {}, stop: () => {}, }); @@ -39,6 +68,9 @@ const messageOf = (error: unknown) => (error instanceof Error ? error.message : export const AppProvider = ({ client, children }: PropsWithChildren<{ client: StreamChat }>) => { const [conversation, setConversation] = useState(); const [failure, setFailure] = useState(); + const [agentState, setAgentState] = useState(IDLE); + const [reasoningVersion, setReasoningVersion] = useState(0); + const [conversationsVersion, setConversationsVersion] = useState(0); const current = useRef(undefined); // Opening one conversation after another: the newest wins, and one that was never asked @@ -49,13 +81,14 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St const session = await opening(); const { channel } = await session.chat(); const previous = current.current; - const next = { channel: channel as Channel, session }; + const next = { channel: channel as Channel, reasoning: new LiveReasoning(), session }; current.current = next; setConversation(next); if (previous && previous.session.id !== session.id) { const unused = previous.channel.state.messages.length === 0; await (unused ? previous.session.delete() : previous.session.close()).catch(() => {}); } + setConversationsVersion((version) => version + 1); } catch (error) { setFailure(messageOf(error)); } @@ -65,12 +98,11 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St void open(() => agentSessions().create({ modelOverwrites: { llm: AgentConfig.chatModel } })); }); - const openConversation = useStableCallback((channel: Channel) => { - if (!channel.id || channel.id === current.current?.session.id) { + const openConversation = useStableCallback((sessionId: string) => { + if (sessionId === current.current?.session.id) { return; } - // A conversation's session id is its channel's id. - void open(() => agentSessions().resume(channel.id as string)); + void open(() => agentSessions().resume(sessionId)); }); const ask = useStableCallback(async (text: string, images: ImageSource[] = []) => { @@ -94,6 +126,53 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St } }); + const reasoningText = useStableCallback((messageId: string, stepId: string) => + current.current?.reasoning.get(messageId, stepId), + ); + + // The open conversation: what the agent is doing, and every live update's slice of thinking, + // read from each event rather than the message list, which can fold several into one. + useEffect(() => { + if (!conversation) { + return; + } + const { channel, reasoning, session } = conversation; + setAgentState(session.state.get()); + const stopState = session.state.subscribe(() => { + const state = session.state.get(); + setAgentState(state); + // The router titles a conversation as it goes. + if (state.kind === 'idle') setConversationsVersion((version) => version + 1); + }); + const subscription = channel.on('message.updated', (event: Event) => { + if (event.message) { + reasoning.read(event.message as { id: string; reasoning?: unknown }); + setReasoningVersion((version) => version + 1); + } + }); + return () => { + stopState(); + subscription.unsubscribe(); + }; + }, [conversation]); + + const approver = useMemo( + () => + conversation + ? { + clientId, + // `decide` sends over the session's socket and throws when it can't. + decide: (call, allowed) => + new Promise((resolve) => { + conversation.session.decide(call.id, { allowed }); + resolve(); + }), + userId: AgentConfig.user.id, + } + : undefined, + [conversation], + ); + useEffect(() => { let cancelled = false; (async () => { @@ -115,14 +194,32 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St const contextValue = useMemo( () => ({ + agentState, + approver, ask, channel: conversation?.channel, + conversationsVersion, failure, openConversation, + reasoningText, + reasoningVersion, + sessionId: conversation?.session.id, startConversation, stop, }), - [ask, conversation, failure, openConversation, startConversation, stop], + [ + agentState, + approver, + ask, + conversation, + conversationsVersion, + failure, + openConversation, + reasoningText, + reasoningVersion, + startConversation, + stop, + ], ); return {children}; diff --git a/examples/ExpoChatGPTSample/screens/ChatContent.tsx b/examples/ExpoChatGPTSample/screens/ChatContent.tsx index 36b796c9f7..6e6b978571 100644 --- a/examples/ExpoChatGPTSample/screens/ChatContent.tsx +++ b/examples/ExpoChatGPTSample/screens/ChatContent.tsx @@ -1,5 +1,13 @@ import { useMemo, useState } from 'react'; -import { ActivityIndicator, Platform, StatusBar, StyleSheet, Text, View } from 'react-native'; +import { + ActivityIndicator, + Platform, + StatusBar, + StyleSheet, + Text, + useWindowDimensions, + View, +} from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -7,25 +15,27 @@ import { useHeaderHeight } from 'expo-router/react-navigation'; import type { MessageProps, ResolvedComponents } from 'stream-chat-expo'; import { - AIStates, Channel, isLocalUrl, mergeThemes, Message, MessageList, ThemeProvider, - useAIState, - useChannelContext, useComponentsContext, useMessageContext, + usePaginatedMessageListContext, useTheme, WithComponents, } from 'stream-chat-expo'; import { AI_MESSAGE_PART_TYPE_PREFIX, + AIMessageParts, + AIThinking, + AIToolApproverProvider, AITypingIndicatorView, ComposerView, + parseMessageParts, StreamingMessageView, } from 'stream-chat-expo/ai-components'; @@ -84,33 +94,43 @@ const CustomMessage = (props: MessageProps) => { type UnsupportedAttachmentComponent = ResolvedComponents['UnsupportedAttachment']; // A reply's steps (`ai_reasoning`, `ai_tool_call`) are attachments the chat SDK doesn't know. They -// get their own views; until then they're left out rather than shown as unsupported files, and any -// other unknown attachment keeps the SDK's own view. +// show in the reply's own view, so they're left out here; any other unknown attachment keeps the +// SDK's own view. const hidingAISteps = (Fallback: UnsupportedAttachmentComponent) => { const HideAISteps: UnsupportedAttachmentComponent = (props) => props.attachment.type?.startsWith(AI_MESSAGE_PART_TYPE_PREFIX) ? null : ; return HideAISteps; }; +// A reply: its steps (reasoning, tool calls and their questions), then its answer. const CustomStreamingMessageView = () => { const { message } = useMessageContext(); + // Read so the steps render again as more of their reasoning arrives. + const { reasoningText, reasoningVersion } = useAppContext(); + const parts = useMemo(() => parseMessageParts(message.attachments), [message.attachments]); + const stepText = useMemo( + () => (stepId: string) => reasoningText(message.id, stepId), + // eslint-disable-next-line react-hooks/exhaustive-deps -- a new function per slice of reasoning + [message.id, reasoningText, reasoningVersion], + ); + // The message list sizes a bubble to its content; a reply's steps take the row's whole width, + // the screen less the list's 16 on each side. + const { width } = useWindowDimensions(); return ( - - + + {parts.length > 0 ? : null} + {message.text ? : null} ); }; const CustomComposerView = () => { - const { channel } = useChannelContext(); - const { stop } = useAppContext(); - - const { aiState } = useAIState(channel); + const { agentState, stop } = useAppContext(); // The router stops through its own session, not with an AI indicator event. const stopGenerating = useMemo(() => () => Promise.resolve(stop()), [stop]); - const isGenerating = [AIStates.Thinking, AIStates.Generating].includes(aiState); + const isGenerating = agentState.kind === 'responding' || agentState.kind === 'working'; const safeAreaInsets = useSafeAreaInsets(); const insets = useMemo( @@ -142,27 +162,30 @@ const EmptyStateIndicator = () => ( ); +// What the agent is doing while its reply shows nothing yet: thinking, or running skills. const CustomAITypingIndicatorView = () => { - const { channel } = useChannelContext(); - const { aiState } = useAIState(channel); - - const allowedStates = { - [AIStates.Thinking]: 'Thinking about the question...', - [AIStates.Generating]: 'Generating a response...', - [AIStates.ExternalSources]: 'Checking external sources...', - }; + const { agentState } = useAppContext(); + const { messages } = usePaginatedMessageListContext(); + const latest = messages[messages.length - 1]; + const replyHasBegun = + !!latest?.ai_generated && (!!latest.text || parseMessageParts(latest.attachments).length > 0); - const indicatorText = allowedStates[aiState as keyof typeof allowedStates]; - - if (!indicatorText) { - return null; + if (agentState.kind === 'working') { + const skills = agentState.skills.map((skill) => skill.replace(/_/g, ' ')).join(' and '); + return ( + + + + ); } - - return ( - - - - ); + if (agentState.kind === 'responding' && !replyHasBegun) { + return ( + + + + ); + } + return null; }; const RenderNull = () => null; @@ -176,7 +199,7 @@ const additionalFlatListProps = { }; export const ChatContent = () => { - const { channel, failure } = useAppContext(); + const { approver, channel, failure } = useAppContext(); const { bottom } = useSafeAreaInsets(); // The channel sits below the drawer header, so the keyboard offset has to account for it // (without it, Android keeps the composer behind the keyboard). @@ -211,13 +234,19 @@ export const ChatContent = () => { UnsupportedAttachment: CustomUnsupportedAttachment, }} > - - - - {failure ? {failure} : null} - - - + + + + + {failure ? {failure} : null} + + + + ); @@ -234,7 +263,7 @@ const styles = StyleSheet.create({ }, emptyContainerText: { fontSize: 24, fontWeight: 'bold' }, failureText: { color: '#B00020', fontSize: 15, paddingHorizontal: 24, textAlign: 'center' }, - streamingMessageViewWrapper: { width: '100%', paddingHorizontal: 16 }, + streamingMessageViewWrapper: { paddingHorizontal: 16 }, aiTypingIndicatorWrapper: { paddingHorizontal: 24, paddingVertical: 12, diff --git a/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx b/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx index 3157a9dd42..7a518cf3a9 100644 --- a/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx +++ b/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx @@ -1,61 +1,40 @@ -import React from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import type { Schemas } from '@stream-io/calypso'; import type { DrawerContentComponentProps } from 'expo-router/drawer'; -import type { ChannelSort } from 'stream-chat'; -import type { Channel as ChannelClass, ChannelFilters } from 'stream-chat'; -import { - ChannelList, - ChannelPreviewProps, - Plus, - useChannelsContext, - useStableCallback, - WithComponents, -} from 'stream-chat-expo'; +import { Plus, useStableCallback } from 'stream-chat-expo'; -import { AgentConfig } from '../agent/config'; +import { agentSessions } from '../agent/agents'; import { useAppContext } from '../contexts/AppContext'; -// The router keeps each conversation in an `agent` channel the user is a member of. -const filters: ChannelFilters = { - members: { - $in: [AgentConfig.user.id], - }, - type: 'agent', -}; - -const sort: ChannelSort = { last_updated: -1 }; +type SessionRow = Schemas['Session']; -const ChannelPreview = (props: ChannelPreviewProps) => { - const channel = props.channel; - const { onSelect } = useChannelsContext(); - const onPress = useStableCallback(() => { - onSelect?.(channel); - }); - return ( - ({ - paddingVertical: 8, - paddingHorizontal: 12, - opacity: pressed ? 0.6 : 1, - })} - onPress={onPress} - > - - {channel.data?.name ?? channel.cid} - - - ); -}; +const keyExtractor = (session: SessionRow) => session.id; +/** The agent's conversations, newest first, as the router lists them. */ export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => { - const { openConversation, startConversation } = useAppContext(); + const { conversationsVersion, openConversation, sessionId, startConversation } = useAppContext(); + const [sessions, setSessions] = useState([]); + + useEffect(() => { + let cancelled = false; + agentSessions() + .query({ limit: 30 }) + .then((page) => { + if (!cancelled) setSessions(page.items); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [conversationsVersion]); - const onSelect = useStableCallback((channel: ChannelClass) => { - openConversation(channel); + const onSelect = useStableCallback((id: string) => { + openConversation(id); navigation.closeDrawer(); }); @@ -64,31 +43,42 @@ export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => { navigation.closeDrawer(); }); + const renderItem = ({ item }: { item: SessionRow }) => ( + onSelect(item.id)} + style={({ pressed }) => [styles.row, { opacity: pressed ? 0.6 : 1 }]} + > + + {item.title || 'New conversation'} + + + ); + return ( Conversations - + - - - + ); }; const styles = StyleSheet.create({ - wrapper: { flex: 1, backgroundColor: '#FFFFFF' }, container: { + borderBottomColor: 'grey', + borderBottomWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginHorizontal: 12, paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: 'grey', }, - title: { fontSize: 15, fontWeight: 'bold' }, + current: { color: '#005FFF' }, previewText: { fontSize: 15, fontWeight: 'bold' }, + row: { paddingHorizontal: 12, paddingVertical: 8 }, + title: { fontSize: 15, fontWeight: 'bold' }, + wrapper: { backgroundColor: '#FFFFFF', flex: 1 }, }); diff --git a/package/src/ai-components/AITypingIndicatorView.tsx b/package/src/ai-components/AITypingIndicatorView.tsx index ac230bf6f8..b648e33699 100644 --- a/package/src/ai-components/AITypingIndicatorView.tsx +++ b/package/src/ai-components/AITypingIndicatorView.tsx @@ -1,31 +1,16 @@ import { useMemo } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { StyleSheet, Text } from 'react-native'; import { useTheme } from './contexts'; - -import { NativeShimmerView } from '../components/UIComponents/NativeShimmerView'; +import { Shimmer } from './internal/Shimmer'; export const AITypingIndicatorView = ({ text }: { text: string }) => { - const { - theme: { - colors: { shimmer }, - }, - } = useTheme(); const styles = useStyles(); - // The native shimmer paints behind its children, so it is laid over the text instead: the - // highlight has to sweep across the glyphs, which a transparent background would otherwise hide. return ( - + {text} - - + ); }; @@ -35,8 +20,6 @@ const useStyles = () => { return useMemo( () => StyleSheet.create({ - // The native highlight band is soft and wide, so it is kept fairly strong over the text. - shimmer: { opacity: 0.8 }, text: { fontSize: 16, color: theme.colors.grey_dark, diff --git a/package/src/ai-components/__tests__/format.test.ts b/package/src/ai-components/__tests__/format.test.ts new file mode 100644 index 0000000000..894fc64d97 --- /dev/null +++ b/package/src/ai-components/__tests__/format.test.ts @@ -0,0 +1,32 @@ +type Format = typeof import('../internal/format'); + +const loadFormat = () => { + let format: Format | undefined; + jest.isolateModules(() => { + format = require('../internal/format'); + }); + return format as Format; +}; + +describe('formatCallDuration', () => { + afterEach(() => jest.restoreAllMocks()); + + it('writes narrow units', () => { + const { formatCallDuration, formatMinutesAndSeconds } = loadFormat(); + expect(formatCallDuration(0.42)).toBe('0.4s'); + expect(formatCallDuration(65)).toBe('1m 5s'); + expect(formatMinutesAndSeconds(120)).toBe('2m'); + }); + + it('writes the same where Intl ignores the narrow width, as Hermes on iOS does', () => { + jest + .spyOn(Intl, 'NumberFormat') + .mockImplementation( + () => ({ format: (value: number) => `${value} sec` }) as unknown as Intl.NumberFormat, + ); + const { formatCallDuration, formatMinutesAndSeconds } = loadFormat(); + expect(formatCallDuration(0.42)).toBe('0.4s'); + expect(formatCallDuration(65)).toBe('1m 5s'); + expect(formatMinutesAndSeconds(7)).toBe('7s'); + }); +}); diff --git a/package/src/ai-components/__tests__/messagePartViews.test.tsx b/package/src/ai-components/__tests__/messagePartViews.test.tsx new file mode 100644 index 0000000000..7f64b6207c --- /dev/null +++ b/package/src/ai-components/__tests__/messagePartViews.test.tsx @@ -0,0 +1,228 @@ +import React, { type ReactElement } from 'react'; +import { I18nManager } from 'react-native'; + +import { act, fireEvent, render, screen } from '@testing-library/react-native'; + +import { + AIMessagePart, + AIReasoning, + AIThinking, + AIToolApproval, + AIToolApprovalCard, + type AIToolApprover, + AIToolCall, + type AIToolCallPart, + parseMessagePart, + StreamTheme, +} from '../index'; + +const renderWithTheme = (ui: ReactElement) => { + const result = render({ui}); + return { + ...result, + rerender: (next: ReactElement) => result.rerender({next}), + }; +}; + +const toolCall = (overrides: Partial = {}): AIToolCallPart => ({ + executor: 'server', + id: 'call_1', + name: 'get_weather', + status: 'running', + ...overrides, +}); + +describe('AIToolCall', () => { + it('names the call from its display title, else its name', () => { + const { rerender } = renderWithTheme( + , + ); + expect(screen.getByText('Checking the weather')).toBeTruthy(); + rerender(); + expect(screen.getByText('get weather')).toBeTruthy(); + }); + + it.each([ + ['awaiting_approval', 'Waiting for approval'], + ['awaiting_authorization', 'Waiting for you to sign in'], + ['awaiting_client', 'Waiting for the device'], + ['failed', "Didn't complete"], + ['cancelled', 'Cancelled'], + ])('says what a %s call waits for', (status, detail) => { + renderWithTheme(); + expect(screen.getByText(detail)).toBeTruthy(); + }); + + it('prefers the summary, and says a declined call was declined', () => { + const { rerender } = renderWithTheme( + , + ); + expect(screen.getByText('No network')).toBeTruthy(); + rerender( + , + ); + expect(screen.getByText('Declined')).toBeTruthy(); + }); + + it('shows how long a finished call took', () => { + const { rerender } = renderWithTheme( + , + ); + expect(screen.getByText('0.4s')).toBeTruthy(); + rerender(); + expect(screen.getByText('1m 5s')).toBeTruthy(); + rerender(); + expect(screen.queryByText('1m 5s')).toBeNull(); + }); +}); + +describe('AIToolApprovalCard', () => { + it('asks with the reason as a sentence and the agent’s own button titles', () => { + const onDecide = jest.fn(); + renderWithTheme( + , + ); + expect(screen.getByText('To check the local weather.')).toBeTruthy(); + expect(screen.getByText('Only your city is shared.')).toBeTruthy(); + fireEvent.press(screen.getByRole('button', { name: 'Share' })); + fireEvent.press(screen.getByRole('button', { name: "Don't Allow" })); + expect(onDecide.mock.calls).toEqual([[true], [false]]); + }); +}); + +describe('AIToolApproval', () => { + const asking = toolCall({ + approval: { title: 'Share your location?' }, + executor: 'client', + status: 'awaiting_approval', + targetClientId: 'ios-1', + targetUserId: 'alex', + }); + const approver = (decide: AIToolApprover['decide']): AIToolApprover => ({ + clientId: 'ios-1', + decide, + userId: 'alex', + }); + + it('asks only the targeted person on the targeted install', () => { + const decide = jest.fn(() => Promise.resolve()); + const { rerender } = renderWithTheme(); + expect(screen.queryByText('Share your location?')).toBeNull(); + rerender(); + expect(screen.queryByText('Share your location?')).toBeNull(); + rerender(); + expect(screen.getByText('Share your location?')).toBeTruthy(); + }); + + it('sends the answer once, and lets the person answer again when it fails', async () => { + let fail: () => void = () => undefined; + const decide = jest.fn( + () => + new Promise((_, reject) => { + fail = () => reject(new Error('offline')); + }), + ); + renderWithTheme(); + + fireEvent.press(screen.getByRole('button', { name: 'Allow' })); + fireEvent.press(screen.getByRole('button', { name: 'Allow' })); + expect(decide).toHaveBeenCalledTimes(1); + expect(decide).toHaveBeenCalledWith(asking, true); + expect(screen.getByRole('button', { name: 'Allow' })).toBeDisabled(); + + await act(async () => { + fail(); + await Promise.resolve(); + }); + expect(screen.getByText("Your answer couldn't be sent. Try again.")).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Allow' })).toBeEnabled(); + }); +}); + +describe('AIMessagePart', () => { + it('shows a placeholder for a step it cannot show', () => { + renderWithTheme(); + expect(screen.getByText("A step this app version can't show")).toBeTruthy(); + }); +}); + +describe('AIReasoning', () => { + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('counts the seconds while thinking, and folds when done', () => { + jest.useFakeTimers(); + const text = 'First thought.\n\n## Plan\nSecond thought.'; + const { rerender } = renderWithTheme(); + expect(screen.getByRole('button', { expanded: true, name: /Thinking\.\.\./ })).toBeTruthy(); + expect(screen.getByText('Plan')).toBeTruthy(); + + act(() => jest.advanceTimersByTime(7_000)); + expect(screen.getByText('Thinking... 7s')).toBeTruthy(); + + rerender( + , + ); + expect(screen.getByRole('button', { expanded: false, name: /Thought for 12s/ })).toBeTruthy(); + expect(screen.getByText('Planned the day')).toBeTruthy(); + expect(screen.queryByText('Plan')).toBeNull(); + + fireEvent.press(screen.getByRole('button', { name: /Thought for 12s/ })); + expect(screen.getByRole('button', { expanded: true, name: /Thought for 12s/ })).toBeTruthy(); + expect(screen.getByText('Plan')).toBeTruthy(); + }); + + it('shows a step’s live text, else its preview', () => { + const part = { durationMs: 2000, id: 'r1', preview: 'The opening', status: 'completed' }; + const { rerender } = renderWithTheme( + 'The whole thought'} />, + ); + expect(screen.getByText('The whole thought')).toBeTruthy(); + expect(screen.getByRole('button', { name: /Thought for 2s/ })).toBeTruthy(); + rerender(); + expect(screen.getByText('The opening')).toBeTruthy(); + }); + + it('says Thinking... for thinking that shows nothing yet', () => { + renderWithTheme(); + expect(screen.getByRole('button', { expanded: false, name: 'Thinking...' })).toBeTruthy(); + }); + + it('turns the open chevron the other way in RTL', () => { + const rotatedBy = (degrees: string) => + screen.UNSAFE_root.findAll((node) => + JSON.stringify(node.props?.style ?? '').includes(`"rotate":"${degrees}"`), + ).length > 0; + const originalIsRTL = I18nManager.isRTL; + const setRTL = (value: boolean) => + Object.defineProperty(I18nManager, 'isRTL', { configurable: true, value }); + + try { + setRTL(false); + const { unmount } = renderWithTheme(); + expect(rotatedBy('90deg')).toBe(true); + unmount(); + + setRTL(true); + renderWithTheme(); + expect(rotatedBy('-90deg')).toBe(true); + } finally { + setRTL(originalIsRTL); + } + }); +}); diff --git a/package/src/ai-components/__tests__/translation.test.tsx b/package/src/ai-components/__tests__/translation.test.tsx index 0193d0707b..c0de463f6c 100644 --- a/package/src/ai-components/__tests__/translation.test.tsx +++ b/package/src/ai-components/__tests__/translation.test.tsx @@ -33,14 +33,16 @@ describe('AI translation', () => { expect(interpolateAIString('Thought for {{ duration }}', {})).toBe( 'Thought for {{ duration }}', ); - expect(interpolateAIString('Thinking…')).toBe('Thinking…'); + expect(interpolateAIString('Thinking...')).toBe('Thinking...'); expect(interpolateAIString('{{ count }} files', { count: 0 })).toBe('0 files'); }); it('is English without ', () => { const { result } = renderHook(() => useAITranslation()); expect(result.current.t).toBe(defaultAITranslate); - expect(result.current.t('Thinking… {{ duration }}', { duration: '4s' })).toBe('Thinking… 4s'); + expect(result.current.t('Thinking... {{ duration }}', { duration: '4s' })).toBe( + 'Thinking... 4s', + ); }); it('follows the chat SDK translator, as inside ', async () => { diff --git a/package/src/ai-components/contexts/translation-context.tsx b/package/src/ai-components/contexts/translation-context.tsx index c7bfca718c..581dfeb974 100644 --- a/package/src/ai-components/contexts/translation-context.tsx +++ b/package/src/ai-components/contexts/translation-context.tsx @@ -33,9 +33,12 @@ export const defaultAITranslate: AITranslate = (key, options) => interpolateAISt * no translator set up (no ``, or before it has one) they are in English. */ export const useAITranslation = () => { - const { t } = useContext(TranslationContext); + const { t, userLanguage } = useContext(TranslationContext); return useMemo( - () => ({ t: t === defaultTranslatorFunction ? defaultAITranslate : (t as AITranslate) }), - [t], + () => ({ + t: t === defaultTranslatorFunction ? defaultAITranslate : (t as AITranslate), + userLanguage: userLanguage ?? 'en', + }), + [t, userLanguage], ); }; diff --git a/package/src/ai-components/index.ts b/package/src/ai-components/index.ts index dbcd54096a..125dc61d25 100644 --- a/package/src/ai-components/index.ts +++ b/package/src/ai-components/index.ts @@ -9,6 +9,8 @@ export * from './voice-visualization'; // agent replies export * from './message-parts'; +export * from './reasoning'; +export * from './tool-calls'; // components export * from './components'; diff --git a/package/src/ai-components/internal/Shimmer.tsx b/package/src/ai-components/internal/Shimmer.tsx new file mode 100644 index 0000000000..aef0925571 --- /dev/null +++ b/package/src/ai-components/internal/Shimmer.tsx @@ -0,0 +1,40 @@ +import React, { type PropsWithChildren } from 'react'; +import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'; + +import { NativeShimmerView } from '../../components/UIComponents/NativeShimmerView'; +import { useTheme } from '../contexts'; + +type ShimmerProps = PropsWithChildren<{ active?: boolean; style?: StyleProp }>; + +/** + * Sweeps the native shimmer across its content while `active`, for something still in progress. + * The native shimmer paints behind its children, so it is laid over the content instead, with a + * transparent base: the highlight has to cross the glyphs, which would otherwise hide it. + */ +export const Shimmer = ({ active = true, children, style }: ShimmerProps) => { + const { + theme: { + colors: { shimmer }, + }, + } = useTheme(); + + return ( + + {children} + {active ? ( + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + // The native highlight band is soft and wide, so it is kept fairly strong over the text. + shimmer: { opacity: 0.8 }, +}); diff --git a/package/src/ai-components/internal/format.ts b/package/src/ai-components/internal/format.ts new file mode 100644 index 0000000000..026f55280d --- /dev/null +++ b/package/src/ai-components/internal/format.ts @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; + +// Hermes on iOS formats units with Foundation, which ignores the narrow width ("1 sec"), so +// there the units are written the way they are without Intl. +const honoursNarrowUnits = (() => { + try { + return ( + new Intl.NumberFormat('en', { style: 'unit', unit: 'second', unitDisplay: 'narrow' }).format( + 1, + ) === '1s' + ); + } catch { + return false; + } +})(); + +const unit = (locale: string, value: number, name: 'minute' | 'second') => { + if (!honoursNarrowUnits) return `${value}${name === 'minute' ? 'm' : 's'}`; + try { + return new Intl.NumberFormat(locale, { + style: 'unit', + unit: name, + unitDisplay: 'narrow', + }).format(value); + } catch { + return `${value}${name === 'minute' ? 'm' : 's'}`; + } +}; + +/** A whole number of seconds as minutes and seconds, in narrow units: "7s", "1m 5s". */ +export const formatMinutesAndSeconds = (totalSeconds: number, locale = 'en') => { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + if (minutes === 0) return unit(locale, rest, 'second'); + if (rest === 0) return unit(locale, minutes, 'minute'); + return `${unit(locale, minutes, 'minute')} ${unit(locale, rest, 'second')}`; +}; + +/** A tool call's duration: tenths below a second ("0.4s"), then minutes and seconds. */ +export const formatCallDuration = (durationSeconds: number, locale = 'en') => + durationSeconds < 1 + ? unit(locale, Math.round(durationSeconds * 10) / 10, 'second') + : formatMinutesAndSeconds(Math.round(durationSeconds), locale); + +/** `Date.now()`, refreshed every `intervalMs` while `active`. */ +export const useNow = (active: boolean, intervalMs = 1000) => { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!active) return; + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), intervalMs); + return () => clearInterval(timer); + }, [active, intervalMs]); + return now; +}; diff --git a/package/src/ai-components/internal/icons/AgentIcons.tsx b/package/src/ai-components/internal/icons/AgentIcons.tsx new file mode 100644 index 0000000000..188bd0fe9b --- /dev/null +++ b/package/src/ai-components/internal/icons/AgentIcons.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { I18nManager } from 'react-native'; +import Svg, { Path } from 'react-native-svg'; + +// The agent reply steps' icons, on a 20 × 20 grid with a 1.5 stroke, as in stream-chat-react's +// AI components. Decorative: the text beside each one says what it means. + +export type AgentIconProps = { color: string; size?: number }; + +const circle = + 'M17.5 10C17.5 14.14 14.14 17.5 10 17.5C5.86 17.5 2.5 14.14 2.5 10C2.5 5.86 5.86 2.5 10 2.5C14.14 2.5 17.5 5.86 17.5 10Z'; + +const strokeIcon = (d: string, mirrorsInRTL = false) => { + const Icon = ({ color, size = 16 }: AgentIconProps) => ( + + + + ); + return Icon; +}; + +export const ReasoningIcon = strokeIcon( + 'M7.5 15.625H12.5M8.125 18.125H11.875M10 1.875C6.89 1.875 4.375 4.39 4.375 7.5C4.375 9.53 5.45 11.31 7.06 12.3C7.33 12.47 7.5 12.76 7.5 13.08V13.75H12.5V13.08C12.5 12.76 12.67 12.47 12.94 12.3C14.55 11.31 15.625 9.53 15.625 7.5C15.625 4.39 13.11 1.875 10 1.875Z', +); + +export const ChevronIcon = strokeIcon('M7.5 4.375L13.125 10L7.5 15.625', true); + +export const CheckIcon = strokeIcon('M4.375 10.625L8.125 14.375L15.625 5.625'); + +export const FailedIcon = strokeIcon(`M10 6.25V10.625M10 13.75H10.006${circle}`); + +export const CancelledIcon = strokeIcon(`M6.875 10H13.125${circle}`); + +export const InfoIcon = strokeIcon(`M10 9.375V13.75M10 6.25H10.006${circle}`); + +export const HandRaisedIcon = strokeIcon( + 'M10 5V9.375M10 5C10 4.31 10.56 3.75 11.25 3.75C11.94 3.75 12.5 4.31 12.5 5V9.375M10 5V3.75C10 3.06 9.44 2.5 8.75 2.5C8.06 2.5 7.5 3.06 7.5 3.75V5M12.5 6.25C12.5 5.56 13.06 5 13.75 5C14.44 5 15 5.56 15 6.25V11.875C15 15.03 12.84 17.5 10 17.5C7.82 17.5 6.43 16.37 5.42 14.43L3.92 11.53C3.6 10.92 3.84 10.17 4.45 9.86C5 9.58 5.67 9.73 6.04 10.23L7.5 12.19V5M7.5 5V9.375', +); + +export const KeyIcon = strokeIcon( + 'M12.5 5.625C13.19 5.625 13.75 6.185 13.75 6.875M16.875 6.875C16.875 9.635 14.635 11.875 11.875 11.875C11.37 11.875 10.88 11.8 10.42 11.66L8.75 13.125H7.5V14.375H6.25V15.625H5V16.875H3.125V14.88L8.34 9.66C8.2 9.2 8.125 8.71 8.125 8.125C8.125 4.94 10.37 1.875 13.75 2.5C15.63 2.85 16.875 4.69 16.875 6.875Z', +); + +export const DeviceIcon = strokeIcon( + 'M8.125 15H11.875M6.25 18.125H13.75C14.44 18.125 15 17.565 15 16.875V3.125C15 2.435 14.44 1.875 13.75 1.875H6.25C5.56 1.875 5 2.435 5 3.125V16.875C5 17.565 5.56 18.125 6.25 18.125Z', +); diff --git a/package/src/ai-components/message-parts/AIMessageParts.tsx b/package/src/ai-components/message-parts/AIMessageParts.tsx new file mode 100644 index 0000000000..9c99694349 --- /dev/null +++ b/package/src/ai-components/message-parts/AIMessageParts.tsx @@ -0,0 +1,94 @@ +import React, { type ReactNode } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { AIToolApproval } from './AIToolApproval'; +import { AIToolCall } from './AIToolCall'; +import type { AIMessagePartData } from './types'; + +import { useAITranslation, useTheme } from '../contexts'; +import { InfoIcon } from '../internal/icons/AgentIcons'; +import { AIReasoning } from '../reasoning/AIReasoning'; +import type { AIToolApprover } from '../tool-calls/AIToolApprover'; + +export type AIMessagePartProps = { + part: AIMessagePartData; + /** + * Who answers tool calls' questions on this device, to ask them. Defaults to the one of the + * nearest `AIToolApproverProvider`. + */ + approver?: AIToolApprover; + /** The whole of a reasoning step's thinking, when the app has it. */ + reasoningText?: (stepId: string) => string | undefined; +}; + +/** + * One step of a reply: a round of reasoning (`AIReasoning`), a tool call (`AIToolCall`, with its + * question when it waits for this person here), or a neutral line for a step this SDK can't + * show. The counterpart of `AIMessagePartView`. + */ +export const AIMessagePart = ({ approver, part, reasoningText }: AIMessagePartProps) => { + const { t } = useAITranslation(); + const { + theme: { colors, messageParts }, + } = useTheme(); + + if (part.reasoning) { + return ; + } + if (part.toolCall) { + return ( + + + + + ); + } + return ( + + + + {t("A step this app version can't show")} + + + ); +}; + +export type AIMessagePartsProps = Omit & { + parts: AIMessagePartData[]; + /** Renders a step, instead of `AIMessagePart`. */ + children?: (part: AIMessagePartData) => ReactNode; +}; + +/** The steps of a reply, in order. The counterpart of `AIMessagePartsView`. */ +export const AIMessageParts = ({ + approver, + children, + parts, + reasoningText, +}: AIMessagePartsProps) => { + const { + theme: { messageParts }, + } = useTheme(); + return ( + + {parts.map((part) => ( + + {children ? ( + children(part) + ) : ( + + )} + + ))} + + ); +}; + +const styles = StyleSheet.create({ + parts: { gap: 8, width: '100%' }, + tool: { gap: 8 }, + unsupported: { alignItems: 'center', flexDirection: 'row', gap: 8 }, + unsupportedText: { flexShrink: 1, fontSize: 14 }, +}); diff --git a/package/src/ai-components/message-parts/AIToolApproval.tsx b/package/src/ai-components/message-parts/AIToolApproval.tsx new file mode 100644 index 0000000000..73e3d2efa6 --- /dev/null +++ b/package/src/ai-components/message-parts/AIToolApproval.tsx @@ -0,0 +1,193 @@ +import React, { type ReactNode, useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { isAwaitingApproval } from './parseMessageParts'; +import type { AIToolApprovalData, AIToolCallPart } from './types'; + +import { useAITranslation, useTheme } from '../contexts'; +import { type AIToolApprover, useAIToolApprover } from '../tool-calls/AIToolApprover'; + +/** Where the person's answer to a tool call's question is. */ +export type AIToolApprovalState = { + /** The answer couldn't be sent, and the person can answer again. */ + failed: boolean; + /** The answer is on its way, or arrived and the call's step hasn't changed yet. */ + isSending: boolean; +}; + +const idle: AIToolApprovalState = { failed: false, isSending: false }; + +/** The agent's reason, as a sentence, then what allowing it shares. */ +export const approvalLines = (approval: AIToolApprovalData) => { + const lines: string[] = []; + const reason = approval.reason?.trim(); + if (reason) { + const sentence = reason[0].toUpperCase() + reason.slice(1); + lines.push(/[.!?…]$/.test(sentence) ? sentence : `${sentence}.`); + } + if (approval.message) lines.push(approval.message); + return lines; +}; + +export type AIToolApprovalCardProps = { + approval: AIToolApprovalData; + /** Called with the person's answer. */ + onDecide: (allowed: boolean) => void; + state?: AIToolApprovalState; +}; + +/** + * Asks the person a tool call's question, such as "Share your location?", with the agent's + * reason and what allowing it shares, and an Allow and a Don't Allow button. Used inline under + * a step by `AIToolApproval`, and on its own, such as on a voice call. The counterpart of + * `AIToolApprovalCard`. + */ +export const AIToolApprovalCard = ({ + approval, + onDecide, + state = idle, +}: AIToolApprovalCardProps) => { + const { t } = useAITranslation(); + const { + theme: { colors, messageParts }, + } = useTheme(); + const card = messageParts?.approvalCard; + + return ( + + + {approval.title} + {approvalLines(approval).map((line) => ( + + {line} + + ))} + + + onDecide(true)} + style={({ pressed }) => [ + styles.button, + { backgroundColor: colors.accent_blue, opacity: pressed ? 0.7 : 1 }, + card?.allowButton, + ]} + > + + {approval.allowTitle ?? t('Allow')} + + + onDecide(false)} + style={({ pressed }) => [ + styles.button, + styles.declineButton, + { borderColor: colors.grey_gainsboro, opacity: pressed ? 0.7 : 1 }, + card?.declineButton, + ]} + > + + {approval.declineTitle ?? t("Don't Allow")} + + + {state.isSending ? : null} + + {state.failed ? ( + + {t("Your answer couldn't be sent. Try again.")} + + ) : null} + + ); +}; + +export type AIToolApprovalProps = { + part: AIToolCallPart; + /** Who answers on this device. Defaults to the one of the nearest `AIToolApproverProvider`. */ + approver?: AIToolApprover; + /** Renders the question, instead of `AIToolApprovalCard`. */ + children?: ( + approval: AIToolApprovalData, + state: AIToolApprovalState, + decide: (allowed: boolean) => void, + ) => ReactNode; +}; + +/** + * Asks the question of a tool call that waits for this person on this device, and sends their + * answer with `approver.decide`. Until the call's step changes the buttons wait; a failed send + * lets them answer again. Renders nothing for a call that does not wait for them here. The + * counterpart of `AIToolApprovalView`. + */ +export const AIToolApproval = ({ approver: approverProp, children, part }: AIToolApprovalProps) => { + const approver = useAIToolApprover(approverProp); + const [state, setState] = useState(idle); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + if ( + !approver || + !part.approval || + !isAwaitingApproval(part, approver.userId, approver.clientId) + ) { + return null; + } + + const decide = (allowed: boolean) => { + if (state.isSending) return; + setState({ failed: false, isSending: true }); + approver.decide(part, allowed).catch(() => { + if (mounted.current) setState({ failed: true, isSending: false }); + }); + }; + + return ( + <> + {children ? ( + children(part.approval, state, decide) + ) : ( + + )} + + ); +}; + +const styles = StyleSheet.create({ + actions: { alignItems: 'center', flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + button: { + alignItems: 'center', + borderRadius: 9999, + justifyContent: 'center', + minHeight: 36, + paddingHorizontal: 14, + paddingVertical: 6, + }, + buttonText: { fontSize: 14, fontWeight: '600' }, + container: { borderRadius: 16, borderWidth: 1, gap: 12, padding: 12 }, + declineButton: { borderWidth: 1 }, + failure: { fontSize: 13 }, + message: { fontSize: 14, lineHeight: 20 }, + text: { gap: 4 }, + title: { fontSize: 15, fontWeight: '600', lineHeight: 20 }, +}); diff --git a/package/src/ai-components/message-parts/AIToolCall.tsx b/package/src/ai-components/message-parts/AIToolCall.tsx new file mode 100644 index 0000000000..1a1422ed21 --- /dev/null +++ b/package/src/ai-components/message-parts/AIToolCall.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +import { isDeclined, isToolCallFinished } from './parseMessageParts'; +import type { AIToolCallPart } from './types'; + +import { useAITranslation, useTheme } from '../contexts'; +import { formatCallDuration } from '../internal/format'; +import { + CancelledIcon, + CheckIcon, + DeviceIcon, + FailedIcon, + HandRaisedIcon, + KeyIcon, +} from '../internal/icons/AgentIcons'; +import { Shimmer } from '../internal/Shimmer'; + +export type AIToolCallProps = { + part: AIToolCallPart; +}; + +/** + * One tool call of a reply: what it is doing (its `displayTitle`, else its name), how it is + * going, and how long it took once it is done. The counterpart of `AIToolCallView`. + */ +export const AIToolCall = ({ part }: AIToolCallProps) => { + const { t, userLanguage } = useAITranslation(); + const { + theme: { colors, messageParts }, + } = useTheme(); + + const title = part.displayTitle ?? part.name.replace(/_/g, ' '); + const detail = (() => { + if (isDeclined(part)) return part.summary ?? t('Declined'); + switch (part.status) { + case 'awaiting_approval': + return part.summary ?? t('Waiting for approval'); + case 'awaiting_authorization': + return part.summary ?? t('Waiting for you to sign in'); + case 'awaiting_client': + return part.summary ?? t('Waiting for the device'); + case 'failed': + return part.summary ?? t("Didn't complete"); + case 'cancelled': + return part.summary ?? t('Cancelled'); + default: + return part.summary; + } + })(); + + // A status this SDK doesn't know reads as still in progress. + const icon = (() => { + switch (part.status) { + case 'awaiting_approval': + return ; + case 'awaiting_authorization': + return ; + case 'awaiting_client': + return ; + case 'completed': + return ; + case 'failed': + return ; + case 'cancelled': + return ; + default: + return ; + } + })(); + + const finished = isToolCallFinished(part.status); + const waiting = part.status.startsWith('awaiting_'); + + return ( + + {icon} + + + + {title} + + + {detail ? ( + + {detail} + + ) : null} + + {finished && part.durationMs !== undefined ? ( + + {formatCallDuration(part.durationMs / 1000, userLanguage)} + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { alignItems: 'flex-start', flexDirection: 'row', gap: 8 }, + detail: { fontSize: 13, lineHeight: 18 }, + duration: { fontSize: 13, fontVariant: ['tabular-nums'], lineHeight: 20 }, + icon: { marginTop: 2 }, + // The platform spinner, sized to the 16 the icons are drawn at. + spinner: { height: 16, transform: [{ scale: 0.8 }], width: 16 }, + text: { flex: 1, gap: 2, minWidth: 0 }, + title: { fontSize: 15, lineHeight: 20 }, + titleShimmer: { alignSelf: 'flex-start' }, +}); diff --git a/package/src/ai-components/message-parts/index.ts b/package/src/ai-components/message-parts/index.ts index 9ade54059a..c34894c2f6 100644 --- a/package/src/ai-components/message-parts/index.ts +++ b/package/src/ai-components/message-parts/index.ts @@ -1,2 +1,5 @@ export * from './types'; export * from './parseMessageParts'; +export * from './AIMessageParts'; +export * from './AIToolApproval'; +export * from './AIToolCall'; diff --git a/package/src/ai-components/reasoning/AIReasoning.tsx b/package/src/ai-components/reasoning/AIReasoning.tsx new file mode 100644 index 0000000000..9b4bae937a --- /dev/null +++ b/package/src/ai-components/reasoning/AIReasoning.tsx @@ -0,0 +1,376 @@ +import React, { memo, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { + I18nManager, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; + +import { FINISHED_REASONING_SHOWN_MS, useAIThinkingTurn } from './thinkingClock'; + +import type { ScrollViewRef } from '../../types/react-native-compat'; +import { type DeepPartial, StreamTheme, useAITranslation, useTheme } from '../contexts'; +import { formatMinutesAndSeconds, useNow } from '../internal/format'; +import { ChevronIcon, ReasoningIcon } from '../internal/icons/AgentIcons'; +import { Shimmer } from '../internal/Shimmer'; +import { Markdown } from '../markdown'; +import type { AIReasoningPart } from '../message-parts/types'; +import type { Theme } from '../theme'; + +/** + * Splits reasoning at blank lines. Reasoning only grows at its end, so a paragraph's position + * is a stable identity. + */ +export const splitReasoningParagraphs = (text: string) => + text + .split('\n\n') + .map((paragraph) => paragraph.trim()) + .filter(Boolean); + +// A heading reads as a bold line, since a paragraph of thinking has no document to structure. +const headingsAsBold = (paragraph: string) => + paragraph + .split('\n') + .map((line) => { + const heading = /^ *#+\s*(.*)$/.exec(line)?.[1]?.trim(); + return heading ? `**${heading}**` : line; + }) + .join('\n'); + +const ReasoningParagraph = memo(({ text }: { text: string }) => ( + {headingsAsBold(text)} +)); +ReasoningParagraph.displayName = 'ReasoningParagraph'; + +// Reveals text that grows a little at a time, a sixth of the backlog every frame at 30 fps, as +// new thoughts arrive about that often. Text that is replaced, or not animated, shows at once. +const useTextReveal = (text: string, animated: boolean) => { + const [shown, setShown] = useState(text); + const shownRef = useRef(text); + const targetRef = useRef(text); + + useEffect(() => { + const grows = animated && text.startsWith(shownRef.current); + targetRef.current = text; + if (!grows) { + shownRef.current = text; + setShown(text); + return; + } + const timer = setInterval(() => { + const target = targetRef.current; + const current = shownRef.current; + if (current.length >= target.length) { + clearInterval(timer); + return; + } + let end = current.length + Math.max(1, Math.floor((target.length - current.length) / 6)); + // never split a surrogate pair + const code = target.charCodeAt(end - 1); + if (code >= 0xd800 && code <= 0xdbff) end += 1; + shownRef.current = target.slice(0, end); + setShown(shownRef.current); + }, 1000 / 30); + return () => clearInterval(timer); + }, [animated, text]); + + return shown; +}; + +const ReasoningPanel = ({ + isLive, + maxHeight, + text, +}: { + isLive: boolean; + maxHeight: number; + text: string; +}) => { + const shown = useTextReveal(text, isLive); + const scrollerRef = useRef(null); + // Whether the reader is at the end, so new thoughts keep it there. A finished trace opens at + // its start. + const following = useRef(isLive); + const { + theme: { messageParts }, + } = useTheme(); + + const onScroll = ({ nativeEvent }: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = nativeEvent; + following.current = contentOffset.y + layoutMeasurement.height >= contentSize.height - 24; + }; + + return ( + { + if (following.current) scrollerRef.current?.scrollToEnd({ animated: false }); + }} + onScroll={onScroll} + ref={scrollerRef} + scrollEventThrottle={32} + style={[{ maxHeight }, messageParts?.reasoning?.panel]} + > + {splitReasoningParagraphs(shown).map((paragraph, index) => ( + + ))} + + ); +}; + +export type AIReasoningProps = { + /** How long the model has thought, in seconds, shown as "Thought for 12s" once it is done. */ + duration?: number; + /** A note under the open reasoning, such as how long it is kept. */ + footnote?: ReactNode; + /** Whether the reasoning is open once the model is done. Defaults to `false`. */ + initiallyExpanded?: boolean; + /** Whether the model is still thinking. Defaults to whether `part` is streaming. */ + isThinking?: boolean; + /** How tall the reasoning grows, in points, before it scrolls. Defaults to 260. */ + maxExpandedHeight?: number; + /** A reasoning step, whose preview, summary, status and duration it shows. */ + part?: AIReasoningPart; + /** + * The whole of a reasoning step's thinking when the app has it, such as from live updates. + * Read on every render; without it, the step shows its own preview. + */ + reasoningText?: (stepId: string) => string | undefined; + /** Whether the reasoning is open while the model thinks. Defaults to `true`. */ + showsLiveReasoning?: boolean; + /** When the thinking began (`Date.now()`), for a header that counts from before it appeared. */ + since?: number; + /** A one-line summary shown beside the header once the model is done. */ + summary?: string; + /** + * The reasoning so far. Blank lines separate paragraphs, and Markdown is rendered. Defaults to + * `reasoningText(part.id)`, else the step's preview or summary. + */ + text?: string; +}; + +const opensByDefault = ( + isThinking: boolean, + showsLiveReasoning: boolean, + initiallyExpanded: boolean, +) => (isThinking ? showsLiveReasoning : initiallyExpanded); + +/** + * A round of the model's reasoning: a header that counts the seconds while it thinks ("Thinking... + * 7s") and says how long it thought once done ("Thought for 12s"), over the reasoning itself, + * which streams into view while the model thinks and folds away when it is done. Pressing the + * header opens or folds it. The counterpart of `StreamingReasoningView`. + * + * Inside an `AIConversation` that showed the agent thinking, the reply's first reasoning step + * takes over that thinking: its seconds count from when the question was asked. + */ +export const AIReasoning = ({ + duration: durationProp, + footnote, + initiallyExpanded = false, + isThinking: isThinkingProp, + maxExpandedHeight = 260, + part, + reasoningText, + showsLiveReasoning = true, + since, + summary: summaryProp, + text: textProp, +}: AIReasoningProps) => { + const { t, userLanguage } = useAITranslation(); + const { theme } = useTheme(); + const { colors, messageParts } = theme; + const reasoningStyle = messageParts?.reasoning; + const turn = useAIThinkingTurn(); + + const text = + textProp ?? + (part ? (reasoningText?.(part.id) ?? part.preview ?? part.summary) : undefined) ?? + ''; + const isThinking = isThinkingProp ?? part?.status === 'streaming'; + const ownDuration = + durationProp ?? (part?.durationMs !== undefined ? part.durationMs / 1000 : undefined); + const summary = summaryProp ?? part?.summary; + + // The turn whose thinking this step takes over, when it is the first step of a turn that was + // seen thinking. + const thinkingTurn = + part && turn && turn.firstStepId === part.id && turn.clock.start(turn.key) !== undefined + ? turn + : undefined; + const clock = thinkingTurn?.clock; + const turnKey = thinkingTurn?.key; + useEffect(() => { + if (isThinking && clock && turnKey !== undefined) clock.firstStepStreams(turnKey); + }, [clock, isThinking, turnKey]); + + // What the reader chose by pressing the header. Until they do, the reasoning is open while + // the model thinks and folded once it is done. + const [choice, setChoice] = useState(null); + const [open, setOpen] = useState(() => + opensByDefault(isThinking, showsLiveReasoning, initiallyExpanded), + ); + const [thinkingSince, setThinkingSince] = useState(() => since ?? Date.now()); + const [thoughtFor, setThoughtFor] = useState(); + + // A conversation's first step that arrived done, with the answer, shows its reasoning before + // it folds: the model sent none of it while it thought. + const [showsFinished] = useState( + () => + !isThinking && + text !== '' && + !!thinkingTurn && + thinkingTurn.clock.showsFirstStep(thinkingTurn.key), + ); + useEffect(() => { + if (!showsFinished) return; + setOpen(true); + const timer = setTimeout(() => setOpen(false), FINISHED_REASONING_SHOWN_MS); + return () => clearTimeout(timer); + }, [showsFinished]); + + const wasThinking = useRef(isThinking); + useEffect(() => { + if (wasThinking.current === isThinking) return; + wasThinking.current = isThinking; + if (isThinking) { + setThinkingSince(Date.now()); + } else { + if (clock && turnKey !== undefined) clock.firstStepDone(turnKey, ownDuration); + setThoughtFor(Math.max(ownDuration ?? 0, (Date.now() - thinkingSince) / 1000)); + } + setOpen(choice ?? opensByDefault(isThinking, showsLiveReasoning, initiallyExpanded)); + }, [ + choice, + clock, + initiallyExpanded, + isThinking, + ownDuration, + showsLiveReasoning, + thinkingSince, + turnKey, + ]); + + const start = since ?? (thinkingTurn ? thinkingTurn.clock.start(thinkingTurn.key) : undefined); + const now = useNow(isThinking); + const elapsed = Math.max(ownDuration ?? 0, (now - (start ?? thinkingSince)) / 1000); + const measured = + !isThinking && thinkingTurn + ? thinkingTurn.clock.durationOfFirstStep(thinkingTurn.key, ownDuration) + : undefined; + const thought = measured ?? ownDuration ?? thoughtFor; + + const title = isThinking + ? elapsed >= 1 + ? t('Thinking... {{ duration }}', { + duration: formatMinutesAndSeconds(elapsed, userLanguage), + }) + : t('Thinking...') + : thought !== undefined + ? t('Thought for {{ duration }}', { + duration: formatMinutesAndSeconds(Math.max(1, thought), userLanguage), + }) + : t('Thought process'); + + // Live reasoning is open before any of it arrives, but there is nothing to show until then. + const expanded = open && text !== ''; + + // The reasoning reads in the secondary colour, a little smaller than an answer. + const panelStyle = useMemo>( + () => ({ + markdown: { + paragraph: { marginBottom: 4, marginTop: 4 }, + text: { color: colors.grey, fontSize: 14, lineHeight: 20 }, + }, + }), + [colors.grey], + ); + + return ( + + { + setChoice(!expanded); + setOpen(!expanded); + }} + style={[styles.header, reasoningStyle?.header]} + > + + + + {title} + + + {summary && !isThinking && !expanded ? ( + + {summary} + + ) : null} + + + + + {expanded ? ( + + + {footnote && !isThinking && (choice === true || initiallyExpanded) ? footnote : null} + + ) : null} + + ); +}; + +export type AIThinkingProps = { + /** When the thinking began (`Date.now()`). Inside an `AIConversation`, when the question was asked. */ + since?: number; +}; + +/** + * The agent thinking before its reply shows anything: a header that counts the seconds. In an + * `AIConversation`, the reply's first reasoning step takes over its seconds. The counterpart of + * `AIThinkingView`. + */ +export const AIThinking = ({ since }: AIThinkingProps) => { + const turn = useAIThinkingTurn(); + return ( + + ); +}; + +const styles = StyleSheet.create({ + container: { borderStartWidth: 2, gap: 4, paddingStart: 12 }, + header: { + alignItems: 'center', + alignSelf: 'flex-start', + flexDirection: 'row', + gap: 6, + maxWidth: '100%', + }, + lead: { alignItems: 'center', flexDirection: 'row', gap: 6 }, + summary: { flexShrink: 1, fontSize: 14 }, + title: { fontSize: 14, fontVariant: ['tabular-nums'], fontWeight: '500' }, +}); diff --git a/package/src/ai-components/reasoning/index.ts b/package/src/ai-components/reasoning/index.ts new file mode 100644 index 0000000000..221d5d7052 --- /dev/null +++ b/package/src/ai-components/reasoning/index.ts @@ -0,0 +1,2 @@ +export * from './AIReasoning'; +export * from './thinkingClock'; diff --git a/package/src/ai-components/reasoning/thinkingClock.ts b/package/src/ai-components/reasoning/thinkingClock.ts new file mode 100644 index 0000000000..7c40c0fab4 --- /dev/null +++ b/package/src/ai-components/reasoning/thinkingClock.ts @@ -0,0 +1,109 @@ +import { createContext, useContext } from 'react'; + +/** How long a first step that arrived done stays open, showing its reasoning, before it folds. */ +export const FINISHED_REASONING_SHOWN_MS = 1600; +/** How long the reasoning takes to fold. */ +export const REASONING_FOLD_MS = 450; +// How soon after a turn stops thinking its first step has to arrive, done, to open and show its +// reasoning: it came with the reply that ended the thinking. +const ARRIVAL_WINDOW_MS = 1000; + +type Turn = { + start: number; + end?: number; + // How long the first step thought, once it was done, in seconds. + firstStepDuration?: number; + firstStepStreamed: boolean; + // Whether a reply of the turn showed. + hadReplies: boolean; +}; + +/** + * Times each turn's thinking, so the reply's first reasoning step takes over the seconds the + * conversation counted while it showed the agent thinking. The counterpart of `AIThinkingClock`. + * + * It is written only from effects (`update`, `firstStepStreams`, `firstStepDone`) and read + * during render, so a render React throws away changes nothing. + */ +export class AIThinkingClock { + private turns = new Map(); + + // A turn's thinking starts the first time it is the latest turn and thinking, and ends when it + // stops. Thinking that resumes before the first step shows carries on. A turn whose replies were + // taken away starts thinking afresh. + update( + latest: string | undefined, + isThinking: boolean, + hasReplies: boolean, + hasFirstStep: boolean, + now = Date.now(), + ) { + if (latest === undefined) return; + const timed = this.turns.get(latest); + if (!timed || (isThinking && timed.hadReplies && !hasReplies)) { + if (isThinking) { + this.turns.set(latest, { + firstStepStreamed: false, + hadReplies: false, + start: now, + }); + } + return; + } + if (hasReplies) timed.hadReplies = true; + if (isThinking) { + if (!hasFirstStep) timed.end = undefined; + } else if (timed.end === undefined) { + timed.end = now; + } + } + + start(turn: string) { + return this.turns.get(turn)?.start; + } + + firstStepStreams(turn: string) { + const timed = this.turns.get(turn); + if (timed) timed.firstStepStreamed = true; + } + + // The first step stopped streaming: it thought from when the turn started thinking until now, + // or for its own time when that is longer. + firstStepDone(turn: string, own: number | undefined, now = Date.now()) { + const timed = this.turns.get(turn); + if (timed && timed.firstStepDuration === undefined) { + timed.firstStepDuration = Math.max(own ?? 0, (now - timed.start) / 1000); + } + } + + // How long a turn's first step thought, in seconds. One that arrived done thought until the + // turn stopped thinking, which with no end yet is the render it arrives in; one that streamed, + // until `firstStepDone`. + durationOfFirstStep(turn: string, own: number | undefined, now = Date.now()) { + const timed = this.turns.get(turn); + if (!timed) return own; + if (timed.firstStepDuration !== undefined) return timed.firstStepDuration; + if (timed.firstStepStreamed) return own; + return Math.max(own ?? 0, ((timed.end ?? now) - timed.start) / 1000); + } + + // Whether a turn's first step, which arrived done with the reply that ends the thinking, opens + // to show its reasoning before it folds and the answer starts. The thinking's end is noted + // after the render the step arrives in, so no end yet counts as ending now. + showsFirstStep(turn: string, now = Date.now()) { + const timed = this.turns.get(turn); + if (!timed || timed.firstStepStreamed) return false; + return timed.end === undefined || now - timed.end < ARRIVAL_WINDOW_MS; + } +} + +/** A turn's clock, and the id of its first reasoning step: the one that takes over its thinking. */ +export type AIThinkingTurn = { + clock: AIThinkingClock; + key: string; + firstStepId?: string; +}; + +export const AIThinkingTurnContext = createContext(undefined); + +export const useAIThinkingTurn = () => useContext(AIThinkingTurnContext); diff --git a/package/src/ai-components/theme.ts b/package/src/ai-components/theme.ts index c2047a2d7b..74dc069346 100644 --- a/package/src/ai-components/theme.ts +++ b/package/src/ai-components/theme.ts @@ -69,6 +69,43 @@ export type Theme = { */ backdropColor?: string; }; + /** + * The steps of an agent's reply: reasoning, tool calls and their questions. Optional, so a + * hand-built `Theme` without it keeps type-checking. + */ + messageParts?: { + /** The colour of a completed tool call's check. */ + successColor?: string; + container?: ViewStyle; + reasoning?: { + container?: ViewStyle; + rule?: ViewStyle; + header?: ViewStyle; + title?: TextStyle; + summary?: TextStyle; + panel?: ViewStyle; + }; + toolCall?: { + container?: ViewStyle; + title?: TextStyle; + detail?: TextStyle; + duration?: TextStyle; + }; + approvalCard?: { + container?: ViewStyle; + title?: TextStyle; + message?: TextStyle; + allowButton?: ViewStyle; + allowButtonText?: TextStyle; + declineButton?: ViewStyle; + declineButtonText?: TextStyle; + failure?: TextStyle; + }; + unsupported?: { + container?: ViewStyle; + text?: TextStyle; + }; + }; }; export const defaultTheme: Theme = { @@ -151,4 +188,21 @@ export const defaultTheme: Theme = { voiceVisualization: { colors: [colors.accent_blue], }, + messageParts: { + successColor: '#20E070', + container: {}, + reasoning: { container: {}, rule: {}, header: {}, title: {}, summary: {}, panel: {} }, + toolCall: { container: {}, title: {}, detail: {}, duration: {} }, + approvalCard: { + container: {}, + title: {}, + message: {}, + allowButton: {}, + allowButtonText: {}, + declineButton: {}, + declineButtonText: {}, + failure: {}, + }, + unsupported: { container: {}, text: {} }, + }, }; diff --git a/package/src/ai-components/tool-calls/AIToolApprover.tsx b/package/src/ai-components/tool-calls/AIToolApprover.tsx new file mode 100644 index 0000000000..73709e81e2 --- /dev/null +++ b/package/src/ai-components/tool-calls/AIToolApprover.tsx @@ -0,0 +1,58 @@ +import React, { createContext, type PropsWithChildren, useContext } from 'react'; + +import type { AIToolCallPart } from '../message-parts/types'; + +/** + * How the person signs in on this device when a tool call waits for their account at a + * provider, and how their "not now" reaches your backend, which holds the call until one or + * the other. + */ +export type AIToolAuthorizer = { + /** + * Opens the call's sign-in. Resolves `true` once the account is connected: your backend then + * updates the call's step, and until it does the sign-in reads as connecting. Resolves `false` + * when the person closed the sign-in or it ended without connecting, so they can try again. + * Rejects when the sign-in could not be opened. + */ + connect: (call: AIToolCallPart) => Promise; + /** + * Tells your backend the person won't sign in now. Your backend ends the call, which never + * runs. A rejection lets the person answer again. + */ + decline: (call: AIToolCallPart) => Promise; +}; + +/** + * The person answering tool calls' questions on this device, and how their answer reaches + * your backend, which holds each call until it gets one. + */ +export type AIToolApprover = { + /** This install, matched against a client tool call's `target_client_id`. */ + clientId: string; + /** + * Sends the answer. Your backend checks it is this person's (and this install's, for a + * client tool), then updates the call's step; until then the question stays, with its + * buttons disabled. A rejection lets the person answer again. + */ + decide: (call: AIToolCallPart, allowed: boolean) => Promise; + /** The person signed in, matched against a call's `target_user_id`. */ + userId: string; + /** How this person signs in when a call waits for their account. Without one, no sign-in is offered here. */ + authorizer?: AIToolAuthorizer; +}; + +const AIToolApproverContext = createContext(undefined); + +/** Provides the approver to the AI components below it, for those not given one as a prop. */ +export const AIToolApproverProvider = ({ + approver, + children, +}: PropsWithChildren<{ approver?: AIToolApprover }>) => ( + {children} +); + +/** The approver given as a prop, else the one of the nearest `AIToolApproverProvider`. */ +export const useAIToolApprover = (approver?: AIToolApprover) => { + const fromContext = useContext(AIToolApproverContext); + return approver ?? fromContext; +}; diff --git a/package/src/ai-components/tool-calls/index.ts b/package/src/ai-components/tool-calls/index.ts new file mode 100644 index 0000000000..ad73d0bd40 --- /dev/null +++ b/package/src/ai-components/tool-calls/index.ts @@ -0,0 +1 @@ +export * from './AIToolApprover'; diff --git a/package/src/i18n/ar.json b/package/src/i18n/ar.json index 9545265f23..59c40b4648 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "رد واحد", "1 Thread Reply": "رد واحد في الموضوع", + "A step this app version can't show": "خطوة لا يمكن لهذا الإصدار من التطبيق عرضها", "Add a comment": "أضف تعليقاً", "Add a comment to the poll": "أضف تعليقًا على التصويت", "Add an option": "أضف خياراً", "Add more": "أضف المزيد", + "Allow": "السماح", "Allow access to your Gallery": "اسمح بالوصول إلى المعرض", "Allow camera access in device settings": "اسمح بالوصول إلى الكاميرا في إعدادات الجهاز", "Also send to channel": "أرسل أيضاً إلى القناة", @@ -20,16 +22,20 @@ "Ban User": "منع المستخدم", "Block User": "حظر المستخدم", "Cancel": "إلغاء", + "Cancelled": "تم الإلغاء", "Cannot Flag Message": "تعذر الإبلاغ عن الرسالة", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "فكّر في تأثير تعليقك على الآخرين وتأكد من اتباع إرشادات مجتمعنا", "Copy Message": "نسخ الرسالة", "Create Poll": "إنشاء تصويت", + "Declined": "تم الرفض", "Delete": "حذف", "Delete Message": "حذف الرسالة", "Delete for me": "حذف لدي", "Device camera is used to take photos or videos.": "تُستخدم كاميرا الجهاز لالتقاط الصور أو الفيديو.", "Device gallery permissions is used to take photos or videos.": "تُستخدم أذونات معرض الجهاز لالتقاط الصور أو الفيديو.", + "Didn't complete": "لم يكتمل", "Do you want to send a copy of this message to a moderator for further investigation?": "هل تريد إرسال نسخة من هذه الرسالة إلى مشرف لمزيد من المراجعة؟", + "Don't Allow": "عدم السماح", "Due since {{ dueSince }}": "مستحق منذ {{ dueSince }}", "Edit Message": "تعديل الرسالة", "Edited": "تم التعديل", @@ -136,7 +142,10 @@ "The message has been reported to a moderator.": "تم الإبلاغ عن الرسالة إلى مشرف.", "The source message was deleted": "تم حذف الرسالة الأصلية", "Thinking...": "جارٍ التفكير...", + "Thinking... {{ duration }}": "جارٍ التفكير... {{ duration }}", "This reply was deleted": "تم حذف هذا الرد", + "Thought for {{ duration }}": "فكّر لمدة {{ duration }}", + "Thought process": "عملية التفكير", "Thread Reply": "رد في الموضوع", "Type a number from 2 to 10": "اكتب رقماً من 2 إلى 10", "Unban User": "إلغاء منع المستخدم", @@ -154,6 +163,10 @@ "Voice message": "رسالة صوتية", "Voice message ({{duration}})": "رسالة صوتية ({{duration}})", "Voice message deleted": "تم حذف الرسالة الصوتية", + "Waiting for approval": "في انتظار الموافقة", + "Waiting for the device": "في انتظار الجهاز", + "Waiting for you to sign in": "في انتظار تسجيل دخولك", + "Your answer couldn't be sent. Try again.": "تعذّر إرسال إجابتك. حاول مرة أخرى.", "Your comment": "تعليقك", "You": "أنت", "You can't send messages in this channel": "لا يمكنك إرسال رسائل في هذه القناة", diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index 09bf73e0cf..5dc68dfb62 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "1 Reply", "1 Thread Reply": "1 Thread Reply", + "A step this app version can't show": "A step this app version can't show", "Add a comment": "Add a comment", "Add a comment to the poll": "Add a comment to the poll", "Add an option": "Add an option", "Add more": "Add more", + "Allow": "Allow", "Allow access to your Gallery": "Allow access to your Gallery", "Allow camera access in device settings": "Allow camera access in device settings", "Also send to channel": "Also send to channel", @@ -20,16 +22,20 @@ "Ban User": "Ban User", "Block User": "Block User", "Cancel": "Cancel", + "Cancelled": "Cancelled", "Cannot Flag Message": "Cannot Flag Message", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "Consider how your comment might make others feel and be sure to follow our Community Guidelines", "Copy Message": "Copy Message", "Create Poll": "Create Poll", + "Declined": "Declined", "Delete": "Delete", "Delete Message": "Delete Message", "Delete for me": "Delete for me", "Device camera is used to take photos or videos.": "Device camera is used to take photos or videos.", "Device gallery permissions is used to take photos or videos.": "Device gallery permissions is used to take photos or videos.", + "Didn't complete": "Didn't complete", "Do you want to send a copy of this message to a moderator for further investigation?": "Do you want to send a copy of this message to a moderator for further investigation?", + "Don't Allow": "Don't Allow", "Due since {{ dueSince }}": "Due since {{ dueSince }}", "Edit Message": "Edit Message", "Edited": "Edited", @@ -92,6 +98,13 @@ "Remove User": "Remove User", "Remove": "Remove", "Are you sure you want to remove this member from the channel?": "Are you sure you want to remove this member from the channel?", + "Thinking... {{ duration }}": "Thinking... {{ duration }}", + "Thought for {{ duration }}": "Thought for {{ duration }}", + "Thought process": "Thought process", + "Waiting for approval": "Waiting for approval", + "Waiting for the device": "Waiting for the device", + "Waiting for you to sign in": "Waiting for you to sign in", + "Your answer couldn't be sent. Try again.": "Your answer couldn't be sent. Try again.", "{{count}} members removed_one": "{{count}} member removed", "{{count}} members removed_other": "{{count}} members removed", "{{count}} members removed_many": "{{count}} members removed", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index ced6a3f81c..f89cc4aebe 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "1 respuesta", "1 Thread Reply": "1 respuesta de hilo", + "A step this app version can't show": "Un paso que esta versión de la app no puede mostrar", "Add a comment": "Agregar un comentario", "Add a comment to the poll": "Añadir un comentario a la encuesta", "Add an option": "Agregar una opción", "Add more": "Añadir más", + "Allow": "Permitir", "Allow access to your Gallery": "Permitir acceso a tu galería", "Allow camera access in device settings": "Permitir el acceso a la cámara en la configuración del dispositivo", "Also send to channel": "También enviar al canal", @@ -20,16 +22,20 @@ "Ban User": "Bloquear Usuario", "Block User": "Bloquear usuario", "Cancel": "Cancelar", + "Cancelled": "Cancelado", "Cannot Flag Message": "No se puede reportar el mensaje", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "Considera cómo tu comentario podría hacer sentir a los demás y asegúrate de seguir nuestras Normas de la Comunidad", "Copy Message": "Copiar mensaje", "Create Poll": "Crear encuesta", + "Declined": "Rechazado", "Delete": "Eliminar", "Delete Message": "Eliminar mensaje", "Delete for me": "Eliminar para mí", "Device camera is used to take photos or videos.": "La cámara del dispositivo se utiliza para tomar fotografías o vídeos.", "Device gallery permissions is used to take photos or videos.": "Los permisos de la galería del dispositivo se utilizan para tomar fotos o videos.", + "Didn't complete": "No se completó", "Do you want to send a copy of this message to a moderator for further investigation?": "¿Deseas enviar una copia de este mensaje a un moderador para una investigación adicional?", + "Don't Allow": "No permitir", "Due since {{ dueSince }}": "Debido desde {{ dueSince }}", "Edit Message": "Editar mensaje", "Edited": "Editado", @@ -136,7 +142,10 @@ "The message has been reported to a moderator.": "El mensaje ha sido reportado a un moderador.", "The source message was deleted": "El mensaje original fue eliminado", "Thinking...": "Pensando...", + "Thinking... {{ duration }}": "Pensando... {{ duration }}", "This reply was deleted": "Esta respuesta fue eliminada", + "Thought for {{ duration }}": "Pensó durante {{ duration }}", + "Thought process": "Proceso de razonamiento", "Thread Reply": "Respuesta de hilo", "Type a number from 2 to 10": "Escribe un número de 2 a 10", "Unban User": "Desbloquear usuario", @@ -154,6 +163,10 @@ "Voice message": "Mensaje de voz", "Voice message ({{duration}})": "Mensaje de voz ({{duration}})", "Voice message deleted": "Mensaje de voz eliminado", + "Waiting for approval": "Esperando aprobación", + "Waiting for the device": "Esperando al dispositivo", + "Waiting for you to sign in": "Esperando a que inicies sesión", + "Your answer couldn't be sent. Try again.": "No se pudo enviar tu respuesta. Inténtalo de nuevo.", "Your comment": "Tu comentario", "You": "Tú", "You can't send messages in this channel": "No puedes enviar mensajes en este canal", diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index 6b36a1f43f..8fcf7db8f6 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "1 Réponse", "1 Thread Reply": "Réponse à 1 fil", + "A step this app version can't show": "Une étape que cette version de l'app ne peut pas afficher", "Add a comment": "Ajouter un commentaire", "Add a comment to the poll": "Ajouter un commentaire au sondage", "Add an option": "Ajouter une option", "Add more": "Ajouter plus", + "Allow": "Autoriser", "Allow access to your Gallery": "Autoriser l'accès à votre galerie", "Allow camera access in device settings": "Autoriser l'accès à la caméra dans les paramètres de l'appareil", "Also send to channel": "Envoyer également à la chaîne", @@ -20,16 +22,20 @@ "Ban User": "Bannir Utilisateur", "Block User": "Bloquer un utilisateur", "Cancel": "Annuler", + "Cancelled": "Annulé", "Cannot Flag Message": "Impossible de signaler le message", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "Considérez comment votre commentaire pourrait faire sentir les autres et assurez-vous de suivre nos directives communautaires", "Copy Message": "Copier le message", "Create Poll": "Créer un sondage", + "Declined": "Refusé", "Delete": "Supprimer", "Delete Message": "Supprimer un message", "Delete for me": "Supprimer pour moi", "Device camera is used to take photos or videos.": "L'appareil photo de l'appareil est utilisé pour prendre des photos ou des vidéos.", "Device gallery permissions is used to take photos or videos.": "Les autorisations de la galerie de l'appareil sont utilisées pour prendre des photos ou des vidéos.", + "Didn't complete": "Non terminé", "Do you want to send a copy of this message to a moderator for further investigation?": "Voulez-vous envoyer une copie de ce message à un modérateur pour une enquête plus approfondie?", + "Don't Allow": "Ne pas autoriser", "Due since {{ dueSince }}": "Échéance depuis {{ dueSince }}", "Edit Message": "Éditer un message", "Edited": "Édité", @@ -136,7 +142,10 @@ "The message has been reported to a moderator.": "Le message a été signalé à un modérateur.", "The source message was deleted": "Le message source a été supprimé", "Thinking...": "Réflexion...", + "Thinking... {{ duration }}": "Réflexion... {{ duration }}", "This reply was deleted": "Cette réponse a été supprimée", + "Thought for {{ duration }}": "A réfléchi pendant {{ duration }}", + "Thought process": "Raisonnement", "Thread Reply": "Réponse à la discussion", "Type a number from 2 to 10": "Entrez un nombre de 2 à 10", "Unban User": "Débannir Utilisateur", @@ -154,6 +163,10 @@ "Voice message": "Message vocal", "Voice message ({{duration}})": "Message vocal ({{duration}})", "Voice message deleted": "Message vocal supprimé", + "Waiting for approval": "En attente d'approbation", + "Waiting for the device": "En attente de l'appareil", + "Waiting for you to sign in": "En attente de votre connexion", + "Your answer couldn't be sent. Try again.": "Votre réponse n'a pas pu être envoyée. Réessayez.", "Your comment": "Votre commentaire", "You": "Toi", "You can't send messages in this channel": "You can't send messages in this channel", diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index 5d903d4411..c2590af3ec 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "תגובה אחת", "1 Thread Reply": "תגובה אחת לשרשור", + "A step this app version can't show": "שלב שגרסת האפליקציה הזו לא יכולה להציג", "Add a comment": "הוסף תגובה", "Add a comment to the poll": "הוסף תגובה לסקר", "Add an option": "הוסף אפשרות", "Add more": "הוסף עוד", + "Allow": "אפשר", "Allow access to your Gallery": "אפשר גישה לגלריה שלך", "Allow camera access in device settings": "אפשר גישה למצלמה בהגדרות המכשיר", "Also send to channel": "שלח/י הודעה לשיחה", @@ -20,16 +22,20 @@ "Ban User": "לחסום משתמש", "Block User": "חסום משתמש", "Cancel": "ביטול", + "Cancelled": "בוטל", "Cannot Flag Message": "סימון הודעה לא אפשרי", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "שקול איך התגובה שלך עשויה להשפיע על אחרים ווודא שאתה עוקב אחר ההנחיות של הקהילה שלנו", "Copy Message": "העתק/י הודעה", "Create Poll": "צור סקר", + "Declined": "נדחה", "Delete": "מחק", "Delete Message": "מחק/י הודעה", "Delete for me": "מחק עבורי", "Device camera is used to take photos or videos.": "מצלמת המכשיר משמשת לצילום תמונות או סרטונים.", "Device gallery permissions is used to take photos or videos.": "הרשאות גלריית המכשיר משמשות לצילום תמונות או סרטונים.", + "Didn't complete": "לא הושלם", "Do you want to send a copy of this message to a moderator for further investigation?": "האם את/ה רוצה לשלוח עותק של הודעה זו למנחה להמשך חקירה?", + "Don't Allow": "אל תאפשר", "Due since {{ dueSince }}": "מועד אחרון מאז {{ dueSince }}", "Edit Message": "ערוך הודעה", "Edited": "נערך", @@ -136,7 +142,10 @@ "The message has been reported to a moderator.": "ההודעה דווחה למנהל", "The source message was deleted": "ההודעה המקורית נמחקה", "Thinking...": "חושב...", + "Thinking... {{ duration }}": "חושב... {{ duration }}", "This reply was deleted": "התגובה הזו נמחקה", + "Thought for {{ duration }}": "חשב במשך {{ duration }}", + "Thought process": "תהליך החשיבה", "Thread Reply": "הגב/י בשרשור", "Type a number from 2 to 10": "הקלד מספר בין 2 ל-10", "Unban User": "לבטל חסימת משתמש", @@ -154,6 +163,10 @@ "Voice message": "הודעת קול", "Voice message ({{duration}})": "הודעת קול ({{duration}})", "Voice message deleted": "הודעת הקול נמחקה", + "Waiting for approval": "ממתין לאישור", + "Waiting for the device": "ממתין למכשיר", + "Waiting for you to sign in": "ממתין להתחברות שלך", + "Your answer couldn't be sent. Try again.": "לא ניתן היה לשלוח את התשובה שלך. נסה שוב.", "Your comment": "התגובה שלך", "You": "את/ה", "You can't send messages in this channel": "את/ב לא יכול/ה לשלוח הודעות בשיחה זו", diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index 5097400d77..422aed325c 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "1 रिप्लाई", "1 Thread Reply": "1 धागा उत्तर", + "A step this app version can't show": "एक चरण जिसे यह ऐप संस्करण नहीं दिखा सकता", "Add a comment": "एक टिप्पणी जोड़ें", "Add a comment to the poll": "पोल में टिप्पणी जोड़ें", "Add an option": "एक विकल्प जोड़ें", "Add more": "और जोड़ें", + "Allow": "अनुमति दें", "Allow access to your Gallery": "अपनी गैलरी तक पहुँचने की अनुमति दें", "Allow camera access in device settings": "डिवाइस सेटिंग्स में कैमरा एक्सेस की अनुमति दें", "Also send to channel": "चैनल को भी भेजें", @@ -20,16 +22,20 @@ "Ban User": "उपयोगकर्ता को प्रतिबंधित करें", "Block User": "उपयोगकर्ता को रोक देना, ब्लॉक यूजर", "Cancel": "रद्द करें", + "Cancelled": "रद्द किया गया", "Cannot Flag Message": "मैसेज फ्लैग नहीं किया जा सकता है", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "ध्यान दें कि आपका संदेश दूसरों को कैसा लगा सकता है और सुनिश्चित हों कि आप हमारी सामुदायिक अनुशासन का पालन कर रहे हैं", "Copy Message": "संदेश की प्रतिलिपि बनाएँ", "Create Poll": "सर्वेक्षण बनाएं", + "Declined": "अस्वीकार किया गया", "Delete": "हटाएं", "Delete Message": "मैसेज को डिलीट करे", "Delete for me": "मुझे हटाएं", "Device camera is used to take photos or videos.": "डिवाइस कैमरे का उपयोग फ़ोटो या वीडियो लेने के लिए किया जाता है।", "Device gallery permissions is used to take photos or videos.": "डिवाइस गैलरी की अनुमतियों का उपयोग फोटो या वीडियो लेने के लिए किया जाता है।", + "Didn't complete": "पूरा नहीं हुआ", "Do you want to send a copy of this message to a moderator for further investigation?": "क्या आप इस संदेश की एक प्रति आगे की जाँच के लिए किसी मॉडरेटर को भेजना चाहते हैं?", + "Don't Allow": "अनुमति न दें", "Due since {{ dueSince }}": "{{ dueSince }} से देय है", "Edit Message": "मैसेज में बदलाव करे", "Edited": "मैसेज बदला गया है", @@ -136,7 +142,10 @@ "The message has been reported to a moderator.": "संदेश एक मॉडरेटर को सूचित किया गया है।", "The source message was deleted": "स्रोत संदेश हटा दिया गया है", "Thinking...": "सोच रहा है...", + "Thinking... {{ duration }}": "सोच रहा है... {{ duration }}", "This reply was deleted": "यह उत्तर हटा दिया गया है", + "Thought for {{ duration }}": "{{ duration }} तक सोचा", + "Thought process": "सोचने की प्रक्रिया", "Thread Reply": "धागा जवाब", "Type a number from 2 to 10": "2 से 10 के बीच एक संख्या दर्ज करें", "Unban User": "उपयोगकर्ता को अनब्लॉक करें", @@ -154,6 +163,10 @@ "Voice message": "वॉइस संदेश", "Voice message ({{duration}})": "वॉइस संदेश ({{duration}})", "Voice message deleted": "वॉइस संदेश हटा दिया गया", + "Waiting for approval": "स्वीकृति की प्रतीक्षा", + "Waiting for the device": "डिवाइस की प्रतीक्षा", + "Waiting for you to sign in": "आपके साइन-इन की प्रतीक्षा", + "Your answer couldn't be sent. Try again.": "आपका जवाब नहीं भेजा जा सका। फिर से कोशिश करें।", "Your comment": "आपकी टिप्पणी", "You": "आप", "You can't send messages in this channel": "आप इस चैनल में संदेश नहीं भेज सकते", diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index 48ac2c4bcb..b9712659ee 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "1 Risposta", "1 Thread Reply": "1 Risposta alla Discussione", + "A step this app version can't show": "Un passaggio che questa versione dell'app non può mostrare", "Add a comment": "Aggiungi un commento", "Add a comment to the poll": "Aggiungi un commento al sondaggio", "Add an option": "Aggiungi un'opzione", "Add more": "Aggiungi altri", + "Allow": "Consenti", "Allow access to your Gallery": "Consenti l'accesso alla tua galleria", "Allow camera access in device settings": "Consenti l'accesso alla fotocamera nelle impostazioni del dispositivo", "Also send to channel": "Invia anche al canale", @@ -20,16 +22,20 @@ "Ban User": "Blocca Utente", "Block User": "Blocca Utente", "Cancel": "Annulla", + "Cancelled": "Annullato", "Cannot Flag Message": "Impossibile Segnalare Messaggio", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "Considera come il tuo commento potrebbe far sentire gli altri e assicurati di seguire le nostre Linee guida della community", "Copy Message": "Copia Messaggio", "Create Poll": "Crea sondaggio", + "Declined": "Rifiutato", "Delete": "Elimina", "Delete Message": "Cancella il Messaggio", "Delete for me": "Elimina per me", "Device camera is used to take photos or videos.": "La fotocamera del dispositivo viene utilizzata per scattare foto o video.", "Device gallery permissions is used to take photos or videos.": "Le autorizzazioni della galleria del dispositivo vengono utilizzate per scattare foto o video.", + "Didn't complete": "Non completato", "Do you want to send a copy of this message to a moderator for further investigation?": "Vuoi inviare una copia di questo messaggio a un moderatore per ulteriori indagini?", + "Don't Allow": "Non consentire", "Due since {{ dueSince }}": "Scadenza dal {{ dueSince }}", "Edit Message": "Modifica Messaggio", "Edited": "Modificato", @@ -136,7 +142,10 @@ "The message has been reported to a moderator.": "Il messaggio è stato segnalato a un moderatore.", "The source message was deleted": "Il messaggio originale è stato eliminato", "Thinking...": "Pensando...", + "Thinking... {{ duration }}": "Pensando... {{ duration }}", "This reply was deleted": "Questa risposta è stata eliminata", + "Thought for {{ duration }}": "Ha pensato per {{ duration }}", + "Thought process": "Ragionamento", "Thread Reply": "Rispondi alla Discussione", "Type a number from 2 to 10": "Digita un numero da 2 a 10", "Unban User": "Sblocca Utente", @@ -154,6 +163,10 @@ "Voice message": "Messaggio vocale", "Voice message ({{duration}})": "Messaggio vocale ({{duration}})", "Voice message deleted": "Messaggio vocale eliminato", + "Waiting for approval": "In attesa di approvazione", + "Waiting for the device": "In attesa del dispositivo", + "Waiting for you to sign in": "In attesa del tuo accesso", + "Your answer couldn't be sent. Try again.": "Impossibile inviare la tua risposta. Riprova.", "Your comment": "Il tuo commento", "You": "Tu", "You can't send messages in this channel": "Non puoi inviare messaggi in questo canale", diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index 1269687ee2..c880eb5859 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "1件の返信", "1 Thread Reply": "1件のスレッド返信", + "A step this app version can't show": "このアプリのバージョンでは表示できないステップ", "Add a comment": "コメントを追加", "Add a comment to the poll": "投票にコメントを追加", "Add an option": "オプションを追加", "Add more": "さらに追加", + "Allow": "許可", "Allow access to your Gallery": "ギャラリーへのアクセスを許可する", "Allow camera access in device settings": "デバイス設定でカメラへのアクセスを許可する", "Also send to channel": "チャンネルにも送信", @@ -20,16 +22,20 @@ "Ban User": "ユーザーを禁止する", "Block User": "ユーザをブロックする", "Cancel": "キャンセル", + "Cancelled": "キャンセルされました", "Cannot Flag Message": "メッセージをフラグできません", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "あなたのコメントが他の人にどのように影響するか考え、必ずコミュニティガイドラインに従ってください", "Copy Message": "メッセージのコピー", "Create Poll": "アンケートを作成", + "Declined": "拒否されました", "Delete": "消去", "Delete Message": "メッセージを削除", "Delete for me": "自分で削除", "Device camera is used to take photos or videos.": "デバイスのカメラは写真やビデオの撮影に使用されます。", "Device gallery permissions is used to take photos or videos.": "デバイスギャラリーの権限は写真やビデオを撮るために使用されます。", + "Didn't complete": "完了しませんでした", "Do you want to send a copy of this message to a moderator for further investigation?": "このメッセージのコピーをモデレーターに送信して、さらに調査しますか?", + "Don't Allow": "許可しない", "Due since {{ dueSince }}": "期限は {{ dueSince }} からです", "Edit Message": "メッセージを編集", "Edited": "編集済み", @@ -136,7 +142,10 @@ "The message has been reported to a moderator.": "メッセージはモデレーターに報告されました。", "The source message was deleted": "元のメッセージが削除されました", "Thinking...": "考え中...", + "Thinking... {{ duration }}": "考え中... {{ duration }}", "This reply was deleted": "この返信は削除されました", + "Thought for {{ duration }}": "{{ duration }} 考えました", + "Thought process": "思考プロセス", "Thread Reply": "スレッドの返信", "Type a number from 2 to 10": "2から10の数字を入力してください", "Unban User": "ユーザーの禁止を解除する", @@ -154,6 +163,10 @@ "Voice message": "ボイスメッセージ", "Voice message ({{duration}})": "ボイスメッセージ({{duration}})", "Voice message deleted": "ボイスメッセージを削除しました", + "Waiting for approval": "承認を待っています", + "Waiting for the device": "デバイスを待っています", + "Waiting for you to sign in": "サインインを待っています", + "Your answer couldn't be sent. Try again.": "回答を送信できませんでした。もう一度お試しください。", "Your comment": "あなたのコメント", "You": "あなた", "You can't send messages in this channel": "このチャンネルではメッセージを送信できません", diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index 8affa8cace..d58bf24e9e 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "답장 1개", "1 Thread Reply": "1개의 스레드 답글", + "A step this app version can't show": "이 앱 버전에서 표시할 수 없는 단계", "Add a comment": "댓글 추가", "Add a comment to the poll": "투표에 의견 추가", "Add an option": "옵션 추가", "Add more": "더 추가", + "Allow": "허용", "Allow access to your Gallery": "갤러리에 대한 액세스를 허용", "Allow camera access in device settings": "기기 설정에서 카메라 액세스를 허용하세요.", "Also send to channel": "채널에도 전송", @@ -20,16 +22,20 @@ "Ban User": "사용자 차단", "Block User": "사용자 차단", "Cancel": "취소", + "Cancelled": "취소됨", "Cannot Flag Message": "메세지를 플래그 할 수 없습니다", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "당신의 댓글이 다른 사람들에게 어떤 영향을 줄지 고려하고 반드시 우리의 커뮤니티 가이드라인을 따르십시오", "Copy Message": "메시지 복사", "Create Poll": "투표 생성", + "Declined": "거절됨", "Delete": "삭제", "Delete Message": "메시지 삭제", "Delete for me": "나 삭제", "Device camera is used to take photos or videos.": "기기 카메라는 사진이나 동영상을 촬영하는 데 사용됩니다.", "Device gallery permissions is used to take photos or videos.": "장치 갤러리 권한은 사진 또는 비디오를 촬영하는 데 사용됩니다.", + "Didn't complete": "완료되지 않음", "Do you want to send a copy of this message to a moderator for further investigation?": "이 메시지의 복사본을 운영자에게 보내 추가 조사를합니까?", + "Don't Allow": "허용 안 함", "Due since {{ dueSince }}": "기한은 {{ dueSince }}부터입니다.", "Edit Message": "메시지 수정", "Edited": "편집됨", @@ -136,7 +142,10 @@ "The message has been reported to a moderator.": "메시지는 운영자에보고되었습니다.", "The source message was deleted": "원본 메시지가 삭제되었습니다", "Thinking...": "생각 중...", + "Thinking... {{ duration }}": "생각 중... {{ duration }}", "This reply was deleted": "이 답글은 삭제되었습니다", + "Thought for {{ duration }}": "{{ duration }} 동안 생각함", + "Thought process": "생각 과정", "Thread Reply": "스레드 답장", "Type a number from 2 to 10": "2에서 10 사이의 숫자를 입력하세요", "Unban User": "사용자 차단 해제", @@ -154,6 +163,10 @@ "Voice message": "음성 메시지", "Voice message ({{duration}})": "음성 메시지 ({{duration}})", "Voice message deleted": "음성 메시지가 삭제되었습니다", + "Waiting for approval": "승인 대기 중", + "Waiting for the device": "기기 대기 중", + "Waiting for you to sign in": "로그인 대기 중", + "Your answer couldn't be sent. Try again.": "답변을 보낼 수 없습니다. 다시 시도하세요.", "Your comment": "댓글 입력", "You": "당신", "You can't send messages in this channel": "이 채널에서는 메세지를 전송할 수 없습니다", diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index ed1b2b01ce..1a73e19925 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "1 Antwoord", "1 Thread Reply": "1 thread antwoord", + "A step this app version can't show": "Een stap die deze app-versie niet kan tonen", "Add a comment": "Voeg een reactie toe", "Add a comment to the poll": "Voeg een reactie toe aan de poll", "Add an option": "Voeg een optie toe", "Add more": "Meer toevoegen", + "Allow": "Toestaan", "Allow access to your Gallery": "Geef toegang tot uw galerij", "Allow camera access in device settings": "Sta cameratoegang toe in de apparaatinstellingen", "Also send to channel": "Stuur ook naar kanaal", @@ -20,16 +22,20 @@ "Ban User": "Gebruiker Verbannen", "Block User": "Blokkeer Gebruiker", "Cancel": "Annuleer", + "Cancelled": "Geannuleerd", "Cannot Flag Message": "Kan bericht niet rapporteren", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "Denk na over hoe jouw opmerking anderen zou kunnen laten voelen en zorg ervoor dat je onze Community-richtlijnen volgt", "Copy Message": "Bericht kopiëren", "Create Poll": "Peiling aanmaken", + "Declined": "Geweigerd", "Delete": "Verwijderen", "Delete Message": "Verwijder bericht", "Delete for me": "Verwijder voor mij", "Device camera is used to take photos or videos.": "De camera van het apparaat wordt gebruikt om foto's of video's te maken.", "Device gallery permissions is used to take photos or videos.": "Apparaatgallerijmachtigingen worden gebruikt om foto’s of video’s te maken.", + "Didn't complete": "Niet voltooid", "Do you want to send a copy of this message to a moderator for further investigation?": "Wil je een kopie van dit bericht naar een moderator sturen voor verder onderzoek?", + "Don't Allow": "Niet toestaan", "Due since {{ dueSince }}": "Vervaldatum sinds {{ dueSince }}", "Edit Message": "Pas bericht aan", "Edited": "Bewerkt", @@ -136,7 +142,10 @@ "The message has been reported to a moderator.": "Het bericht is gerapporteerd aan een moderator.", "The source message was deleted": "Het oorspronkelijke bericht is verwijderd", "Thinking...": "Aan het denken...", + "Thinking... {{ duration }}": "Aan het denken... {{ duration }}", "This reply was deleted": "Deze reactie is verwijderd", + "Thought for {{ duration }}": "{{ duration }} nagedacht", + "Thought process": "Denkproces", "Thread Reply": "Discussie beantwoorden", "Type a number from 2 to 10": "Typ een getal van 2 tot 10", "Unban User": "Gebruiker Deblokeren", @@ -154,6 +163,10 @@ "Voice message": "Spraakbericht", "Voice message ({{duration}})": "Spraakbericht ({{duration}})", "Voice message deleted": "Spraakbericht verwijderd", + "Waiting for approval": "Wacht op goedkeuring", + "Waiting for the device": "Wacht op het apparaat", + "Waiting for you to sign in": "Wacht tot je je aanmeldt", + "Your answer couldn't be sent. Try again.": "Je antwoord kon niet worden verzonden. Probeer het opnieuw.", "Your comment": "Jouw reactie", "You": "U", "You can't send messages in this channel": "Je kan geen berichten sturen in dit kanaal", diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index 8ecec16566..d14c416151 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "1 Resposta", "1 Thread Reply": "1 Resposta de Thread", + "A step this app version can't show": "Uma etapa que esta versão do app não consegue mostrar", "Add a comment": "Adicionar um comentário", "Add a comment to the poll": "Adicionar um comentário à enquete", "Add an option": "Adicionar uma opção", "Add more": "Adicionar mais", + "Allow": "Permitir", "Allow access to your Gallery": "Permitir acesso à sua Galeria", "Allow camera access in device settings": "Permitir acesso à câmera nas configurações do dispositivo", "Also send to channel": "Também enviar para o canal", @@ -20,16 +22,20 @@ "Ban User": "Banir Usuário", "Block User": "Bloquear Usuário", "Cancel": "Cancelar", + "Cancelled": "Cancelado", "Cannot Flag Message": "Não é possível reportar a mensagem", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "Considere como seu comentário pode fazer os outros se sentirem e certifique-se de seguir nossas Diretrizes da Comunidade", "Copy Message": "Copiar Mensagem", "Create Poll": "Criar enquete", + "Declined": "Recusado", "Delete": "Excluir", "Delete Message": "Excluir Mensagem", "Delete for me": "Excluir para mim", "Device camera is used to take photos or videos.": "A câmera do dispositivo é usada para tirar fotos ou vídeos.", "Device gallery permissions is used to take photos or videos.": "As permissões da galeria do dispositivo são usadas para tirar fotos ou vídeos.", + "Didn't complete": "Não concluído", "Do you want to send a copy of this message to a moderator for further investigation?": "Deseja enviar uma cópia desta mensagem para um moderador para investigação adicional?", + "Don't Allow": "Não permitir", "Due since {{ dueSince }}": "Vencido desde {{ dueSince }}", "Edit Message": "Editar Mensagem", "Edited": "Editado", @@ -136,7 +142,10 @@ "The message has been reported to a moderator.": "A mensagem foi relatada a um moderador.", "The source message was deleted": "A mensagem original foi excluída", "Thinking...": "Pensando...", + "Thinking... {{ duration }}": "Pensando... {{ duration }}", "This reply was deleted": "Esta resposta foi excluída", + "Thought for {{ duration }}": "Pensou por {{ duration }}", + "Thought process": "Raciocínio", "Thread Reply": "Respostas de Tópico", "Type a number from 2 to 10": "Digite um número de 2 a 10", "Unban User": "Desbanir Usuário", @@ -154,6 +163,10 @@ "Voice message": "Mensagem de voz", "Voice message ({{duration}})": "Mensagem de voz ({{duration}})", "Voice message deleted": "Mensagem de voz excluída", + "Waiting for approval": "Aguardando aprovação", + "Waiting for the device": "Aguardando o dispositivo", + "Waiting for you to sign in": "Aguardando seu login", + "Your answer couldn't be sent. Try again.": "Não foi possível enviar sua resposta. Tente novamente.", "Your comment": "Seu comentário", "You": "Você", "You can't send messages in this channel": "Você não pode enviar mensagens neste canal", diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index a6d33d6d47..c344a8d557 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "1 Ответ", "1 Thread Reply": "1 тема Ответить", + "A step this app version can't show": "Шаг, который эта версия приложения не может показать", "Add a comment": "Добавить комментарий", "Add a comment to the poll": "Добавить комментарий к опросу", "Add an option": "Добавить вариант", "Add more": "Добавить ещё", + "Allow": "Разрешить", "Allow access to your Gallery": "Разрешить доступ к вашей галерее", "Allow camera access in device settings": "Разрешите доступ к камере в настройках устройства.", "Also send to channel": "Также отправить на канал", @@ -20,16 +22,20 @@ "Ban User": "Заблокировать Пользователя", "Block User": "Заблокировать пользователя", "Cancel": "Отмена", + "Cancelled": "Отменено", "Cannot Flag Message": "Невозможно пожаловаться на сообщение", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "Обдумайте, как ваш комментарий может повлиять на других, и убедитесь, что вы следуете нашим правилам сообщества", "Copy Message": "Копировать сообщение", "Create Poll": "Создать опрос", + "Declined": "Отклонено", "Delete": "удалять", "Delete Message": "Удалить сообщение", "Delete for me": "Удалить для себя", "Device camera is used to take photos or videos.": "Камера устройства используется для съемки фотографий или видео.", "Device gallery permissions is used to take photos or videos.": "Разрешения галереи устройства используются для съемки фото или видео.", + "Didn't complete": "Не выполнено", "Do you want to send a copy of this message to a moderator for further investigation?": "Вы хотите отправить копию этого сообщения модератору для дальнейшего изучения?", + "Don't Allow": "Не разрешать", "Due since {{ dueSince }}": "Срок с {{ dueSince }}", "Edit Message": "Редактировать сообщение", "Edited": "Отредактировано", @@ -136,7 +142,10 @@ "The message has been reported to a moderator.": "Сообщение отправлено модератору.", "The source message was deleted": "Исходное сообщение было удалено", "Thinking...": "Думаю...", + "Thinking... {{ duration }}": "Думаю... {{ duration }}", "This reply was deleted": "Этот ответ был удалён", + "Thought for {{ duration }}": "Думал {{ duration }}", + "Thought process": "Ход рассуждений", "Thread Reply": "Тема Ответить", "Type a number from 2 to 10": "Введите число от 2 до 10", "Unban User": "Разблокировать Пользователя", @@ -154,6 +163,10 @@ "Voice message": "Голосовое сообщение", "Voice message ({{duration}})": "Голосовое сообщение ({{duration}})", "Voice message deleted": "Голосовое сообщение удалено", + "Waiting for approval": "Ожидает подтверждения", + "Waiting for the device": "Ожидает устройство", + "Waiting for you to sign in": "Ожидает вашего входа", + "Your answer couldn't be sent. Try again.": "Не удалось отправить ваш ответ. Попробуйте ещё раз.", "Your comment": "Ваш комментарий", "You": "Вы", "You can't send messages in this channel": "Вы не можете отправлять сообщения в этот канал", diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index c6560782e3..5d5036e190 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -4,10 +4,12 @@ "+{{count}}_other": "+{{count}}", "1 Reply": "1 Cevap", "1 Thread Reply": "1 Konu Yanıtı", + "A step this app version can't show": "Bu uygulama sürümünün gösteremediği bir adım", "Add a comment": "Yorum ekle", "Add a comment to the poll": "Ankete yorum ekle", "Add an option": "Seçenek ekle", "Add more": "Daha fazla ekle", + "Allow": "İzin ver", "Allow access to your Gallery": "Galerinize erişime izin verin", "Allow camera access in device settings": "Cihaz ayarlarında kamera erişimine izin ver", "Also send to channel": "Kanala da gönder", @@ -20,16 +22,20 @@ "Ban User": "Kullanıcıyı Yasakla", "Block User": "Kullanıcıyı engelle", "Cancel": "İptal", + "Cancelled": "İptal edildi", "Cannot Flag Message": "Raporlama Başarısız", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "Yorumunuzun diğerlerini nasıl hissettirebileceğini düşünün ve topluluk kurallarımızı takip ettiğinizden emin olun", "Copy Message": "Mesajı Kopyala", "Create Poll": "Anket oluştur", + "Declined": "Reddedildi", "Delete": "Sil", "Delete Message": "Mesajı Sil", "Delete for me": "Benim için sil", "Device camera is used to take photos or videos.": "Cihaz kamerası fotoğraf veya video çekmek için kullanılır.", "Device gallery permissions is used to take photos or videos.": "Cihaz galerisi izinleri fotoğraf veya video çekmek için kullanılır.", + "Didn't complete": "Tamamlanmadı", "Do you want to send a copy of this message to a moderator for further investigation?": "Detaylı inceleme için bu mesajın kopyasını moderatöre göndermek istiyor musunuz?", + "Don't Allow": "İzin verme", "Due since {{ dueSince }}": "Son tarihi {{ dueSince }} itibarıyla geçmiştir.", "Edit Message": "Mesajı Düzenle", "Edited": "Düzenlendi", @@ -136,7 +142,10 @@ "The message has been reported to a moderator.": "Mesaj moderatöre bildirildi.", "The source message was deleted": "Kaynak mesaj silindi", "Thinking...": "Düşünüyor...", + "Thinking... {{ duration }}": "Düşünüyor... {{ duration }}", "This reply was deleted": "Bu yanıt silindi", + "Thought for {{ duration }}": "{{ duration }} düşündü", + "Thought process": "Düşünme süreci", "Thread Reply": "Konu Yanıtı", "Type a number from 2 to 10": "2 ile 10 arasında bir sayı girin", "Unban User": "Kullanıcının Yasağını Kaldır", @@ -154,6 +163,10 @@ "Voice message": "Sesli mesaj", "Voice message ({{duration}})": "Sesli mesaj ({{duration}})", "Voice message deleted": "Sesli mesaj silindi", + "Waiting for approval": "Onay bekleniyor", + "Waiting for the device": "Cihaz bekleniyor", + "Waiting for you to sign in": "Oturum açmanız bekleniyor", + "Your answer couldn't be sent. Try again.": "Yanıtınız gönderilemedi. Tekrar deneyin.", "Your comment": "Yorumunuz", "You": "Sen", "You can't send messages in this channel": "Bu konuşmaya mesaj gönderemezsiniz",