From 82cbc58468cbe0dd3ff271e2dd47e3f5241329b7 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 3 Nov 2026 22:59:08 +0100 Subject: [PATCH] feat(ai-components): native AIAgent module for install id and tool-call sign-in --- examples/ExpoChatGPTSample/app.json | 2 +- .../ExpoChatGPTSample/app/+native-intent.tsx | 5 + .../ExpoChatGPTSample/contexts/AppContext.tsx | 37 ++- .../ExpoChatGPTSample/screens/ChatContent.tsx | 20 +- package/bin/validate-entry-isolation.js | 4 + package/expo-package/.gitignore | 1 + package/expo-package/android/build.gradle | 24 ++ .../android/src/main/AndroidManifest.xml | 8 + .../streamchatexpo/StreamChatExpoPackage.java | 16 + .../com/streamchatexpo/AIAgentModule.kt | 37 +++ package/expo-package/package.json | 1 + .../expo-package/src/native/NativeAIAgent.ts | 25 ++ package/expo-package/stream-chat-expo.podspec | 5 +- package/native-package/.gitignore | 1 + package/native-package/android/build.gradle | 24 ++ .../android/src/main/AndroidManifest.xml | 10 +- .../StreamChatReactNativePackage.java | 17 ++ .../streamchatreactnative/AIAgentModule.kt | 37 +++ package/native-package/package.json | 1 + .../src/native/NativeAIAgent.ts | 25 ++ .../stream-chat-react-native.podspec | 5 +- package/scripts/clean-shared-native-copies.sh | 3 +- package/scripts/sync-shared-native.sh | 10 + .../android-authtab/AgentAuthTab.kt | 24 ++ .../android-customtabs/AgentAuthTab.kt | 20 ++ .../android/agent/AIAgentController.kt | 173 +++++++++++ package/shared-native/ios/AIAgent.h | 16 + package/shared-native/ios/AIAgent.mm | 65 ++++ package/shared-native/ios/AIAgentModule.swift | 84 ++++++ .../ai-components/__tests__/signIn.test.tsx | 279 ++++++++++++++++++ .../message-parts/AIMessageParts.tsx | 4 +- .../message-parts/AIToolAuthorization.tsx | 266 +++++++++++++++++ .../src/ai-components/message-parts/index.ts | 1 + .../native-specs/NativeAIAgent.ts | 25 ++ package/src/ai-components/theme.ts | 22 ++ .../tool-calls/AIClientIdentity.ts | 33 +++ .../tool-calls/authSessionAuthorizer.ts | 125 ++++++++ package/src/ai-components/tool-calls/index.ts | 2 + package/src/i18n/ar.json | 8 + package/src/i18n/en.json | 8 + package/src/i18n/es.json | 8 + package/src/i18n/fr.json | 8 + package/src/i18n/he.json | 8 + package/src/i18n/hi.json | 8 + package/src/i18n/it.json | 8 + package/src/i18n/ja.json | 8 + package/src/i18n/ko.json | 8 + package/src/i18n/nl.json | 8 + package/src/i18n/pt-br.json | 8 + package/src/i18n/ru.json | 8 + package/src/i18n/tr.json | 8 + 51 files changed, 1545 insertions(+), 16 deletions(-) create mode 100644 examples/ExpoChatGPTSample/app/+native-intent.tsx create mode 100644 package/expo-package/android/src/newarch/com/streamchatexpo/AIAgentModule.kt create mode 100644 package/expo-package/src/native/NativeAIAgent.ts create mode 100644 package/native-package/android/src/newarch/com/streamchatreactnative/AIAgentModule.kt create mode 100644 package/native-package/src/native/NativeAIAgent.ts create mode 100644 package/shared-native/android-authtab/AgentAuthTab.kt create mode 100644 package/shared-native/android-customtabs/AgentAuthTab.kt create mode 100644 package/shared-native/android/agent/AIAgentController.kt create mode 100644 package/shared-native/ios/AIAgent.h create mode 100644 package/shared-native/ios/AIAgent.mm create mode 100644 package/shared-native/ios/AIAgentModule.swift create mode 100644 package/src/ai-components/__tests__/signIn.test.tsx create mode 100644 package/src/ai-components/message-parts/AIToolAuthorization.tsx create mode 100644 package/src/ai-components/native-specs/NativeAIAgent.ts create mode 100644 package/src/ai-components/tool-calls/AIClientIdentity.ts create mode 100644 package/src/ai-components/tool-calls/authSessionAuthorizer.ts diff --git a/examples/ExpoChatGPTSample/app.json b/examples/ExpoChatGPTSample/app.json index 7310f41c67..6607bf3c99 100644 --- a/examples/ExpoChatGPTSample/app.json +++ b/examples/ExpoChatGPTSample/app.json @@ -2,7 +2,7 @@ "expo": { "name": "ExpoChatGPTSample", "slug": "ExpoChatGPTSample", - "scheme": "expochatgptsample", + "scheme": ["expochatgptsample", "io.getstream.expochatgptsample"], "ios": { "bundleIdentifier": "io.getstream.expochatgptsample", "appleTeamId": "EHV7XZLAHA" diff --git a/examples/ExpoChatGPTSample/app/+native-intent.tsx b/examples/ExpoChatGPTSample/app/+native-intent.tsx new file mode 100644 index 0000000000..a54ac3b988 --- /dev/null +++ b/examples/ExpoChatGPTSample/app/+native-intent.tsx @@ -0,0 +1,5 @@ +// A sign-in opened in a Custom Tab comes back to the app on its scheme (`io.getstream.expochatgptsample:// +// sign-in?…`), which the SDK's sign-in reads itself. Stay on the screen it was opened from rather +// than routing to a page that doesn't exist. +export const redirectSystemPath = ({ path }: { initial: boolean; path: string | null }) => + path?.includes('sign-in') ? null : path; diff --git a/examples/ExpoChatGPTSample/contexts/AppContext.tsx b/examples/ExpoChatGPTSample/contexts/AppContext.tsx index 14ee62f117..73636148ae 100644 --- a/examples/ExpoChatGPTSample/contexts/AppContext.tsx +++ b/examples/ExpoChatGPTSample/contexts/AppContext.tsx @@ -9,7 +9,11 @@ import { } 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 { + AIClientIdentity, + type AIToolApprover, + createAuthSessionAuthorizer, +} from 'stream-chat-expo/ai-components'; import { agents, agentSessions, streamToken } from '../agent/agents'; import { AgentConfig } from '../agent/config'; @@ -18,8 +22,20 @@ type Conversation = { channel: Channel; reasoning: LiveReasoning; session: Sessi 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()}`; +// This install, for the tool calls addressed to it. +const clientId = AIClientIdentity.installId(); + +// Where a sign-in comes back to: the app's own scheme, which the web auth session hands back. +// The router takes only https or a native app's own scheme in reverse domain form (RFC 8252). +const SIGN_IN_SCHEME = 'io.getstream.expochatgptsample'; +const SIGN_IN_RETURN_URL = `${SIGN_IN_SCHEME}://sign-in`; + +// The person's "not now" on a sign-in. calypso 0.2.3 has no `declineLogin` yet, so this sends +// the frame Vision-Agents' JS SDK sends for it over the session's socket. +const declineLogin = (session: Session, toolCallId: string) => + (session as unknown as { held(): { send(frame: Record): void } }) + .held() + .send({ allowed: false, tool_call_id: toolCallId, type: 'tool_approval' }); export type AppContextValue = { /** What the agent is doing in the open conversation, from the session's socket. */ @@ -95,7 +111,12 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St }); const startConversation = useStableCallback(() => { - void open(() => agentSessions().create({ modelOverwrites: { llm: AgentConfig.chatModel } })); + void open(() => + agentSessions().create({ + modelOverwrites: { llm: AgentConfig.chatModel }, + return_url: SIGN_IN_RETURN_URL, + }), + ); }); const openConversation = useStableCallback((sessionId: string) => { @@ -160,6 +181,14 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St () => conversation ? { + authorizer: createAuthSessionAuthorizer({ + callbackScheme: SIGN_IN_SCHEME, + decline: (call) => + new Promise((resolve) => { + declineLogin(conversation.session, call.id); + resolve(); + }), + }), clientId, // `decide` sends over the session's socket and throws when it can't. decide: (call, allowed) => diff --git a/examples/ExpoChatGPTSample/screens/ChatContent.tsx b/examples/ExpoChatGPTSample/screens/ChatContent.tsx index 6e6b978571..722e24721b 100644 --- a/examples/ExpoChatGPTSample/screens/ChatContent.tsx +++ b/examples/ExpoChatGPTSample/screens/ChatContent.tsx @@ -94,19 +94,27 @@ 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 -// show in the reply's own view, so they're left out here; any other unknown attachment keeps the -// SDK's own view. +// 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) ? null : ; + 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 } = useMessageContext(); - // Read so the steps render again as more of their reasoning arrives. - const { reasoningText, reasoningVersion } = useAppContext(); + const { message: listed } = useMessageContext(); + // Read so the steps render again as each live update arrives. + const { channel, 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 stepText = useMemo( () => (stepId: string) => reasoningText(message.id, stepId), diff --git a/package/bin/validate-entry-isolation.js b/package/bin/validate-entry-isolation.js index adcd0d0047..a345754a16 100644 --- a/package/bin/validate-entry-isolation.js +++ b/package/bin/validate-entry-isolation.js @@ -208,6 +208,10 @@ const copies = [ path.join(AI_SRC, 'native-specs/NativeAIDictation.ts'), ...WRAPPERS.map((w) => path.join(w, 'src/native/NativeAIDictation.ts')), ], + [ + path.join(AI_SRC, 'native-specs/NativeAIAgent.ts'), + ...WRAPPERS.map((w) => path.join(w, 'src/native/NativeAIAgent.ts')), + ], WRAPPERS.map((w) => path.join(w, 'src/native/PerfTextNativeComponent.ts')), ]; for (const [source, ...targets] of copies) { diff --git a/package/expo-package/.gitignore b/package/expo-package/.gitignore index 81e0235dce..c70b7e72ca 100644 --- a/package/expo-package/.gitignore +++ b/package/expo-package/.gitignore @@ -1,6 +1,7 @@ # android android/build android/src/main/java/com/streamchatreactnative/shared +android/src/agent-sign-in # ios ios/build diff --git a/package/expo-package/android/build.gradle b/package/expo-package/android/build.gradle index 5e231ac5dc..a63828b6cd 100644 --- a/package/expo-package/android/build.gradle +++ b/package/expo-package/android/build.gradle @@ -42,6 +42,26 @@ def hasNativeSources = { File dir -> dir.exists() && !fileTree(dir).matching { include "**/*.kt"; include "**/*.java" }.files.isEmpty() } +// The agent components' sign-ins open in the browser's Auth Tab, which needs androidx.browser +// 1.9.0, and that needs the app to build with compileSdk 36 and AGP 8.9.1. An app below either +// gets 1.8.0 and opens them in a Custom Tab instead, so the SDK asks neither of anyone. +def versionAtLeast = { String version, List wanted -> + def parts = version.tokenize('.-').collect { it.replaceAll(/\D.*$/, '') }.findAll { it }.collect { it.toInteger() } + for (int i = 0; i < wanted.size(); i++) { + def part = i < parts.size() ? parts[i] : 0 + if (part != wanted[i]) return part > wanted[i] + } + return true +} +def agentSignInWithAuthTab = + getExtOrIntegerDefault("compileSdkVersion").toString().toInteger() >= 36 && + versionAtLeast(com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION, [8, 9, 1]) +def agentSignInVariant = agentSignInWithAuthTab ? "authtab" : "customtabs" +def sharedAgentSignInDir = new File(canonicalProjectDir, "../../shared-native/android-${agentSignInVariant}") +def agentSignInDir = hasNativeSources(sharedAgentSignInDir) + ? sharedAgentSignInDir + : new File(projectDir, "src/agent-sign-in/${agentSignInVariant}") + android { compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION @@ -82,6 +102,7 @@ android { java.srcDirs += [ "src/newarch", "src/main/java/com/streamchatreactnative/shared", + agentSignInDir, "${project.buildDir}/generated/source/codegen/java" ] } @@ -140,6 +161,9 @@ repositories { dependencies { implementation "com.facebook.react:react-native:+" + // The agent components' tool-call sign-ins: an Auth Tab where the app's build allows 1.9.0, + // else a Custom Tab (see `agentSignInWithAuthTab`). + implementation "androidx.browser:browser:${agentSignInWithAuthTab ? '1.9.0' : '1.8.0'}" } if (isNewArchitectureEnabled()) { diff --git a/package/expo-package/android/src/main/AndroidManifest.xml b/package/expo-package/android/src/main/AndroidManifest.xml index 17df9bf89c..b71eebea63 100644 --- a/package/expo-package/android/src/main/AndroidManifest.xml +++ b/package/expo-package/android/src/main/AndroidManifest.xml @@ -1,4 +1,12 @@ + + + + + + + diff --git a/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java b/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java index 4442d860eb..9336dd4546 100644 --- a/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java +++ b/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java @@ -18,6 +18,7 @@ public class StreamChatExpoPackage extends TurboReactPackage { private static final String STREAM_MULTIPART_UPLOADER_MODULE = "StreamMultipartUploader"; private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail"; private static final String AI_DICTATION_MODULE = "AIDictation"; + private static final String AI_AGENT_MODULE = "AIAgent"; @Nullable @Override @@ -34,6 +35,10 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext) return createNewArchModule("com.streamchatexpo.AIDictationModule", reactContext); } + if (name.equals(AI_AGENT_MODULE)) { + return createNewArchModule("com.streamchatexpo.AIAgentModule", reactContext); + } + return null; } @@ -75,6 +80,17 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // isCxxModule true // isTurboModule )); + moduleInfos.put( + AI_AGENT_MODULE, + new ReactModuleInfo( + AI_AGENT_MODULE, + AI_AGENT_MODULE, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + true // isTurboModule + )); return moduleInfos; }; } diff --git a/package/expo-package/android/src/newarch/com/streamchatexpo/AIAgentModule.kt b/package/expo-package/android/src/newarch/com/streamchatexpo/AIAgentModule.kt new file mode 100644 index 0000000000..3a3490e723 --- /dev/null +++ b/package/expo-package/android/src/newarch/com/streamchatexpo/AIAgentModule.kt @@ -0,0 +1,37 @@ +package com.streamchatexpo + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.streamchatreactnative.shared.agent.AIAgentController + +/** + * The `AIAgent` TurboModule used by the `ai-components` agent views: this install's identifier + * and the sign-ins of tool calls. The implementation is shared with the other SDK package; see + * [AIAgentController]. + */ +class AIAgentModule( + reactContext: ReactApplicationContext, +) : NativeAIAgentSpec(reactContext) { + private val controller = AIAgentController(reactContext) + + override fun getName(): String = NAME + + override fun initialize() { + super.initialize() + controller.initialize() + } + + override fun invalidate() { + super.invalidate() + controller.invalidate() + } + + override fun installId(): String = controller.installId() + + override fun openAuthSession(url: String, callbackScheme: String, promise: Promise) = + controller.openAuthSession(url, callbackScheme, promise) + + companion object { + const val NAME = AIAgentController.NAME + } +} diff --git a/package/expo-package/package.json b/package/expo-package/package.json index 97c9d13155..fdd009e843 100644 --- a/package/expo-package/package.json +++ b/package/expo-package/package.json @@ -111,6 +111,7 @@ }, "ios": { "modulesProvider": { + "AIAgent": "AIAgent", "StreamMultipartUploader": "StreamMultipartUploader", "StreamVideoThumbnail": "StreamVideoThumbnail" }, diff --git a/package/expo-package/src/native/NativeAIAgent.ts b/package/expo-package/src/native/NativeAIAgent.ts new file mode 100644 index 0000000000..7e22ca992e --- /dev/null +++ b/package/expo-package/src/native/NativeAIAgent.ts @@ -0,0 +1,25 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +/** + * Codegen spec of the native module behind the agent components: this install's identifier and + * the sign-ins of tool calls. + * + * This file is duplicated (byte for byte) in `package/native-package/src/native/NativeAIAgent.ts` + * and `package/expo-package/src/native/NativeAIAgent.ts`, which are the copies codegen reads. + * `yarn validate-entry-isolation` fails if they drift apart. + */ +export interface Spec extends TurboModule { + /** This install's identifier, created on first use and kept in storage that isn't backed up. */ + installId(): string; + /** + * Opens `url` in the platform's web authentication session and resolves with the URL the page + * handed back to `callbackScheme`, or an empty string when the person closed it. Rejects when + * the session couldn't be opened. + */ + openAuthSession(url: string, callbackScheme: string): Promise; +} + +// `get` rather than `getEnforcing`, so importing the AI entry point never throws in an app built +// without the module, such as one on an older version of the SDK's native code. +export default TurboModuleRegistry.get('AIAgent'); diff --git a/package/expo-package/stream-chat-expo.podspec b/package/expo-package/stream-chat-expo.podspec index ead4ddac26..b9e842bef4 100644 --- a/package/expo-package/stream-chat-expo.podspec +++ b/package/expo-package/stream-chat-expo.podspec @@ -42,9 +42,12 @@ Pod::Spec.new do |s| false end + # `AIAgent` (the agent components' install id and tool-call sign-ins) opens sign-ins in + # `ASWebAuthenticationSession`, which needs no usage description, so it is always compiled. if dictation_enabled - s.frameworks = "Speech" + s.frameworks = ["AuthenticationServices", "Speech"] else + s.frameworks = "AuthenticationServices" s.exclude_files = ["ios/shared/AIDictation.h", "ios/shared/AIDictation.mm", "ios/shared/AIDictationModule.swift"] end diff --git a/package/native-package/.gitignore b/package/native-package/.gitignore index 81e0235dce..c70b7e72ca 100644 --- a/package/native-package/.gitignore +++ b/package/native-package/.gitignore @@ -1,6 +1,7 @@ # android android/build android/src/main/java/com/streamchatreactnative/shared +android/src/agent-sign-in # ios ios/build diff --git a/package/native-package/android/build.gradle b/package/native-package/android/build.gradle index 11d3af6763..187eef47e3 100644 --- a/package/native-package/android/build.gradle +++ b/package/native-package/android/build.gradle @@ -49,6 +49,26 @@ def hasNativeSources = { File dir -> dir.exists() && !fileTree(dir).matching { include "**/*.kt"; include "**/*.java" }.files.isEmpty() } +// The agent components' sign-ins open in the browser's Auth Tab, which needs androidx.browser +// 1.9.0, and that needs the app to build with compileSdk 36 and AGP 8.9.1. An app below either +// gets 1.8.0 and opens them in a Custom Tab instead, so the SDK asks neither of anyone. +def versionAtLeast = { String version, List wanted -> + def parts = version.tokenize('.-').collect { it.replaceAll(/\D.*$/, '') }.findAll { it }.collect { it.toInteger() } + for (int i = 0; i < wanted.size(); i++) { + def part = i < parts.size() ? parts[i] : 0 + if (part != wanted[i]) return part > wanted[i] + } + return true +} +def agentSignInWithAuthTab = + getExtOrIntegerDefault("compileSdkVersion").toString().toInteger() >= 36 && + versionAtLeast(com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION, [8, 9, 1]) +def agentSignInVariant = agentSignInWithAuthTab ? "authtab" : "customtabs" +def sharedAgentSignInDir = new File(canonicalProjectDir, "../../shared-native/android-${agentSignInVariant}") +def agentSignInDir = hasNativeSources(sharedAgentSignInDir) + ? sharedAgentSignInDir + : new File(projectDir, "src/agent-sign-in/${agentSignInVariant}") + android { compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION @@ -92,6 +112,7 @@ android { java.srcDirs += [ "src/newarch", "src/main/java/com/streamchatreactnative/shared", + agentSignInDir, // This is needed to build Kotlin project with NewArch enabled "${project.buildDir}/generated/source/codegen/java" ] @@ -158,6 +179,9 @@ dependencies { //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" implementation "androidx.exifinterface:exifinterface:1.3.2" + // The agent components' tool-call sign-ins: an Auth Tab where the app's build allows 1.9.0, + // else a Custom Tab (see `agentSignInWithAuthTab`). + implementation "androidx.browser:browser:${agentSignInWithAuthTab ? '1.9.0' : '1.8.0'}" } if (isNewArchitectureEnabled()) { diff --git a/package/native-package/android/src/main/AndroidManifest.xml b/package/native-package/android/src/main/AndroidManifest.xml index 95bb2ff52c..df471ba82c 100644 --- a/package/native-package/android/src/main/AndroidManifest.xml +++ b/package/native-package/android/src/main/AndroidManifest.xml @@ -1,4 +1,12 @@ - \ No newline at end of file + + + + + + + + diff --git a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java index 0a07a2c2b4..aa8e7be391 100644 --- a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java +++ b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java @@ -17,6 +17,7 @@ public class StreamChatReactNativePackage extends TurboReactPackage { private static final String STREAM_MULTIPART_UPLOADER_MODULE = "StreamMultipartUploader"; private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail"; private static final String AI_DICTATION_MODULE = "AIDictation"; + private static final String AI_AGENT_MODULE = "AIAgent"; @Nullable @Override @@ -38,6 +39,11 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext) "com.streamchatreactnative.AIDictationModule", reactContext ); + } else if (name.equals(AI_AGENT_MODULE)) { + return createNewArchModule( + "com.streamchatreactnative.AIAgentModule", + reactContext + ); } else { return null; } @@ -92,6 +98,17 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // isCxxModule true // isTurboModule )); + moduleInfos.put( + AI_AGENT_MODULE, + new ReactModuleInfo( + AI_AGENT_MODULE, + AI_AGENT_MODULE, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + true // isTurboModule + )); return moduleInfos; }; } diff --git a/package/native-package/android/src/newarch/com/streamchatreactnative/AIAgentModule.kt b/package/native-package/android/src/newarch/com/streamchatreactnative/AIAgentModule.kt new file mode 100644 index 0000000000..35ac1c99d6 --- /dev/null +++ b/package/native-package/android/src/newarch/com/streamchatreactnative/AIAgentModule.kt @@ -0,0 +1,37 @@ +package com.streamchatreactnative + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.streamchatreactnative.shared.agent.AIAgentController + +/** + * The `AIAgent` TurboModule used by the `ai-components` agent views: this install's identifier + * and the sign-ins of tool calls. The implementation is shared with the other SDK package; see + * [AIAgentController]. + */ +class AIAgentModule( + reactContext: ReactApplicationContext, +) : NativeAIAgentSpec(reactContext) { + private val controller = AIAgentController(reactContext) + + override fun getName(): String = NAME + + override fun initialize() { + super.initialize() + controller.initialize() + } + + override fun invalidate() { + super.invalidate() + controller.invalidate() + } + + override fun installId(): String = controller.installId() + + override fun openAuthSession(url: String, callbackScheme: String, promise: Promise) = + controller.openAuthSession(url, callbackScheme, promise) + + companion object { + const val NAME = AIAgentController.NAME + } +} diff --git a/package/native-package/package.json b/package/native-package/package.json index 5626004ee8..86e0f97a59 100644 --- a/package/native-package/package.json +++ b/package/native-package/package.json @@ -105,6 +105,7 @@ }, "ios": { "modulesProvider": { + "AIAgent": "AIAgent", "StreamChatReactNative": "StreamChatReactNative", "StreamMultipartUploader": "StreamMultipartUploader", "StreamVideoThumbnail": "StreamVideoThumbnail" diff --git a/package/native-package/src/native/NativeAIAgent.ts b/package/native-package/src/native/NativeAIAgent.ts new file mode 100644 index 0000000000..7e22ca992e --- /dev/null +++ b/package/native-package/src/native/NativeAIAgent.ts @@ -0,0 +1,25 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +/** + * Codegen spec of the native module behind the agent components: this install's identifier and + * the sign-ins of tool calls. + * + * This file is duplicated (byte for byte) in `package/native-package/src/native/NativeAIAgent.ts` + * and `package/expo-package/src/native/NativeAIAgent.ts`, which are the copies codegen reads. + * `yarn validate-entry-isolation` fails if they drift apart. + */ +export interface Spec extends TurboModule { + /** This install's identifier, created on first use and kept in storage that isn't backed up. */ + installId(): string; + /** + * Opens `url` in the platform's web authentication session and resolves with the URL the page + * handed back to `callbackScheme`, or an empty string when the person closed it. Rejects when + * the session couldn't be opened. + */ + openAuthSession(url: string, callbackScheme: string): Promise; +} + +// `get` rather than `getEnforcing`, so importing the AI entry point never throws in an app built +// without the module, such as one on an older version of the SDK's native code. +export default TurboModuleRegistry.get('AIAgent'); diff --git a/package/native-package/stream-chat-react-native.podspec b/package/native-package/stream-chat-react-native.podspec index aef09c54f1..c584dcd81e 100644 --- a/package/native-package/stream-chat-react-native.podspec +++ b/package/native-package/stream-chat-react-native.podspec @@ -42,9 +42,12 @@ Pod::Spec.new do |s| false end + # `AIAgent` (the agent components' install id and tool-call sign-ins) opens sign-ins in + # `ASWebAuthenticationSession`, which needs no usage description, so it is always compiled. if dictation_enabled - s.frameworks = "Speech" + s.frameworks = ["AuthenticationServices", "Speech"] else + s.frameworks = "AuthenticationServices" s.exclude_files = ["ios/shared/AIDictation.h", "ios/shared/AIDictation.mm", "ios/shared/AIDictationModule.swift"] end diff --git a/package/scripts/clean-shared-native-copies.sh b/package/scripts/clean-shared-native-copies.sh index 4b183454ce..1b96e1fc3b 100644 --- a/package/scripts/clean-shared-native-copies.sh +++ b/package/scripts/clean-shared-native-copies.sh @@ -11,8 +11,9 @@ clean_package() { local package_name="$1" local android_dir="$ROOT_DIR/$package_name/android/src/main/java/com/streamchatreactnative/shared" local ios_dir="$ROOT_DIR/$package_name/ios/shared" + local agent_sign_in_dir="$ROOT_DIR/$package_name/android/src/agent-sign-in" - rm -rf "$android_dir" "$ios_dir" + rm -rf "$android_dir" "$ios_dir" "$agent_sign_in_dir" rm -f \ "$STATE_DIR/${package_name}_android.manifest" \ "$STATE_DIR/${package_name}_ios.manifest" diff --git a/package/scripts/sync-shared-native.sh b/package/scripts/sync-shared-native.sh index 8d09ded9d5..06d0acb396 100644 --- a/package/scripts/sync-shared-native.sh +++ b/package/scripts/sync-shared-native.sh @@ -7,6 +7,9 @@ TARGET="${1:-all}" ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" SHARED_ANDROID_DIR="$ROOT_DIR/shared-native/android" SHARED_IOS_DIR="$ROOT_DIR/shared-native/ios" +# The agent sign-in's two Android builds: with Auth Tab (androidx.browser 1.9.0) and without. +# Each package's build.gradle compiles the one the app's build allows. +AGENT_SIGN_IN_VARIANTS=(authtab customtabs) sync_dir_contents() { local src_dir="$1" @@ -33,6 +36,13 @@ copy_to_package() { echo "Skipping Android sync: missing $SHARED_ANDROID_DIR" fi + for variant in "${AGENT_SIGN_IN_VARIANTS[@]}"; do + local variant_src="$ROOT_DIR/shared-native/android-$variant" + if [ -d "$variant_src" ]; then + sync_dir_contents "$variant_src" "$ROOT_DIR/$package_name/android/src/agent-sign-in/$variant" + fi + done + if [ -d "$SHARED_IOS_DIR" ]; then sync_dir_contents "$SHARED_IOS_DIR" "$ios_dst_dir" else diff --git a/package/shared-native/android-authtab/AgentAuthTab.kt b/package/shared-native/android-authtab/AgentAuthTab.kt new file mode 100644 index 0000000000..2092f9d21c --- /dev/null +++ b/package/shared-native/android-authtab/AgentAuthTab.kt @@ -0,0 +1,24 @@ +package com.streamchatreactnative.shared.agent + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.browser.auth.AuthTabIntent +import androidx.browser.customtabs.CustomTabsClient + +/** + * The browser's Auth Tab, from `androidx.browser` 1.9.0. Compiled in when the app builds with + * compileSdk 36 and AGP 8.9.1 or newer, which that version needs; otherwise the build takes the + * `android-customtabs` copy of this file, and sign-ins open in a Custom Tab. + */ +internal object AgentAuthTab { + fun isSupported(context: Context, browser: String): Boolean = + CustomTabsClient.isAuthTabSupported(context, browser) + + fun launch(launcher: ActivityResultLauncher, browser: String, uri: Uri, callbackScheme: String) { + AuthTabIntent.Builder().build() + .apply { intent.setPackage(browser) } + .launch(launcher, uri, callbackScheme) + } +} diff --git a/package/shared-native/android-customtabs/AgentAuthTab.kt b/package/shared-native/android-customtabs/AgentAuthTab.kt new file mode 100644 index 0000000000..4e6d9fa0bc --- /dev/null +++ b/package/shared-native/android-customtabs/AgentAuthTab.kt @@ -0,0 +1,20 @@ +package com.streamchatreactnative.shared.agent + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher + +/** + * No Auth Tab: the app builds below compileSdk 36 or AGP 8.9.1, which `androidx.browser` 1.9.0 + * needs, so sign-ins open in a Custom Tab. The `android-authtab` copy of this file has the Auth Tab. + */ +internal object AgentAuthTab { + @Suppress("UNUSED_PARAMETER") + fun isSupported(context: Context, browser: String): Boolean = false + + @Suppress("UNUSED_PARAMETER") + fun launch(launcher: ActivityResultLauncher, browser: String, uri: Uri, callbackScheme: String) { + throw UnsupportedOperationException("Auth Tabs need androidx.browser 1.9.0.") + } +} diff --git a/package/shared-native/android/agent/AIAgentController.kt b/package/shared-native/android/agent/AIAgentController.kt new file mode 100644 index 0000000000..af4dbd41e5 --- /dev/null +++ b/package/shared-native/android/agent/AIAgentController.kt @@ -0,0 +1,173 @@ +package com.streamchatreactnative.shared.agent + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsIntent +import com.facebook.react.bridge.ActivityEventListener +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import java.io.File +import java.util.Locale +import java.util.UUID + +/** + * This install's identifier and the sign-ins of tool calls, for the `AIAgent` TurboModule of both + * SDK packages (each package's codegen generates its own `NativeAIAgentSpec`), which forward + * their calls here. + * + * A sign-in opens in the browser's Auth Tab where the app is built with one (see + * `AgentAuthTab`) and the browser has it: the tab hands the page's redirect to the callback scheme + * straight back. Otherwise it opens in a Custom Tab: the redirect then reaches the app as a new + * intent, and coming back to the app without one means the person closed the tab. + */ +class AIAgentController(private val reactContext: ReactApplicationContext) : + ActivityEventListener, + LifecycleEventListener { + companion object { + const val NAME = "AIAgent" + private const val INSTALL_ID_FILE = "io.getstream.ai.client-id" + private const val ERROR = "ai_agent_auth_session" + } + + /** The sign-in open now. One runs at a time; opening another ends this one. */ + private class SignIn( + val callbackScheme: String, + val promise: Promise, + val launcher: ActivityResultLauncher?, + ) { + /** Whether the person left the app for the Custom Tab, so coming back means it was closed. */ + var left = false + } + + private var signIn: SignIn? = null + private var launches = 0 + + fun initialize() { + reactContext.addActivityEventListener(this) + reactContext.addLifecycleEventListener(this) + } + + fun invalidate() { + reactContext.removeActivityEventListener(this) + reactContext.removeLifecycleEventListener(this) + finish("") + } + + /** + * This install's identifier, created on first use and kept until the app's data is cleared. + * The same file and form as the Android AI SDK's `AIClientIdentity`. It is not backed up, so a + * device restored from a backup gets its own. + */ + @Synchronized + fun installId(): String { + // noBackupFilesDir: a restored device must not answer calls meant for the old one. + val file = File(reactContext.applicationContext.noBackupFilesDir, INSTALL_ID_FILE) + if (file.exists()) file.readText().takeIf { it.isNotBlank() }?.let { return it } + val created = "android-" + UUID.randomUUID().toString().uppercase(Locale.ROOT) + file.writeText(created) + return created + } + + fun openAuthSession(url: String, callbackScheme: String, promise: Promise) { + val activity = reactContext.currentActivity + if (activity == null) { + promise.reject(ERROR, "There's no activity to open the sign-in from.") + return + } + activity.runOnUiThread { + finish("") + val uri = Uri.parse(url) + val browser = CustomTabsClient.getPackageName(activity, null) + try { + if (activity is ComponentActivity && + browser != null && + AgentAuthTab.isSupported(activity, browser) + ) { + openAuthTab(activity, browser, uri, callbackScheme, promise) + } else { + signIn = SignIn(callbackScheme, promise, launcher = null) + CustomTabsIntent.Builder().build().launchUrl(activity, uri) + } + } catch (error: Exception) { + signIn = null + promise.reject(ERROR, error.message ?: "The sign-in couldn't be opened.", error) + } + } + } + + private fun openAuthTab( + activity: ComponentActivity, + browser: String, + uri: Uri, + callbackScheme: String, + promise: Promise, + ) { + // Registered for this sign-in alone and dropped once it answers, since a TurboModule can't + // register before the activity starts, as `AuthTabIntent.registerActivityResultLauncher` + // requires. Auth Tab's own contract only passes the intent through and reads the result's + // data, which is what `StartActivityForResult` does. + lateinit var launcher: ActivityResultLauncher + launcher = activity.activityResultRegistry.register( + "stream-chat-ai-sign-in-${launches++}", + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (signIn?.launcher !== launcher) return@register + when (result.resultCode) { + // Auth Tab's RESULT_OK and RESULT_CANCELED are the activity's own. + Activity.RESULT_OK -> finish(result.data?.data?.toString() ?: "") + Activity.RESULT_CANCELED -> finish("") + else -> fail("The sign-in didn't complete.") + } + } + signIn = SignIn(callbackScheme, promise, launcher) + AgentAuthTab.launch(launcher, browser, uri, callbackScheme) + } + + private fun finish(callback: String) { + val current = signIn ?: return + signIn = null + current.launcher?.unregister() + current.promise.resolve(callback) + } + + private fun fail(message: String) { + val current = signIn ?: return + signIn = null + current.launcher?.unregister() + current.promise.reject(ERROR, message) + } + + // The Custom Tab's redirect to the callback scheme reaches the app as a new intent, which + // arrives before the activity resumes. + override fun onNewIntent(intent: Intent) { + val current = signIn ?: return + val data = intent.data ?: return + if (current.launcher == null && data.scheme.equals(current.callbackScheme, ignoreCase = true)) { + finish(data.toString()) + } + } + + override fun onActivityResult( + activity: Activity, + requestCode: Int, + resultCode: Int, + data: Intent?, + ) = Unit + + override fun onHostPause() { + signIn?.takeIf { it.launcher == null }?.left = true + } + + override fun onHostResume() { + val current = signIn ?: return + if (current.launcher == null && current.left) finish("") + } + + override fun onHostDestroy() = Unit +} diff --git a/package/shared-native/ios/AIAgent.h b/package/shared-native/ios/AIAgent.h new file mode 100644 index 0000000000..e9c50ba2b9 --- /dev/null +++ b/package/shared-native/ios/AIAgent.h @@ -0,0 +1,16 @@ +#ifdef RCT_NEW_ARCH_ENABLED + +#if __has_include("StreamChatReactNativeSpec.h") +#import "StreamChatReactNativeSpec.h" +#elif __has_include("StreamChatExpoSpec.h") +#import "StreamChatExpoSpec.h" +#else +#error "Unable to find generated codegen spec header for AIAgent." +#endif + +// This install's identifier and the sign-ins of tool calls, for the `ai-components` agent views. +// The implementation lives in AIAgentModule.swift. +@interface AIAgent : NSObject +@end + +#endif diff --git a/package/shared-native/ios/AIAgent.mm b/package/shared-native/ios/AIAgent.mm new file mode 100644 index 0000000000..1ae5578491 --- /dev/null +++ b/package/shared-native/ios/AIAgent.mm @@ -0,0 +1,65 @@ +#import "AIAgent.h" + +#ifdef RCT_NEW_ARCH_ENABLED + +#if __has_include() +#import +#elif __has_include() +#import +#elif __has_include("stream_chat_react_native-Swift.h") +#import "stream_chat_react_native-Swift.h" +#elif __has_include("stream_chat_expo-Swift.h") +#import "stream_chat_expo-Swift.h" +#else +#error "Unable to import generated Swift header for AIAgent." +#endif + +@implementation AIAgent { + AIAgentModule *moduleInstance; +} + +RCT_EXPORT_MODULE(AIAgent) + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (instancetype)init +{ + if (self = [super init]) { + moduleInstance = [AIAgentModule new]; + } + return self; +} + +- (NSString *)installId +{ + return [AIAgentModule installId]; +} + +- (void)openAuthSession:(NSString *)url + callbackScheme:(NSString *)callbackScheme + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [moduleInstance openAuthSessionWithUrl:url + callbackScheme:callbackScheme + completion:^(NSString *_Nullable callback, NSString *_Nullable error) { + if (error) { + reject(@"ai_agent_auth_session", error, nil); + } else { + resolve(callback ?: @""); + } + }]; +} + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + +@end + +#endif diff --git a/package/shared-native/ios/AIAgentModule.swift b/package/shared-native/ios/AIAgentModule.swift new file mode 100644 index 0000000000..e7e4eac086 --- /dev/null +++ b/package/shared-native/ios/AIAgentModule.swift @@ -0,0 +1,84 @@ +import AuthenticationServices +import Foundation +import UIKit + +/// This install's identifier and the sign-ins of tool calls, for the `AIAgent` TurboModule +/// (`AIAgent.mm`). Ported from StreamChatAI's `AIClientIdentity` and `AIWebAuthentication`. +@objc(AIAgentModule) +public class AIAgentModule: NSObject { + private static let installIdKey = "io.getstream.ai.client-id" + + /// The session open now. One sign-in runs at a time; opening another ends this one. + private var session: ASWebAuthenticationSession? + private var completion: ((String?, String?) -> Void)? + private let anchor = PresentationAnchor() + + /// This install's identifier, created on first use. The same key and form as StreamChatAI's, + /// so a native app moving to React Native keeps its id. + @objc public static func installId() -> String { + if let saved = UserDefaults.standard.string(forKey: installIdKey) { return saved } + let created = "ios-" + UUID().uuidString + UserDefaults.standard.set(created, forKey: installIdKey) + return created + } + + /// Opens `url` in a web authentication session. `completion` gets the URL the page handed + /// back to `callbackScheme`, nothing when the person closed the sheet, or an error when the + /// session couldn't be opened. + @objc public func openAuthSession( + url: String, + callbackScheme: String, + completion: @escaping (String?, String?) -> Void + ) { + DispatchQueue.main.async { + guard let target = URL(string: url) else { + completion(nil, "The sign-in URL isn't valid.") + return + } + // A sign-in still open is ended as closed; its session's own answer is then ignored. + let previous = self.session + self.finish(nil, error: nil) + previous?.cancel() + + // Weak, so the session and its own callback don't hold each other. + weak var opened: ASWebAuthenticationSession? + let session = ASWebAuthenticationSession(url: target, callbackURLScheme: callbackScheme) { callback, error in + let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin + DispatchQueue.main.async { + guard let opened, self.session === opened else { return } + if let callback { + self.finish(callback.absoluteString, error: nil) + } else if cancelled { + self.finish(nil, error: nil) + } else { + self.finish(nil, error: error?.localizedDescription ?? "The sign-in couldn't be opened.") + } + } + } + opened = session + session.presentationContextProvider = self.anchor + self.session = session + self.completion = completion + if !session.start() { + self.finish(nil, error: "The sign-in couldn't be opened.") + } + } + } + + // The session may answer after a start that failed, so each sign-in is answered once. + private func finish(_ callback: String?, error: String?) { + guard let completion else { return } + self.completion = nil + session = nil + completion(callback, error) + } +} + +// Kept off `AIAgentModule`, whose conformances go in the generated Swift header that every +// Objective-C file of the pod imports without AuthenticationServices. +private final class PresentationAnchor: NSObject, ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + return scenes.flatMap(\.windows).first(where: \.isKeyWindow) ?? scenes.first?.windows.first ?? ASPresentationAnchor() + } +} diff --git a/package/src/ai-components/__tests__/signIn.test.tsx b/package/src/ai-components/__tests__/signIn.test.tsx new file mode 100644 index 0000000000..7dca68fd39 --- /dev/null +++ b/package/src/ai-components/__tests__/signIn.test.tsx @@ -0,0 +1,279 @@ +import React, { type ReactElement } from 'react'; + +import { act, fireEvent, render, screen } from '@testing-library/react-native'; + +import { + AIClientIdentity, + AIConnectorSignInError, + AIMessagePart, + type AIToolApprover, + AIToolApproverProvider, + AIToolAuthorization, + AIToolAuthorizationCard, + type AIToolAuthorizer, + type AIToolCallPart, + createAuthSessionAuthorizer, + parseMessagePart, + readConnectorSignInResult, + signInLaunchUrl, + StreamTheme, +} from '../index'; +import NativeAIAgent from '../native-specs/NativeAIAgent'; + +jest.mock('../native-specs/NativeAIAgent', () => ({ + __esModule: true, + default: { installId: jest.fn(), openAuthSession: jest.fn() }, +})); + +const native = NativeAIAgent as unknown as { + installId: jest.Mock; + openAuthSession: jest.Mock; +}; + +const renderWithTheme = (ui: ReactElement) => { + const result = render({ui}); + return { + ...result, + rerender: (next: ReactElement) => result.rerender({next}), + }; +}; + +const signIn = parseMessagePart({ + authorization: { + handoff_token: 'tok', + provider: 'linear', + provider_name: 'Linear', + title: 'Connect Linear', + type: 'oauth', + url: 'https://router.example.com/launch/abc', + }, + id: 'call_2', + name: 'linear_create_issue', + status: 'awaiting_authorization', + target_user_id: 'alex', + type: 'ai_tool_call', +})?.toolCall as AIToolCallPart; + +const approverWith = (authorizer?: AIToolAuthorizer): AIToolApprover => ({ + authorizer, + clientId: 'ios-1', + decide: jest.fn(() => Promise.resolve()), + userId: 'alex', +}); + +describe('AIToolAuthorization', () => { + it('asks nothing without an authorizer, or of another person', () => { + const { rerender } = renderWithTheme( + , + ); + expect(screen.queryByText('Connect Linear')).toBeNull(); + rerender( + , + ); + expect(screen.queryByText('Connect Linear')).toBeNull(); + }); + + it('connects, and lets the person try again when the sign-in closes', async () => { + let finish: (connected: boolean) => void = () => undefined; + const connect = jest.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + renderWithTheme( + , + ); + fireEvent.press(screen.getByRole('button', { name: 'Connect' })); + expect(connect).toHaveBeenCalledWith(signIn); + expect(screen.getByRole('button', { name: 'Connecting...' })).toBeDisabled(); + // Not now stays available while the sign-in is open. + expect(screen.getByRole('button', { name: 'Not now' })).toBeEnabled(); + + await act(async () => { + finish(false); + await Promise.resolve(); + }); + expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled(); + }); + + it('keeps connecting once connected, until the step changes', async () => { + renderWithTheme( + Promise.resolve(true), decline: jest.fn() })} + part={signIn} + />, + ); + fireEvent.press(screen.getByRole('button', { name: 'Connect' })); + await act(() => Promise.resolve()); + expect(screen.getByRole('button', { name: 'Connecting...' })).toBeDisabled(); + }); + + it('says when the sign-in could not open, and lets the person try again', async () => { + const connect = jest.fn(() => Promise.reject(new AIConnectorSignInError('failed'))); + renderWithTheme( + , + ); + fireEvent.press(screen.getByRole('button', { name: 'Connect' })); + await act(() => Promise.resolve()); + expect(screen.getByText("The sign-in couldn't be opened. Try again.")).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled(); + }); + + it('declines, and lets the person answer again when that fails', async () => { + const decline = jest.fn(() => Promise.reject(new Error('offline'))); + renderWithTheme( + , + ); + fireEvent.press(screen.getByRole('button', { name: 'Not now' })); + expect(decline).toHaveBeenCalledWith(signIn); + expect(screen.getByRole('button', { name: 'Not now' })).toBeDisabled(); + await act(() => Promise.resolve()); + expect(screen.getByText("Your answer couldn't be sent. Try again.")).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Not now' })).toBeEnabled(); + }); + + it.each([ + ['connected', 'Connected to Linear'], + ['declined', 'Not connected'], + ['failed', "The sign-in didn't complete."], + ['expired', 'This sign-in has expired.'], + ])('says how a %s sign-in ended, without buttons', (status, outcome) => { + renderWithTheme( + , + ); + expect(screen.getByText(outcome)).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull(); + }); + + it('can no longer be opened past its expiry', () => { + renderWithTheme( + , + ); + expect(screen.getByText('This sign-in has expired.')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Not now' })).toBeEnabled(); + }); + + it('is asked under its tool call, with the approver of the provider', () => { + renderWithTheme( + + + , + ); + expect(screen.getByText('Connect Linear')).toBeTruthy(); + expect(screen.getByText('Waiting for you to sign in')).toBeTruthy(); + }); +}); + +const signInAttachment = { + authorization: { + handoff_token: 'tok', + provider: 'linear', + title: 'Connect Linear', + type: 'oauth', + url: 'https://router.example.com/launch/abc', + }, + id: 'call_3', + name: 'linear_list_issues', + status: 'awaiting_authorization', + target_user_id: 'alex', +}; + +describe('createAuthSessionAuthorizer', () => { + beforeEach(() => jest.clearAllMocks()); + + it('puts the handoff token in the launch page’s fragment, encoded', () => { + expect(signInLaunchUrl({ ...signIn.authorization!, handoffToken: 'a+b&c=d/e' })).toBe( + 'https://router.example.com/launch/abc#handoff_token=a%2Bb%26c%3Dd%2Fe', + ); + expect(signInLaunchUrl({ ...signIn.authorization!, handoffToken: undefined })).toBe( + 'https://router.example.com/launch/abc', + ); + }); + + it('opens the sign-in for the app’s scheme, and reads how it ended', async () => { + const decline = jest.fn(() => Promise.resolve()); + const authorizer = createAuthSessionAuthorizer({ callbackScheme: 'myapp', decline }); + + native.openAuthSession.mockResolvedValueOnce( + 'myapp://signed-in?connection_id=c1&status=connected', + ); + await expect(authorizer.connect(signIn)).resolves.toBe(true); + expect(native.openAuthSession).toHaveBeenCalledWith( + 'https://router.example.com/launch/abc#handoff_token=tok', + 'myapp', + ); + + native.openAuthSession.mockResolvedValueOnce( + 'myapp://signed-in?connection_id=c1&status=denied', + ); + await expect(authorizer.connect(signIn)).resolves.toBe(false); + + // The person closed the sheet. + native.openAuthSession.mockResolvedValueOnce(''); + await expect(authorizer.connect(signIn)).resolves.toBe(false); + + await authorizer.decline(signIn); + expect(decline).toHaveBeenCalledWith(signIn); + }); + + it('rejects when the sign-in could not be opened', async () => { + const authorizer = createAuthSessionAuthorizer({ callbackScheme: 'myapp', decline: jest.fn() }); + + native.openAuthSession.mockRejectedValueOnce(new Error('No browser')); + await expect(authorizer.connect(signIn)).rejects.toMatchObject({ + message: 'No browser', + reason: 'failed', + }); + await expect(authorizer.connect({ ...signIn, authorization: undefined })).rejects.toMatchObject( + { reason: 'unavailable' }, + ); + }); + + it('reads only an outcome the router sends', () => { + expect(readConnectorSignInResult('myapp://x?status=connected&connection_id=conn-1')).toEqual({ + connectionId: 'conn-1', + status: 'connected', + }); + expect(readConnectorSignInResult('myapp://x?status=maybe&connection_id=conn-1')).toBeNull(); + expect(readConnectorSignInResult('myapp://x?status=connected')).toBeNull(); + expect(readConnectorSignInResult('myapp://x')).toBeNull(); + }); +}); + +describe('AIClientIdentity', () => { + it('is the install id the native module keeps', () => { + native.installId.mockReturnValue('ios-ABC'); + expect(AIClientIdentity.installId()).toBe('ios-ABC'); + }); + + it('lives in memory, unchanged, when the native module has none', () => { + native.installId.mockImplementation(() => { + throw new Error('not linked'); + }); + const first = AIClientIdentity.installId(); + expect(first).toMatch(/^(ios|android)-[0-9a-f]{32}$/); + expect(AIClientIdentity.installId()).toBe(first); + }); +}); diff --git a/package/src/ai-components/message-parts/AIMessageParts.tsx b/package/src/ai-components/message-parts/AIMessageParts.tsx index 9c99694349..707fd727ee 100644 --- a/package/src/ai-components/message-parts/AIMessageParts.tsx +++ b/package/src/ai-components/message-parts/AIMessageParts.tsx @@ -2,6 +2,7 @@ import React, { type ReactNode } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { AIToolApproval } from './AIToolApproval'; +import { AIToolAuthorization } from './AIToolAuthorization'; import { AIToolCall } from './AIToolCall'; import type { AIMessagePartData } from './types'; @@ -23,7 +24,7 @@ export type AIMessagePartProps = { /** * 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 + * question or sign-in 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) => { @@ -40,6 +41,7 @@ export const AIMessagePart = ({ approver, part, reasoningText }: AIMessagePartPr + ); } diff --git a/package/src/ai-components/message-parts/AIToolAuthorization.tsx b/package/src/ai-components/message-parts/AIToolAuthorization.tsx new file mode 100644 index 0000000000..599960f8be --- /dev/null +++ b/package/src/ai-components/message-parts/AIToolAuthorization.tsx @@ -0,0 +1,266 @@ +import React, { type ReactNode, useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { isAuthorizationExpired, isAwaitingAuthorization } from './parseMessageParts'; +import type { AIToolAuthorizationData, AIToolCallPart } from './types'; + +import { useAITranslation, useTheme } from '../contexts'; +import { useNow } from '../internal/format'; +import { + type AIToolApprover, + type AIToolAuthorizer, + useAIToolApprover, +} from '../tool-calls/AIToolApprover'; + +/** Where the person's answer to a sign-in is. */ +export type AIToolAuthorizationState = { + /** The sign-in couldn't be opened, and the person can try again. */ + connectFailed: boolean; + /** Their "not now" couldn't be sent, and the person can answer again. */ + declineFailed: boolean; + /** The sign-in is open, or done and the call's step hasn't changed yet. */ + isConnecting: boolean; + /** Their "not now" is on its way, or arrived and the call's step hasn't changed yet. */ + isDeclining: boolean; +}; + +const idle: AIToolAuthorizationState = { + connectFailed: false, + declineFailed: false, + isConnecting: false, + isDeclining: false, +}; + +export type AIToolAuthorizationCardProps = { + authorization: AIToolAuthorizationData; + onConnect: () => void; + onDecline: () => void; + state?: AIToolAuthorizationState; +}; + +/** + * Asks the person to sign in at a provider a tool call needs, such as "Connect Linear", with a + * Connect and a Not now button. Once the sign-in is over it says how it ended (connected, + * declined, expired or failed), and a sign-in past its expiry can no longer be opened. The + * counterpart of `AIToolAuthorizationCard`. + */ +export const AIToolAuthorizationCard = ({ + authorization, + onConnect, + onDecline, + state = idle, +}: AIToolAuthorizationCardProps) => { + const { t } = useAITranslation(); + const { + theme: { colors, messageParts }, + } = useTheme(); + const card = messageParts?.authorizationCard; + const open = authorization.status === undefined; + // Re-checked every few seconds while it is open, so it turns expired on its own. + const now = useNow(open && authorization.expiresAt !== undefined, 5000); + const expired = isAuthorizationExpired(authorization, now); + const provider = authorization.providerName ?? authorization.provider; + const busy = state.isConnecting || state.isDeclining; + + const outcome = (() => { + if (expired) return t('This sign-in has expired.'); + switch (authorization.status) { + case undefined: + return undefined; + case 'connected': + return t('Connected to {{ provider }}', { provider }); + case 'declined': + return t('Not connected'); + default: + return t("The sign-in didn't complete."); + } + })(); + + const failure = (() => { + if (state.connectFailed) return t("The sign-in couldn't be opened. Try again."); + if (state.declineFailed) return t("Your answer couldn't be sent. Try again."); + return undefined; + })(); + + return ( + + + + {authorization.title} + + {authorization.message ? ( + + {authorization.message} + + ) : null} + + {outcome ? ( + {outcome} + ) : null} + {open ? ( + + {!expired ? ( + [ + styles.button, + { backgroundColor: colors.accent_blue, opacity: pressed ? 0.7 : 1 }, + card?.connectButton, + ]} + > + + {state.isConnecting ? t('Connecting...') : t('Connect')} + + + ) : null} + {/* Left enabled while the sign-in is open, so one that never comes back leaves a way out. */} + [ + styles.button, + styles.declineButton, + { borderColor: colors.grey_gainsboro, opacity: pressed ? 0.7 : 1 }, + card?.declineButton, + ]} + > + + {t('Not now')} + + + {busy ? : null} + + ) : null} + {failure ? ( + + {failure} + + ) : null} + + ); +}; + +/** + * The sign-in this person is asked for on `call` here: one the call waits for them to finish, + * of a kind this device opens, and only when they can sign in on it. + */ +export const signInFor = (call: AIToolCallPart, approver: AIToolApprover | undefined) => { + if (!approver?.authorizer || !isAwaitingAuthorization(call, approver.userId)) return; + return call.authorization?.type === 'oauth' ? call.authorization : undefined; +}; + +export type AIToolAuthorizationProps = { + part: AIToolCallPart; + /** Who answers on this device, with the `authorizer` the sign-in needs. Defaults to the one of the nearest `AIToolApproverProvider`. */ + approver?: AIToolApprover; + /** Renders the sign-in, instead of `AIToolAuthorizationCard`. */ + children?: ( + authorization: AIToolAuthorizationData, + state: AIToolAuthorizationState, + actions: { connect: () => void; decline: () => void }, + ) => ReactNode; +}; + +/** + * Asks for the sign-in of a tool call that waits for this person, and runs it with + * `approver.authorizer`. Renders nothing for a call that does not wait for them, or without an + * authorizer. The counterpart of `AIToolAuthorizationView`. + */ +export const AIToolAuthorization = ({ + approver: approverProp, + children, + part, +}: AIToolAuthorizationProps) => { + const approver = useAIToolApprover(approverProp); + const [state, setState] = useState(idle); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const authorization = signInFor(part, approver); + const authorizer = approver?.authorizer; + if (!authorization || !authorizer) return null; + + const update = (next: AIToolAuthorizationState) => { + if (mounted.current) setState(next); + }; + + const connect = (signIn: AIToolAuthorizer) => { + if (state.isConnecting || state.isDeclining) return; + setState({ ...idle, isConnecting: true }); + signIn.connect(part).then( + // Your backend has the provider's answer before the sign-in comes back, and its change to + // the step hides this. Until then it says it is connecting, unless the person closed the + // sign-in or the provider did not connect them. + (connected) => { + if (!connected) update(idle); + }, + () => update({ ...idle, connectFailed: true }), + ); + }; + + const decline = (signIn: AIToolAuthorizer) => { + if (state.isDeclining) return; + setState({ ...idle, isDeclining: true }); + signIn.decline(part).catch(() => update({ ...idle, declineFailed: true })); + }; + + const actions = { + connect: () => connect(authorizer), + decline: () => decline(authorizer), + }; + + return ( + <> + {children ? ( + children(authorization, state, actions) + ) : ( + + )} + + ); +}; + +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/index.ts b/package/src/ai-components/message-parts/index.ts index c34894c2f6..23b9b28e4b 100644 --- a/package/src/ai-components/message-parts/index.ts +++ b/package/src/ai-components/message-parts/index.ts @@ -3,3 +3,4 @@ export * from './parseMessageParts'; export * from './AIMessageParts'; export * from './AIToolApproval'; export * from './AIToolCall'; +export * from './AIToolAuthorization'; diff --git a/package/src/ai-components/native-specs/NativeAIAgent.ts b/package/src/ai-components/native-specs/NativeAIAgent.ts new file mode 100644 index 0000000000..7e22ca992e --- /dev/null +++ b/package/src/ai-components/native-specs/NativeAIAgent.ts @@ -0,0 +1,25 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +/** + * Codegen spec of the native module behind the agent components: this install's identifier and + * the sign-ins of tool calls. + * + * This file is duplicated (byte for byte) in `package/native-package/src/native/NativeAIAgent.ts` + * and `package/expo-package/src/native/NativeAIAgent.ts`, which are the copies codegen reads. + * `yarn validate-entry-isolation` fails if they drift apart. + */ +export interface Spec extends TurboModule { + /** This install's identifier, created on first use and kept in storage that isn't backed up. */ + installId(): string; + /** + * Opens `url` in the platform's web authentication session and resolves with the URL the page + * handed back to `callbackScheme`, or an empty string when the person closed it. Rejects when + * the session couldn't be opened. + */ + openAuthSession(url: string, callbackScheme: string): Promise; +} + +// `get` rather than `getEnforcing`, so importing the AI entry point never throws in an app built +// without the module, such as one on an older version of the SDK's native code. +export default TurboModuleRegistry.get('AIAgent'); diff --git a/package/src/ai-components/theme.ts b/package/src/ai-components/theme.ts index 74dc069346..af50497239 100644 --- a/package/src/ai-components/theme.ts +++ b/package/src/ai-components/theme.ts @@ -101,6 +101,17 @@ export type Theme = { declineButtonText?: TextStyle; failure?: TextStyle; }; + authorizationCard?: { + container?: ViewStyle; + title?: TextStyle; + message?: TextStyle; + outcome?: TextStyle; + connectButton?: ViewStyle; + connectButtonText?: TextStyle; + declineButton?: ViewStyle; + declineButtonText?: TextStyle; + failure?: TextStyle; + }; unsupported?: { container?: ViewStyle; text?: TextStyle; @@ -203,6 +214,17 @@ export const defaultTheme: Theme = { declineButtonText: {}, failure: {}, }, + authorizationCard: { + container: {}, + title: {}, + message: {}, + outcome: {}, + connectButton: {}, + connectButtonText: {}, + declineButton: {}, + declineButtonText: {}, + failure: {}, + }, unsupported: { container: {}, text: {} }, }, }; diff --git a/package/src/ai-components/tool-calls/AIClientIdentity.ts b/package/src/ai-components/tool-calls/AIClientIdentity.ts new file mode 100644 index 0000000000..d47504e004 --- /dev/null +++ b/package/src/ai-components/tool-calls/AIClientIdentity.ts @@ -0,0 +1,33 @@ +import { Platform } from 'react-native'; + +import NativeAIAgent from '../native-specs/NativeAIAgent'; + +// Used when the app is built without the SDK's native code, so the id still holds for as long +// as the app runs. +let inMemoryId: string | undefined; + +const randomHex = (length: number) => + Array.from({ length }, () => Math.floor(Math.random() * 16).toString(16)).join(''); + +/** + * A stable identifier for this install, for addressing client tool calls to it. Send it with the + * person's questions (`client_id`) so the agent addresses the calls it makes while answering to + * this install, and pass it to `AIToolApprover` as `clientId`. + */ +export const AIClientIdentity = { + /** + * This install's identifier, created on first use and kept until the app's data is cleared: + * `ios-…` in `UserDefaults`, `android-…` in a file that isn't backed up, as in the native AI + * SDKs. Without the SDK's native code it lives in memory, for as long as the app runs. + */ + installId(): string { + try { + const saved = NativeAIAgent?.installId(); + if (saved) return saved; + } catch { + // Falls back to an id kept in memory. + } + inMemoryId ??= `${Platform.OS}-${randomHex(32)}`; + return inMemoryId; + }, +}; diff --git a/package/src/ai-components/tool-calls/authSessionAuthorizer.ts b/package/src/ai-components/tool-calls/authSessionAuthorizer.ts new file mode 100644 index 0000000000..8fe64f716e --- /dev/null +++ b/package/src/ai-components/tool-calls/authSessionAuthorizer.ts @@ -0,0 +1,125 @@ +import type { AIToolAuthorizer } from './AIToolApprover'; + +import type { AIToolAuthorizationData, AIToolCallPart } from '../message-parts/types'; +import NativeAIAgent from '../native-specs/NativeAIAgent'; + +// The sign-in runs the router's launch page in the platform's web authentication session +// (`ASWebAuthenticationSession` on iOS, an Auth Tab or a Custom Tab on Android), with the +// handoff token in the page's fragment, which no server sees. The page goes to the provider, +// which sends the person back through the router to the session's return URL, on this app's +// scheme, with how it ended in the query. The counterpart of `AIWebAuthentication` in +// StreamChatAI and of `createPopupAuthorizer` on the web. + +/** How the router says a consent ended, in the return URL's `status` query parameter. */ +export type ConnectorSignInStatus = 'connected' | 'denied' | 'failed' | 'account_mismatch'; + +const OUTCOMES: readonly string[] = [ + 'connected', + 'denied', + 'failed', + 'account_mismatch', +] satisfies ConnectorSignInStatus[]; + +const CONNECTION_ID = /^[\w-]{1,128}$/; + +/** The sign-in's outcome, as the return URL says it. */ +export type ConnectorSignInResult = { + connectionId: string; + status: ConnectorSignInStatus; +}; + +/** + * Why a sign-in could not be opened: the call carries no sign-in, or the app was built without + * the SDK's native code (`unavailable`), or the platform's session failed to open (`failed`). + */ +export class AIConnectorSignInError extends Error { + readonly reason: 'failed' | 'unavailable'; + + constructor(reason: AIConnectorSignInError['reason'], message?: string) { + super(message ?? `The sign-in couldn't be opened (${reason}).`); + this.name = 'AIConnectorSignInError'; + this.reason = reason; + } +} + +// The page reads its fragment as form parameters, where `+` is a space and `&` and `=` separate +// them, so a token carrying one of them is percent-encoded, as is everything else unsafe. +const fragmentValue = (value: string) => encodeURIComponent(value); + +/** + * The page to open for a sign-in: its `url` with the handoff token in the fragment. Read without + * `URL`, whose parts throw on React Native before 0.80. + */ +export const signInLaunchUrl = ({ handoffToken, url }: AIToolAuthorizationData) => { + if (!handoffToken) return url; + const base = url.split('#')[0]; + return `${base}#handoff_token=${fragmentValue(handoffToken)}`; +}; + +/** Reads the router's redirect to the return URL: `…?connection_id=…&status=…`. */ +export const readConnectorSignInResult = (returned: string): ConnectorSignInResult | null => { + const query = returned.split('#')[0].split('?')[1] ?? ''; + const params: Record = {}; + for (const pair of query.split('&')) { + const [name, value = ''] = pair.split('='); + if (!name) continue; + try { + params[decodeURIComponent(name)] = decodeURIComponent(value.replace(/\+/g, ' ')); + } catch { + // A parameter that isn't valid percent-encoding is left out. + } + } + const connectionId = params.connection_id ?? ''; + const status = params.status ?? ''; + if (!CONNECTION_ID.test(connectionId) || !OUTCOMES.includes(status)) return null; + return { connectionId, status: status as ConnectorSignInStatus }; +}; + +export type AuthSessionAuthorizerOptions = { + /** + * Your app's own URL scheme, such as `com.example.app`. It is the scheme of the return URL your + * sessions give their sign-ins (`return_url`), which the sign-in comes back to. + */ + callbackScheme: string; + /** + * Tells your backend the person won't sign in now. Your backend ends the call. A rejection lets + * the person answer again. + */ + decline: (call: AIToolCallPart) => Promise; +}; + +export type AuthSessionAuthorizer = AIToolAuthorizer & { callbackScheme: string }; + +/** + * An `AIToolAuthorizer` that opens a call's sign-in in the platform's web authentication session: + * `ASWebAuthenticationSession` on iOS, and on Android the browser's Auth Tab, or a Custom Tab when + * the browser has none. + * + * `connect` resolves `true` when the return URL says the account is connected, and `false` when + * the person closed the sign-in or it ended otherwise. It rejects with an `AIConnectorSignInError` + * when the sign-in couldn't be opened. + */ +export const createAuthSessionAuthorizer = ({ + callbackScheme, + decline, +}: AuthSessionAuthorizerOptions): AuthSessionAuthorizer => { + const connect = async (call: AIToolCallPart) => { + const authorization = call.authorization; + if (!authorization || !NativeAIAgent) throw new AIConnectorSignInError('unavailable'); + let returned: string; + try { + returned = await NativeAIAgent.openAuthSession( + signInLaunchUrl(authorization), + callbackScheme, + ); + } catch (error) { + throw new AIConnectorSignInError( + 'failed', + error instanceof Error ? error.message : undefined, + ); + } + return returned !== '' && readConnectorSignInResult(returned)?.status === 'connected'; + }; + + return { callbackScheme, connect, decline }; +}; diff --git a/package/src/ai-components/tool-calls/index.ts b/package/src/ai-components/tool-calls/index.ts index ad73d0bd40..98cd79cca7 100644 --- a/package/src/ai-components/tool-calls/index.ts +++ b/package/src/ai-components/tool-calls/index.ts @@ -1 +1,3 @@ +export * from './AIClientIdentity'; export * from './AIToolApprover'; +export * from './authSessionAuthorizer'; diff --git a/package/src/i18n/ar.json b/package/src/i18n/ar.json index 59c40b4648..9b5b4460c7 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -24,6 +24,9 @@ "Cancel": "إلغاء", "Cancelled": "تم الإلغاء", "Cannot Flag Message": "تعذر الإبلاغ عن الرسالة", + "Connect": "اتصال", + "Connected to {{ provider }}": "متصل بـ {{ provider }}", + "Connecting...": "جارٍ الاتصال...", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "فكّر في تأثير تعليقك على الآخرين وتأكد من اتباع إرشادات مجتمعنا", "Copy Message": "نسخ الرسالة", "Create Poll": "إنشاء تصويت", @@ -89,6 +92,8 @@ "Message unpinned": "تم إلغاء تثبيت الرسالة", "Multiple votes": "تصويتات متعددة", "Network error": "خطأ في الشبكة", + "Not connected": "غير متصل", + "Not now": "ليس الآن", "Select more than one option": "اختر أكثر من خيار واحد", "Limit votes per person": "تحديد عدد الأصوات لكل شخص", "Choose between 2–10 options": "اختر بين 2 و10 خيارات", @@ -140,10 +145,13 @@ "Slow mode, wait {{seconds}}s...": "وضع بطيء، انتظر {{seconds}}ث...", "Suggest an option": "اقترح خياراً", "The message has been reported to a moderator.": "تم الإبلاغ عن الرسالة إلى مشرف.", + "The sign-in couldn't be opened. Try again.": "تعذّر فتح تسجيل الدخول. حاول مرة أخرى.", + "The sign-in didn't complete.": "لم يكتمل تسجيل الدخول.", "The source message was deleted": "تم حذف الرسالة الأصلية", "Thinking...": "جارٍ التفكير...", "Thinking... {{ duration }}": "جارٍ التفكير... {{ duration }}", "This reply was deleted": "تم حذف هذا الرد", + "This sign-in has expired.": "انتهت صلاحية تسجيل الدخول هذا.", "Thought for {{ duration }}": "فكّر لمدة {{ duration }}", "Thought process": "عملية التفكير", "Thread Reply": "رد في الموضوع", diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index 5dc68dfb62..25e1f79ca5 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -24,6 +24,9 @@ "Cancel": "Cancel", "Cancelled": "Cancelled", "Cannot Flag Message": "Cannot Flag Message", + "Connect": "Connect", + "Connected to {{ provider }}": "Connected to {{ provider }}", + "Connecting...": "Connecting...", "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", @@ -91,6 +94,8 @@ "Message unpinned": "Message unpinned", "Multiple votes": "Multiple votes", "Network error": "Network error", + "Not connected": "Not connected", + "Not now": "Not now", "Select more than one option": "Select more than one option", "Limit votes per person": "Limit votes per person", "Choose between 2–10 options": "Choose between 2–10 options", @@ -98,7 +103,10 @@ "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?", + "The sign-in couldn't be opened. Try again.": "The sign-in couldn't be opened. Try again.", + "The sign-in didn't complete.": "The sign-in didn't complete.", "Thinking... {{ duration }}": "Thinking... {{ duration }}", + "This sign-in has expired.": "This sign-in has expired.", "Thought for {{ duration }}": "Thought for {{ duration }}", "Thought process": "Thought process", "Waiting for approval": "Waiting for approval", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index f89cc4aebe..f06559c486 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -24,6 +24,9 @@ "Cancel": "Cancelar", "Cancelled": "Cancelado", "Cannot Flag Message": "No se puede reportar el mensaje", + "Connect": "Conectar", + "Connected to {{ provider }}": "Conectado a {{ provider }}", + "Connecting...": "Conectando...", "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", @@ -89,6 +92,8 @@ "Message unpinned": "Mensaje desfijado", "Multiple votes": "Votos múltiples", "Network error": "Error de red", + "Not connected": "No conectado", + "Not now": "Ahora no", "Select more than one option": "Selecciona más de una opción", "Limit votes per person": "Limita los votos por persona", "Choose between 2–10 options": "Elige entre 2 y 10 opciones", @@ -140,10 +145,13 @@ "Slow mode, wait {{seconds}}s...": "Modo lento, espera {{seconds}}s...", "Suggest an option": "Sugerir una opción", "The message has been reported to a moderator.": "El mensaje ha sido reportado a un moderador.", + "The sign-in couldn't be opened. Try again.": "No se pudo abrir el inicio de sesión. Inténtalo de nuevo.", + "The sign-in didn't complete.": "El inicio de sesión no se completó.", "The source message was deleted": "El mensaje original fue eliminado", "Thinking...": "Pensando...", "Thinking... {{ duration }}": "Pensando... {{ duration }}", "This reply was deleted": "Esta respuesta fue eliminada", + "This sign-in has expired.": "Este inicio de sesión ha caducado.", "Thought for {{ duration }}": "Pensó durante {{ duration }}", "Thought process": "Proceso de razonamiento", "Thread Reply": "Respuesta de hilo", diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index 8fcf7db8f6..b581f9a5dc 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -24,6 +24,9 @@ "Cancel": "Annuler", "Cancelled": "Annulé", "Cannot Flag Message": "Impossible de signaler le message", + "Connect": "Connecter", + "Connected to {{ provider }}": "Connecté à {{ provider }}", + "Connecting...": "Connexion...", "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", @@ -89,6 +92,8 @@ "Message unpinned": "Message désépinglé", "Multiple votes": "Votes multiples", "Network error": "Erreur réseau", + "Not connected": "Non connecté", + "Not now": "Pas maintenant", "Select more than one option": "Sélectionnez plus d’une option", "Limit votes per person": "Limiter les votes par personne", "Choose between 2–10 options": "Choisissez entre 2 et 10 options", @@ -140,10 +145,13 @@ "Slow mode, wait {{seconds}}s...": "Mode lent, attendez {{seconds}}s...", "Suggest an option": "Suggérer une option", "The message has been reported to a moderator.": "Le message a été signalé à un modérateur.", + "The sign-in couldn't be opened. Try again.": "La connexion n'a pas pu être ouverte. Réessayez.", + "The sign-in didn't complete.": "La connexion n'a pas abouti.", "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", + "This sign-in has expired.": "Cette connexion a expiré.", "Thought for {{ duration }}": "A réfléchi pendant {{ duration }}", "Thought process": "Raisonnement", "Thread Reply": "Réponse à la discussion", diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index c2590af3ec..ff47b395f4 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -24,6 +24,9 @@ "Cancel": "ביטול", "Cancelled": "בוטל", "Cannot Flag Message": "סימון הודעה לא אפשרי", + "Connect": "התחברות", + "Connected to {{ provider }}": "מחובר ל-{{ provider }}", + "Connecting...": "מתחבר...", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "שקול איך התגובה שלך עשויה להשפיע על אחרים ווודא שאתה עוקב אחר ההנחיות של הקהילה שלנו", "Copy Message": "העתק/י הודעה", "Create Poll": "צור סקר", @@ -89,6 +92,8 @@ "Message unpinned": "נעיצת ההודעה בוטלה", "Multiple votes": "הצבעות מרובות", "Network error": "שגיאת רשת", + "Not connected": "לא מחובר", + "Not now": "לא עכשיו", "Select more than one option": "בחר/י יותר מאפשרות אחת", "Limit votes per person": "הגבל/י את מספר ההצבעות לאדם", "Choose between 2–10 options": "בחר/י בין 2 ל-10 אפשרויות", @@ -140,10 +145,13 @@ "Slow mode, wait {{seconds}}s...": "מצב איטי, חכה {{seconds}}s...", "Suggest an option": "הצע אפשרות", "The message has been reported to a moderator.": "ההודעה דווחה למנהל", + "The sign-in couldn't be opened. Try again.": "לא ניתן היה לפתוח את ההתחברות. נסו שוב.", + "The sign-in didn't complete.": "ההתחברות לא הושלמה.", "The source message was deleted": "ההודעה המקורית נמחקה", "Thinking...": "חושב...", "Thinking... {{ duration }}": "חושב... {{ duration }}", "This reply was deleted": "התגובה הזו נמחקה", + "This sign-in has expired.": "פג תוקף ההתחברות הזו.", "Thought for {{ duration }}": "חשב במשך {{ duration }}", "Thought process": "תהליך החשיבה", "Thread Reply": "הגב/י בשרשור", diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index 422aed325c..b8a4aeb17d 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -24,6 +24,9 @@ "Cancel": "रद्द करें", "Cancelled": "रद्द किया गया", "Cannot Flag Message": "मैसेज फ्लैग नहीं किया जा सकता है", + "Connect": "कनेक्ट करें", + "Connected to {{ provider }}": "{{ provider }} से कनेक्ट है", + "Connecting...": "कनेक्ट हो रहा है...", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "ध्यान दें कि आपका संदेश दूसरों को कैसा लगा सकता है और सुनिश्चित हों कि आप हमारी सामुदायिक अनुशासन का पालन कर रहे हैं", "Copy Message": "संदेश की प्रतिलिपि बनाएँ", "Create Poll": "सर्वेक्षण बनाएं", @@ -89,6 +92,8 @@ "Message unpinned": "संदेश से पिन हटाया गया", "Multiple votes": "एकाधिक वोट", "Network error": "नेटवर्क त्रुटि", + "Not connected": "कनेक्ट नहीं है", + "Not now": "अभी नहीं", "Select more than one option": "एक से अधिक विकल्प चुनें", "Limit votes per person": "प्रति व्यक्ति वोट सीमित करें", "Choose between 2–10 options": "2–10 विकल्प चुनें", @@ -140,10 +145,13 @@ "Slow mode, wait {{seconds}}s...": "स्लो मोड, {{seconds}}s इंतजार करें...", "Suggest an option": "एक विकल्प सुझाएं", "The message has been reported to a moderator.": "संदेश एक मॉडरेटर को सूचित किया गया है।", + "The sign-in couldn't be opened. Try again.": "साइन-इन नहीं खुल सका। फिर से कोशिश करें।", + "The sign-in didn't complete.": "साइन-इन पूरा नहीं हुआ।", "The source message was deleted": "स्रोत संदेश हटा दिया गया है", "Thinking...": "सोच रहा है...", "Thinking... {{ duration }}": "सोच रहा है... {{ duration }}", "This reply was deleted": "यह उत्तर हटा दिया गया है", + "This sign-in has expired.": "यह साइन-इन समाप्त हो गया है।", "Thought for {{ duration }}": "{{ duration }} तक सोचा", "Thought process": "सोचने की प्रक्रिया", "Thread Reply": "धागा जवाब", diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index b9712659ee..d36c823b4d 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -24,6 +24,9 @@ "Cancel": "Annulla", "Cancelled": "Annullato", "Cannot Flag Message": "Impossibile Segnalare Messaggio", + "Connect": "Connetti", + "Connected to {{ provider }}": "Connesso a {{ provider }}", + "Connecting...": "Connessione...", "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", @@ -89,6 +92,8 @@ "Message unpinned": "Messaggio non più fissato", "Multiple votes": "Voti multipli", "Network error": "Errore di rete", + "Not connected": "Non connesso", + "Not now": "Non ora", "Select more than one option": "Seleziona più di un'opzione", "Limit votes per person": "Limita i voti per persona", "Choose between 2–10 options": "Scegli tra 2 e 10 opzioni", @@ -140,10 +145,13 @@ "Slow mode, wait {{seconds}}s...": "Slowmode, attendi {{seconds}}s...", "Suggest an option": "Suggerisci un'opzione", "The message has been reported to a moderator.": "Il messaggio è stato segnalato a un moderatore.", + "The sign-in couldn't be opened. Try again.": "Impossibile aprire l'accesso. Riprova.", + "The sign-in didn't complete.": "L'accesso non è stato completato.", "The source message was deleted": "Il messaggio originale è stato eliminato", "Thinking...": "Pensando...", "Thinking... {{ duration }}": "Pensando... {{ duration }}", "This reply was deleted": "Questa risposta è stata eliminata", + "This sign-in has expired.": "Questo accesso è scaduto.", "Thought for {{ duration }}": "Ha pensato per {{ duration }}", "Thought process": "Ragionamento", "Thread Reply": "Rispondi alla Discussione", diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index c880eb5859..4d58044a62 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -24,6 +24,9 @@ "Cancel": "キャンセル", "Cancelled": "キャンセルされました", "Cannot Flag Message": "メッセージをフラグできません", + "Connect": "接続", + "Connected to {{ provider }}": "{{ provider }} に接続済み", + "Connecting...": "接続中...", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "あなたのコメントが他の人にどのように影響するか考え、必ずコミュニティガイドラインに従ってください", "Copy Message": "メッセージのコピー", "Create Poll": "アンケートを作成", @@ -89,6 +92,8 @@ "Message unpinned": "メッセージのピン留めを解除しました", "Multiple votes": "複数投票", "Network error": "ネットワークエラー", + "Not connected": "未接続", + "Not now": "後で", "Select more than one option": "2つ以上のオプションを選択", "Limit votes per person": "1人あたりの投票数を制限", "Choose between 2–10 options": "2~10個のオプションから選択", @@ -140,10 +145,13 @@ "Slow mode, wait {{seconds}}s...": "スローモード, {{seconds}}s 待ってください...", "Suggest an option": "オプションを提案", "The message has been reported to a moderator.": "メッセージはモデレーターに報告されました。", + "The sign-in couldn't be opened. Try again.": "サインインを開けませんでした。もう一度お試しください。", + "The sign-in didn't complete.": "サインインが完了しませんでした。", "The source message was deleted": "元のメッセージが削除されました", "Thinking...": "考え中...", "Thinking... {{ duration }}": "考え中... {{ duration }}", "This reply was deleted": "この返信は削除されました", + "This sign-in has expired.": "このサインインは期限切れです。", "Thought for {{ duration }}": "{{ duration }} 考えました", "Thought process": "思考プロセス", "Thread Reply": "スレッドの返信", diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index d58bf24e9e..d544b58369 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -24,6 +24,9 @@ "Cancel": "취소", "Cancelled": "취소됨", "Cannot Flag Message": "메세지를 플래그 할 수 없습니다", + "Connect": "연결", + "Connected to {{ provider }}": "{{ provider }}에 연결됨", + "Connecting...": "연결 중...", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "당신의 댓글이 다른 사람들에게 어떤 영향을 줄지 고려하고 반드시 우리의 커뮤니티 가이드라인을 따르십시오", "Copy Message": "메시지 복사", "Create Poll": "투표 생성", @@ -89,6 +92,8 @@ "Message unpinned": "메시지 고정이 해제되었습니다", "Multiple votes": "복수 투표", "Network error": "네트워크 오류", + "Not connected": "연결되지 않음", + "Not now": "나중에", "Select more than one option": "두 개 이상의 옵션을 선택하세요", "Limit votes per person": "1인당 투표 수 제한", "Choose between 2–10 options": "2~10개의 옵션 중에서 선택하세요", @@ -140,10 +145,13 @@ "Slow mode, wait {{seconds}}s...": "슬로모드, {{seconds}}s 대기...", "Suggest an option": "옵션 제안", "The message has been reported to a moderator.": "메시지는 운영자에보고되었습니다.", + "The sign-in couldn't be opened. Try again.": "로그인 창을 열 수 없습니다. 다시 시도하세요.", + "The sign-in didn't complete.": "로그인이 완료되지 않았습니다.", "The source message was deleted": "원본 메시지가 삭제되었습니다", "Thinking...": "생각 중...", "Thinking... {{ duration }}": "생각 중... {{ duration }}", "This reply was deleted": "이 답글은 삭제되었습니다", + "This sign-in has expired.": "이 로그인은 만료되었습니다.", "Thought for {{ duration }}": "{{ duration }} 동안 생각함", "Thought process": "생각 과정", "Thread Reply": "스레드 답장", diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index 1a73e19925..5b0b929e55 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -24,6 +24,9 @@ "Cancel": "Annuleer", "Cancelled": "Geannuleerd", "Cannot Flag Message": "Kan bericht niet rapporteren", + "Connect": "Verbinden", + "Connected to {{ provider }}": "Verbonden met {{ provider }}", + "Connecting...": "Verbinden...", "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", @@ -89,6 +92,8 @@ "Message unpinned": "Bericht losgemaakt", "Multiple votes": "Meerdere stemmen", "Network error": "Netwerkfout", + "Not connected": "Niet verbonden", + "Not now": "Niet nu", "Select more than one option": "Selecteer meer dan één optie", "Limit votes per person": "Beperk stemmen per persoon", "Choose between 2–10 options": "Kies tussen 2 en 10 opties", @@ -140,10 +145,13 @@ "Slow mode, wait {{seconds}}s...": "Langzame modus, wacht {{seconds}}s...", "Suggest an option": "Stel een optie voor", "The message has been reported to a moderator.": "Het bericht is gerapporteerd aan een moderator.", + "The sign-in couldn't be opened. Try again.": "De aanmelding kon niet worden geopend. Probeer het opnieuw.", + "The sign-in didn't complete.": "De aanmelding is niet voltooid.", "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", + "This sign-in has expired.": "Deze aanmelding is verlopen.", "Thought for {{ duration }}": "{{ duration }} nagedacht", "Thought process": "Denkproces", "Thread Reply": "Discussie beantwoorden", diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index d14c416151..a4c61e8378 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -24,6 +24,9 @@ "Cancel": "Cancelar", "Cancelled": "Cancelado", "Cannot Flag Message": "Não é possível reportar a mensagem", + "Connect": "Conectar", + "Connected to {{ provider }}": "Conectado a {{ provider }}", + "Connecting...": "Conectando...", "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", @@ -89,6 +92,8 @@ "Message unpinned": "Mensagem desafixada", "Multiple votes": "Votos múltiplos", "Network error": "Erro de rede", + "Not connected": "Não conectado", + "Not now": "Agora não", "Select more than one option": "Selecione mais de uma opção", "Limit votes per person": "Limite os votos por pessoa", "Choose between 2–10 options": "Escolha entre 2 e 10 opções", @@ -140,10 +145,13 @@ "Slow mode, wait {{seconds}}s...": "Modo lento, aguarde {{seconds}}s...", "Suggest an option": "Sugerir uma opção", "The message has been reported to a moderator.": "A mensagem foi relatada a um moderador.", + "The sign-in couldn't be opened. Try again.": "Não foi possível abrir o login. Tente novamente.", + "The sign-in didn't complete.": "O login não foi concluído.", "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", + "This sign-in has expired.": "Este login expirou.", "Thought for {{ duration }}": "Pensou por {{ duration }}", "Thought process": "Raciocínio", "Thread Reply": "Respostas de Tópico", diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index c344a8d557..fc22da6e13 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -24,6 +24,9 @@ "Cancel": "Отмена", "Cancelled": "Отменено", "Cannot Flag Message": "Невозможно пожаловаться на сообщение", + "Connect": "Подключить", + "Connected to {{ provider }}": "Подключено к {{ provider }}", + "Connecting...": "Подключение...", "Consider how your comment might make others feel and be sure to follow our Community Guidelines": "Обдумайте, как ваш комментарий может повлиять на других, и убедитесь, что вы следуете нашим правилам сообщества", "Copy Message": "Копировать сообщение", "Create Poll": "Создать опрос", @@ -89,6 +92,8 @@ "Message unpinned": "Сообщение откреплено", "Multiple votes": "Несколько голосов", "Network error": "Ошибка сети", + "Not connected": "Не подключено", + "Not now": "Не сейчас", "Select more than one option": "Выберите больше одного варианта", "Limit votes per person": "Ограничить количество голосов на человека", "Choose between 2–10 options": "Выберите от 2 до 10 вариантов", @@ -140,10 +145,13 @@ "Slow mode, wait {{seconds}}s...": "Медленный режим, ожидайте {{seconds}}s...", "Suggest an option": "Предложить вариант", "The message has been reported to a moderator.": "Сообщение отправлено модератору.", + "The sign-in couldn't be opened. Try again.": "Не удалось открыть вход. Попробуйте ещё раз.", + "The sign-in didn't complete.": "Вход не завершён.", "The source message was deleted": "Исходное сообщение было удалено", "Thinking...": "Думаю...", "Thinking... {{ duration }}": "Думаю... {{ duration }}", "This reply was deleted": "Этот ответ был удалён", + "This sign-in has expired.": "Срок действия этого входа истёк.", "Thought for {{ duration }}": "Думал {{ duration }}", "Thought process": "Ход рассуждений", "Thread Reply": "Тема Ответить", diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index 5d5036e190..ee45cf536e 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -24,6 +24,9 @@ "Cancel": "İptal", "Cancelled": "İptal edildi", "Cannot Flag Message": "Raporlama Başarısız", + "Connect": "Bağlan", + "Connected to {{ provider }}": "{{ provider }} ile bağlandı", + "Connecting...": "Bağlanıyor...", "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", @@ -89,6 +92,8 @@ "Message unpinned": "Mesaj sabitlemesi kaldırıldı", "Multiple votes": "Çoklu oy", "Network error": "Ağ hatası", + "Not connected": "Bağlı değil", + "Not now": "Şimdi değil", "Select more than one option": "Birden fazla seçenek seçin", "Limit votes per person": "Kişi başına oy sayısını sınırla", "Choose between 2–10 options": "2 ile 10 arasında seçenek seçin", @@ -140,10 +145,13 @@ "Slow mode, wait {{seconds}}s...": "Yavaş mod, {{seconds}}s bekleyin...", "Suggest an option": "Bir seçenek öner", "The message has been reported to a moderator.": "Mesaj moderatöre bildirildi.", + "The sign-in couldn't be opened. Try again.": "Oturum açma penceresi açılamadı. Tekrar deneyin.", + "The sign-in didn't complete.": "Oturum açma tamamlanmadı.", "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", + "This sign-in has expired.": "Bu oturum açmanın süresi doldu.", "Thought for {{ duration }}": "{{ duration }} düşündü", "Thought process": "Düşünme süreci", "Thread Reply": "Konu Yanıtı",