Relay reconnection after resume, status-aware cooldowns, and notification improvements

- Add RelayFailure flow and cooldown-aware reconnection to Relay/RelayPool
- RelayPool.reconnectAll() reconnects all persistent/DM relays on app resume
- Status-aware cooldowns: 10min for down relays, 1min for 4xx errors
- FeedViewModel.onAppResume() reconnects before resubscribing feeds
- Refactor notifications into grouped model (reactions, zaps, replies, reposts)
- Add zap message extraction (Nip57.getZapMessage)
- Show bottom bar on all routes except auth/onboarding
- Profile edit receives RelayPool for relay-aware editing
- Add LICENSE and README
This commit is contained in:
Barry Deen
2026-02-19 15:38:22 -05:00
parent 08ecdc2995
commit 3b7361c8ed
14 changed files with 1168 additions and 214 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Barry Deen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+394
View File
@@ -0,0 +1,394 @@
# Wisp
A minimal, performant Android client for the [Nostr](https://nostr.com) protocol. Built with Kotlin and Jetpack Compose, Wisp prioritizes decentralization, intelligent relay routing, and a clean native experience.
> **Status:** Early alpha (v0.1.0) — actively developed, expect breaking changes.
---
## Table of Contents
- [Why Wisp](#why-wisp)
- [Key Features](#key-features)
- [Screenshots](#screenshots)
- [Architecture](#architecture)
- [Supported NIPs](#supported-nips)
- [Getting Started](#getting-started)
- [Building from Source](#building-from-source)
- [Contributing](#contributing)
- [Roadmap](#roadmap)
- [License](#license)
---
## Why Wisp
Most Nostr clients treat relays as interchangeable dumb pipes. Wisp takes a different approach — it implements the outbox/inbox relay model from day one, routing messages intelligently based on where users actually publish and read. The result is faster event delivery, less wasted bandwidth, and a client that actively promotes the decentralized architecture Nostr was designed for.
Wisp is built to be fast, lightweight, and respectful of both your device and the relay network.
---
## Key Features
### Intelligent Outbox/Inbox Relay Routing
Wisp implements a full outbox/inbox model with relay scoring:
- **Outbox reads**: Fetches a user's posts from their *write relays* (where they actually publish), not from a hardcoded list
- **Inbox writes**: Delivers replies and reactions to a user's *read relays* (where they actually look), ensuring they see your interactions
- **Relay scoring**: Tracks relay reliability and author coverage to optimize which relays to query, minimizing redundant connections
- **Smart relay hints**: When tagging events, selects relay hints that overlap between your outbox and the target's inbox for optimal discoverability
- **Ephemeral connections**: Dynamically opens short-lived relay connections as needed (up to 30) with automatic cleanup after 5 minutes of inactivity
- **Fallback strategies**: Gracefully degrades to broadcast mode for users without published relay lists
### Performance First
- **LRU caching** across events (5,000), profiles, reactions, reposts, and zaps — data is fetched once and reused
- **Off-main-thread processing**: All event parsing and relay communication runs on background dispatchers
- **Debounced UI updates**: Feed emissions are coalesced to one per 16ms frame, preventing excessive recompositions from rapid relay events
- **Deduplication**: Atomic check-then-put prevents the same event from being processed twice across multiple relays
- **Lazy profile loading**: Metadata is fetched asynchronously in batches with periodic sweep cycles
- **Relay cooldowns**: Failed relays get a 5-minute cooldown before retry, preventing connection storms
### Promotes Decentralization from the Start
- No hardcoded "mega-relay" dependency — default relays are starting points, not requirements
- First-class NIP-65 relay list support encourages users to publish their own relay preferences
- Outbox model means the client respects *where users choose to publish*, not where the client developer decided to look
- Blocked relay support (NIP-51 kind 10006) lets users opt out of specific relays entirely
- DM relay sets (NIP-51 kind 10050) allow separate relay infrastructure for private messaging
- Blossom media uploads distribute content across decentralized media servers instead of centralized CDNs
### NWC Wallet Integration
- Full [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md) Nostr Wallet Connect support
- Connect any NWC-compatible Lightning wallet via `nostr+walletconnect://` URI
- Check balance, pay invoices, and create invoices directly from the app
- Send Lightning zaps to posts with optional messages
- Zap receipt tracking and display on the feed
### Blossom Media Support
- Upload images and media to decentralized [Blossom](https://github.com/hzrd149/blossom) servers
- Manage your server list (kind 10063) with per-account isolation
- Nostr event-based authentication for uploads (kind 24242)
- Multi-server fallback — tries each configured server until one succeeds
- Automatic EXIF stripping where supported by the server
### Private Messaging
- **NIP-17 gift wrap encryption**: Three-layer privacy model (rumor → seal → gift wrap) with timestamp randomization
- **NIP-44 modern encryption**: ECDH + HKDF + XChaCha20 + HMAC-SHA256, replacing legacy NIP-04
- **Conversation key caching**: Expensive ECDH computations are cached per peer
- **Separate DM relays**: Publish DMs to dedicated relay sets for better privacy
### Safety Controls
- Mute lists (NIP-51 kind 10000) for blocking pubkeys
- Keyword muting for content filtering
- Relay blocking to opt out of specific relays
- All safety lists sync across clients via published Nostr events
### Additional Features
- **Thread view** with NIP-10 reply threading and root resolution
- **Notifications** aggregating mentions, reactions, and zaps
- **Search** for profiles and content
- **Bookmarks and pins** (NIP-51)
- **Custom follow sets** (NIP-51 kind 30000) as alternative feed sources
- **Reposts** with tracking and display
- **Emoji reactions** (NIP-25) with custom emoji picker
- **NIP-05 DNS verification** with caching
- **NIP-19 bech32 encoding** — npub, nsec, note, nevent, nprofile support
- **Nostr URI rendering** in post content (nostr:npub..., nostr:note...)
- **QR code display** for sharing keys and profiles
- **Multiple account support** with per-account encrypted storage
- **Biometric authentication** for key access
- **Relay console** for debugging relay communication
- **Profile editing** with metadata publishing
- **Onboarding flow** with follow suggestions for new users
---
## Screenshots
*Coming soon*
---
## Architecture
Wisp follows an MVVM architecture with clear layer separation:
```
┌─────────────────────────────────────────────┐
│ UI Layer │
│ Jetpack Compose Screens │
│ (FeedScreen, ThreadScreen, DmScreen...) │
├─────────────────────────────────────────────┤
│ ViewModel Layer │
│ FeedViewModel, ThreadViewModel, │
│ DmConversationViewModel, WalletViewModel │
├─────────────────────────────────────────────┤
│ Repository Layer │
│ EventRepo, ContactRepo, DmRepo, NwcRepo, │
│ RelayListRepo, BlossomRepo, MuteRepo... │
├─────────────────────────────────────────────┤
│ Protocol Layer │
│ Nip01, Nip02, Nip10, Nip17, Nip19, │
│ Nip25, Nip44, Nip47, Nip51, Nip57, Nip65 │
├─────────────────────────────────────────────┤
│ Relay Layer │
│ RelayPool, OutboxRouter, RelayScoreBoard, │
│ SubscriptionManager, Relay (WebSocket) │
└─────────────────────────────────────────────┘
```
### Key Design Decisions
- **No database**: All state is held in-memory (LRU caches) or in SharedPreferences/EncryptedSharedPreferences. This keeps the app simple and fast — events are fetched from relays on each session, as Nostr intended.
- **NIP objects**: Each NIP is implemented as a Kotlin `object` with static helper functions (e.g., `Nip17.createGiftWrap()`), making the protocol layer modular and testable.
- **Flow-based reactivity**: SharedFlow for relay events, StateFlow for UI state. No RxJava, no LiveData — pure coroutines.
- **Encrypted key storage**: Private keys never touch plain SharedPreferences. AES256-GCM via Android's EncryptedSharedPreferences.
### Project Structure
```
app/src/main/kotlin/com/wisp/app/
├── nostr/ # Protocol implementations (NipXX.kt objects)
│ ├── Event.kt # Core event structure, signing, serialization
│ ├── Filter.kt # Subscription filters
│ ├── Keys.kt # Key generation and conversion
│ ├── Nip02.kt # Follow list management
│ ├── Nip10.kt # Reply threading
│ ├── Nip17.kt # Gift wrap DMs
│ ├── Nip19.kt # Bech32 encoding
│ ├── Nip25.kt # Reactions
│ ├── Nip44.kt # Modern encryption
│ ├── Nip47.kt # Wallet Connect
│ ├── Nip51.kt # Lists (mute, bookmark, pin, follow sets)
│ ├── Nip57.kt # Zaps
│ └── Nip65.kt # Relay list metadata
├── relay/ # Relay connection and routing
│ ├── Relay.kt # WebSocket connection per relay
│ ├── RelayPool.kt # Connection pool (persistent + ephemeral)
│ ├── OutboxRouter.kt # Outbox/inbox routing logic
│ ├── RelayScoreBoard.kt # Relay quality scoring
│ └── SubscriptionManager.kt # Subscription lifecycle
├── repo/ # Data repositories and persistence
├── viewmodel/ # Screen ViewModels
├── ui/ # Jetpack Compose screens and components
│ ├── screen/ # Full screens (Feed, Thread, DM, etc.)
│ └── component/ # Reusable UI components
└── db/ # Database layer
```
---
## Supported NIPs
| NIP | Description | Status |
|-----|-------------|--------|
| [01](https://github.com/nostr-protocol/nips/blob/master/01.md) | Basic protocol flow | Implemented |
| [02](https://github.com/nostr-protocol/nips/blob/master/02.md) | Follow lists | Implemented |
| [04](https://github.com/nostr-protocol/nips/blob/master/04.md) | Encrypted DMs (legacy) | Implemented (fallback) |
| [05](https://github.com/nostr-protocol/nips/blob/master/05.md) | DNS-based verification | Implemented |
| [09](https://github.com/nostr-protocol/nips/blob/master/09.md) | Event deletion | Implemented |
| [10](https://github.com/nostr-protocol/nips/blob/master/10.md) | Reply threading | Implemented |
| [11](https://github.com/nostr-protocol/nips/blob/master/11.md) | Relay information | Implemented |
| [17](https://github.com/nostr-protocol/nips/blob/master/17.md) | Private DMs (gift wrap) | Implemented |
| [18](https://github.com/nostr-protocol/nips/blob/master/18.md) | Reposts | Implemented |
| [19](https://github.com/nostr-protocol/nips/blob/master/19.md) | Bech32 encoding | Implemented |
| [25](https://github.com/nostr-protocol/nips/blob/master/25.md) | Reactions | Implemented |
| [44](https://github.com/nostr-protocol/nips/blob/master/44.md) | Versioned encryption | Implemented |
| [47](https://github.com/nostr-protocol/nips/blob/master/47.md) | Wallet Connect (NWC) | Implemented |
| [51](https://github.com/nostr-protocol/nips/blob/master/51.md) | Lists | Implemented |
| [57](https://github.com/nostr-protocol/nips/blob/master/57.md) | Lightning zaps | Implemented |
| [65](https://github.com/nostr-protocol/nips/blob/master/65.md) | Relay list metadata | Implemented |
---
## Getting Started
### Requirements
- Android 8.0 (API 26) or higher
- A Nostr keypair (you can generate one in-app or import an existing nsec)
### Installation
APK downloads will be available on the [Releases](../../releases) page once published.
### First Launch
1. **Create or import a key** — Generate a fresh keypair or paste your existing `nsec`
2. **Set up your profile** — The onboarding flow walks you through name, picture, and bio
3. **Follow some people** — Wisp suggests popular accounts to get your feed started
4. **Configure relays** — Your relay list is published as a NIP-65 event so other outbox-aware clients can find you
---
## Building from Source
### Prerequisites
- [Android Studio](https://developer.android.com/studio) Ladybug or later
- JDK 17
- Android SDK 35
### Build
```bash
# Clone the repository
git clone https://github.com/barrydeen/wisp.git
cd wisp
# Build debug APK
./gradlew assembleDebug
# Install on connected device
./gradlew installDebug
```
### Run Tests
```bash
./gradlew test
```
---
## Contributing
Contributions are welcome! Wisp is an open-source project and we appreciate help from the community.
### How to Contribute
1. **Fork** the repository
2. **Create a branch** for your feature or fix:
```bash
git checkout -b feature/your-feature-name
```
3. **Make your changes** — follow the existing code patterns and conventions
4. **Test** your changes on a real device or emulator
5. **Commit** with a clear, descriptive message
6. **Open a pull request** against `main`
### Code Conventions
- **Kotlin** with Jetpack Compose — no XML layouts
- **NIP implementations** go in `NipXX.kt` as Kotlin `object` with static helper functions
- **Events** are created via `NostrEvent.create(privkey, pubkey, kind, content, tags)`
- **Hex encoding** uses `ByteArray.toHex()` / `String.hexToByteArray()` extensions
- **Coroutines** for all async work — `Dispatchers.Default` for CPU-bound, `Dispatchers.IO` for network
- **StateFlow** for UI state, **SharedFlow** for relay events
- Keep functions small and focused. Prefer clarity over cleverness.
### Areas Where Help is Needed
- UI/UX polish and accessibility improvements
- Additional NIP implementations
- Testing — unit tests and integration tests
- Performance profiling and optimization
- Translations and localization
- Documentation improvements
### Reporting Issues
Found a bug or have a feature request? [Open an issue](../../issues) with:
- Steps to reproduce (for bugs)
- Expected vs actual behavior
- Device and Android version
- Relevant logs from the in-app relay console (if applicable)
---
## Roadmap
### Near Term
- [ ] Local database for offline access and faster startup (Room or SQLDelight)
- [ ] Image and video previews in the feed
- [ ] Hashtag following and trending topics
- [ ] Push notifications via UnifiedPush
- [ ] Profile banner images
- [ ] Event deletion (NIP-09) UI
- [ ] Improved thread view with collapsible replies
### Medium Term
- [ ] NIP-42 relay authentication
- [ ] NIP-96 file storage integration
- [ ] Long-form content (NIP-23) reading and publishing
- [ ] Community/group support (NIP-72)
- [ ] Relay discovery and recommendations
- [ ] Advanced search with relay-side filtering
- [ ] Custom emoji packs (NIP-30)
- [ ] Media gallery per profile
### Long Term
- [ ] Marketplace integration (NIP-15)
- [ ] Tor/proxy support for enhanced privacy
- [ ] Offline-first architecture with background sync
- [ ] Widgets for Android home screen
- [ ] Wear OS companion app
- [ ] Full accessibility audit and WCAG compliance
### Ongoing
- [ ] Performance optimization and memory profiling
- [ ] Expanded NIP coverage as the protocol evolves
- [ ] UI refinements based on community feedback
- [ ] Security audits and hardening
---
## Tech Stack
| Component | Technology |
|-----------|-----------|
| Language | Kotlin 2.0 |
| UI Framework | Jetpack Compose (Material 3) |
| Networking | OkHttp 4 (WebSocket) |
| Image Loading | Coil 3 |
| Serialization | kotlinx.serialization |
| Cryptography | secp256k1-kmp (Schnorr), Bouncy Castle (XChaCha20), Android Security Crypto (AES-GCM) |
| Navigation | Jetpack Navigation Compose |
| Media | Media3 / ExoPlayer |
| QR Codes | ZXing |
| Build | Gradle 8.7 / AGP 8.7 |
| Min SDK | Android 8.0 (API 26) |
| Target SDK | Android 15 (API 35) |
---
## License
Wisp is released under the [MIT License](LICENSE).
```
MIT License
Copyright (c) 2025 Barry Deen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
---
Built with care for the Nostr ecosystem.
@@ -149,9 +149,9 @@ fun WispNavHost() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val tabRoutes = setOf(Routes.FEED, Routes.SEARCH, Routes.DM_LIST, Routes.NOTIFICATIONS)
val hideBottomBarRoutes = setOf(Routes.AUTH, Routes.ONBOARDING_PROFILE, Routes.ONBOARDING_SUGGESTIONS)
val showBottomBar by remember(currentRoute) {
derivedStateOf { currentRoute in tabRoutes }
derivedStateOf { currentRoute != null && currentRoute !in hideBottomBarRoutes }
}
val newNoteCount by feedViewModel.newNoteCount.collectAsState()
@@ -371,7 +371,7 @@ fun WispNavHost() {
onToggleFollow = { pubkey -> feedViewModel.toggleFollow(pubkey) },
isOwnProfile = isOwnProfile,
onEditProfile = {
profileViewModel.loadCurrentProfile(feedViewModel.eventRepo)
profileViewModel.loadCurrentProfile(feedViewModel.eventRepo, feedViewModel.relayPool)
navController.navigate(Routes.PROFILE_EDIT)
},
isBlocked = pubkey in isBlockedState,
@@ -34,6 +34,17 @@ object Nip57 {
}
}
fun getZapMessage(event: NostrEvent): String {
val description = event.tags.firstOrNull { it.size >= 2 && it[0] == "description" }?.get(1)
?: return ""
return try {
val zapRequest = NostrEvent.fromJson(description)
zapRequest.content
} catch (_: Exception) {
""
}
}
fun getZapAmountSats(event: NostrEvent): Long {
val bolt11 = event.tags.firstOrNull { it.size >= 2 && it[0] == "bolt11" }?.get(1)
?: return 0
@@ -1,51 +1,50 @@
package com.wisp.app.nostr
sealed class NotificationItem {
abstract val id: String
abstract val senderPubkey: String
abstract val createdAt: Long
abstract val referencedEventId: String?
data class ZapEntry(
val pubkey: String,
val sats: Long,
val message: String,
val createdAt: Long
)
data class Reaction(
override val id: String,
override val senderPubkey: String,
override val createdAt: Long,
override val referencedEventId: String?,
val emoji: String
) : NotificationItem()
sealed class NotificationGroup {
abstract val groupId: String
abstract val latestTimestamp: Long
data class Reply(
override val id: String,
override val senderPubkey: String,
override val createdAt: Long,
override val referencedEventId: String?,
data class ReactionGroup(
override val groupId: String,
val referencedEventId: String,
val reactions: Map<String, List<String>>, // emoji -> list of pubkeys
override val latestTimestamp: Long
) : NotificationGroup()
data class ZapGroup(
override val groupId: String,
val referencedEventId: String,
val zaps: List<ZapEntry>,
val totalSats: Long,
override val latestTimestamp: Long
) : NotificationGroup()
data class ReplyNotification(
override val groupId: String,
val senderPubkey: String,
val replyEventId: String,
val contentPreview: String
) : NotificationItem()
val referencedEventId: String?,
override val latestTimestamp: Long
) : NotificationGroup()
data class Zap(
override val id: String,
override val senderPubkey: String,
override val createdAt: Long,
override val referencedEventId: String?,
val amountSats: Long
) : NotificationItem()
data class QuoteNotification(
override val groupId: String,
val senderPubkey: String,
val quoteEventId: String,
override val latestTimestamp: Long
) : NotificationGroup()
data class Quote(
override val id: String,
override val senderPubkey: String,
override val createdAt: Long,
override val referencedEventId: String?,
val contentPreview: String,
val quotedEventId: String
) : NotificationItem()
data class Mention(
override val id: String,
override val senderPubkey: String,
override val createdAt: Long,
override val referencedEventId: String?,
val contentPreview: String,
val eventId: String
) : NotificationItem()
data class MentionNotification(
override val groupId: String,
val senderPubkey: String,
val eventId: String,
override val latestTimestamp: Long
) : NotificationGroup()
}
@@ -11,6 +11,8 @@ import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit
data class RelayFailure(val relayUrl: String, val httpCode: Int?, val message: String)
class Relay(
val config: RelayConfig,
private val client: OkHttpClient
@@ -19,6 +21,7 @@ class Relay(
var isConnected = false
private set
var autoReconnect = true
@Volatile var cooldownUntil: Long = 0L
private val _messages = MutableSharedFlow<RelayMessage>(extraBufferCapacity = 512)
val messages: SharedFlow<RelayMessage> = _messages
@@ -29,6 +32,9 @@ class Relay(
private val _connectionErrors = MutableSharedFlow<ConsoleLogEntry>(extraBufferCapacity = 16)
val connectionErrors: SharedFlow<ConsoleLogEntry> = _connectionErrors
private val _failures = MutableSharedFlow<RelayFailure>(extraBufferCapacity = 16)
val failures: SharedFlow<RelayFailure> = _failures
fun connect() {
if (isConnected) return
val request = try {
@@ -57,6 +63,7 @@ class Relay(
type = ConsoleLogType.CONN_FAILURE,
message = t.message ?: "Unknown error"
))
_failures.tryEmit(RelayFailure(config.url, response?.code, t.message ?: "Unknown error"))
reconnect()
}
@@ -88,10 +95,12 @@ class Relay(
private fun reconnect() {
webSocket = null
if (!autoReconnect) return
// Simple reconnect after a delay using OkHttp's thread pool
// Reconnect after cooldown delay using OkHttp's thread pool
client.dispatcher.executorService.execute {
try {
Thread.sleep(3000)
val now = System.currentTimeMillis()
val sleepMs = maxOf(3000L, cooldownUntil - now)
Thread.sleep(sleepMs)
if (!isConnected) connect()
} catch (_: InterruptedException) {}
}
@@ -1,5 +1,6 @@
package com.wisp.app.relay
import android.util.Log
import android.util.LruCache
import com.wisp.app.nostr.ClientMessage
import com.wisp.app.nostr.NostrEvent
@@ -25,13 +26,15 @@ class RelayPool {
private val ephemeralRelays = java.util.concurrent.ConcurrentHashMap<String, Relay>()
private val ephemeralLastUsed = java.util.concurrent.ConcurrentHashMap<String, Long>()
private val relayCooldowns = java.util.concurrent.ConcurrentHashMap<String, Long>()
private val COOLDOWN_MS = 5 * 60 * 1000L // 5 minutes
private var blockedUrls = emptySet<String>()
companion object {
const val MAX_PERSISTENT = 50
const val MAX_EPHEMERAL = 30
const val COOLDOWN_DOWN_MS = 10 * 60 * 1000L // 10 min — 5xx, connection failures, DNS errors
const val COOLDOWN_REJECTED_MS = 1 * 60 * 1000L // 1 min — 4xx like 401/403/429
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val seenEvents = LruCache<String, Boolean>(5000)
private val seenLock = Any()
@@ -126,7 +129,8 @@ class RelayPool {
// since events may already have been seen during feed loading
val bypassDedup = msg.subscriptionId.startsWith("thread-") ||
msg.subscriptionId.startsWith("user") ||
msg.subscriptionId.startsWith("quote-")
msg.subscriptionId.startsWith("quote-") ||
msg.subscriptionId == "editprofile"
val shouldEmit = if (bypassDedup) {
true
} else {
@@ -172,6 +176,7 @@ class RelayPool {
scope.launch {
relay.connectionState.collect { updateConnectedCount() }
}
collectRelayFailures(relay)
}
private fun updateConnectedCount() {
@@ -238,7 +243,6 @@ class RelayPool {
val relay = Relay(RelayConfig(url, read = true, write = false), client)
relay.autoReconnect = false
collectMessages(relay)
collectEphemeralFailures(relay)
relay.connect()
relay
}
@@ -247,19 +251,62 @@ class RelayPool {
return true
}
private fun collectEphemeralFailures(relay: Relay) {
private fun cooldownForFailure(httpCode: Int?): Long {
return if (httpCode != null && httpCode in 400..499) COOLDOWN_REJECTED_MS else COOLDOWN_DOWN_MS
}
private fun collectRelayFailures(relay: Relay) {
scope.launch {
relay.connectionState.collect { connected ->
if (!connected) {
relayCooldowns[relay.config.url] = System.currentTimeMillis() + COOLDOWN_MS
// Clean up the dead ephemeral relay
relay.failures.collect { failure ->
val cooldownMs = cooldownForFailure(failure.httpCode)
val until = System.currentTimeMillis() + cooldownMs
// Set cooldownUntil on the relay itself (throttles auto-reconnect delay)
relay.cooldownUntil = until
// Only set relayCooldowns map entry for ephemeral relays
// (this map gates sendToRelayOrEphemeral — persistent/DM relays shouldn't be gated)
val isEphemeral = ephemeralRelays.containsKey(relay.config.url)
if (isEphemeral) {
relayCooldowns[relay.config.url] = until
ephemeralRelays.remove(relay.config.url)
ephemeralLastUsed.remove(relay.config.url)
}
Log.d("RelayPool", "Cooldown ${cooldownMs / 1000}s for ${relay.config.url} (http=${failure.httpCode}, ephemeral=$isEphemeral)")
}
}
}
fun reconnectAll(): Int {
var count = 0
// Reconnect disconnected persistent relays — clear cooldowns since this is explicit resume
for (relay in relays) {
if (!relay.isConnected) {
relay.cooldownUntil = 0L
relayCooldowns.remove(relay.config.url)
Log.d("RelayPool", "Reconnecting persistent relay: ${relay.config.url}")
relay.connect()
count++
}
}
// Reconnect disconnected DM relays
for (relay in dmRelays) {
if (!relay.isConnected) {
relay.cooldownUntil = 0L
relayCooldowns.remove(relay.config.url)
Log.d("RelayPool", "Reconnecting DM relay: ${relay.config.url}")
relay.connect()
count++
}
}
// Clean up dead ephemeral relays so they can be recreated fresh
val deadEphemerals = ephemeralRelays.filter { !it.value.isConnected }.keys
for (url in deadEphemerals) {
ephemeralRelays.remove(url)?.disconnect()
ephemeralLastUsed.remove(url)
}
if (count > 0) Log.d("RelayPool", "reconnectAll: $count relays reconnecting")
return count
}
fun closeOnAllRelays(subscriptionId: String) {
val msg = ClientMessage.close(subscriptionId)
for (relay in relays) relay.send(msg)
@@ -4,43 +4,40 @@ import android.util.LruCache
import com.wisp.app.nostr.Nip10
import com.wisp.app.nostr.Nip57
import com.wisp.app.nostr.NostrEvent
import com.wisp.app.nostr.NotificationItem
import com.wisp.app.nostr.NotificationGroup
import com.wisp.app.nostr.ZapEntry
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class NotificationRepository {
private val seenEvents = LruCache<String, Boolean>(2000)
private val _notifications = MutableStateFlow<List<NotificationItem>>(emptyList())
val notifications: StateFlow<List<NotificationItem>> = _notifications
private val groupMap = mutableMapOf<String, NotificationGroup>()
private val _notifications = MutableStateFlow<List<NotificationGroup>>(emptyList())
val notifications: StateFlow<List<NotificationGroup>> = _notifications
private val _hasUnread = MutableStateFlow(false)
val hasUnread: StateFlow<Boolean> = _hasUnread
fun addEvent(event: NostrEvent, myPubkey: String) {
// Filter out own activity
if (event.pubkey == myPubkey) return
// Dedup
if (seenEvents.get(event.id) != null) return
// Validate p-tag references myPubkey
val hasPTag = event.tags.any { it.size >= 2 && it[0] == "p" && it[1] == myPubkey }
if (!hasPTag) return
seenEvents.put(event.id, true)
val item = when (event.kind) {
7 -> buildReaction(event)
1 -> buildKind1(event)
9735 -> buildZap(event, myPubkey)
else -> null
} ?: return
val merged = when (event.kind) {
7 -> mergeReaction(event)
1 -> mergeKind1(event)
9735 -> mergeZap(event)
else -> false
}
if (!merged) return
_hasUnread.value = true
val current = _notifications.value.toMutableList()
// Insert in sorted position (newest first by event timestamp)
val insertIndex = current.indexOfFirst { it.createdAt < item.createdAt }
if (insertIndex == -1) current.add(item) else current.add(insertIndex, item)
_notifications.value = if (current.size > 200) current.take(200) else current
rebuildSortedList()
}
fun markRead() {
@@ -49,85 +46,151 @@ class NotificationRepository {
fun clear() {
seenEvents.evictAll()
groupMap.clear()
_notifications.value = emptyList()
_hasUnread.value = false
}
fun purgeUser(pubkey: String) {
_notifications.value = _notifications.value.filter { it.senderPubkey != pubkey }
val toRemove = mutableListOf<String>()
val toUpdate = mutableMapOf<String, NotificationGroup>()
for ((key, group) in groupMap) {
when (group) {
is NotificationGroup.ReactionGroup -> {
val filtered = group.reactions.mapValues { (_, pks) -> pks.filter { it != pubkey } }
.filter { it.value.isNotEmpty() }
if (filtered.isEmpty()) toRemove.add(key)
else toUpdate[key] = group.copy(reactions = filtered)
}
is NotificationGroup.ZapGroup -> {
val filtered = group.zaps.filter { it.pubkey != pubkey }
if (filtered.isEmpty()) toRemove.add(key)
else toUpdate[key] = group.copy(
zaps = filtered,
totalSats = filtered.sumOf { it.sats }
)
}
is NotificationGroup.ReplyNotification -> {
if (group.senderPubkey == pubkey) toRemove.add(key)
}
is NotificationGroup.QuoteNotification -> {
if (group.senderPubkey == pubkey) toRemove.add(key)
}
is NotificationGroup.MentionNotification -> {
if (group.senderPubkey == pubkey) toRemove.add(key)
}
}
}
toRemove.forEach { groupMap.remove(it) }
toUpdate.forEach { (k, v) -> groupMap[k] = v }
if (toRemove.isNotEmpty() || toUpdate.isNotEmpty()) {
rebuildSortedList()
}
}
private fun buildReaction(event: NostrEvent): NotificationItem.Reaction {
private fun rebuildSortedList() {
val sorted = groupMap.values.sortedByDescending { it.latestTimestamp }
_notifications.value = if (sorted.size > 200) sorted.take(200) else sorted
}
private fun mergeReaction(event: NostrEvent): Boolean {
val emoji = event.content.ifBlank { "+" }
val referencedId = event.tags.lastOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
return NotificationItem.Reaction(
id = event.id,
senderPubkey = event.pubkey,
createdAt = event.created_at,
referencedEventId = referencedId,
emoji = emoji
)
?: return false
val key = "reactions:$referencedId"
val existing = groupMap[key] as? NotificationGroup.ReactionGroup
if (existing != null) {
val currentPubkeys = existing.reactions[emoji] ?: emptyList()
if (event.pubkey in currentPubkeys) return false
val updatedReactions = existing.reactions.toMutableMap()
updatedReactions[emoji] = currentPubkeys + event.pubkey
groupMap[key] = existing.copy(
reactions = updatedReactions,
latestTimestamp = maxOf(existing.latestTimestamp, event.created_at)
)
} else {
groupMap[key] = NotificationGroup.ReactionGroup(
groupId = key,
referencedEventId = referencedId,
reactions = mapOf(emoji to listOf(event.pubkey)),
latestTimestamp = event.created_at
)
}
return true
}
private fun buildKind1(event: NostrEvent): NotificationItem? {
// Check for quote (q tag) first
val quotedId = event.tags.firstOrNull { it.size >= 2 && it[0] == "q" }?.get(1)
if (quotedId != null) return buildQuote(event, quotedId)
// Check for reply
val replyTarget = Nip10.getReplyTarget(event)
if (replyTarget != null) return buildReply(event, replyTarget)
// Otherwise it's a mention
return buildMention(event)
}
private fun buildReply(event: NostrEvent, replyTarget: String): NotificationItem.Reply {
return NotificationItem.Reply(
id = event.id,
senderPubkey = event.pubkey,
createdAt = event.created_at,
referencedEventId = replyTarget,
replyEventId = event.id,
contentPreview = event.content.take(120)
)
}
private fun buildQuote(event: NostrEvent, quotedEventId: String): NotificationItem.Quote {
return NotificationItem.Quote(
id = event.id,
senderPubkey = event.pubkey,
createdAt = event.created_at,
referencedEventId = quotedEventId,
contentPreview = event.content.take(120),
quotedEventId = quotedEventId
)
}
private fun buildMention(event: NostrEvent): NotificationItem.Mention {
return NotificationItem.Mention(
id = event.id,
senderPubkey = event.pubkey,
createdAt = event.created_at,
referencedEventId = null,
contentPreview = event.content.take(120),
eventId = event.id
)
}
private fun buildZap(event: NostrEvent, myPubkey: String): NotificationItem.Zap? {
private fun mergeZap(event: NostrEvent): Boolean {
val amount = Nip57.getZapAmountSats(event)
if (amount <= 0) return null
// Zapper pubkey from uppercase P tag
val zapperPubkey = event.tags.firstOrNull { it.size >= 2 && it[0] == "P" }?.get(1)
?: event.pubkey
val referencedId = Nip57.getZappedEventId(event)
return NotificationItem.Zap(
id = event.id,
senderPubkey = zapperPubkey,
createdAt = event.created_at,
referencedEventId = referencedId,
amountSats = amount
if (amount <= 0) return false
val zapperPubkey = Nip57.getZapperPubkey(event) ?: return false
val referencedId = Nip57.getZappedEventId(event) ?: return false
val key = "zaps:$referencedId"
val message = Nip57.getZapMessage(event)
val entry = ZapEntry(pubkey = zapperPubkey, sats = amount, message = message, createdAt = event.created_at)
val existing = groupMap[key] as? NotificationGroup.ZapGroup
if (existing != null) {
groupMap[key] = existing.copy(
zaps = existing.zaps + entry,
totalSats = existing.totalSats + amount,
latestTimestamp = maxOf(existing.latestTimestamp, event.created_at)
)
} else {
groupMap[key] = NotificationGroup.ZapGroup(
groupId = key,
referencedEventId = referencedId,
zaps = listOf(entry),
totalSats = amount,
latestTimestamp = event.created_at
)
}
return true
}
private fun mergeKind1(event: NostrEvent): Boolean {
val quotedId = event.tags.firstOrNull { it.size >= 2 && it[0] == "q" }?.get(1)
if (quotedId != null) return mergeQuote(event, quotedId)
val replyTarget = Nip10.getReplyTarget(event)
if (replyTarget != null) return mergeReply(event, replyTarget)
return mergeMention(event)
}
private fun mergeReply(event: NostrEvent, replyTarget: String): Boolean {
val key = "reply:${event.id}"
groupMap[key] = NotificationGroup.ReplyNotification(
groupId = key,
senderPubkey = event.pubkey,
replyEventId = event.id,
referencedEventId = replyTarget,
latestTimestamp = event.created_at
)
return true
}
private fun mergeQuote(event: NostrEvent, quotedEventId: String): Boolean {
val key = "quote:${event.id}"
groupMap[key] = NotificationGroup.QuoteNotification(
groupId = key,
senderPubkey = event.pubkey,
quoteEventId = event.id,
latestTimestamp = event.created_at
)
return true
}
private fun mergeMention(event: NostrEvent): Boolean {
val key = "mention:${event.id}"
groupMap[key] = NotificationGroup.MentionNotification(
groupId = key,
senderPubkey = event.pubkey,
eventId = event.id,
latestTimestamp = event.created_at
)
return true
}
}
@@ -191,7 +191,7 @@ fun RichContent(
}
@Composable
private fun QuotedNote(eventId: String, eventRepo: EventRepository, relayHints: List<String> = emptyList(), onNoteClick: ((String) -> Unit)? = null) {
fun QuotedNote(eventId: String, eventRepo: EventRepository, relayHints: List<String> = emptyList(), onNoteClick: ((String) -> Unit)? = null) {
// Observe version so we recompose when quoted events arrive from relays
val version by eventRepo.quotedEventVersion.collectAsState()
val event = remember(eventId, version) { eventRepo.getEvent(eventId) }
@@ -21,13 +21,19 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.wisp.app.nostr.NotificationItem
import com.wisp.app.nostr.NotificationGroup
import com.wisp.app.nostr.ProfileData
import com.wisp.app.nostr.ZapEntry
import com.wisp.app.repo.EventRepository
import com.wisp.app.ui.component.ProfilePicture
import com.wisp.app.ui.component.QuotedNote
import com.wisp.app.ui.component.RichContent
import com.wisp.app.ui.component.StackedAvatarRow
import com.wisp.app.viewmodel.NotificationsViewModel
import java.text.SimpleDateFormat
import java.util.Date
@@ -41,6 +47,7 @@ fun NotificationsScreen(
onProfileClick: (String) -> Unit
) {
val notifications by viewModel.notifications.collectAsState()
val eventRepo = viewModel.eventRepository
Scaffold(
topBar = {
@@ -73,14 +80,43 @@ fun NotificationsScreen(
.fillMaxSize()
.padding(padding)
) {
items(items = notifications, key = { it.id }) { item ->
val profile = viewModel.getProfileData(item.senderPubkey)
NotificationRow(
item = item,
profile = profile,
onNoteClick = onNoteClick,
onProfileClick = onProfileClick
)
items(items = notifications, key = { it.groupId }) { group ->
when (group) {
is NotificationGroup.ReactionGroup -> ReactionGroupRow(
group = group,
eventRepo = eventRepo,
resolveProfile = { viewModel.getProfileData(it) },
onNoteClick = onNoteClick,
onProfileClick = onProfileClick
)
is NotificationGroup.ZapGroup -> ZapGroupRow(
group = group,
eventRepo = eventRepo,
resolveProfile = { viewModel.getProfileData(it) },
onNoteClick = onNoteClick,
onProfileClick = onProfileClick
)
is NotificationGroup.ReplyNotification -> ReplyNotificationRow(
item = group,
eventRepo = eventRepo,
onNoteClick = onNoteClick,
onProfileClick = onProfileClick
)
is NotificationGroup.QuoteNotification -> QuoteNotificationRow(
item = group,
eventRepo = eventRepo,
resolveProfile = { viewModel.getProfileData(it) },
onNoteClick = onNoteClick,
onProfileClick = onProfileClick
)
is NotificationGroup.MentionNotification -> MentionNotificationRow(
item = group,
eventRepo = eventRepo,
resolveProfile = { viewModel.getProfileData(it) },
onNoteClick = onNoteClick,
onProfileClick = onProfileClick
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = 0.5.dp)
}
}
@@ -88,68 +124,368 @@ fun NotificationsScreen(
}
}
// ── Reaction Group ──────────────────────────────────────────────────────
// Each emoji on its own row: <emoji> <stacked avatars of that emoji's reactors>
// Then the referenced note rendered inline.
@Composable
private fun NotificationRow(
item: NotificationItem,
profile: ProfileData?,
private fun ReactionGroupRow(
group: NotificationGroup.ReactionGroup,
eventRepo: EventRepository?,
resolveProfile: (String) -> ProfileData?,
onNoteClick: (String) -> Unit,
onProfileClick: (String) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onNoteClick(group.referencedEventId) }
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
// Timestamp on top-right
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(Modifier.weight(1f))
Text(
text = formatNotifTimestamp(group.latestTimestamp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Each emoji row: <emoji> <avatars>
group.reactions.forEach { (emoji, pubkeys) ->
val displayEmoji = if (emoji == "+") "\u2764\uFE0F" else emoji
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = displayEmoji,
style = MaterialTheme.typography.bodyLarge
)
Spacer(Modifier.width(8.dp))
StackedAvatarRow(
pubkeys = pubkeys,
resolveProfile = resolveProfile,
onProfileClick = onProfileClick
)
}
}
// Inline referenced note
if (eventRepo != null) {
QuotedNote(
eventId = group.referencedEventId,
eventRepo = eventRepo,
onNoteClick = onNoteClick
)
}
}
}
// ── Zap Group ───────────────────────────────────────────────────────────
// Each zap on its own row (most recent first): <zap icon> <amount> <avatar> <message>
// Then the referenced note rendered inline.
@Composable
private fun ZapGroupRow(
group: NotificationGroup.ZapGroup,
eventRepo: EventRepository?,
resolveProfile: (String) -> ProfileData?,
onNoteClick: (String) -> Unit,
onProfileClick: (String) -> Unit
) {
val sortedZaps = remember(group.zaps) { group.zaps.sortedByDescending { it.createdAt } }
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onNoteClick(group.referencedEventId) }
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
// Header with total + timestamp
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\u26A1 ${formatSats(group.totalSats)} total",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.tertiary
)
Spacer(Modifier.weight(1f))
Text(
text = formatNotifTimestamp(group.latestTimestamp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(4.dp))
// Each zap row: <zap icon> <amount> <avatar> <name> <message>
sortedZaps.forEach { zap ->
ZapEntryRow(
zap = zap,
profile = resolveProfile(zap.pubkey),
onProfileClick = onProfileClick
)
}
// Inline referenced note
if (eventRepo != null) {
QuotedNote(
eventId = group.referencedEventId,
eventRepo = eventRepo,
onNoteClick = onNoteClick
)
}
}
}
@Composable
private fun ZapEntryRow(
zap: ZapEntry,
profile: ProfileData?,
onProfileClick: (String) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\u26A1",
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.width(4.dp))
Text(
text = formatSats(zap.sats),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.tertiary
)
Spacer(Modifier.width(8.dp))
ProfilePicture(
url = profile?.picture,
size = 24,
modifier = Modifier.clickable { onProfileClick(zap.pubkey) }
)
Spacer(Modifier.width(6.dp))
if (zap.message.isNotBlank()) {
Text(
text = zap.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
} else {
val name = profile?.displayString
?: zap.pubkey.take(8) + "..."
Text(
text = name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
}
}
// ── Reply ───────────────────────────────────────────────────────────────
// Render the reply event directly inline (like a feed note) — no QuotedNote container.
@Composable
private fun ReplyNotificationRow(
item: NotificationGroup.ReplyNotification,
eventRepo: EventRepository?,
onNoteClick: (String) -> Unit,
onProfileClick: (String) -> Unit
) {
if (eventRepo == null) return
val version by eventRepo.quotedEventVersion.collectAsState()
val event = remember(item.replyEventId, version) { eventRepo.getEvent(item.replyEventId) }
val profile = remember(event, version) { event?.let { eventRepo.getProfileData(it.pubkey) } }
val displayName = profile?.displayString
?: item.senderPubkey.take(8) + "..." + item.senderPubkey.takeLast(4)
val description = when (item) {
is NotificationItem.Reaction -> {
val emoji = if (item.emoji == "+") "\u2764\uFE0F" else item.emoji
"$emoji reacted to your note"
}
is NotificationItem.Reply -> "replied: ${item.contentPreview}"
is NotificationItem.Zap -> "\u26A1 zapped ${item.amountSats} sats"
is NotificationItem.Quote -> "quoted your note: ${item.contentPreview}"
is NotificationItem.Mention -> "mentioned you: ${item.contentPreview}"
}
Row(
verticalAlignment = Alignment.CenterVertically,
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
val eventId = when (item) {
is NotificationItem.Reply -> item.replyEventId
is NotificationItem.Quote -> item.id
is NotificationItem.Mention -> item.eventId
else -> item.referencedEventId
}
if (eventId != null) onNoteClick(eventId)
}
.clickable { onNoteClick(item.replyEventId) }
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
ProfilePicture(
url = profile?.picture,
modifier = Modifier.clickable { onProfileClick(item.senderPubkey) }
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
// Author row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
ProfilePicture(
url = profile?.picture,
size = 34,
modifier = Modifier.clickable { onProfileClick(item.senderPubkey) }
)
Spacer(Modifier.width(10.dp))
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(4.dp))
Text(
text = description,
text = "replied",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(8.dp))
Text(
text = formatNotifTimestamp(item.latestTimestamp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Render reply content directly inline
if (event != null) {
Spacer(Modifier.height(6.dp))
RichContent(
content = event.content,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
eventRepo = eventRepo,
onProfileClick = onProfileClick,
onNoteClick = onNoteClick
)
}
}
}
// ── Quote ───────────────────────────────────────────────────────────────
@Composable
private fun QuoteNotificationRow(
item: NotificationGroup.QuoteNotification,
eventRepo: EventRepository?,
resolveProfile: (String) -> ProfileData?,
onNoteClick: (String) -> Unit,
onProfileClick: (String) -> Unit
) {
val profile = resolveProfile(item.senderPubkey)
val displayName = profile?.displayString
?: item.senderPubkey.take(8) + "..." + item.senderPubkey.takeLast(4)
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onNoteClick(item.quoteEventId) }
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
ProfilePicture(
url = profile?.picture,
size = 34,
modifier = Modifier.clickable { onProfileClick(item.senderPubkey) }
)
Spacer(Modifier.width(10.dp))
Text(
text = displayName,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(4.dp))
Text(
text = "quoted your note",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(8.dp))
Text(
text = formatNotifTimestamp(item.latestTimestamp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (eventRepo != null) {
QuotedNote(
eventId = item.quoteEventId,
eventRepo = eventRepo,
onNoteClick = onNoteClick
)
}
}
}
// ── Mention ─────────────────────────────────────────────────────────────
@Composable
private fun MentionNotificationRow(
item: NotificationGroup.MentionNotification,
eventRepo: EventRepository?,
resolveProfile: (String) -> ProfileData?,
onNoteClick: (String) -> Unit,
onProfileClick: (String) -> Unit
) {
val profile = resolveProfile(item.senderPubkey)
val displayName = profile?.displayString
?: item.senderPubkey.take(8) + "..." + item.senderPubkey.takeLast(4)
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onNoteClick(item.eventId) }
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
ProfilePicture(
url = profile?.picture,
size = 34,
modifier = Modifier.clickable { onProfileClick(item.senderPubkey) }
)
Spacer(Modifier.width(10.dp))
Text(
text = displayName,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(4.dp))
Text(
text = "mentioned you",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(8.dp))
Text(
text = formatNotifTimestamp(item.latestTimestamp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (eventRepo != null) {
QuotedNote(
eventId = item.eventId,
eventRepo = eventRepo,
onNoteClick = onNoteClick
)
}
Text(
text = formatNotifTimestamp(item.createdAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -159,3 +495,9 @@ private fun formatNotifTimestamp(epoch: Long): String {
if (epoch == 0L) return ""
return notifTimeFormat.format(Date(epoch * 1000))
}
private fun formatSats(sats: Long): String = when {
sats >= 1_000_000 -> "${sats / 1_000_000}M sats"
sats >= 1_000 -> "${sats / 1_000}K sats"
else -> "$sats sats"
}
@@ -74,8 +74,15 @@ import com.wisp.app.ui.component.QrCodeDialog
import com.wisp.app.ui.component.ProfilePicture
import com.wisp.app.ui.component.ZapDialog
import com.wisp.app.viewmodel.UserProfileViewModel
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
@@ -212,18 +219,39 @@ fun UserProfileScreen(
}
},
actions = {
val context = LocalContext.current
IconButton(onClick = { showQrDialog = true }) {
Icon(Icons.Default.QrCode2, "QR Code")
}
if (!isOwnProfile) {
var menuExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, "More options")
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
var menuExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, "More options")
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
DropdownMenuItem(
text = { Text("Copy Profile JSON") },
onClick = {
menuExpanded = false
profile?.let { p ->
val json = buildJsonObject {
p.name?.let { put("name", it) }
p.displayName?.let { put("display_name", it) }
p.about?.let { put("about", it) }
p.picture?.let { put("picture", it) }
p.banner?.let { put("banner", it) }
p.nip05?.let { put("nip05", it) }
p.lud16?.let { put("lud16", it) }
}.toString()
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Profile JSON", json))
Toast.makeText(context, "Profile JSON copied", Toast.LENGTH_SHORT).show()
}
}
)
if (!isOwnProfile) {
DropdownMenuItem(
text = { Text("Add to List") },
onClick = {
@@ -287,12 +287,13 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
if (muteRepo.isBlocked(event.pubkey)) return
val myPubkey = getUserPubkey()
if (myPubkey != null) {
eventRepo.cacheEvent(event)
notifRepo.addEvent(event, myPubkey)
if (eventRepo.getProfileData(event.pubkey) == null) {
metadataFetcher.addToPendingProfiles(event.pubkey)
}
if (event.kind == 9735) {
val zapperPubkey = event.tags.firstOrNull { it.size >= 2 && it[0] == "P" }?.get(1)
val zapperPubkey = Nip57.getZapperPubkey(event)
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey)
}
@@ -422,8 +423,12 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
fun onAppResume() {
if (!relaysInitialized) return
subscribeFeed()
fetchRelayListsForFollows()
val reconnected = relayPool.reconnectAll()
viewModelScope.launch {
if (reconnected > 0) delay(2000) // Give WebSockets time to establish
subscribeFeed()
fetchRelayListsForFollows()
}
}
private fun subscribeFeed() {
@@ -2,7 +2,7 @@ package com.wisp.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.wisp.app.nostr.NotificationItem
import com.wisp.app.nostr.NotificationGroup
import com.wisp.app.nostr.ProfileData
import com.wisp.app.repo.EventRepository
import com.wisp.app.repo.NotificationRepository
@@ -11,12 +11,15 @@ import kotlinx.coroutines.flow.StateFlow
class NotificationsViewModel(app: Application) : AndroidViewModel(app) {
val notifications: StateFlow<List<NotificationItem>>
val notifications: StateFlow<List<NotificationGroup>>
get() = notifRepo?.notifications ?: MutableStateFlow(emptyList())
val hasUnread: StateFlow<Boolean>
get() = notifRepo?.hasUnread ?: MutableStateFlow(false)
val eventRepository: EventRepository?
get() = eventRepo
private var notifRepo: NotificationRepository? = null
private var eventRepo: EventRepository? = null
@@ -7,11 +7,13 @@ import android.webkit.MimeTypeMap
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.wisp.app.nostr.ClientMessage
import com.wisp.app.nostr.Filter
import com.wisp.app.nostr.NostrEvent
import com.wisp.app.relay.RelayPool
import com.wisp.app.repo.BlossomRepository
import com.wisp.app.repo.EventRepository
import com.wisp.app.repo.KeyRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -56,6 +58,8 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) {
private val _uploading = MutableStateFlow<String?>(null)
val uploading: StateFlow<String?> = _uploading
private var refreshJob: Job? = null
fun uploadImage(contentResolver: ContentResolver, uri: Uri, target: ImageTarget) {
viewModelScope.launch {
try {
@@ -82,16 +86,44 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) {
enum class ImageTarget { PICTURE, BANNER }
fun loadCurrentProfile(eventRepo: EventRepository) {
fun loadCurrentProfile(eventRepo: EventRepository, relayPool: RelayPool? = null) {
val keypair = keyRepo.getKeypair() ?: return
val pubkeyHex = keypair.pubkey.joinToString("") { "%02x".format(it) }
val profile = eventRepo.getProfileData(pubkeyHex) ?: return
_name.value = profile.name ?: ""
_about.value = profile.about ?: ""
_picture.value = profile.picture ?: ""
_nip05.value = profile.nip05 ?: ""
_banner.value = profile.banner ?: ""
_lud16.value = profile.lud16 ?: ""
// Load from cache immediately
val profile = eventRepo.getProfileData(pubkeyHex)
if (profile != null) {
_name.value = profile.name ?: ""
_about.value = profile.about ?: ""
_picture.value = profile.picture ?: ""
_nip05.value = profile.nip05 ?: ""
_banner.value = profile.banner ?: ""
_lud16.value = profile.lud16 ?: ""
}
// Request fresh profile from relays
if (relayPool == null) return
val subId = "editprofile"
relayPool.closeOnAllRelays(subId)
val filter = Filter(kinds = listOf(0), authors = listOf(pubkeyHex), limit = 1)
relayPool.sendToAll(ClientMessage.req(subId, filter))
refreshJob?.cancel()
refreshJob = viewModelScope.launch {
relayPool.relayEvents.collect { (event, _, subscriptionId) ->
if (subscriptionId != subId) return@collect
if (event.kind == 0 && event.pubkey == pubkeyHex) {
eventRepo.addEvent(event)
val updated = eventRepo.getProfileData(pubkeyHex) ?: return@collect
_name.value = updated.name ?: ""
_about.value = updated.about ?: ""
_picture.value = updated.picture ?: ""
_nip05.value = updated.nip05 ?: ""
_banner.value = updated.banner ?: ""
_lud16.value = updated.lud16 ?: ""
}
}
}
}
fun publishProfile(relayPool: RelayPool): Boolean {