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:
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user