Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import com.getcode.opencode.exchange.Exchange
import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError
import com.getcode.util.resources.ResourceHelper
import com.getcode.utils.TraceType
import com.getcode.utils.trace
import kotlinx.coroutines.flow.first
import javax.inject.Inject

Expand Down Expand Up @@ -50,16 +52,15 @@ internal class ReactionBoostPayer @Inject constructor(
totalCents: Long,
onAddMoney: () -> Unit,
): Result<Unit> {
val owner = userManager.accountCluster
?: return Result.failure(IllegalStateException("no account"))
val owner = userManager.accountCluster ?: return failed("no account")
val plan = when (val planned = plan(author, chat, totalCents)) {
is ReactionBoostPlan.Pay -> planned
ReactionBoostPlan.ShareBelowMinimum -> {
alert(R.string.error_title_amountTooSmall, R.string.error_description_amountTooSmall)
return blocked()
}
ReactionBoostPlan.SelfBoost, null ->
return Result.failure(IllegalStateException("cannot boost own message"))
ReactionBoostPlan.SelfBoost -> return failed("cannot boost own message")
null -> return failed("no account to plan from")
}

val token = startChattingPayer.observeSelectedToken().first()
Expand Down Expand Up @@ -87,14 +88,32 @@ internal class ReactionBoostPayer @Inject constructor(
alert(R.string.error_title_amountTooSmall, R.string.error_description_amountTooSmall)
is ComputeVerifiedFiatError ->
alert(R.string.error_title_staleRates, R.string.error_description_staleRates)
else -> alert(R.string.error_title_paymentFailed, R.string.error_description_paymentFailed)
else -> alertPaymentFailed()
}
}
}

/**
* The alert for a boost that can't go out for a reason the user can't act on. Without it the
* slider springs back with no explanation.
*/
fun alertPaymentFailed() {
alert(R.string.error_title_paymentFailed, R.string.error_description_paymentFailed)
}

private fun failed(reason: String): Result<Unit> {
trace(tag = TAG, message = "Boost not sent: $reason", type = TraceType.Error)
alertPaymentFailed()
return blocked()
}

private fun alert(title: Int, message: Int) {
BottomBarManager.showAlert(title = resources.getString(title), message = resources.getString(message))
}

private fun blocked(): Result<Unit> = Result.failure(StartChattingPayer.PaymentBlocked())

private companion object {
const val TAG = "ReactionBoost"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,13 @@ internal class ReactionBoostViewModel @Inject constructor(
private fun boost() {
val state = stateFlow.value
val chatId = state.chatId ?: return
val messageId = state.messageId ?: return
val emoji = state.emoji?.takeIf { it in state.ownReactions } ?: return
val cents = state.selectedCents ?: return
if (!state.sendProgress.isIdle) return
// The slider only enables with a chat, an amount and an emoji, so these two pass it and
// then fail here; say so rather than letting it spring back.
val messageId = state.messageId ?: return notSent("no message id")
val emoji = state.emoji?.takeIf { it in state.ownReactions }
?: return notSent("${state.emoji} is no longer the viewer's reaction on $messageId")

viewModelScope.launch {
dispatchEvent(Event.SendProgressChanged(LoadingSuccessState(loading = true)))
Expand All @@ -190,10 +193,12 @@ internal class ReactionBoostViewModel @Inject constructor(
val metadata = chatCoordinator.observeMetadata(chatId).first()?.metadata
val chat = boostChatOf(metadata?.type, metadata?.creator)
if (author == null || chat == null) {
trace(
tag = TAG,
message = "No author or chat kind for message $messageId; boost not sent",
type = TraceType.Error,
notSent(
unresolvedReason(
hasAuthor = author != null,
type = metadata?.type,
hasCreator = metadata?.creator != null,
) + " for message $messageId",
)
dispatchEvent(Event.SendProgressChanged(LoadingSuccessState()))
return@launch
Expand All @@ -217,6 +222,11 @@ internal class ReactionBoostViewModel @Inject constructor(
}
}

private fun notSent(reason: String) {
trace(tag = TAG, message = "Boost not sent: $reason", type = TraceType.Error)
payer.alertPaymentFailed()
}

internal companion object {
private const val TAG = "ReactionBoost"

Expand All @@ -233,6 +243,18 @@ internal class ReactionBoostViewModel @Inject constructor(
ChatType.UNKNOWN, null -> null
}

/** Which of the author and the chat kind [boostChatOf] needs is missing, for the trace. */
fun unresolvedReason(hasAuthor: Boolean, type: ChatType?, hasCreator: Boolean): String =
listOfNotNull(
"no author".takeUnless { hasAuthor },
when {
type == null -> "no chat metadata"
type == ChatType.UNKNOWN -> "unknown chat type"
type == ChatType.GROUP && !hasCreator -> "group has no creator"
else -> null
},
).joinToString().ifEmpty { "unresolved" }

/** The subtitle's [SplitCopy] for [plan] in [chat]; null when either is unknown. */
fun splitCopyOf(chat: BoostChat?, plan: ReactionBoostPlan?): SplitCopy? = when {
chat == null || plan !is ReactionBoostPlan.Pay -> null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,16 @@ class ReactionBoostReducerTest {
assertEquals(null, ReactionBoostViewModel.splitCopyOf(group, ReactionBoostSplit.plan(member, member, group, 100)))
assertEquals(null, ReactionBoostViewModel.splitCopyOf(null, null))
}

@Test
fun `an unresolved boost names what was missing`() {
fun reason(hasAuthor: Boolean, type: ChatType?, hasCreator: Boolean) =
ReactionBoostViewModel.unresolvedReason(hasAuthor, type, hasCreator)

assertEquals("group has no creator", reason(true, ChatType.GROUP, false))
assertEquals("no chat metadata", reason(true, null, false))
assertEquals("unknown chat type", reason(true, ChatType.UNKNOWN, true))
assertEquals("no author", reason(false, ChatType.TIP_DM, false))
assertEquals("no author, group has no creator", reason(false, ChatType.GROUP, false))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ interface ChatMetadataDao {
"description = :description, " +
"picture_json = :pictureJson, " +
"cover_picture_json = COALESCE(:coverPictureJson, cover_picture_json), " +
// Never cleared: the creator is fixed at creation. Refreshed rather than insert-only so
// a row stored before the column existed (DB v38) picks it up on the next sync.
"creator_hex = COALESCE(:creatorHex, creator_hex), " +
"rules_json = :rulesJson, " +
"is_member = :isMember, " +
"use_e2ee = :useE2ee, " +
Expand All @@ -109,6 +112,7 @@ interface ChatMetadataDao {
description: String?,
pictureJson: MediaItem?,
coverPictureJson: MediaItem?,
creatorHex: String?,
rulesJson: ChatRulesSerialized?,
isMember: Boolean,
useE2ee: Boolean,
Expand Down Expand Up @@ -212,6 +216,7 @@ interface ChatMetadataDao {
description = entity.description,
pictureJson = entity.pictureJson,
coverPictureJson = entity.coverPictureJson,
creatorHex = entity.creatorHex,
rulesJson = entity.rulesJson,
isMember = entity.isMember,
useE2ee = entity.useE2ee,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class ChatMetadataDaoTest {
description: String? = null,
pictureJson: MediaItem? = null,
coverPictureJson: MediaItem? = null,
creatorHex: String? = null,
memberCount: Long = 0,
rosterVersion: Long = 0,
rulesJson: ChatRulesSerialized? = null,
Expand All @@ -78,6 +79,7 @@ class ChatMetadataDaoTest {
description = description,
pictureJson = pictureJson,
coverPictureJson = coverPictureJson,
creatorHex = creatorHex,
memberCount = memberCount,
rosterVersion = rosterVersion,
rulesJson = rulesJson,
Expand Down Expand Up @@ -289,6 +291,25 @@ class ChatMetadataDaoTest {
assertEquals(cover, dao.getById(CHAT_HEX)?.coverPictureJson)
}

/** A row stored before `creator_hex` existed (DB v38) has none until a sync supplies it. */
@Test
fun `upsert backfills the creator of a stored group`() = runTest {
dao.upsert(entity(chatType = "GROUP", creatorHex = null))

dao.upsert(entity(chatType = "GROUP", creatorHex = CREATOR_HEX))

assertEquals(CREATOR_HEX, dao.getById(CHAT_HEX)?.creatorHex)
}

@Test
fun `upsert without a creator keeps the stored creator`() = runTest {
dao.upsert(entity(chatType = "GROUP", creatorHex = CREATOR_HEX))

dao.upsert(entity(chatType = "GROUP", creatorHex = null))

assertEquals(CREATOR_HEX, dao.getById(CHAT_HEX)?.creatorHex)
}

@Test
fun `upsert with a cover replaces the cached cover`() = runTest {
val cover = MediaItem(renditions = emptyList())
Expand Down Expand Up @@ -626,5 +647,6 @@ class ChatMetadataDaoTest {
private companion object {
const val CHAT_HEX = "aabbccdd"
const val OTHER_HEX = "eeff0011"
const val CREATOR_HEX = "c0ffee01"
}
}
Loading