Skip to content
39 changes: 30 additions & 9 deletions app/src/main/kotlin/com/wisp/app/Navigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1271,7 +1271,9 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays,
recipientPubkey = searchZapTarget?.pubkey,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
SearchScreen(
Expand Down Expand Up @@ -1641,7 +1643,9 @@ fun WispNavHost(
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays,
initialSatsHint = groupRoomZapInitialSats
initialSatsHint = groupRoomZapInitialSats,
recipientPubkey = groupRoomZapTarget?.pubkey,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
val groupRoomMediaLauncher = rememberLauncherForActivityResult(
Expand Down Expand Up @@ -1918,7 +1922,9 @@ fun WispNavHost(
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && threadUserHasDmRelays && threadRecipientHasDmRelays,
forcePrivate = threadZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true
forcePrivate = threadZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true,
recipientPubkey = threadZapTarget?.pubkey,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
val threadSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState()
Expand Down Expand Up @@ -2088,7 +2094,9 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays,
recipientPubkey = hashtagZapTarget?.pubkey,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}

Expand Down Expand Up @@ -2240,7 +2248,9 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays,
recipientPubkey = setFeedZapTarget?.pubkey,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}

Expand Down Expand Up @@ -2405,7 +2415,9 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays,
recipientPubkey = articleZapTarget?.pubkey,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}

Expand Down Expand Up @@ -2612,7 +2624,12 @@ fun WispNavHost(
// DIP-03 needs a concrete note id for the ephemeral key
// derivation; live-stream zaps target an addressable event
// (a-tag) instead, so private zaps don't apply here.
canPrivateZap = false
canPrivateZap = false,
// Live streams: use the streamer override pubkey when set
// (the chat host is what's interesting to identify), else
// fall back to the post author.
recipientPubkey = liveZapRecipientOverride ?: liveZapTarget?.pubkey,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
val streamActivityEventId = remember(hostPubkey, dTag) {
Expand Down Expand Up @@ -3175,7 +3192,9 @@ fun WispNavHost(
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && notifUserHasDmRelays && notifRecipientHasDmRelays,
forcePrivate = notifZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true
forcePrivate = notifZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true,
recipientPubkey = notifZapTarget?.pubkey,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}

Expand All @@ -3193,7 +3212,9 @@ fun WispNavHost(
rumorId = target.rumorId.ifEmpty { null }
)
},
onGoToWallet = { navController.navigate(Routes.WALLET) }
onGoToWallet = { navController.navigate(Routes.WALLET) },
recipientPubkey = notifDmZapTarget?.senderPubkey,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}

Expand Down
76 changes: 72 additions & 4 deletions app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ class InterfacePreferences(context: Context) {
}
}

companion object {
val postUndoTimerOptions = listOf(5, 10, 15, 20, 30)
}

private val prefs = context.getSharedPreferences("wisp_settings", Context.MODE_PRIVATE)

