Skip to content

Commit ad0ca5b

Browse files
authored
Merge pull request #490 from code-payments/feat/typing-indicator
feat(chat): add typing indicator
2 parents 65af1ed + ab8b5cb commit ad0ca5b

13 files changed

Lines changed: 494 additions & 45 deletions

File tree

api/src/main/java/com/getcode/model/chat/ChatStreamEventUpdate.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ import com.getcode.mapper.PointerStatus
44

55
data class ChatStreamEventUpdate(
66
val messages: List<ChatMessage>,
7-
val pointers: List<PointerStatus>
7+
val pointers: List<PointerStatus>,
8+
val isTyping: Boolean,
89
)

api/src/main/java/com/getcode/network/ConversationController.kt

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.getcode.network
33
import androidx.paging.Pager
44
import androidx.paging.PagingConfig
55
import androidx.paging.PagingData
6+
import com.getcode.api.BuildConfig
67
import com.getcode.db.AppDatabase
78
import com.getcode.db.Database
89
import com.getcode.manager.SessionManager
@@ -15,7 +16,6 @@ import com.getcode.model.ConversationWithLastPointers
1516
import com.getcode.model.ID
1617
import com.getcode.model.MessageStatus
1718
import com.getcode.model.SocialUser
18-
import com.getcode.model.chat.Chat
1919
import com.getcode.model.chat.ChatType
2020
import com.getcode.model.chat.MessageContent
2121
import com.getcode.model.chat.OutgoingMessageContent
@@ -27,13 +27,15 @@ import com.getcode.network.client.ChatMessageStreamReference
2727
import com.getcode.network.exchange.Exchange
2828
import com.getcode.network.repository.base58
2929
import com.getcode.network.service.ChatServiceV2
30-
import com.getcode.solana.keys.PublicKey
3130
import com.getcode.utils.ErrorUtils
3231
import com.getcode.utils.bytes
3332
import com.getcode.utils.trace
3433
import kotlinx.coroutines.CoroutineScope
3534
import kotlinx.coroutines.Dispatchers
3635
import kotlinx.coroutines.flow.Flow
36+
import kotlinx.coroutines.flow.MutableStateFlow
37+
import kotlinx.coroutines.flow.first
38+
import kotlinx.coroutines.flow.map
3739
import kotlinx.coroutines.launch
3840
import javax.inject.Inject
3941

@@ -50,6 +52,9 @@ interface ConversationController {
5052
suspend fun advanceReadPointer(conversationId: ID, messageId: ID, status: MessageStatus)
5153
suspend fun sendMessage(conversationId: ID, message: String): Result<ID>
5254
fun conversationPagingData(conversationId: ID): Flow<PagingData<ConversationMessageWithContent>>
55+
fun observeTyping(conversationId: ID): Flow<Boolean>
56+
suspend fun onUserStartedTypingIn(conversationId: ID)
57+
suspend fun onUserStoppedTypingIn(conversationId: ID)
5358
}
5459

