-
Notifications
You must be signed in to change notification settings - Fork 9
feat: support for notification inbox #660
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b2b8946
178a506
273b46d
515bff1
3cd670d
10b6a98
2ea28c6
69696e5
70d9831
eb1c650
1bbfda8
a6911dd
b5cf24d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,8 +7,12 @@ import io.customer.messaginginapp.di.inAppPreferenceStore | |
| import io.customer.messaginginapp.di.inAppSseLogger | ||
| import io.customer.messaginginapp.gist.data.AnonymousMessageManager | ||
| import io.customer.messaginginapp.gist.data.NetworkUtilities | ||
| import io.customer.messaginginapp.gist.data.model.InboxMessage | ||
| import io.customer.messaginginapp.gist.data.model.Message | ||
| import io.customer.messaginginapp.gist.data.model.isMessageAnonymous | ||
| import io.customer.messaginginapp.gist.data.model.response.InboxMessageFactory | ||
| import io.customer.messaginginapp.gist.data.model.response.QueueMessagesResponse | ||
| import io.customer.messaginginapp.gist.data.model.response.toLogString | ||
| import io.customer.messaginginapp.state.InAppMessagingAction | ||
| import io.customer.messaginginapp.state.InAppMessagingState | ||
| import io.customer.messaginginapp.store.InAppPreferenceStore | ||
|
|
@@ -25,27 +29,33 @@ import retrofit2.Response | |
| import retrofit2.Retrofit | ||
| import retrofit2.converter.gson.GsonConverterFactory | ||
| import retrofit2.http.Body | ||
| import retrofit2.http.PATCH | ||
| import retrofit2.http.POST | ||
| import retrofit2.http.Path | ||
| import retrofit2.http.Query | ||
|
|
||
| interface GistQueueService { | ||
| @POST("/api/v3/users") | ||
| suspend fun fetchMessagesForUser(@Body body: Any = Object(), @Query("sessionId") sessionId: String): Response<List<Message>> | ||
| internal interface GistQueueService { | ||
| @POST("/api/v4/users") | ||
| suspend fun fetchMessagesForUser(@Body body: Any = Object(), @Query("sessionId") sessionId: String): Response<QueueMessagesResponse> | ||
|
|
||
| @POST("/api/v1/logs/message/{messageId}") | ||
| suspend fun logMessageView(@Path("messageId") messageId: String, @Query("sessionId") sessionId: String) | ||
|
|
||
| @POST("/api/v1/logs/queue/{queueId}") | ||
| suspend fun logUserMessageView(@Path("queueId") queueId: String, @Query("sessionId") sessionId: String) | ||
|
|
||
| @PATCH("/api/v1/messages/{queueId}") | ||
| suspend fun logInboxMessageOpened(@Path("queueId") queueId: String, @Query("sessionId") sessionId: String, @Body body: Map<String, Boolean>) | ||
| } | ||
|
|
||
| interface GistQueue { | ||
| internal interface GistQueue { | ||
| fun fetchUserMessages() | ||
| fun logView(message: Message) | ||
| fun logOpenedStatus(message: InboxMessage, opened: Boolean) | ||
| fun logDeleted(message: InboxMessage) | ||
| } | ||
|
|
||
| class Queue : GistQueue { | ||
| internal class Queue : GistQueue { | ||
|
|
||
| private val inAppMessagingManager = SDKComponent.inAppMessagingManager | ||
| private val state: InAppMessagingState | ||
|
|
@@ -108,12 +118,16 @@ class Queue : GistQueue { | |
| return response | ||
| } | ||
|
|
||
| // Populates 304 response body with cached data and converts to 200 for Retrofit compatibility. | ||
| // Retrofit only populates body() for 2xx responses, so we must convert 304->200. | ||
| // Adds custom header to track that this was originally a 304 response. | ||
| private fun interceptNotModifiedResponse(response: okhttp3.Response, originalRequest: okhttp3.Request): okhttp3.Response { | ||
| val cachedResponse = inAppPreferenceStore.getNetworkResponse(originalRequest.url.toString()) | ||
| return cachedResponse?.let { | ||
| response.newBuilder() | ||
| .body(it.toResponseBody(null)) | ||
| .code(200) | ||
| .header(HEADER_FROM_CACHE, "true") | ||
| .build() | ||
| } ?: response | ||
| } | ||
|
|
@@ -125,9 +139,14 @@ class Queue : GistQueue { | |
| val latestMessagesResponse = gistQueueService.fetchMessagesForUser(sessionId = state.sessionId) | ||
|
|
||
| val code = latestMessagesResponse.code() | ||
| val fromCache = latestMessagesResponse.headers()[HEADER_FROM_CACHE] == "true" | ||
| when { | ||
| (code == 204 || code == 304) -> handleNoContent(code) | ||
| latestMessagesResponse.isSuccessful -> handleSuccessfulFetch(latestMessagesResponse.body()) | ||
| latestMessagesResponse.isSuccessful -> handleSuccessfulFetch( | ||
| responseBody = latestMessagesResponse.body(), | ||
| fromCache = fromCache | ||
| ) | ||
|
|
||
| else -> handleFailedFetch(code) | ||
| } | ||
|
|
||
|
|
@@ -141,34 +160,64 @@ class Queue : GistQueue { | |
|
|
||
| private fun handleNoContent(responseCode: Int) { | ||
| logger.debug("No messages found for user with response code: $responseCode") | ||
| inAppMessagingManager.dispatch(InAppMessagingAction.ClearMessageQueue) | ||
| inAppMessagingManager.dispatch(InAppMessagingAction.ClearMessageQueue(isContentEmpty = true)) | ||
| } | ||
|
|
||
| private fun handleSuccessfulFetch(responseBody: List<Message>?) { | ||
| logger.debug("Found ${responseBody?.count()} messages for user") | ||
| responseBody?.let { messages -> | ||
| // For cached responses (304), apply locally cached opened status to preserve user's changes. | ||
| // For fresh responses (200), clear cached status and use server's data. | ||
| private fun handleSuccessfulFetch(responseBody: QueueMessagesResponse?, fromCache: Boolean) { | ||
| if (responseBody == null) { | ||
| logger.error("Received null response body for successful fetch") | ||
| return | ||
| } | ||
|
|
||
| responseBody.let { response -> | ||
| // Process in-app messages first | ||
| val inAppMessages = response.inAppMessages | ||
| logger.debug("Found ${inAppMessages.count()} in-app messages for user") | ||
| // Store anonymous messages locally for frequency management | ||
| anonymousMessageManager.updateAnonymousMessagesLocalStore(messages) | ||
| anonymousMessageManager.updateAnonymousMessagesLocalStore(inAppMessages) | ||
|
|
||
| // Get eligible anonymous messages from local storage (respects frequency/dismissal rules) | ||
| val eligibleAnonymousMessages = anonymousMessageManager.getEligibleAnonymousMessages() | ||
|
|
||
| // Filter out anonymous messages from server response (we use local ones instead) | ||
| val regularMessages = messages.filter { !it.isMessageAnonymous() } | ||
| val regularMessages = inAppMessages.filter { !it.isMessageAnonymous() } | ||
|
|
||
| // Combine regular messages with eligible anonymous messages | ||
| val allMessages = regularMessages + eligibleAnonymousMessages | ||
|
|
||
| logger.debug("Processing ${regularMessages.size} regular messages and ${eligibleAnonymousMessages.size} eligible anonymous messages") | ||
|
|
||
| // Process all messages through the normal queue | ||
| // Process all in-app messages through the normal queue | ||
| inAppMessagingManager.dispatch(InAppMessagingAction.ProcessMessageQueue(allMessages)) | ||
|
|
||
| // Process inbox messages next | ||
| val inboxMessages = response.inboxMessages | ||
| logger.debug("Found ${inboxMessages.count()} inbox messages for user") | ||
| val inboxMessagesMapped = inboxMessages.mapNotNull { item -> | ||
| InboxMessageFactory.fromResponse(item)?.let { message -> | ||
| if (fromCache) { | ||
| // 304: apply cached opened status if available | ||
| val cachedOpenedStatus = inAppPreferenceStore.getInboxMessageOpenedStatus(message.queueId) | ||
| return@let cachedOpenedStatus?.let { message.copy(opened = it) } ?: message | ||
| } else { | ||
| // 200: clear cached status and use server's data | ||
| inAppPreferenceStore.clearInboxMessageOpenedStatus(message.queueId) | ||
| return@let message | ||
| } | ||
| } | ||
| } | ||
| if (inboxMessagesMapped.size < inboxMessages.size) { | ||
| logger.debug("Filtered out ${inboxMessages.size - inboxMessagesMapped.size} invalid inbox message(s)") | ||
| } | ||
| inAppMessagingManager.dispatch(InAppMessagingAction.ProcessInboxMessages(inboxMessagesMapped)) | ||
| } | ||
| } | ||
|
|
||
| private fun handleFailedFetch(responseCode: Int) { | ||
| logger.error("Failed to fetch messages: $responseCode") | ||
| inAppMessagingManager.dispatch(InAppMessagingAction.ClearMessageQueue) | ||
| inAppMessagingManager.dispatch(InAppMessagingAction.ClearMessageQueue(isContentEmpty = false)) | ||
| } | ||
|
|
||
| private fun updatePollingInterval(headers: Headers) { | ||
|
|
@@ -207,4 +256,44 @@ class Queue : GistQueue { | |
| } | ||
| } | ||
| } | ||
|
|
||
| override fun logOpenedStatus(message: InboxMessage, opened: Boolean) { | ||
| scope.launch { | ||
| try { | ||
| logger.debug("Updating inbox message ${message.toLogString()} opened status to: $opened") | ||
| // Log updated opened status to server | ||
| gistQueueService.logInboxMessageOpened( | ||
| queueId = message.queueId, | ||
| sessionId = state.sessionId, | ||
| body = mapOf("opened" to opened) | ||
| ) | ||
| // Cache the opened status locally for 304 responses | ||
| inAppPreferenceStore.saveInboxMessageOpenedStatus(message.queueId, opened) | ||
| } catch (e: Exception) { | ||
| logger.error("Failed to update inbox message ${message.toLogString()} opened status: ${e.message}") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| override fun logDeleted(message: InboxMessage) { | ||
| scope.launch { | ||
| try { | ||
| logger.debug("Deleting inbox message: ${message.toLogString()}") | ||
| // Log deletion event to server | ||
| gistQueueService.logUserMessageView( | ||
| queueId = message.queueId, | ||
| sessionId = state.sessionId | ||
| ) | ||
| // Clear any cached opened status for deleted message | ||
| inAppPreferenceStore.clearInboxMessageOpenedStatus(message.queueId) | ||
| } catch (e: Exception) { | ||
| logger.error("Failed to delete inbox message ${message.toLogString()}: ${e.message}") | ||
| } | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Delete inbox message calls view-logging endpoint insteadHigh Severity The Additional Locations (1)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's intentional. The same API is used for marking an in app message as viewed and for deleting an inbox message. I'll think of more descriptive name and rename the method later to minimize confusion. |
||
|
|
||
| companion object { | ||
| // Custom header to distinguish 304 responses (converted to 200) from genuine 200 responses | ||
| private const val HEADER_FROM_CACHE = "X-CIO-MOBILE-SDK-Cache" | ||
| } | ||
| } | ||


Uh oh!
There was an error while loading. Please reload this page.