diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..58b65f9 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,33 @@ +# This is a basic workflow to help you get started with Actions + +name: Publish to Pub.dev + +# Controls when the workflow will run +on: + ## Triggers the workflow on push or pull request events but only for the main branch + #push: + # branches: [ main ] + #pull_request: + # branches: [ main ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + publishing: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - name: 'Checkout' + uses: actions/checkout@v2 # required! + + - name: '>> Dart package <<' + uses: k-paxian/dart-package-publisher@master + with: + accessToken: ${{ secrets.OAUTH_ACCESS_TOKEN }} + refreshToken: ${{ secrets.OAUTH_REFRESH_TOKEN }} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1985397 --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..54f6c7b --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 1d9032c7e1d867f071f2277eb1673e8f9b0274e3 + channel: stable + +project_type: package diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8ae19cf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +## 0.1.0 + +- Initial version. + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e67741e --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2022, TalkJS + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 77f41e6..85c09e8 100644 --- a/README.md +++ b/README.md @@ -1 +1,64 @@ -# flutter-sdk-victor \ No newline at end of file +# TalkJS Flutter SDK + +Official TalkJS SDK for Flutter + +**What is TalkJS?** + +TalkJS lets you add user-to-user chat to your marketplace, on-demand app, or +social platform. +For more information, visit +[talkjs.com](https://talkjs.com/?ref=jssdk-npm-readme). + +![Screenshots of TalkJS running on various devices](https://talkjs.com/images/devices_home.jpg) + +Don't hesitate to +[let us know](https://talkjs.com/?chat) +if you have any questions about TalkJS. + +## Requirements + +- Dart sdk: ">=2.15.0 <3.0.0" +- Flutter: ">=2.8.1" +- Android: `minSDKVersion 19` + +## Installation + +Edit the dependencies section of your project's `pubspec.yaml` file in your +Flutter project as follows: + +```yaml +dependencies: + talkjs_flutter: ^0.1.0 +``` + +Run the command: `flutter pub get` on the command line or through Android +Studio's **Get dependencies** button. + + +## Usage + +Import TalkJS in your project source files. + +```dart +import 'package:talkjs_flutter/talkjs.dart'; +``` + +Then follow our +[Flutter guide](https://talkjs.com/docs/Getting_Started/Frameworks/Flutter/) +to start using TalkJS in your project. + +## TalkJS is fully forward compatible +We promise to never break API compatibility. +We may at times deprecate methods or fields, but we will never remove them. +If something that used to work stops working, then that's a bug. +Please [report it](https://talkjs.com/?chat) and we'll fix it asap. + +The package is being released in a beta state. +The reason for this is that there are things that one can do with the TalkJS +JavaScript SDK that aren't possible with the Flutter SDK. +We will release v1.0.0 of this package once the two SDKs are similar in terms +of features. +This however does not take away from our commitment to always maintain backward +compatibility. +So you can be assured that the package is stable for production use. + diff --git a/lib/assets/index.html b/lib/assets/index.html new file mode 100644 index 0000000..d9de508 --- /dev/null +++ b/lib/assets/index.html @@ -0,0 +1,23 @@ + + + + + + + + + +
+ + diff --git a/lib/src/chatbox.dart b/lib/src/chatbox.dart new file mode 100644 index 0000000..a2b7c06 --- /dev/null +++ b/lib/src/chatbox.dart @@ -0,0 +1,473 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:webview_flutter/webview_flutter.dart'; + +import './session.dart'; +import './conversation.dart'; +import './chatoptions.dart'; +import './user.dart'; +import './message.dart'; +import './predicate.dart'; + +typedef SendMessageHandler = void Function(SendMessageEvent event); +typedef TranslationToggledHandler = void Function(TranslationToggledEvent event); + +class SendMessageEvent { + final ConversationData conversation; + final UserData me; + final SentMessage message; + + SendMessageEvent.fromJson(Map json) + : conversation = ConversationData.fromJson(json['conversation']), + me = UserData.fromJson(json['me']), + message = SentMessage.fromJson(json['message']); +} + +class TranslationToggledEvent { + final ConversationData conversation; + final bool isEnabled; + + TranslationToggledEvent.fromJson(Map json) + : conversation = ConversationData.fromJson(json['conversation']), + isEnabled = json['isEnabled']; +} + +/// A messaging UI for just a single conversation. +/// +/// Create a Chatbox through [Session.createChatbox] and then call [mount] to show it. +/// There is no way for the user to switch between conversations +class ChatBox extends StatefulWidget { + final Session session; + + final TextDirection? dir; + final MessageFieldOptions? messageField; + final bool? showChatHeader; + final TranslationToggle? showTranslationToggle; + final String? theme; + final TranslateConversations? translateConversations; + final List highlightedWords = const []; + final MessagePredicate messageFilter; + + final Conversation? conversation; + final bool? asGuest; + + final SendMessageHandler? onSendMessage; + final TranslationToggledHandler? onTranslationToggled; + + const ChatBox({ + Key? key, + required this.session, + this.dir, + this.messageField, + this.showChatHeader, + this.showTranslationToggle, + this.theme, + this.translateConversations, + //this.highlightedWords = const [], // Commented out due to bug #1953 + this.messageFilter = const MessagePredicate(), + this.conversation, + this.asGuest, + this.onSendMessage, + this.onTranslationToggled, + }) : super(key: key); + + @override + State createState() => ChatBoxState(); +} + +class ChatBoxState extends State { + /// Used to control the underlying WebView + WebViewController? _webViewController; + bool _webViewCreated = false; + + /// List of JavaScript statements that haven't been executed. + final _pending = []; + + // A counter to ensure that IDs are unique + int _idCounter = 0; + + /// A mapping of user ids to the variable name of the respective JavaScript + /// Talk.User object. + final _users = {}; + final _userObjs = {}; + + /// A mapping of conversation ids to the variable name of the respective JavaScript + /// Talk.ConversationBuilder object. + final _conversations = {}; + final _conversationObjs = {}; + + /// Encapsulates the message entry field tied to the currently selected conversation. + // TODO: messageField still needs to be refactored + //late MessageField messageField; + + /// Objects stored for comparing changes + ChatBoxOptions? _oldOptions; + List _oldHighlightedWords = []; + MessagePredicate _oldMessageFilter = const MessagePredicate(); + bool? _oldAsGuest; + Conversation? _oldConversation; + + @override + Widget build(BuildContext context) { + if (kDebugMode) { + print('📗 chatbox.build (_webViewCreated: $_webViewCreated)'); + } + + if (!_webViewCreated) { + // If it's the first time that the widget is built, then build everything + _webViewCreated = true; + + execute('let chatBox;'); + + _createSession(); + _createChatBox(); + // messageFilter and highlightedWords are set as options for the chatbox + _createConversation(); + + execute('chatBox.mount(document.getElementById("talkjs-container"));'); + } else { + // If it's not the first time that the widget is built, + // then check what needs to be rebuilt + + // TODO: If something has changed in the Session we should do something + + final chatBoxRecreated = _checkRecreateChatBox(); + + if (chatBoxRecreated) { + // messageFilter and highlightedWords are set as options for the chatbox + _createConversation(); + } else { + _checkMessageFilter(); + _checkHighlightedWords(); + _checkRecreateConversation(); + } + + // Mount the chatbox only if it's new (else the existing chatbox has already been mounted) + if (chatBoxRecreated) { + execute('chatBox.mount(document.getElementById("talkjs-container"));'); + } + } + + return WebView( + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + debuggingEnabled: kDebugMode, + onWebViewCreated: _webViewCreatedCallback, + onPageFinished: _onPageFinished, + javascriptChannels: { + JavascriptChannel(name: 'JSCSendMessage', onMessageReceived: _jscSendMessage), + JavascriptChannel(name: 'JSCTranslationToggled', onMessageReceived: _jscTranslationToggled), + }); + } + + void _createSession() { + // Initialize Session object + final options = {}; + + options['appId'] = widget.session.appId; + + if (widget.session.signature != null) { + options["signature"] = widget.session.signature; + } + + execute('const options = ${json.encode(options)};'); + + final variableName = getUserVariableName(widget.session.me); + execute('options["me"] = $variableName;'); + + execute('const session = new Talk.Session(options);'); + } + + void _createChatBox() { + _oldOptions = ChatBoxOptions( + dir: widget.dir, + messageField: widget.messageField, + showChatHeader: widget.showChatHeader, + showTranslationToggle: widget.showTranslationToggle, + theme: widget.theme, + translateConversations: widget.translateConversations, + ); + + _oldHighlightedWords = List.of(widget.highlightedWords); + _oldMessageFilter = MessagePredicate.of(widget.messageFilter); + + execute('chatBox = session.createChatbox(${_oldOptions!.getJsonString(this)});'); + + execute('chatBox.on("sendMessage", (event) => JSCSendMessage.postMessage(JSON.stringify(event)));'); + execute('chatBox.on("translationToggled", (event) => JSCTranslationToggled.postMessage(JSON.stringify(event)));'); + } + + bool _checkRecreateChatBox() { + final options = ChatBoxOptions( + dir: widget.dir, + messageField: widget.messageField, + showChatHeader: widget.showChatHeader, + showTranslationToggle: widget.showTranslationToggle, + theme: widget.theme, + translateConversations: widget.translateConversations, + ); + + if (options != _oldOptions) { + execute('chatBox.destroy();'); + _createChatBox(); + + return true; + } else { + return false; + } + } + + void _createConversation() { + final result = {}; + + _oldAsGuest = widget.asGuest; + if (_oldAsGuest != null) { + result['asGuest'] = _oldAsGuest; + } + + _oldConversation = widget.conversation; + if (_oldConversation != null) { + execute('chatBox.select(${getConversationVariableName(_oldConversation!)}, ${json.encode(result)});'); + } else { + if (result.isNotEmpty) { + execute('chatBox.select(undefined, ${json.encode(result)});'); + } else { + execute('chatBox.select(undefined);'); + } + } + } + + bool _checkRecreateConversation() { + if ((widget.asGuest != _oldAsGuest) || (widget.conversation != _oldConversation)) { + _createConversation(); + + return true; + } + + return false; + } + + void _setHighlightedWords() { + _oldHighlightedWords = List.of(widget.highlightedWords); + + execute('chatBox.setHighlightedWords(${json.encode(_oldHighlightedWords)});'); + } + + bool _checkHighlightedWords() { + if (!listEquals(widget.highlightedWords, _oldHighlightedWords)) { + _setHighlightedWords(); + + return true; + } + + return false; + } + + void _setMessageFilter() { + _oldMessageFilter = MessagePredicate.of(widget.messageFilter); + + execute('chatBox.setMessageFilter(${json.encode(_oldMessageFilter)});'); + } + + bool _checkMessageFilter() { + if (widget.messageFilter != _oldMessageFilter) { + _setMessageFilter(); + + return true; + } + + return false; + } + + void _webViewCreatedCallback(WebViewController webViewController) async { + if (kDebugMode) { + print('📗 chatbox._webViewCreatedCallback'); + } + + String htmlData = await rootBundle.loadString('packages/talkjs_flutter/assets/index.html'); + Uri uri = Uri.dataFromString(htmlData, mimeType: 'text/html', encoding: Encoding.getByName('utf-8')); + webViewController.loadUrl(uri.toString()); + + _webViewController = webViewController; + } + + void _onPageFinished(String url) { + if (kDebugMode) { + print('📗 chatbox._onPageFinished'); + } + + if (url != 'about:blank') { + // Wait for TalkJS to be ready + // Not all WebViews support top level await, so it's better to use an + // async IIFE + final js = '(async function () { await Talk.ready; }());'; + + if (kDebugMode) { + print('📗 chatbox._onPageFinished: $js'); + } + + _webViewController!.runJavascript(js); + + // Execute any pending instructions + for (var statement in _pending) { + if (kDebugMode) { + print('📗 chatbox._onPageFinished _pending: $statement'); + } + + _webViewController!.runJavascript(statement); + } + } + } + + void _jscSendMessage(JavascriptMessage message) { + if (kDebugMode) { + print('📗 chatbox._jscSendMessage: ${message.message}'); + } + + widget.onSendMessage?.call(SendMessageEvent.fromJson(json.decode(message.message))); + } + + void _jscTranslationToggled(JavascriptMessage message) { + if (kDebugMode) { + print('📗 chatbox._jscTranslationToggled: ${message.message}'); + } + + widget.onTranslationToggled?.call(TranslationToggledEvent.fromJson(json.decode(message.message))); + } + + /// For internal use only. Implementation detail that may change anytime. + /// + /// Return a string with a unique ID + String getUniqueId() { + final id = _idCounter; + + _idCounter += 1; + + return '_$id'; + } + + /// For internal use only. Implementation detail that may change anytime. + /// + /// Returns the JavaScript variable name of the Talk.User object associated + /// with the given [User] + String getUserVariableName(User user) { + if (_users[user.id] == null) { + // Generate unique variable name + final variableName = 'user${getUniqueId()}'; + + _users[user.id] = variableName; + + execute('let $variableName = new Talk.User(${user.getJsonString()});'); + + _userObjs[user.id] = User.of(user); + } else if (_userObjs[user.id] != user) { + final variableName = _users[user.id]!; + + execute('$variableName = new Talk.User(${user.getJsonString()});'); + + _userObjs[user.id] = User.of(user); + } + + return _users[user.id]!; + } + + /// For internal use only. Implementation detail that may change anytime. + String getConversationVariableName(Conversation conversation) { + if (_conversations[conversation.id] == null) { + final variableName = 'conversation${getUniqueId()}'; + + _conversations[conversation.id] = variableName; + + execute('let $variableName = session.getOrCreateConversation("${conversation.id}")'); + + _setConversationAttributes(variableName, conversation); + _setConversationParticipants(variableName, conversation); + + _conversationObjs[conversation.id] = Conversation.of(conversation); + } else if (_conversationObjs[conversation.id] != conversation) { + final variableName = _conversations[conversation.id]!; + + _setConversationAttributes(variableName, conversation); + + if (!setEquals(conversation.participants, _conversationObjs[conversation.id]!.participants)) { + _setConversationParticipants(variableName, conversation); + } + + _conversationObjs[conversation.id] = Conversation.of(conversation); + } + + return _conversations[conversation.id]!; + } + + void _setConversationAttributes(String variableName, Conversation conversation) { + final attributes = {}; + + if (conversation.custom != null) { + attributes['custom'] = conversation.custom; + } + + if (conversation.welcomeMessages != null) { + attributes['welcomeMessages'] = conversation.welcomeMessages; + } + + if (conversation.photoUrl != null) { + attributes['photoUrl'] = conversation.photoUrl; + } + + if (conversation.subject != null) { + attributes['subject'] = conversation.subject; + } + + if (attributes.isNotEmpty) { + execute('$variableName.setAttributes(${json.encode(attributes)});'); + } + } + + void _setConversationParticipants(String variableName, Conversation conversation) { + for (var participant in conversation.participants) { + final userVariableName = getUserVariableName(participant.user); + final result = {}; + + if (participant.access != null) { + result['access'] = participant.access!.getValue(); + } + + if (participant.notify != null) { + result['notify'] = participant.notify; + } + + execute('$variableName.setParticipant($userVariableName, ${json.encode(result)});'); + } + } + + /// For internal use only. Implementation detail that may change anytime. + /// + /// Sets the options for ChatBoxOptions for the properties where there exists + /// both a declarative option and an imperative method + void setExtraOptions(Map result) { + result['highlightedWords'] = widget.highlightedWords; + result['messageFilter'] = widget.messageFilter; + } + + /// For internal use only. Implementation detail that may change anytime. + /// + /// Evaluates the JavaScript statement given. + void execute(String statement) { + final controller = _webViewController; + + if (kDebugMode) { + print('📘 chatbox.execute: $statement'); + } + + if (controller != null) { + controller.runJavascript(statement); + } else { + this._pending.add(statement); + } + } +} + diff --git a/lib/src/chatoptions.dart b/lib/src/chatoptions.dart new file mode 100644 index 0000000..7a0c9ea --- /dev/null +++ b/lib/src/chatoptions.dart @@ -0,0 +1,265 @@ +import 'dart:convert'; +import 'dart:ui'; + +import './chatbox.dart'; + +/// The values that dictate the chat direction. +enum TextDirection { + /// right-to-left + rtl, + /// left-to-right + ltr, +} + + +/// Settings that affect the behavior of the message field +class MessageFieldOptions { + /// Determines whether the message field should automatically focus when the + /// user navigates. + /// + /// Defaults to "smart", which means that the message field gets focused + /// whenever a conversation is selected, if possible without negative side + /// effects. + /// If you need more control, consider setting [autofocus] to false and + /// calling focus() at appropriate times. + final bool? autofocus; // Convert to "smart" + + /// If set to true, pressing the enter key sends the message + /// (if there is text in the message field). + /// + /// When set to false, the only way to send a message is by clicking or + /// touching the "Send" button. + /// Defaults to true. + final bool? enterSendsMessage; + + /// The text displayed in the message field when the user hasn't started + /// typing anything. + final String? placeholder; + + /// This enables spell checking. + /// + /// Note that setting this to true may also enable autocorrect on some mobile + /// devices. + /// Defaults to false + final bool? spellcheck; + + /// TODO: visible + + const MessageFieldOptions({this.autofocus, this.enterSendsMessage, this.placeholder, this.spellcheck}); + + Map toJson() { + final result = {}; + + if (autofocus != null) { + if (autofocus == true) { + result['autofocus'] = 'smart'; + } else { + result['autofocus'] = autofocus; + } + } + + if (autofocus != null) { + result['enterSendsMessage'] = enterSendsMessage; + } + + if (autofocus != null) { + result['placeholder'] = placeholder; + } + + if (autofocus != null) { + result['spellcheck'] = spellcheck; + } + + return result; + } + + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (!(other is MessageFieldOptions)) { + return false; + } + + if (autofocus != other.autofocus) { + return false; + } + + if (enterSendsMessage != other.enterSendsMessage) { + return false; + } + + if (placeholder != other.placeholder) { + return false; + } + + if (spellcheck != other.spellcheck) { + return false; + } + + return true; + } + + int get hashCode => hashValues(autofocus, enterSendsMessage, placeholder, spellcheck); +} + +/// The possible values for showTranslationToggle +enum TranslationToggle { off, on, auto } + +extension TranslationToggleValue on TranslationToggle { + /// Converts this enum's values to String. + dynamic getValue() { + switch (this) { + case TranslationToggle.off: + return false; + case TranslationToggle.on: + return true; + case TranslationToggle.auto: + return 'auto'; + } + } +} + +/// The possible values for translateConversations +enum TranslateConversations { off, on, auto } + +extension TranslateConversationsValue on TranslateConversations { + /// Converts this enum's values to String. + dynamic getValue() { + switch (this) { + case TranslateConversations.off: + return false; + case TranslateConversations.on: + return true; + case TranslateConversations.auto: + return 'auto'; + } + } +} + +/// Options to configure the behaviour of the [ChatBox] UI. +class ChatBoxOptions { + /// Controls the text direction (for supporting right-to-left languages such + /// as Arabic and Hebrew). + /// + /// Defaults to [TextDirection.rtl]. + final TextDirection? dir; + + /// Settings that affect the behavior of the message field + final MessageFieldOptions? messageField; + + /// TODO: messageFilter + + /// Used to control if the Chat Header is displayed in the UI. + /// + /// Defaults to true. + final bool? showChatHeader; + + /// Set this to on to show a translation toggle in all conversations. + /// Set this to auto to show a translation toggle in conversations where there are participants with different locales. + final TranslationToggle? showTranslationToggle; + + /// Overrides the theme used for this chat UI. + final String? theme; + + /// TODO: thirdparties + + /// Enables conversation translation with Google Translate. + final TranslateConversations? translateConversations; + + const ChatBoxOptions({ + this.dir, + this.messageField, + this.showChatHeader, + this.showTranslationToggle, + this.theme, + this.translateConversations, + }); + + /// For internal use only. Implementation detail that may change anytime. + /// + /// This method is used instead of toJson, as we need to output valid JS + /// that is not valid JSON. + /// The toJson method is intentionally omitted, to produce an error if + /// someone tries to convert this object to JSON instead of using the + /// getJsonString method. + String getJsonString(ChatBoxState chatBox) { + final result = {}; + + if (dir != null) { + result['dir'] = dir!.name; + } + + if (messageField != null) { + result['messageField'] = messageField; + } + + if (showChatHeader != null) { + result['showChatHeader'] = showChatHeader; + } + + // 'auto' gets the priority over the boolean value + if (showTranslationToggle != null) { + result['showTranslationToggle'] = showTranslationToggle!.getValue(); + } + + if (theme != null) { + result['theme'] = theme; + } + + if (translateConversations != null) { + result['translateConversations'] = translateConversations!.getValue(); + } + + chatBox.setExtraOptions(result); + + return json.encode(result); + } + + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (!(other is ChatBoxOptions)) { + return false; + } + + if (dir != other.dir) { + return false; + } + + if (messageField != other.messageField) { + return false; + } + + if (showChatHeader != other.showChatHeader) { + return false; + } + + if (showTranslationToggle != other.showTranslationToggle) { + return false; + } + + if (theme != other.theme) { + return false; + } + + if (translateConversations != other.translateConversations) { + return false; + } + + return true; + } + + int get hashCode => hashValues( + dir, + messageField, + showChatHeader, + showTranslationToggle, + theme, + translateConversations, + ); +} + diff --git a/lib/src/conversation.dart b/lib/src/conversation.dart new file mode 100644 index 0000000..39787df --- /dev/null +++ b/lib/src/conversation.dart @@ -0,0 +1,207 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +import './session.dart'; +import './user.dart'; + +/// Possible values for participants' permissions +enum ParticipantAccess { read, readWrite } + +extension ParticipantAccessString on ParticipantAccess { + /// Converts this enum's values to String. + String getValue() { + switch (this) { + case ParticipantAccess.read: + return 'Read'; + case ParticipantAccess.readWrite: + return 'ReadWrite'; + } + } +} + +// Participants are users + options relative to this conversation +class Participant { + final User user; + + final ParticipantAccess? access; + + final bool? notify; + + const Participant(this.user, {this.access, this.notify}); + + Participant.of(Participant other) + : user = User.of(other.user), + access = other.access, + notify = other.notify; + + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (!(other is Participant)) { + return false; + } + + if (user != other.user) { + return false; + } + + if (access != other.access) { + return false; + } + + if (notify != other.notify) { + return false; + } + + return true; + } + + int get hashCode => hashValues(user, access, notify); +} + +/// This represents a conversation that is about to be created, fetched, or +/// updated. +/// +/// You can use this object to set up or modify a conversation before showing it. +/// Note: any changes you make here will not be sent to TalkJS immediately. +/// Instead, instantiate a TalkJS UI using methods such as [Session.createInbox]. +class _BaseConversation { + /// The unique conversation identifier. + final String id; + + /// Custom metadata for this conversation + final Map? custom; + + /// Messages sent at the beginning of a chat. + /// + /// The messages will appear as system messages. + final List? welcomeMessages; + + /// The URL to a photo which will be shown as the photo for the conversation. + final String? photoUrl; + + /// The conversation subject which will be displayed in the chat header. + final String? subject; + + const _BaseConversation({ + required this.id, + this.custom, + this.welcomeMessages, + this.photoUrl, + this.subject, + }); +} + +class Conversation extends _BaseConversation { + // The participants for this conversation + final Set participants; + + // To tie the conversation to a session + final Session _session; + + const Conversation({ + required Session session, + required String id, + Map? custom, + List? welcomeMessages, + String? photoUrl, + String? subject, + required this.participants, + }) + : _session = session, + super( + id: id, + custom: custom, + welcomeMessages: welcomeMessages, + photoUrl: photoUrl, + subject: subject, + ); + + Conversation.of(Conversation other) + : _session = other._session, + participants = Set.of(other.participants.map((participant) => Participant.of(participant))), + super( + id: other.id, + custom: (other.custom != null ? Map.of(other.custom!) : null), + welcomeMessages: (other.welcomeMessages != null ? List.of(other.welcomeMessages!) : null), + photoUrl: other.photoUrl, + subject: other.subject + ); + +/* TODO: conversation.sendMessage is to be rewritten so that it works when we don't show the WebView + /// Sends a text message in a given conversation. + void sendMessage(String text, {Map? custom}) { + final result = {}; + + if (custom != null) { + result['custom'] = custom; + } + + session.execute('$variableName.sendMessage("$text", ${json.encode(result)});'); + } + */ + + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (!(other is Conversation)) { + return false; + } + + if (_session != other._session) { + return false; + } + + if (!setEquals(participants, other.participants)) { + return false; + } + + if (id != other.id) { + return false; + } + + if (!mapEquals(custom, other.custom)) { + return false; + } + + if (!listEquals(welcomeMessages, other.welcomeMessages)) { + return false; + } + + if (photoUrl != other.photoUrl) { + return false; + } + + if (subject != other.subject) { + return false; + } + + return true; + } + + int get hashCode => hashValues( + _session, + hashList(participants), + id, + (custom != null ? hashList(custom!.keys) : custom), + (custom != null ? hashList(custom!.values) : custom), + (welcomeMessages != null ? hashList(welcomeMessages) : welcomeMessages), + photoUrl, + subject, + ); +} + +class ConversationData extends _BaseConversation { + ConversationData.fromJson(Map json) + : super(id: json['id'], + custom: (json['custom'] != null ? Map.from(json['custom']) : null), + welcomeMessages: (json['welcomeMessages'] != null ? List.from(json['welcomeMessages']) : null), + photoUrl: json['photoUrl'], + subject: json['subject']); +} + diff --git a/lib/src/conversationlist.dart b/lib/src/conversationlist.dart new file mode 100644 index 0000000..dec4714 --- /dev/null +++ b/lib/src/conversationlist.dart @@ -0,0 +1,303 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; + +import 'package:webview_flutter/webview_flutter.dart'; + +import './session.dart'; +import './conversation.dart'; +import './user.dart'; +import './predicate.dart'; + +typedef SelectConversationHandler = void Function(SelectConversationEvent event); + +class SelectConversationEvent { + final ConversationData conversation; + final UserData me; + final List others; + + SelectConversationEvent.fromJson(Map json) + : conversation = ConversationData.fromJson(json['conversation']), + me = UserData.fromJson(json['me']), + others = json['others'].map((user) => UserData.fromJson(user)).toList(); +} + +class ConversationListOptions { + /// Controls if the feed header containing the toggle to enable desktop notifications is shown. + /// Defaults to true. + final bool? showFeedHeader; + + /// Controls whether the user navigating between conversation should count + /// as steps in the browser history. Defaults to true, which means that if the user + /// clicks the browser's back button, they go back to the previous conversation + /// (if any). + /// + /// NOT NEEDED FOR FLUTTER? + //bool? useBrowserHistory; + + /// Whether to show a "Back" button at the top of the chat screen on mobile devices. + /// + /// NOT NEEDED FOR FLUTTER? + //bool? showMobileBackButton; + + /// Overrides the theme used for this chat UI. + final String? theme; + + const ConversationListOptions({this.showFeedHeader, this.theme}); + + /// For internal use only. Implementation detail that may change anytime. + /// + /// This method is used instead of toJson for coherence with ChatBoxOptions. + /// The toJson method is intentionally omitted, to produce an error if + /// someone tries to convert this object to JSON instead of using the + /// getJsonString method. + String getJsonString(ConversationListState conversationList) { + final result = {}; + + if (showFeedHeader != null) { + result['showFeedHeader'] = showFeedHeader; + } + + if (theme != null) { + result['theme'] = theme; + } + + conversationList.setExtraOptions(result); + + return json.encode(result); + } +} + +class ConversationList extends StatefulWidget { + final Session session; + + final bool? showFeedHeader; + + final String? theme; + + final ConversationPredicate feedFilter; + + final SelectConversationHandler? onSelectConversation; + + const ConversationList({ + Key? key, + required this.session, + this.showFeedHeader, + this.theme, + this.feedFilter = const ConversationPredicate(), + this.onSelectConversation, + }) : super(key: key); + + @override + State createState() => ConversationListState(); +} + +class ConversationListState extends State { + /// Used to control the underlying WebView + WebViewController? _webViewController; + bool _webViewCreated = false; + + /// List of JavaScript statements that haven't been executed. + final _pending = []; + + // A counter to ensure that IDs are unique + int _idCounter = 0; + + /// A mapping of user ids to the variable name of the respective JavaScript + /// Talk.User object. + final _users = {}; + + /// Objects stored for comparing changes + ConversationPredicate _oldFeedFilter = const ConversationPredicate(); + + @override + Widget build(BuildContext context) { + if (kDebugMode) { + print('📗 conversationlist.build (_webViewCreated: $_webViewCreated)'); + } + + if (!_webViewCreated) { + _webViewCreated = true; + + _createSession(); + _createConversationList(); + // feedFilter is set as an option for the inbox + + execute('conversationList.mount(document.getElementById("talkjs-container"));'); + } else { + // If it's not the first time that the widget is built, + // then check what needs to be rebuilt + + // TODO: If something has changed in the Session we should do something + _checkFeedFilter(); + } + + return WebView( + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + debuggingEnabled: kDebugMode, + onWebViewCreated: _webViewCreatedCallback, + onPageFinished: _onPageFinished, + javascriptChannels: { + JavascriptChannel(name: 'JSCSelectConversation', onMessageReceived: _jscSelectConversation), + }); + } + + void _createSession() { + // Initialize Session object + final options = {}; + + options['appId'] = widget.session.appId; + + if (widget.session.signature != null) { + options["signature"] = widget.session.signature; + } + + execute('const options = ${json.encode(options)};'); + + final variableName = getUserVariableName(widget.session.me); + execute('options["me"] = $variableName;'); + + execute('const session = new Talk.Session(options);'); + } + + void _createConversationList() { + final options = ConversationListOptions( + showFeedHeader: widget.showFeedHeader, + theme: widget.theme, + ); + + _oldFeedFilter = ConversationPredicate.of(widget.feedFilter); + + execute('const conversationList = session.createInbox(${options.getJsonString(this)});'); + + execute('''conversationList.on("selectConversation", (event) => { + event.preventDefault(); + JSCSelectConversation.postMessage(JSON.stringify(event)); + }); '''); + } + + void _setFeedFilter() { + _oldFeedFilter = ConversationPredicate.of(widget.feedFilter); + + execute('conversationList.setFeedFilter(${json.encode(_oldFeedFilter)});'); + } + + bool _checkFeedFilter() { + if (widget.feedFilter != _oldFeedFilter) { + _setFeedFilter(); + + return true; + } + + return false; + } + + void _webViewCreatedCallback(WebViewController webViewController) async { + if (kDebugMode) { + print('📗 conversationlist._webViewCreatedCallback'); + } + + String htmlData = await rootBundle.loadString('packages/talkjs_flutter/assets/index.html'); + Uri uri = Uri.dataFromString(htmlData, mimeType: 'text/html', encoding: Encoding.getByName('utf-8')); + webViewController.loadUrl(uri.toString()); + + _webViewController = webViewController; + } + + void _onPageFinished(String url) { + if (kDebugMode) { + print('📗 conversationlist._onPageFinished'); + } + + if (url != 'about:blank') { + // Wait for TalkJS to be ready + // Not all WebViews support top level await, so it's better to use an + // async IIFE + final js = '(async function () { await Talk.ready; }());'; + + if (kDebugMode) { + print('📗 conversationlist._onPageFinished: $js'); + } + + _webViewController!.runJavascript(js); + + // Execute any pending instructions + for (var statement in _pending) { + if (kDebugMode) { + print('📗 conversationlist._onPageFinished _pending: $statement'); + } + + _webViewController!.runJavascript(statement); + } + } + } + + void _jscSelectConversation(JavascriptMessage message) { + if (kDebugMode) { + print('📗 conversationlist._jscSelectConversation: ${message.message}'); + } + + widget.onSelectConversation?.call(SelectConversationEvent.fromJson(json.decode(message.message))); + } + + /// For internal use only. Implementation detail that may change anytime. + /// + /// Return a string with a unique ID + String getUniqueId() { + final id = _idCounter; + + _idCounter += 1; + + return '_$id'; + } + + /// For internal use only. Implementation detail that may change anytime. + /// + /// Returns the JavaScript variable name of the Talk.User object associated + /// with the given [User] + String getUserVariableName(User user) { + if (_users[user.id] == null) { + // Generate unique variable name + final variableName = 'user${getUniqueId()}'; + + execute('const $variableName = new Talk.User(${user.getJsonString()});'); + _users[user.id] = variableName; + } + + return _users[user.id]!; + } + + /// For internal use only. Implementation detail that may change anytime. + /// + /// Sets the options for ConversationListOptions for the properties where there exists + /// both a declarative option and an imperative method + void setExtraOptions(Map result) { + result['feedFilter'] = widget.feedFilter; + } + + /// For internal use only. Implementation detail that may change anytime. + /// + /// Evaluates the JavaScript statement given. + void execute(String statement) { + final controller = _webViewController; + + if (kDebugMode) { + print('📘 conversationlist.execute: $statement'); + } + + if (controller != null) { + controller.runJavascript(statement); + } else { + this._pending.add(statement); + } + } + + /// Destroys this UI element and removes all event listeners it has running. + void destroy() { + execute('conversationList.destroy();'); + } +} + diff --git a/lib/src/message.dart b/lib/src/message.dart new file mode 100644 index 0000000..57df158 --- /dev/null +++ b/lib/src/message.dart @@ -0,0 +1,51 @@ + +enum MessageType { UserMessage, SystemMessage } + +class Attachment { + final String url; + final int size; + + Attachment.fromJson(Map json) + : url = json['url'], + size = json['size']; +} + +class SentMessage { + /// The message ID of the message that was sent + final String? id; + + /// The ID of the conversation that the message belongs to + final String conversationId; + + /// Identifies the message as either a User message or System message + final MessageType type; + + /// Contains an Array of User.id's that have read the message + final List readBy; + + /// Contains the user ID for the person that sent the message + final String senderId; // redundant since the user is always me, but keeps it consistant + + /// Contains the message's text + final String? text; + + /// Only given if the message contains a file. An object with the URL and filesize (in bytes) of the given file. + final Attachment? attachment; + + /// Only given if the message contains a location. An array of two numbers which represent the longitude and latitude of this location, respectively. Only given if this message is a shared location. + /// + /// Example: + /// [51.481083, -3.178306] + final List? location; + + SentMessage.fromJson(Map json) + : id = json['id'], + conversationId = json['conversationId'], + type = (json['type'] == 'UserMessage' ? MessageType.UserMessage : MessageType.SystemMessage), + readBy = List.from(json['readBy']), + senderId = json['senderId'], + text = json['text'], + attachment = (json['attachment'] != null ? Attachment.fromJson(json['attachment']) : null), + location = (json['location'] != null ? List.from(json['location']) : null); +} + diff --git a/lib/src/predicate.dart b/lib/src/predicate.dart new file mode 100644 index 0000000..1b935d4 --- /dev/null +++ b/lib/src/predicate.dart @@ -0,0 +1,406 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +class FieldPredicate { + final String _operand; + String? _value; + List? _values; + + FieldPredicate.equals(T value) : _operand = '==', _value = value.toString(); + FieldPredicate.notEquals(T value) : _operand = '!=', _value = value.toString(); + FieldPredicate.oneOf(List values) : _operand = 'oneOf', _values = values.map((value) => value.toString()).toList(); + FieldPredicate.notOneOf(List values) : _operand = '!oneOf', _values = values.map((value) => value.toString()).toList(); + + FieldPredicate.of(FieldPredicate other) + : _operand = other._operand, + _value = other._value, + _values = (other._values != null ? List.of(other._values!) : null); + + @override + String toString() { + return json.encode(this); + } + + dynamic toJson() { + final result = []; + + result.add(_operand); + + if (_value != null) { + result.add(_value); + } + + if (_values != null) { + result.add(_values); + } + + return result; + } + + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (!(other is FieldPredicate)) { + return false; + } + + if (_operand != other._operand) { + return false; + } + + if (_value != other._value) { + return false; + } + + if (!listEquals(_values, other._values)) { + return false; + } + + return true; + } + + int get hashCode => hashValues( + _operand, + _value, + (_values != null ? hashList(_values) : _values), + ); +} + +class CustomFieldPredicate extends FieldPredicate { + bool? _exists; + + CustomFieldPredicate.equals(String value) : super.equals(value); + CustomFieldPredicate.notEquals(String value) : super.notEquals(value); + CustomFieldPredicate.oneOf(List values) : super.oneOf(values); + CustomFieldPredicate.notOneOf(List values) : super.notOneOf(values); + CustomFieldPredicate.exists() : _exists = true, super.equals(''); + CustomFieldPredicate.notExists() : _exists = false, super.notEquals(''); + + CustomFieldPredicate.of(CustomFieldPredicate other) : _exists = other._exists, super.of(other); + + @override + dynamic toJson() { + if (_exists == null) { + return super.toJson(); + } else if (_exists!) { + return 'exists'; + } else { + return '!exists'; + } + } + + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (!(other is CustomFieldPredicate)) { + return false; + } + + if (_operand != other._operand) { + return false; + } + + if (_value != other._value) { + return false; + } + + if (!listEquals(_values, other._values)) { + return false; + } + + if (_exists != other._exists) { + return false; + } + + return true; + } + + int get hashCode => hashValues( + _operand, + _value, + (_values != null ? hashList(_values) : _values), + _exists, + ); +} + +class ConversationAccessLevel { + final String _value; + + const ConversationAccessLevel._(this._value); + + static const ConversationAccessLevel none = ConversationAccessLevel._('None'); + static const ConversationAccessLevel read = ConversationAccessLevel._('Read'); + static const ConversationAccessLevel readWrite = ConversationAccessLevel._('ReadWrite'); + + @override + String toString() => _value; +} + +class ConversationPredicate { + /// Only select conversations that the current user as specific access to. + final FieldPredicate? access; + + /// Only select conversations that have particular custom fields set to particular values. + final Map? custom; + + /// Set this field to only select conversations that have, or don't have any, unread messages. + final bool? hasUnreadMessages; + + const ConversationPredicate({this.access, this.custom, this.hasUnreadMessages}); + + ConversationPredicate.of(ConversationPredicate other) + : access = (other.access != null ? FieldPredicate.of(other.access!) : null), + custom = (other.custom != null ? Map.of(other.custom!) : null), + hasUnreadMessages = other.hasUnreadMessages; + + @override + String toString() { + return json.encode(this); + } + + Map toJson() { + final result = {}; + + if (access != null) { + result['access'] = access; + } + + if (custom != null) { + result['custom'] = custom; + } + + if (hasUnreadMessages != null) { + result['hasUnreadMessages'] = hasUnreadMessages; + } + + return result; + } + + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (!(other is ConversationPredicate)) { + return false; + } + + if (access != other.access) { + return false; + } + + if (!mapEquals(custom, other.custom)) { + return false; + } + + if (hasUnreadMessages != other.hasUnreadMessages) { + return false; + } + + return true; + } + + int get hashCode => hashValues( + access, + (custom != null ? hashList(custom!.keys) : custom), + (custom != null ? hashList(custom!.values) : custom), + hasUnreadMessages, + ); +} + +class MessageOrigin { + final String _value; + + const MessageOrigin._(this._value); + + static const MessageOrigin web = MessageOrigin._('web'); + static const MessageOrigin rest = MessageOrigin._('rest'); + static const MessageOrigin email = MessageOrigin._('email'); + static const MessageOrigin import = MessageOrigin._('import'); + + @override + String toString() => _value; +} + +class MessageType { + final String _value; + + const MessageType._(this._value); + + static const MessageType userMessage = MessageType._('UserMessage'); + static const MessageType systemMessage = MessageType._('SystemMessage'); + + @override + String toString() => _value; +} + +class SenderPredicate { + final FieldPredicate? id; + final Map? custom; + final FieldPredicate? locale; + final FieldPredicate? role; + + const SenderPredicate({this.id, this.custom, this.locale, this.role}); + + SenderPredicate.of(SenderPredicate other) + : id = (other.id != null ? FieldPredicate.of(other.id!) : null), + custom = (other.custom != null ? Map.of(other.custom!) : null), + locale = (other.locale != null ? FieldPredicate.of(other.locale!) : null), + role = (other.role != null ? FieldPredicate.of(other.role!) : null); + + @override + String toString() { + return json.encode(this); + } + + Map toJson() { + final result = {}; + + if (id != null) { + result['id'] = id; + } + + if (custom != null) { + result['custom'] = custom; + } + + if (locale != null) { + result['locale'] = locale; + } + + if (role != null) { + result['role'] = role; + } + + return result; + } + + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (!(other is SenderPredicate)) { + return false; + } + + if (id != other.id) { + return false; + } + + if (!mapEquals(custom, other.custom)) { + return false; + } + + if (locale != other.locale) { + return false; + } + + if (role != other.role) { + return false; + } + + return true; + } + + int get hashCode => hashValues( + id, + (custom != null ? hashList(custom!.keys) : custom), + (custom != null ? hashList(custom!.values) : custom), + locale, + role, + ); +} + +class MessagePredicate { + /// Only select messages that have particular custom fields set to particular values. + final Map? custom; + + /// Only show messages that were sent by users (web), through the REST API (rest), via + /// reply-to-email (email) or via the import REST API (import). + final FieldPredicate? origin; + + /// Only show messages that are sent by a sender that has all of the given properties + final SenderPredicate? sender; + + /// Only show messages of a given type + final FieldPredicate? type; + + const MessagePredicate({this.custom, this.origin, this.sender, this.type}); + + MessagePredicate.of(MessagePredicate other) + : custom = (other.custom != null ? Map.of(other.custom!) : null), + origin = (other.origin != null ? FieldPredicate.of(other.origin!) : null), + sender = (other.sender != null ? SenderPredicate.of(other.sender!) : null), + type = (other.type != null ? FieldPredicate.of(other.type!) : null); + + @override + String toString() { + return json.encode(this); + } + + Map toJson() { + final result = {}; + + if (custom != null) { + result['custom'] = custom; + } + + if (origin != null) { + result['origin'] = origin; + } + + if (sender != null) { + result['sender'] = sender; + } + + if (type != null) { + result['type'] = type; + } + + return result; + } + + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (!(other is MessagePredicate)) { + return false; + } + + if (!mapEquals(custom, other.custom)) { + return false; + } + + if (origin != other.origin) { + return false; + } + + if (sender != other.sender) { + return false; + } + + if (type != other.type) { + return false; + } + + return true; + } + + int get hashCode => hashValues( + (custom != null ? hashList(custom!.keys) : custom), + (custom != null ? hashList(custom!.values) : custom), + origin, + sender, + type, + ); +} + diff --git a/lib/src/session.dart b/lib/src/session.dart new file mode 100644 index 0000000..dd299b8 --- /dev/null +++ b/lib/src/session.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import './user.dart'; +import './conversation.dart'; + +/// A session represents a currently active user. +class Session with ChangeNotifier { + /// Your TalkJS AppId that can be found your TalkJS [dashboard](https://talkjs.com/dashboard). + final String appId; + + /// The TalkJS [User] associated with the current user in your application. + User? _me; + + User get me { + if (_me == null) { + throw StateError('Set the me property before using the Session object'); + } else { + return _me!; + } + } + + set me(User user) { + if (_me != null) { + throw StateError('The me property has already been set for the Session object'); + } else { + _me = user; + } + } + + /// A digital signature of the current [User.id] + /// + /// This is the HMAC-SHA256 hash of the current user id, signed with your + /// TalkJS secret key. + /// DO NOT embed your secret key within your mobile application / frontend + /// code. + final String? signature; + + Session({required this.appId, this.signature}); + + User getUser({ + required String id, + required String name, + List? email, + List? phone, + String? availabilityText, + String? locale, + String? photoUrl, + String? role, + Map? custom, + String? welcomeMessage, + }) => User( + session: this, + id: id, + name: name, + email: email, + phone: phone, + availabilityText: availabilityText, + locale: locale, + photoUrl: photoUrl, + role: role, + custom: custom, + welcomeMessage: welcomeMessage, + ); + + User getUserById(String id) => User.fromId(id, this); + + Conversation getConversation({ + required String id, + Map? custom, + List? welcomeMessages, + String? photoUrl, + String? subject, + Set participants = const {}, + }) => Conversation( + session: this, + id: id, + custom: custom, + welcomeMessages: welcomeMessages, + photoUrl: photoUrl, + subject: subject, + participants: participants, + ); +} + diff --git a/lib/src/user.dart b/lib/src/user.dart new file mode 100644 index 0000000..76532e9 --- /dev/null +++ b/lib/src/user.dart @@ -0,0 +1,266 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +import './session.dart'; + +/// A user of your app. +/// +/// TalkJS uses the [id] to uniquely identify this user. All other fields of a +/// [User] are allowed to vary over time and the TalkJS database will update its +/// fields accordingly. +class _BaseUser { + /// The default message a user sees when starting a chat with this person. + /// + /// This acts similarly to [welcomeMessage] with the difference being that + /// this appears as a system message. + final String? availabilityText; + + /// Custom metadata for this user. + final Map? custom; + + /// One or more email address belonging to this user. + /// + /// The email addresses will be used for [Email Notifications](https://talkjs.com/docs/Features/Notifications/Email_Notifications/index.html) + /// if they are enabled. + final List? email; + + /// One or more phone numbers belonging to this user. + /// + /// The phone numbers will be used for [SMS Notifications](https://talkjs.com/docs/Features/Notifications/SMS_Notifications.html). + /// This feature requires standard plan and up. + final List? phone; + + /// The unique user identifier. + final String id; + + /// This user's name which will be displayed on the TalkJS UI + final String name; + + /// The language on the UI. + /// + /// This field expects an [IETF language tag](https://www.w3.org/International/articles/language-tags/). + final String? locale; + + /// An optional URL to a photo which will be displayed as this user's avatar + final String? photoUrl; + + /// This user's role which allows you to change the behaviour of TalkJS for + /// different users. + final String? role; + + /// The default message a user sees when starting a chat with this person. + final String? welcomeMessage; + + const _BaseUser({ + required this.id, + required this.name, + this.email, + this.phone, + this.availabilityText, + this.locale, + this.photoUrl, + this.role, + this.custom, + this.welcomeMessage, + }); +} + +class User extends _BaseUser { + // To support creating users with only an id + final bool _idOnly; + + // To tie the user to a session + final Session _session; + + const User({ + required Session session, + required String id, + required String name, + List? email, + List? phone, + String? availabilityText, + String? locale, + String? photoUrl, + String? role, + Map? custom, + String? welcomeMessage, + }) + : _session = session, + _idOnly = false, + super( + id: id, + name: name, + email: email, + phone: phone, + availabilityText: availabilityText, + locale: locale, + photoUrl: photoUrl, + role: role, + custom: custom, + welcomeMessage: welcomeMessage, + ); + + const User.fromId(String id, Session session) : _session = session, _idOnly = true, super(id: id, name: ''); + + User.of(User other) + : _session = other._session, + _idOnly = other._idOnly, + super( + id: other.id, + name: other.name, + email: (other.email != null ? List.of(other.email!) : null), + phone: (other.phone != null ? List.of(other.phone!) : null), + availabilityText: other.availabilityText, + locale: other.locale, + photoUrl: other.photoUrl, + role: other.role, + custom: (other.custom != null ? Map.of(other.custom!) : null), + welcomeMessage: other.welcomeMessage, + ); + + /// For internal use only. Implementation detail that may change anytime. + /// + /// This method is used instead of toJson, as we need to output valid JS + /// that is not valid JSON. + /// The toJson method is intentionally omitted, to produce an error if + /// someone tries to convert this object to JSON instead of using the + /// getJsonString method. + String getJsonString() { + if (this._idOnly) { + return '"$id"'; + } else { + final result = {}; + + result['id'] = id; + result['name'] = name; + + if (email != null) { + result['email'] = email; + } + + if (phone != null) { + result['phone'] = phone; + } + + if (availabilityText != null) { + result['availabilityText'] = availabilityText; + } + + if (locale != null) { + result['locale'] = locale; + } + + if (photoUrl != null) { + result['photoUrl'] = photoUrl; + } + + if (role != null) { + result['role'] = role; + } + + if (welcomeMessage != null) { + result['welcomeMessage'] = welcomeMessage; + } + + if (custom != null) { + result['custom'] = custom; + } + + return json.encode(result); + } + } + + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (!(other is User)) { + return false; + } + + if (_session != other._session) { + return false; + } + + if (_idOnly != other._idOnly) { + return false; + } + + if (availabilityText != other.availabilityText) { + return false; + } + + if (!mapEquals(custom, other.custom)) { + return false; + } + + if (!listEquals(email, other.email)) { + return false; + } + + if (!listEquals(phone, other.phone)) { + return false; + } + + if (id != other.id) { + return false; + } + + if (name != other.name) { + return false; + } + + if (locale != other.locale) { + return false; + } + + if (photoUrl != other.photoUrl) { + return false; + } + + if (role != other.role) { + return false; + } + + if (welcomeMessage != other.welcomeMessage) { + return false; + } + + return true; + } + + int get hashCode => hashValues( + _session, + _idOnly, + availabilityText, + (custom != null ? hashList(custom!.keys) : custom), + (custom != null ? hashList(custom!.values) : custom), + (email != null ? hashList(email) : email), + (phone != null ? hashList(phone) : phone), + id, + name, + locale, + photoUrl, + role, + welcomeMessage, + ); +} + +class UserData extends _BaseUser { + UserData.fromJson(Map json) + : super(availabilityText: json['availabilityText'], + custom: (json['custom'] != null ? Map.from(json['custom']) : null), + email: (json['email'] != null ? (json['email'] is String ? [json['email']] : List.from(json['email'])) : null), + phone: (json['phone'] != null ? (json['phone'] is String ? [json['phone']] : List.from(json['phone'])) : null), + id: json['id'], + name: json['name'], + locale: json['locale'], + photoUrl: json['photoUrl'], + role: json['role'], + welcomeMessage: json['welcomeMessage'], + ); +} + diff --git a/lib/talkjs.dart b/lib/talkjs.dart new file mode 100644 index 0000000..14d0a6e --- /dev/null +++ b/lib/talkjs.dart @@ -0,0 +1,32 @@ +library talkjs; + +import 'dart:convert' show json, utf8; +import 'package:crypto/crypto.dart' show sha1; + +export 'src/chatoptions.dart'; +export 'src/conversation.dart'; +export 'src/session.dart'; +export 'src/chatbox.dart'; +export 'src/user.dart'; +export 'src/conversationlist.dart'; +export 'src/predicate.dart'; + +/// The [Talk] object provides utility functions to help use TalkJS. +class Talk { + + /// Compute a Conversation ID based on participants' ids given. + /// + /// The order of the parameters does not matter. + /// Use this method if you want to simply create a conversation between two + /// users, not related to a particular product, order or transaction. + static String oneOnOneId(String me, String other) { + List ids = [me, other]; + ids.sort(); + + final encoded = json.encode(ids); + final digest = sha1.convert(utf8.encode(encoded)); + + final hash = digest.toString().toLowerCase(); + return hash.substring(0, 20); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..0adf604 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,189 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.8.2" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.0" + crypto: + dependency: "direct main" + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.11" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.3" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.8.2" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + url: "https://pub.dartlang.org" + source: hosted + version: "2.7.1" +sdks: + dart: ">=2.15.0 <3.0.0" + flutter: ">=2.8.1" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..ea2d399 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,58 @@ +name: talkjs_flutter +description: Official TalkJS SDK for Flutter +version: 0.1.0 +homepage: https://talkjs.com + +environment: + sdk: ">=2.15.0 <3.0.0" + flutter: ">=2.8.1" + +dependencies: + flutter: + sdk: flutter + + webview_flutter: ^3.0.0 + crypto: ^3.0.1 + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + assets: + - packages/talkjs_flutter/assets/index.html + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/test/talkjs_test.dart b/test/talkjs_test.dart new file mode 100644 index 0000000..b7faab8 --- /dev/null +++ b/test/talkjs_test.dart @@ -0,0 +1,251 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:talkjs/talkjs.dart'; + +void main() { + test('test oneOnOneId', () { + expect(Talk.oneOnOneId('1234', 'abcd'), '35ec37e6e0ca43ac8ccc'); + expect(Talk.oneOnOneId('abcd', '1234'), '35ec37e6e0ca43ac8ccc'); + }); + + test('test FieldPredicate ==', () { + expect(FieldPredicate.equals(ConversationAccessLevel.readWrite) == FieldPredicate.equals(ConversationAccessLevel.readWrite), true); + expect(FieldPredicate.notOneOf(['it', 'fr']) == FieldPredicate.notOneOf(['it', 'fr']), true); + }); + + test('test CustomFieldPredicate ==', () { + expect(CustomFieldPredicate.exists() == CustomFieldPredicate.exists(), true); + expect(CustomFieldPredicate.equals('it') == CustomFieldPredicate.equals('it'), true); + expect(CustomFieldPredicate.oneOf(['it', 'fr']) == CustomFieldPredicate.oneOf(['it', 'fr']), true); + }); + + test('test ConversationPredicate ==', () { + expect( + ConversationPredicate( + access: FieldPredicate.notEquals(ConversationAccessLevel.none), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + hasUnreadMessages: false, + ) == ConversationPredicate( + access: FieldPredicate.notEquals(ConversationAccessLevel.none), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + hasUnreadMessages: false, + ) + , true); + }); + + test('test SenderPredicate ==', () { + expect( + SenderPredicate( + id: FieldPredicate.notEquals('INVALID_ID'), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + locale: FieldPredicate.notOneOf(['it', 'fr']), + role: FieldPredicate.notEquals('admin'), + ) == SenderPredicate( + id: FieldPredicate.notEquals('INVALID_ID'), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + locale: FieldPredicate.notOneOf(['it', 'fr']), + role: FieldPredicate.notEquals('admin'), + ) + , true); + }); + + test('test MessagePredicate ==', () { + expect( + MessagePredicate( + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + origin: FieldPredicate.equals(MessageOrigin.web), + sender: SenderPredicate( + id: FieldPredicate.notEquals('INVALID_ID'), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + locale: FieldPredicate.notOneOf(['it', 'fr']), + role: FieldPredicate.notEquals('admin'), + ), + type: FieldPredicate.notEquals(MessageType.systemMessage), + ) == MessagePredicate( + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + origin: FieldPredicate.equals(MessageOrigin.web), + sender: SenderPredicate( + id: FieldPredicate.notEquals('INVALID_ID'), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + locale: FieldPredicate.notOneOf(['it', 'fr']), + role: FieldPredicate.notEquals('admin'), + ), + type: FieldPredicate.notEquals(MessageType.systemMessage), + ) + , true); + }); + + test('test FieldPredicate.of', () { + expect(FieldPredicate.of(FieldPredicate.equals(ConversationAccessLevel.readWrite)) == FieldPredicate.equals(ConversationAccessLevel.readWrite), true); + expect(FieldPredicate.of(FieldPredicate.notOneOf(['it', 'fr'])) == FieldPredicate.notOneOf(['it', 'fr']), true); + }); + + test('test CustomFieldPredicate of', () { + expect(CustomFieldPredicate.of(CustomFieldPredicate.exists()) == CustomFieldPredicate.exists(), true); + expect(CustomFieldPredicate.of(CustomFieldPredicate.equals('it')) == CustomFieldPredicate.equals('it'), true); + expect(CustomFieldPredicate.of(CustomFieldPredicate.oneOf(['it', 'fr'])) == CustomFieldPredicate.oneOf(['it', 'fr']), true); + }); + + test('test ConversationPredicate of', () { + expect( + ConversationPredicate.of(ConversationPredicate( + access: FieldPredicate.notEquals(ConversationAccessLevel.none), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + hasUnreadMessages: false, + )) == ConversationPredicate( + access: FieldPredicate.notEquals(ConversationAccessLevel.none), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + hasUnreadMessages: false, + ) + , true); + }); + + test('test SenderPredicate of', () { + expect( + SenderPredicate.of(SenderPredicate( + id: FieldPredicate.notEquals('INVALID_ID'), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + locale: FieldPredicate.notOneOf(['it', 'fr']), + role: FieldPredicate.notEquals('admin'), + )) == SenderPredicate( + id: FieldPredicate.notEquals('INVALID_ID'), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + locale: FieldPredicate.notOneOf(['it', 'fr']), + role: FieldPredicate.notEquals('admin'), + ) + , true); + }); + + test('test MessagePredicate of', () { + expect( + MessagePredicate.of(MessagePredicate( + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + origin: FieldPredicate.equals(MessageOrigin.web), + sender: SenderPredicate( + id: FieldPredicate.notEquals('INVALID_ID'), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + locale: FieldPredicate.notOneOf(['it', 'fr']), + role: FieldPredicate.notEquals('admin'), + ), + type: FieldPredicate.notEquals(MessageType.systemMessage), + )) == MessagePredicate( + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + origin: FieldPredicate.equals(MessageOrigin.web), + sender: SenderPredicate( + id: FieldPredicate.notEquals('INVALID_ID'), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + locale: FieldPredicate.notOneOf(['it', 'fr']), + role: FieldPredicate.notEquals('admin'), + ), + type: FieldPredicate.notEquals(MessageType.systemMessage), + ) + , true); + }); + + test('test ConversationPredicate string', () { + expect( + json.encode(ConversationPredicate( + access: FieldPredicate.notEquals(ConversationAccessLevel.none), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + hasUnreadMessages: false, + )), + '{"access":["!=","None"],"custom":{"seller":"exists","category":["oneOf",["shoes","sandals"]],"visibility":["==","visible"]},"hasUnreadMessages":false}' + ); + }); + + test('test MessagePredicate string', () { + expect( + json.encode(MessagePredicate( + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + origin: FieldPredicate.equals(MessageOrigin.web), + sender: SenderPredicate( + id: FieldPredicate.notEquals('INVALID_ID'), + custom: { + 'seller': CustomFieldPredicate.exists(), + 'category': CustomFieldPredicate.oneOf(['shoes', 'sandals']), + 'visibility': CustomFieldPredicate.equals('visible'), + }, + locale: FieldPredicate.notOneOf(['it', 'fr']), + role: FieldPredicate.notEquals('admin'), + ), + type: FieldPredicate.notEquals(MessageType.systemMessage), + )), + '{"custom":{"seller":"exists","category":["oneOf",["shoes","sandals"]],"visibility":["==","visible"]},"origin":["==","web"],"sender":{"id":["!=","INVALID_ID"],"custom":{"seller":"exists","category":["oneOf",["shoes","sandals"]],"visibility":["==","visible"]},"locale":["!oneOf",["it","fr"]],"role":["!=","admin"]},"type":["!=","SystemMessage"]}' + ); + }); +} +