From 2789ab20667322cc76e8a7aac7ffd212a93dbae7 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 4 Nov 2026 01:10:56 +0100 Subject: [PATCH] feat(ai-components): message views, reply actions and icon overrides --- .../ExpoChatGPTSample/contexts/AppContext.tsx | 24 ++ examples/ExpoChatGPTSample/custom-types.d.ts | 2 + .../ExpoChatGPTSample/screens/ChatContent.tsx | 150 +++++---- .../__tests__/messageViews.test.tsx | 289 ++++++++++++++++++ .../ai-components/contexts/theme-context.tsx | 14 +- package/src/ai-components/icons.ts | 98 ++++++ package/src/ai-components/index.ts | 2 + .../internal/icons/AgentIcons.tsx | 88 +++++- .../message-parts/AIMessageParts.tsx | 5 +- .../message-parts/AIToolCall.tsx | 22 +- .../message/AIAssistantMessage.tsx | 82 +++++ .../message/AIMessageAttachment.tsx | 247 +++++++++++++++ .../ai-components/message/AIReplyActions.tsx | 134 ++++++++ .../ai-components/message/AIUserMessage.tsx | 121 ++++++++ package/src/ai-components/message/index.ts | 4 + .../ai-components/reasoning/AIReasoning.tsx | 7 +- .../services/clipboard-service/expo.ts | 22 +- package/src/ai-components/theme.ts | 65 ++++ package/src/i18n/ar.json | 9 + package/src/i18n/en.json | 9 + package/src/i18n/es.json | 9 + package/src/i18n/fr.json | 9 + package/src/i18n/he.json | 9 + package/src/i18n/hi.json | 9 + package/src/i18n/it.json | 9 + package/src/i18n/ja.json | 9 + package/src/i18n/ko.json | 9 + package/src/i18n/nl.json | 9 + package/src/i18n/pt-br.json | 9 + package/src/i18n/ru.json | 9 + package/src/i18n/tr.json | 9 + 31 files changed, 1382 insertions(+), 111 deletions(-) create mode 100644 package/src/ai-components/__tests__/messageViews.test.tsx create mode 100644 package/src/ai-components/icons.ts create mode 100644 package/src/ai-components/message/AIAssistantMessage.tsx create mode 100644 package/src/ai-components/message/AIMessageAttachment.tsx create mode 100644 package/src/ai-components/message/AIReplyActions.tsx create mode 100644 package/src/ai-components/message/AIUserMessage.tsx create mode 100644 package/src/ai-components/message/index.ts diff --git a/examples/ExpoChatGPTSample/contexts/AppContext.tsx b/examples/ExpoChatGPTSample/contexts/AppContext.tsx index 73636148ae..78addabde3 100644 --- a/examples/ExpoChatGPTSample/contexts/AppContext.tsx +++ b/examples/ExpoChatGPTSample/contexts/AppContext.tsx @@ -11,6 +11,7 @@ import type { Channel, Event, StreamChat } from 'stream-chat'; import { useStableCallback } from 'stream-chat-expo'; import { AIClientIdentity, + type AIMessageRating, type AIToolApprover, createAuthSessionAuthorizer, } from 'stream-chat-expo/ai-components'; @@ -54,6 +55,10 @@ export type AppContextValue = { ask: (text: string, images?: ImageSource[]) => Promise; /** Carries on a conversation from the drawer, by its session id. */ openConversation: (sessionId: string) => void; + /** Rates a reply, or takes the rating back with null. */ + rate: (messageId: string, rating: AIMessageRating | null) => void; + /** How the person rated replies, by message. Kept for this run only: the router takes no ratings yet. */ + ratings: Readonly>; /** 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. */ @@ -72,6 +77,8 @@ export const AppContext = React.createContext({ conversationsVersion: 0, failure: undefined, openConversation: () => {}, + rate: () => {}, + ratings: {}, reasoningText: () => undefined, reasoningVersion: 0, sessionId: undefined, @@ -87,6 +94,7 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St const [agentState, setAgentState] = useState(IDLE); const [reasoningVersion, setReasoningVersion] = useState(0); const [conversationsVersion, setConversationsVersion] = useState(0); + const [ratings, setRatings] = useState>>({}); const current = useRef(undefined); // Opening one conversation after another: the newest wins, and one that was never asked @@ -147,6 +155,18 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St } }); + const rate = useStableCallback((messageId: string, rating: AIMessageRating | null) => + setRatings((previous) => { + const next = { ...previous }; + if (rating) { + next[messageId] = rating; + } else { + delete next[messageId]; + } + return next; + }), + ); + const reasoningText = useStableCallback((messageId: string, stepId: string) => current.current?.reasoning.get(messageId, stepId), ); @@ -230,6 +250,8 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St conversationsVersion, failure, openConversation, + rate, + ratings, reasoningText, reasoningVersion, sessionId: conversation?.session.id, @@ -244,6 +266,8 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St conversationsVersion, failure, openConversation, + rate, + ratings, reasoningText, reasoningVersion, startConversation, diff --git a/examples/ExpoChatGPTSample/custom-types.d.ts b/examples/ExpoChatGPTSample/custom-types.d.ts index a54ef2f03b..c4d72a1e2e 100644 --- a/examples/ExpoChatGPTSample/custom-types.d.ts +++ b/examples/ExpoChatGPTSample/custom-types.d.ts @@ -30,6 +30,8 @@ declare module 'stream-chat' { interface CustomMessageData extends DefaultMessageData { ai_generated?: boolean; + /** Set by the router while the agent is still writing a reply. */ + generating?: boolean; } interface CustomPollOptionData extends DefaultPollOptionData {} diff --git a/examples/ExpoChatGPTSample/screens/ChatContent.tsx b/examples/ExpoChatGPTSample/screens/ChatContent.tsx index 722e24721b..e5eb2e9118 100644 --- a/examples/ExpoChatGPTSample/screens/ChatContent.tsx +++ b/examples/ExpoChatGPTSample/screens/ChatContent.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { ActivityIndicator, Platform, @@ -13,30 +13,26 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useHeaderHeight } from 'expo-router/react-navigation'; -import type { MessageProps, ResolvedComponents } from 'stream-chat-expo'; +import type { Attachment, LocalMessage } from 'stream-chat'; +import type { MessageProps } from 'stream-chat-expo'; import { Channel, isLocalUrl, - mergeThemes, - Message, MessageList, - ThemeProvider, - useComponentsContext, - useMessageContext, usePaginatedMessageListContext, - useTheme, WithComponents, } from 'stream-chat-expo'; import { - AI_MESSAGE_PART_TYPE_PREFIX, - AIMessageParts, + AIAssistantMessage, + type AIMessageAttachmentData, + AIReplyActions, AIThinking, AIToolApproverProvider, AITypingIndicatorView, + AIUserMessage, ComposerView, parseMessageParts, - StreamingMessageView, } from 'stream-chat-expo/ai-components'; import { bottomSheetOptions } from '../bottomSheetOptions'; @@ -44,11 +40,39 @@ import { VoiceMode } from '../components/VoiceMode'; import { useAppContext } from '../contexts/AppContext'; import { useSendAIMessage } from '../hooks/useSendAIMessage'; -const CustomMessage = (props: MessageProps) => { - const { theme } = useTheme(); - const { message } = props; - const isFromBot = message.ai_generated; - const hasPendingAttachments = useMemo( +// Images and files only: a reply's steps (`ai_*`) are read as its parts, and other kinds, such as +// the router's `connector_authorization`, a second copy of a sign-in its tool call's step asks for, +// aren't shown. +const attachmentOf = (attachment: Attachment): AIMessageAttachmentData | undefined => { + const name = attachment.title ?? attachment.fallback ?? undefined; + if (attachment.type === 'image') { + const url = attachment.image_url ?? attachment.asset_url ?? attachment.thumb_url; + return url ? { kind: 'image', mimeType: attachment.mime_type, name, url } : undefined; + } + if (attachment.type === 'file') { + const size = Number(attachment.file_size); + return attachment.asset_url + ? { + kind: 'file', + mimeType: attachment.mime_type, + name, + size: Number.isFinite(size) && size > 0 ? size : undefined, + url: attachment.asset_url, + } + : undefined; + } + return undefined; +}; + +const attachmentsOf = (attachments: Attachment[] | undefined) => + (attachments ?? []) + .map(attachmentOf) + .filter((attachment): attachment is AIMessageAttachmentData => !!attachment); + +// The person's message, dimmed while its photos upload. +const UserMessage = ({ message }: { message: LocalMessage }) => { + const attachments = useMemo(() => attachmentsOf(message.attachments), [message.attachments]); + const isUploading = useMemo( () => (message.attachments ?? []).some( (attachment) => @@ -57,81 +81,55 @@ const CustomMessage = (props: MessageProps) => { ), [message.attachments], ); - - const modifiedTheme = useMemo( - () => - mergeThemes({ - theme, - style: { - messageItemView: isFromBot - ? { - content: { - containerInner: { - backgroundColor: 'transparent', - borderRadius: 0, - borderColor: 'transparent', - }, - textContainer: { maxWidth: '100%' }, - }, - } - : { - container: { - opacity: hasPendingAttachments ? 0.5 : 1, - }, - }, - }, - }), - [theme, isFromBot, hasPendingAttachments], - ); - return ( - - - + + + ); }; -type UnsupportedAttachmentComponent = ResolvedComponents['UnsupportedAttachment']; - -// A reply's steps (`ai_reasoning`, `ai_tool_call`) are attachments the chat SDK doesn't know. They -// show in the reply's own view, so they're left out here, as is the router's `connector_authorization`, -// a second copy of a sign-in its tool call's step already asks for. 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) || - props.attachment.type === 'connector_authorization' ? null : ( - - ); - return HideAISteps; -}; - -// A reply: its steps (reasoning, tool calls and their questions), then its answer. -const CustomStreamingMessageView = () => { - const { message: listed } = useMessageContext(); - // Read so the steps render again as each live update arrives. - const { channel, reasoningText, reasoningVersion } = useAppContext(); +// A reply: its steps, its answer and what it sent, then its actions once it's written. +const AssistantMessage = ({ message: listed }: { message: LocalMessage }) => { + // Read so the reply renders again as each live update arrives. + const { channel, rate, ratings, reasoningText, reasoningVersion } = useAppContext(); // The list passes a row its message again only when it changes in ways it compares, which a // step changing on its own, such as a tool call starting to wait for a sign-in, isn't. The // channel's state has every update. const message = channel?.state.findMessage(listed.id) ?? listed; const parts = useMemo(() => parseMessageParts(message.attachments), [message.attachments]); + const attachments = useMemo(() => attachmentsOf(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 isGenerating = message.generating === true; + const text = message.text ?? ''; + // The list sizes a row to its content; a reply takes the row's whole width. const { width } = useWindowDimensions(); return ( - - {parts.length > 0 ? : null} - {message.text ? : null} + + + {!isGenerating && text ? ( + rate(message.id, rating)} + rating={ratings[message.id] ?? null} + text={text} + /> + ) : null} ); }; +const CustomMessage = ({ message }: MessageProps) => + message.ai_generated ? : ; + const CustomComposerView = () => { const { agentState, stop } = useAppContext(); @@ -212,9 +210,6 @@ export const ChatContent = () => { // 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). const headerHeight = useHeaderHeight(); - // Read above this screen's own overrides, so it is the SDK's view. - const { UnsupportedAttachment } = useComponentsContext(); - const [CustomUnsupportedAttachment] = useState(() => hidingAISteps(UnsupportedAttachment)); if (!channel) { return ( @@ -235,11 +230,7 @@ export const ChatContent = () => { overrides={{ EmptyStateIndicator, Message: CustomMessage, - MessageAuthor: RenderNull, - MessageFooter: RenderNull, NetworkDownIndicator: RenderNull, - StreamingMessageView: CustomStreamingMessageView, - UnsupportedAttachment: CustomUnsupportedAttachment, }} > @@ -271,7 +262,8 @@ const styles = StyleSheet.create({ }, emptyContainerText: { fontSize: 24, fontWeight: 'bold' }, failureText: { color: '#B00020', fontSize: 15, paddingHorizontal: 24, textAlign: 'center' }, - streamingMessageViewWrapper: { paddingHorizontal: 16 }, + assistantMessage: { gap: 4 }, + message: { paddingHorizontal: 16, paddingVertical: 8 }, aiTypingIndicatorWrapper: { paddingHorizontal: 24, paddingVertical: 12, diff --git a/package/src/ai-components/__tests__/messageViews.test.tsx b/package/src/ai-components/__tests__/messageViews.test.tsx new file mode 100644 index 0000000000..c91ea64079 --- /dev/null +++ b/package/src/ai-components/__tests__/messageViews.test.tsx @@ -0,0 +1,289 @@ +import React, { type ReactElement } from 'react'; +import { Linking, Share, Text } from 'react-native'; + +import { act, fireEvent, render, screen } from '@testing-library/react-native'; + +import { + AIAssistantMessage, + type AIIcon, + AIMessageAttachment, + type AIMessageAttachmentProps, + AIReplyActions, + AIUserMessage, + attachmentDetails, + defaultAIIcons, + formatFileSize, + mergeThemes, + parseMessageParts, + StreamTheme, +} from '../index'; +import { setClipboardString } from '../services/clipboard-service'; + +jest.mock('../services/clipboard-service', () => ({ setClipboardString: jest.fn() })); + +const renderWithTheme = (ui: ReactElement) => render({ui}); + +afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); +}); + +describe('formatFileSize', () => { + it('writes three significant digits in the largest unit that fits', () => { + expect(formatFileSize(0)).toBe('0.00 B'); + expect(formatFileSize(512)).toBe('512 B'); + expect(formatFileSize(2_453_000)).toBe('2.34 MB'); + }); + + it('rounds a size just below the next unit instead of turning exponential', () => { + expect(formatFileSize(1000)).toBe('1000 B'); + expect(formatFileSize(1023 * 1024)).toBe('1023 kB'); + }); +}); + +describe('attachmentDetails', () => { + it('joins the type and the size', () => { + expect(attachmentDetails({ kind: 'file', name: 'report.pdf', size: 2_453_000 })).toBe( + 'PDF · 2.34 MB', + ); + }); + + it('takes the type from the URL, then the media type', () => { + expect(attachmentDetails({ kind: 'file', url: 'https://x.io/a/notes.txt?v=1' })).toBe('TXT'); + expect(attachmentDetails({ kind: 'file', mimeType: 'image/svg+xml' })).toBe('SVG'); + }); + + it('is empty with nothing to say', () => { + expect(attachmentDetails({ kind: 'file' })).toBe(''); + }); +}); + +describe('AIMessageAttachment', () => { + it('shows an image as a thumbnail named for it, which does not open', () => { + const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); + renderWithTheme( + , + ); + expect(screen.getByLabelText('cat.png')).toBeTruthy(); + expect(screen.queryByRole('imagebutton')).toBeNull(); + expect(screen.queryByRole('link')).toBeNull(); + expect(openURL).not.toHaveBeenCalled(); + }); + + it('shows an image without a URL as a file', () => { + renderWithTheme(); + expect(screen.getByText('photo.png')).toBeTruthy(); + expect(screen.getByText('PNG')).toBeTruthy(); + }); + + it('shows a file with its name, type and size, and opens one at a web URL', () => { + const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); + renderWithTheme( + , + ); + expect(screen.getByText('report.pdf')).toBeTruthy(); + expect(screen.getByText('PDF · 2.34 MB')).toBeTruthy(); + fireEvent.press(screen.getByRole('link')); + expect(openURL).toHaveBeenCalledWith('https://x.io/r'); + }); + + it("doesn't open anything but a web URL", () => { + renderWithTheme( + , + ); + expect(screen.queryByRole('link')).toBeNull(); + }); + + it('names one without a name by its kind, and shows a kind it does not know as a file', () => { + renderWithTheme(); + expect(screen.getByText('Image')).toBeTruthy(); + renderWithTheme(); + expect(screen.getByText('File')).toBeTruthy(); + }); +}); + +describe('AIUserMessage', () => { + it('shows the text and the attachments', () => { + renderWithTheme( + , + ); + expect(screen.getByText('Summarise this')).toBeTruthy(); + expect(screen.getByText('a.pdf')).toBeTruthy(); + }); + + it('offers editing only with onEdit', () => { + const onEdit = jest.fn(); + const { rerender } = renderWithTheme(); + expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); + rerender( + + + , + ); + fireEvent.press(screen.getByRole('button', { name: 'Edit' })); + expect(onEdit).toHaveBeenCalledTimes(1); + }); + + it('shows no bubble for a message of attachments alone', () => { + renderWithTheme(); + expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); + expect(screen.getByText('a.pdf')).toBeTruthy(); + }); +}); + +describe('AIAssistantMessage', () => { + it('shows the steps, then the answer, then the attachments', () => { + const parts = parseMessageParts([ + { id: 'r1', status: 'completed', summary: 'Looked it up', type: 'ai_reasoning' }, + ]); + renderWithTheme( + , + ); + expect(screen.getByTestId('ai-message-parts')).toBeTruthy(); + expect(screen.getByText('The answer')).toBeTruthy(); + expect(screen.getByText('chart.csv')).toBeTruthy(); + }); + + it('is busy while the agent writes it', () => { + renderWithTheme(); + expect(screen.getByTestId('ai-assistant-message').props.accessibilityState).toEqual({ + busy: true, + }); + }); + + it('shows nothing of the answer before it has text', () => { + renderWithTheme(); + expect(screen.queryByTestId('ai-message-parts')).toBeNull(); + }); +}); + +describe('AIReplyActions', () => { + it('copies the text and shows it was copied for a moment', () => { + jest.useFakeTimers(); + renderWithTheme(); + fireEvent.press(screen.getByRole('button', { name: 'Copy' })); + expect(setClipboardString).toHaveBeenCalledWith('The answer'); + expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy(); + act(() => { + jest.advanceTimersByTime(1500); + }); + expect(screen.getByRole('button', { name: 'Copy' })).toBeTruthy(); + }); + + it('shares the text through the system share sheet', () => { + const share = jest + .spyOn(Share, 'share') + .mockResolvedValue({ action: 'sharedAction', activityType: undefined }); + renderWithTheme(); + fireEvent.press(screen.getByRole('button', { name: 'Share' })); + expect(share).toHaveBeenCalledWith({ message: 'The answer' }); + }); + + it('offers regenerating and rating only when given what they do', () => { + renderWithTheme(); + expect(screen.queryByRole('button', { name: 'Regenerate' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Good response' })).toBeNull(); + }); + + it('regenerates', () => { + const onRegenerate = jest.fn(); + renderWithTheme(); + fireEvent.press(screen.getByRole('button', { name: 'Regenerate' })); + expect(onRegenerate).toHaveBeenCalledTimes(1); + }); + + it('rates, and takes a rating back', () => { + const onRate = jest.fn(); + const { rerender } = renderWithTheme(); + fireEvent.press(screen.getByRole('button', { name: 'Bad response' })); + expect(onRate).toHaveBeenLastCalledWith('negative'); + + rerender( + + + , + ); + const good = screen.getByRole('button', { name: 'Good response' }); + expect(good.props.accessibilityState).toEqual({ selected: true }); + fireEvent.press(good); + expect(onRate).toHaveBeenLastCalledWith(null); + }); +}); + +const iconNamed = + (name: string): AIIcon => + () => {`icon:${name}`}; + +describe('icons', () => { + it('draws with the theme’s icons over the defaults', () => { + render( + + + , + ); + expect(screen.getByText('icon:Copy')).toBeTruthy(); + expect(screen.getByText('icon:Share')).toBeTruthy(); + expect(screen.queryByText('icon:ThumbsUp')).toBeNull(); + }); + + it('keeps icons as they are when themes merge', () => { + const Copy = iconNamed('Copy'); + const Share = iconNamed('Share'); + const theme = mergeThemes({ style: { icons: { Copy } } }); + expect(theme.icons?.Copy).toBe(Copy); + const merged = mergeThemes({ style: { icons: { Share } }, theme }); + expect(merged.icons).toEqual({ Copy, Share }); + expect(merged.colors).toEqual(theme.colors); + }); + + it.each([ + [{ kind: 'file', name: 'report.pdf' }, 'Pdf'], + [{ kind: 'file', mimeType: 'application/pdf', name: 'report' }, 'Pdf'], + [{ kind: 'file', name: 'song.mp3' }, 'Audio'], + [{ kind: 'file', mimeType: 'video/mp4', name: 'clip' }, 'Video'], + [{ kind: 'file', name: 'backup.zip' }, 'Archive'], + [{ kind: 'file', mimeType: 'application/vnd.rar', name: 'x' }, 'Archive'], + [{ kind: 'image', name: 'photo' }, 'Image'], + [{ kind: 'file', name: 'notes.txt' }, 'File'], + ])('gives a file of %o the %s icon', (attachment, icon) => { + const icons = Object.fromEntries( + Object.keys(defaultAIIcons).map((name) => [name, iconNamed(name)]), + ); + render( + + + , + ); + expect(screen.getByText(`icon:${icon}`)).toBeTruthy(); + }); +}); + +describe('Attachment slot', () => { + const Mine = ({ attachment, role }: AIMessageAttachmentProps) => ( + {`mine:${role}:${attachment.name}`} + ); + + it('shows the person’s attachments with a view of your own', () => { + renderWithTheme( + , + ); + expect(screen.getByText('mine:user:a.pdf')).toBeTruthy(); + }); + + it('shows the agent’s attachments with a view of your own', () => { + renderWithTheme( + , + ); + expect(screen.getByText('mine:assistant:b.csv')).toBeTruthy(); + }); +}); diff --git a/package/src/ai-components/contexts/theme-context.tsx b/package/src/ai-components/contexts/theme-context.tsx index b1f68bf1ae..3a6f995a50 100644 --- a/package/src/ai-components/contexts/theme-context.tsx +++ b/package/src/ai-components/contexts/theme-context.tsx @@ -26,15 +26,23 @@ export type ThemeContextValue = { export const mergeThemes = (params: MergedThemesParams) => { const { style, theme } = params; + // Icons are components, which the JSON copy below would drop and a deep merge would take apart, + // so they are carried over as they are. + const { icons: themeIcons, ...themeRest } = theme ?? {}; + const { icons: styleIcons, ...styleRest } = style ?? {}; // TODO: Use structured-clone here or something similar. const finalTheme = ( - !theme || Object.keys(theme).length === 0 + !theme || Object.keys(themeRest).length === 0 ? JSON.parse(JSON.stringify(defaultTheme)) - : JSON.parse(JSON.stringify(theme)) + : JSON.parse(JSON.stringify(themeRest)) ) as Theme; if (style) { - merge(finalTheme, style); + merge(finalTheme, styleRest); + } + + if (themeIcons || styleIcons) { + finalTheme.icons = { ...themeIcons, ...styleIcons } as Theme['icons']; } return finalTheme; diff --git a/package/src/ai-components/icons.ts b/package/src/ai-components/icons.ts new file mode 100644 index 0000000000..48a1a02145 --- /dev/null +++ b/package/src/ai-components/icons.ts @@ -0,0 +1,98 @@ +import { type ComponentType, useMemo } from 'react'; + +import { useTheme } from './contexts/theme-context'; +import { + ArchiveIcon, + AudioIcon, + CancelledIcon, + CheckIcon, + ChevronIcon, + CopiedIcon, + CopyIcon, + DeviceIcon, + EditIcon, + FailedIcon, + FileIcon, + HandRaisedIcon, + ImageIcon, + InfoIcon, + KeyIcon, + PdfIcon, + ReasoningIcon, + RegenerateIcon, + ShareIcon, + ThumbsDownFilledIcon, + ThumbsDownIcon, + ThumbsUpFilledIcon, + ThumbsUpIcon, + VideoIcon, +} from './internal/icons/AgentIcons'; + +/** + * What an icon of the agent replies' views is drawn with. An icon is decorative (the text or + * label beside it says what it means), so one of your own hides itself from screen readers, and + * one that points somewhere, such as `Chevron`, mirrors itself in a right-to-left layout. + */ +export type AIIconProps = { color: string; size?: number }; + +export type AIIcon = ComponentType; + +/** The icons of the agent replies' views, by what they stand for. */ +export const defaultAIIcons = { + /** A file of an archive type, such as ZIP, on its attachment. */ + Archive: ArchiveIcon, + /** An audio file, on its attachment. */ + Audio: AudioIcon, + /** A tool call that was cancelled or declined. */ + Cancelled: CancelledIcon, + /** A tool call that completed. */ + Check: CheckIcon, + /** A reasoning step's fold. */ + Chevron: ChevronIcon, + /** The copy action, for a moment after it copied. */ + Copied: CopiedIcon, + /** The copy action. */ + Copy: CopyIcon, + /** A tool call that waits for the person's device. */ + Device: DeviceIcon, + /** The edit action on the person's message. */ + Edit: EditIcon, + /** A tool call that didn't complete. */ + Failed: FailedIcon, + /** A file of a type without an icon of its own, on its attachment. */ + File: FileIcon, + /** A tool call that waits for the person's approval. */ + HandRaised: HandRaisedIcon, + /** An image shown as a file, such as one without a URL, on its attachment. */ + Image: ImageIcon, + /** A step this version can't show. */ + Info: InfoIcon, + /** A tool call that waits for the person to sign in. */ + Key: KeyIcon, + /** A PDF, on its attachment. */ + Pdf: PdfIcon, + /** A reasoning step. */ + Reasoning: ReasoningIcon, + /** The regenerate action. */ + Regenerate: RegenerateIcon, + /** The share action. */ + Share: ShareIcon, + /** The bad response rating, and the same once chosen. */ + ThumbsDown: ThumbsDownIcon, + ThumbsDownFilled: ThumbsDownFilledIcon, + /** The good response rating, and the same once chosen. */ + ThumbsUp: ThumbsUpIcon, + ThumbsUpFilled: ThumbsUpFilledIcon, + /** A video, on its attachment. */ + Video: VideoIcon, +} satisfies Record; + +export type AIIcons = Record; + +/** The icons to draw with: the theme's `icons`, over the defaults. */ +export const useAIIcons = (): AIIcons => { + const { + theme: { icons }, + } = useTheme(); + return useMemo(() => ({ ...defaultAIIcons, ...icons }), [icons]); +}; diff --git a/package/src/ai-components/index.ts b/package/src/ai-components/index.ts index 125dc61d25..c3aded6a4b 100644 --- a/package/src/ai-components/index.ts +++ b/package/src/ai-components/index.ts @@ -8,6 +8,8 @@ export * from './AITypingIndicatorView'; export * from './voice-visualization'; // agent replies +export * from './icons'; +export * from './message'; export * from './message-parts'; export * from './reasoning'; export * from './tool-calls'; diff --git a/package/src/ai-components/internal/icons/AgentIcons.tsx b/package/src/ai-components/internal/icons/AgentIcons.tsx index 188bd0fe9b..cec2259f0e 100644 --- a/package/src/ai-components/internal/icons/AgentIcons.tsx +++ b/package/src/ai-components/internal/icons/AgentIcons.tsx @@ -2,15 +2,27 @@ 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. +// The agent replies' 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, or its button's label, 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) => { +type StrokeIconOptions = { + /** Filled with its colour as well as drawn, such as a chosen rating. */ + filled?: boolean; + /** Flipped in a right-to-left layout, for one that points somewhere. */ + mirrorsInRTL?: boolean; + /** Turned around the grid's centre, in degrees. */ + rotation?: number; +}; + +const strokeIcon = ( + d: string, + { filled = false, mirrorsInRTL = false, rotation }: StrokeIconOptions = {}, +) => { const Icon = ({ color, size = 16 }: AgentIconProps) => ( { > ); @@ -37,7 +55,7 @@ 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 ChevronIcon = strokeIcon('M7.5 4.375L13.125 10L7.5 15.625', { mirrorsInRTL: true }); export const CheckIcon = strokeIcon('M4.375 10.625L8.125 14.375L15.625 5.625'); @@ -58,3 +76,61 @@ export const KeyIcon = strokeIcon( 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', ); + +// A message's actions, from stream-chat-react's icon set. + +export const CopyIcon = strokeIcon( + 'M13.125 13.125H16.875V3.125H6.875V6.875M3.125 6.875H13.125V16.875H3.125V6.875Z', +); + +export const CopiedIcon = strokeIcon('M3.125 11.25L7.5 15.625L17.5 5.625'); + +export const ShareIcon = strokeIcon( + 'M13.125 6.25L10 3.125M10 3.125L6.875 6.25M10 3.125V12.5M6.875 8.75H5C4.31 8.75 3.75 9.31 3.75 10V15.625C3.75 16.315 4.31 16.875 5 16.875H15C15.69 16.875 16.25 16.315 16.25 15.625V10C16.25 9.31 15.69 8.75 15 8.75H13.125', +); + +export const RegenerateIcon = strokeIcon( + 'M13.125 7.5H16.875M16.875 7.5V3.75M16.875 7.5L14.67 5.29C13.39 4.01 11.65 3.29 9.84 3.28C8.03 3.27 6.29 3.97 5 5.23M6.875 12.5H3.125M3.125 12.5V16.25M3.125 12.5L5.33 14.71C6.61 15.99 8.35 16.71 10.16 16.72C11.97 16.73 13.71 16.03 15 14.77', +); + +export const EditIcon = strokeIcon( + 'M10.625 5L15 9.375M7.24 16.875H3.75C3.58 16.875 3.43 16.81 3.31 16.69C3.19 16.57 3.125 16.42 3.125 16.25V12.76C3.125 12.59 3.19 12.43 3.31 12.32L12.94 2.68C13.06 2.57 13.22 2.5 13.38 2.5C13.55 2.5 13.71 2.57 13.83 2.68L17.32 6.17C17.43 6.29 17.5 6.45 17.5 6.61C17.5 6.78 17.43 6.94 17.32 7.06L7.68 16.69C7.57 16.81 7.41 16.875 7.24 16.875Z', +); + +export const FileIcon = strokeIcon( + 'M11.875 2.5H4.375C4.21 2.5 4.05 2.57 3.93 2.68C3.82 2.8 3.75 2.96 3.75 3.125V16.875C3.75 17.04 3.82 17.2 3.93 17.32C4.05 17.43 4.21 17.5 4.375 17.5H15.625C15.79 17.5 15.95 17.43 16.07 17.32C16.18 17.2 16.25 17.04 16.25 16.875V6.875M11.875 2.5L16.25 6.875M11.875 2.5V6.875H16.25', +); + +const thumb = + 'M5.625 8.75V16.25M5.625 8.75H3.75C3.405 8.75 3.125 9.03 3.125 9.375V15.625C3.125 15.97 3.405 16.25 3.75 16.25H5.625M5.625 8.75L8.75 2.5C9.44 2.5 10.1 2.77 10.59 3.26C11.08 3.75 11.25 4.41 11.25 5.1V6.875H15.94C16.12 6.875 16.29 6.91 16.45 6.99C16.61 7.06 16.75 7.17 16.86 7.3C16.98 7.43 17.06 7.59 17.11 7.75C17.16 7.92 17.17 8.09 17.15 8.27L16.21 15.77C16.17 16.07 16.03 16.34 15.8 16.54C15.58 16.74 15.29 16.85 14.99 16.85H5.625'; + +export const ThumbsUpIcon = strokeIcon(thumb); + +export const ThumbsUpFilledIcon = strokeIcon(thumb, { filled: true }); + +export const ThumbsDownIcon = strokeIcon(thumb, { rotation: 180 }); + +export const ThumbsDownFilledIcon = strokeIcon(thumb, { filled: true, rotation: 180 }); + +// A file's type, on its attachment's tile. PDF and waveform are drawn to match StreamChatAI's +// `doc.richtext` and `waveform`, which stream-chat-react's set lacks. + +export const PdfIcon = strokeIcon( + 'M11.875 2.5H4.375C4.21 2.5 4.05 2.57 3.93 2.68C3.82 2.8 3.75 2.96 3.75 3.125V16.875C3.75 17.04 3.82 17.2 3.93 17.32C4.05 17.43 4.21 17.5 4.375 17.5H15.625C15.79 17.5 15.95 17.43 16.07 17.32C16.18 17.2 16.25 17.04 16.25 16.875V6.875M11.875 2.5L16.25 6.875M11.875 2.5V6.875H16.25M6.875 10.625H13.125M6.875 13.75H13.125', +); + +export const ImageIcon = strokeIcon( + 'M4.43 16.875L13 8.31C13.05 8.25 13.12 8.2 13.2 8.17C13.27 8.14 13.36 8.125 13.44 8.125C13.52 8.125 13.6 8.14 13.68 8.17C13.75 8.2 13.82 8.25 13.88 8.31L16.875 11.3M3.75 3.125H16.25C16.6 3.125 16.875 3.4 16.875 3.75V16.25C16.875 16.6 16.6 16.875 16.25 16.875H3.75C3.4 16.875 3.125 16.6 3.125 16.25V3.75C3.125 3.4 3.4 3.125 3.75 3.125ZM8.75 7.5C8.75 8.19 8.19 8.75 7.5 8.75C6.81 8.75 6.25 8.19 6.25 7.5C6.25 6.81 6.81 6.25 7.5 6.25C8.19 6.25 8.75 6.81 8.75 7.5Z', +); + +export const AudioIcon = strokeIcon( + 'M3.75 8.125V11.875M6.875 5.625V14.375M10 3.125V16.875M13.125 6.875V13.125M16.25 8.75V11.25', +); + +export const VideoIcon = strokeIcon( + 'M15.625 8.75L19.375 6.25V13.75L15.625 11.25M2.5 5H15C15.35 5 15.625 5.28 15.625 5.625V14.375C15.625 14.72 15.35 15 15 15H2.5C2.15 15 1.875 14.72 1.875 14.375V5.625C1.875 5.28 2.15 5 2.5 5Z', +); + +export const ArchiveIcon = strokeIcon( + 'M16.875 7.5V15C16.875 15.17 16.81 15.32 16.69 15.44C16.57 15.56 16.42 15.625 16.25 15.625H3.75C3.58 15.625 3.43 15.56 3.31 15.44C3.19 15.32 3.125 15.17 3.125 15V7.5M8.125 10.625H11.875M2.5 4.375H17.5C17.85 4.375 18.125 4.65 18.125 5V6.875C18.125 7.22 17.85 7.5 17.5 7.5H2.5C2.15 7.5 1.875 7.22 1.875 6.875V5C1.875 4.65 2.15 4.375 2.5 4.375Z', +); diff --git a/package/src/ai-components/message-parts/AIMessageParts.tsx b/package/src/ai-components/message-parts/AIMessageParts.tsx index 707fd727ee..7d2403594f 100644 --- a/package/src/ai-components/message-parts/AIMessageParts.tsx +++ b/package/src/ai-components/message-parts/AIMessageParts.tsx @@ -7,7 +7,7 @@ import { AIToolCall } from './AIToolCall'; import type { AIMessagePartData } from './types'; import { useAITranslation, useTheme } from '../contexts'; -import { InfoIcon } from '../internal/icons/AgentIcons'; +import { useAIIcons } from '../icons'; import { AIReasoning } from '../reasoning/AIReasoning'; import type { AIToolApprover } from '../tool-calls/AIToolApprover'; @@ -32,6 +32,7 @@ export const AIMessagePart = ({ approver, part, reasoningText }: AIMessagePartPr const { theme: { colors, messageParts }, } = useTheme(); + const { Info } = useAIIcons(); if (part.reasoning) { return ; @@ -47,7 +48,7 @@ export const AIMessagePart = ({ approver, part, reasoningText }: AIMessagePartPr } return ( - + diff --git a/package/src/ai-components/message-parts/AIToolCall.tsx b/package/src/ai-components/message-parts/AIToolCall.tsx index 1a1422ed21..ffe3427b49 100644 --- a/package/src/ai-components/message-parts/AIToolCall.tsx +++ b/package/src/ai-components/message-parts/AIToolCall.tsx @@ -5,15 +5,8 @@ import { isDeclined, isToolCallFinished } from './parseMessageParts'; import type { AIToolCallPart } from './types'; import { useAITranslation, useTheme } from '../contexts'; +import { useAIIcons } from '../icons'; import { formatCallDuration } from '../internal/format'; -import { - CancelledIcon, - CheckIcon, - DeviceIcon, - FailedIcon, - HandRaisedIcon, - KeyIcon, -} from '../internal/icons/AgentIcons'; import { Shimmer } from '../internal/Shimmer'; export type AIToolCallProps = { @@ -29,6 +22,7 @@ export const AIToolCall = ({ part }: AIToolCallProps) => { const { theme: { colors, messageParts }, } = useTheme(); + const { Cancelled, Check, Device, Failed, HandRaised, Key } = useAIIcons(); const title = part.displayTitle ?? part.name.replace(/_/g, ' '); const detail = (() => { @@ -53,17 +47,17 @@ export const AIToolCall = ({ part }: AIToolCallProps) => { const icon = (() => { switch (part.status) { case 'awaiting_approval': - return ; + return ; case 'awaiting_authorization': - return ; + return ; case 'awaiting_client': - return ; + return ; case 'completed': - return ; + return ; case 'failed': - return ; + return ; case 'cancelled': - return ; + return ; default: return ; } diff --git a/package/src/ai-components/message/AIAssistantMessage.tsx b/package/src/ai-components/message/AIAssistantMessage.tsx new file mode 100644 index 0000000000..176f63dd84 --- /dev/null +++ b/package/src/ai-components/message/AIAssistantMessage.tsx @@ -0,0 +1,82 @@ +import React, { type ComponentType } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { + AIMessageAttachment, + type AIMessageAttachmentData, + type AIMessageAttachmentProps, +} from './AIMessageAttachment'; + +import { useTheme } from '../contexts'; +import { AIMessageParts } from '../message-parts/AIMessageParts'; +import type { AIMessagePartData } from '../message-parts/types'; +import { StreamingMessageView } from '../StreamingMessageView'; +import type { AIToolApprover } from '../tool-calls/AIToolApprover'; + +export type AIAssistantMessageProps = { + /** The answer, in Markdown. */ + text: string; + /** + * Who answers tool calls' questions and sign-ins on this device, to ask them. Defaults to the + * one of the nearest `AIToolApproverProvider`. + */ + approver?: AIToolApprover; + /** Shows each attachment, instead of `AIMessageAttachment`, such as for a kind of your own. */ + Attachment?: ComponentType; + /** What the agent sent with it, such as images and files. */ + attachments?: AIMessageAttachmentData[]; + /** Whether the agent is still writing it. The text that arrives meanwhile is typed out. */ + isGenerating?: boolean; + /** Opens a link in the answer, instead of the system's handler. */ + onLink?: (url: string) => void; + /** The steps the agent took, in order, shown before the answer. */ + parts?: AIMessagePartData[]; + /** The whole of a reasoning step's thinking, when the app has it. */ + reasoningText?: (stepId: string) => string | undefined; +}; + +/** + * The agent's reply: its steps (reasoning, tool calls and their questions), then its answer, + * typed out as it streams in, then what it sent with it. The counterpart of + * `AIAssistantMessageView`. + */ +export const AIAssistantMessage = ({ + approver, + Attachment = AIMessageAttachment, + attachments = [], + isGenerating = false, + onLink, + parts = [], + reasoningText, + text, +}: AIAssistantMessageProps) => { + const { + theme: { message }, + } = useTheme(); + const styles = message?.assistantMessage; + return ( + + {parts.length > 0 ? ( + + ) : null} + {/* What arrives after mount is typed out; a reply loaded from history shows at once. */} + {text !== '' ? : null} + {attachments.length > 0 ? ( + + {attachments.map((attachment, index) => ( + + ))} + + ) : null} + + ); +}; + +const defaultStyles = StyleSheet.create({ + attachments: { alignItems: 'flex-start', gap: 8 }, + container: { gap: 8 }, +}); diff --git a/package/src/ai-components/message/AIMessageAttachment.tsx b/package/src/ai-components/message/AIMessageAttachment.tsx new file mode 100644 index 0000000000..81e63364d9 --- /dev/null +++ b/package/src/ai-components/message/AIMessageAttachment.tsx @@ -0,0 +1,247 @@ +import React from 'react'; +import { Image, Linking, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { useAITranslation, useTheme } from '../contexts'; +import { type AIIcons, useAIIcons } from '../icons'; + +/** + * What a message carries besides its text, such as an image or a file. The counterpart of + * `AIMessageAttachment` in StreamChatAI. + */ +export type AIMessageAttachmentData = { + /** `image` shows a thumbnail; `file` shows its name, type and size. An open set. */ + kind: 'image' | 'file' | (string & {}); + /** Data of its own, such as what an attachment of a kind of your own needs. */ + extraData?: Record; + /** Its media type, such as `application/pdf`. Without it, the type comes from its name or URL. */ + mimeType?: string; + /** Its name, such as the file's. */ + name?: string; + /** Its size, in bytes. */ + size?: number; + /** Where it is: a web URL, or a local one of something the app holds, such as a photo just picked. */ + url?: string; +}; + +/** The extension of its name, else of its URL. */ +const pathExtensionOf = (attachment: AIMessageAttachmentData) => { + for (const candidate of [attachment.name, attachment.url]) { + if (!candidate) continue; + const path = candidate.split(/[?#]/)[0] ?? ''; + const file = path.split('/').pop() ?? ''; + const dot = file.lastIndexOf('.'); + if (dot > 0 && dot < file.length - 1) return file.slice(dot + 1); + } + return undefined; +}; + +const extensionOf = (attachment: AIMessageAttachmentData) => + pathExtensionOf(attachment) ?? attachment.mimeType?.split('/')[1]?.split(/[+;]/)[0]; + +const ARCHIVE_EXTENSIONS = ['7z', 'bz2', 'gz', 'rar', 'tar', 'tgz', 'xz', 'zip']; +const ARCHIVE_SUBTYPES = [ + 'gzip', + 'vnd.rar', + 'x-7z-compressed', + 'x-bzip2', + 'x-rar-compressed', + 'x-tar', + 'zip', +]; +const AUDIO_EXTENSIONS = ['aac', 'flac', 'm4a', 'mp3', 'ogg', 'opus', 'wav']; +const IMAGE_EXTENSIONS = ['bmp', 'gif', 'heic', 'heif', 'jpeg', 'jpg', 'png', 'svg', 'webp']; +const VIDEO_EXTENSIONS = ['avi', 'm4v', 'mkv', 'mov', 'mp4', 'webm']; + +/** + * The icon of a file's type, from its media type, else from the extension of its name or URL, as + * in StreamChatAI and the Android AI SDK. + */ +const fileTypeIcon = ( + attachment: AIMessageAttachmentData, +): keyof Pick => { + const [type, subtype] = attachment.mimeType?.toLowerCase().split(';')[0]?.trim().split('/') ?? []; + const extension = pathExtensionOf(attachment)?.toLowerCase(); + const is = (wanted: string, extensions: string[]) => + type ? type === wanted : !!extension && extensions.includes(extension); + if (type ? subtype === 'pdf' : extension === 'pdf') return 'Pdf'; + if (is('image', IMAGE_EXTENSIONS)) return 'Image'; + if (is('audio', AUDIO_EXTENSIONS)) return 'Audio'; + if (is('video', VIDEO_EXTENSIONS)) return 'Video'; + if ( + type + ? !!subtype && ARCHIVE_SUBTYPES.includes(subtype) + : !!extension && ARCHIVE_EXTENSIONS.includes(extension) + ) { + return 'Archive'; + } + return !type && !extension && attachment.kind === 'image' ? 'Image' : 'File'; +}; + +const FILE_SIZE_UNITS = ['B', 'kB', 'MB', 'GB']; + +/** A size in bytes to three significant digits, such as "2.34 MB", as the chat SDK writes it. */ +export const formatFileSize = (bytes: number) => { + const exponent = + bytes <= 0 + ? 0 + : Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), FILE_SIZE_UNITS.length - 1); + const mantissa = bytes / 1024 ** exponent; + // `toPrecision` turns exponential past three integer digits (1000–1023 of a unit). + const formatted = mantissa >= 1000 ? Math.round(mantissa).toString() : mantissa.toPrecision(3); + return `${formatted} ${FILE_SIZE_UNITS[exponent]}`; +}; + +/** Its type and size, such as "PDF · 2.34 MB". */ +export const attachmentDetails = (attachment: AIMessageAttachmentData) => + [ + extensionOf(attachment)?.toUpperCase(), + attachment.size !== undefined ? formatFileSize(attachment.size) : undefined, + ] + .filter(Boolean) + .join(' · '); + +// Only a web URL opens; anything else, such as a local file, a data URL or `javascript:`, doesn't. +const isWebUrl = (url: string | undefined): url is string => !!url && /^https?:\/\//i.test(url); + +const open = (url: string) => { + Linking.openURL(url).catch(() => undefined); +}; + +export type AIMessageAttachmentProps = { + attachment: AIMessageAttachmentData; + /** Who sent it. */ + role?: 'user' | 'assistant' | (string & {}); +}; + +/** + * An image or a file on a message: an image as a thumbnail, a file with its type's icon, name, type + * and size. A file at a web URL opens in the browser when tapped; an image doesn't open, as in + * StreamChatAI and the Android AI SDK. The counterpart of `AIMessageAttachmentView`. + */ +export const AIMessageAttachment = ({ + attachment, + role = 'assistant', +}: AIMessageAttachmentProps) => { + const { t } = useAITranslation(); + const { + theme: { colors, message }, + } = useTheme(); + const styles = message?.attachment; + const icons = useAIIcons(); + const { url } = attachment; + const name = + attachment.name || + (url && !url.startsWith('data:') ? url.split(/[?#]/)[0]?.split('/').pop() : undefined) || + (attachment.kind === 'image' ? t('Image') : t('File')); + + if (attachment.kind === 'image' && url) { + return ( + + + + ); + } + + const details = attachmentDetails(attachment); + const TypeIcon = icons[fileTypeIcon(attachment)]; + const content = ( + <> + + + + + + {name} + + {details ? ( + + {details} + + ) : null} + + + ); + const fileStyle = [ + defaultStyles.file, + { + backgroundColor: message?.attachmentBackground ?? colors.white, + borderColor: message?.attachmentBorder ?? colors.grey_gainsboro, + }, + styles?.file, + ]; + + return isWebUrl(url) ? ( + open(url)} + style={({ pressed }) => [fileStyle, { opacity: pressed ? 0.7 : 1 }]} + testID={`ai-message-attachment-${role}`} + > + {content} + + ) : ( + + {content} + + ); +}; + +const defaultStyles = StyleSheet.create({ + // The Android AI SDK's card: a 36 tile with a 20 icon, 8 padding, 10 apart, at most 280 wide. + file: { + alignItems: 'center', + borderRadius: 16, + borderWidth: 1, + flexDirection: 'row', + gap: 10, + maxWidth: 280, + padding: 8, + }, + fileDetails: { fontSize: 12 }, + fileIcon: { + alignItems: 'center', + borderRadius: 8, + height: 36, + justifyContent: 'center', + width: 36, + }, + fileName: { fontSize: 14, fontWeight: '500' }, + fileText: { flexShrink: 1 }, + image: { borderRadius: 16, height: 180, overflow: 'hidden', width: 180 }, +}); diff --git a/package/src/ai-components/message/AIReplyActions.tsx b/package/src/ai-components/message/AIReplyActions.tsx new file mode 100644 index 0000000000..6cdc1969dc --- /dev/null +++ b/package/src/ai-components/message/AIReplyActions.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Pressable, Share, StyleSheet, View } from 'react-native'; + +import { useAITranslation, useTheme } from '../contexts'; +import { type AIIcon, useAIIcons } from '../icons'; +import { setClipboardString } from '../services/clipboard-service'; + +/** How the person rated a reply. */ +export type AIMessageRating = 'positive' | 'negative'; + +export type AIReplyActionsProps = { + /** The reply's text, which copying and sharing hand on. */ + text: string; + /** Rates the reply, or takes the rating back with `null`. Without it, no rating is offered. */ + onRate?: (rating: AIMessageRating | null) => void; + /** Asks the agent for a new reply in its place. Without it, no regenerate button. */ + onRegenerate?: () => void; + /** How the person rated the reply, if they did. */ + rating?: AIMessageRating | null; +}; + +/** How long the copy button shows its check. */ +const COPIED_MS = 1500; + +type ActionProps = { + Icon: AIIcon; + label: string; + onPress: () => void; + selected?: boolean; +}; + +const Action = ({ Icon, label, onPress, selected }: ActionProps) => { + const { + theme: { colors, message }, + } = useTheme(); + const styles = message?.replyActions; + return ( + [ + defaultStyles.button, + { opacity: pressed ? 0.5 : 1 }, + styles?.button, + ]} + > + + + ); +}; + +/** + * The actions under a finished reply: copy (when the app has a clipboard module), share through + * the system's share sheet, regenerate, and a thumbs up or down rating. The counterpart of + * `AIReplyActionsView`. + */ +export const AIReplyActions = ({ onRate, onRegenerate, rating, text }: AIReplyActionsProps) => { + const { t } = useAITranslation(); + const { + theme: { message }, + } = useTheme(); + const icons = useAIIcons(); + const [isCopied, setIsCopied] = useState(false); + const timer = useRef>(undefined); + useEffect(() => () => clearTimeout(timer.current), []); + + const setString = setClipboardString; + const copy = setString + ? () => { + setString(text); + setIsCopied(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => setIsCopied(false), COPIED_MS); + } + : undefined; + + // The person closing the share sheet resolves too; there's nothing to do either way. + const share = () => { + Share.share({ message: text }).catch(() => undefined); + }; + + const positive = rating === 'positive'; + const negative = rating === 'negative'; + + return ( + + {copy ? ( + + ) : null} + + {onRegenerate ? ( + + ) : null} + {onRate ? ( + <> + onRate(positive ? null : 'positive')} + selected={positive} + /> + onRate(negative ? null : 'negative')} + selected={negative} + /> + + ) : null} + + ); +}; + +const defaultStyles = StyleSheet.create({ + // StreamChatAI's 40 buttons, 8 apart; the Android AI SDK's 18 icons. + button: { + alignItems: 'center', + height: 40, + justifyContent: 'center', + width: 40, + }, + container: { alignItems: 'center', flexDirection: 'row', gap: 8 }, +}); diff --git a/package/src/ai-components/message/AIUserMessage.tsx b/package/src/ai-components/message/AIUserMessage.tsx new file mode 100644 index 0000000000..1443c44da2 --- /dev/null +++ b/package/src/ai-components/message/AIUserMessage.tsx @@ -0,0 +1,121 @@ +import React, { type ComponentType } from 'react'; +import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { + AIMessageAttachment, + type AIMessageAttachmentData, + type AIMessageAttachmentProps, +} from './AIMessageAttachment'; + +import { useAITranslation, useTheme } from '../contexts'; +import { useAIIcons } from '../icons'; + +export type AIUserMessageProps = { + /** What the person wrote. */ + text: string; + /** Shows each attachment, instead of `AIMessageAttachment`, such as for a kind of your own. */ + Attachment?: ComponentType; + /** What they sent with it, such as images and files. */ + attachments?: AIMessageAttachmentData[]; + /** Edits the message, such as by putting its text back in the composer. Without it, no edit button. */ + onEdit?: () => void; +}; + +/** + * The person's message: its attachments, then its text in a bubble on the trailing side. The + * counterpart of `AIUserMessageView`. + */ +export const AIUserMessage = ({ + Attachment = AIMessageAttachment, + attachments = [], + onEdit, + text, +}: AIUserMessageProps) => { + const { t } = useAITranslation(); + const { + theme: { colors, message }, + } = useTheme(); + const { Edit } = useAIIcons(); + const styles = message?.userMessage; + + return ( + + {attachments.length > 0 ? ( + + {attachments.map((attachment, index) => ( + + ))} + + ) : null} + {text !== '' ? ( + + {onEdit ? ( + [ + defaultStyles.editButton, + { opacity: pressed ? 0.5 : 1 }, + styles?.editButton, + ]} + > + + + ) : null} + + + {text} + + + + ) : null} + + ); +}; + +// StreamChatAI's and the Android AI SDK's bubble: the design tokens' default surface and text. +const USER_MESSAGE_BACKGROUND = '#EBEEF1'; +const USER_MESSAGE_TEXT = '#1A1B25'; + +const defaultStyles = StyleSheet.create({ + // One under the other, as in StreamChatAI and the Android AI SDK. + attachments: { alignItems: 'flex-end', gap: 8 }, + bubble: { + borderRadius: 20, + flexShrink: 1, + paddingHorizontal: 16, + paddingVertical: 10, + }, + container: { + alignItems: 'flex-end', + gap: 8, + paddingStart: 48, + }, + editButton: { padding: 4 }, + row: { + alignItems: 'flex-end', + flexDirection: 'row', + gap: 4, + maxWidth: '100%', + }, + text: { + fontSize: 16, + lineHeight: 22, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, +}); diff --git a/package/src/ai-components/message/index.ts b/package/src/ai-components/message/index.ts new file mode 100644 index 0000000000..eb9d1e94e1 --- /dev/null +++ b/package/src/ai-components/message/index.ts @@ -0,0 +1,4 @@ +export * from './AIAssistantMessage'; +export * from './AIMessageAttachment'; +export * from './AIReplyActions'; +export * from './AIUserMessage'; diff --git a/package/src/ai-components/reasoning/AIReasoning.tsx b/package/src/ai-components/reasoning/AIReasoning.tsx index 9b4bae937a..5ec972bcaf 100644 --- a/package/src/ai-components/reasoning/AIReasoning.tsx +++ b/package/src/ai-components/reasoning/AIReasoning.tsx @@ -14,8 +14,8 @@ 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 { useAIIcons } from '../icons'; 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'; @@ -183,6 +183,7 @@ export const AIReasoning = ({ }: AIReasoningProps) => { const { t, userLanguage } = useAITranslation(); const { theme } = useTheme(); + const { Chevron, Reasoning } = useAIIcons(); const { colors, messageParts } = theme; const reasoningStyle = messageParts?.reasoning; const turn = useAIThinkingTurn(); @@ -309,7 +310,7 @@ export const AIReasoning = ({ style={[styles.header, reasoningStyle?.header]} > - + {title} @@ -327,7 +328,7 @@ export const AIReasoning = ({ transform: [{ rotate: expanded ? (I18nManager.isRTL ? '-90deg' : '90deg') : '0deg' }], }} > - + {expanded ? ( diff --git a/package/src/ai-components/services/clipboard-service/expo.ts b/package/src/ai-components/services/clipboard-service/expo.ts index 4d72eee31f..7ab67ef721 100644 --- a/package/src/ai-components/services/clipboard-service/expo.ts +++ b/package/src/ai-components/services/clipboard-service/expo.ts @@ -1,4 +1,9 @@ -let Clipboard: { setString: (string: string) => void } | undefined; +let Clipboard: + | { + setString?: (string: string) => void; + setStringAsync?: (string: string) => Promise; + } + | undefined; try { Clipboard = require('expo-clipboard'); @@ -6,6 +11,15 @@ try { // do nothing } -export const setClipboardString = Clipboard - ? (string: string) => Clipboard?.setString(string) - : undefined; +// `expo-clipboard` removed the synchronous `setString` in favour of `setStringAsync`; older +// versions only have `setString`. +export const setClipboardString = + Clipboard && (Clipboard.setStringAsync || Clipboard.setString) + ? (string: string) => { + if (Clipboard?.setStringAsync) { + Clipboard.setStringAsync(string).catch(() => undefined); + } else { + Clipboard?.setString?.(string); + } + } + : undefined; diff --git a/package/src/ai-components/theme.ts b/package/src/ai-components/theme.ts index af50497239..6d5c63ff5c 100644 --- a/package/src/ai-components/theme.ts +++ b/package/src/ai-components/theme.ts @@ -1,5 +1,6 @@ import type { ImageStyle, TextStyle, ViewStyle } from 'react-native'; +import type { AIIcons } from './icons'; import type { MarkdownStyle } from './markdown'; export const colors = { @@ -117,6 +118,53 @@ export type Theme = { text?: TextStyle; }; }; + /** + * Icons of your own for the agent replies' views, by name, over `defaultAIIcons`. Kept as they + * are when themes merge. + */ + icons?: Partial; + /** + * The person's and the agent's messages, what they carry, and the actions under a reply. + * Optional, so a hand-built `Theme` without it keeps type-checking. + */ + message?: { + /** The person's bubble. */ + userMessageBackground?: string; + userMessageText?: string; + /** The icons of the actions under a reply. */ + messageAction?: string; + /** A file's card, and the tile with its type's icon. */ + attachmentBackground?: string; + attachmentBorder?: string; + attachmentIcon?: string; + attachmentIconBackground?: string; + attachmentName?: string; + attachmentDetails?: string; + /** What an image shows while it loads, or when it can't. */ + attachmentImagePlaceholder?: string; + userMessage?: { + container?: ViewStyle; + attachments?: ViewStyle; + bubble?: ViewStyle; + text?: TextStyle; + editButton?: ViewStyle; + }; + assistantMessage?: { + container?: ViewStyle; + attachments?: ViewStyle; + }; + attachment?: { + image?: ImageStyle; + file?: ViewStyle; + fileIcon?: ViewStyle; + fileName?: TextStyle; + fileDetails?: TextStyle; + }; + replyActions?: { + container?: ViewStyle; + button?: ViewStyle; + }; + }; }; export const defaultTheme: Theme = { @@ -227,4 +275,21 @@ export const defaultTheme: Theme = { }, unsupported: { container: {}, text: {} }, }, + message: { + // StreamChatAI's and the Android AI SDK's defaults, from the shared design tokens. + userMessageBackground: '#EBEEF1', + userMessageText: '#1A1B25', + messageAction: '#414552', + attachmentBackground: '#FFFFFF', + attachmentBorder: '#D5DBE1', + attachmentIcon: '#FFFFFF', + attachmentIconBackground: '#005FFF', + attachmentName: '#1A1B25', + attachmentDetails: '#414552', + attachmentImagePlaceholder: '#F6F8FA', + userMessage: { container: {}, attachments: {}, bubble: {}, text: {}, editButton: {} }, + assistantMessage: { container: {}, attachments: {} }, + attachment: { image: {}, file: {}, fileIcon: {}, fileName: {}, fileDetails: {} }, + replyActions: { container: {}, button: {} }, + }, }; diff --git a/package/src/i18n/ar.json b/package/src/i18n/ar.json index 9b5b4460c7..3c446ec8bc 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -19,6 +19,7 @@ "Are you sure?": "هل أنت متأكد؟", "Ask a question": "اطرح سؤالاً", "Audio": "صوت", + "Bad response": "رد سيئ", "Ban User": "منع المستخدم", "Block User": "حظر المستخدم", "Cancel": "إلغاء", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "فشلت عملية الإبلاغ بسبب مشكلة في الشبكة أو لأن الرسالة مبلّغ عنها بالفعل.", "Generating...": "جارٍ الإنشاء...", "Giphy": "Giphy", + "Good response": "رد جيد", "Hide who voted": "إخفاء من صوّت", "Hold to record. Release to save.": "اضغط مطولًا للتسجيل. حرّر للحفظ.", "Hold to start recording.": "اضغط مطولاً لبدء التسجيل.", "How about sending your first message to a friend?": "ما رأيك في إرسال أول رسالة إلى صديق؟", + "Image": "صورة", "Instant Commands": "الأوامر السريعة", "Let others add options": "السماح للآخرين بإضافة خيارات", "Let's start chatting!": "لنبدأ الدردشة!", @@ -124,6 +127,7 @@ "Questions": "الأسئلة", "Reconnecting...": "جارٍ إعادة الاتصال...", "Reply": "رد", + "Reply actions": "إجراءات الرد", "Reply to {{name}}": "الرد على {{name}}", "Reply to Message": "الرد على الرسالة", "Resend": "إعادة الإرسال", @@ -139,6 +143,7 @@ "Send Anyway": "أرسل على أي حال", "Send a message": "أرسل رسالة", "Sending links is not allowed in this conversation": "إرسال الروابط غير مسموح في هذه المحادثة", + "Share": "مشاركة", "Show All": "عرض الكل", "Slide to Cancel": "اسحب للإلغاء", "Slow mode ON": "الوضع البطيء قيد التشغيل", @@ -361,6 +366,8 @@ "Add": "إضافة", "Add Members": "إضافة أعضاء", "Contact Info": "معلومات جهة الاتصال", + "Copied": "تم النسخ", + "Copy": "نسخ", "Edit": "تعديل", "Files": "الملفات", "Group Info": "معلومات المجموعة", @@ -418,11 +425,13 @@ "Failed to update channel mute status": "فشل تحديث حالة كتم القناة", "Failed to update channel name": "فشل تحديث اسم القناة", "Failed to update channel pinned status": "فشل تحديث حالة تثبيت القناة", + "File": "ملف", "Left channel": "تمت مغادرة القناة", "{{count}} members added_one": "تمت إضافة {{count}} عضو", "{{count}} members added_other": "تمت إضافة {{count}} أعضاء", "{{count}} members added_many": "تمت إضافة {{count}} أعضاء", "Recording format is not supported and cannot be reproduced": "تنسيق التسجيل غير مدعوم ولا يمكن تشغيله", + "Regenerate": "إعادة الإنشاء", "Send message request failed": "فشل طلب إرسال الرسالة", "User blocked": "تم حظر المستخدم", "User unblocked": "تم إلغاء حظر المستخدم", diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index 25e1f79ca5..fbd520059a 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -19,6 +19,7 @@ "Are you sure?": "Are you sure?", "Ask a question": "Ask a question", "Audio": "Audio", + "Bad response": "Bad response", "Ban User": "Ban User", "Block User": "Block User", "Cancel": "Cancel", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "Flag action failed either due to a network issue or the message is already flagged.", "Generating...": "Generating...", "Giphy": "Giphy", + "Good response": "Good response", "Hide who voted": "Hide who voted", "Hold to record. Release to save.": "Hold to record. Release to save.", "Hold to start recording.": "Hold to start recording.", "How about sending your first message to a friend?": "How about sending your first message to a friend?", + "Image": "Image", "Instant Commands": "Instant Commands", "Let others add options": "Let others add options", "Let's start chatting!": "Let's start chatting!", @@ -149,6 +152,7 @@ "Questions": "Questions", "Reconnecting...": "Reconnecting...", "Reply": "Reply", + "Reply actions": "Reply actions", "Reply to {{name}}": "Reply to {{name}}", "Reply to Message": "Reply to Message", "Resend": "Resend", @@ -164,6 +168,7 @@ "Send Anyway": "Send Anyway", "Send a message": "Send a message", "Sending links is not allowed in this conversation": "Sending links is not allowed in this conversation", + "Share": "Share", "Show All": "Show All", "Slide to Cancel": "Slide to Cancel", "Slow mode ON": "Slow mode ON", @@ -390,6 +395,8 @@ "Add": "Add", "Add Members": "Add Members", "Contact Info": "Contact Info", + "Copied": "Copied", + "Copy": "Copy", "Edit": "Edit", "Files": "Files", "Share a file to see it here": "Share a file to see it here", @@ -455,11 +462,13 @@ "Failed to update channel mute status": "Failed to update channel mute status", "Failed to update channel name": "Failed to update channel name", "Failed to update channel pinned status": "Failed to update channel pinned status", + "File": "File", "Left channel": "Left channel", "{{count}} members added_one": "{{count}} member added", "{{count}} members added_other": "{{count}} members added", "{{count}} members added_many": "{{count}} members added", "Recording format is not supported and cannot be reproduced": "Recording format is not supported and cannot be reproduced", + "Regenerate": "Regenerate", "Send message request failed": "Send message request failed", "User blocked": "User blocked", "User unblocked": "User unblocked", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index f06559c486..e91be08534 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -19,6 +19,7 @@ "Are you sure?": "¿Estás seguro?", "Ask a question": "Hacer una pregunta", "Audio": "Audio", + "Bad response": "Mala respuesta", "Ban User": "Bloquear Usuario", "Block User": "Bloquear usuario", "Cancel": "Cancelar", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "El reporte falló debido a un problema de red o el mensaje ya fue reportado.", "Generating...": "Generando...", "Giphy": "Giphy", + "Good response": "Buena respuesta", "Hide who voted": "Ocultar quién votó", "Hold to record. Release to save.": "Mantén presionado para grabar. Suelta para guardar.", "Hold to start recording.": "Mantén presionado para comenzar a grabar.", "How about sending your first message to a friend?": "¿Qué tal enviar tu primer mensaje a un amigo?", + "Image": "Imagen", "Instant Commands": "Comandos instantáneos", "Let others add options": "Permitir que otros añadan opciones", "Let's start chatting!": "¡Empecemos a charlar!", @@ -124,6 +127,7 @@ "Questions": "Preguntas", "Reconnecting...": "Reconectando...", "Reply": "Responder", + "Reply actions": "Acciones de respuesta", "Reply to {{name}}": "Responder a {{name}}", "Reply to Message": "Responder al mensaje", "Resend": "Reenviar", @@ -139,6 +143,7 @@ "Send Anyway": "Enviar de todos modos", "Send a message": "Enviar un mensaje", "Sending links is not allowed in this conversation": "No está permitido enviar enlaces en esta conversación", + "Share": "Compartir", "Show All": "Mostrar todo", "Slide to Cancel": "Desliza para cancelar", "Slow mode ON": "Modo lento ACTIVADO", @@ -361,6 +366,8 @@ "Add": "Añadir", "Add Members": "Añadir miembros", "Contact Info": "Información de contacto", + "Copied": "Copiado", + "Copy": "Copiar", "Edit": "Editar", "Files": "Archivos", "Group Info": "Información del grupo", @@ -418,11 +425,13 @@ "Failed to update channel mute status": "No se pudo actualizar el estado de silencio del canal", "Failed to update channel name": "No se pudo actualizar el nombre del canal", "Failed to update channel pinned status": "No se pudo actualizar el estado de fijación del canal", + "File": "Archivo", "Left channel": "Saliste del canal", "{{count}} members added_one": "{{count}} miembro añadido", "{{count}} members added_other": "{{count}} miembros añadidos", "{{count}} members added_many": "{{count}} miembros añadidos", "Recording format is not supported and cannot be reproduced": "El formato de la grabación no es compatible y no se puede reproducir", + "Regenerate": "Regenerar", "Send message request failed": "No se pudo enviar el mensaje", "User blocked": "Usuario bloqueado", "User unblocked": "Usuario desbloqueado", diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index b581f9a5dc..185921066c 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -19,6 +19,7 @@ "Are you sure?": "Es-tu sûr ?", "Ask a question": "Poser une question", "Audio": "Audio", + "Bad response": "Mauvaise réponse", "Ban User": "Bannir Utilisateur", "Block User": "Bloquer un utilisateur", "Cancel": "Annuler", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "L'action de signalisation a échoué en raison d'un problème de réseau ou le message est déjà signalé.", "Generating...": "Génération...", "Giphy": "Giphy", + "Good response": "Bonne réponse", "Hide who voted": "Masquer qui a voté", "Hold to record. Release to save.": "Hold to record. Release to save.", "Hold to start recording.": "Hold to start recording.", "How about sending your first message to a friend?": "Et si vous envoyiez votre premier message à un ami ?", + "Image": "Image", "Instant Commands": "Commandes Instantanées", "Let others add options": "Autoriser d'autres à ajouter des options", "Let's start chatting!": "Commençons à discuter !", @@ -124,6 +127,7 @@ "Questions": "Questions", "Reconnecting...": "Se Reconnecter...", "Reply": "Répondre", + "Reply actions": "Actions de réponse", "Reply to {{name}}": "Répondre à {{name}}", "Reply to Message": "Répondre au message", "Resend": "Renvoyer", @@ -139,6 +143,7 @@ "Send Anyway": "Envoyer quand même", "Send a message": "Envoyer un message", "Sending links is not allowed in this conversation": "Sending links is not allowed in this conversation", + "Share": "Partager", "Show All": "Afficher tout", "Slide to Cancel": "Glisser pour annuler", "Slow mode ON": "Mode lent activé", @@ -361,6 +366,8 @@ "Add": "Ajouter", "Add Members": "Ajouter des membres", "Contact Info": "Informations de contact", + "Copied": "Copié", + "Copy": "Copier", "Edit": "Modifier", "Files": "Fichiers", "Group Info": "Informations du groupe", @@ -418,11 +425,13 @@ "Failed to update channel mute status": "Échec de la mise à jour du statut de mise en sourdine du canal", "Failed to update channel name": "Échec de la mise à jour du nom du canal", "Failed to update channel pinned status": "Échec de la mise à jour du statut d’épinglage du canal", + "File": "Fichier", "Left channel": "Canal quitté", "{{count}} members added_one": "{{count}} membre ajouté", "{{count}} members added_other": "{{count}} membres ajoutés", "{{count}} members added_many": "{{count}} membres ajoutés", "Recording format is not supported and cannot be reproduced": "Le format de l’enregistrement n’est pas pris en charge et ne peut pas être lu", + "Regenerate": "Régénérer", "Send message request failed": "Échec de l’envoi du message", "User blocked": "Utilisateur bloqué", "User unblocked": "Utilisateur débloqué", diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index ff47b395f4..280a9a1c54 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -19,6 +19,7 @@ "Are you sure?": "האם אתה בטוח?", "Ask a question": "שאל שאלה", "Audio": "אודיו", + "Bad response": "תשובה גרועה", "Ban User": "לחסום משתמש", "Block User": "חסום משתמש", "Cancel": "ביטול", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "פעולת הסימון נכשלה בגלל בעיית רשת או שההודעה כבר סומנה.", "Generating...": "מייצר...", "Giphy": "Giphy", + "Good response": "תשובה טובה", "Hide who voted": "הסתר מי הצביע", "Hold to record. Release to save.": "לחץ והחזק כדי להקליט. שחרר כדי לשמור.", "Hold to start recording.": "לחץ והחזק כדי להתחיל להקליט.", "How about sending your first message to a friend?": "מה דעתך לשלוח את ההודעה הראשונה שלך לחבר?", + "Image": "תמונה", "Instant Commands": "פעולות מיידיות", "Let others add options": "אפשר לאחרים להוסיף אפשרויות", "Let's start chatting!": "בואו נתחיל לשוחח!", @@ -124,6 +127,7 @@ "Questions": "שאלות", "Reconnecting...": "מתחבר מחדש...", "Reply": "השב/י", + "Reply actions": "פעולות לתשובה", "Reply to {{name}}": "השב/י ל-{{name}}", "Reply to Message": "השב/י להודעה", "Resend": "שלח/י שוב", @@ -139,6 +143,7 @@ "Send Anyway": "שלח בכל זאת", "Send a message": "שלח/י הודעה", "Sending links is not allowed in this conversation": "שליחת קישורים אינה מותרת בשיחה זו", + "Share": "שיתוף", "Show All": "הצג הכל", "Slide to Cancel": "גלגל/י כדי לבטל", "Slow mode ON": "מצב איטי מופעל", @@ -361,6 +366,8 @@ "Add": "הוסף", "Add Members": "הוספת חברים", "Contact Info": "פרטי איש קשר", + "Copied": "הועתק", + "Copy": "העתקה", "Edit": "ערוך", "Files": "קבצים", "Group Info": "פרטי הקבוצה", @@ -418,11 +425,13 @@ "Failed to update channel mute status": "עדכון מצב ההשתקה של השיחה נכשל", "Failed to update channel name": "עדכון שם השיחה נכשל", "Failed to update channel pinned status": "עדכון מצב הנעיצה של השיחה נכשל", + "File": "קובץ", "Left channel": "עזבת את השיחה", "{{count}} members added_one": "נוסף חבר אחד", "{{count}} members added_other": "{{count}} חברים נוספו", "{{count}} members added_many": "{{count}} חברים נוספו", "Recording format is not supported and cannot be reproduced": "פורמט ההקלטה אינו נתמך ולא ניתן להשמיע אותו", + "Regenerate": "יצירה מחדש", "Send message request failed": "שליחת ההודעה נכשלה", "User blocked": "המשתמש נחסם", "User unblocked": "חסימת המשתמש בוטלה", diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index b8a4aeb17d..faf6bff541 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -19,6 +19,7 @@ "Are you sure?": "क्या आप सुनिश्चित हैं?", "Ask a question": "एक प्रश्न पूछें", "Audio": "ऑडियो", + "Bad response": "खराब जवाब", "Ban User": "उपयोगकर्ता को प्रतिबंधित करें", "Block User": "उपयोगकर्ता को रोक देना, ब्लॉक यूजर", "Cancel": "रद्द करें", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "फ़्लैग कार्रवाई या तो नेटवर्क समस्या के कारण विफल हो गई या संदेश पहले से फ़्लैग किया गया है।", "Generating...": "जनरेट कर रहा है...", "Giphy": "Giphy", + "Good response": "अच्छा जवाब", "Hide who voted": "वोट करने वालों को छुपाएँ", "Hold to record. Release to save.": "रिकॉर्ड करने के लिए दबाकर रखें। सेव करने के लिए छोड़ें।", "Hold to start recording.": "रिकॉर्डिंग शुरू करने के लिए दबाएं।", "How about sending your first message to a friend?": "किसी मित्र को अपना पहला संदेश भेजने के बारे में क्या ख़याल है?", + "Image": "छवि", "Instant Commands": "त्वरित कमांड", "Let others add options": "दूसरों को विकल्प जोड़ने दें", "Let's start chatting!": "आइए चैट करना शुरू करें!", @@ -124,6 +127,7 @@ "Questions": "प्रश्न", "Reconnecting...": "पुनः कनेक्ट हो...", "Reply": "मैसेज को रिप्लाई करे", + "Reply actions": "जवाब के विकल्प", "Reply to {{name}}": "{{name}} को जवाब दें", "Reply to Message": "संदेश का जवाब दें", "Resend": "पुन: भेजें", @@ -139,6 +143,7 @@ "Send Anyway": "फिर भी भेजें", "Send a message": "एक संदेश भेजें", "Sending links is not allowed in this conversation": "इस बातचीत में लिंक भेजने की अनुमति नहीं है", + "Share": "साझा करें", "Show All": "सभी दिखाएं", "Slide to Cancel": "स्लाइड करके रद्द करें", "Slow mode ON": "स्लो मोड चालू", @@ -361,6 +366,8 @@ "Add": "जोड़ें", "Add Members": "सदस्य जोड़ें", "Contact Info": "संपर्क जानकारी", + "Copied": "कॉपी हो गया", + "Copy": "कॉपी करें", "Edit": "संपादित करें", "Files": "फ़ाइलें", "Group Info": "ग्रुप की जानकारी", @@ -418,11 +425,13 @@ "Failed to update channel mute status": "चैनल म्यूट स्थिति अपडेट करने में विफल", "Failed to update channel name": "चैनल का नाम अपडेट करने में विफल", "Failed to update channel pinned status": "चैनल पिन स्थिति अपडेट करने में विफल", + "File": "फ़ाइल", "Left channel": "चैनल छोड़ दिया", "{{count}} members added_one": "{{count}} सदस्य जोड़ा गया", "{{count}} members added_other": "{{count}} सदस्य जोड़े गए", "{{count}} members added_many": "{{count}} सदस्य जोड़े गए", "Recording format is not supported and cannot be reproduced": "रिकॉर्डिंग फ़ॉर्मेट समर्थित नहीं है और इसे चलाया नहीं जा सकता", + "Regenerate": "फिर से बनाएं", "Send message request failed": "संदेश भेजने का अनुरोध विफल रहा", "User blocked": "उपयोगकर्ता ब्लॉक किया गया", "User unblocked": "उपयोगकर्ता अनब्लॉक किया गया", diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index d36c823b4d..4211a5d4b0 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -19,6 +19,7 @@ "Are you sure?": "Sei sicuro?", "Ask a question": "Fai una domanda", "Audio": "Audio", + "Bad response": "Risposta negativa", "Ban User": "Blocca Utente", "Block User": "Blocca Utente", "Cancel": "Annulla", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "L'azione di segnalazione non è riuscita a causa di un problema di rete o il messaggio è già segnalato.", "Generating...": "Generando...", "Giphy": "Giphy", + "Good response": "Risposta positiva", "Hide who voted": "Nascondi chi ha votato", "Hold to record. Release to save.": "Tieni premuto per registrare. Rilascia per salvare.", "Hold to start recording.": "Tieni premuto per avviare la registrazione.", "How about sending your first message to a friend?": "Che ne dici di inviare il tuo primo messaggio ad un amico?", + "Image": "Immagine", "Instant Commands": "Comandi Istantanei", "Let others add options": "Permetti ad altri di aggiungere opzioni", "Let's start chatting!": "Iniziamo a chattare!", @@ -124,6 +127,7 @@ "Questions": "Domande", "Reconnecting...": "Ricollegarsi...", "Reply": "Rispondi", + "Reply actions": "Azioni della risposta", "Reply to {{name}}": "Rispondi a {{name}}", "Reply to Message": "Rispondi al messaggio", "Resend": "Invia di nuovo", @@ -139,6 +143,7 @@ "Send Anyway": "Invia comunque", "Send a message": "Mandare un messaggio", "Sending links is not allowed in this conversation": "L'invio di link non è consentito in questa conversazione", + "Share": "Condividi", "Show All": "Mostra tutto", "Slide to Cancel": "Scorri per annullare", "Slow mode ON": "Slowmode attiva", @@ -361,6 +366,8 @@ "Add": "Aggiungi", "Add Members": "Aggiungi membri", "Contact Info": "Informazioni di contatto", + "Copied": "Copiato", + "Copy": "Copia", "Edit": "Modifica", "Files": "File", "Group Info": "Informazioni del gruppo", @@ -418,11 +425,13 @@ "Failed to update channel mute status": "Impossibile aggiornare lo stato di silenziamento del canale", "Failed to update channel name": "Impossibile aggiornare il nome del canale", "Failed to update channel pinned status": "Impossibile aggiornare lo stato di fissaggio del canale", + "File": "File", "Left channel": "Canale lasciato", "{{count}} members added_one": "{{count}} membro aggiunto", "{{count}} members added_other": "{{count}} membri aggiunti", "{{count}} members added_many": "{{count}} membri aggiunti", "Recording format is not supported and cannot be reproduced": "Il formato della registrazione non è supportato e non può essere riprodotto", + "Regenerate": "Rigenera", "Send message request failed": "Richiesta di invio del messaggio non riuscita", "User blocked": "Utente bloccato", "User unblocked": "Utente sbloccato", diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index 4d58044a62..b8787d12f4 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -19,6 +19,7 @@ "Are you sure?": "本当によろしいですか?", "Ask a question": "質問をする", "Audio": "音声", + "Bad response": "良くない回答", "Ban User": "ユーザーを禁止する", "Block User": "ユーザをブロックする", "Cancel": "キャンセル", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "ネットワーク接続に問題があるか、すでにフラグが設定されているため、フラグが失敗しました。", "Generating...": "生成中...", "Giphy": "Giphy", + "Good response": "良い回答", "Hide who voted": "投票者を非表示", "Hold to record. Release to save.": "長押しして録音します。離すと保存します。", "Hold to start recording.": "録音を開始するには押し続けてください。", "How about sending your first message to a friend?": "初めてのメッセージを友達に送ってみてはいかがでしょうか?", + "Image": "画像", "Instant Commands": "インスタントコマンド", "Let others add options": "他の人が選択肢を追加できるようにする", "Let's start chatting!": "チャットを始めましょう!", @@ -124,6 +127,7 @@ "Questions": "質問", "Reconnecting...": "再接続中。。。", "Reply": "返事", + "Reply actions": "回答のアクション", "Reply to {{name}}": "{{name}}に返信", "Reply to Message": "メッセージに返信", "Resend": "再送", @@ -139,6 +143,7 @@ "Send Anyway": "とにかく送信", "Send a message": "メッセージを送る", "Sending links is not allowed in this conversation": "この会話ではリンク機能を使用できません。", + "Share": "共有", "Show All": "すべて表示", "Slide to Cancel": "スライドしてキャンセル", "Slow mode ON": "スローモードオン", @@ -361,6 +366,8 @@ "Add": "追加", "Add Members": "メンバーを追加", "Contact Info": "連絡先情報", + "Copied": "コピーしました", + "Copy": "コピー", "Edit": "編集", "Files": "ファイル", "Group Info": "グループ情報", @@ -418,11 +425,13 @@ "Failed to update channel mute status": "チャンネルのミュート状態の更新に失敗しました", "Failed to update channel name": "チャンネルの名前の更新に失敗しました", "Failed to update channel pinned status": "チャンネルのピン留め状態の更新に失敗しました", + "File": "ファイル", "Left channel": "チャンネルから退出しました", "{{count}} members added_one": "{{count}}人のメンバーを追加しました", "{{count}} members added_other": "{{count}}人のメンバーを追加しました", "{{count}} members added_many": "{{count}}人のメンバーを追加しました", "Recording format is not supported and cannot be reproduced": "録音形式がサポートされていないため再生できません", + "Regenerate": "再生成", "Send message request failed": "メッセージの送信リクエストに失敗しました", "User blocked": "ユーザーをブロックしました", "User unblocked": "ユーザーのブロックを解除しました", diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index d544b58369..71941fbfdd 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -19,6 +19,7 @@ "Are you sure?": "확실합니까?", "Ask a question": "질문하기", "Audio": "오디오", + "Bad response": "별로인 답변", "Ban User": "사용자 차단", "Block User": "사용자 차단", "Cancel": "취소", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "네트워크 연결에 문제가 있거나 이미 플래그 되어서 플래그에 실패했습니다.", "Generating...": "생성 중...", "Giphy": "Giphy", + "Good response": "좋은 답변", "Hide who voted": "투표한 사람 숨기기", "Hold to record. Release to save.": "녹음하려면 길게 누르세요. 저장하려면 놓으세요.", "Hold to start recording.": "녹음을 시작하려면 눌러주세요.", "How about sending your first message to a friend?": "친구에게 첫 번째 메시지를 보내는 것은 어떻습니까?", + "Image": "이미지", "Instant Commands": "인스턴트 명령", "Let others add options": "다른 사람이 옵션을 추가하도록 허용", "Let's start chatting!": "채팅을 시작합시다!", @@ -124,6 +127,7 @@ "Questions": "질문", "Reconnecting...": "다시 연결 중...", "Reply": "답장", + "Reply actions": "답변 작업", "Reply to {{name}}": "{{name}}님에게 답장", "Reply to Message": "메시지에 답장", "Resend": "재전송", @@ -139,6 +143,7 @@ "Send Anyway": "그래도 보내기", "Send a message": "메세지를 보내다", "Sending links is not allowed in this conversation": "이 대화에서는 링크 기능을 사용할 수 없습니다", + "Share": "공유", "Show All": "모두 보기", "Slide to Cancel": "슬라이드하여 취소", "Slow mode ON": "슬로모드 켜짐", @@ -361,6 +366,8 @@ "Add": "추가", "Add Members": "멤버 추가", "Contact Info": "연락처 정보", + "Copied": "복사됨", + "Copy": "복사", "Edit": "편집", "Files": "파일", "Group Info": "그룹 정보", @@ -418,11 +425,13 @@ "Failed to update channel mute status": "채널 음소거 상태 업데이트에 실패했습니다", "Failed to update channel name": "채널 이름 업데이트에 실패했습니다", "Failed to update channel pinned status": "채널 고정 상태 업데이트에 실패했습니다", + "File": "파일", "Left channel": "채널에서 나갔습니다", "{{count}} members added_one": "{{count}}명의 멤버가 추가되었습니다", "{{count}} members added_other": "{{count}}명의 멤버가 추가되었습니다", "{{count}} members added_many": "{{count}}명의 멤버가 추가되었습니다", "Recording format is not supported and cannot be reproduced": "녹음 형식이 지원되지 않아 재생할 수 없습니다", + "Regenerate": "다시 생성", "Send message request failed": "메시지 보내기 요청이 실패했습니다", "User blocked": "사용자가 차단되었습니다", "User unblocked": "사용자 차단이 해제되었습니다", diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index 5b0b929e55..c2a90c8fff 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -19,6 +19,7 @@ "Are you sure?": "Weet je het zeker?", "Ask a question": "Stel een vraag", "Audio": "Audio", + "Bad response": "Slecht antwoord", "Ban User": "Gebruiker Verbannen", "Block User": "Blokkeer Gebruiker", "Cancel": "Annuleer", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "Rapporteren mislukt door een netwerk fout of het berich is al gerapporteerd", "Generating...": "Aan het genereren...", "Giphy": "Giphy", + "Good response": "Goed antwoord", "Hide who voted": "Verberg wie heeft gestemd", "Hold to record. Release to save.": "Houd ingedrukt om op te nemen. Laat los om op te slaan.", "Hold to start recording.": "Houd vast om opname te starten.", "How about sending your first message to a friend?": "Wat dacht je ervan om je eerste bericht naar een vriend te sturen?", + "Image": "Afbeelding", "Instant Commands": "Directe Opdrachten", "Let others add options": "Laat anderen opties toevoegen", "Let's start chatting!": "Laten we beginnen met chatten!", @@ -124,6 +127,7 @@ "Questions": "Vragen", "Reconnecting...": "Opnieuw Verbinding Maken...", "Reply": "Antwoord", + "Reply actions": "Antwoordacties", "Reply to {{name}}": "Antwoord aan {{name}}", "Reply to Message": "Beantwoord bericht", "Resend": "Opnieuw versturen", @@ -139,6 +143,7 @@ "Send Anyway": "Toch verzenden", "Send a message": "Stuur een bericht", "Sending links is not allowed in this conversation": "In dit gesprek is het niet toegestaan links te versturen", + "Share": "Delen", "Show All": "Alles weergeven", "Slide to Cancel": "Slide om te annuleren", "Slow mode ON": "Langzame modus aan", @@ -361,6 +366,8 @@ "Add": "Toevoegen", "Add Members": "Leden toevoegen", "Contact Info": "Contactgegevens", + "Copied": "Gekopieerd", + "Copy": "Kopiëren", "Edit": "Bewerken", "Files": "Bestanden", "Group Info": "Groepsinformatie", @@ -418,11 +425,13 @@ "Failed to update channel mute status": "Bijwerken van dempstatus van kanaal mislukt", "Failed to update channel name": "Bijwerken van kanaalnaam mislukt", "Failed to update channel pinned status": "Bijwerken van vastzetstatus van kanaal mislukt", + "File": "Bestand", "Left channel": "Kanaal verlaten", "{{count}} members added_one": "{{count}} lid toegevoegd", "{{count}} members added_other": "{{count}} leden toegevoegd", "{{count}} members added_many": "{{count}} leden toegevoegd", "Recording format is not supported and cannot be reproduced": "Opnameformaat wordt niet ondersteund en kan niet worden afgespeeld", + "Regenerate": "Opnieuw genereren", "Send message request failed": "Verzoek om bericht te verzenden mislukt", "User blocked": "Gebruiker geblokkeerd", "User unblocked": "Gebruiker gedeblokkeerd", diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index a4c61e8378..3fdfe96866 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -19,6 +19,7 @@ "Are you sure?": "Tem certeza?", "Ask a question": "Fazer uma pergunta", "Audio": "Áudio", + "Bad response": "Resposta ruim", "Ban User": "Banir Usuário", "Block User": "Bloquear Usuário", "Cancel": "Cancelar", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "A ação para reportar a mensagem falhou devido a um problema de rede ou a mensagem já foi reportada.", "Generating...": "Gerando...", "Giphy": "Giphy", + "Good response": "Boa resposta", "Hide who voted": "Ocultar quem votou", "Hold to record. Release to save.": "Mantenha pressionado para gravar. Solte para salvar.", "Hold to start recording.": "Mantenha pressionado para começar a gravar.", "How about sending your first message to a friend?": "Que tal enviar sua primeira mensagem para um amigo?", + "Image": "Imagem", "Instant Commands": "Comandos Instantâneos", "Let others add options": "Permitir que outros adicionem opções", "Let's start chatting!": "Vamos começar a conversar!", @@ -124,6 +127,7 @@ "Questions": "Perguntas", "Reconnecting...": "Reconectando...", "Reply": "Responder", + "Reply actions": "Ações da resposta", "Reply to {{name}}": "Responder a {{name}}", "Reply to Message": "Responder à Mensagem", "Resend": "Reenviar", @@ -139,6 +143,7 @@ "Send Anyway": "Enviar de qualquer maneira", "Send a message": "Enviar uma mensagem", "Sending links is not allowed in this conversation": "Não é permitido enviar links nesta conversa", + "Share": "Compartilhar", "Show All": "Mostrar tudo", "Slide to Cancel": "Deslize para cancelar", "Slow mode ON": "Modo Lento ATIVADO", @@ -361,6 +366,8 @@ "Add": "Adicionar", "Add Members": "Adicionar membros", "Contact Info": "Informações de contato", + "Copied": "Copiado", + "Copy": "Copiar", "Edit": "Editar", "Files": "Arquivos", "Group Info": "Informações do grupo", @@ -418,11 +425,13 @@ "Failed to update channel mute status": "Falha ao atualizar o status de silenciamento do canal", "Failed to update channel name": "Falha ao atualizar o nome do canal", "Failed to update channel pinned status": "Falha ao atualizar o status de fixação do canal", + "File": "Arquivo", "Left channel": "Você saiu do canal", "{{count}} members added_one": "{{count}} membro adicionado", "{{count}} members added_other": "{{count}} membros adicionados", "{{count}} members added_many": "{{count}} membros adicionados", "Recording format is not supported and cannot be reproduced": "O formato da gravação não é compatível e não pode ser reproduzido", + "Regenerate": "Gerar novamente", "Send message request failed": "Falha na solicitação de envio da mensagem", "User blocked": "Usuário bloqueado", "User unblocked": "Usuário desbloqueado", diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index fc22da6e13..4248c503f1 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -19,6 +19,7 @@ "Are you sure?": "Вы уверены?", "Ask a question": "Задайте вопрос", "Audio": "Аудио", + "Bad response": "Плохой ответ", "Ban User": "Заблокировать Пользователя", "Block User": "Заблокировать пользователя", "Cancel": "Отмена", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "Не удалось отправить жалобу. Возможные причины: проблема с подключением к интернету или ваша жалоба уже была принята.", "Generating...": "Генерирую...", "Giphy": "Giphy", + "Good response": "Хороший ответ", "Hide who voted": "Скрыть, кто проголосовал", "Hold to record. Release to save.": "Удерживайте, чтобы записать. Отпустите, чтобы сохранить.", "Hold to start recording.": "Удерживайте, чтобы начать запись.", "How about sending your first message to a friend?": "Как насчет отправки первого сообщения другу?", + "Image": "Изображение", "Instant Commands": "Мгновенные Команды", "Let others add options": "Разрешить другим добавлять варианты", "Let's start chatting!": "Давайте начнем общаться!", @@ -124,6 +127,7 @@ "Questions": "Вопросы", "Reconnecting...": "Переподключение...", "Reply": "Ответить", + "Reply actions": "Действия с ответом", "Reply to {{name}}": "Ответить пользователю {{name}}", "Reply to Message": "Ответить на сообщение", "Resend": "Отправить", @@ -139,6 +143,7 @@ "Send Anyway": "Всё равно отправить", "Send a message": "Отправить сообщение", "Sending links is not allowed in this conversation": "Отправка ссылок недоступна в этом чате", + "Share": "Поделиться", "Show All": "Показать все", "Slide to Cancel": "Слайд для отмены", "Slow mode ON": "Медленный режим включен", @@ -361,6 +366,8 @@ "Add": "Добавить", "Add Members": "Добавить участников", "Contact Info": "Контактная информация", + "Copied": "Скопировано", + "Copy": "Копировать", "Edit": "Изменить", "Files": "Файлы", "Group Info": "Информация о группе", @@ -418,11 +425,13 @@ "Failed to update channel mute status": "Не удалось обновить статус уведомлений канала", "Failed to update channel name": "Не удалось обновить имя канала", "Failed to update channel pinned status": "Не удалось обновить статус закрепления канала", + "File": "Файл", "Left channel": "Вы покинули канал", "{{count}} members added_one": "Добавлен {{count}} участник", "{{count}} members added_other": "Добавлено {{count}} участника", "{{count}} members added_many": "Добавлено {{count}} участников", "Recording format is not supported and cannot be reproduced": "Формат записи не поддерживается, ее невозможно воспроизвести", + "Regenerate": "Сгенерировать заново", "Send message request failed": "Не удалось отправить сообщение", "User blocked": "Пользователь заблокирован", "User unblocked": "Пользователь разблокирован", diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index ee45cf536e..f93e8667fc 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -19,6 +19,7 @@ "Are you sure?": "Emin misiniz?", "Ask a question": "Bir soru sor", "Audio": "Ses", + "Bad response": "Kötü yanıt", "Ban User": "Kullanıcıyı Yasakla", "Block User": "Kullanıcıyı engelle", "Cancel": "İptal", @@ -67,10 +68,12 @@ "Flag action failed either due to a network issue or the message is already flagged": "Mesajın daha önce raporlanmış olması veya bir ağ bağlantısı sorunu nedeniyle raporlama işlemi başarısız oldu.", "Generating...": "Oluşturuluyor...", "Giphy": "Giphy", + "Good response": "İyi yanıt", "Hide who voted": "Kimin oy verdiğini gizle", "Hold to record. Release to save.": "Kayıt yapmak için basılı tutun. Kaydetmek için bırakın.", "Hold to start recording.": "Kayıt yapmak için basılı tutun.", "How about sending your first message to a friend?": "İlk mesajınızı bir arkadaşınıza göndermeye ne dersiniz?", + "Image": "Görsel", "Instant Commands": "Anlık Komutlar", "Let others add options": "Başkalarının seçenek eklemesine izin ver", "Let's start chatting!": "Haydi sohbete başlayalım!", @@ -124,6 +127,7 @@ "Questions": "Sorular", "Reconnecting...": "Yeniden Bağlanılıyor...", "Reply": "Yanıtla", + "Reply actions": "Yanıt işlemleri", "Reply to {{name}}": "{{name}} için yanıtla", "Reply to Message": "Mesajı Yanıtla", "Resend": "Yeniden gönder", @@ -139,6 +143,7 @@ "Send Anyway": "Yine de Gönder", "Send a message": "Mesaj gönder", "Sending links is not allowed in this conversation": "Bu konuşmada bağlantı göndermek desteklenmiyor", + "Share": "Paylaş", "Show All": "Hepsini göster", "Slide to Cancel": "Silmek için kaydırın", "Slow mode ON": "Yavaş Mod Açık", @@ -361,6 +366,8 @@ "Add": "Ekle", "Add Members": "Üye ekle", "Contact Info": "İletişim bilgileri", + "Copied": "Kopyalandı", + "Copy": "Kopyala", "Edit": "Düzenle", "Files": "Dosyalar", "Group Info": "Grup bilgileri", @@ -418,11 +425,13 @@ "Failed to update channel mute status": "Kanal sessize alma durumu güncellenemedi", "Failed to update channel name": "Kanal adı güncellenemedi", "Failed to update channel pinned status": "Kanal sabitleme durumu güncellenemedi", + "File": "Dosya", "Left channel": "Kanaldan çıkıldı", "{{count}} members added_one": "{{count}} üye eklendi", "{{count}} members added_other": "{{count}} üye eklendi", "{{count}} members added_many": "{{count}} üye eklendi", "Recording format is not supported and cannot be reproduced": "Kayıt biçimi desteklenmiyor ve oynatılamıyor", + "Regenerate": "Yeniden oluştur", "Send message request failed": "Mesaj gönderme isteği başarısız oldu", "User blocked": "Kullanıcı engellendi", "User unblocked": "Kullanıcının engeli kaldırıldı",