5560
class ConversationStreamController @Inject constructor(
@@ -66,6 +71,8 @@ class ConversationStreamController @Inject constructor(
6671

6772
private var stream: ChatMessageStreamReference? = null
6873

74+
private val typingChats = MutableStateFlow<List<ID>>(emptyList())
75+
6976
private fun conversationPagingSource(conversationId: ID) =
7077
db.conversationMessageDao().observeConversationMessages(conversationId.base58)
7178

@@ -136,7 +143,13 @@ class ConversationStreamController @Inject constructor(
136143
) result@{ result ->
137144
if (result.isSuccess) {
138145
val updates = result.getOrNull() ?: return@result
139-
val (messages, pointers) = updates
146+
val (messages, pointers, isTyping) = updates
147+
148+
typingChats.value = if (isTyping) {
149+
typingChats.value + listOf(conversation.id).toSet()
150+
} else {
151+
typingChats.value - listOf(conversation.id).toSet()
152+
}
140153

141154
historyController.updateChatWithMessages(chat, messages)
142155
val messagesWithContent = messages.map {
@@ -165,7 +178,7 @@ class ConversationStreamController @Inject constructor(
165178
}
166179
}
167180

168-
println("chat messages: ${messages.count()}, pointers=${pointers.count()}")
181+
println("chat messages: ${messages.count()}, pointers=${pointers.count()}, isTyping=$isTyping")
169182

170183
scope.launch(Dispatchers.IO) {
171184
db.conversationMessageDao().upsertMessagesWithContent(messagesWithContent)
@@ -259,4 +272,45 @@ class ConversationStreamController @Inject constructor(
259272
initialKey = null,
260273
) { conversationPagingSource(conversationId) }.flow
261274

275+
override fun observeTyping(conversationId: ID): Flow<Boolean> =
276+
typingChats.map { it.contains(conversationId) }
277+
278+
override suspend fun onUserStartedTypingIn(conversationId: ID) {
279+
val owner = SessionManager.getOrganizer()?.ownerKeyPair ?: return
280+
281+
val chat = historyController.findChat { it.id == conversationId }
282+
?: return
283+
284+
val memberId = chat.selfId ?: return
285+
286+
chatService.onStartedTyping(
287+
owner, chat, memberId
288+
).onSuccess {
289+
println("on typing started reported")
290+
}.onFailure {
291+
if (BuildConfig.DEBUG) {
292+
it.printStackTrace()
293+
}
294+
}
295+
}
296+
297+
override suspend fun onUserStoppedTypingIn(conversationId: ID) {
298+
val owner = SessionManager.getOrganizer()?.ownerKeyPair ?: return
299+
300+
val chat = historyController.findChat { it.id == conversationId }
301+
?: return
302+
303+
val memberId = chat.selfId ?: return
304+
305+
chatService.onStoppedTyping(
306+
owner, chat, memberId
307+
).onSuccess {
308+
println("on typing stopped reported")
309+
}.onFailure {
310+
if (BuildConfig.DEBUG) {
311+
it.printStackTrace()
312+
}
313+
}
314+
}
315+
262316
}

api/src/main/java/com/getcode/network/api/ChatApiV2.kt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.getcode.network.api
33
import com.codeinc.gen.chat.v2.ChatService
44
import com.codeinc.gen.chat.v2.ChatService.ChatMemberIdentity
55
import com.codeinc.gen.chat.v2.ChatService.Content
6+
import com.codeinc.gen.chat.v2.ChatService.NotifyIsTypingRequest
7+
import com.codeinc.gen.chat.v2.ChatService.NotifyIsTypingResponse
68
import com.codeinc.gen.chat.v2.ChatService.PointerType
79
import com.codeinc.gen.chat.v2.ChatService.RevealIdentityRequest
810
import com.codeinc.gen.chat.v2.ChatService.RevealIdentityResponse
@@ -264,6 +266,46 @@ class ChatApiV2 @Inject constructor(
264266

265267
api.revealIdentity(request, observer)
266268
}
269+
270+
fun onStartedTyping(
271+
owner: KeyPair,
272+
chatId: ID,
273+
memberId: UUID,
274+
observer: StreamObserver<NotifyIsTypingResponse>
275+
) {
276+
val request = NotifyIsTypingRequest.newBuilder()
277+
.setChatId(ChatId.newBuilder()
278+
.setValue(chatId.toByteArray().toByteString())
279+
).setIsTyping(true)
280+
.setMemberId(ChatService.ChatMemberId.newBuilder()
281+
.setValue(memberId.bytes.toByteString())
282+
)
283+
.setOwner(owner.publicKeyBytes.toSolanaAccount())
284+
.apply { setSignature(sign(owner)) }
285+
.build()
286+
287+
api.notifyIsTyping(request, observer)
288+
}
289+
290+
fun onStoppedTyping(
291+
owner: KeyPair,
292+
chatId: ID,
293+
memberId: UUID,
294+
observer: StreamObserver<NotifyIsTypingResponse>
295+
) {
296+
val request = NotifyIsTypingRequest.newBuilder()
297+
.setChatId(ChatId.newBuilder()
298+
.setValue(chatId.toByteArray().toByteString())
299+
).setIsTyping(false)
300+
.setMemberId(ChatService.ChatMemberId.newBuilder()
301+
.setValue(memberId.bytes.toByteString())
302+
)
303+
.setOwner(owner.publicKeyBytes.toSolanaAccount())
304+
.apply { setSignature(sign(owner)) }
305+
.build()
306+
307+
api.notifyIsTyping(request, observer)
308+
}
267309
}
268310

269311
private val SocialUser.chatMemberIdentity: ChatMemberIdentity

api/src/main/java/com/getcode/network/service/ChatServiceV2.kt

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.getcode.network.service
22

33
import com.codeinc.gen.chat.v2.ChatService
44
import com.codeinc.gen.chat.v2.ChatService.ChatMemberId
5+
import com.codeinc.gen.chat.v2.ChatService.NotifyIsTypingResponse
56
import com.codeinc.gen.chat.v2.ChatService.OpenChatEventStream
67
import com.codeinc.gen.chat.v2.ChatService.PointerType
78
import com.codeinc.gen.chat.v2.ChatService.RevealIdentityResponse
@@ -26,6 +27,7 @@ import com.getcode.model.chat.ChatType
2627
import com.getcode.model.chat.OutgoingMessageContent
2728
import com.getcode.model.chat.Platform
2829
import com.getcode.model.description
30+
import com.getcode.model.uuid
2931
import com.getcode.network.api.ChatApiV2
3032
import com.getcode.network.client.ChatMessageStreamReference
3133
import com.getcode.network.core.NetworkOracle
@@ -398,8 +400,12 @@ class ChatServiceV2 @Inject constructor(
398400
.map { it.message }
399401
.map { messageMapper.map(chatLookup(conversation) to it) }
400402

403+
val isTyping = value.events.eventsList
404+
.map { it.isTyping }
405+
.find { it.isTyping && it.memberId.value.toList().uuid != memberId } != null
406+
401407
trace("Chat ${conversation.id.description} received ${messages.count()} messages and ${pointerStatuses.count()} status updates.")
402-
val update = ChatStreamEventUpdate(messages, pointerStatuses)
408+
val update = ChatStreamEventUpdate(messages, pointerStatuses, isTyping)
403409
onEvent(Result.success(update))
404410
}
405411

@@ -644,4 +650,150 @@ class ChatServiceV2 @Inject constructor(
644650
cont.resume(Result.failure(e))
645651
}
646652
}
653+
654+
suspend fun onStartedTyping(
655+
owner: KeyPair,
656+
chat: Chat,
657+
memberId: UUID,
658+
): Result<Unit> = suspendCancellableCoroutine { cont ->
659+
val chatId = chat.id
660+
try {
661+
api.onStartedTyping(
662+
owner,
663+
chatId,
664+
memberId,
665+
observer = object : StreamObserver<NotifyIsTypingResponse> {
666+
override fun onNext(value: NotifyIsTypingResponse?) {
667+
val requestResult = value?.result
668+
if (requestResult == null) {
669+
trace(
670+
message = "Chat NotifyIsTyping Server returned empty message. This is unexpected.",
671+
type = TraceType.Error
672+
)
673+
return
674+
}
675+
676+
val result = when (requestResult) {
677+
NotifyIsTypingResponse.Result.OK -> {
678+
Result.success(Unit)
679+
}
680+
681+
NotifyIsTypingResponse.Result.DENIED -> {
682+
val error = Throwable("Error: Send Message: Denied")
683+
Timber.e(t = error)
684+
Result.failure(error)
685+
}
686+
687+
NotifyIsTypingResponse.Result.CHAT_NOT_FOUND -> {
688+
val error = Throwable("Error: Send Message: chat not found $chatId")
689+
Timber.e(t = error)
690+
Result.failure(error)
691+
}
692+
693+
NotifyIsTypingResponse.Result.UNRECOGNIZED -> {
694+
val error = Throwable("Error: Send Message: Unrecognized request.")
695+
Timber.e(t = error)
696+
Result.failure(error)
697+
}
698+
699+
else -> {
700+
val error = Throwable("Error: Unknown")
701+
Timber.e(t = error)
702+
Result.failure(error)
703+
}
704+
}
705+
706+
cont.resume(result)
707+
}
708+
709+
override fun onError(t: Throwable?) {
710+
val error = t ?: Throwable("Error: Hit a snag")
711+
ErrorUtils.handleError(error)
712+
cont.resume(Result.failure(error))
713+
}
714+
715+
override fun onCompleted() {
716+
717+
}
718+
719+
}
720+
)
721+
} catch (e: Exception) {
722+
ErrorUtils.handleError(e)
723+
cont.resume(Result.failure(e))
724+
}
725+
}
726+
727+
suspend fun onStoppedTyping(
728+
owner: KeyPair,
729+
chat: Chat,
730+
memberId: UUID,
731+
): Result<Unit> = suspendCancellableCoroutine { cont ->
732+
val chatId = chat.id
733+
try {
734+
api.onStoppedTyping(
735+
owner,
736+
chatId,
737+
memberId,
738+
observer = object : StreamObserver<NotifyIsTypingResponse> {
739+
override fun onNext(value: NotifyIsTypingResponse?) {
740+
val requestResult = value?.result
741+
if (requestResult == null) {
742+
trace(
743+
message = "Chat NotifyIsTyping Server returned empty message. This is unexpected.",
744+
type = TraceType.Error
745+
)
746+
return
747+
}
748+
749+
val result = when (requestResult) {
750+
NotifyIsTypingResponse.Result.OK -> {
751+
Result.success(Unit)
752+
}
753+
754+
NotifyIsTypingResponse.Result.DENIED -> {
755+
val error = Throwable("Error: NotifyIsTyping: Denied")
756+
Timber.e(t = error)
757+
Result.failure(error)
758+
}
759+
760+
NotifyIsTypingResponse.Result.CHAT_NOT_FOUND -> {
761+
val error = Throwable("Error: NotifyIsTyping: chat not found $chatId")
762+
Timber.e(t = error)
763+
Result.failure(error)
764+
}
765+
766+
NotifyIsTypingResponse.Result.UNRECOGNIZED -> {
767+
val error = Throwable("Error: NotifyIsTyping: Unrecognized request.")
768+
Timber.e(t = error)
769+
Result.failure(error)
770+
}
771+
772+
else -> {
773+
val error = Throwable("Error: Unknown")
774+
Timber.e(t = error)
775+
Result.failure(error)
776+
}
777+
}
778+
779+
cont.resume(result)
780+
}
781+
782+
override fun onError(t: Throwable?) {
783+
val error = t ?: Throwable("Error: Hit a snag")
784+
ErrorUtils.handleError(error)
785+
cont.resume(Result.failure(error))
786+
}
787+
788+
override fun onCompleted() {
789+
790+
}
791+
792+
}
793+
)
794+
} catch (e: Exception) {
795+
ErrorUtils.handleError(e)
796+
cont.resume(Result.failure(e))
797+
}
798+
}
647799
}

app/src/main/java/com/getcode/navigation/screens/Modals.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ internal fun NamedScreen.ModalContainer(
9595
}
9696
callback()
9797
}
98+
Unit
9899
}
99100
SheetTitle(
100101
modifier = Modifier,
@@ -111,9 +112,9 @@ internal fun NamedScreen.ModalContainer(
111112
closeButton = closeButton,
112113
backButtonEnabled = isBackEnabled,
113114
closeButtonEnabled = isCloseEnabled,
114-
onBackIconClicked = onBackClicked?.let { { it() } }
115+
onBackIconClicked = onBackClicked?.let { { hideSheet { it() } } }
115116
?: { hideSheet { navigator.pop() } },
116-
onCloseIconClicked = onCloseClicked?.let { { it() } }
117+
onCloseIconClicked = onCloseClicked?.let { { hideSheet { it() } } }
117118
?: { hideSheet { navigator.hide() } }
118119
)
119120
Box(

0 commit comments

Comments
 (0)