Merge pull request #26 from dmnyc/feat/zap-sheet-overhaul

feat(zap): zap sheet overhaul — fiat input, iOS layout rewrite, hero composer, per-account presets
This commit is contained in:
Barry Deen
2026-06-16 00:35:38 -04:00
committed by GitHub
17 changed files with 1393 additions and 882 deletions
@@ -1152,6 +1152,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) },
@@ -1269,10 +1277,12 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
zapPrefsRepo = feedViewModel.zapPrefs,
canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays,
recipientPubkey = zapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, zapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
recipientPubkey = searchZapTarget?.pubkey,
recipientHasLud16 = (searchZapTarget?.pubkey)?.let { zapRecipientHasLud16(feedViewModel, it) } ?: true,
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
SearchScreen(
@@ -1312,6 +1322,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 ->
@@ -1434,6 +1453,7 @@ fun WispNavHost(
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
isWalletConnected = feedViewModel.activeWalletProvider.hasConnection(),
onGoToWallet = { navController.navigate(Routes.WALLET) },
zapPrefs = feedViewModel.zapPrefs,
noteActions = remember {
com.darkwisp.app.ui.component.NoteActions(
nip05Repo = feedViewModel.nip05Repo,
@@ -1523,6 +1543,7 @@ fun WispNavHost(
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
isWalletConnected = feedViewModel.activeWalletProvider.hasConnection(),
onGoToWallet = { navController.navigate(Routes.WALLET) },
zapPrefs = feedViewModel.zapPrefs,
noteActions = remember {
com.darkwisp.app.ui.component.NoteActions(
nip05Repo = feedViewModel.nip05Repo,
@@ -1645,11 +1666,13 @@ 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,
recipientPubkey = zapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, zapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
recipientPubkey = groupRoomZapTarget?.pubkey,
recipientHasLud16 = (groupRoomZapTarget?.pubkey)?.let { zapRecipientHasLud16(feedViewModel, it) } ?: true,
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
val groupRoomMediaLauncher = rememberLauncherForActivityResult(
@@ -1926,11 +1949,13 @@ 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,
recipientPubkey = threadZapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, threadZapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
recipientPubkey = threadZapTarget?.pubkey,
recipientHasLud16 = (threadZapTarget?.pubkey)?.let { zapRecipientHasLud16(feedViewModel, it) } ?: true,
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
val threadSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState()
@@ -1981,6 +2006,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,
@@ -2099,10 +2133,12 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
zapPrefsRepo = feedViewModel.zapPrefs,
canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays,
recipientPubkey = hashtagZapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, hashtagZapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
recipientPubkey = hashtagZapTarget?.pubkey,
recipientHasLud16 = (hashtagZapTarget?.pubkey)?.let { zapRecipientHasLud16(feedViewModel, it) } ?: true,
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
@@ -2123,6 +2159,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 },
@@ -2253,10 +2298,12 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
zapPrefsRepo = feedViewModel.zapPrefs,
canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays,
recipientPubkey = setFeedZapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, setFeedZapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
recipientPubkey = setFeedZapTarget?.pubkey,
recipientHasLud16 = (setFeedZapTarget?.pubkey)?.let { zapRecipientHasLud16(feedViewModel, it) } ?: true,
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
@@ -2277,6 +2324,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 },
@@ -2420,10 +2476,12 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
zapPrefsRepo = feedViewModel.zapPrefs,
canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays,
recipientPubkey = zapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, zapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
recipientPubkey = articleZapTarget?.pubkey,
recipientHasLud16 = (articleZapTarget?.pubkey)?.let { zapRecipientHasLud16(feedViewModel, it) } ?: true,
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
@@ -2444,6 +2502,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 },
@@ -2505,6 +2572,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,
@@ -2627,13 +2703,18 @@ 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,
recipientPubkey = zapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, zapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
// 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,
recipientHasLud16 = (liveZapRecipientOverride ?: liveZapTarget?.pubkey)?.let { zapRecipientHasLud16(feedViewModel, it) } ?: true,
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
val streamActivityEventId = remember(hostPubkey, dTag) {
@@ -3190,11 +3271,13 @@ 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,
recipientPubkey = notifZapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, notifZapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
recipientPubkey = notifZapTarget?.pubkey,
recipientHasLud16 = (notifZapTarget?.pubkey)?.let { zapRecipientHasLud16(feedViewModel, it) } ?: true,
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
@@ -3213,10 +3296,12 @@ fun WispNavHost(
)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
zapPrefsRepo = feedViewModel.zapPrefs,
recipientPubkey = notifDmZapTarget?.senderPubkey,
recipientHasLud16 = notifDmZapTarget?.senderPubkey
?.let { zapRecipientHasLud16(feedViewModel, it) } ?: true,
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
profileLookup = { feedViewModel.profileRepo.get(it) }
)
}
@@ -3317,6 +3402,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) },
@@ -283,5 +283,3 @@ object Nip19 {
return IntArray(6) { i -> (polymod shr (5 * (5 - i))) and 31 }
}
}
fun String.toNpub(): String = Nip19.npubEncode(hexToByteArray())
@@ -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())
@@ -66,6 +62,78 @@ class InterfacePreferences(context: Context) {
fun isPostUndoTimerForReplies(): Boolean = prefs.getBoolean("post_undo_timer_for_replies", false)
fun setPostUndoTimerForReplies(enabled: Boolean) = prefs.edit().putBoolean("post_undo_timer_for_replies", enabled).apply()
// ── Instant (a.k.a. quick) zaps ─────────────────────────────────────────
// Hold-to-zap on the post-card fires immediately at the configured
// amount when enabled; tap still opens the composer.
//
// Keys are scoped per-account via activePubkey (companion object) so
// switching accounts never inherits the previous account's values.
// Call reload(pubkey) on every account switch to update the scope.
private fun quickZapKey(base: String): String =
activePubkey?.let { "${base}_$it" } ?: base
fun isQuickZapEnabled(): Boolean = prefs.getBoolean(quickZapKey("quick_zap_enabled"), false)
fun setQuickZapEnabled(enabled: Boolean) =
prefs.edit().putBoolean(quickZapKey("quick_zap_enabled"), enabled).apply()
fun getQuickZapAmountSats(): Long =
prefs.getLong(quickZapKey("quick_zap_amount_sats"), 21L).coerceIn(1L, QUICK_ZAP_MAX_SATS)
fun setQuickZapAmountSats(amount: Long) {
// Hard clamp at 10K sats so an instant zap never bypasses the soft
// confirmation dialog in the ZapSheet (which fires at >10K).
val clamped = amount.coerceIn(1L, QUICK_ZAP_MAX_SATS)
prefs.edit().putLong(quickZapKey("quick_zap_amount_sats"), clamped).apply()
}
fun getQuickZapAmountFiat(): Double =
prefs.getString(quickZapKey("quick_zap_amount_fiat"), "0.10")?.toDoubleOrNull()?.coerceAtLeast(0.0) ?: 0.10
fun setQuickZapAmountFiat(amount: Double) {
// Fiat clamp happens at fire time against the cached exchange rate
// (callers in ZapSheet do `min(localFiat, sats→fiat(10_000))`).
val clamped = amount.coerceAtLeast(0.0)
prefs.edit().putString(quickZapKey("quick_zap_amount_fiat"), clamped.toString()).apply()
}
fun getQuickZapMessage(): String = prefs.getString(quickZapKey("quick_zap_message"), "") ?: ""
fun setQuickZapMessage(message: String) =
prefs.edit().putString(quickZapKey("quick_zap_message"), message).apply()
/**
* Update the active account scope for instant-zap keys. Call on every
* account switch AND at initial app launch. On the very first call with
* a non-null pubkey (activePubkey was null), migrates any values that
* were stored under the old unscoped keys so existing settings survive
* the upgrade to per-account storage.
*/
fun reload(pubkey: String?) {
val wasNull = activePubkey == null
activePubkey = pubkey
if (wasNull && pubkey != null) migrateGlobalIfNeeded(pubkey)
}
private fun migrateGlobalIfNeeded(pubkey: String) {
val migKey = "quick_zap_migrated_v1_$pubkey"
if (prefs.getBoolean(migKey, false)) return
val edit = prefs.edit().putBoolean(migKey, true)
if (!prefs.contains("quick_zap_amount_sats_$pubkey") && prefs.contains("quick_zap_amount_sats"))
edit.putLong("quick_zap_amount_sats_$pubkey", prefs.getLong("quick_zap_amount_sats", 21L))
if (!prefs.contains("quick_zap_amount_fiat_$pubkey") && prefs.contains("quick_zap_amount_fiat"))
prefs.getString("quick_zap_amount_fiat", null)?.let { edit.putString("quick_zap_amount_fiat_$pubkey", it) }
if (!prefs.contains("quick_zap_enabled_$pubkey") && prefs.contains("quick_zap_enabled"))
edit.putBoolean("quick_zap_enabled_$pubkey", prefs.getBoolean("quick_zap_enabled", false))
if (!prefs.contains("quick_zap_message_$pubkey") && prefs.contains("quick_zap_message"))
prefs.getString("quick_zap_message", null)?.let { edit.putString("quick_zap_message_$pubkey", it) }
edit.apply()
}
companion object {
/** Shared across all InterfacePreferences instances — updated by reload(). */
@Volatile var activePubkey: String? = null
val postUndoTimerOptions = listOf(5, 10, 15, 20, 30)
const val QUICK_ZAP_MAX_SATS = 10_000L
}
/** Reset all interface preferences to defaults (called on full logout). */
fun reset() {
prefs.edit()
@@ -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)
}
}
@@ -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
}
@@ -123,6 +123,7 @@ fun PostCard(
hasUserReposted: Boolean = false,
repostCount: Int = 0,
onZap: () -> Unit = {},
onZapLongPress: (() -> Unit)? = null,
onZapDisabledTap: () -> Unit = {},
zapEnabled: Boolean = true,
hasUserZapped: Boolean = false,
@@ -794,6 +795,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,
@@ -807,7 +811,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)
)
@@ -157,6 +157,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 = {},
@@ -1236,6 +1237,7 @@ fun QuotedNote(
hasUserReposted = hasUserReposted,
repostCount = repostCount,
onZap = { effectiveActions.onZap(event) },
onZapLongPress = { effectiveActions.onZapInstant(event) },
hasUserZapped = hasUserZapped,
likeCount = likeCount,
replyCount = replyCount,
File diff suppressed because it is too large Load Diff
@@ -78,6 +78,7 @@ fun ArticleScreen(
onRepost: (NostrEvent) -> Unit = {},
onQuote: (NostrEvent) -> Unit = {},
onZap: (NostrEvent) -> Unit = {},
onZapInstant: (NostrEvent) -> Unit = onZap,
onAddToList: (String) -> Unit = {},
noteActions: NoteActions? = null,
zapAnimatingIds: Set<String> = emptySet(),
@@ -362,6 +363,7 @@ fun ArticleScreen(
hasUserReposted = commentHasUserReposted,
repostCount = commentRepostCount,
onZap = { onZap(event) },
onZapLongPress = { onZapInstant(event) },
hasUserZapped = commentHasUserZapped,
likeCount = commentLikeCount,
replyCount = commentReplyCount,
@@ -123,6 +123,7 @@ fun DmConversationScreen(
socialActionManager: SocialActionManager? = null,
isWalletConnected: Boolean = false,
onGoToWallet: () -> Unit = {},
zapPrefs: com.darkwisp.app.repo.ZapPreferences,
noteActions: com.darkwisp.app.ui.component.NoteActions? = null,
resolvedEmojis: Map<String, String> = emptyMap(),
unicodeEmojis: List<String> = emptyList(),
@@ -635,10 +636,12 @@ fun DmConversationScreen(
zapTargetMessage = null
onGoToWallet()
},
zapPrefsRepo = zapPrefs,
recipientPubkey = zapTargetMessage?.senderPubkey,
recipientHasLud16 = zapTargetMessage?.senderPubkey
?.let { pk -> eventRepo?.getProfileData(pk)?.let { !it.lud16.isNullOrBlank() } } ?: true,
fetchPaymentTargets = fetchPaymentTargets
fetchPaymentTargets = fetchPaymentTargets,
profileLookup = { pk -> peerProfile?.takeIf { it.pubkey == pk } }
)
}
}
@@ -334,6 +334,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,
@@ -554,12 +563,15 @@ fun FeedScreen(
viewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = onWallet,
zapPrefsRepo = viewModel.zapPrefs,
canPrivateZap = userHasDmRelays && recipientHasDmRelays,
recipientPubkey = zapRecipient,
recipientPubkey = zapTargetEvent?.pubkey,
// Unknown profile -> assume zappable; the send path surfaces the error.
recipientHasLud16 = viewModel.eventRepo.getProfileData(zapRecipient)
?.let { !it.lud16.isNullOrBlank() } ?: true,
fetchPaymentTargets = viewModel::fetchPaymentTargets
recipientHasLud16 = zapTargetEvent?.pubkey?.let { pk ->
viewModel.eventRepo.getProfileData(pk)?.let { !it.lud16.isNullOrBlank() }
} ?: true,
fetchPaymentTargets = viewModel::fetchPaymentTargets,
profileLookup = { viewModel.profileRepo.get(it) }
)
}
@@ -587,7 +599,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) }
)
}
@@ -1338,6 +1353,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) },
@@ -1439,6 +1455,7 @@ private fun FeedItem(
onRepost: () -> Unit,
onQuote: () -> Unit,
onZap: () -> Unit,
onZapLongPress: (() -> Unit)? = null,
onAddToList: () -> Unit = {},
onPin: () -> Unit = {},
onDelete: () -> Unit = {},
@@ -1582,6 +1599,7 @@ private fun FeedItem(
hasUserReposted = hasUserReposted,
repostCount = repostCount,
onZap = onZap,
onZapLongPress = onZapLongPress,
hasUserZapped = hasUserZapped,
likeCount = likeCount,
replyCount = replyCount,
@@ -3,6 +3,9 @@ package com.darkwisp.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
@@ -140,6 +144,7 @@ fun InterfaceScreen(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
@@ -720,6 +725,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) {
@@ -157,6 +157,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 = {},
@@ -254,6 +255,7 @@ fun NotificationsScreen(
onRepost = onRepost,
onQuote = onQuote,
onZap = onZap,
onZapInstant = onZapInstant,
onFollowToggle = onFollowToggle,
onBlockUser = onBlockUser,
onMuteThread = onMuteThread,
@@ -1423,6 +1425,7 @@ private fun ReferencedNotePostCard(
hasUserReposted = hasUserReposted,
repostCount = repostCount,
onZap = { params.onZap(event) },
onZapLongPress = { params.onZapInstant(event) },
hasUserZapped = hasUserZapped,
likeCount = likeCount,
replyCount = replyCount,
@@ -1522,6 +1525,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 = {},
@@ -98,6 +98,7 @@ fun SearchScreen(
onRepost: (NostrEvent) -> Unit = {},
onQuote: (NostrEvent) -> Unit = {},
onZap: (NostrEvent) -> Unit = {},
onZapInstant: (NostrEvent) -> Unit = onZap,
zapInProgress: Set<String> = emptySet(),
zapAnimatingIds: Set<String> = emptySet(),
onToggleFollow: (String) -> Unit = {},
@@ -387,6 +388,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) },
@@ -434,6 +436,7 @@ private fun SearchNoteItem(
onRepost: () -> Unit,
onQuote: () -> Unit = {},
onZap: () -> Unit,
onZapInstant: () -> Unit = onZap,
onFollowAuthor: () -> Unit,
onBlockAuthor: () -> Unit,
onAddToList: () -> Unit,
@@ -479,6 +482,7 @@ private fun SearchNoteItem(
onRepost = onRepost,
onQuote = onQuote,
onZap = onZap,
onZapLongPress = onZapInstant,
hasUserZapped = hasUserZapped,
zapSats = zapSats,
isZapAnimating = isZapAnimating,
@@ -95,6 +95,7 @@ fun ThreadScreen(
onToggleFollow: (String) -> Unit = {},
onBlockUser: (String) -> Unit = {},
onZap: (NostrEvent) -> Unit = {},
onZapInstant: (NostrEvent) -> Unit = onZap,
zapAnimatingIds: Set<String> = emptySet(),
zapInProgressIds: Set<String> = emptySet(),
listedIds: Set<String> = emptySet(),
@@ -182,6 +183,7 @@ fun ThreadScreen(
onRepost = onRepost,
onQuote = onQuote,
onZap = onZap,
onZapInstant = onZapInstant,
onProfileClick = onProfileClick,
onNoteClick = { eventId -> onQuotedNoteClick?.invoke(eventId) },
onAddToList = onAddToList,
@@ -389,6 +391,7 @@ fun ThreadScreen(
hasUserReposted = hasUserReposted,
repostCount = repostCount,
onZap = { onZap(event) },
onZapLongPress = { onZapInstant(event) },
hasUserZapped = hasUserZapped,
likeCount = likeCount,
replyCount = replyCount,
@@ -480,6 +483,7 @@ fun ThreadScreen(
hasUserReposted = hasUserReposted,
repostCount = repostCount,
onZap = { onZap(event) },
onZapLongPress = { onZapInstant(event) },
hasUserZapped = hasUserZapped,
likeCount = likeCount,
replyCount = replyCount,
@@ -157,6 +157,8 @@ fun UserProfileScreen(
onQuotedNoteClick: ((String) -> Unit)? = null,
onReact: (NostrEvent, String) -> Unit = { _, _ -> },
onZap: (NostrEvent, Long, String, Boolean, Boolean) -> Unit = { _, _, _, _, _ -> },
onZapInstant: ((NostrEvent) -> Unit)? = null,
zapPrefs: com.darkwisp.app.repo.ZapPreferences,
userPubkey: String? = null,
isWalletConnected: Boolean = false,
onWallet: () -> Unit = {},
@@ -291,11 +293,14 @@ fun UserProfileScreen(
onZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = onWallet,
zapPrefsRepo = zapPrefs,
canPrivateZap = resolvedCanPrivateZap,
recipientPubkey = zapRecipient,
recipientHasLud16 = eventRepo?.getProfileData(zapRecipient)
?.let { !it.lud16.isNullOrBlank() } ?: true,
fetchPaymentTargets = fetchPaymentTargets
recipientPubkey = zapTargetEvent?.pubkey,
recipientHasLud16 = zapTargetEvent?.pubkey?.let { pk ->
eventRepo?.getProfileData(pk)?.let { !it.lud16.isNullOrBlank() }
} ?: true,
fetchPaymentTargets = fetchPaymentTargets,
profileLookup = { eventRepo?.getProfileData(it) }
)
}
@@ -309,10 +314,13 @@ fun UserProfileScreen(
onZapProfile?.invoke(amountMsats, message, isAnonymous)
},
onGoToWallet = onWallet,
zapPrefsRepo = zapPrefs,
canPrivateZap = false,
recipientPubkey = profilePubkey.ifEmpty { null },
// Profile zap — recipient is the profile being viewed.
recipientPubkey = profile?.pubkey ?: profilePubkey.ifEmpty { null },
recipientHasLud16 = profile?.let { !it.lud16.isNullOrBlank() } ?: true,
fetchPaymentTargets = fetchPaymentTargets
fetchPaymentTargets = fetchPaymentTargets,
profileLookup = { pk -> profile?.takeIf { it.pubkey == pk } }
)
}
@@ -823,6 +831,7 @@ fun UserProfileScreen(
userReactionEmojis = userEmojis,
hasUserReposted = hasUserReposted,
onZap = { zapTargetEvent = event },
onZapLongPress = { onZapInstant?.invoke(event) ?: run { zapTargetEvent = event } },
hasUserZapped = hasUserZapped,
likeCount = likeCount,
replyCount = replyCount,
@@ -1007,6 +1016,7 @@ fun UserProfileScreen(
userReactionEmojis = userEmojis,
hasUserReposted = hasUserReposted2,
onZap = { zapTargetEvent = event },
onZapLongPress = { onZapInstant?.invoke(event) ?: run { zapTargetEvent = event } },
hasUserZapped = hasUserZapped2,
likeCount = likeCount,
replyCount = replyCount,
@@ -468,7 +468,10 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
fun markLoadingComplete() = feedSub.markLoadingComplete()
// -- Startup delegates --
fun initRelays() = startup.initRelays()
fun initRelays() {
interfacePrefs.reload(getUserPubkey())
startup.initRelays()
}
fun setTorEnabled(enabled: Boolean) = startup.setTorEnabled(enabled)
fun resetForAccountSwitch() {
startup.resetForAccountSwitch()
@@ -482,6 +485,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
}
fun reloadForNewAccount() {
safetyPrefs.reload(getUserPubkey())
interfacePrefs.reload(getUserPubkey())
startup.reloadForNewAccount()
groupRepo.reload(getUserPubkey())
}