fun getAccentColor(): Int = prefs.getInt("accent_color", 0xFFFF9800.toInt())
Expand Down Expand Up @@ -69,6 +65,78 @@ class InterfacePreferences(context: Context) {
fun isPostUndoTimerForReplies(): Boolean = prefs.getBoolean("post_undo_timer_for_replies", false)
fun setPostUndoTimerForReplies(enabled: Boolean) = prefs.edit().putBoolean("post_undo_timer_for_replies", enabled).apply()

// ── Instant (a.k.a. quick) zaps ─────────────────────────────────────────
// Hold-to-zap on the post-card fires immediately at the configured
// amount when enabled; tap still opens the composer.
//
// Keys are scoped per-account via activePubkey (companion object) so
// switching accounts never inherits the previous account's values.
// Call reload(pubkey) on every account switch to update the scope.

private fun quickZapKey(base: String): String =
activePubkey?.let { "${base}_$it" } ?: base

fun isQuickZapEnabled(): Boolean = prefs.getBoolean(quickZapKey("quick_zap_enabled"), false)
fun setQuickZapEnabled(enabled: Boolean) =
prefs.edit().putBoolean(quickZapKey("quick_zap_enabled"), enabled).apply()

fun getQuickZapAmountSats(): Long =
prefs.getLong(quickZapKey("quick_zap_amount_sats"), 21L).coerceIn(1L, QUICK_ZAP_MAX_SATS)
fun setQuickZapAmountSats(amount: Long) {
// Hard clamp at 10K sats so an instant zap never bypasses the soft
// confirmation dialog in the ZapSheet (which fires at >10K).
val clamped = amount.coerceIn(1L, QUICK_ZAP_MAX_SATS)
prefs.edit().putLong(quickZapKey("quick_zap_amount_sats"), clamped).apply()
}

fun getQuickZapAmountFiat(): Double =
prefs.getString(quickZapKey("quick_zap_amount_fiat"), "0.10")?.toDoubleOrNull()?.coerceAtLeast(0.0) ?: 0.10
fun setQuickZapAmountFiat(amount: Double) {
// Fiat clamp happens at fire time against the cached exchange rate
// (callers in ZapSheet do `min(localFiat, sats→fiat(10_000))`).
val clamped = amount.coerceAtLeast(0.0)
prefs.edit().putString(quickZapKey("quick_zap_amount_fiat"), clamped.toString()).apply()
}

fun getQuickZapMessage(): String = prefs.getString(quickZapKey("quick_zap_message"), "") ?: ""
fun setQuickZapMessage(message: String) =
prefs.edit().putString(quickZapKey("quick_zap_message"), message).apply()

/**
* Update the active account scope for instant-zap keys. Call on every
* account switch AND at initial app launch. On the very first call with
* a non-null pubkey (activePubkey was null), migrates any values that
* were stored under the old unscoped keys so existing settings survive
* the upgrade to per-account storage.
*/
fun reload(pubkey: String?) {
val wasNull = activePubkey == null
activePubkey = pubkey
if (wasNull && pubkey != null) migrateGlobalIfNeeded(pubkey)
}

private fun migrateGlobalIfNeeded(pubkey: String) {
val migKey = "quick_zap_migrated_v1_$pubkey"
if (prefs.getBoolean(migKey, false)) return
val edit = prefs.edit().putBoolean(migKey, true)
if (!prefs.contains("quick_zap_amount_sats_$pubkey") && prefs.contains("quick_zap_amount_sats"))
edit.putLong("quick_zap_amount_sats_$pubkey", prefs.getLong("quick_zap_amount_sats", 21L))
if (!prefs.contains("quick_zap_amount_fiat_$pubkey") && prefs.contains("quick_zap_amount_fiat"))
prefs.getString("quick_zap_amount_fiat", null)?.let { edit.putString("quick_zap_amount_fiat_$pubkey", it) }
if (!prefs.contains("quick_zap_enabled_$pubkey") && prefs.contains("quick_zap_enabled"))
edit.putBoolean("quick_zap_enabled_$pubkey", prefs.getBoolean("quick_zap_enabled", false))
if (!prefs.contains("quick_zap_message_$pubkey") && prefs.contains("quick_zap_message"))
prefs.getString("quick_zap_message", null)?.let { edit.putString("quick_zap_message_$pubkey", it) }
edit.apply()
}

companion object {
/** Shared across all InterfacePreferences instances — updated by reload(). */
@Volatile var activePubkey: String? = null
val postUndoTimerOptions = listOf(5, 10, 15, 20, 30)
const val QUICK_ZAP_MAX_SATS = 10_000L
}

/** Reset all interface preferences to defaults (called on full logout). */
fun reset() {
prefs.edit()
Expand Down
36 changes: 32 additions & 4 deletions app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ fun ActionBar(
onQuote: () -> Unit = {},
hasUserReposted: Boolean = false,
onZap: () -> Unit = {},
onZapLongPress: (() -> Unit)? = null,
hasUserZapped: Boolean = false,
onAddToList: () -> Unit = {},
isInList: Boolean = false,
Expand Down Expand Up @@ -209,12 +210,39 @@ fun ActionBar(
Spacer(Modifier.width(8.dp))
Box {
val zapClickable = !isZapInProgress
IconButton(
onClick = { if (zapEnabled) onZap() else onZapDisabledTap() },
enabled = zapClickable
// combinedClickable lets the zap glyph distinguish tap
// (open composer) from long-press (fire instant zap when
// enabled). Pin a fired-flag so the tap handler doesn't
// also fire when the long-press completes — Compose, like
// SwiftUI, fires both onClick AND onLongClick on release.
val longPressFired = remember { androidx.compose.runtime.mutableStateOf(false) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(48.dp)
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = androidx.compose.material3.ripple(bounded = false, radius = 24.dp),
enabled = zapClickable,
onClick = {
if (longPressFired.value) {
longPressFired.value = false
} else if (zapEnabled) {
onZap()
} else {
onZapDisabledTap()
}
},
onLongClick = if (zapEnabled && onZapLongPress != null) {
{
longPressFired.value = true
onZapLongPress()
}
} else null
)
) {
val zapTint = when {
!zapEnabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
!zapEnabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
hasUserZapped -> WispThemeColors.zapColor
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Expand Down
9 changes: 8 additions & 1 deletion app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ fun PostCard(
hasUserReposted: Boolean = false,
repostCount: Int = 0,
onZap: () -> Unit = {},
onZapLongPress: (() -> Unit)? = null,
onZapDisabledTap: () -> Unit = {},
zapEnabled: Boolean = true,
hasUserZapped: Boolean = false,
Expand Down Expand Up @@ -822,6 +823,9 @@ fun PostCard(
hasUserReposted = hasUserReposted,
repostCount = repostCount,
onZap = onZap,
// Self-zap short-circuit: long-press also disabled
// when zapEnabled is false. iOS does the same.
onZapLongPress = onZapLongPress,
hasUserZapped = hasUserZapped,
onAddToList = onAddToList,
isInList = isInList,
Expand All @@ -835,7 +839,10 @@ fun PostCard(
unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary,
isPrivate = isPrivate,
zapEnabled = zapEnabled,
// Self-zap disabled: render at low opacity, both tap
// and long-press become no-ops (the latter via
// `zapEnabled` short-circuit in ActionBar).
zapEnabled = zapEnabled && !isOwnEvent,
onZapDisabledTap = onZapDisabledTap,
modifier = Modifier.weight(1f)
)
Expand Down
Loading