Skip to content

Commit 0ccb656

Browse files
committed
fix(messenger): never unfurl an invite URL on the website host
An apex /chat/invite link without a fragment became a web card. Also moves initWrongKey so each KDoc sits above its own function, and covers the wrong-key reducer and chat switch.
1 parent 50ba3e4 commit 0ccb656

4 files changed

Lines changed: 91 additions & 6 deletions

File tree

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2717,18 +2717,19 @@ internal class ChatViewModel @AssistedInject constructor(
27172717
.launchIn(viewModelScope)
27182718
}
27192719

2720-
/**
2721-
* The group events that are not an RPC's answer: the gate appearing, and the taps on the
2722-
* invite sheet, an invite card, the gate's funding button and the group profile. The screens report the taps
2723-
* as events so the member count and the gate mint come from the state this holds.
2724-
*/
2720+
/** Whether the key this account holds for the open chat is the wrong one (an invite join gone bad). */
27252721
private fun initWrongKey() {
27262722
stateFlow.map { it.chatId }.filterNotNull().distinctUntilChanged()
27272723
.flatMapLatest { wrongKeyChats.observe(it) }
27282724
.onEach { dispatchEvent(Event.OnWrongKey(it)) }
27292725
.launchIn(viewModelScope)
27302726
}
27312727

2728+
/**
2729+
* The group events that are not an RPC's answer: the gate appearing, and the taps on the
2730+
* invite sheet, an invite card, the gate's funding button and the group profile. The screens report the taps
2731+
* as events so the member count and the gate mint come from the state this holds.
2732+
*/
27322733
private fun initGroupAnalytics() {
27332734
// Once per view model, which is once per visit: the chat flow scopes it to the Chat route.
27342735
// Keyed on the gate being drawn — outside the group with the access decided — and not on

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,14 @@ internal class LinkCardClassifier @Inject constructor(
7777
private fun HttpUrl.isWebsitePage(): Boolean =
7878
host in WEBSITE_HOSTS &&
7979
fragment == null &&
80-
pathSegments.first().lowercase() !in SECRET_SEGMENTS
80+
pathSegments.first().lowercase() !in SECRET_SEGMENTS &&
81+
!isInvitePath()
82+
83+
/** `/chat/invite` in any case: an invite URL is never unfurled, with its fragment or without. */
84+
private fun HttpUrl.isInvitePath(): Boolean =
85+
pathSegments.size >= 2 &&
86+
pathSegments[0].equals("chat", ignoreCase = true) &&
87+
pathSegments[1].equals("invite", ignoreCase = true)
8188

8289
/**
8390
* The message a tapped link points at, when it is a message link on our hosts (wrapped in a

‎apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
3131
import kotlinx.coroutines.flow.MutableSharedFlow
3232
import kotlinx.coroutines.flow.MutableStateFlow
3333
import kotlinx.coroutines.flow.flowOf
34+
import kotlin.test.assertEquals
3435
import kotlin.test.assertFalse
3536
import kotlin.test.assertTrue
3637
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -195,4 +196,57 @@ class ChatTypingGateTest {
195196
advanceUntilIdle()
196197
assertFalse(viewModel.stateFlow.value.wrongKey)
197198
}
199+
200+
@Test
201+
fun `the wrong-key event sets the flag and nothing else`() = runTest {
202+
val viewModel = createViewModel()
203+
val before = viewModel.stateFlow.value
204+
viewModel.dispatchEvent(ChatViewModel.Event.OnWrongKey(true))
205+
advanceUntilIdle()
206+
assertEquals(before.copy(wrongKey = true), viewModel.stateFlow.value)
207+
208+
viewModel.dispatchEvent(ChatViewModel.Event.OnWrongKey(false))
209+
advanceUntilIdle()
210+
assertEquals(before, viewModel.stateFlow.value)
211+
}
212+
213+
@Test
214+
fun `the wrong-key mark follows the chat the screen switches to`() = runTest {
215+
val other = ChatId(UUID.randomUUID().bytes)
216+
every { contactCoordinator.lookupContact(any()) } returns Result.failure(NoSuchElementException())
217+
coEvery { contactCoordinator.lookupContactByDmChatId(any()) } returns null
218+
for (id in listOf(chatId, other)) {
219+
every { chatCoordinator.observeMetadata(id) } returns flowOf(
220+
ChatMembership(
221+
metadata = mockk<ChatMetadata>(relaxed = true) { every { this@mockk.type } returns ChatType.TIP_DM },
222+
isMember = true,
223+
),
224+
)
225+
}
226+
val first = MutableStateFlow(true)
227+
val second = MutableStateFlow(false)
228+
every { wrongKeyChats.observe(chatId) } returns first
229+
every { wrongKeyChats.observe(other) } returns second
230+
coEvery { chatCoordinator.hydrateChat(other) } returns ChatHydration.Stored
231+
every { chatCoordinator.observeMessages(other) } returns flowOf(emptyList())
232+
233+
val viewModel = createViewModel()
234+
viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(ChatIdentifier.ByChatId(chatId)))
235+
advanceUntilIdle()
236+
assertTrue(viewModel.stateFlow.value.wrongKey)
237+
238+
viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(ChatIdentifier.ByChatId(other)))
239+
advanceUntilIdle()
240+
assertFalse(viewModel.stateFlow.value.wrongKey)
241+
242+
// The old chat's mark changing is no longer this screen's business.
243+
first.value = false
244+
first.value = true
245+
advanceUntilIdle()
246+
assertFalse(viewModel.stateFlow.value.wrongKey)
247+
248+
second.value = true
249+
advanceUntilIdle()
250+
assertTrue(viewModel.stateFlow.value.wrongKey)
251+
}
198252
}

‎apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,29 @@ class LinkCardClassifierTest {
318318
assertEquals(outside, (card as? LinkCard.Web)?.url)
319319
}
320320

321+
/**
322+
* The fake router above stands in for the parser. These run the real `AppRouter` (through the
323+
* module that builds it), so a route the fake mis-states cannot hide an invite card.
324+
*/
325+
@Test
326+
fun `the real router gives an invite link no card on any host or wrapper`() {
327+
val classifier = LinkCardClassifier(
328+
com.flipcash.app.router.inject.RouterModule.providesRouter(io.mockk.mockk(relaxed = true)),
329+
)
330+
val secret = "A".repeat(43)
331+
val invite = "/chat/invite#s=$secret"
332+
for (url in listOf(
333+
"https://app.flipcash.com$invite",
334+
"https://flipcash.com$invite",
335+
"https://jump.flipcash.com/#source=" +
336+
java.net.URLEncoder.encode("https://app.flipcash.com$invite", "UTF-8"),
337+
"https://app.flipcash.com/chat/invite",
338+
"https://flipcash.com/chat/invite",
339+
)) {
340+
assertNull(classifier.firstCard(listOf(DetectedUrl(0, url.length, url))), url)
341+
}
342+
}
343+
321344
/** The send-cash path under a chat opens a payment, not the group, so it stays a plain link. */
322345
@Test
323346
fun `a chat send cash link stays a link`() {

0 commit comments

Comments
 (0)