Skip to content
121 changes: 112 additions & 9 deletions app/src/main/kotlin/com/wisp/app/Navigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) }
)
}

Expand All @@ -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 },
Expand Down Expand Up @@ -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) }
)
}

Expand All @@ -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 },
Expand Down Expand Up @@ -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) }
)
}

Expand All @@ -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 },
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) }
)
}

Expand All @@ -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) }
)
}

Expand Down Expand Up @@ -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) },
Expand Down
36 changes: 32 additions & 4 deletions app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ class InterfacePreferences(context: Context) {
}
}

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

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

fun getAccentColor(): Int = prefs.getInt("accent_color", 0xFFFF9800.toInt())
Expand Down Expand Up @@ -69,6 +65,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()
Expand Down
35 changes: 32 additions & 3 deletions app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)
}
}
Loading