diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index 80c11255..d1580296 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -1153,6 +1153,14 @@ fun WispNavHost( onQuotedNoteClick = { eventId -> navController.navigate("thread/$eventId") }, onReact = { event, emoji -> feedViewModel.toggleReaction(event, emoji) }, onZap = { event, amountMsats, message, isAnonymous, isPrivate -> feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } + }, + zapPrefs = feedViewModel.zapPrefs, userPubkey = feedViewModel.getUserPubkey(), isWalletConnected = feedViewModel.activeWalletProvider.hasConnection(), onWallet = { navController.navigate(Routes.WALLET) }, @@ -1271,7 +1279,10 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays + zapPrefsRepo = feedViewModel.zapPrefs, + canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays, + recipientPubkey = searchZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } SearchScreen( @@ -1310,6 +1321,15 @@ fun WispNavHost( onZap = { event -> searchZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + searchZapTarget = event + } + }, zapInProgress = searchZapInProgress, zapAnimatingIds = searchZapAnimatingIds, onToggleFollow = { pubkey -> @@ -1432,6 +1452,7 @@ fun WispNavHost( socialActionManager = feedViewModel.socialActions, isWalletConnected = feedViewModel.activeWalletProvider.hasConnection(), onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefs = feedViewModel.zapPrefs, noteActions = remember { com.wisp.app.ui.component.NoteActions( nip05Repo = feedViewModel.nip05Repo, @@ -1519,6 +1540,7 @@ fun WispNavHost( socialActionManager = feedViewModel.socialActions, isWalletConnected = feedViewModel.activeWalletProvider.hasConnection(), onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefs = feedViewModel.zapPrefs, noteActions = remember { com.wisp.app.ui.component.NoteActions( nip05Repo = feedViewModel.nip05Repo, @@ -1640,8 +1662,11 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays, - initialSatsHint = groupRoomZapInitialSats + initialSatsHint = groupRoomZapInitialSats, + recipientPubkey = groupRoomZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } val groupRoomMediaLauncher = rememberLauncherForActivityResult( @@ -1917,8 +1942,11 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, 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() @@ -1969,6 +1997,15 @@ fun WispNavHost( feedViewModel.blockUser(pubkey) }, onZap = { event -> threadZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + threadZapTarget = event + } + }, zapAnimatingIds = threadZapAnimatingIds, zapInProgressIds = threadZapInProgress, listedIds = threadListedIds, @@ -2088,7 +2125,10 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays + zapPrefsRepo = feedViewModel.zapPrefs, + canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays, + recipientPubkey = hashtagZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -2109,6 +2149,15 @@ fun WispNavHost( navController.navigate(Routes.COMPOSE) }, onZap = { event -> hashtagZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + hashtagZapTarget = event + } + }, onProfileClick = { pubkey -> navController.navigate("profile/$pubkey") }, onNoteClick = { eventId -> navController.navigate("thread/$eventId") }, onAddToList = { eventId -> addToListEventId = eventId }, @@ -2240,7 +2289,10 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays + zapPrefsRepo = feedViewModel.zapPrefs, + canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays, + recipientPubkey = setFeedZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -2261,6 +2313,15 @@ fun WispNavHost( navController.navigate(Routes.COMPOSE) }, onZap = { event -> setFeedZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + setFeedZapTarget = event + } + }, onProfileClick = { pubkey -> navController.navigate("profile/$pubkey") }, onNoteClick = { eventId -> navController.navigate("thread/$eventId") }, onAddToList = { eventId -> addToListEventId = eventId }, @@ -2405,7 +2466,10 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays + zapPrefsRepo = feedViewModel.zapPrefs, + canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays, + recipientPubkey = articleZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -2426,6 +2490,15 @@ fun WispNavHost( navController.navigate(Routes.COMPOSE) }, onZap = { event -> articleZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + articleZapTarget = event + } + }, onProfileClick = { pubkey -> navController.navigate("profile/$pubkey") }, onNoteClick = { eventId -> navController.navigate("thread/$eventId") }, onAddToList = { eventId -> addToListEventId = eventId }, @@ -2487,6 +2560,15 @@ fun WispNavHost( navController.navigate(Routes.COMPOSE) }, onZap = { event -> articleZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + articleZapTarget = event + } + }, onAddToList = { eventId -> addToListEventId = eventId }, noteActions = articleNoteActions, zapAnimatingIds = articleZapAnimatingIds, @@ -2609,10 +2691,16 @@ fun WispNavHost( eventATag = aTag) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, // 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) { @@ -3174,8 +3262,11 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, 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) } ) } @@ -3193,7 +3284,10 @@ fun WispNavHost( rumorId = target.rumorId.ifEmpty { null } ) }, - onGoToWallet = { navController.navigate(Routes.WALLET) } + onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, + recipientPubkey = notifDmZapTarget?.senderPubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -3294,6 +3388,15 @@ fun WispNavHost( navController.navigate(Routes.COMPOSE) }, onZap = { event -> notifZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + notifZapTarget = event + } + }, onFollowToggle = { pubkey -> feedViewModel.toggleFollow(pubkey) }, onBlockUser = { pubkey -> feedViewModel.blockUser(pubkey) }, onMuteThread = { rootEventId -> feedViewModel.muteThread(rootEventId) }, diff --git a/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt b/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt index 8bd40085..c02071f4 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt @@ -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()) @@ -69,6 +65,38 @@ 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. + + fun isQuickZapEnabled(): Boolean = prefs.getBoolean("quick_zap_enabled", false) + fun setQuickZapEnabled(enabled: Boolean) = prefs.edit().putBoolean("quick_zap_enabled", enabled).apply() + + fun getQuickZapAmountSats(): Long = prefs.getLong("quick_zap_amount_sats", 100L).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("quick_zap_amount_sats", clamped).apply() + } + + fun getQuickZapAmountFiat(): Double = + prefs.getString("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("quick_zap_amount_fiat", clamped.toString()).apply() + } + + fun getQuickZapMessage(): String = prefs.getString("quick_zap_message", "") ?: "" + fun setQuickZapMessage(message: String) = prefs.edit().putString("quick_zap_message", message).apply() + + companion object { + 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() diff --git a/app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt b/app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt index 4f1e2fe1..97de228e 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt @@ -13,9 +13,41 @@ data class ZapPreset( class ZapPreferences(private val context: Context, pubkeyHex: String? = null) { private var prefs: SharedPreferences = context.getSharedPreferences(prefsName(pubkeyHex), Context.MODE_PRIVATE) + .also { migrateFromGlobalIfNeeded(context, it, pubkeyHex) } + + fun reload(pubkeyHex: String?) { + prefs = context.getSharedPreferences(prefsName(pubkeyHex), Context.MODE_PRIVATE) + .also { migrateFromGlobalIfNeeded(context, it, pubkeyHex) } + } companion object { private const val KEY_ZAP_PRESETS = "zap_presets" + private const val KEY_MIGRATED_FROM_GLOBAL = "migrated_from_global_v1" + + /** + * Before the per-account fix, the in-sheet "+" button wrote + * presets to the un-scoped `zap_prefs` file. Copy them into the + * per-account file on first read so users who saved presets + * pre-fix don't see them disappear. + */ + private fun migrateFromGlobalIfNeeded( + context: Context, + perAccount: SharedPreferences, + pubkeyHex: String? + ) { + if (pubkeyHex == null) return + if (perAccount.getBoolean(KEY_MIGRATED_FROM_GLOBAL, false)) return + if (perAccount.contains(KEY_ZAP_PRESETS)) { + perAccount.edit().putBoolean(KEY_MIGRATED_FROM_GLOBAL, true).apply() + return + } + val global = context.getSharedPreferences("zap_prefs", Context.MODE_PRIVATE) + val globalJson = global.getString(KEY_ZAP_PRESETS, null) + val edit = perAccount.edit().putBoolean(KEY_MIGRATED_FROM_GLOBAL, true) + if (globalJson != null) edit.putString(KEY_ZAP_PRESETS, globalJson) + edit.apply() + } + val DEFAULT_PRESETS = listOf( ZapPreset(21), ZapPreset(100), @@ -70,7 +102,4 @@ class ZapPreferences(private val context: Context, pubkeyHex: String? = null) { return updated } - fun reload(pubkeyHex: String?) { - prefs = context.getSharedPreferences(prefsName(pubkeyHex), Context.MODE_PRIVATE) - } } diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt index e1ba35d5..0e1bc810 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt @@ -40,7 +40,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap @@ -71,6 +73,7 @@ fun ActionBar( onQuote: () -> Unit = {}, hasUserReposted: Boolean = false, onZap: () -> Unit = {}, + onZapLongPress: (() -> Unit)? = null, hasUserZapped: Boolean = false, onAddToList: () -> Unit = {}, isInList: Boolean = false, @@ -209,12 +212,45 @@ 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) } + val haptics = LocalHapticFeedback.current + 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 + // Confirms the long-press registered before + // the zap network round-trip kicks off, so + // the user can lift their finger knowing + // the instant zap is on the way. + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + 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 } diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt b/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt index 06f139e5..34103353 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt @@ -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, @@ -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, @@ -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) ) diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt b/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt index 6cb7127c..742eeff6 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt @@ -156,6 +156,7 @@ data class NoteActions( val onRepost: (NostrEvent) -> Unit = {}, val onQuote: (NostrEvent) -> Unit = {}, val onZap: (NostrEvent) -> Unit = {}, + val onZapInstant: (NostrEvent) -> Unit = {}, val onProfileClick: (String) -> Unit = {}, val onNoteClick: (String) -> Unit = {}, val onAddToList: (String) -> Unit = {}, @@ -1190,6 +1191,7 @@ fun QuotedNote( hasUserReposted = hasUserReposted, repostCount = repostCount, onZap = { effectiveActions.onZap(event) }, + onZapLongPress = { effectiveActions.onZapInstant(event) }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt index bb888fae..47913aa3 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt @@ -1,22 +1,11 @@ package com.wisp.app.ui.component -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,68 +13,83 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff -import androidx.compose.material.icons.automirrored.outlined.Message - import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation +import kotlinx.coroutines.delay +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties +import coil3.compose.AsyncImage import com.wisp.app.R +import com.wisp.app.nostr.ProfileData import com.wisp.app.repo.ExchangeRateRepository import com.wisp.app.repo.FiatCurrency import com.wisp.app.repo.FiatPreferences @@ -93,26 +97,48 @@ import com.wisp.app.repo.ZapPreferences import com.wisp.app.repo.ZapPreset import com.wisp.app.ui.theme.WispThemeColors import com.wisp.app.ui.util.AmountFormatter -import androidx.compose.runtime.collectAsState -import kotlin.math.sin -import kotlin.random.Random - -private val LightningYellow: Color - @Composable get() = WispThemeColors.zapColor - -private val LightningOrange: Color - @Composable get() = WispThemeColors.zapColor +import kotlinx.coroutines.launch -private val LightningAmber: Color - @Composable get() = WispThemeColors.zapColor.copy(alpha = 0.7f) - -@OptIn(ExperimentalLayoutApi::class) +/** + * Zap composer — iOS-faithful layout in a draggable bottom sheet. + * + * Layout, top to bottom: + * 1. Toolbar — Close (left, pill) / Presets (right, orange pill) + * 2. Recipient row — avatar + display name + lud16 + copy + * (hidden if no `profileLookup` data for the + * `recipientPubkey`) + * 3. Hero amount — editable BasicTextField styled as the big + * orange number; doubles as the amount input, + * matching iOS. + * 4. Preset strip — wrapping FlowRow of pills + Custom-with-plus chip + * 5. Message field — single-line OutlinedTextField + * 6. Privacy dropdown — Public / Anonymous / Private with helper text + * 7. Instant zaps — toggle bound to `quickZapEnabled` setting + * 8. Zap button — full-width orange action button. Over 1M sats + * disables it; over 10K routes through a + * soft-confirmation dialog. + * + * Wrapping `ModalBottomSheet` provides drag-handle dismiss, scrim-tap + * dismiss, and a partial-height presentation so the sheet doesn't take + * over the whole viewport — fixes the "impossible to dismiss" complaint + * from the previous Dialog-based version. + */ +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable fun ZapDialog( isWalletConnected: Boolean, onDismiss: () -> Unit, onZap: (amountMsats: Long, message: String, isAnonymous: Boolean, isPrivate: Boolean) -> Unit, onGoToWallet: () -> Unit, + /** + * Per-account preset store. Must be the same instance the + * `AppSettingsRepository` registered its `onSyncedFieldChanged` + * callback on, otherwise preset writes from the dialog land in a + * different SharedPreferences file than NIP-78 reads from on + * publish/restore — the symptom is presets appearing not to sync + * between Android and iOS. + */ + zapPrefsRepo: ZapPreferences, canPrivateZap: Boolean = false, /** * Lock the zap to DIP-03 private mode (private + anon toggles hidden, isPrivate held true). @@ -121,7 +147,11 @@ fun ZapDialog( */ forcePrivate: Boolean = false, /** When opening from a quick preset (e.g. chat actions sheet), pre-select that amount in sats. */ - initialSatsHint: Int? = null + initialSatsHint: Int? = null, + /** Recipient pubkey for the optional recipient header row. */ + recipientPubkey: String? = null, + /** Profile lookup for the recipient header row. Returns null if unknown. */ + profileLookup: (String) -> ProfileData? = { null } ) { if (!isWalletConnected) { AlertDialog( @@ -132,9 +162,7 @@ fun ZapDialog( TextButton(onClick = { onDismiss() onGoToWallet() - }) { - Text(stringResource(R.string.btn_go_to_wallet)) - } + }) { Text(stringResource(R.string.btn_go_to_wallet)) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) } @@ -144,864 +172,947 @@ fun ZapDialog( } val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val scope = rememberCoroutineScope() + val accent = WispThemeColors.zapColor + val fiatPrefs = remember { FiatPreferences.get(context) } val fiatMode by fiatPrefs.fiatMode.collectAsState() val fiatCurrency by fiatPrefs.currency.collectAsState() - var presets by remember { mutableStateOf(ZapPreferences(context).getPresets().sortedBy { it.amountSats }) } + val interfacePrefs = remember { com.wisp.app.repo.InterfacePreferences(context) } + var presets by remember { mutableStateOf(zapPrefsRepo.getPresets().sortedBy { it.amountSats }) } var selectedPreset by remember { mutableStateOf(presets.firstOrNull()) } var isCustom by remember { mutableStateOf(false) } - var customAmount by remember { mutableStateOf("") } + // TextFieldValue so we can pre-select the seeded amount on focus — + // the first keystroke then replaces the whole seed, matching the + // iOS "first-keystroke-replaces-seed" UX. + var customAmountTfv by remember { mutableStateOf(TextFieldValue("")) } + val customAmount = customAmountTfv.text var message by remember { mutableStateOf("") } var isAnonymous by remember { mutableStateOf(false) } var isPrivate by remember(forcePrivate) { mutableStateOf(forcePrivate) } - var editMode by remember { mutableStateOf(false) } + var instantZapsEnabled by remember { mutableStateOf(interfacePrefs.isQuickZapEnabled()) } + var showLargeAmountConfirm by remember { mutableStateOf(false) } + var showEditPresetsSheet by remember { mutableStateOf(false) } + var privacyMenuExpanded by remember { mutableStateOf(false) } + val amountFocusRequester = remember { FocusRequester() } + + val recipientProfile = recipientPubkey?.let { profileLookup(it) } + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + fun closeSheet() { + scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() } + } LaunchedEffect(initialSatsHint) { val hint = initialSatsHint ?: return@LaunchedEffect val h = hint.toLong().coerceAtLeast(1L) - val fromPrefs = ZapPreferences(context).getPresets().sortedBy { it.amountSats } - val match = fromPrefs.find { it.amountSats == h } + val match = presets.find { it.amountSats == h } if (match != null) { selectedPreset = match isCustom = false message = match.message } else { isCustom = true - customAmount = h.toString() + seedCustomAmount(h.toString()) { customAmountTfv = it } message = "" } } - val effectiveAmount = if (isCustom) { + // Seed amount from the configured instant-zap amount on first open. + // Auto-focus the field after the sheet's mount + transition has + // settled (~450ms) — matches the iOS deferral. The seed is selected + // so the first keystroke replaces it entirely. + LaunchedEffect(Unit) { + if (initialSatsHint == null) { + val seedSats = if (fiatMode) { + val major = interfacePrefs.getQuickZapAmountFiat() + (major * 100.0).toLong().coerceAtLeast(0L) + } else { + interfacePrefs.getQuickZapAmountSats() + } + if (seedSats > 0) { + isCustom = true + seedCustomAmount(seedSats.toString()) { customAmountTfv = it } + message = interfacePrefs.getQuickZapMessage() + } + } + delay(450) + runCatching { amountFocusRequester.requestFocus() } + } + + val effectiveAmount: Long = if (isCustom) { if (fiatMode) { - // Register-style: customAmount is a digit-only string that's - // interpreted as cents (last two digits). "21" → $0.21, - // "2100" → $21.00. val cents = customAmount.toLongOrNull() ?: 0L - if (cents > 0) { - ExchangeRateRepository.fiatToSats(cents.toDouble() / 100.0, fiatCurrency) ?: 0L - } else 0L + if (cents > 0) ExchangeRateRepository.fiatToSats(cents.toDouble() / 100.0, fiatCurrency) ?: 0L else 0L } else { customAmount.toLongOrNull() ?: 0L } } else { selectedPreset?.amountSats ?: 0L } - val effectiveMessage = if (isCustom) message else (selectedPreset?.message ?: "") + val overHardCap = effectiveAmount > ZAP_HARD_CAP_SATS + val canSavePreset = isCustom && effectiveAmount > 0 && + presets.none { it.amountSats == effectiveAmount } && + presets.size < 8 - Dialog( + ModalBottomSheet( onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + // Drag handle replaces the iOS "swipe down" affordance. ) { - Surface( + // Two-row stack: scrollable content on top, pinned Zap button + // at the bottom. `fillMaxSize()` locks the sheet to the full + // available height from open — without it the sheet sizes to + // content, and the keyboard rising 450ms later forces a second + // layout pass that visibly jumps the sheet taller. `imePadding` + // then lifts the stack above the keyboard within the fixed + // sheet bounds so the Zap button stays visible. + Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - shape = RoundedCornerShape(28.dp), - color = WispThemeColors.backgroundColor, - tonalElevation = 8.dp + .fillMaxSize() + .imePadding() ) { - Box { - // Background lightning effect - LightningBackground( - modifier = Modifier - .matchParentSize() - .clip(RoundedCornerShape(28.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + // ── 1. Toolbar ────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + PillButton(text = stringResource(R.string.btn_close), onClick = { closeSheet() }) + PillButton( + text = "Presets", + onClick = { showEditPresetsSheet = true }, + contentColor = accent, + borderColor = accent.copy(alpha = 0.45f) ) + } - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + // ── 2. Recipient row (optional) ───────────────────────── + if (recipientProfile != null) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - // Header with animated bolt — switches to a coin-stack - // glyph in fiat mode so the icon reads as money rather - // than zap. Mirrors the iOS dynamic `zapImage`. - AnimatedBoltHeader(fiatMode = fiatMode) - - Spacer(Modifier.height(8.dp)) - - Text( - text = stringResource( - if (fiatMode) R.string.zap_send_money else R.string.zap_send - ), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + AsyncImage( + model = recipientProfile.picture, + contentDescription = null, + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) ) - - Spacer(Modifier.height(4.dp)) - - // Amount display — tap to edit directly. In fiat mode the - // input is interpreted register-style: digits fill from the - // cents place ("21" → $0.21, "2100" → $21.00). The field - // is bound to the raw digit string and a - // `VisualTransformation` renders it as the formatted - // dollar string with the cursor pinned to the end — that - // way backspace removes the rightmost digit and Compose - // can map cursor positions cleanly (binding the field - // directly to the formatted string and rewriting it on - // every keystroke broke backspace because Compose - // couldn't map the cursor between the old and new - // formatted strings). - val currency = if (fiatMode) { - ExchangeRateRepository.currencyFor(fiatCurrency) - } else null - val centsTransformation = remember(currency) { - currency?.let { CentsVisualTransformation(it) } - } - if (isCustom) { - BasicTextField( - value = customAmount, - onValueChange = { new -> - customAmount = if (fiatMode) sanitizeFiatInput(new) else new.filter { c -> c.isDigit() } - }, - visualTransformation = if (fiatMode && centsTransformation != null) { - centsTransformation - } else VisualTransformation.None, - textStyle = MaterialTheme.typography.displaySmall.copy( - fontWeight = FontWeight.Bold, - color = LightningOrange, - textAlign = TextAlign.Center - ), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - cursorBrush = SolidColor(LightningOrange), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - decorationBox = { inner -> - Box(contentAlignment = Alignment.Center) { - if (customAmount.isEmpty()) { - Text( - text = if (fiatMode) "${currency?.symbol ?: ""}0.00" else "0", - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.Bold, - color = LightningOrange.copy(alpha = 0.3f) - ) - } - inner() - } - } - ) - if (!fiatMode) { - Text( - text = stringResource(R.string.zap_sats), - style = MaterialTheme.typography.labelLarge, - color = LightningOrange.copy(alpha = 0.7f) - ) - } - } else if (effectiveAmount > 0) { + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { Text( - text = AmountFormatter.formatShort(effectiveAmount, context), - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.Bold, - color = LightningOrange, - modifier = Modifier - .clickable { - // Seed the field with the equivalent cents - // digit string so the user can refine the - // selected preset rather than starting from - // empty in fiat mode. - customAmount = if (fiatMode) { - seedRegisterCents(effectiveAmount, fiatCurrency) - } else { - effectiveAmount.toString() - } - isCustom = true - } - .padding(vertical = 4.dp) + recipientProfile.displayString, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1 ) - if (!fiatMode) { + if (!recipientProfile.lud16.isNullOrBlank()) { Text( - text = stringResource(R.string.zap_sats), - style = MaterialTheme.typography.labelLarge, - color = LightningOrange.copy(alpha = 0.7f) + recipientProfile.lud16, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 ) } } - - Spacer(Modifier.height(16.dp)) - - // Preset chips header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.zap_quick_amounts), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row { - if (editMode) { - TextButton(onClick = { editMode = false }) { - Text(stringResource(R.string.btn_done), color = LightningOrange, fontSize = 12.sp) - } - } else { - IconButton( - onClick = { editMode = true }, - modifier = Modifier.size(32.dp) - ) { - Text( - stringResource(R.string.btn_edit), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - - Spacer(Modifier.height(8.dp)) - - // Preset amount chips - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - presets.forEach { preset -> - ZapPresetChip( - preset = preset, - isSelected = !isCustom && selectedPreset == preset, - editMode = editMode, - onClick = { - if (!editMode) { - selectedPreset = preset - isCustom = false - message = preset.message - } - }, - onRemove = { - presets = ZapPreferences(context).removePreset(preset) - if (selectedPreset == preset) { - selectedPreset = presets.firstOrNull() - } - } + if (!recipientProfile.lud16.isNullOrBlank()) { + IconButton(onClick = { + clipboard.setText(AnnotatedString(recipientProfile.lud16!!)) + }) { + Icon( + Icons.Filled.ContentCopy, + contentDescription = "Copy lightning address", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) ) } - - // Custom amount chip - ZapChipButton( - label = stringResource(R.string.btn_custom), - isSelected = isCustom, - onClick = { isCustom = true } - ) } + } + } - Spacer(Modifier.height(16.dp)) - - // Custom amount input - AnimatedVisibility( - visible = isCustom, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column { - OutlinedTextField( - value = customAmount, - onValueChange = { new -> - customAmount = if (fiatMode) sanitizeFiatInput(new) else new.filter { c -> c.isDigit() } - }, - visualTransformation = if (fiatMode && centsTransformation != null) { - centsTransformation - } else VisualTransformation.None, - label = { + // ── 3. Hero amount (editable) ─────────────────────────── + // The hero IS the input — matches iOS. Typed digits update + // the value directly; preset taps seed it; visual + // transformation inserts thousands separators in bitcoin + // mode so the displayed number stays readable while the + // underlying state stays as raw digits. + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val heroStyle = TextStyle( + color = accent, + fontSize = 56.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + // Hide the text-selection background so the seeded + // select-all (which powers first-keystroke-replaces-seed) + // doesn't paint an ugly box behind the hero number. iOS + // achieves the same UX with no visible selection rect. + val invisibleSelection = remember(accent) { + TextSelectionColors( + handleColor = accent, + backgroundColor = Color.Transparent + ) + } + CompositionLocalProvider(LocalTextSelectionColors provides invisibleSelection) { + BasicTextField( + value = customAmountTfv, + onValueChange = { newTfv -> + val filtered = newTfv.text.filter { it.isDigit() } + customAmountTfv = newTfv.copy(text = filtered) + if (filtered.isNotEmpty()) isCustom = true + }, + textStyle = heroStyle, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + cursorBrush = SolidColor(accent), + visualTransformation = if (fiatMode) VisualTransformation.None + else ThousandsSeparatorTransformation, + modifier = Modifier + .fillMaxWidth() + .focusRequester(amountFocusRequester), + decorationBox = { inner -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + if (customAmountTfv.text.isEmpty()) { Text( - if (fiatMode) { - stringResource(R.string.placeholder_amount_currency, currency?.symbol ?: "") - } else { - stringResource(R.string.placeholder_amount_sats) - } + "0", + style = heroStyle.copy(color = accent.copy(alpha = 0.35f)) ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LightningOrange, - focusedLabelColor = LightningOrange, - cursorColor = LightningOrange - ) - ) - // Save-as-preset only makes sense for whole-sat - // amounts; in fiat mode we always derive sats - // through the exchange rate, which can drift. - val saveAmount = if (fiatMode) 0L else (customAmount.toLongOrNull() ?: 0L) - if (saveAmount > 0) { - Spacer(Modifier.height(6.dp)) - TextButton( - onClick = { - val preset = ZapPreset(saveAmount, message.trim()) - presets = ZapPreferences(context).addPreset(preset) - selectedPreset = presets.firstOrNull { it.amountSats == saveAmount } - isCustom = false - customAmount = "" - } - ) { - Icon( - Icons.Filled.Add, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.btn_save)) } + inner() } - Spacer(Modifier.height(8.dp)) } - } - - // Message input - OutlinedTextField( - value = message, - onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(message, new)) message = new }, - label = { Text(stringResource(R.string.placeholder_message_optional)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LightningOrange, - focusedLabelColor = LightningOrange, - cursorColor = LightningOrange - ) ) + } + Text( + if (fiatMode) ExchangeRateRepository.currencyFor(fiatCurrency).code else "sats", + color = accent.copy(alpha = 0.75f), + style = MaterialTheme.typography.titleSmall + ) + } - // Clear message when switching away from a preset with a saved message - // (message is pre-filled when selecting a preset with a message) + // ── 4. Preset strip ───────────────────────────────────── + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + presets.forEach { preset -> + val selected = !isCustom && selectedPreset?.amountSats == preset.amountSats + PresetPill( + label = AmountFormatter.formatShort(preset.amountSats, context), + selected = selected, + accent = accent, + onClick = { + selectedPreset = preset + isCustom = false + // Seed the hero with the preset's value so + // the big number reflects the selection. + seedCustomAmount(preset.amountSats.toString()) { customAmountTfv = it } + // Auto-fill the preset's optional default + // message only when the message field is + // currently empty (don't clobber typing). + if (preset.message.isNotEmpty() && message.isBlank()) { + message = preset.message + } + } + ) + } + CustomPlusPill( + label = if (isCustom && effectiveAmount > 0) + AmountFormatter.formatShort(effectiveAmount, context) + else "Custom", + selected = isCustom, + accent = accent, + showPlus = canSavePreset, + onClick = { + isCustom = true + if (effectiveAmount == 0L) { + customAmountTfv = TextFieldValue("") + } else { + // Re-seed and select-all so first keystroke replaces. + seedCustomAmount(customAmount) { customAmountTfv = it } + } + runCatching { amountFocusRequester.requestFocus() } + }, + onPlusClick = { + // Save the current custom amount as a new preset + if (canSavePreset) { + presets = zapPrefsRepo.addPreset( + ZapPreset(effectiveAmount, message.trim()) + ).sortedBy { it.amountSats } + } + } + ) + } - Spacer(Modifier.height(16.dp)) + // ── 5. Message ────────────────────────────────────────── + OutlinedTextField( + value = message, + onValueChange = { message = it }, + placeholder = { Text("Message (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) - if (forcePrivate) { - // Parent is a private reply — zap is locked to DIP-03 mode and the - // anon/private toggles are hidden. A small label keeps the user - // oriented; the lock icon mirrors the orange lock used elsewhere. + // ── 6. Privacy dropdown ───────────────────────────────── + if (!forcePrivate) { + Box(modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { privacyMenuExpanded = true }, + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { + val (icon, label, helper) = when { + isPrivate -> Triple(Icons.Filled.Lock, "Private", "Recipient only — sent via DM-relay route.") + isAnonymous -> Triple(Icons.Outlined.VisibilityOff, "Anonymous", "Recipient won't see your identity.") + else -> Triple(Icons.Outlined.Visibility, "Public", null) + } Icon( - imageVector = Icons.Outlined.VisibilityOff, + icon, contentDescription = null, - tint = LightningOrange, - modifier = Modifier.size(16.dp) + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) ) - Spacer(Modifier.width(6.dp)) - Text( - text = stringResource(R.string.zap_private_locked_for_private_reply), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface) + if (helper != null) { + Text( + helper, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Icon( + Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) ) } - } else { - // Anonymous toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + } + DropdownMenu( + expanded = privacyMenuExpanded, + onDismissRequest = { privacyMenuExpanded = false } ) { - Text( - text = stringResource(R.string.btn_anonymous), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + DropdownMenuItem( + text = { Text("Public") }, + leadingIcon = { Icon(Icons.Outlined.Visibility, null) }, + onClick = { + isPrivate = false + isAnonymous = false + privacyMenuExpanded = false + } ) - Switch( - checked = isAnonymous, - onCheckedChange = { - isAnonymous = it - if (it) isPrivate = false - }, - colors = SwitchDefaults.colors( - checkedThumbColor = LightningOrange, - checkedTrackColor = LightningOrange.copy(alpha = 0.5f), - uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, - uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant, - uncheckedBorderColor = MaterialTheme.colorScheme.outline - ) + DropdownMenuItem( + text = { Text("Anonymous") }, + leadingIcon = { Icon(Icons.Outlined.VisibilityOff, null) }, + onClick = { + isAnonymous = true + isPrivate = false + privacyMenuExpanded = false + } ) - } - - // Private toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - text = stringResource(R.string.btn_private), - style = MaterialTheme.typography.bodyMedium, - color = if (canPrivateZap) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + if (canPrivateZap) { + DropdownMenuItem( + text = { Text("Private") }, + leadingIcon = { Icon(Icons.Filled.Lock, null) }, + onClick = { + isPrivate = true + isAnonymous = false + privacyMenuExpanded = false + } ) - if (!canPrivateZap) { - Text( - text = stringResource(R.string.zap_both_parties_need_dm_relays), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - } } - Switch( - checked = isPrivate, - onCheckedChange = { - isPrivate = it - if (it) isAnonymous = false - }, - enabled = canPrivateZap, - colors = SwitchDefaults.colors( - checkedThumbColor = LightningOrange, - checkedTrackColor = LightningOrange.copy(alpha = 0.5f), - uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, - uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant, - uncheckedBorderColor = MaterialTheme.colorScheme.outline - ) - ) } - } // end !forcePrivate + } + } - Spacer(Modifier.height(16.dp)) + // ── 7. Instant zaps toggle ────────────────────────────── + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_bolt), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(10.dp)) + Text( + if (fiatMode) "Instant payments" else "Instant zaps", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface + ) + Switch( + checked = instantZapsEnabled, + onCheckedChange = { + instantZapsEnabled = it + interfacePrefs.setQuickZapEnabled(it) + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = accent, + uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, + uncheckedTrackColor = MaterialTheme.colorScheme.surface + ) + ) + } + } - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - TextButton( - onClick = onDismiss, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(R.string.btn_cancel)) - } + } // end scrollable content Column - Button( - onClick = { - onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) - }, - enabled = effectiveAmount > 0, - modifier = Modifier.weight(2f), - colors = ButtonDefaults.buttonColors( - containerColor = LightningOrange, - contentColor = Color.White - ), - shape = RoundedCornerShape(16.dp) - ) { - Icon( - painter = painterResource( - if (fiatMode) R.drawable.ic_coin_stack else R.drawable.ic_bolt - ), - contentDescription = null, - modifier = Modifier.size(15.dp) - ) - Spacer(Modifier.width(6.dp)) - Text( - if (fiatMode) { - stringResource( - R.string.zap_x_amount, - AmountFormatter.formatShort(effectiveAmount, context) - ) - } else { - stringResource(R.string.zap_x_sats, effectiveAmount) - }, - fontWeight = FontWeight.Bold - ) + // ── 8. Zap button — pinned to the bottom of the sheet ── + // Lives outside the scrollable region above so it stays on + // screen even when the keyboard is up. The outer Column's + // imePadding() ensures it floats above the IME. + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(top = 12.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (overHardCap) { + Text( + "Max ${"%,d".format(ZAP_HARD_CAP_SATS)} sats per zap", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + Button( + onClick = { + if (effectiveAmount > ZAP_SOFT_CONFIRM_SATS) { + showLargeAmountConfirm = true + } else { + onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) } - } + }, + enabled = effectiveAmount > 0 && !overHardCap, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = accent, + contentColor = Color.White, + disabledContainerColor = accent.copy(alpha = 0.35f) + ) + ) { + Icon( + painter = painterResource( + if (fiatMode) R.drawable.ic_coin_stack else R.drawable.ic_bolt + ), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + if (fiatMode) "Zap ${AmountFormatter.formatShort(effectiveAmount, context)}" + else "Zap ${"%,d".format(effectiveAmount)} sats", + fontWeight = FontWeight.Bold, + fontSize = 17.sp + ) } } } } + // 10K-sat soft-confirmation dialog. Large zaps surface a "double-check + // before sending" prompt so a stray preset tap doesn't drain a wallet. + if (showLargeAmountConfirm) { + AlertDialog( + onDismissRequest = { showLargeAmountConfirm = false }, + title = { Text("Zap %,d sats?".format(effectiveAmount)) }, + text = { Text("This is a large amount, double-check before sending.") }, + confirmButton = { + Button( + onClick = { + showLargeAmountConfirm = false + onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) + }, + colors = ButtonDefaults.buttonColors(containerColor = accent) + ) { Text("Send", fontWeight = FontWeight.Bold) } + }, + dismissButton = { + TextButton(onClick = { showLargeAmountConfirm = false }) { + Text(stringResource(R.string.btn_cancel)) + } + } + ) + } + + if (showEditPresetsSheet) { + EditPresetsSheet( + initial = presets, + accent = accent, + onDismiss = { showEditPresetsSheet = false }, + onSave = { newList -> + zapPrefsRepo.setPresets(newList) + presets = newList.sortedBy { it.amountSats } + showEditPresetsSheet = false + } + ) + } } -/** - * Register-style sanitiser: digits only. The fiat-mode amount field is - * interpreted as integer cents (last two digits = cents place), so - * decimal points and other punctuation in user input are stripped — the - * decimal in the displayed string ("$0.21") is presentation-only. - */ -private fun sanitizeFiatInput(text: String): String = - text.filter { it.isDigit() } +// ─── Helpers ───────────────────────────────────────────────────────────── -/** - * Format a digit-only string as `$X.XX` register-style — last two digits - * are cents, everything before them is whole dollars. "21" → "$0.21", - * "2100" → "$21.00". Used for the empty-field placeholder; the live - * field display goes through [CentsVisualTransformation] so Compose's - * cursor mapping works correctly. - */ -private fun formatRegisterCents(digits: String, currency: FiatCurrency): String { - val cents = digits.toLongOrNull() ?: 0L - val dollars = cents.toDouble() / 100.0 - val formatter = java.text.DecimalFormat("#,##0.00") - return "${currency.symbol}${formatter.format(dollars)}" -} +private const val ZAP_SOFT_CONFIRM_SATS = 10_000L +private const val ZAP_HARD_CAP_SATS = 1_000_000L /** - * Renders a digit-only field value as the formatted register-style - * dollar string and pegs the cursor to the end. The previous approach - * — binding the TextField directly to the formatted string and - * re-formatting in `onValueChange` — broke backspace on Android: when - * Compose's internal text changed from "$0.2" (after the user removed - * the last char) to our re-formatted "$0.02", Compose couldn't map the - * old cursor position into the new string and effectively snapped it - * to the start, so subsequent backspaces just moved the cursor instead - * of deleting. With a [VisualTransformation] the field is bound to the - * raw digits, the cursor lives in raw-string coordinates, and the - * `OffsetMapping` always points to the end of the formatted view. + * Insert thousands separators in the hero number while typing without + * mutating the underlying raw-digit state. Maps cursor positions so a + * tap or arrow-key lands on the digit the user expects. */ -private class CentsVisualTransformation( - private val currency: FiatCurrency -) : VisualTransformation { - override fun filter(text: AnnotatedString): TransformedText { - val formatted = formatRegisterCents(text.text, currency) - val rawLength = text.text.length - val transformedLength = formatted.length - val mapping = object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int = transformedLength - override fun transformedToOriginal(offset: Int): Int = rawLength +private val ThousandsSeparatorTransformation: VisualTransformation = VisualTransformation { text -> + val raw = text.text + if (raw.isEmpty()) return@VisualTransformation TransformedText(text, OffsetMapping.Identity) + val formatted = try { "%,d".format(raw.toLong()) } catch (_: NumberFormatException) { raw } + val mapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + val clamped = offset.coerceIn(0, raw.length) + val digitsFromRight = raw.length - clamped + val totalCommas = (raw.length - 1) / 3 + val commasFromRight = ((digitsFromRight - 1).coerceAtLeast(0)) / 3 + val commasBefore = totalCommas - commasFromRight + return (clamped + commasBefore).coerceIn(0, formatted.length) + } + override fun transformedToOriginal(offset: Int): Int { + val clamped = offset.coerceIn(0, formatted.length) + var rawOffset = 0 + for (i in 0 until clamped) { + if (formatted[i] != ',') rawOffset++ + } + return rawOffset.coerceIn(0, raw.length) } - return TransformedText(AnnotatedString(formatted), mapping) } + TransformedText(AnnotatedString(formatted), mapping) } /** - * Cents-as-digits seed for the custom-amount field. Converts the current - * sats amount through the cached rate, rounds to the nearest cent, and - * returns the digit string (e.g. 1234 cents → "1234"). Empty for zero - * or when no rate is cached. + * Seed the custom-amount field with the given text AND select the + * whole range — so the next keystroke replaces the seed entirely. + * Lets the user open the sheet, see the configured instant-zap + * amount, then type a new value over it without backspacing first. */ -private fun seedRegisterCents(amountSats: Long, currencyCode: String): String { - val dollars = ExchangeRateRepository.satsToFiat(amountSats, currencyCode) ?: return "" - val cents = (dollars * 100.0).toLong() - return if (cents > 0) cents.toString() else "" +private fun seedCustomAmount(text: String, set: (TextFieldValue) -> Unit) { + set(TextFieldValue(text = text, selection = TextRange(0, text.length))) } +/** + * Pill-shaped text button — used for the toolbar's Close + Presets + * actions. Border-only by default, fillable via `borderColor`. + */ @Composable -private fun AnimatedBoltHeader(fiatMode: Boolean = false) { - val infiniteTransition = rememberInfiniteTransition(label = "bolt") - - val glowAlpha by infiniteTransition.animateFloat( - initialValue = 0.3f, - targetValue = 0.8f, - animationSpec = infiniteRepeatable( - animation = tween(1200, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "glow" - ) - - val boltScale by animateFloatAsState( - targetValue = 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - label = "boltScale" - ) - - val zapColor = WispThemeColors.zapColor - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(64.dp) +private fun PillButton( + text: String, + onClick: () -> Unit, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + borderColor: Color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) +) { + Surface( + modifier = Modifier.clickable(onClick = onClick), + shape = CircleShape, + color = Color.Transparent, + border = BorderStroke(1.dp, borderColor) ) { - // Glow circle behind bolt - Box( - modifier = Modifier - .size(56.dp) - .alpha(glowAlpha) - .background( - brush = Brush.radialGradient( - colors = listOf( - zapColor.copy(alpha = 0.4f), - zapColor.copy(alpha = 0.1f), - Color.Transparent - ) - ), - shape = CircleShape - ) - ) - - // Bolt / coin-stack icon depending on mode - Icon( - painter = painterResource( - if (fiatMode) R.drawable.ic_coin_stack else R.drawable.ic_bolt - ), - contentDescription = null, - tint = zapColor, - modifier = Modifier - .size(30.dp) - .scale(boltScale) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = contentColor, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp) ) } } +/** Single preset chip in the FlowRow. Selected = filled accent + white. */ @Composable -private fun LightningBackground(modifier: Modifier = Modifier) { - val infiniteTransition = rememberInfiniteTransition(label = "lightning_bg") - - val phase by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(4000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "phase" - ) - - val zapColor = WispThemeColors.zapColor - - // Stable random values for bolt positions - val boltData = remember { - List(5) { i -> - Triple( - Random(i * 42).nextFloat(), // x position (0..1) - Random(i * 42 + 1).nextFloat(), // y position (0..1) - Random(i * 42 + 2).nextFloat() * 0.3f + 0.1f // size scale - ) - } - } - - Canvas(modifier = modifier) { - val w = size.width - val h = size.height - - // Subtle gradient overlay at the top - drawRect( - brush = Brush.verticalGradient( - colors = listOf( - zapColor.copy(alpha = 0.03f), - Color.Transparent - ), - startY = 0f, - endY = h * 0.4f - ) +private fun PresetPill( + label: String, + selected: Boolean, + accent: Color, + onClick: () -> Unit +) { + Surface( + modifier = Modifier.clickable(onClick = onClick), + shape = CircleShape, + color = if (selected) accent else MaterialTheme.colorScheme.surfaceVariant + ) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp) ) - - // Animated mini lightning bolts scattered in background - boltData.forEach { (xFrac, yFrac, scale) -> - val animatedAlpha = (sin((phase + xFrac) * Math.PI * 2).toFloat() * 0.5f + 0.5f) * 0.06f - val cx = xFrac * w - val cy = yFrac * h - val boltSize = 20f * scale - - drawMiniBolt( - center = Offset(cx, cy), - size = boltSize, - color = zapColor.copy(alpha = animatedAlpha) - ) - } } } -private fun DrawScope.drawMiniBolt(center: Offset, size: Float, color: Color) { - val path = Path().apply { - moveTo(center.x, center.y - size) - lineTo(center.x - size * 0.4f, center.y + size * 0.1f) - lineTo(center.x + size * 0.1f, center.y + size * 0.1f) - lineTo(center.x - size * 0.1f, center.y + size) - lineTo(center.x + size * 0.4f, center.y - size * 0.1f) - lineTo(center.x - size * 0.1f, center.y - size * 0.1f) - close() - } - drawPath(path, color, style = Fill) -} - +/** Custom-amount chip. When selected AND not yet in the preset list, + * the trailing + badge becomes tappable to save the value as a preset. */ @Composable -private fun ZapPresetChip( - preset: ZapPreset, - isSelected: Boolean, - editMode: Boolean, +private fun CustomPlusPill( + label: String, + selected: Boolean, + accent: Color, + showPlus: Boolean, onClick: () -> Unit, - onRemove: () -> Unit + onPlusClick: () -> Unit ) { - val scale by animateFloatAsState( - targetValue = if (isSelected) 1.05f else 1f, - animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), - label = "chipScale" - ) - - Box { - Surface( - modifier = Modifier - .scale(scale) - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), - color = if (isSelected) LightningOrange else MaterialTheme.colorScheme.surfaceVariant, - border = if (isSelected) { - androidx.compose.foundation.BorderStroke( - 1.5.dp, - Brush.linearGradient(listOf(LightningYellow, LightningOrange)) - ) - } else { - null - }, - shadowElevation = if (isSelected) 4.dp else 0.dp + Surface( + modifier = Modifier.clickable(onClick = onClick), + shape = CircleShape, + color = if (selected) accent else MaterialTheme.colorScheme.surfaceVariant + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 18.dp, end = if (showPlus) 4.dp else 18.dp, top = 4.dp, bottom = 4.dp) ) { - Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (isSelected) { - Icon( - painter = painterResource(R.drawable.ic_bolt), - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = Color.White - ) - Spacer(Modifier.width(3.dp)) - } - val chipContext = LocalContext.current - Text( - text = AmountFormatter.formatShort(preset.amountSats, chipContext), - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant - ) - if (preset.message.isNotEmpty()) { - Spacer(Modifier.width(4.dp)) + Text( + label, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) Color.White else MaterialTheme.colorScheme.onSurface + ) + if (showPlus) { + Spacer(Modifier.width(6.dp)) + Box( + modifier = Modifier + .size(22.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.18f)) + .clickable(onClick = onPlusClick), + contentAlignment = Alignment.Center + ) { Icon( - Icons.AutoMirrored.Outlined.Message, - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = if (isSelected) Color.White.copy(alpha = 0.7f) - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + Icons.Filled.Add, + contentDescription = "Save as preset", + tint = Color.White, + modifier = Modifier.size(14.dp) ) } } } - - // Remove badge in edit mode - if (editMode) { - Surface( - modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = 4.dp, y = (-4).dp) - .size(20.dp) - .clip(CircleShape) - .clickable(onClick = onRemove), - shape = CircleShape, - color = MaterialTheme.colorScheme.error - ) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource(R.string.btn_remove), - modifier = Modifier - .padding(2.dp) - .size(16.dp), - tint = Color.White - ) - } - } } } -@Composable -private fun ZapChipButton( - label: String, - isSelected: Boolean, - onClick: () -> Unit -) { - Surface( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), - color = if (isSelected) LightningOrange else MaterialTheme.colorScheme.surfaceVariant, - border = if (isSelected) { - androidx.compose.foundation.BorderStroke( - 1.5.dp, - Brush.linearGradient(listOf(LightningYellow, LightningOrange)) - ) - } else { - null +/** + * Translate raw Lightning/SDK error strings into plain user-facing + * copy. Mirrors iOS `ZapAnimationStore.friendlyMessage(for:)`. + * Fallback: extract the substring between the first `("` and `")` + * (Swift enum description wrapper) if present; otherwise pass through + * the raw error. + */ +internal fun friendlyZapErrorMessage(raw: String?): String { + val msg = raw?.trim().orEmpty() + if (msg.isEmpty()) return "Zap failed." + val lower = msg.lowercase() + return when { + "insufficient funds" in lower || "insufficient balance" in lower -> + "Not enough sats in your wallet." + "no route" in lower || "route not found" in lower || "unreachable" in lower -> + "Couldn't find a payment route to the recipient. Try again later." + "expired" in lower || "invoice has expired" in lower -> + "The lightning invoice expired before it could be paid. Try again." + "timeout" in lower || "timed out" in lower -> + "The payment timed out. Check your connection and try again." + "no lud16" in lower || "no lightning address" in lower -> + "This account doesn't have a lightning address." + "lnurl" in lower && "400" in lower -> + "The recipient's lightning provider rejected this zap. Try a different amount." + "amount too small" in lower || "below minimum" in lower -> + "Amount is below the recipient's minimum. Try a larger zap." + "amount too large" in lower || "above maximum" in lower -> + "Amount is above the recipient's maximum. Try a smaller zap." + else -> { + val start = msg.indexOf("(\"") + val end = msg.indexOf("\")", startIndex = (start + 2).coerceAtLeast(0)) + if (start >= 0 && end > start + 2) msg.substring(start + 2, end) else msg } - ) { - Text( - text = label, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant - ) } } +/** + * iOS-equivalent "Edit Presets" sheet — full list editor reachable from + * the composer's "Presets" pill. Mirrors the iOS layout: each row has + * inline editable amount + message text fields, a leading minus icon to + * remove the row, and a final "+ Add preset" row in accent color. Done + * persists the list via `zapPrefsRepo.setPresets`, which kicks the + * NIP-78 debounced publish so the change propagates to the user's other + * devices. + * + * The Add row is disabled while a blank row already exists so the + * caller can't pile up empty entries (matches iOS behavior). + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SaveZapPresetDialog( - onSave: (ZapPreset) -> Unit, - onDismiss: () -> Unit +private fun EditPresetsSheet( + initial: List, + accent: Color, + onDismiss: () -> Unit, + onSave: (List) -> Unit ) { - var amount by remember { mutableStateOf("") } - var presetMessage by remember { mutableStateOf("") } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + // Working copy — only committed back via `onSave` when Done is + // pressed, so dismissing via drag-down / scrim discards in-progress + // edits (matches the iOS sheet's Cancel-on-dismiss semantics). + var rows by remember { + mutableStateOf(initial.map { EditableRow(it.amountSats.toString(), it.message) }) + } + fun closeSheet(commit: Boolean) { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (commit) { + val parsed = rows.mapNotNull { r -> + val sats = r.amount.toLongOrNull() ?: return@mapNotNull null + if (sats <= 0) null else ZapPreset(sats, r.message.trim()) + } + onSave(parsed) + } else { + onDismiss() + } + } + } + val hasBlankRow = rows.any { it.amount.isBlank() || (it.amount.toLongOrNull() ?: 0L) == 0L } - AlertDialog( + ModalBottomSheet( onDismissRequest = onDismiss, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(R.drawable.ic_bolt), - contentDescription = null, - tint = LightningOrange, - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.btn_save)) - } - }, - text = { - Column { - OutlinedTextField( - value = amount, - onValueChange = { amount = it.filter { c -> c.isDigit() } }, - label = { Text(stringResource(R.string.placeholder_amount_sats)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LightningOrange, - focusedLabelColor = LightningOrange, - cursorColor = LightningOrange - ) + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface + ) { + // fillMaxHeight expands the sheet to the full available height + // under the drag handle; ModalBottomSheet's outer container + // reserves the system insets, so this stops just below the + // status bar instead of bleeding into it. + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + // Header row — "Edit Presets" centered, "Done" right-aligned. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(Modifier.width(60.dp)) + Text( + "Edit Presets", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f) ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = presetMessage, - onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(presetMessage, new)) presetMessage = new }, - label = { Text(stringResource(R.string.placeholder_message_optional)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LightningOrange, - focusedLabelColor = LightningOrange, - cursorColor = LightningOrange - ) + PillButton( + text = stringResource(R.string.btn_done), + onClick = { closeSheet(commit = true) }, + contentColor = accent, + borderColor = accent.copy(alpha = 0.45f) ) } - }, - confirmButton = { - Button( - onClick = { - val sats = amount.toLongOrNull() ?: return@Button - onSave(ZapPreset(sats, presetMessage.trim())) - }, - enabled = (amount.toLongOrNull() ?: 0L) > 0, - colors = ButtonDefaults.buttonColors(containerColor = LightningOrange) + Spacer(Modifier.height(12.dp)) + + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) ) { - Text(stringResource(R.string.btn_save), fontWeight = FontWeight.Bold) + Column { + rows.forEachIndexed { idx, row -> + if (idx > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.18f), + thickness = 0.5.dp, + modifier = Modifier.padding(start = 14.dp) + ) + } + // Keyed by stable row identity so swiping away one + // row doesn't leak its dismiss state into the next + // row sliding into its position. + val rowKey = remember { java.util.UUID.randomUUID().toString() } + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { target -> + if (target == SwipeToDismissBoxValue.EndToStart) { + rows = rows.toMutableList().also { it.removeAt(idx) } + true + } else false + } + ) + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = false, + enableDismissFromEndToStart = true, + backgroundContent = { + // Trailing-swipe affordance — solid iOS-red + // panel with a trailing delete glyph. Sized + // to fillMaxSize so the panel spans the full + // row height and reaches the trailing edge. + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFFF3B30)), + contentAlignment = Alignment.CenterEnd + ) { + Icon( + Icons.Filled.Delete, + contentDescription = "Delete preset", + tint = Color.White, + modifier = Modifier.padding(end = 24.dp) + ) + } + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 14.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BasicTextField( + value = row.amount, + onValueChange = { newVal -> + val filtered = newVal.filter { it.isDigit() } + rows = rows.toMutableList().also { + it[idx] = it[idx].copy(amount = filtered) + } + }, + textStyle = TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp + ), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + cursorBrush = SolidColor(accent), + decorationBox = { inner -> + Box { + if (row.amount.isEmpty()) { + Text( + "Sats", + color = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = 0.7f), + fontSize = 16.sp + ) + } + inner() + } + }, + modifier = Modifier.width(80.dp) + ) + Spacer(Modifier.width(8.dp)) + BasicTextField( + value = row.message, + onValueChange = { newVal -> + val sanitized = newVal.replace(",", "").replace(":", "") + rows = rows.toMutableList().also { + it[idx] = it[idx].copy(message = sanitized) + } + }, + textStyle = TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp + ), + singleLine = true, + cursorBrush = SolidColor(accent), + decorationBox = { inner -> + Box { + if (row.message.isEmpty()) { + Text( + "Message (optional)", + color = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = 0.7f), + fontSize = 16.sp + ) + } + inner() + } + }, + modifier = Modifier.weight(1f) + ) + } + } + } + + if (rows.isNotEmpty()) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.18f), + thickness = 0.5.dp, + modifier = Modifier.padding(start = 14.dp) + ) + } + // Add preset row — iOS disables it while a blank row + // already exists so the user finishes the current + // entry before adding another. + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !hasBlankRow) { + rows = rows + EditableRow("", "") + } + .padding(horizontal = 14.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.Add, + contentDescription = null, + tint = if (hasBlankRow) accent.copy(alpha = 0.35f) else accent, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + "Add preset", + color = if (hasBlankRow) accent.copy(alpha = 0.35f) else accent, + fontWeight = FontWeight.SemiBold + ) + } + } } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) } + Spacer(Modifier.height(20.dp)) } - ) + } } +/** Working-copy row used inside the Edit Presets sheet. */ +private data class EditableRow(val amount: String, val message: String) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt index 3537fc4e..0e4d2525 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt @@ -79,6 +79,7 @@ fun ArticleScreen( onRepost: (NostrEvent) -> Unit = {}, onQuote: (NostrEvent) -> Unit = {}, onZap: (NostrEvent) -> Unit = {}, + onZapInstant: (NostrEvent) -> Unit = onZap, onAddToList: (String) -> Unit = {}, noteActions: NoteActions? = null, zapAnimatingIds: Set = emptySet(), @@ -363,6 +364,7 @@ fun ArticleScreen( hasUserReposted = commentHasUserReposted, repostCount = commentRepostCount, onZap = { onZap(event) }, + onZapLongPress = { onZapInstant(event) }, hasUserZapped = commentHasUserZapped, likeCount = commentLikeCount, replyCount = commentReplyCount, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt index f1b5c0f6..5554eab6 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt @@ -124,6 +124,7 @@ fun DmConversationScreen( socialActionManager: SocialActionManager? = null, isWalletConnected: Boolean = false, onGoToWallet: () -> Unit = {}, + zapPrefs: com.wisp.app.repo.ZapPreferences, noteActions: com.wisp.app.ui.component.NoteActions? = null, resolvedEmojis: Map = emptyMap(), unicodeEmojis: List = emptyList(), @@ -639,7 +640,10 @@ fun DmConversationScreen( onGoToWallet = { zapTargetMessage = null onGoToWallet() - } + }, + zapPrefsRepo = zapPrefs, + recipientPubkey = zapTargetMessage?.senderPubkey, + profileLookup = { pk -> peerProfile?.takeIf { it.pubkey == pk } } ) } } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt index 34902103..a5a6be12 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt @@ -328,6 +328,15 @@ fun FeedScreen( onRepost = onRepost, onQuote = onQuote, onZap = { event -> zapTargetEvent = event }, + onZapInstant = { event -> + if (viewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = viewModel.interfacePrefs.getQuickZapAmountSats() + val msg = viewModel.interfacePrefs.getQuickZapMessage() + viewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + zapTargetEvent = event + } + }, onProfileClick = onProfileClick, onNoteClick = { eventId -> onQuotedNoteClick?.invoke(eventId) }, onAddToList = onAddToList, @@ -547,7 +556,10 @@ fun FeedScreen( viewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = onWallet, - canPrivateZap = userHasDmRelays && recipientHasDmRelays + zapPrefsRepo = viewModel.zapPrefs, + canPrivateZap = userHasDmRelays && recipientHasDmRelays, + recipientPubkey = zapTargetEvent?.pubkey, + profileLookup = { viewModel.profileRepo.get(it) } ) } @@ -575,7 +587,10 @@ fun FeedScreen( zapPollTarget = null viewModel.sendZapPollVote(pollEvent, optionIndex, amountMsats, message, isAnonymous) }, - onGoToWallet = onWallet + onGoToWallet = onWallet, + zapPrefsRepo = viewModel.zapPrefs, + recipientPubkey = pollEvent.pubkey, + profileLookup = { viewModel.profileRepo.get(it) } ) } @@ -1223,6 +1238,7 @@ fun FeedScreen( onRepost = { onRepost(event) }, onQuote = { onQuote(event) }, onZap = { zapTargetEvent = event }, + onZapLongPress = { noteActions.onZapInstant(event) }, onAddToList = { onAddToList(event.id) }, onPin = { viewModel.togglePin(event.id) }, onDelete = { viewModel.deleteEvent(event.id, event.kind) }, @@ -1324,6 +1340,7 @@ private fun FeedItem( onRepost: () -> Unit, onQuote: () -> Unit, onZap: () -> Unit, + onZapLongPress: (() -> Unit)? = null, onAddToList: () -> Unit = {}, onPin: () -> Unit = {}, onDelete: () -> Unit = {}, @@ -1467,6 +1484,7 @@ private fun FeedItem( hasUserReposted = hasUserReposted, repostCount = repostCount, onZap = onZap, + onZapLongPress = onZapLongPress, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt index 110da1cb..624174ef 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt @@ -3,6 +3,9 @@ package com.wisp.app.ui.screen import android.app.Activity import android.app.Application import androidx.compose.foundation.Canvas +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.animation.AnimatedVisibility @@ -17,6 +20,7 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -141,6 +145,7 @@ fun InterfaceScreen( modifier = Modifier .fillMaxSize() .padding(padding) + .imePadding() .verticalScroll(rememberScrollState()) .padding(16.dp) ) { @@ -753,6 +758,107 @@ fun InterfaceScreen( Spacer(Modifier.height(24.dp)) + // ── Instant zaps / Instant payments ───────────────────────────── + // Hold-to-zap on the post-card fires the configured amount + // immediately when enabled; tap still opens the composer. + var quickZapEnabled by remember { mutableStateOf(interfacePrefs.isQuickZapEnabled()) } + var quickZapSats by remember { mutableStateOf(interfacePrefs.getQuickZapAmountSats().toString()) } + var quickZapFiat by remember { mutableStateOf(interfacePrefs.getQuickZapAmountFiat().toString()) } + var quickZapMessage by remember { mutableStateOf(interfacePrefs.getQuickZapMessage()) } + + Text( + if (fiatModeEnabled) "Payments" else "Zaps", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + if (fiatModeEnabled) "Instant payments" else "Instant zaps", + style = MaterialTheme.typography.bodyMedium + ) + Text( + if (fiatModeEnabled) + "Hold a post's pay button to send the configured amount instantly. Tap still opens the composer." + else + "Hold a post's zap bolt to send the configured amount instantly. Tap still opens the composer.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + Switch( + checked = quickZapEnabled, + onCheckedChange = { + quickZapEnabled = it + interfacePrefs.setQuickZapEnabled(it) + }, + colors = wispSwitchColors() + ) + } + } + + if (quickZapEnabled) { + Spacer(Modifier.height(12.dp)) + if (fiatModeEnabled) { + OutlinedTextField( + value = quickZapFiat, + onValueChange = { raw -> + // Allow only digits + a single dot. Trim leading zeros. + val cleaned = raw.filter { it.isDigit() || it == '.' } + .let { s -> + val firstDot = s.indexOf('.') + if (firstDot < 0) s + else s.substring(0, firstDot + 1) + + s.substring(firstDot + 1).filter { it != '.' } + } + quickZapFiat = cleaned + cleaned.toDoubleOrNull()?.let { + interfacePrefs.setQuickZapAmountFiat(it) + } + }, + label = { Text("Amount (${fiatCurrency})") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } else { + OutlinedTextField( + value = quickZapSats, + onValueChange = { raw -> + val digits = raw.filter { it.isDigit() }.trimStart('0').ifEmpty { "" } + quickZapSats = digits + // Hard cap at QUICK_ZAP_MAX_SATS so instant zaps + // never bypass the soft-confirmation dialog. + val parsed = digits.toLongOrNull()?.coerceIn(1L, InterfacePreferences.QUICK_ZAP_MAX_SATS) + if (parsed != null) interfacePrefs.setQuickZapAmountSats(parsed) + }, + label = { Text("Amount (sats)") }, + supportingText = { Text("Max ${InterfacePreferences.QUICK_ZAP_MAX_SATS}") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = quickZapMessage, + onValueChange = { + quickZapMessage = it + interfacePrefs.setQuickZapMessage(it) + }, + label = { Text("Message (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(Modifier.height(24.dp)) + // Zap icon toggle — hidden in fiat mode since the bolt is forced var zapBoltIcon by remember { mutableStateOf(interfacePrefs.isZapBoltIcon()) } if (!fiatModeEnabled) { diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt index 5ed61e69..1343a74c 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt @@ -158,6 +158,7 @@ fun NotificationsScreen( onRepost: (NostrEvent) -> Unit = {}, onQuote: (NostrEvent) -> Unit = {}, onZap: (NostrEvent) -> Unit = {}, + onZapInstant: (NostrEvent) -> Unit = onZap, onFollowToggle: (String) -> Unit = {}, onBlockUser: (String) -> Unit = {}, onMuteThread: (String) -> Unit = {}, @@ -256,6 +257,7 @@ fun NotificationsScreen( onRepost = onRepost, onQuote = onQuote, onZap = onZap, + onZapInstant = onZapInstant, onFollowToggle = onFollowToggle, onBlockUser = onBlockUser, onMuteThread = onMuteThread, @@ -1426,6 +1428,7 @@ private fun ReferencedNotePostCard( hasUserReposted = hasUserReposted, repostCount = repostCount, onZap = { params.onZap(event) }, + onZapLongPress = { params.onZapInstant(event) }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, @@ -1526,6 +1529,7 @@ private data class NotifPostCardParams( val onRepost: (NostrEvent) -> Unit, val onQuote: (NostrEvent) -> Unit, val onZap: (NostrEvent) -> Unit, + val onZapInstant: (NostrEvent) -> Unit = onZap, val onFollowToggle: (String) -> Unit, val onBlockUser: (String) -> Unit, val onMuteThread: (String) -> Unit = {}, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt index 0aa0b7eb..c7c1688f 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt @@ -103,6 +103,7 @@ fun SearchScreen( onRepost: (NostrEvent) -> Unit = {}, onQuote: (NostrEvent) -> Unit = {}, onZap: (NostrEvent) -> Unit = {}, + onZapInstant: (NostrEvent) -> Unit = onZap, zapInProgress: Set = emptySet(), zapAnimatingIds: Set = emptySet(), onToggleFollow: (String) -> Unit = {}, @@ -376,6 +377,7 @@ fun SearchScreen( onRepost = { onRepost(event) }, onQuote = { onQuote(event) }, onZap = { onZap(event) }, + onZapInstant = { onZapInstant(event) }, onFollowAuthor = { onToggleFollow(event.pubkey) }, onBlockAuthor = { onBlockUser(event.pubkey) }, onAddToList = { onAddToList(event.id) }, @@ -463,6 +465,7 @@ private fun SearchNoteItem( onRepost: () -> Unit, onQuote: () -> Unit = {}, onZap: () -> Unit, + onZapInstant: () -> Unit = onZap, onFollowAuthor: () -> Unit, onBlockAuthor: () -> Unit, onAddToList: () -> Unit, @@ -509,6 +512,7 @@ private fun SearchNoteItem( onRepost = onRepost, onQuote = onQuote, onZap = onZap, + onZapLongPress = onZapInstant, hasUserZapped = hasUserZapped, zapSats = zapSats, isZapAnimating = isZapAnimating, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt index b97e729e..631eff8f 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt @@ -90,6 +90,7 @@ fun ThreadScreen( onToggleFollow: (String) -> Unit = {}, onBlockUser: (String) -> Unit = {}, onZap: (NostrEvent) -> Unit = {}, + onZapInstant: (NostrEvent) -> Unit = onZap, zapAnimatingIds: Set = emptySet(), zapInProgressIds: Set = emptySet(), listedIds: Set = emptySet(), @@ -178,6 +179,7 @@ fun ThreadScreen( onRepost = onRepost, onQuote = onQuote, onZap = onZap, + onZapInstant = onZapInstant, onProfileClick = onProfileClick, onNoteClick = { eventId -> onQuotedNoteClick?.invoke(eventId) }, onAddToList = onAddToList, @@ -360,6 +362,7 @@ fun ThreadScreen( hasUserReposted = hasUserReposted, repostCount = repostCount, onZap = { onZap(event) }, + onZapLongPress = { onZapInstant(event) }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, @@ -477,6 +480,7 @@ fun ThreadScreen( hasUserReposted = hasUserReposted, repostCount = repostCount, onZap = { onZap(event) }, + onZapLongPress = { onZapInstant(event) }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt index ee8ccf25..60a064f9 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt @@ -163,6 +163,8 @@ fun UserProfileScreen( onQuotedNoteClick: ((String) -> Unit)? = null, onReact: (NostrEvent, String) -> Unit = { _, _ -> }, onZap: (NostrEvent, Long, String, Boolean, Boolean) -> Unit = { _, _, _, _, _ -> }, + onZapInstant: ((NostrEvent) -> Unit)? = null, + zapPrefs: com.wisp.app.repo.ZapPreferences, userPubkey: String? = null, isWalletConnected: Boolean = false, onWallet: () -> Unit = {}, @@ -293,7 +295,10 @@ fun UserProfileScreen( onZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = onWallet, - canPrivateZap = resolvedCanPrivateZap + zapPrefsRepo = zapPrefs, + canPrivateZap = resolvedCanPrivateZap, + recipientPubkey = zapTargetEvent?.pubkey, + profileLookup = { eventRepo?.getProfileData(it) } ) } @@ -307,7 +312,11 @@ fun UserProfileScreen( onZapProfile?.invoke(amountMsats, message, isAnonymous) }, onGoToWallet = onWallet, - canPrivateZap = false + zapPrefsRepo = zapPrefs, + canPrivateZap = false, + // Profile zap — recipient is the profile being viewed. + recipientPubkey = profile?.pubkey, + profileLookup = { pk -> profile?.takeIf { it.pubkey == pk } } ) } @@ -868,6 +877,7 @@ fun UserProfileScreen( userReactionEmojis = userEmojis, hasUserReposted = hasUserReposted, onZap = { zapTargetEvent = event }, + onZapLongPress = { onZapInstant?.invoke(event) ?: run { zapTargetEvent = event } }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, @@ -1053,6 +1063,7 @@ fun UserProfileScreen( userReactionEmojis = userEmojis, hasUserReposted = hasUserReposted2, onZap = { zapTargetEvent = event }, + onZapLongPress = { onZapInstant?.invoke(event) ?: run { zapTargetEvent = event } }, hasUserZapped = hasUserZapped2, likeCount = likeCount, replyCount = replyCount, @@ -1128,6 +1139,7 @@ fun UserProfileScreen( userReactionEmojis = userEmojis, hasUserReposted = hasUserReposted, onZap = { zapTargetEvent = event }, + onZapLongPress = { onZapInstant?.invoke(event) ?: run { zapTargetEvent = event } }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount,