From 4c4c972144234ba0b71158e808f79e02b125a82a Mon Sep 17 00:00:00 2001 From: Radmir Date: Thu, 4 Jun 2026 23:37:47 +0500 Subject: [PATCH] Add native support chat Replace the Chatwoot support WebView with a native chat across the stack: - core: Chatwoot widget client, support message/image endpoints, support primitives and stream event - iOS: chat scene with text and image messages, optimistic send with retry, local image cache, GRDB store - Android: support API client methods and stream handler --- .../services/gemapi/GemDeviceApiClient.kt | 4 - .../core/primitives/generated/Stream.kt | 2 +- .../core/primitives/generated/Support.kt | 29 --- core/apps/api/src/main.rs | 1 - core/apps/api/src/support/client.rs | 6 +- core/apps/api/src/support/mod.rs | 7 +- core/crates/primitives/src/lib.rs | 3 +- core/crates/primitives/src/stream.rs | 4 +- core/crates/primitives/src/support.rs | 30 --- core/crates/support/src/chatwoot.rs | 29 +-- core/crates/support/src/client.rs | 32 +--- core/crates/support/src/constants.rs | 4 - core/crates/support/src/model.rs | 137 +------------ core/crates/support/tests/model_tests.rs | 20 +- ios/Features/Settings/Package.swift | 2 - .../Settings/Scenes/SettingsScene.swift | 10 - ios/Features/Support/Package.swift | 5 + .../Sources/Scenes/ChatwootWebScene.swift | 22 --- .../Sources/Scenes/SupportChatScene.swift | 55 ++++++ .../Support/Sources/Scenes/SupportScene.swift | 52 ----- .../Support/Sources/Scenes/WebViewScene.swift | 22 --- .../Sources/Types/ChatwootHandler.swift | 18 -- .../Sources/Types/ChatwootSettings.swift | 27 --- .../PhotosPickerItem+ImageAttachment.swift | 14 ++ .../Sources/Types/SupportChatDay.swift | 32 ++++ .../Sources/Types/SupportChatDayBuilder.swift | 59 ++++++ .../Sources/Types/SupportInputMessage.swift | 14 ++ .../Support/Sources/Types/SupportType.swift | 6 - .../ViewModels/ChatwootWebViewModel.swift | 181 ------------------ .../SupportChatSceneViewModel.swift | 73 +++++++ .../SupportMessageBubbleViewModel.swift | 60 ++++++ .../SupportMessageInputBarViewModel.swift | 39 ++++ .../ViewModels/SupportSceneViewModel.swift | 62 ------ .../ViewModels/WebSceneViewModel.swift | 36 ---- .../Sources/Views/ChatwootWebView.swift | 30 --- .../Views/SupportAgentMessageGroup.swift | 37 ++++ .../Sources/Views/SupportChatSeparators.swift | 17 ++ .../Sources/Views/SupportMessageBubble.swift | 104 ++++++++++ .../Views/SupportMessageInputBar.swift | 86 +++++++++ .../Views/SupportSelectedItemPreview.swift | 55 ++++++ .../Views/SupportUserMessageGroup.swift | 19 ++ .../Support/Sources/Views/WebView.swift | 26 --- ios/Gem.xcodeproj/project.pbxproj | 7 + .../Settings/SettingsNavigationStack.swift | 10 +- ios/Gem/Services/AppResolver+Services.swift | 4 + .../Services/AppResolver+ViewInjection.swift | 1 + ios/Gem/Services/ServicesFactory.swift | 4 + ios/Gem/Types/Environment.swift | 2 + .../ViewModifiers/LiquidGlassModifier.swift | 15 +- ios/Packages/FeatureServices/Package.swift | 10 + .../StreamService/StreamEventService.swift | 1 + .../SupportChatService/ImageAttachment.swift | 13 ++ .../SupportChatService/SupportChatError.swift | 7 + .../SupportChatService.swift | 76 ++++++++ .../SupportImageStore.swift | 31 +++ .../SupportMessage+SupportChat.swift | 55 ++++++ .../GemAPI/Sources/GemAPIService.swift | 6 - .../GemAPI/Sources/GemDeviceAPI.swift | 5 - .../GemAPISupportService+TestKit.swift | 13 +- .../Primitives/Sources/Generated/Stream.swift | 4 +- .../Sources/Generated/Support.swift | 72 +------ ios/Packages/Store/Sources/Migrations.swift | 4 + .../Sources/Models/SupportMessageRecord.swift | 71 +++++++ .../Requests/SupportMessagesRequest.swift | 18 ++ .../Store/Sources/Stores/StoreManager.swift | 2 + .../Sources/Stores/SupportChatStore.swift | 28 +++ ios/Packages/Style/Sources/SystemImage.swift | 4 + 67 files changed, 1048 insertions(+), 886 deletions(-) delete mode 100644 ios/Features/Support/Sources/Scenes/ChatwootWebScene.swift create mode 100644 ios/Features/Support/Sources/Scenes/SupportChatScene.swift delete mode 100644 ios/Features/Support/Sources/Scenes/SupportScene.swift delete mode 100644 ios/Features/Support/Sources/Scenes/WebViewScene.swift delete mode 100644 ios/Features/Support/Sources/Types/ChatwootHandler.swift delete mode 100644 ios/Features/Support/Sources/Types/ChatwootSettings.swift create mode 100644 ios/Features/Support/Sources/Types/PhotosPickerItem+ImageAttachment.swift create mode 100644 ios/Features/Support/Sources/Types/SupportChatDay.swift create mode 100644 ios/Features/Support/Sources/Types/SupportChatDayBuilder.swift create mode 100644 ios/Features/Support/Sources/Types/SupportInputMessage.swift delete mode 100644 ios/Features/Support/Sources/Types/SupportType.swift delete mode 100644 ios/Features/Support/Sources/ViewModels/ChatwootWebViewModel.swift create mode 100644 ios/Features/Support/Sources/ViewModels/SupportChatSceneViewModel.swift create mode 100644 ios/Features/Support/Sources/ViewModels/SupportMessageBubbleViewModel.swift create mode 100644 ios/Features/Support/Sources/ViewModels/SupportMessageInputBarViewModel.swift delete mode 100644 ios/Features/Support/Sources/ViewModels/SupportSceneViewModel.swift delete mode 100644 ios/Features/Support/Sources/ViewModels/WebSceneViewModel.swift delete mode 100644 ios/Features/Support/Sources/Views/ChatwootWebView.swift create mode 100644 ios/Features/Support/Sources/Views/SupportAgentMessageGroup.swift create mode 100644 ios/Features/Support/Sources/Views/SupportChatSeparators.swift create mode 100644 ios/Features/Support/Sources/Views/SupportMessageBubble.swift create mode 100644 ios/Features/Support/Sources/Views/SupportMessageInputBar.swift create mode 100644 ios/Features/Support/Sources/Views/SupportSelectedItemPreview.swift create mode 100644 ios/Features/Support/Sources/Views/SupportUserMessageGroup.swift delete mode 100644 ios/Features/Support/Sources/Views/WebView.swift create mode 100644 ios/Packages/FeatureServices/SupportChatService/ImageAttachment.swift create mode 100644 ios/Packages/FeatureServices/SupportChatService/SupportChatError.swift create mode 100644 ios/Packages/FeatureServices/SupportChatService/SupportChatService.swift create mode 100644 ios/Packages/FeatureServices/SupportChatService/SupportImageStore.swift create mode 100644 ios/Packages/FeatureServices/SupportChatService/SupportMessage+SupportChat.swift create mode 100644 ios/Packages/Store/Sources/Models/SupportMessageRecord.swift create mode 100644 ios/Packages/Store/Sources/Requests/SupportMessagesRequest.swift create mode 100644 ios/Packages/Store/Sources/Stores/SupportChatStore.swift diff --git a/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/GemDeviceApiClient.kt b/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/GemDeviceApiClient.kt index 2244efba1d..a255d73373 100644 --- a/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/GemDeviceApiClient.kt +++ b/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/GemDeviceApiClient.kt @@ -22,7 +22,6 @@ import com.wallet.core.primitives.Rewards import com.wallet.core.primitives.ScanTransaction import com.wallet.core.primitives.ScanTransactionPayload import com.wallet.core.primitives.SupportAction -import com.wallet.core.primitives.SupportConversation import com.wallet.core.primitives.SupportMessage import com.wallet.core.primitives.SupportMessageInput import com.wallet.core.primitives.Transaction @@ -118,9 +117,6 @@ interface GemDeviceApiClient { @POST("/v2/devices/scan/transaction") suspend fun getScanTransaction(@Body payload: ScanTransactionPayload): ScanTransaction - @GET("/v2/devices/support") - suspend fun getSupportConversation(): SupportConversation? - @GET("/v2/devices/support/messages") suspend fun getSupportMessages( @Query("from_timestamp") fromTimestamp: Long, diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Stream.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Stream.kt index bd0e534126..fb7c0b3d69 100644 --- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Stream.kt +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Stream.kt @@ -68,7 +68,7 @@ sealed class StreamEvent { data class FiatTransaction(val data: StreamWalletUpdate): StreamEvent() @Serializable @SerialName("support") - data class Support(val data: SupportStreamEvent): StreamEvent() + data class Support(val data: SupportMessage): StreamEvent() } @Serializable diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Support.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Support.kt index b8490e9fc1..50129fe588 100644 --- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Support.kt +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Support.kt @@ -13,24 +13,6 @@ data class SupportAgent ( val avatarUrl: String? = null ) -@Serializable -enum class SupportConversationStatus(val string: String) { - @SerialName("open") - Open("open"), - @SerialName("resolved") - Resolved("resolved"), -} - -@Serializable -data class SupportConversation ( - val id: String, - val status: SupportConversationStatus, - val firstMessage: String? = null, - val lastMessage: String? = null, - val lastActivityAt: SerializedDate, - val unreadCount: Int -) - @Serializable sealed class SupportMessageSender { @Serializable @@ -65,7 +47,6 @@ data class SupportMessageImage ( @Serializable data class SupportMessage ( val id: String, - val conversationId: String, val content: String, val sender: SupportMessageSender, val deliveryStatus: SupportMessageDeliveryStatus, @@ -88,16 +69,6 @@ sealed class SupportAction { object LastSeen: SupportAction() } -@Serializable -sealed class SupportStreamEvent { - @Serializable - @SerialName("message") - data class Message(val data: SupportMessage): SupportStreamEvent() - @Serializable - @SerialName("conversation") - data class Conversation(val data: SupportConversation): SupportStreamEvent() -} - @Serializable enum class SupportTypingStatus(val string: String) { @SerialName("on") diff --git a/core/apps/api/src/main.rs b/core/apps/api/src/main.rs index 3e1ba22f2d..eb5a636872 100644 --- a/core/apps/api/src/main.rs +++ b/core/apps/api/src/main.rs @@ -133,7 +133,6 @@ fn mount_routes(rocket: Rocket, admin_enabled: bool) -> Rocket { devices::create_device_referral_v2, devices::use_device_referral_code_v2, devices::redeem_device_rewards_v2, - support::get_support_conversation, support::get_support_messages, support::post_support_action, support::post_support_image, diff --git a/core/apps/api/src/support/client.rs b/core/apps/api/src/support/client.rs index 158be9ad3d..6ba8637d4d 100644 --- a/core/apps/api/src/support/client.rs +++ b/core/apps/api/src/support/client.rs @@ -1,4 +1,4 @@ -use primitives::{SupportAction, SupportConversation, SupportMessage, SupportMessageInput}; +use primitives::{SupportAction, SupportMessage, SupportMessageInput}; use std::{error::Error, future::Future}; use storage::models::DeviceRow; @@ -15,10 +15,6 @@ impl SupportApiClient { } } - pub async fn conversation(&self, device: &DeviceRow) -> Result, Box> { - self.with_session(device, |session| async move { self.chatwoot.conversation(&session).await }).await - } - pub async fn messages(&self, device: &DeviceRow, from_timestamp: Option) -> Result, Box> { self.with_session(device, |session| async move { self.chatwoot.messages(&session, from_timestamp).await }) .await diff --git a/core/apps/api/src/support/mod.rs b/core/apps/api/src/support/mod.rs index ad461c1c26..4314bd31bd 100644 --- a/core/apps/api/src/support/mod.rs +++ b/core/apps/api/src/support/mod.rs @@ -1,7 +1,7 @@ mod client; pub use client::SupportApiClient; -use primitives::{SupportAction, SupportConversation, SupportMessage, SupportMessageInput}; +use primitives::{SupportAction, SupportMessage, SupportMessageInput}; use rocket::{Data, State, data::ToByteUnit, get, http::ContentType, post, serde::json::Json, tokio::sync::Mutex}; use crate::{ @@ -11,11 +11,6 @@ use crate::{ const MAX_SUPPORT_IMAGE_BYTES: u64 = 10 * 1024 * 1024; -#[get("/devices/support")] -pub async fn get_support_conversation(device: AuthenticatedDevice, client: &State>) -> Result>, ApiError> { - Ok(client.lock().await.conversation(&device.device_row).await?.into()) -} - #[get("/devices/support/messages?")] pub async fn get_support_messages( device: AuthenticatedDevice, diff --git a/core/crates/primitives/src/lib.rs b/core/crates/primitives/src/lib.rs index d691214a9b..8906cd2f70 100644 --- a/core/crates/primitives/src/lib.rs +++ b/core/crates/primitives/src/lib.rs @@ -226,8 +226,7 @@ pub mod stream; pub use self::stream::{StreamBalanceUpdate, StreamEvent, StreamMessage, StreamMessagePrices, StreamTransactionsUpdate, StreamWalletUpdate, device_stream_channel}; pub mod support; pub use self::support::{ - SupportAction, SupportAgent, SupportConversation, SupportConversationStatus, SupportMessage, SupportMessageDeliveryStatus, SupportMessageImage, SupportMessageInput, - SupportMessageSender, SupportStreamEvent, SupportTypingStatus, + SupportAction, SupportAgent, SupportMessage, SupportMessageDeliveryStatus, SupportMessageImage, SupportMessageInput, SupportMessageSender, SupportTypingStatus, }; pub mod asset_balance; pub use self::asset_balance::{AddressBalances, AssetBalance, Balance}; diff --git a/core/crates/primitives/src/stream.rs b/core/crates/primitives/src/stream.rs index bc4c6e2a45..58fbf06961 100644 --- a/core/crates/primitives/src/stream.rs +++ b/core/crates/primitives/src/stream.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; -use crate::{AssetId, InAppNotification, SupportStreamEvent, TransactionId, WalletId, WebSocketPricePayload}; +use crate::{AssetId, InAppNotification, SupportMessage, TransactionId, WalletId, WebSocketPricePayload}; pub const DEVICE_STREAM_CHANNEL_PREFIX: &str = "stream:device:"; @@ -22,7 +22,7 @@ pub enum StreamEvent { Perpetual(StreamWalletUpdate), InAppNotification(StreamNotificationUpdate), FiatTransaction(StreamWalletUpdate), - Support(SupportStreamEvent), + Support(SupportMessage), } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/core/crates/primitives/src/support.rs b/core/crates/primitives/src/support.rs index 7173120705..e4c017b75f 100644 --- a/core/crates/primitives/src/support.rs +++ b/core/crates/primitives/src/support.rs @@ -3,14 +3,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use typeshare::typeshare; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[typeshare(swift = "Equatable, CaseIterable, Sendable")] -#[serde(rename_all = "lowercase")] -pub enum SupportConversationStatus { - Open, - Resolved, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[typeshare(swift = "Equatable, CaseIterable, Sendable")] #[serde(rename_all = "lowercase")] @@ -53,20 +45,6 @@ impl SupportMessageSender { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[typeshare(swift = "Sendable, Equatable, Hashable, Identifiable")] -#[serde(rename_all = "camelCase")] -pub struct SupportConversation { - pub id: String, - pub status: SupportConversationStatus, - #[serde(skip_serializing_if = "Option::is_none")] - pub first_message: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_message: Option, - pub last_activity_at: DateTime, - pub unread_count: i32, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[typeshare(swift = "Sendable, Equatable")] #[serde(rename_all = "camelCase")] @@ -90,7 +68,6 @@ pub struct SupportMessageImage { #[serde(rename_all = "camelCase")] pub struct SupportMessage { pub id: String, - pub conversation_id: String, pub content: String, pub sender: SupportMessageSender, pub delivery_status: SupportMessageDeliveryStatus, @@ -121,10 +98,3 @@ pub enum SupportAction { LastSeen, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", content = "data", rename_all = "camelCase")] -#[typeshare(swift = "Sendable")] -pub enum SupportStreamEvent { - Message(SupportMessage), - Conversation(SupportConversation), -} diff --git a/core/crates/support/src/chatwoot.rs b/core/crates/support/src/chatwoot.rs index fc7b1a06c5..346509a247 100644 --- a/core/crates/support/src/chatwoot.rs +++ b/core/crates/support/src/chatwoot.rs @@ -1,5 +1,5 @@ use chrono::Utc; -use primitives::{Device, SupportConversation, SupportMessage, SupportTypingStatus}; +use primitives::{Device, SupportMessage, SupportTypingStatus}; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use reqwest::multipart::{Form, Part}; use reqwest::{Client, RequestBuilder, Response}; @@ -8,11 +8,8 @@ use std::error::Error; use std::io; use crate::{ - ChatwootConfigResponse, ChatwootContactResponse, ChatwootContactUpdate, ChatwootConversationResponse, ChatwootMessageInput, ChatwootMessagesResponse, ChatwootSession, - ChatwootTypingInput, Message, - constants::{ - PATH_CONFIG, PATH_CONTACT_SET_USER, PATH_CONVERSATIONS, PATH_MESSAGES, PATH_TOGGLE_TYPING, PATH_UPDATE_LAST_SEEN, QUERY_CHATWOOT_AFTER, QUERY_WIDGET_PUBLIC_TOKEN, - }, + ChatwootConfigResponse, ChatwootContactResponse, ChatwootContactUpdate, ChatwootMessageInput, ChatwootMessagesResponse, ChatwootSession, ChatwootTypingInput, Message, + constants::{PATH_CONFIG, PATH_CONTACT_SET_USER, PATH_MESSAGES, PATH_TOGGLE_TYPING, PATH_UPDATE_LAST_SEEN, QUERY_CHATWOOT_AFTER, QUERY_WIDGET_PUBLIC_TOKEN}, support_messages, }; @@ -52,26 +49,6 @@ impl ChatwootClient { }) } - pub async fn conversation(&self, session: &ChatwootSession) -> Result, Box> { - let conversation: ChatwootConversationResponse = self - .json( - self.authenticated(self.client.get(self.widget_url(PATH_CONVERSATIONS)), &session.auth_token)? - .send() - .await?, - ) - .await?; - - let Some(id) = conversation.id else { - return Ok(None); - }; - - let messages = self.messages(session, None).await?; - let conversation = conversation - .support_conversation(&messages) - .ok_or_else(|| io::Error::other(format!("conversation {id} has no activity timestamp")))?; - Ok(Some(conversation)) - } - pub async fn messages(&self, session: &ChatwootSession, from_timestamp: Option) -> Result, Box> { let mut request = self.authenticated(self.client.get(self.widget_url(PATH_MESSAGES)), &session.auth_token)?; if let Some(from_timestamp) = from_timestamp { diff --git a/core/crates/support/src/client.rs b/core/crates/support/src/client.rs index f7abe4f62a..2b69b559be 100644 --- a/core/crates/support/src/client.rs +++ b/core/crates/support/src/client.rs @@ -1,11 +1,8 @@ -use crate::{ - ChatwootWebhookPayload, - constants::{EVENT_CONVERSATION_STATUS_CHANGED, EVENT_CONVERSATION_UPDATED, EVENT_MESSAGE_CREATED}, -}; +use crate::{ChatwootWebhookPayload, constants::EVENT_MESSAGE_CREATED}; use cacher::CacherClient; use localizer::LanguageLocalizer; use primitives::{ - Device, GorushNotification, PushNotification, PushNotificationTypes, StreamEvent, SupportStreamEvent, device_stream_channel, push_notification::PushNotificationSupport, + Device, GorushNotification, PushNotification, PushNotificationTypes, StreamEvent, device_stream_channel, push_notification::PushNotificationSupport, }; use std::error::Error; use storage::database::devices::DevicesStore; @@ -32,14 +29,10 @@ impl SupportClient { } pub async fn process_webhook(&self, device: &Device, payload: &ChatwootWebhookPayload) -> Result<(usize, usize), Box> { - match payload.event.as_str() { - EVENT_MESSAGE_CREATED => self.process_message_created(device, payload).await, - EVENT_CONVERSATION_UPDATED | EVENT_CONVERSATION_STATUS_CHANGED => self.process_conversation_updated(device, payload).await, - _ => Ok((0, 0)), + if payload.event.as_str() != EVENT_MESSAGE_CREATED { + return Ok((0, 0)); } - } - async fn process_message_created(&self, device: &Device, payload: &ChatwootWebhookPayload) -> Result<(usize, usize), Box> { let notifications_count = if let Some(notification) = Self::build_notification(device, payload) { self.stream_producer.publish_notifications_support(NotificationsPayload::new(vec![notification])).await?; 1 @@ -52,15 +45,6 @@ impl SupportClient { Ok((notifications_count, stream_events_count)) } - async fn process_conversation_updated(&self, device: &Device, payload: &ChatwootWebhookPayload) -> Result<(usize, usize), Box> { - if let Some(conversation) = payload.support_conversation() { - self.publish_stream_event(device, SupportStreamEvent::Conversation(conversation)).await?; - Ok((0, 1)) - } else { - Ok((0, 0)) - } - } - fn build_notification(device: &Device, payload: &ChatwootWebhookPayload) -> Option { if !payload.is_public_outgoing_message() { return None; @@ -81,13 +65,9 @@ impl SupportClient { return Ok(0); }; - self.publish_stream_event(device, SupportStreamEvent::Message(message)).await?; - Ok(1) - } - - async fn publish_stream_event(&self, device: &Device, event: SupportStreamEvent) -> Result<(), Box> { let channel = device_stream_channel(&device.id); - self.cacher.publish(&channel, &StreamEvent::Support(event)).await + self.cacher.publish(&channel, &StreamEvent::Support(message)).await?; + Ok(1) } } diff --git a/core/crates/support/src/constants.rs b/core/crates/support/src/constants.rs index 5afadcb81f..329c9444e5 100644 --- a/core/crates/support/src/constants.rs +++ b/core/crates/support/src/constants.rs @@ -3,15 +3,11 @@ pub(crate) const CHATWOOT_DELIVERY_STATUS_DELIVERED: &str = "delivered"; pub(crate) const CHATWOOT_DELIVERY_STATUS_READ: &str = "read"; pub(crate) const CHATWOOT_DELIVERY_STATUS_SENT: &str = "sent"; pub(crate) const CHATWOOT_FILE_TYPE_IMAGE: &str = "image"; -pub(crate) const CHATWOOT_STATUS_RESOLVED: &str = "resolved"; -pub(crate) const EVENT_CONVERSATION_STATUS_CHANGED: &str = "conversation_status_changed"; -pub(crate) const EVENT_CONVERSATION_UPDATED: &str = "conversation_updated"; pub(crate) const EVENT_MESSAGE_CREATED: &str = "message_created"; pub(crate) const PATH_CONFIG: &str = "config"; pub(crate) const PATH_CONTACT_SET_USER: &str = "contact/set_user"; -pub(crate) const PATH_CONVERSATIONS: &str = "conversations"; pub(crate) const PATH_MESSAGES: &str = "messages"; pub(crate) const PATH_TOGGLE_TYPING: &str = "conversations/toggle_typing"; pub(crate) const PATH_UPDATE_LAST_SEEN: &str = "conversations/update_last_seen"; diff --git a/core/crates/support/src/model.rs b/core/crates/support/src/model.rs index 93a958fe23..a8fa12f712 100644 --- a/core/crates/support/src/model.rs +++ b/core/crates/support/src/model.rs @@ -1,14 +1,10 @@ use chrono::{DateTime, Utc}; -use primitives::{ - Device, SupportAgent, SupportConversation, SupportConversationStatus, SupportMessage, SupportMessageDeliveryStatus, SupportMessageImage, SupportMessageSender, - SupportTypingStatus, -}; +use primitives::{Device, SupportAgent, SupportMessage, SupportMessageDeliveryStatus, SupportMessageImage, SupportMessageSender, SupportTypingStatus}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use crate::constants::{ CHATWOOT_CONTENT_TYPE_TEXT, CHATWOOT_DELIVERY_STATUS_DELIVERED, CHATWOOT_DELIVERY_STATUS_READ, CHATWOOT_DELIVERY_STATUS_SENT, CHATWOOT_FILE_TYPE_IMAGE, - CHATWOOT_STATUS_RESOLVED, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -142,20 +138,6 @@ impl ChatwootWebhookPayload { self.message_type.as_deref() == Some("outgoing") && self.private == Some(false) } - fn conversation_id(&self) -> Option { - self.conversation.as_ref().and_then(|c| c.id) - } - - fn messages(&self) -> &[Message] { - if !self.messages.is_empty() { - &self.messages - } else if let Some(conversation) = &self.conversation { - &conversation.messages - } else { - &[] - } - } - pub fn support_message(&self) -> Option { let sender = match self.message_type.as_deref()? { "incoming" => SupportMessageSender::User, @@ -165,7 +147,6 @@ impl ChatwootWebhookPayload { support_message( self.id?, - self.conversation_id()?, self.content.as_deref(), self.content_type.as_deref(), self.private, @@ -175,36 +156,6 @@ impl ChatwootWebhookPayload { &self.attachments, ) } - - pub fn support_conversation(&self) -> Option { - if let Some(conversation) = &self.conversation { - return conversation.support_conversation(); - } - - map_support_conversation( - self.id?, - self.status.as_deref(), - self.messages(), - self.unread_count, - self.contact_last_seen_at, - self.last_activity_at - .and_then(datetime_from_unix_timestamp) - .or_else(|| self.created_at.as_ref().and_then(ChatwootDateTime::datetime)), - ) - } -} - -impl Conversation { - fn support_conversation(&self) -> Option { - map_support_conversation( - self.id?, - self.status.as_deref(), - &self.messages, - self.unread_count, - self.contact_last_seen_at, - self.last_activity_at.and_then(datetime_from_unix_timestamp), - ) - } } impl Message { @@ -216,7 +167,6 @@ impl Message { support_message( self.id, - self.conversation_id?, self.content.as_deref(), self.content_type.as_deref(), self.private, @@ -282,13 +232,6 @@ pub(crate) struct ChatwootMessagesResponse { pub(crate) payload: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct ChatwootConversationResponse { - pub(crate) id: Option, - pub(crate) status: Option, - pub(crate) contact_last_seen_at: Option, -} - #[derive(Debug, Clone, Serialize)] pub(crate) struct ChatwootContactUpdate { pub(crate) identifier: String, @@ -313,22 +256,6 @@ impl ChatwootContactUpdate { } } -impl ChatwootConversationResponse { - pub(crate) fn support_conversation(&self, messages: &[SupportMessage]) -> Option { - support_conversation( - self.id?.to_string(), - self.status.as_deref(), - messages, - None, - self.contact_last_seen_at, - messages - .last() - .map(|message| message.created_at) - .or_else(|| self.contact_last_seen_at.and_then(datetime_from_unix_timestamp)), - ) - } -} - #[derive(Debug, Clone, Serialize)] pub(crate) struct ChatwootMessageInput { pub(crate) message: ChatwootMessageData, @@ -370,7 +297,6 @@ pub(crate) fn support_messages(messages: &[Message]) -> Vec { fn support_message( id: i64, - conversation_id: i64, content: Option<&str>, content_type: Option<&str>, private: Option, @@ -395,7 +321,6 @@ fn support_message( Some(SupportMessage { id: id.to_string(), - conversation_id: conversation_id.to_string(), content, sender, delivery_status, @@ -408,56 +333,6 @@ fn support_images(attachments: &[Attachment]) -> Vec { attachments.iter().filter_map(Attachment::support_image).collect() } -fn map_support_conversation( - id: i64, - status: Option<&str>, - messages: &[Message], - unread_count_value: Option, - contact_last_seen_at: Option, - explicit_last_activity_at: Option>, -) -> Option { - let messages = support_messages(messages); - support_conversation( - id.to_string(), - status, - &messages, - unread_count_value, - contact_last_seen_at, - explicit_last_activity_at.or_else(|| messages.last().map(|message| message.created_at)), - ) -} - -fn support_conversation( - id: String, - status: Option<&str>, - messages: &[SupportMessage], - unread_count_value: Option, - contact_last_seen_at: Option, - last_activity_at: Option>, -) -> Option { - let first_message = messages - .iter() - .find(|message| message.sender.is_user() && !message.content.is_empty()) - .map(|message| message.content.clone()); - let last_message = messages.iter().rev().find(|message| !message.content.is_empty()).map(|message| message.content.clone()); - - Some(SupportConversation { - id, - status: support_conversation_status(status), - first_message, - last_message, - last_activity_at: last_activity_at?, - unread_count: unread_count_value.unwrap_or_else(|| unread_count(messages, contact_last_seen_at)), - }) -} - -fn support_conversation_status(status: Option<&str>) -> SupportConversationStatus { - match status { - Some(CHATWOOT_STATUS_RESOLVED) => SupportConversationStatus::Resolved, - _ => SupportConversationStatus::Open, - } -} - fn support_delivery_status(status: Option<&str>) -> SupportMessageDeliveryStatus { match status { Some(CHATWOOT_DELIVERY_STATUS_SENT) | Some(CHATWOOT_DELIVERY_STATUS_DELIVERED) | Some(CHATWOOT_DELIVERY_STATUS_READ) | None => SupportMessageDeliveryStatus::Sent, @@ -465,16 +340,6 @@ fn support_delivery_status(status: Option<&str>) -> SupportMessageDeliveryStatus } } -fn unread_count(messages: &[SupportMessage], contact_last_seen_at: Option) -> i32 { - let Some(contact_last_seen_at) = contact_last_seen_at else { - return 0; - }; - messages - .iter() - .filter(|message| message.sender.is_agent() && message.created_at.timestamp() > contact_last_seen_at) - .count() as i32 -} - fn datetime_from_unix_timestamp(value: i64) -> Option> { DateTime::::from_timestamp(value, 0) } diff --git a/core/crates/support/tests/model_tests.rs b/core/crates/support/tests/model_tests.rs index 0123425157..ef5441cb94 100644 --- a/core/crates/support/tests/model_tests.rs +++ b/core/crates/support/tests/model_tests.rs @@ -1,13 +1,6 @@ -use primitives::{SupportAgent, SupportConversationStatus, SupportMessageDeliveryStatus, SupportMessageSender}; +use primitives::{SupportAgent, SupportMessageDeliveryStatus, SupportMessageSender}; use support::ChatwootWebhookPayload; -#[test] -fn test_parse_conversation_updated_payload() { - let payload: ChatwootWebhookPayload = serde_json::from_str(include_str!("testdata/chatwoot_conversation_updated.json")).unwrap(); - assert_eq!(payload.event, "conversation_updated"); - assert_eq!(payload.get_device_id(), Some("test-device-id".to_string())); -} - #[test] fn test_parse_device_id() { let payload: ChatwootWebhookPayload = @@ -40,11 +33,10 @@ fn test_is_public_outgoing_message() { } #[test] -fn test_support_mapping() { +fn test_support_message_mapping() { let payload: ChatwootWebhookPayload = serde_json::from_str(include_str!("testdata/chatwoot_message_created.json")).unwrap(); let message = payload.support_message().unwrap(); assert_eq!(message.id, "1"); - assert_eq!(message.conversation_id, "1"); assert_eq!(message.content, "from agent"); assert!(message.images.is_empty()); assert_eq!( @@ -55,14 +47,6 @@ fn test_support_mapping() { }) ); assert_eq!(message.delivery_status, SupportMessageDeliveryStatus::Sent); - - let payload: ChatwootWebhookPayload = serde_json::from_str(include_str!("testdata/chatwoot_conversation_updated.json")).unwrap(); - let conversation = payload.support_conversation().unwrap(); - assert_eq!(conversation.id, "1"); - assert_eq!(conversation.status, SupportConversationStatus::Open); - assert_eq!(conversation.first_message, None); - assert_eq!(conversation.last_message, Some("Test message".to_string())); - assert_eq!(conversation.unread_count, 1); } #[test] diff --git a/ios/Features/Settings/Package.swift b/ios/Features/Settings/Package.swift index 84c31f81e9..b7789f607a 100644 --- a/ios/Features/Settings/Package.swift +++ b/ios/Features/Settings/Package.swift @@ -28,7 +28,6 @@ let package = Package( .package(name: "Formatters", path: "../../Packages/Formatters"), .package(name: "Validators", path: "../../Packages/Validators"), .package(name: "QRScanner", path: "../QRScanner"), - .package(name: "Support", path: "../Support"), ], targets: [ .target( @@ -60,7 +59,6 @@ let package = Package( .product(name: "NodeService", package: "ChainServices"), .product(name: "ExplorerService", package: "ChainServices"), "QRScanner", - "Support", ], path: "Sources", ), diff --git a/ios/Features/Settings/Sources/Settings/Scenes/SettingsScene.swift b/ios/Features/Settings/Sources/Settings/Scenes/SettingsScene.swift index 5044263e86..c2f8a402e4 100644 --- a/ios/Features/Settings/Sources/Settings/Scenes/SettingsScene.swift +++ b/ios/Features/Settings/Sources/Settings/Scenes/SettingsScene.swift @@ -4,7 +4,6 @@ import Components import Localization import Primitives import PrimitivesComponents -import Support import SwiftUI public struct SettingsScene: View { @@ -13,18 +12,15 @@ public struct SettingsScene: View { @State private var model: SettingsViewModel @Binding private var isPresentingWallets: Bool @Binding private var isPresentingSupport: Bool - private let deviceId: String public init( model: SettingsViewModel, isPresentingWallets: Binding, isPresentingSupport: Binding, - deviceId: String, ) { _model = State(initialValue: model) _isPresentingWallets = isPresentingWallets _isPresentingSupport = isPresentingSupport - self.deviceId = deviceId } public var body: some View { @@ -41,12 +37,6 @@ public struct SettingsScene: View { .listStyle(.insetGrouped) .listSectionSpacing(.compact) .navigationTitle(model.title) - .sheet(isPresented: $isPresentingSupport) { - SupportScene(model: SupportSceneViewModel( - deviceId: deviceId, - isPresentingSupport: $isPresentingSupport, - )) - } } } diff --git a/ios/Features/Support/Package.swift b/ios/Features/Support/Package.swift index 5db237605c..9154037fac 100644 --- a/ios/Features/Support/Package.swift +++ b/ios/Features/Support/Package.swift @@ -20,6 +20,8 @@ let package = Package( .package(name: "PrimitivesComponents", path: "../../Packages/PrimitivesComponents"), .package(name: "Preferences", path: "../../Packages/Preferences"), .package(name: "FeatureServices", path: "../../Packages/FeatureServices"), + .package(name: "Store", path: "../../Packages/Store"), + .package(name: "GemAPI", path: "../../Packages/GemAPI"), ], targets: [ .target( @@ -34,6 +36,9 @@ let package = Package( "Preferences", .product(name: "NotificationService", package: "FeatureServices"), .product(name: "DeviceService", package: "FeatureServices"), + .product(name: "SupportChatService", package: "FeatureServices"), + "Store", + .product(name: "GemAPI", package: "GemAPI"), ], path: "Sources", ), diff --git a/ios/Features/Support/Sources/Scenes/ChatwootWebScene.swift b/ios/Features/Support/Sources/Scenes/ChatwootWebScene.swift deleted file mode 100644 index 953f339d7f..0000000000 --- a/ios/Features/Support/Sources/Scenes/ChatwootWebScene.swift +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Components -import SwiftUI - -struct ChatwootWebScene: View { - @State var model: ChatwootWebViewModel - - init(model: ChatwootWebViewModel) { - _model = State(wrappedValue: model) - } - - var body: some View { - ZStack { - ChatwootWebView(model: model) - - if model.isLoading { - LoadingView() - } - } - } -} diff --git a/ios/Features/Support/Sources/Scenes/SupportChatScene.swift b/ios/Features/Support/Sources/Scenes/SupportChatScene.swift new file mode 100644 index 0000000000..cfb327a15d --- /dev/null +++ b/ios/Features/Support/Sources/Scenes/SupportChatScene.swift @@ -0,0 +1,55 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Primitives +import Store +import Style +import SwiftUI + +public struct SupportChatScene: View { + @State private var model: SupportChatSceneViewModel + + public init(model: SupportChatSceneViewModel) { + _model = State(initialValue: model) + } + + public var body: some View { + ScrollView { + LazyVStack(spacing: .small) { + ForEach(model.days) { day in + SupportDateSeparator(date: day.date) + ForEach(day.groups) { group in + switch group { + case let .agent(header, messages): + SupportAgentMessageGroup(header: header, messages: messages) + case let .user(messages): + SupportUserMessageGroup(messages: messages) + } + } + } + } + .padding(.medium) + } + .defaultScrollAnchor(.bottom) + .bindQuery(model.query) + .background(Colors.grayBackground) + .overlay { + if model.isEmpty { + StateEmptyView( + title: model.emptyTitle, + description: model.emptyDescription, + image: Image(systemName: SystemImage.bubbleLeftAndBubbleRight), + ) + .padding(.medium) + } + } + .safeAreaInset(edge: .bottom) { + SupportMessageInputBar(model: model.inputBarModel) + } + .navigationTitle(model.title) + .navigationBarTitleDisplayMode(.inline) + .task { + await model.fetch() + } + } +} diff --git a/ios/Features/Support/Sources/Scenes/SupportScene.swift b/ios/Features/Support/Sources/Scenes/SupportScene.swift deleted file mode 100644 index 58a6f52760..0000000000 --- a/ios/Features/Support/Sources/Scenes/SupportScene.swift +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Components -import Localization -import PrimitivesComponents -import Style -import SwiftUI -import WebKit - -public struct SupportScene: View { - @State private var model: SupportSceneViewModel - - public init(model: SupportSceneViewModel) { - _model = State(initialValue: model) - } - - public var body: some View { - NavigationView { - TabView(selection: $model.selectedType) { - ChatwootWebScene(model: model.chatwootModel) - .tag(SupportType.support) - WebViewScene(url: model.helpCenterURL) - .tag(SupportType.docs) - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .background(Colors.grayBackground) - .toolbarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - supportTypePickerView - } - ToolbarItem(placement: .topBarLeading) { - Button("", systemImage: SystemImage.xmark, action: model.onDismiss) - } - } - .task { - await model.requestPushNotifications() - } - } - } - - var supportTypePickerView: some View { - Picker("", selection: $model.selectedType) { - Text(Localized.Settings.support) - .tag(SupportType.support) - Text(Localized.Settings.helpCenter) - .tag(SupportType.docs) - } - .pickerStyle(.segmented) - .fixedSize() - } -} diff --git a/ios/Features/Support/Sources/Scenes/WebViewScene.swift b/ios/Features/Support/Sources/Scenes/WebViewScene.swift deleted file mode 100644 index 5d2b89a690..0000000000 --- a/ios/Features/Support/Sources/Scenes/WebViewScene.swift +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Components -import SwiftUI - -struct WebViewScene: View { - @State var model: WebSceneViewModel - - init(url: URL) { - _model = State(initialValue: WebSceneViewModel(url: url)) - } - - var body: some View { - ZStack { - WebView(model: model) - - if model.isLoading { - LoadingView() - } - } - } -} diff --git a/ios/Features/Support/Sources/Types/ChatwootHandler.swift b/ios/Features/Support/Sources/Types/ChatwootHandler.swift deleted file mode 100644 index 6faf3a8266..0000000000 --- a/ios/Features/Support/Sources/Types/ChatwootHandler.swift +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation - -enum ChatwootJSEvent: String { - case ready = "chatwoot:ready" - case closed = "chatwoot:closed" -} - -enum ChatwootHandler: String { - case chatOpened -} - -enum ChatwootMessage: String { - case ready - case closed - case opened -} diff --git a/ios/Features/Support/Sources/Types/ChatwootSettings.swift b/ios/Features/Support/Sources/Types/ChatwootSettings.swift deleted file mode 100644 index 5d4d9b94b4..0000000000 --- a/ios/Features/Support/Sources/Types/ChatwootSettings.swift +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation - -struct ChatwootSettings { - enum DarkMode: String { - case auto - case light - case dark - } - - let hideMessageBubble: Bool - let locale: Locale - let darkMode: DarkMode - let enableEmojiPicker: Bool - let enableEndConversation: Bool -} - -extension ChatwootSettings { - static let defaultSettings = ChatwootSettings( - hideMessageBubble: true, - locale: .current, - darkMode: .auto, - enableEmojiPicker: false, - enableEndConversation: false, - ) -} diff --git a/ios/Features/Support/Sources/Types/PhotosPickerItem+ImageAttachment.swift b/ios/Features/Support/Sources/Types/PhotosPickerItem+ImageAttachment.swift new file mode 100644 index 0000000000..89d68ce9b6 --- /dev/null +++ b/ios/Features/Support/Sources/Types/PhotosPickerItem+ImageAttachment.swift @@ -0,0 +1,14 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import PhotosUI +import SupportChatService +import SwiftUI + +extension PhotosPickerItem { + func imageAttachment() async throws -> ImageAttachment? { + guard let data = try await loadTransferable(type: Data.self) else { return nil } + let utType = supportedContentTypes.first { $0.conforms(to: .image) } ?? .jpeg + let fileExtension = utType.preferredFilenameExtension ?? "jpg" + return ImageAttachment(data: data, fileName: "image-\(UUID().uuidString).\(fileExtension)") + } +} diff --git a/ios/Features/Support/Sources/Types/SupportChatDay.swift b/ios/Features/Support/Sources/Types/SupportChatDay.swift new file mode 100644 index 0000000000..edcbc2f79d --- /dev/null +++ b/ios/Features/Support/Sources/Types/SupportChatDay.swift @@ -0,0 +1,32 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +struct SupportChatDay: Identifiable { + let id: String + let date: Date + let groups: [SupportChatGroup] +} + +enum SupportChatGroup: Identifiable { + case user(messages: [SupportMessageBubbleViewModel]) + case agent(header: SupportAgentHeader, messages: [SupportMessageBubbleViewModel]) + + var id: String { + switch self { + case let .user(messages): "user-\(messages.first?.id ?? "")" + case let .agent(_, messages): "agent-\(messages.first?.id ?? "")" + } + } +} + +struct SupportAgentHeader { + let name: String + let avatarURL: URL? + + init(agent: SupportAgent) { + name = agent.name + avatarURL = agent.avatarUrl?.asURL + } +} diff --git a/ios/Features/Support/Sources/Types/SupportChatDayBuilder.swift b/ios/Features/Support/Sources/Types/SupportChatDayBuilder.swift new file mode 100644 index 0000000000..9dde41d335 --- /dev/null +++ b/ios/Features/Support/Sources/Types/SupportChatDayBuilder.swift @@ -0,0 +1,59 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +struct SupportChatDayBuilder { + let messages: [SupportMessage] + let retryAction: (SupportMessage) -> Void + + func build() -> [SupportChatDay] { + Dictionary(grouping: messages) { Calendar.current.startOfDay(for: $0.createdAt) } + .sorted { $0.key < $1.key } + .map { date, messages in + SupportChatDay(id: date.ISO8601Format(), date: date, groups: groups(from: messages)) + } + } +} + +// MARK: - Private + +private extension SupportChatDayBuilder { + func groups(from messages: [SupportMessage]) -> [SupportChatGroup] { + messages + .chunked(by: sameSender) + .compactMap(group(from:)) + } + + func group(from messages: [SupportMessage]) -> SupportChatGroup? { + guard let sender = messages.first?.sender else { return nil } + let bubbles = messages.map { SupportMessageBubbleViewModel(message: $0, retryAction: retryAction) } + switch sender { + case .user: + return .user(messages: bubbles) + case let .agent(agent): + return .agent(header: SupportAgentHeader(agent: agent), messages: bubbles) + } + } + + func sameSender(_ lhs: SupportMessage, _ rhs: SupportMessage) -> Bool { + switch (lhs.sender, rhs.sender) { + case (.user, .user), (.agent, .agent): true + case (.user, .agent), (.agent, .user): false + } + } +} + +private extension Array { + func chunked(by belongsInSameChunk: (Element, Element) -> Bool) -> [[Element]] { + var chunks: [[Element]] = [] + for element in self { + if let last = chunks.last?.last, belongsInSameChunk(last, element) { + chunks[chunks.count - 1].append(element) + } else { + chunks.append([element]) + } + } + return chunks + } +} diff --git a/ios/Features/Support/Sources/Types/SupportInputMessage.swift b/ios/Features/Support/Sources/Types/SupportInputMessage.swift new file mode 100644 index 0000000000..2abd77fc8a --- /dev/null +++ b/ios/Features/Support/Sources/Types/SupportInputMessage.swift @@ -0,0 +1,14 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PhotosUI +import SwiftUI + +struct SupportInputMessage { + let content: String + let attachments: [PhotosPickerItem] + + var isEmpty: Bool { + content.isEmpty && attachments.isEmpty + } +} diff --git a/ios/Features/Support/Sources/Types/SupportType.swift b/ios/Features/Support/Sources/Types/SupportType.swift deleted file mode 100644 index df517efa9a..0000000000 --- a/ios/Features/Support/Sources/Types/SupportType.swift +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -enum SupportType { - case support - case docs -} diff --git a/ios/Features/Support/Sources/ViewModels/ChatwootWebViewModel.swift b/ios/Features/Support/Sources/ViewModels/ChatwootWebViewModel.swift deleted file mode 100644 index 5743321f32..0000000000 --- a/ios/Features/Support/Sources/ViewModels/ChatwootWebViewModel.swift +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import DeviceService -import Foundation -import Preferences -import Primitives -import SwiftUI -import UIKit -import WebKit - -@Observable -@MainActor -final class ChatwootWebViewModel: NSObject, Sendable { - let websiteToken: String - let baseUrl: URL - let settings: ChatwootSettings - let deviceId: String - let domainPolicy: WebViewDomainPolicy - - var isPresentingSupport: Binding - var isLoading: Bool = true - - init( - websiteToken: String, - baseUrl: URL, - deviceId: String, - domainPolicy: WebViewDomainPolicy, - settings: ChatwootSettings = .defaultSettings, - isPresentingSupport: Binding, - ) { - self.websiteToken = websiteToken - self.baseUrl = baseUrl - self.deviceId = deviceId - self.domainPolicy = domainPolicy - self.settings = settings - self.isPresentingSupport = isPresentingSupport - } - - var htmlContent: String { - """ - - - - - - - - - - - """ - } - - func configureWebView() -> WKWebViewConfiguration { - let configuration = WKWebViewConfiguration() - configuration.userContentController.add(self, name: ChatwootHandler.chatOpened.rawValue) - configuration.userContentController.addUserScript(hideCloseButtonUserScript) - return configuration - } - - private var hideCloseButtonUserScript: WKUserScript { - let source = """ - var style = document.createElement('style'); - style.textContent = '.close-button, .rn-close-button { display: none !important; visibility: hidden !important; }'; - document.head.appendChild(style); - """ - return WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false) - } - - // MARK: - Private properties - - private var chatwootSettingsScript: String { - """ - window.chatwootSettings = { - hideMessageBubble: \(settings.hideMessageBubble), - locale: '\(settings.locale.identifier)', - darkMode: '\(settings.darkMode.rawValue)', - enableEmojiPicker: \(settings.enableEmojiPicker), - enableEndConversation: \(settings.enableEndConversation) - }; - """ - } - - private var sdkInitializationScript: String { - """ - window.chatwootSDK.run({ - websiteToken: '\(websiteToken)', - baseUrl: '\(baseUrl.absoluteString)' - }); - """ - } - - private var setDeviceIdScript: String { - let os = UIDevice.current.osName - let device = UIDevice.current.modelName - let currency = Preferences.standard.currency - let appVersion = Bundle.main.releaseVersionNumber - let currentPlatformStore = platformStore - return """ - window.addEventListener('chatwoot:ready', function () { - window.$chatwoot.setCustomAttributes({ - device_id: '\(deviceId)', - platform: 'ios', - platform_store: '\(currentPlatformStore.rawValue)', - os: '\(os)', - device: '\(device)', - currency: '\(currency)', - app_version: '\(appVersion)' - }); - }); - """ - } - - private var toggleChatScript: String { - "window.$chatwoot.toggle(open);" - } - - private var platformStore: PlatformStore { - #if targetEnvironment(simulator) - .local - #else - .appStore - #endif - } - - private var sdkSourceURL: String { - "\(baseUrl.absoluteString)/packs/js/sdk.js" - } - - private var chatOpenEventHandler: String { - eventHandler(event: .ready, handler: .chatOpened, message: .ready) - } - - // MARK: - Private methods - - private func eventHandler(event: ChatwootJSEvent, handler: ChatwootHandler, message: ChatwootMessage) -> String { - """ - window.addEventListener('\(event.rawValue)', function(event) { - if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.\(handler.rawValue)) { - window.webkit.messageHandlers.\(handler.rawValue).postMessage('\(message.rawValue)'); - } - }); - """ - } -} - -// MARK: - WKNavigationDelegate, WKScriptMessageHandler - -extension ChatwootWebViewModel: WKNavigationDelegate, WKScriptMessageHandler { - func webView( - _: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - ) async -> WKNavigationActionPolicy { - guard let url = navigationAction.request.url else { - return .cancel - } - - if url.isAllowed(by: domainPolicy) { - return .allow - } - - if domainPolicy.openExternalLinksInSafari, url.scheme == "https" || url.scheme == "http" { - await UIApplication.shared.open(url) - } - return .cancel - } - - func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { - switch ChatwootHandler(rawValue: message.name) { - case .chatOpened: isLoading = false - default: break - } - } -} diff --git a/ios/Features/Support/Sources/ViewModels/SupportChatSceneViewModel.swift b/ios/Features/Support/Sources/ViewModels/SupportChatSceneViewModel.swift new file mode 100644 index 0000000000..adb87b51c5 --- /dev/null +++ b/ios/Features/Support/Sources/ViewModels/SupportChatSceneViewModel.swift @@ -0,0 +1,73 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Localization +import PhotosUI +import Primitives +import Store +import SupportChatService +import SwiftUI + +@Observable +@MainActor +public final class SupportChatSceneViewModel { + private let service: SupportChatService + + public let query: ObservableQuery + + public init(service: SupportChatService) { + self.service = service + query = ObservableQuery(SupportMessagesRequest(), initialValue: []) + } + + var title: String { Localized.Settings.support } + + var inputBarModel: SupportMessageInputBarViewModel { + SupportMessageInputBarViewModel(onSend: { [weak self] in self?.onSend($0) }) + } + + var days: [SupportChatDay] { + SupportChatDayBuilder( + messages: query.value, + retryAction: { [weak self] in self?.onRetry($0) }, + ).build() + } + + var isEmpty: Bool { query.value.isEmpty } + + var emptyTitle: String { "How can we help?" } + var emptyDescription: String { "Send us a message and we'll reply as soon as we can." } + + func fetch() async { + do { + try await service.syncMessages(fromTimestamp: query.value.last.map { Int($0.createdAt.timeIntervalSince1970) } ?? 0) + } catch { + debugLog("SupportChatSceneViewModel fetch error: \(error)") + } + } + + func onSend(_ input: SupportInputMessage) { + Task { + var attachments: [ImageAttachment] = [] + for item in input.attachments { + guard let attachment = try? await item.imageAttachment() else { continue } + attachments.append(attachment) + } + do { + try await service.sendMessage(content: input.content, attachments: attachments) + } catch { + debugLog("SupportChatSceneViewModel send error: \(error)") + } + } + } + + func onRetry(_ message: SupportMessage) { + Task { + do { + try await service.retryMessage(message) + } catch { + debugLog("SupportChatSceneViewModel retry error: \(error)") + } + } + } +} diff --git a/ios/Features/Support/Sources/ViewModels/SupportMessageBubbleViewModel.swift b/ios/Features/Support/Sources/ViewModels/SupportMessageBubbleViewModel.swift new file mode 100644 index 0000000000..806da90029 --- /dev/null +++ b/ios/Features/Support/Sources/ViewModels/SupportMessageBubbleViewModel.swift @@ -0,0 +1,60 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import Style +import SwiftUI + +struct SupportMessageBubbleViewModel: Identifiable { + private let message: SupportMessage + private let retryAction: (SupportMessage) -> Void + + init(message: SupportMessage, retryAction: @escaping (SupportMessage) -> Void) { + self.message = message + self.retryAction = retryAction + } + + var id: String { message.id } + var content: String { message.content } + var hasContent: Bool { message.content.isNotEmpty } + var hasImages: Bool { message.images.isNotEmpty } + var images: [SupportMessageImage] { message.images } + var isSending: Bool { message.deliveryStatus == .sending } + + var palette: Palette { + switch message.sender { + case .user: + Palette(text: Colors.whiteSolid, background: Colors.blue, secondary: Colors.whiteSolid) + case .agent: + Palette(text: Colors.black, background: Colors.white, secondary: Colors.secondaryText) + } + } + + var status: Status { + switch message.deliveryStatus { + case .sending: .sending + case .sent: .sent(time: message.createdAt.formatted(date: .omitted, time: .shortened)) + case .failed: .failed + } + } + + func retry() { + retryAction(message) + } +} + +// MARK: - Types + +extension SupportMessageBubbleViewModel { + struct Palette { + let text: Color + let background: Color + let secondary: Color + } + + enum Status { + case sending + case sent(time: String) + case failed + } +} diff --git a/ios/Features/Support/Sources/ViewModels/SupportMessageInputBarViewModel.swift b/ios/Features/Support/Sources/ViewModels/SupportMessageInputBarViewModel.swift new file mode 100644 index 0000000000..69e3def531 --- /dev/null +++ b/ios/Features/Support/Sources/ViewModels/SupportMessageInputBarViewModel.swift @@ -0,0 +1,39 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PhotosUI +import SwiftUI + +@Observable +@MainActor +final class SupportMessageInputBarViewModel { + var text: String = "" + var selectedItems: [PhotosPickerItem] = [] + + private let onSend: (SupportInputMessage) -> Void + + init(onSend: @escaping (SupportInputMessage) -> Void) { + self.onSend = onSend + } + + var placeholder: String { "Message" } + + var inputMessage: SupportInputMessage { + SupportInputMessage( + content: text.trimmingCharacters(in: .whitespacesAndNewlines), + attachments: selectedItems, + ) + } + + var canSend: Bool { !inputMessage.isEmpty } + + func send() { + onSend(inputMessage) + text = "" + selectedItems = [] + } + + func removeItem(_ item: PhotosPickerItem) { + selectedItems.removeAll { $0 == item } + } +} diff --git a/ios/Features/Support/Sources/ViewModels/SupportSceneViewModel.swift b/ios/Features/Support/Sources/ViewModels/SupportSceneViewModel.swift deleted file mode 100644 index 527b304e5f..0000000000 --- a/ios/Features/Support/Sources/ViewModels/SupportSceneViewModel.swift +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import GemstonePrimitives -import Localization -import NotificationService -import Primitives -import SwiftUI - -@Observable -@MainActor -public final class SupportSceneViewModel: Sendable { - var selectedType: SupportType = .support - var isPresentingSupport: Binding - - private let pushNotificationService: PushNotificationEnablerService - private let deviceId: String - - public init( - pushNotificationService: PushNotificationEnablerService = PushNotificationEnablerService(), - deviceId: String, - isPresentingSupport: Binding, - ) { - self.isPresentingSupport = isPresentingSupport - self.pushNotificationService = pushNotificationService - self.deviceId = deviceId - } - - var title: String { - Localized.Settings.support - } - - var helpCenterURL: URL { - AppUrl.docs(.start) - } - - var chatwootModel: ChatwootWebViewModel { - ChatwootWebViewModel( - websiteToken: Constants.Support.chatwootPublicToken, - baseUrl: Constants.Support.chatwootURL, - deviceId: deviceId, - domainPolicy: Constants.Support.domainPolicy, - isPresentingSupport: isPresentingSupport, - ) - } - - func requestPushNotifications() async { - do { - _ = try await pushNotificationService.requestPermissions() - } catch { - debugLog("Failed to request push notifications: \(error)") - } - } -} - -// MARK: - Actions - -extension SupportSceneViewModel { - func onDismiss() { - isPresentingSupport.wrappedValue = false - } -} diff --git a/ios/Features/Support/Sources/ViewModels/WebSceneViewModel.swift b/ios/Features/Support/Sources/ViewModels/WebSceneViewModel.swift deleted file mode 100644 index 6a4accc2bf..0000000000 --- a/ios/Features/Support/Sources/ViewModels/WebSceneViewModel.swift +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import SwiftUI -import WebKit - -@Observable -@MainActor -final class WebSceneViewModel: NSObject, Sendable { - let url: URL - var isLoading: Bool = true - - init(url: URL) { - self.url = url - } -} - -// MARK: - WKNavigationDelegate - -extension WebSceneViewModel: WKNavigationDelegate { - func webView(_: WKWebView, didStartProvisionalNavigation _: WKNavigation) { - isLoading = true - } - - func webView(_: WKWebView, didCommit _: WKNavigation!) { - isLoading = false - } - - func webView(_: WKWebView, didFail _: WKNavigation, withError _: Error) { - isLoading = false - } - - func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation, withError _: Error) { - isLoading = false - } -} diff --git a/ios/Features/Support/Sources/Views/ChatwootWebView.swift b/ios/Features/Support/Sources/Views/ChatwootWebView.swift deleted file mode 100644 index 09c561c1dd..0000000000 --- a/ios/Features/Support/Sources/Views/ChatwootWebView.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Style -import SwiftUI -import WebKit - -struct ChatwootWebView: UIViewRepresentable { - let model: ChatwootWebViewModel - - func makeUIView(context _: Context) -> WKWebView { - let configuration = model.configureWebView() - - let webView = WKWebView(frame: .zero, configuration: configuration) - webView.navigationDelegate = model - webView.isOpaque = false - webView.backgroundColor = .clear - webView.scrollView.backgroundColor = .clear - - return webView - } - - func updateUIView(_ webView: WKWebView, context _: Context) { - webView.loadHTMLString(model.htmlContent, baseURL: model.baseUrl) - } - - static func dismantleUIView(_ webView: WKWebView, coordinator _: ()) { - webView.navigationDelegate = nil - webView.configuration.userContentController.removeAllScriptMessageHandlers() - } -} diff --git a/ios/Features/Support/Sources/Views/SupportAgentMessageGroup.swift b/ios/Features/Support/Sources/Views/SupportAgentMessageGroup.swift new file mode 100644 index 0000000000..2a1af490d0 --- /dev/null +++ b/ios/Features/Support/Sources/Views/SupportAgentMessageGroup.swift @@ -0,0 +1,37 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Style +import SwiftUI + +struct SupportAgentMessageGroup: View { + let header: SupportAgentHeader + let messages: [SupportMessageBubbleViewModel] + + var body: some View { + VStack(alignment: .leading, spacing: .tiny) { + headerView + ForEach(messages) { message in + HStack(spacing: .zero) { + SupportMessageBubble(model: message) + Spacer(minLength: .space32) + } + } + } + } + + private var headerView: some View { + HStack(spacing: .small) { + AssetImageView( + assetImage: AssetImage( + imageURL: header.avatarURL, + placeholder: Image(systemName: SystemImage.personCircleFill), + ), + size: .image.small, + ) + Text(header.name) + .font(.caption) + .foregroundStyle(Colors.secondaryText) + } + } +} diff --git a/ios/Features/Support/Sources/Views/SupportChatSeparators.swift b/ios/Features/Support/Sources/Views/SupportChatSeparators.swift new file mode 100644 index 0000000000..9d38a6d173 --- /dev/null +++ b/ios/Features/Support/Sources/Views/SupportChatSeparators.swift @@ -0,0 +1,17 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import PrimitivesComponents +import Style +import SwiftUI + +struct SupportDateSeparator: View { + let date: Date + + var body: some View { + Text(TransactionDateFormatter(date: date).section) + .font(.caption) + .foregroundStyle(Colors.secondaryText) + .frame(maxWidth: .infinity) + .padding(.vertical, .small) + } +} diff --git a/ios/Features/Support/Sources/Views/SupportMessageBubble.swift b/ios/Features/Support/Sources/Views/SupportMessageBubble.swift new file mode 100644 index 0000000000..ec156e23fb --- /dev/null +++ b/ios/Features/Support/Sources/Views/SupportMessageBubble.swift @@ -0,0 +1,104 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Primitives +import Style +import SwiftUI + +struct SupportMessageBubble: View { + let model: SupportMessageBubbleViewModel + + private enum Constants { + static let maxImageWidth: CGFloat = 240 + static let imageAspectRatio: CGFloat = 4.0 / 3.0 + } + + var body: some View { + VStack(alignment: .leading, spacing: .tiny) { + if model.hasImages { + imagesView + } + if model.hasContent { + textBubble + } + } + } + + private var textBubble: some View { + HStack(alignment: .lastTextBaseline, spacing: .small) { + Text(model.content) + .font(.body) + .foregroundStyle(model.palette.text) + statusView + } + .padding(.vertical, .small) + .padding(.horizontal, .space12) + .background(model.palette.background) + .clipShape(RoundedRectangle(cornerRadius: .space16)) + } + + private var imagesView: some View { + VStack(spacing: .tiny) { + ForEach(model.images, id: \.id) { image in + imageView(image) + } + } + } + + private func imageView(_ image: SupportMessageImage) -> some View { + ZStack { + CachedAsyncImage(url: (image.thumbnailUrl ?? image.url).asURL) { loaded in + loaded.resizable().aspectRatio(contentMode: .fit) + } placeholder: { + Colors.grayLightFaded.aspectRatio(Constants.imageAspectRatio, contentMode: .fit) + } + .opacity(model.isSending ? .semiStrong : 1) + imageOverlay + } + .frame(maxWidth: Constants.maxImageWidth) + .clipShape(RoundedRectangle(cornerRadius: .space12)) + } + + @ViewBuilder + private var imageOverlay: some View { + switch model.status { + case .sending: + ProgressView() + .controlSize(.large) + .tint(Colors.whiteSolid) + case .failed: + Button(action: model.retry) { + Image(systemName: SystemImage.refresh) + .font(.title2) + .foregroundStyle(Colors.whiteSolid) + .padding(.small) + .background(Colors.black.opacity(.medium)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + case .sent: + EmptyView() + } + } + + @ViewBuilder + private var statusView: some View { + switch model.status { + case .sending: + ProgressView() + .controlSize(.small) + .tint(model.palette.secondary) + case let .sent(time): + Text(time) + .font(.caption2) + .foregroundStyle(model.palette.secondary) + case .failed: + Button(action: model.retry) { + Image(systemName: SystemImage.refresh) + .font(.caption) + .foregroundStyle(model.palette.secondary) + } + .buttonStyle(.plain) + } + } +} diff --git a/ios/Features/Support/Sources/Views/SupportMessageInputBar.swift b/ios/Features/Support/Sources/Views/SupportMessageInputBar.swift new file mode 100644 index 0000000000..3425b50862 --- /dev/null +++ b/ios/Features/Support/Sources/Views/SupportMessageInputBar.swift @@ -0,0 +1,86 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import PhotosUI +import Primitives +import Style +import SwiftUI + +struct SupportMessageInputBar: View { + @State private var model: SupportMessageInputBarViewModel + + init(model: SupportMessageInputBarViewModel) { + _model = State(initialValue: model) + } + + var body: some View { + @Bindable var model = model + VStack(alignment: .leading, spacing: .small) { + if model.selectedItems.isNotEmpty { + previewStrip + } + HStack(alignment: .bottom, spacing: .small) { + attachButton + textField + sendButton + } + } + .padding(.horizontal, .medium) + .padding(.vertical, .small) + .frame(maxWidth: .infinity) + .background(Colors.grayBackground.ignoresSafeArea(edges: .bottom)) + } + + private var previewStrip: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: .small) { + ForEach(model.selectedItems, id: \.self) { item in + SupportSelectedItemPreview(item: item, onRemove: { model.removeItem(item) }) + } + } + } + } + + private var attachButton: some View { + PhotosPicker(selection: $model.selectedItems, matching: .images, photoLibrary: .shared()) { + AttachButtonLabel() + } + } + + private var textField: some View { + TextField(model.placeholder, text: $model.text, axis: .vertical) + .lineLimit(1 ... 8) + .padding(.vertical, .small) + .padding(.horizontal, .space12) + .liquidGlass( + interactive: false, + in: RoundedRectangle(cornerRadius: .space16), + fallback: { $0.background(Colors.grayVeryLight).clipShape(RoundedRectangle(cornerRadius: .space16)) }, + ) + } + + private struct AttachButtonLabel: View { + var body: some View { + Image(systemName: SystemImage.plus) + .font(.system(size: .space16, weight: .semibold)) + .foregroundStyle(Colors.gray) + .frame(size: .space32 + .space6) + .liquidGlass(fallback: { $0.background(Colors.grayVeryLight).clipShape(Circle()) }) + } + } + + private var sendButton: some View { + Button(action: model.send) { + Image(systemName: SystemImage.arrowUp) + .font(.system(size: .space16, weight: .semibold)) + .foregroundStyle(model.canSend ? Colors.whiteSolid : Colors.gray) + .frame(size: .space32 + .space6) + .liquidGlass( + tint: model.canSend ? Colors.blue : nil, + interactive: model.canSend, + fallback: { $0.background(model.canSend ? Colors.blue : Colors.grayVeryLight).clipShape(Circle()) }, + ) + } + .disabled(!model.canSend) + } +} diff --git a/ios/Features/Support/Sources/Views/SupportSelectedItemPreview.swift b/ios/Features/Support/Sources/Views/SupportSelectedItemPreview.swift new file mode 100644 index 0000000000..7384f469f3 --- /dev/null +++ b/ios/Features/Support/Sources/Views/SupportSelectedItemPreview.swift @@ -0,0 +1,55 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import PhotosUI +import Style +import SwiftUI +import Primitives + +struct SupportSelectedItemPreview: View { + let item: PhotosPickerItem + let onRemove: () -> Void + + @State private var image: UIImage? + + var body: some View { + ZStack(alignment: .topTrailing) { + thumbnail + .frame(size: .image.semiLarge) + .clipShape(RoundedRectangle(cornerRadius: .space8)) + removeButton + .padding(.tiny) + } + .task { + guard image == nil else { return } + if let data = try? await item.loadTransferable(type: Data.self) { + image = UIImage(data: data) + } + } + } + + @ViewBuilder + private var thumbnail: some View { + if let image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Rectangle() + .fill(Colors.grayVeryLight) + .overlay { + Image(systemName: SystemImage.photo) + .foregroundStyle(Colors.gray) + } + } + } + + private var removeButton: some View { + Button(action: onRemove) { + Image(systemName: SystemImage.xmarkCircle) + .font(.title3) + .foregroundStyle(Colors.black, Colors.whiteSolid) + .background(Circle().fill(Colors.whiteSolid)) + } + .buttonStyle(.plain) + } +} diff --git a/ios/Features/Support/Sources/Views/SupportUserMessageGroup.swift b/ios/Features/Support/Sources/Views/SupportUserMessageGroup.swift new file mode 100644 index 0000000000..7e91a9ff95 --- /dev/null +++ b/ios/Features/Support/Sources/Views/SupportUserMessageGroup.swift @@ -0,0 +1,19 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Style +import SwiftUI + +struct SupportUserMessageGroup: View { + let messages: [SupportMessageBubbleViewModel] + + var body: some View { + VStack(alignment: .trailing, spacing: .tiny) { + ForEach(messages) { message in + HStack(spacing: .zero) { + Spacer(minLength: .space32) + SupportMessageBubble(model: message) + } + } + } + } +} diff --git a/ios/Features/Support/Sources/Views/WebView.swift b/ios/Features/Support/Sources/Views/WebView.swift deleted file mode 100644 index bdc0aac51f..0000000000 --- a/ios/Features/Support/Sources/Views/WebView.swift +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import SwiftUI -import WebKit - -struct WebView: UIViewRepresentable { - let model: WebSceneViewModel - - func makeUIView(context _: Context) -> WKWebView { - let webView = WKWebView() - webView.isOpaque = false - webView.backgroundColor = UIColor.clear - webView.scrollView.backgroundColor = UIColor.clear - webView.navigationDelegate = model - return webView - } - - func updateUIView(_ webView: WKWebView, context _: Context) { - let request = URLRequest(url: model.url) - webView.load(request) - } - - static func dismantleUIView(_ webView: WKWebView, coordinator _: ()) { - webView.navigationDelegate = nil - } -} diff --git a/ios/Gem.xcodeproj/project.pbxproj b/ios/Gem.xcodeproj/project.pbxproj index 74e49bd3e6..137a31999c 100644 --- a/ios/Gem.xcodeproj/project.pbxproj +++ b/ios/Gem.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ AA0000032F26000000000002 /* ConnectionsService in Frameworks */ = {isa = PBXBuildFile; productRef = AA0000022F26000000000002 /* ConnectionsService */; }; B604C2E82F3F538000CBAFDC /* Contacts in Frameworks */ = {isa = PBXBuildFile; productRef = B604C2E92F3F538100CBAFDC /* Contacts */; }; B60C6C352E68848900899BAC /* Support in Frameworks */ = {isa = PBXBuildFile; productRef = B60C6C342E68848900899BAC /* Support */; }; + SUPPCHATSVC0000000000002 /* SupportChatService in Frameworks */ = {isa = PBXBuildFile; productRef = SUPPCHATSVC0000000000001 /* SupportChatService */; }; B617D60B2D5B75F0001697A6 /* AvatarService in Frameworks */ = {isa = PBXBuildFile; productRef = B617D60A2D5B75F0001697A6 /* AvatarService */; }; B62F6B9D2DE61ADF00AF56AB /* Onboarding in Frameworks */ = {isa = PBXBuildFile; productRef = B62F6B9C2DE61ADF00AF56AB /* Onboarding */; }; B66AFC6C2DDB260A0082C026 /* AppService in Frameworks */ = {isa = PBXBuildFile; productRef = B66AFC6B2DDB260A0082C026 /* AppService */; }; @@ -384,6 +385,7 @@ B66DEDB02D49F0EA00309D53 /* ImageGalleryService in Frameworks */, 83D6808B2CCA90D900B45089 /* InfoSheet in Frameworks */, D829BF542CBF314100DEB2E8 /* PriceAlertService in Frameworks */, + SUPPCHATSVC0000000000002 /* SupportChatService in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -873,6 +875,7 @@ B60C6C342E68848900899BAC /* Support */, 83D0114E2EFB6341009274B8 /* TransactionStateService */, AA0000022F26000000000002 /* ConnectionsService */, + SUPPCHATSVC0000000000001 /* SupportChatService */, ); productName = wallet; productReference = C30952B0299C39D70004C0F9 /* Gem.app */; @@ -1880,6 +1883,10 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + SUPPCHATSVC0000000000001 /* SupportChatService */ = { + isa = XCSwiftPackageProductDependency; + productName = SupportChatService; + }; 074168392D3AFDBB00AC26A4 /* NFT */ = { isa = XCSwiftPackageProductDependency; productName = NFT; diff --git a/ios/Gem/Navigation/Settings/SettingsNavigationStack.swift b/ios/Gem/Navigation/Settings/SettingsNavigationStack.swift index 60ced907c2..234e404a33 100644 --- a/ios/Gem/Navigation/Settings/SettingsNavigationStack.swift +++ b/ios/Gem/Navigation/Settings/SettingsNavigationStack.swift @@ -13,6 +13,8 @@ import PrimitivesComponents import RewardsService import Settings import Store +import Support +import SupportChatService import SwiftUI import WalletConnector import WalletSessionService @@ -41,6 +43,7 @@ struct SettingsNavigationStack: View { @Environment(\.inAppNotificationService) private var inAppNotificationService @Environment(\.contactService) private var contactService @Environment(\.nameService) private var nameService + @Environment(\.supportChatService) private var supportChatService @State private var isPresentingWallets = false @State private var currencyModel: CurrencySceneViewModel @@ -78,7 +81,6 @@ struct SettingsNavigationStack: View { ), isPresentingWallets: $isPresentingWallets, isPresentingSupport: $isPresentingSupport, - deviceId: (try? SecurePreferences.standard.getDeviceId()) ?? "", ) .navigationBarTitleDisplayMode(.inline) .navigationDestination(for: Scenes.Security.self) { _ in @@ -199,6 +201,12 @@ struct SettingsNavigationStack: View { .sheet(isPresented: $isPresentingWallets) { WalletsNavigationStack() } + .sheet(isPresented: $isPresentingSupport) { + NavigationStack { + SupportChatScene(model: SupportChatSceneViewModel(service: supportChatService)) + .toolbarDismissItem(type: .close, placement: .topBarLeading) + } + } } } } diff --git a/ios/Gem/Services/AppResolver+Services.swift b/ios/Gem/Services/AppResolver+Services.swift index 5c5ec76e95..5f77884f51 100644 --- a/ios/Gem/Services/AppResolver+Services.swift +++ b/ios/Gem/Services/AppResolver+Services.swift @@ -27,6 +27,7 @@ import RewardsService import ScanService import StakeService import StreamService +import SupportChatService import SwapService import TransactionsService import TransactionStateService @@ -87,6 +88,7 @@ extension AppResolver { let portfolioService: PortfolioService let fiatService: FiatService let contactService: ContactService + let supportChatService: SupportChatService init( assetsService: AssetsService, @@ -139,6 +141,7 @@ extension AppResolver { portfolioService: PortfolioService, fiatService: FiatService, contactService: ContactService, + supportChatService: SupportChatService, ) { self.assetsService = assetsService self.balanceService = balanceService @@ -191,6 +194,7 @@ extension AppResolver { self.portfolioService = portfolioService self.fiatService = fiatService self.contactService = contactService + self.supportChatService = supportChatService } } } diff --git a/ios/Gem/Services/AppResolver+ViewInjection.swift b/ios/Gem/Services/AppResolver+ViewInjection.swift index 98d4e30854..7f8f4c774f 100644 --- a/ios/Gem/Services/AppResolver+ViewInjection.swift +++ b/ios/Gem/Services/AppResolver+ViewInjection.swift @@ -35,6 +35,7 @@ extension View { .environment(\.releaseService, services.appReleaseService) .environment(\.viewModelFactory, services.viewModelFactory) .environment(\.inAppNotificationService, services.inAppNotificationService) + .environment(\.supportChatService, services.supportChatService) } private func inject(storages: AppResolver.Storages) -> some View { diff --git a/ios/Gem/Services/ServicesFactory.swift b/ios/Gem/Services/ServicesFactory.swift index c4d35203e4..60e3ba6380 100644 --- a/ios/Gem/Services/ServicesFactory.swift +++ b/ios/Gem/Services/ServicesFactory.swift @@ -38,6 +38,7 @@ import ScanService import StakeService import Store import StreamService +import SupportChatService import SwapService import SwiftHTTPClient import TransactionsService @@ -296,6 +297,8 @@ struct ServicesFactory { let contactService = ContactService(store: storeManager.contactStore, addressStore: storeManager.addressStore) + let supportChatService = SupportChatService(store: storeManager.supportChatStore, provider: apiService) + let appLifecycleService = AppLifecycleService( preferences: preferences, connectionsService: connectionsService, @@ -378,6 +381,7 @@ struct ServicesFactory { portfolioService: portfolioService, fiatService: fiatService, contactService: contactService, + supportChatService: supportChatService, ) } } diff --git a/ios/Gem/Types/Environment.swift b/ios/Gem/Types/Environment.swift index 10ea4ce2a5..905633e103 100644 --- a/ios/Gem/Types/Environment.swift +++ b/ios/Gem/Types/Environment.swift @@ -29,6 +29,7 @@ import ScanService import StakeService import Store import StreamService +import SupportChatService import SwiftUI import TransactionsService import TransactionStateService @@ -76,4 +77,5 @@ extension EnvironmentValues { @Entry var inAppNotificationService: InAppNotificationService = AppResolver.main.services.inAppNotificationService @Entry var portfolioService: PortfolioService = AppResolver.main.services.portfolioService @Entry var contactService: ContactService = AppResolver.main.services.contactService + @Entry var supportChatService: SupportChatService = AppResolver.main.services.supportChatService } diff --git a/ios/Packages/Components/Sources/ViewModifiers/LiquidGlassModifier.swift b/ios/Packages/Components/Sources/ViewModifiers/LiquidGlassModifier.swift index 5f55fd0940..219e4a162d 100644 --- a/ios/Packages/Components/Sources/ViewModifiers/LiquidGlassModifier.swift +++ b/ios/Packages/Components/Sources/ViewModifiers/LiquidGlassModifier.swift @@ -4,39 +4,44 @@ import SwiftUI public struct LiquidGlassModifier: ViewModifier { private let tint: Color? private let interactive: Bool + private let shape: AnyShape - public init(tint: Color?, interactive: Bool) { + public init(tint: Color?, interactive: Bool, shape: AnyShape) { self.tint = tint self.interactive = interactive + self.shape = shape } public func body(content: Content) -> some View { content - .glassEffect(.regular.tint(tint).interactive(interactive)) + .glassEffect(.regular.tint(tint).interactive(interactive), in: shape) } } public extension View { @ViewBuilder - func liquidGlass( + func liquidGlass( tint: Color? = nil, interactive: Bool = true, + in shape: GlassShape = Capsule(), fallback: (Self) -> some View, ) -> some View { if #available(iOS 26.0, *) { - modifier(LiquidGlassModifier(tint: tint, interactive: interactive)) + modifier(LiquidGlassModifier(tint: tint, interactive: interactive, shape: AnyShape(shape))) } else { fallback(self) } } - func liquidGlass( + func liquidGlass( tint: Color? = nil, interactive: Bool = true, + in shape: GlassShape = Capsule(), ) -> some View { liquidGlass( tint: tint, interactive: interactive, + in: shape, fallback: { $0 }, ) } diff --git a/ios/Packages/FeatureServices/Package.swift b/ios/Packages/FeatureServices/Package.swift index 814a6929ee..8a01427c6d 100644 --- a/ios/Packages/FeatureServices/Package.swift +++ b/ios/Packages/FeatureServices/Package.swift @@ -55,6 +55,7 @@ let package = Package( .library(name: "ConnectionsService", targets: ["ConnectionsService"]), .library(name: "ConnectionsServiceTestKit", targets: ["ConnectionsServiceTestKit"]), .library(name: "ContactService", targets: ["ContactService"]), + .library(name: "SupportChatService", targets: ["SupportChatService"]), .library(name: "EarnService", targets: ["EarnService"]), .library(name: "EarnServiceTestKit", targets: ["EarnServiceTestKit"]), .library(name: "FiatService", targets: ["FiatService"]), @@ -556,6 +557,15 @@ let package = Package( ], path: "ContactService", ), + .target( + name: "SupportChatService", + dependencies: [ + "Primitives", + "Store", + "GemAPI", + ], + path: "SupportChatService", + ), .target( name: "RewardsService", dependencies: [ diff --git a/ios/Packages/FeatureServices/StreamService/StreamEventService.swift b/ios/Packages/FeatureServices/StreamService/StreamEventService.swift index 928aef645c..2e974a0059 100644 --- a/ios/Packages/FeatureServices/StreamService/StreamEventService.swift +++ b/ios/Packages/FeatureServices/StreamService/StreamEventService.swift @@ -67,6 +67,7 @@ public struct StreamEventService: Sendable { case let .fiatTransaction(update): Task { await perform { try await handleFiatTransactionUpdate(update) } } case .support: + // TODO: route to SupportChatService once the support stream backend is wired up. break } } diff --git a/ios/Packages/FeatureServices/SupportChatService/ImageAttachment.swift b/ios/Packages/FeatureServices/SupportChatService/ImageAttachment.swift new file mode 100644 index 0000000000..5665ee3db0 --- /dev/null +++ b/ios/Packages/FeatureServices/SupportChatService/ImageAttachment.swift @@ -0,0 +1,13 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public struct ImageAttachment: Sendable { + public let data: Data + public let fileName: String + + public init(data: Data, fileName: String) { + self.data = data + self.fileName = fileName + } +} diff --git a/ios/Packages/FeatureServices/SupportChatService/SupportChatError.swift b/ios/Packages/FeatureServices/SupportChatService/SupportChatError.swift new file mode 100644 index 0000000000..cfc5a98fd2 --- /dev/null +++ b/ios/Packages/FeatureServices/SupportChatService/SupportChatError.swift @@ -0,0 +1,7 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +enum SupportChatError: Error { + case imageDataUnavailable +} diff --git a/ios/Packages/FeatureServices/SupportChatService/SupportChatService.swift b/ios/Packages/FeatureServices/SupportChatService/SupportChatService.swift new file mode 100644 index 0000000000..470b9794a3 --- /dev/null +++ b/ios/Packages/FeatureServices/SupportChatService/SupportChatService.swift @@ -0,0 +1,76 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import GemAPI +import Primitives +import Store + +public final class SupportChatService: Sendable { + private let store: SupportChatStore + private let provider: any GemAPISupportService + private let imageStore = SupportImageStore() + + public init( + store: SupportChatStore, + provider: any GemAPISupportService = GemAPIService.shared, + ) { + self.store = store + self.provider = provider + } + + public func syncMessages(fromTimestamp: Int) async throws { + try store.addMessages(await provider.getSupportMessages(fromTimestamp: fromTimestamp)) + } + + public func sendMessage(content: String, attachments: [ImageAttachment]) async throws { + let messages = try pendingMessages(content: content, attachments: attachments) + try store.addMessages(messages) + for message in messages { + await deliver(message) + } + } + + public func retryMessage(_ message: SupportMessage) async throws { + try store.addMessages([message.with(deliveryStatus: .sending)]) + await deliver(message) + } +} + +// MARK: - Private + +private extension SupportChatService { + func pendingMessages(content: String, attachments: [ImageAttachment]) throws -> [SupportMessage] { + var messages: [SupportMessage] = [] + if content.isNotEmpty { + messages.append(.userText(content)) + } + for attachment in attachments { + let id = UUID().uuidString + let url = try imageStore.store(attachment.data, id: id) + messages.append(.userImage(id: id, url: url, fileName: attachment.fileName, fileSize: attachment.data.count)) + } + return messages + } + + func deliver(_ message: SupportMessage) async { + do { + let sent = try await send(message) + try store.replace(id: message.id, with: sent) + removeLocalImage(of: message) + } catch { + try? store.addMessages([message.with(deliveryStatus: .failed)]) + } + } + + func send(_ message: SupportMessage) async throws -> SupportMessage { + if let image = message.images.first, let url = image.url.asURL, let data = imageStore.data(at: url) { + return try await provider.sendSupportImage(image: data, fileName: image.fileName ?? "image", mimeType: image.mimeType) + } + return try await provider.sendSupportMessage(input: SupportMessageInput(content: message.content)) + } + + func removeLocalImage(of message: SupportMessage) { + guard let url = message.images.first?.url.asURL else { return } + imageStore.remove(at: url) + } +} diff --git a/ios/Packages/FeatureServices/SupportChatService/SupportImageStore.swift b/ios/Packages/FeatureServices/SupportChatService/SupportImageStore.swift new file mode 100644 index 0000000000..8437ee1ff9 --- /dev/null +++ b/ios/Packages/FeatureServices/SupportChatService/SupportImageStore.swift @@ -0,0 +1,31 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +struct SupportImageStore { + private let directoryName = "support_uploads" + private var directory: URL { + get throws { + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let directory = base.appendingPathComponent(directoryName, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + } + + func store(_ data: Data, id: String) throws -> URL { + let url = try directory.appendingPathComponent(id) + try data.write(to: url, options: .atomic) + return url + } + + func data(at url: URL) -> Data? { + guard url.isFileURL else { return nil } + return try? Data(contentsOf: url) + } + + func remove(at url: URL) { + guard url.isFileURL else { return } + try? FileManager.default.removeItem(at: url) + } +} diff --git a/ios/Packages/FeatureServices/SupportChatService/SupportMessage+SupportChat.swift b/ios/Packages/FeatureServices/SupportChatService/SupportMessage+SupportChat.swift new file mode 100644 index 0000000000..c8b0b0aac7 --- /dev/null +++ b/ios/Packages/FeatureServices/SupportChatService/SupportMessage+SupportChat.swift @@ -0,0 +1,55 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import UniformTypeIdentifiers + +extension SupportMessage { + static func userText(_ content: String) -> SupportMessage { + SupportMessage( + id: UUID().uuidString, + content: content, + sender: .user, + deliveryStatus: .sending, + createdAt: .now, + images: [], + ) + } + + static func userImage(id: String, url: URL, fileName: String, fileSize: Int) -> SupportMessage { + SupportMessage( + id: id, + content: "", + sender: .user, + deliveryStatus: .sending, + createdAt: .now, + images: [SupportMessageImage( + id: id, + url: url.absoluteString, + thumbnailUrl: nil, + fileName: fileName, + fileSize: UInt64(fileSize), + width: nil, + height: nil, + )], + ) + } + + func with(deliveryStatus: SupportMessageDeliveryStatus) -> SupportMessage { + SupportMessage( + id: id, + content: content, + sender: sender, + deliveryStatus: deliveryStatus, + createdAt: createdAt, + images: images, + ) + } +} + +extension SupportMessageImage { + var mimeType: String { + let fileExtension = fileName.map { ($0 as NSString).pathExtension } ?? "" + return UTType(filenameExtension: fileExtension)?.preferredMIMEType ?? "image/jpeg" + } +} diff --git a/ios/Packages/GemAPI/Sources/GemAPIService.swift b/ios/Packages/GemAPI/Sources/GemAPIService.swift index d690ef5195..6e8f23384d 100644 --- a/ios/Packages/GemAPI/Sources/GemAPIService.swift +++ b/ios/Packages/GemAPI/Sources/GemAPIService.swift @@ -87,7 +87,6 @@ public protocol GemAPIScanService: Sendable { } public protocol GemAPISupportService: Sendable { - func getSupportConversation() async throws -> SupportConversation? func getSupportMessages(fromTimestamp: Int) async throws -> [SupportMessage] func sendSupportMessage(input: SupportMessageInput) async throws -> SupportMessage func sendSupportImage(image: Data, fileName: String, mimeType: String) async throws -> SupportMessage @@ -308,11 +307,6 @@ extension GemAPIService: GemAPIScanService { } extension GemAPIService: GemAPISupportService { - public func getSupportConversation() async throws -> SupportConversation? { - try await requestDevice(.getSupportConversation) - .mapResponse(as: SupportConversation?.self) - } - public func getSupportMessages(fromTimestamp: Int) async throws -> [SupportMessage] { try await requestDevice(.getSupportMessages(fromTimestamp: fromTimestamp)) .mapResponse(as: [SupportMessage].self) diff --git a/ios/Packages/GemAPI/Sources/GemDeviceAPI.swift b/ios/Packages/GemAPI/Sources/GemDeviceAPI.swift index 476a75d229..579ece5b71 100644 --- a/ios/Packages/GemAPI/Sources/GemDeviceAPI.swift +++ b/ios/Packages/GemAPI/Sources/GemDeviceAPI.swift @@ -29,7 +29,6 @@ public enum GemDeviceAPI: TargetType { case scanTransaction(payload: ScanTransactionPayload) case getWalletConfiguration(walletId: WalletId) - case getSupportConversation case getSupportMessages(fromTimestamp: Int) case sendSupportMessage(input: SupportMessageInput) case sendSupportImage(image: Data, fileName: String, mimeType: String) @@ -83,7 +82,6 @@ public enum GemDeviceAPI: TargetType { .getFiatTransactions, .getNameRecord, .getWalletConfiguration, - .getSupportConversation, .getSupportMessages: .GET case .addDevice, @@ -148,8 +146,6 @@ public enum GemDeviceAPI: TargetType { return "/v2/devices/scan/transaction" case .getWalletConfiguration: return "/v2/devices/wallet_configuration" - case .getSupportConversation: - return "/v2/devices/support" case let .getSupportMessages(fromTimestamp): return "/v2/devices/support/messages?from_timestamp=\(fromTimestamp)" case .sendSupportMessage: @@ -237,7 +233,6 @@ public enum GemDeviceAPI: TargetType { .getFiatQuoteUrl, .getFiatTransactions, .getNameRecord, - .getSupportConversation, .getSupportMessages: return .plain case let .getPriceAlerts(assetId): diff --git a/ios/Packages/GemAPI/TestKit/GemAPISupportService+TestKit.swift b/ios/Packages/GemAPI/TestKit/GemAPISupportService+TestKit.swift index eef7d7ca07..624ef3d1f1 100644 --- a/ios/Packages/GemAPI/TestKit/GemAPISupportService+TestKit.swift +++ b/ios/Packages/GemAPI/TestKit/GemAPISupportService+TestKit.swift @@ -5,25 +5,16 @@ import GemAPI import Primitives public actor GemAPISupportServiceMock: GemAPISupportService { - private let conversation: SupportConversation? private let messages: [SupportMessage] public private(set) var sentMessages: [SupportMessageInput] = [] public private(set) var sentImages: [(image: Data, fileName: String, mimeType: String)] = [] public private(set) var sentActions: [SupportAction] = [] - public init( - conversation: SupportConversation? = nil, - messages: [SupportMessage] = [], - ) { - self.conversation = conversation + public init(messages: [SupportMessage] = []) { self.messages = messages } - public func getSupportConversation() async throws -> SupportConversation? { - conversation - } - public func getSupportMessages(fromTimestamp _: Int) async throws -> [SupportMessage] { messages } @@ -32,7 +23,6 @@ public actor GemAPISupportServiceMock: GemAPISupportService { sentMessages.append(input) return SupportMessage( id: "", - conversationId: "", content: input.content, sender: .user, deliveryStatus: .sent, @@ -45,7 +35,6 @@ public actor GemAPISupportServiceMock: GemAPISupportService { sentImages.append((image, fileName, mimeType)) return SupportMessage( id: "", - conversationId: "", content: "", sender: .user, deliveryStatus: .sent, diff --git a/ios/Packages/Primitives/Sources/Generated/Stream.swift b/ios/Packages/Primitives/Sources/Generated/Stream.swift index 3b77ed28a1..d436d1e7ce 100644 --- a/ios/Packages/Primitives/Sources/Generated/Stream.swift +++ b/ios/Packages/Primitives/Sources/Generated/Stream.swift @@ -67,7 +67,7 @@ public enum StreamEvent: Codable, Sendable { case perpetual(StreamWalletUpdate) case inAppNotification(StreamNotificationUpdate) case fiatTransaction(StreamWalletUpdate) - case support(SupportStreamEvent) + case support(SupportMessage) enum CodingKeys: String, CodingKey, Codable { case prices, @@ -130,7 +130,7 @@ public enum StreamEvent: Codable, Sendable { return } case .support: - if let content = try? container.decode(SupportStreamEvent.self, forKey: .data) { + if let content = try? container.decode(SupportMessage.self, forKey: .data) { self = .support(content) return } diff --git a/ios/Packages/Primitives/Sources/Generated/Support.swift b/ios/Packages/Primitives/Sources/Generated/Support.swift index cd26af62fd..e0beafe4b4 100644 --- a/ios/Packages/Primitives/Sources/Generated/Support.swift +++ b/ios/Packages/Primitives/Sources/Generated/Support.swift @@ -14,29 +14,6 @@ public struct SupportAgent: Codable, Equatable, Sendable { } } -public enum SupportConversationStatus: String, Codable, CaseIterable, Equatable, Sendable { - case open - case resolved -} - -public struct SupportConversation: Codable, Equatable, Hashable, Identifiable, Sendable { - public let id: String - public let status: SupportConversationStatus - public let firstMessage: String? - public let lastMessage: String? - public let lastActivityAt: Date - public let unreadCount: Int32 - - public init(id: String, status: SupportConversationStatus, firstMessage: String?, lastMessage: String?, lastActivityAt: Date, unreadCount: Int32) { - self.id = id - self.status = status - self.firstMessage = firstMessage - self.lastMessage = lastMessage - self.lastActivityAt = lastActivityAt - self.unreadCount = unreadCount - } -} - public enum SupportMessageSender: Codable, Equatable, Sendable { case user case agent(SupportAgent) @@ -107,16 +84,14 @@ public struct SupportMessageImage: Codable, Equatable, Sendable { public struct SupportMessage: Codable, Equatable, Sendable { public let id: String - public let conversationId: String public let content: String public let sender: SupportMessageSender public let deliveryStatus: SupportMessageDeliveryStatus public let createdAt: Date public let images: [SupportMessageImage] - public init(id: String, conversationId: String, content: String, sender: SupportMessageSender, deliveryStatus: SupportMessageDeliveryStatus, createdAt: Date, images: [SupportMessageImage]) { + public init(id: String, content: String, sender: SupportMessageSender, deliveryStatus: SupportMessageDeliveryStatus, createdAt: Date, images: [SupportMessageImage]) { self.id = id - self.conversationId = conversationId self.content = content self.sender = sender self.deliveryStatus = deliveryStatus @@ -175,51 +150,6 @@ public enum SupportAction: Codable, Equatable, Sendable { } } -public enum SupportStreamEvent: Codable, Sendable { - case message(SupportMessage) - case conversation(SupportConversation) - - enum CodingKeys: String, CodingKey, Codable { - case message, - conversation - } - - private enum ContainerCodingKeys: String, CodingKey { - case type, data - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: ContainerCodingKeys.self) - if let type = try? container.decode(CodingKeys.self, forKey: .type) { - switch type { - case .message: - if let content = try? container.decode(SupportMessage.self, forKey: .data) { - self = .message(content) - return - } - case .conversation: - if let content = try? container.decode(SupportConversation.self, forKey: .data) { - self = .conversation(content) - return - } - } - } - throw DecodingError.typeMismatch(SupportStreamEvent.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for SupportStreamEvent")) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: ContainerCodingKeys.self) - switch self { - case .message(let content): - try container.encode(CodingKeys.message, forKey: .type) - try container.encode(content, forKey: .data) - case .conversation(let content): - try container.encode(CodingKeys.conversation, forKey: .type) - try container.encode(content, forKey: .data) - } - } -} - public enum SupportTypingStatus: String, Codable, CaseIterable, Equatable, Sendable { case on case off diff --git a/ios/Packages/Store/Sources/Migrations.swift b/ios/Packages/Store/Sources/Migrations.swift index 0d1c0fa597..15ac82cf88 100644 --- a/ios/Packages/Store/Sources/Migrations.swift +++ b/ios/Packages/Store/Sources/Migrations.swift @@ -494,6 +494,10 @@ struct Migrations { ) } + migrator.registerMigration("Create \(SupportMessageRecord.databaseTableName)") { db in + try SupportMessageRecord.create(db: db) + } + try migrator.migrate(dbQueue) } } diff --git a/ios/Packages/Store/Sources/Models/SupportMessageRecord.swift b/ios/Packages/Store/Sources/Models/SupportMessageRecord.swift new file mode 100644 index 0000000000..d0f10f85d2 --- /dev/null +++ b/ios/Packages/Store/Sources/Models/SupportMessageRecord.swift @@ -0,0 +1,71 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import GRDB +import Primitives + +struct SupportMessageRecord: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName: String = "support_messages" + + enum Columns { + static let id = Column("id") + static let content = Column("content") + static let sender = Column("sender") + static let deliveryStatus = Column("deliveryStatus") + static let createdAt = Column("createdAt") + static let images = Column("images") + } + + var id: String + var content: String + var sender: SupportMessageSender + var deliveryStatus: SupportMessageDeliveryStatus + var createdAt: Date + var images: [SupportMessageImage] +} + +extension SupportMessageRecord: CreateTable { + static func create(db: Database) throws { + try db.create(table: databaseTableName, ifNotExists: true) { + $0.primaryKey(Columns.id.name, .text) + .notNull() + $0.column(Columns.content.name, .text) + .notNull() + $0.column(Columns.sender.name, .jsonText) + .notNull() + $0.column(Columns.deliveryStatus.name, .text) + .notNull() + $0.column(Columns.createdAt.name, .datetime) + .notNull() + .indexed() + $0.column(Columns.images.name, .jsonText) + .notNull() + } + } +} + +extension SupportMessageRecord { + var message: SupportMessage { + SupportMessage( + id: id, + content: content, + sender: sender, + deliveryStatus: deliveryStatus, + createdAt: createdAt, + images: images, + ) + } +} + +extension SupportMessage { + var record: SupportMessageRecord { + SupportMessageRecord( + id: id, + content: content, + sender: sender, + deliveryStatus: deliveryStatus, + createdAt: createdAt, + images: images, + ) + } +} diff --git a/ios/Packages/Store/Sources/Requests/SupportMessagesRequest.swift b/ios/Packages/Store/Sources/Requests/SupportMessagesRequest.swift new file mode 100644 index 0000000000..020f690f03 --- /dev/null +++ b/ios/Packages/Store/Sources/Requests/SupportMessagesRequest.swift @@ -0,0 +1,18 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import GRDB +import Primitives + +public struct SupportMessagesRequest: DatabaseQueryable { + public init() {} + + public func fetch(_ db: Database) throws -> [SupportMessage] { + try SupportMessageRecord + .order(SupportMessageRecord.Columns.createdAt.asc) + .fetchAll(db) + .map { $0.message } + } +} + +extension SupportMessagesRequest: Equatable {} diff --git a/ios/Packages/Store/Sources/Stores/StoreManager.swift b/ios/Packages/Store/Sources/Stores/StoreManager.swift index 82db63c900..b1fe403b60 100644 --- a/ios/Packages/Store/Sources/Stores/StoreManager.swift +++ b/ios/Packages/Store/Sources/Stores/StoreManager.swift @@ -22,6 +22,7 @@ public struct StoreManager: Sendable { public let inAppNotificationStore: InAppNotificationStore public let contactStore: ContactStore public let fiatTransactionStore: FiatTransactionStore + public let supportChatStore: SupportChatStore public init(db: DB) { assetStore = AssetStore(db: db) @@ -43,5 +44,6 @@ public struct StoreManager: Sendable { inAppNotificationStore = InAppNotificationStore(db: db) contactStore = ContactStore(db: db) fiatTransactionStore = FiatTransactionStore(db: db) + supportChatStore = SupportChatStore(db: db) } } diff --git a/ios/Packages/Store/Sources/Stores/SupportChatStore.swift b/ios/Packages/Store/Sources/Stores/SupportChatStore.swift new file mode 100644 index 0000000000..69c5c888eb --- /dev/null +++ b/ios/Packages/Store/Sources/Stores/SupportChatStore.swift @@ -0,0 +1,28 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import GRDB +import Primitives + +public struct SupportChatStore: Sendable { + let db: DatabaseQueue + + public init(db: DB) { + self.db = db.dbQueue + } + + public func addMessages(_ messages: [SupportMessage]) throws { + try db.write { db in + for message in messages { + try message.record.upsert(db) + } + } + } + + public func replace(id: String, with message: SupportMessage) throws { + try db.write { db in + _ = try SupportMessageRecord.deleteOne(db, key: id) + try message.record.upsert(db) + } + } +} diff --git a/ios/Packages/Style/Sources/SystemImage.swift b/ios/Packages/Style/Sources/SystemImage.swift index bcf775dedf..9a5c2cff91 100644 --- a/ios/Packages/Style/Sources/SystemImage.swift +++ b/ios/Packages/Style/Sources/SystemImage.swift @@ -9,7 +9,10 @@ public enum SystemImage { public static let paste = "doc.on.clipboard" public static let copy = "doc.on.doc" public static let arrowSwap = "arrow.trianglehead.2.clockwise" + public static let arrowUp = "arrow.up" public static let paperplane = "paperplane" + public static let compose = "square.and.pencil" + public static let bubbleLeftAndBubbleRight = "bubble.left.and.bubble.right" public static let chevronDown = "chevron.down" public static let chevronRight = "chevron.right" public static let clear = "multiply.circle.fill" @@ -54,6 +57,7 @@ public enum SystemImage { public static let arrowTriangleUp = "arrowtriangle.up.fill" public static let arrowTriangleDown = "arrowtriangle.down.fill" public static let person = "person" + public static let personCircleFill = "person.crop.circle.fill" public static let personBadgePlus = "person.crop.circle.badge.plus" public static let chartLineUptrendXyaxis = "chart.line.uptrend.xyaxis" public static let checkmarkSealFill = "checkmark.seal.fill"