Skip to content

Commit 3d6c1b0

Browse files
authored
fix(chat): sync the group feed on every trigger that refreshes the list (#1475)
The conversation list is two feeds behind one surface, and only one of them was reachable after login. `refreshFeed` delegated to `FeedSyncDelegate`, which fetches `CONTACT_DM` and `TIP_DM`; a group's row comes from `GroupFeedDelegate`, whose `syncGroupFeed` had exactly one caller, `onUserLoggedIn`. A chat push, a foreground resume, a network reconnect, the heartbeat's stream recovery, and both payment delegates refreshed the DM half alone. The chat-id-targeted half of a push plan hid most of this. `ApplyMessage` and `LoadMessages` do not branch on chat type, so a group the device already has reorders and re-previews normally. Both only ever `UPDATE chat_metadata ... WHERE chat_id_hex = :hex`, though, which is a no-op with no row, so a group joined from another device or joined while this one was backgrounded stayed out of the list until the next login. `syncFeeds` pairs the two fetches and every trigger goes through it, including the event stream's signal that a message arrived for a chat with no local row. The group feed is requested descending with a limit of 100, so a group that just pushed you a message is at the head of the page that fetches. The routing tests now tear the coordinator down in a `finally`. The heartbeat the login hook starts is a `while (true)` on the test scheduler, so a test that fails before its teardown line hangs the run instead of reporting.
1 parent f262065 commit 3d6c1b0

2 files changed

Lines changed: 125 additions & 39 deletions

File tree

apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import javax.inject.Singleton
4545
import kotlin.time.Duration.Companion.seconds
4646

4747
/**
48-
* Thin orchestration shell that implements [ChatCoordinator] by composing three
48+
* Thin orchestration shell that implements [ChatCoordinator] by composing five
4949
* focused delegates via Kotlin `by` interface delegation:
5050
*
5151
* | Delegate | Interface | Responsibility |
@@ -54,13 +54,17 @@ import kotlin.time.Duration.Companion.seconds
5454
* | [EventStreamDelegate] | [EventStreamOperations] | Event stream, real-time updates, gap-aware sequencing, reactions, typing |
5555
* | [DmChatResolverDelegate] | [DmChatResolver] | Resolve a DM's [ChatId] from its participants (derive or look up) |
5656
* | [MessagingDelegate] | [MessagingOperations] | Per-chat send/receive, read pointers, paging, notifications |
57+
* | [GroupFeedDelegate] | [GroupOperations] | Group feed sync, join/leave, roster changes |
5758
*
5859
* **What lives here (and why):**
5960
* - **Event routing** — each delegate exposes a `Flow<Event>` (backed by a `Channel`);
6061
* the `init` block collects both and dispatches cross-delegate calls (e.g.
6162
* feed-delegate's `DeltaSyncNeeded` → `eventStreamDelegate.performDeltaSync`,
62-
* event-stream-delegate's `SyncFeedRequested` → `feedDelegate.syncFeed`).
63+
* event-stream-delegate's `SyncFeedRequested` → [syncFeeds]).
6364
* All cross-delegate wiring is visible in one place.
65+
* - **Feed composition** — the conversation list is [FeedSyncDelegate]'s DM feeds plus
66+
* [GroupFeedDelegate]'s group feed, so every refresh trigger goes through [syncFeeds]
67+
* rather than either delegate directly. See [refreshFeed].
6468
* - **Lifecycle methods** — [onStart]/[onStop] are inherently cross-cutting
6569
* (stream connect/disconnect, heartbeat start/stop, active-chat save/restore).
6670
* - **Flow observers** — network reconnect re-syncing the chat feed.
@@ -120,12 +124,11 @@ class RealChatCoordinator @Inject constructor(
120124
this.cluster.value = cluster
121125
feedDelegate.initialize(scope)
122126
eventStreamDelegate.initialize(scope)
123-
feedDelegate.observeFeedFromDb()
124-
feedDelegate.syncFeed()
125127
groupFeedDelegate.initialize(scope)
126-
groupFeedDelegate.syncGroupFeed()
128+
feedDelegate.observeFeedFromDb()
129+
syncFeeds()
127130
eventStreamDelegate.open()
128-
eventStreamDelegate.startHeartbeat { feedDelegate.syncFeed() }
131+
eventStreamDelegate.startHeartbeat { syncFeeds() }
129132
}
130133

131134
// endregion
@@ -166,7 +169,7 @@ class RealChatCoordinator @Inject constructor(
166169
.onEach { event ->
167170
when (event) {
168171
is EventStreamDelegate.Event.SyncFeedRequested ->
169-
feedDelegate.syncFeed()
172+
syncFeeds()
170173
is EventStreamDelegate.Event.LoadMessages ->
171174
messagingDelegate.loadMessages(event.chatId)
172175
is EventStreamDelegate.Event.RosterChanged ->
@@ -191,7 +194,7 @@ class RealChatCoordinator @Inject constructor(
191194
.debounce(1.seconds)
192195
.onEach {
193196
trace(tag = TAG, message = "Network connected, re-syncing chat feed", type = TraceType.Process)
194-
feedDelegate.syncFeed()
197+
syncFeeds()
195198
eventStreamDelegate.open()
196199
}
197200
.launchIn(scope)
@@ -205,9 +208,9 @@ class RealChatCoordinator @Inject constructor(
205208
scope.launch {
206209
if (cluster.value != null) {
207210
trace(tag = TAG, message = "Lifecycle resumed, syncing chat feed", type = TraceType.Process)
208-
feedDelegate.syncFeed()
211+
syncFeeds()
209212
eventStreamDelegate.open()
210-
eventStreamDelegate.startHeartbeat { feedDelegate.syncFeed() }
213+
eventStreamDelegate.startHeartbeat { syncFeeds() }
211214
}
212215
}
213216
}
@@ -223,6 +226,33 @@ class RealChatCoordinator @Inject constructor(
223226

224227
// region ChatCoordinator
225228

229+
/**
230+
* Overrides the [FeedOperations] delegation, which would otherwise refresh the DM half alone.
231+
*
232+
* The callers — a chat push, a contact payment, a tip payment — are asking for the
233+
* conversation list, and none of them know which half a chat belongs to.
234+
*/
235+
override fun refreshFeed() {
236+
syncFeeds()
237+
}
238+
239+
/**
240+
* Fetches both halves of the conversation list.
241+
*
242+
* The list is two feeds behind one surface: [FeedSyncDelegate] fetches `CONTACT_DM` and
243+
* `TIP_DM`, and a group's row comes from [GroupFeedDelegate] alone. Every trigger that
244+
* re-syncs means "the list may be stale", which is never true of only one half, so pairing
245+
* them is this class's job rather than something each trigger site remembers.
246+
*
247+
* Both are launch-and-return, and the delegates hold separate jobs, so the two fetches
248+
* overlap rather than queue. A group feed that fails is traced and dropped by
249+
* [GroupFeedDelegate.performGroupFeedSync]; it cannot take the DM list down with it.
250+
*/
251+
private fun syncFeeds() {
252+
feedDelegate.syncFeed()
253+
groupFeedDelegate.syncGroupFeed()
254+
}
255+
226256
override suspend fun teardown() {
227257
eventStreamDelegate.stopHeartbeat()
228258
eventStreamDelegate.close()

apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt

Lines changed: 85 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.flipcash.shared.chat
22

3+
import androidx.lifecycle.LifecycleOwner
34
import com.flipcash.app.core.dispatchers.TestDispatchers
45
import com.flipcash.app.persistence.sources.ChatMemberDataSource
56
import com.flipcash.app.persistence.sources.ChatMessageDataSource
@@ -12,6 +13,7 @@ import com.flipcash.services.controllers.EventStreamingController
1213
import com.flipcash.services.models.UserProfile
1314
import com.flipcash.services.models.chat.ChatId
1415
import com.flipcash.services.models.chat.ChatMember
16+
import com.flipcash.services.models.chat.ChatType
1517
import com.flipcash.services.models.chat.ChatUpdate
1618
import com.flipcash.services.models.chat.RosterChange
1719
import com.flipcash.services.models.chat.RosterSummary
@@ -26,38 +28,45 @@ import com.flipcash.shared.chat.internal.delegates.GroupFeedDelegate
2628
import com.flipcash.shared.chat.internal.delegates.MessagingDelegate
2729
import com.getcode.utils.network.NetworkConnectivityListener
2830
import com.getcode.opencode.model.accounts.AccountCluster
31+
import io.mockk.clearMocks
2932
import io.mockk.coEvery
3033
import io.mockk.coVerify
3134
import io.mockk.every
3235
import io.mockk.mockk
36+
import io.mockk.verify
3337
import kotlinx.coroutines.ExperimentalCoroutinesApi
3438
import kotlinx.coroutines.channels.Channel
3539
import kotlinx.coroutines.flow.receiveAsFlow
3640
import kotlinx.coroutines.test.TestCoroutineScheduler
41+
import kotlinx.coroutines.test.TestScope
3742
import kotlinx.coroutines.test.runCurrent
3843
import kotlinx.coroutines.test.runTest
3944
import org.junit.Test
4045
import org.junit.runner.RunWith
4146
import org.robolectric.RobolectricTestRunner
4247

4348
/**
44-
* The group work reaches the rest of the app through exactly two seams: a roster change on the
45-
* event stream, and the login hook. Both are the coordinator's, so both are checked here rather
46-
* than in the delegates that do the work.
49+
* The group work reaches the rest of the app through the coordinator and nowhere else: a roster
50+
* change on the event stream, and every trigger that re-syncs the conversation list. All of them
51+
* are checked here rather than in the delegates that do the work.
52+
*
53+
* The refresh triggers are the reason this file is not only about routing. A group's row comes
54+
* from `GroupFeedDelegate` alone, so a trigger that reaches `FeedSyncDelegate` by itself leaves
55+
* the group half of the list on whatever the login pass fetched — which is what these assert
56+
* against.
4757
*/
4858
@OptIn(ExperimentalCoroutinesApi::class)
4959
@RunWith(RobolectricTestRunner::class)
5060
class GroupChatRoutingTest {
5161

52-
// Every test tears the coordinator down. The heartbeat the login hook starts is a `while
53-
// (true)` on the test scheduler, so a test that leaves it running advances virtual time
54-
// forever instead of finishing.
55-
5662
private val chatId = ChatId("11223344")
5763
private val selfId = listOf<Byte>(1, 2, 3)
5864
private val chatUpdatesChannel = Channel<ChatUpdate>(capacity = Channel.UNLIMITED)
5965

6066
private val groupFeedDelegate = mockk<GroupFeedDelegate>(relaxed = true)
67+
private val chatController = mockk<ChatController>(relaxed = true).also {
68+
coEvery { it.getDmChatFeed(any(), any()) } returns Result.failure(RuntimeException("not needed"))
69+
}
6170

6271
private val testDispatchers = TestDispatchers(TestCoroutineScheduler())
6372

@@ -70,9 +79,6 @@ class GroupChatRoutingTest {
7079
every { it.isConnected } returns true
7180
every { it.isStreamActive } returns true
7281
}
73-
val chatController = mockk<ChatController>(relaxed = true).also {
74-
coEvery { it.getDmChatFeed(any(), any()) } returns Result.failure(RuntimeException("not needed"))
75-
}
7682
val metadataDataSource = mockk<ChatMetadataDataSource>(relaxed = true)
7783
val messageDataSource = mockk<ChatMessageDataSource>(relaxed = true)
7884
val memberDataSource = mockk<ChatMemberDataSource>(relaxed = true)
@@ -135,40 +141,90 @@ class GroupChatRoutingTest {
135141
rosterSummary = RosterSummary(memberCount = 13, version = 5),
136142
)
137143

138-
@Test
139-
fun `a roster change on the stream reaches the group delegate`() = runTest(testDispatchers.dispatcher) {
144+
/**
145+
* Runs [block] against a logged-in coordinator and tears it down afterwards, however it ends.
146+
*
147+
* The `finally` is the point. The heartbeat the login hook starts is a `while (true)` on the
148+
* test scheduler, so a test that fails its assertion before reaching teardown hangs the run
149+
* advancing virtual time instead of reporting the failure.
150+
*/
151+
private suspend fun TestScope.loggedIn(block: suspend (RealChatCoordinator) -> Unit) {
140152
val subject = coordinator()
141153
subject.onUserLoggedIn(mockk<AccountCluster>(relaxed = true))
142154
runCurrent()
155+
try {
156+
block(subject)
157+
} finally {
158+
subject.teardown()
159+
}
160+
}
143161

144-
chatUpdatesChannel.send(ChatUpdate(chatId = chatId, rosterUpdates = listOf(change)))
145-
runCurrent()
162+
@Test
163+
fun `a roster change on the stream reaches the group delegate`() = runTest(testDispatchers.dispatcher) {
164+
loggedIn {
165+
chatUpdatesChannel.send(ChatUpdate(chatId = chatId, rosterUpdates = listOf(change)))
166+
runCurrent()
146167

147-
coVerify { groupFeedDelegate.applyRosterChanges(chatId, listOf(change)) }
148-
subject.teardown()
168+
coVerify { groupFeedDelegate.applyRosterChanges(chatId, listOf(change)) }
169+
}
149170
}
150171

151172
@Test
152173
fun `an update with no roster change does not reach the group delegate`() = runTest(testDispatchers.dispatcher) {
153-
val subject = coordinator()
154-
subject.onUserLoggedIn(mockk<AccountCluster>(relaxed = true))
155-
runCurrent()
174+
loggedIn {
175+
chatUpdatesChannel.send(ChatUpdate(chatId = chatId))
176+
runCurrent()
156177

157-
chatUpdatesChannel.send(ChatUpdate(chatId = chatId))
158-
runCurrent()
159-
160-
coVerify(exactly = 0) { groupFeedDelegate.applyRosterChanges(any(), any()) }
161-
subject.teardown()
178+
coVerify(exactly = 0) { groupFeedDelegate.applyRosterChanges(any(), any()) }
179+
}
162180
}
163181

164182
@Test
165183
fun `logging in syncs the group feed`() = runTest(testDispatchers.dispatcher) {
166-
val subject = coordinator()
167-
subject.onUserLoggedIn(mockk<AccountCluster>(relaxed = true))
168-
runCurrent()
184+
loggedIn {
185+
verify(exactly = 1) { groupFeedDelegate.syncGroupFeed() }
186+
}
187+
}
169188

170-
coVerify { groupFeedDelegate.syncGroupFeed() }
171-
subject.teardown()
189+
@Test
190+
fun `refreshing the feed syncs the group feed`() = runTest(testDispatchers.dispatcher) {
191+
loggedIn { subject ->
192+
clearMocks(groupFeedDelegate, answers = false)
193+
194+
// What a chat push and both payment delegates call.
195+
subject.refreshFeed()
196+
runCurrent()
197+
198+
verify(exactly = 1) { groupFeedDelegate.syncGroupFeed() }
199+
}
200+
}
201+
202+
@Test
203+
fun `refreshing the feed still syncs the DM feeds`() = runTest(testDispatchers.dispatcher) {
204+
loggedIn { subject ->
205+
clearMocks(chatController, answers = false)
206+
207+
// [RealChatCoordinator.refreshFeed] overrides the FeedOperations delegation, so the
208+
// half that used to be the only one running needs asserting as well as the half that
209+
// did not.
210+
subject.refreshFeed()
211+
runCurrent()
212+
213+
coVerify(exactly = 1) { chatController.getDmChatFeed(ChatType.CONTACT_DM, any()) }
214+
coVerify(exactly = 1) { chatController.getDmChatFeed(ChatType.TIP_DM, any()) }
215+
}
216+
}
217+
218+
@Test
219+
fun `resuming from the background syncs the group feed`() = runTest(testDispatchers.dispatcher) {
220+
loggedIn { subject ->
221+
clearMocks(groupFeedDelegate, answers = false)
222+
223+
subject.onStart(mockk<LifecycleOwner>(relaxed = true))
224+
runCurrent()
225+
226+
verify(exactly = 1) { groupFeedDelegate.syncGroupFeed() }
227+
}
172228
}
173229

174230
@Test

0 commit comments

Comments
 (0)