mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
2 Commits
c79b795477
...
claude/ame
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb50866650 | ||
|
|
8220f4155b |
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.home.isHomeFeedRenderableKind
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -32,29 +32,17 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
|
||||
class HomeNewThreadFeedFilter(
|
||||
@@ -124,33 +112,7 @@ class HomeNewThreadFeedFilter(
|
||||
filterParams: FilterByListParams,
|
||||
): Boolean {
|
||||
val noteEvent = it.event
|
||||
return (
|
||||
noteEvent is TextNoteEvent ||
|
||||
noteEvent is ClassifiedsEvent ||
|
||||
noteEvent is FundraiserEvent ||
|
||||
noteEvent is BirdexEvent ||
|
||||
noteEvent.isRenderableRepost() ||
|
||||
(noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) ||
|
||||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
|
||||
noteEvent is ZapPollEvent ||
|
||||
noteEvent is PollEvent ||
|
||||
noteEvent is HighlightEvent ||
|
||||
noteEvent is InteractiveStoryPrologueEvent ||
|
||||
noteEvent is CommentEvent ||
|
||||
noteEvent is AudioTrackEvent ||
|
||||
noteEvent is MusicTrackEvent ||
|
||||
noteEvent is MusicPlaylistEvent ||
|
||||
noteEvent is PodcastEpisodeEvent ||
|
||||
noteEvent is PodcastMetadataEvent ||
|
||||
noteEvent is VoiceEvent ||
|
||||
noteEvent is AudioHeaderEvent ||
|
||||
noteEvent is ChessGameEvent ||
|
||||
noteEvent is LiveChessGameEndEvent ||
|
||||
noteEvent is AttestationEvent ||
|
||||
noteEvent is AttestationRequestEvent ||
|
||||
noteEvent is AttestorRecommendationEvent ||
|
||||
noteEvent is AttestorProficiencyEvent
|
||||
) &&
|
||||
return noteEvent.isHomeFeedRenderableKind() &&
|
||||
filterParams.match(noteEvent, it.relays) &&
|
||||
it.isNewThread()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.notifications.NotificationFeedKinds
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -37,48 +38,33 @@ import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -118,34 +104,7 @@ class NotificationFeedFilter(
|
||||
AttestationRequestEvent.KIND,
|
||||
)
|
||||
|
||||
val NOTIFICATION_KINDS =
|
||||
setOf(
|
||||
BadgeAwardEvent.KIND,
|
||||
ChannelMessageEvent.KIND,
|
||||
ChatMessageEvent.KIND,
|
||||
ChatMessageEncryptedFileHeaderEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
GitIssueEvent.KIND,
|
||||
GitPatchEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
TextNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
NutzapEvent.KIND,
|
||||
OnchainZapEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
PictureEvent.KIND,
|
||||
PollEvent.KIND,
|
||||
ZapPollEvent.KIND,
|
||||
PrivateDmEvent.KIND,
|
||||
PublicMessageEvent.KIND,
|
||||
VideoNormalEvent.KIND,
|
||||
VideoShortEvent.KIND,
|
||||
VoiceEvent.KIND,
|
||||
VoiceReplyEvent.KIND,
|
||||
) + ADDRESSABLE_KINDS
|
||||
val NOTIFICATION_KINDS = NotificationFeedKinds
|
||||
|
||||
// Shared with EventNotificationConsumer so push notifications and the
|
||||
// in-app feed apply the same per-kind "is this event for me" rule.
|
||||
|
||||
@@ -79,6 +79,30 @@ rejected_by: (none)
|
||||
`amy notes feed` reads recent kind:1 notes from your follows; `--limit N`
|
||||
caps the count, `--author npub1…` narrows to one user.
|
||||
|
||||
`amy notes home` is the full Amethyst home feed: top-level posts and reposts
|
||||
from everyone you follow, across the whole `HomeNewThreadFeedFilter` kind set,
|
||||
with muted authors and threads removed. By default it prints a snapshot page
|
||||
(one JSON object). Because relay events arrive at any time and a terminal can't
|
||||
re-sort lines it has already printed, the live view is opt-in: `--watch` prints
|
||||
the backfill page oldest-first and then streams each new event as its own JSONL
|
||||
line (`tail -f`-style) until `--duration SECS` elapses (default 60) or you
|
||||
Ctrl-C.
|
||||
|
||||
```text
|
||||
amy --json notes home --watch --duration 120 | jq -r '.content'
|
||||
```
|
||||
|
||||
`amy notifications` (alias `notifs`) is the Notifications feed: every event
|
||||
that p-tags you — reactions, reposts, zaps, replies, mentions, comments —
|
||||
across the full notification kind set, minus your own (zaps excepted) and muted
|
||||
authors. It reproduces Amethyst's *Global* notification mode with one
|
||||
`{"#p":[you], "kinds":[…]}` query against your inbox relays. Same snapshot /
|
||||
`--watch` model as `notes home`.
|
||||
|
||||
```text
|
||||
amy --json notifications --watch --duration 300 | jq -r '"\(.kind)\t\(.pubkey)"'
|
||||
```
|
||||
|
||||
### 2. Send a direct message
|
||||
|
||||
```text
|
||||
@@ -213,6 +237,8 @@ $ amy relay publish-lists # broadcast updated kind:10002/10050/10051
|
||||
|---|---|
|
||||
| `amy notes post TEXT [--relay URL]` | Publish a kind:1 short text note. |
|
||||
| `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). |
|
||||
| `amy notes home [--limit N] [--watch [--duration SECS]]` | Amethyst home feed: top-level posts/reposts from your follows (full kind set, mutes removed). Snapshot by default; `--watch` streams live events as JSONL. |
|
||||
| `amy notifications [--limit N] [--watch [--duration SECS]]` | Notifications feed: events that p-tag you (reactions, reposts, zaps, replies, mentions). Snapshot by default; `--watch` streams live events as JSONL. Alias: `notifs`. |
|
||||
| `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. |
|
||||
| `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. |
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| `await` polling (KP / group / member / admin / message / rename / epoch) | ✅ | `AwaitCommands` |
|
||||
| NIP-01 note publish (`amy notes post TEXT`) | ✅ | `PostCommand` — outbox via `RelayCommands` configured set. |
|
||||
| NIP-01 feed read (`amy notes feed [--following \| --author NPUB]`) | ✅ | `FeedCommand`. Hashtag / community feeds still pending. |
|
||||
| Home feed (`amy notes home [--watch]`) | ✅ | `HomeFeedCommand`. Full `HomeNewThreadFeedFilter` kind set + mutes, shared via `commons/.../ui/feeds/home/`. Snapshot + opt-in live JSONL tail. Plan: [`cli/plans/2026-06-19-home-feed.md`](./plans/2026-06-19-home-feed.md). Followed-hashtag/geo/community parity + repost-collapse still pending. |
|
||||
| NIP-02 follow list add / remove / list | 🆕 | Logic in `amethyst/model/nip02FollowLists/`. |
|
||||
| NIP-09 event deletion | 🆕 | Builder exists in quartz. |
|
||||
| NIP-17 DMs send / list / await | ✅ | `DmCommands` — reuses Quartz `NIP17Factory` + `RecipientRelayFetcher`; filter extracted to `commons/relayClient/nip17Dm/`. Plan: [`cli/plans/2026-04-23-nip17-dm.md`](./plans/2026-04-23-nip17-dm.md). |
|
||||
@@ -67,7 +68,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| NIP-46 bunker signer | 🆕 | Needs a signers abstraction in Amy. |
|
||||
| Profile view (`amy profile show NPUB`) + edit | ✅ | `ProfileCommands`. Cache-first; `--refresh` forces a relay drain. |
|
||||
| Thread view (`amy thread show EVENT_ID`) | ⚠️ | Same. |
|
||||
| Notifications feed | 🆕 | |
|
||||
| Notifications feed (`amy notifications [--watch]`) | ✅ | `NotificationsCommand`. Global mode: `#p=[me]` over the full notification kind set, self + mutes removed; shared via `commons/.../ui/feeds/notifications/`. Snapshot + opt-in live JSONL tail. Plan: [`cli/plans/2026-06-19-notifications.md`](./plans/2026-06-19-notifications.md). Selected-mode `tagsAnEventByUser` relevance (Note-graph) still pending. |
|
||||
| Search (NIP-50) | 🆕 | |
|
||||
|
||||
---
|
||||
@@ -86,8 +87,9 @@ move anything, re-audit — you're probably duplicating logic.
|
||||
smallest end-to-end write+read loop outside Marmot. Post + feed
|
||||
✅ shipped; `notes show` and outer-event `react` still pending.
|
||||
3. **`amy notes feed home|profile|hashtag|thread`** reading through the
|
||||
renderer. `--following` and `--author NPUB` ✅; hashtag/thread
|
||||
variants still pending.
|
||||
renderer. `--following` and `--author NPUB` ✅; `home` (full
|
||||
`HomeNewThreadFeedFilter` parity, snapshot + `--watch` live tail) ✅;
|
||||
hashtag/thread variants still pending.
|
||||
4. **`amy follow add|remove|list`** (NIP-02) — proves extraction of
|
||||
list-building logic from `amethyst/model/`.
|
||||
5. **`amy dm send|list`** (NIP-17) — ✅ shipped. Reuses the gift-wrap
|
||||
|
||||
138
cli/plans/2026-06-19-home-feed.md
Normal file
138
cli/plans/2026-06-19-home-feed.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Amy home feed — `amy notes home`
|
||||
|
||||
Status: implementing.
|
||||
Owner extraction: `commons/.../ui/feeds/home/HomeFeed.kt`.
|
||||
|
||||
## Goal
|
||||
|
||||
Bring Amethyst's **home feed** to Amy with full `HomeNewThreadFeedFilter`
|
||||
inclusion parity, and solve the one problem that doesn't exist in the
|
||||
Android app: **how do you "draw" a feed in a non-interactive CLI when
|
||||
events arrive from relays at any time?**
|
||||
|
||||
## The drawing problem
|
||||
|
||||
The Android home feed is a *reactive, mutable* list: `LocalCache` builds a
|
||||
`Note` graph, `HomeNewThreadFeedFilter` produces an ordered `List<Note>`,
|
||||
and every relay event re-runs `applyFilter` + `sort` so the `LazyColumn`
|
||||
re-sorts and inserts the new note wherever its `createdAt` lands.
|
||||
|
||||
Amy can't do that. Two hard rules forbid it:
|
||||
|
||||
- **Rule 3 — non-interactive.** No TTY cursor control, no in-place redraw,
|
||||
no REPL.
|
||||
- **Rule 4 — no daemon.** `~/.amy/` is reloaded every run; there is no
|
||||
persistent mutable list to re-sort between events.
|
||||
|
||||
The key realisation: **a terminal stream is append-only — you cannot
|
||||
re-sort lines you have already printed.** So the feed splits into two
|
||||
phases that map cleanly onto a CLI:
|
||||
|
||||
1. **Backfill (snapshot).** Subscribe, `drain` to EOSE, filter + sort
|
||||
newest-first, cap at `--limit`, emit once. This is the page you'd see
|
||||
on open. Keeps Amy's "one JSON object per command" contract.
|
||||
2. **Live tail (`--watch`).** *After* EOSE, keep the subscription open and
|
||||
print **one event per line, in arrival order, as it arrives** (JSONL),
|
||||
like `tail -f` / `kubectl logs -f`. No re-sort, no in-place mutation —
|
||||
each event is drawn once, appended at the bottom. Terminates on
|
||||
`--duration` / SIGINT.
|
||||
|
||||
Default is the snapshot; `--watch` opts into the live tail. The live tail
|
||||
is a **second, explicit output contract** (one JSON object per line)
|
||||
distinct from the snapshot's single object — documented as such so neither
|
||||
silently changes.
|
||||
|
||||
### Output contracts
|
||||
|
||||
| Mode | stdout |
|
||||
|---|---|
|
||||
| snapshot (default) | one JSON object `{mode, authors, queried_relays, count, notes:[…]}` |
|
||||
| `--watch` | JSONL: backfill page oldest-first, then live events, one object per line, each `{phase:"backfill"\|"live", id, pubkey, created_at, kind, content, tags}` |
|
||||
|
||||
Backfill in `--watch` is printed **oldest-first** so the newest sits at the
|
||||
bottom and live events append naturally below it (chat-log order).
|
||||
|
||||
## Inclusion parity
|
||||
|
||||
Amethyst's `HomeNewThreadFeedFilter` is **Event-level-clean** except two
|
||||
Note-graph touches, both of which have exact Event equivalents:
|
||||
|
||||
- `Note.isNewThread()` → `Event.isNewThreadEvent()`: a root is its own
|
||||
thread root, so `threadRootIdOrSelf() == id` (plus the repost / chat /
|
||||
comment-with-root carve-outs) reproduces it without a reply graph.
|
||||
- `Note.relays` (per-note relay observable) → the single relay the event
|
||||
arrived on.
|
||||
|
||||
Everything else — the 25-kind acceptance OR, mutes, muted threads, future
|
||||
clamp, excessive-hashtag clamp — is pure `Event`.
|
||||
|
||||
### Extraction (Rule 5)
|
||||
|
||||
The kind-acceptance OR moves verbatim from `HomeNewThreadFeedFilter`
|
||||
into `commons/.../ui/feeds/home/HomeFeed.kt` as
|
||||
`Event?.isHomeFeedRenderableKind()`, and Android's `acceptableEvent` is
|
||||
updated to call it. New shared, Event-level pieces live alongside it:
|
||||
|
||||
- `HomeFeedKinds: List<Int>` — drives the relay REQ filter.
|
||||
- `Event.isNewThreadEvent()`.
|
||||
- `HomeFeedParams(followedAuthors, hidden: LiveHiddenUsers, now)` with
|
||||
`match(event)` mirroring `FilterByListParams.match` minus the
|
||||
Android-only `IFeedTopNavFilter` (which is `LocalCache`-bound).
|
||||
- `Collection<Event>.sortedByHomeFeedOrder()` — `createdAt` desc, `id`
|
||||
tiebreak, dedup by id.
|
||||
|
||||
`FilterByListParams` itself stays in `amethyst/` (it depends on
|
||||
`IFeedTopNavFilter` → `LocalCache`). `HomeFeedParams` is the CLI-safe
|
||||
subset, not a duplicate of the Android relay-hydration path.
|
||||
|
||||
### Inputs Amy feeds the filter
|
||||
|
||||
- `followedAuthors`: NIP-02 contact list (cache-first, drain fallback) —
|
||||
same resolution as `amy notes feed --following`.
|
||||
- `hidden`: NIP-51 mute list (kind 10000), public **and** private mutes
|
||||
(Amy has the signer) → `UserTag.pubKey` (muted users) and
|
||||
`EventTag.eventId` (muted threads).
|
||||
|
||||
### Known parity gaps (documented, deferred)
|
||||
|
||||
- **Followed hashtags / geohashes / communities.** Android's `AllFollows`
|
||||
top-nav filter also admits notes by followed hashtag / geohash /
|
||||
community, resolved through the outbox-per-author `LocalCache` model.
|
||||
Amy's first cut is author-follow + mutes only; the REQ is authored by
|
||||
the follow set, so this only omits hashtag/geo/community-sourced notes
|
||||
from non-followed authors.
|
||||
- **Repost collapse.** Android keeps only the most recent repost per
|
||||
boosted id; Amy dedups by event id (reposts of the same target may
|
||||
appear more than once).
|
||||
|
||||
## CLI surface
|
||||
|
||||
```
|
||||
amy notes home [--limit N] [--since TS] [--until TS] [--timeout SECS]
|
||||
[--watch [--duration SECS]]
|
||||
```
|
||||
|
||||
- snapshot: `--limit` (50), `--timeout` (8, backfill drain).
|
||||
- watch: `--duration` (60, live tail length; SIGINT also stops).
|
||||
|
||||
## New CLI plumbing
|
||||
|
||||
- `Context.stream(filters, timeoutMs, onEose, onEvent)` — long-lived
|
||||
subscription that calls `onEvent` per verified+stored event until the
|
||||
timeout, firing `onEose` once all relays EOSE. (`drain` returns at EOSE;
|
||||
`requestResponse` returns on first event — neither fits a live tail.)
|
||||
- `Output.emitLine(map)` — one JSON object per line in `--json`; a compact
|
||||
one-line human render in text mode.
|
||||
- `Context.muteListOf(pubKey)` — cache-first kind:10000 read, mirroring
|
||||
`contactsOf`.
|
||||
|
||||
## Files
|
||||
|
||||
- `commons/.../ui/feeds/home/HomeFeed.kt` (new shared).
|
||||
- `amethyst/.../home/dal/HomeNewThreadFeedFilter.kt` (call shared predicate).
|
||||
- `cli/.../commands/HomeFeedCommand.kt` (new).
|
||||
- `cli/.../Context.kt` (`stream`, `muteListOf`).
|
||||
- `cli/.../Output.kt` (`emitLine`).
|
||||
- `cli/.../commands/NotesCommands.kt` (+`home`), `Main.kt` usage,
|
||||
`README.md`, `ROADMAP.md`.
|
||||
- `commons/.../HomeFeedTest.kt` (predicate unit test).
|
||||
99
cli/plans/2026-06-19-notifications.md
Normal file
99
cli/plans/2026-06-19-notifications.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Amy notifications — `amy notifications`
|
||||
|
||||
Status: implemented.
|
||||
Owner extraction: `commons/.../ui/feeds/notifications/NotificationFeed.kt`.
|
||||
|
||||
## Goal
|
||||
|
||||
Bring Amethyst's **Notifications** feed to Amy: the events that notify you —
|
||||
reactions, reposts, zaps, replies, mentions, comments — with the same
|
||||
two-phase "drawing" model already used by `notes home` (snapshot by
|
||||
default, opt-in live JSONL tail under `--watch`). See
|
||||
`cli/plans/2026-06-19-home-feed.md` for the drawing-problem rationale; it
|
||||
is identical here.
|
||||
|
||||
## Inclusion: a notification is an event that p-tags you
|
||||
|
||||
Amethyst's `NotificationFeedFilter.acceptableEvent` gates every event on
|
||||
`isTaggedUser(me)` — a `#p`-tag for the logged-in user. So the entire feed
|
||||
is a single relay query: `{"#p":[me], "kinds":[NOTIFICATION_KINDS]}`. The
|
||||
p-tag gate already captures the dominant cases — reactions, reposts and
|
||||
zaps all tag the note's author (you), and replies/mentions tag you
|
||||
directly.
|
||||
|
||||
On top of the p-tag gate, Amethyst's **Global** notification mode applies
|
||||
only Event-level checks, all reproducible against a raw Event store:
|
||||
|
||||
- kind ∈ `NOTIFICATION_KINDS`
|
||||
- not your own event — **except zaps** (a zap can be self-directed through
|
||||
a provider, so `is LnZapEvent || pubKey != me`)
|
||||
- author not muted/blocked
|
||||
- not stamped in the future
|
||||
|
||||
Its **Selected** mode additionally runs `tagsAnEventByUser`, the per-kind
|
||||
relevance heuristic (reaction/repost target authorship, reply parents,
|
||||
citations, community moderation). That walks the `Note` reply graph and
|
||||
does `LocalCache` author lookups, so it is **not** reproducible against
|
||||
Amy's raw Event store. Amy therefore reproduces **Global** mode — a real,
|
||||
first-class Amethyst mode — exactly, and defers Selected-mode narrowing.
|
||||
|
||||
### Extraction (Rule 5)
|
||||
|
||||
The notification kind taxonomy moves to
|
||||
`commons/.../ui/feeds/notifications/NotificationFeed.kt` as the single
|
||||
source of truth:
|
||||
|
||||
- `NotificationFeedKinds: Set<Int>` (base + addressable) — Android's
|
||||
`NotificationFeedFilter.NOTIFICATION_KINDS` now references it.
|
||||
- `NotificationFeedAddressableKinds: List<Int>` — the addressable subset
|
||||
Android still scans separately.
|
||||
- `Event?.isNotificationRenderableKind()` (with the `returns(true)→non-null`
|
||||
contract).
|
||||
- `NotificationFeedParams(myPubkey, hidden)` with `match(event)` =
|
||||
the Global-mode predicate above.
|
||||
- `Collection<Event>.sortedByNotificationFeedOrder()` — newest-first, dedup.
|
||||
|
||||
Android keeps `ADDRESSABLE_KINDS` (its addressables-cache scan needs it)
|
||||
and the whole `tagsAnEventByUser` Selected-mode path untouched.
|
||||
|
||||
### Inputs Amy feeds the filter
|
||||
|
||||
- `myPubkey`: the active identity.
|
||||
- `hidden`: NIP-51 kind:10000 mute list (public + private via signer),
|
||||
loaded by the shared `Context.hiddenUsers()` (also used by `notes home`).
|
||||
- relays: **inbox** (NIP-65 read) relays — where events tagging you land.
|
||||
|
||||
### Known parity gaps (documented, deferred)
|
||||
|
||||
- **Selected-mode `tagsAnEventByUser`.** Reaction/repost target authorship,
|
||||
reply-parent and citation relevance, and community-moderation checks need
|
||||
the Note reply graph + `LocalCache`. Global mode (Amy's default) does not
|
||||
use them.
|
||||
- **Zap author muting.** Android mutes on the *zapper* (decrypted from the
|
||||
zap request); Amy mutes on the receipt's `pubKey` (the provider). A muted
|
||||
user's zap may slip through.
|
||||
- **Muted-thread resolution** for reaction/zap/repost *targets* (needs the
|
||||
target's thread root from the graph) and **decrypted-DM-content hiding**
|
||||
are not applied.
|
||||
|
||||
## CLI surface
|
||||
|
||||
```
|
||||
amy notifications [--limit N] [--since TS] [--until TS] [--timeout SECS]
|
||||
[--watch [--duration SECS]]
|
||||
```
|
||||
|
||||
Alias: `notifs`. Snapshot `--limit` (50) / `--timeout` (8); watch
|
||||
`--duration` (60, SIGINT also stops). Reuses `Context.stream` /
|
||||
`Output.emitLine` from the home-feed work.
|
||||
|
||||
## Files
|
||||
|
||||
- `commons/.../ui/feeds/notifications/NotificationFeed.kt` (new shared).
|
||||
- `amethyst/.../notifications/dal/NotificationFeedFilter.kt`
|
||||
(`NOTIFICATION_KINDS` → references commons; prune now-unused imports).
|
||||
- `cli/.../commands/NotificationsCommand.kt` (new).
|
||||
- `cli/.../Context.kt` (`hiddenUsers()` lifted out of `HomeFeedCommand`).
|
||||
- `cli/.../commands/Commands.kt`, `Main.kt` (dispatch + usage),
|
||||
`README.md`, `ROADMAP.md`.
|
||||
- `commons/.../NotificationFeedTest.kt` (predicate unit test).
|
||||
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
|
||||
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
||||
import com.vitorpamplona.amethyst.commons.marmot.ingest
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
import com.vitorpamplona.quartz.marmot.MarmotFilters
|
||||
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
@@ -47,6 +48,9 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -311,6 +315,90 @@ class Context(
|
||||
return collected
|
||||
}
|
||||
|
||||
/**
|
||||
* Live counterpart to [drain]. Subscribes across [filters] and invokes
|
||||
* [onEvent] for every verified+stored event as it arrives — including those
|
||||
* that land *after* EOSE, which [drain] returns too early to see. [onEose]
|
||||
* fires once, when every relay has signalled EOSE (i.e. the historical
|
||||
* backfill is exhausted and everything after is genuinely live).
|
||||
*
|
||||
* Unlike [drain] this never returns at EOSE: it keeps the subscription open
|
||||
* and pumps events until [timeoutMs] elapses (or the coroutine is
|
||||
* cancelled, e.g. SIGINT). This is the primitive a non-interactive feed
|
||||
* "tail" is built on — the CLI cannot redraw an in-place list, so it draws
|
||||
* each event once, in arrival order, via [Output.emitLine].
|
||||
*/
|
||||
suspend fun stream(
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long,
|
||||
onEose: suspend () -> Unit = {},
|
||||
onEvent: suspend (NormalizedRelayUrl, Event) -> Unit,
|
||||
) {
|
||||
if (filters.isEmpty()) return
|
||||
val eventChannel = Channel<Pair<NormalizedRelayUrl, Event>>(UNLIMITED)
|
||||
val doneChannel = Channel<NormalizedRelayUrl>(UNLIMITED)
|
||||
val remaining = filters.keys.toMutableSet()
|
||||
var eosed = false
|
||||
val subId = newSubId()
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eventChannel.trySend(relay to event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
doneChannel.trySend(relay)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
message: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
doneChannel.trySend(relay)
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: NormalizedRelayUrl,
|
||||
message: String,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
doneChannel.trySend(relay)
|
||||
}
|
||||
}
|
||||
try {
|
||||
client.subscribe(subId, filters, listener)
|
||||
withTimeoutOrNull(timeoutMs) {
|
||||
while (true) {
|
||||
select {
|
||||
eventChannel.onReceive { (relay, event) ->
|
||||
if (verifyAndStore(event)) onEvent(relay, event)
|
||||
}
|
||||
doneChannel.onReceive { r ->
|
||||
remaining.remove(r)
|
||||
if (!eosed && remaining.isEmpty()) {
|
||||
eosed = true
|
||||
onEose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.unsubscribe(subId)
|
||||
eventChannel.close()
|
||||
doneChannel.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish [request] to [relays], then wait for the FIRST event matching [responseFilter]
|
||||
* — a live reply that arrives after our own EOSE, which [drain] would miss (it returns at
|
||||
@@ -407,6 +495,54 @@ class Context(
|
||||
Filter(authors = listOf(pubKey), kinds = listOf(ContactListEvent.KIND), limit = 1),
|
||||
).firstOrNull() as? ContactListEvent
|
||||
|
||||
/**
|
||||
* Latest known kind:10000 mute list (NIP-51) for [pubKey], or `null` if
|
||||
* Amy has never observed one. Used by the home feed to drop muted authors
|
||||
* and muted threads. Public mutes read straight off the tags; private
|
||||
* mutes require decryption with [signer] (callers pass it explicitly).
|
||||
*/
|
||||
suspend fun muteListOf(pubKey: HexKey): MuteListEvent? =
|
||||
store
|
||||
.query<Event>(
|
||||
Filter(authors = listOf(pubKey), kinds = listOf(MuteListEvent.KIND), limit = 1),
|
||||
).firstOrNull() as? MuteListEvent
|
||||
|
||||
/**
|
||||
* The account's mute/block state as a [LiveHiddenUsers], from its NIP-51
|
||||
* kind:10000 list — public mutes plus private mutes (Amy holds the signer
|
||||
* so it can decrypt them). Cache-first; falls back to a one-shot relay
|
||||
* drain if the list isn't local yet. Best-effort: an absent list yields
|
||||
* empty mutes (nothing hidden). Shared by the home and notification feeds.
|
||||
*/
|
||||
suspend fun hiddenUsers(timeoutMs: Long): LiveHiddenUsers {
|
||||
val self = identity.pubKeyHex
|
||||
var mute = muteListOf(self)
|
||||
if (mute == null) {
|
||||
val relays = outboxRelays().ifEmpty { bootstrapRelays() }
|
||||
if (relays.isNotEmpty()) {
|
||||
val filter = Filter(kinds = listOf(MuteListEvent.KIND), authors = listOf(self), limit = 1)
|
||||
mute =
|
||||
drain(relays.associateWith { listOf(filter) }, timeoutMs)
|
||||
.mapNotNull { it.second as? MuteListEvent }
|
||||
.maxByOrNull { it.createdAt }
|
||||
}
|
||||
}
|
||||
val empty =
|
||||
LiveHiddenUsers(
|
||||
showSensitiveContent = null,
|
||||
hiddenWordsCase = emptyList(),
|
||||
hiddenUsersHashCodes = emptySet(),
|
||||
spammersHashCodes = emptySet(),
|
||||
)
|
||||
if (mute == null) return empty
|
||||
|
||||
val tags = mute.publicMutes() + (runCatching { mute.privateMutes(signer) }.getOrNull() ?: emptyList())
|
||||
return empty.copy(
|
||||
hiddenUsers = tags.filterIsInstance<UserTag>().mapTo(HashSet()) { it.pubKey },
|
||||
mutedThreads = tags.filterIsInstance<EventTag>().mapTo(HashSet()) { it.eventId },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest known kind:10050 chat-message (NIP-17 DM) inbox relay list
|
||||
* for [pubKey], or `null` if Amy has never observed one. Used by
|
||||
|
||||
@@ -162,6 +162,10 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
Commands.notes(dataDir, tail)
|
||||
}
|
||||
|
||||
"notifications", "notifs" -> {
|
||||
Commands.notifications(dataDir, tail)
|
||||
}
|
||||
|
||||
"nsite" -> {
|
||||
Commands.nsite(dataDir, tail)
|
||||
}
|
||||
@@ -365,6 +369,16 @@ private fun printUsage() {
|
||||
| [--limit N] --following: every contact-list pubkey)
|
||||
| [--since TS] [--until TS]
|
||||
| [--timeout SECS]
|
||||
| notes home [--limit N] home feed: top-level posts/reposts from
|
||||
| [--since TS] [--until TS] everyone you follow (full HomeNewThread
|
||||
| [--timeout SECS] kind set, muted authors/threads removed)
|
||||
| [--watch [--duration SECS]] --watch: stream live events as JSONL
|
||||
|
|
||||
|Notifications:
|
||||
| notifications [--limit N] events that p-tag you: reactions, reposts,
|
||||
| [--since TS] [--until TS] zaps, replies, mentions (Global mode;
|
||||
| [--timeout SECS] your own + muted authors removed)
|
||||
| [--watch [--duration SECS]] --watch: stream live events as JSONL
|
||||
|
|
||||
|Static websites (NIP-5A kind:15128/35128):
|
||||
| nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and
|
||||
|
||||
@@ -60,6 +60,47 @@ object Output {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming sibling of [emit]: writes ONE record per line, so a command can
|
||||
* draw an unbounded live stream (e.g. `notes home --watch`) that arrives
|
||||
* event-by-event. In [Mode.JSON] this is JSONL — one compact JSON object per
|
||||
* line, a distinct contract from [emit]'s single object. In [Mode.TEXT] it
|
||||
* renders a compact one-line summary keyed off the common note fields.
|
||||
*
|
||||
* Each line is flushed immediately so consumers (`jq`, `tail -f`-style
|
||||
* pipes) see events as they land rather than at process exit.
|
||||
*/
|
||||
fun emitLine(record: Map<String, Any?>) {
|
||||
when (mode) {
|
||||
Mode.JSON -> println(mapper.writeValueAsString(record))
|
||||
Mode.TEXT -> println(renderLine(record))
|
||||
}
|
||||
System.out.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact single-line render for [emitLine] in text mode:
|
||||
* `<iso-time> <pubkey8> k<kind> <content-oneline>`, with an optional
|
||||
* leading `[phase]` tag (backfill / live). Best-effort — text shape is not
|
||||
* a contract.
|
||||
*/
|
||||
private fun renderLine(record: Map<String, Any?>): String {
|
||||
val color = Ansi.forStream(isStderr = false)
|
||||
val createdAt = (record["created_at"] as? Number)?.toLong()
|
||||
val time = createdAt?.let { ISO_FORMAT.format(Instant.ofEpochSecond(it)) } ?: "?"
|
||||
val pubkey = record["pubkey"]?.toString()?.take(8) ?: "????????"
|
||||
val kind = record["kind"]?.toString()?.let { "k$it" } ?: ""
|
||||
val phase = record["phase"]?.toString()
|
||||
val content =
|
||||
record["content"]
|
||||
?.toString()
|
||||
?.replace('\n', ' ')
|
||||
?.trim()
|
||||
?.take(120) ?: ""
|
||||
val phaseTag = if (phase != null) color.dim("[$phase] ") else ""
|
||||
return "$phaseTag${color.dim(time)} ${color.bold(pubkey)} ${color.yellow(kind)} $content"
|
||||
}
|
||||
|
||||
fun error(
|
||||
code: String,
|
||||
detail: String? = null,
|
||||
|
||||
@@ -91,6 +91,11 @@ object Commands {
|
||||
tail: Array<String>,
|
||||
): Int = NotesCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun notifications(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = NotificationsCommand.run(dataDir, tail)
|
||||
|
||||
suspend fun nsite(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
|
||||
@@ -151,7 +151,7 @@ object FeedCommand {
|
||||
else -> ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
|
||||
}
|
||||
|
||||
private suspend fun resolveFollowing(
|
||||
internal suspend fun resolveFollowing(
|
||||
ctx: Context,
|
||||
timeoutMs: Long,
|
||||
): List<HexKey> {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.home.HomeFeedKinds
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.home.HomeFeedParams
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.home.sortedByHomeFeedOrder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* `amy notes home [--limit N] [--since TS] [--until TS] [--timeout SECS]`
|
||||
* ` [--watch [--duration SECS]]`
|
||||
*
|
||||
* Amethyst's home feed: top-level posts (and reposts) from the accounts you
|
||||
* follow, across the full `HomeNewThreadFeedFilter` kind set, with muted
|
||||
* authors / threads removed. The inclusion rules live in
|
||||
* `commons/.../ui/feeds/home/HomeFeed.kt` and are shared with the Android app.
|
||||
*
|
||||
* ## Two ways to draw a feed in a non-interactive CLI
|
||||
*
|
||||
* The Android feed is a reactive mutable list that re-sorts on every relay
|
||||
* event. A terminal stream is append-only — you can't re-sort lines already
|
||||
* printed — so this command splits into two phases:
|
||||
*
|
||||
* - **snapshot (default):** drain to EOSE, filter + sort newest-first, cap at
|
||||
* `--limit`, emit ONE JSON object `{notes:[…]}`. The page you'd see on open.
|
||||
* - **`--watch`:** print the backfill page oldest-first, then keep the
|
||||
* subscription open and emit each new live event as its own line (JSONL),
|
||||
* `tail -f`-style, until `--duration` elapses (or SIGINT).
|
||||
*/
|
||||
object HomeFeedCommand {
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val limit = args.intFlag("limit", 50)
|
||||
if (limit <= 0) return Output.error("bad_args", "home: --limit must be > 0")
|
||||
val since = args.flag("since")?.toLongOrNull()
|
||||
val until = args.flag("until")?.toLongOrNull()
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
val watch = args.bool("watch")
|
||||
val durationSecs = args.longFlag("duration", 60L)
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
|
||||
val authors = FeedCommand.resolveFollowing(ctx, timeoutSecs * 1000)
|
||||
val relays = ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
|
||||
|
||||
// No follows (or no relays) → nothing to draw. Keep the contract:
|
||||
// an empty page in snapshot mode, an immediate finish in watch mode.
|
||||
if (authors.isEmpty() || relays.isEmpty()) {
|
||||
if (!watch) {
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"mode" to "home",
|
||||
"authors" to authors,
|
||||
"queried_relays" to relays.map { it.url },
|
||||
"count" to 0,
|
||||
"notes" to emptyList<Any>(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
System.err.println("[cli] home --watch: nothing to follow yet (no contacts or relays)")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
val params = HomeFeedParams(authors.toSet(), ctx.hiddenUsers(timeoutSecs * 1000))
|
||||
|
||||
// ---- Phase 1: backfill ------------------------------------------
|
||||
val backfillFilter =
|
||||
Filter(
|
||||
kinds = HomeFeedKinds,
|
||||
authors = authors,
|
||||
since = since,
|
||||
until = until,
|
||||
// Headroom: each relay caps independently, then we merge,
|
||||
// dedup and re-cap across the union.
|
||||
limit = (limit * 2).coerceAtMost(500),
|
||||
)
|
||||
val received = ctx.drain(relays.associateWith { listOf(backfillFilter) }, timeoutSecs * 1000)
|
||||
val page =
|
||||
received
|
||||
.asSequence()
|
||||
.map { it.second }
|
||||
.filter { params.match(it) }
|
||||
.toList()
|
||||
.sortedByHomeFeedOrder()
|
||||
.take(limit)
|
||||
|
||||
if (!watch) {
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"mode" to "home",
|
||||
"authors" to authors,
|
||||
"queried_relays" to relays.map { it.url },
|
||||
"count" to page.size,
|
||||
"notes" to page.map { it.toNoteMap() },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- Phase 2: live tail -----------------------------------------
|
||||
// Backfill oldest-first so the newest sits at the bottom and live
|
||||
// events append naturally below it (chat-log order).
|
||||
val seen = HashSet<HexKey>()
|
||||
for (ev in page.asReversed()) {
|
||||
seen.add(ev.id)
|
||||
Output.emitLine(ev.toNoteMap("backfill"))
|
||||
}
|
||||
|
||||
// Only events stamped from "now" forward are genuinely live; relays
|
||||
// replay history until EOSE, so we de-dup against the backfill and
|
||||
// re-apply the same acceptance to anything new.
|
||||
val liveFilter =
|
||||
Filter(
|
||||
kinds = HomeFeedKinds,
|
||||
authors = authors,
|
||||
since = TimeUtils.now(),
|
||||
)
|
||||
System.err.println("[cli] home --watch: streaming for ${durationSecs}s (Ctrl-C to stop)")
|
||||
ctx.stream(
|
||||
filters = relays.associateWith { listOf(liveFilter) },
|
||||
timeoutMs = durationSecs * 1000,
|
||||
onEvent = { _, event ->
|
||||
if (seen.add(event.id) && params.match(event)) {
|
||||
Output.emitLine(event.toNoteMap("live"))
|
||||
}
|
||||
},
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON/stream shape for one note. [phase] is set only for `--watch` lines. */
|
||||
private fun Event.toNoteMap(phase: String? = null): Map<String, Any?> =
|
||||
buildMap {
|
||||
if (phase != null) put("phase", phase)
|
||||
put("id", id)
|
||||
put("pubkey", pubKey)
|
||||
put("created_at", createdAt)
|
||||
put("kind", kind)
|
||||
put("content", content)
|
||||
put("tags", tags.map { it.toList() })
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,12 @@ object NotesCommands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "notes <post|feed> …")
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "notes <post|feed|home> …")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"post" -> PostCommand.run(dataDir, rest)
|
||||
"feed" -> FeedCommand.run(dataDir, rest)
|
||||
"home" -> HomeFeedCommand.run(dataDir, rest)
|
||||
else -> Output.error("bad_args", "notes ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.notifications.NotificationFeedKinds
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.notifications.NotificationFeedParams
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.notifications.sortedByNotificationFeedOrder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* `amy notifications [--limit N] [--since TS] [--until TS] [--timeout SECS]`
|
||||
* ` [--watch [--duration SECS]]`
|
||||
*
|
||||
* Amethyst's Notifications feed: events that p-tag you — reactions, reposts,
|
||||
* zaps, replies, mentions, comments — across the full notification kind set,
|
||||
* minus your own (zaps excepted) and muted authors. Reproduces Amethyst's
|
||||
* **Global** notification mode; the inclusion rules live in
|
||||
* `commons/.../ui/feeds/notifications/NotificationFeed.kt` and are shared with
|
||||
* the Android app.
|
||||
*
|
||||
* Same two-phase drawing model as `notes home` (a terminal stream is
|
||||
* append-only, so the live view can't re-sort): a **snapshot** (one JSON
|
||||
* object) by default, or a **live JSONL tail** under `--watch`.
|
||||
*
|
||||
* The query is a single `{"#p":[me], "kinds":[…]}` REQ against your inbox
|
||||
* relays — the p-tag gate captures reactions/reposts/zaps (which tag the note
|
||||
* author) and replies/mentions alike.
|
||||
*/
|
||||
object NotificationsCommand {
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val limit = args.intFlag("limit", 50)
|
||||
if (limit <= 0) return Output.error("bad_args", "notifications: --limit must be > 0")
|
||||
val since = args.flag("since")?.toLongOrNull()
|
||||
val until = args.flag("until")?.toLongOrNull()
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
val watch = args.bool("watch")
|
||||
val durationSecs = args.longFlag("duration", 60L)
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
|
||||
val me = ctx.identity.pubKeyHex
|
||||
// Notifications arrive on the relays you read from (NIP-65 inbox).
|
||||
val relays = ctx.inboxRelays().ifEmpty { ctx.bootstrapRelays() }
|
||||
if (relays.isEmpty()) {
|
||||
if (!watch) {
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"mode" to "notifications",
|
||||
"pubkey" to me,
|
||||
"queried_relays" to emptyList<String>(),
|
||||
"count" to 0,
|
||||
"notes" to emptyList<Any>(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
System.err.println("[cli] notifications --watch: no relays to query (configure relays first)")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
val params = NotificationFeedParams(me, ctx.hiddenUsers(timeoutSecs * 1000))
|
||||
|
||||
// ---- Phase 1: backfill ------------------------------------------
|
||||
val backfillFilter =
|
||||
Filter(
|
||||
kinds = NotificationFeedKinds.toList(),
|
||||
tags = mapOf("p" to listOf(me)),
|
||||
since = since,
|
||||
until = until,
|
||||
limit = (limit * 2).coerceAtMost(500),
|
||||
)
|
||||
val received = ctx.drain(relays.associateWith { listOf(backfillFilter) }, timeoutSecs * 1000)
|
||||
val page =
|
||||
received
|
||||
.asSequence()
|
||||
.map { it.second }
|
||||
.filter { params.match(it) }
|
||||
.toList()
|
||||
.sortedByNotificationFeedOrder()
|
||||
.take(limit)
|
||||
|
||||
if (!watch) {
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"mode" to "notifications",
|
||||
"pubkey" to me,
|
||||
"queried_relays" to relays.map { it.url },
|
||||
"count" to page.size,
|
||||
"notes" to page.map { it.toNotificationMap() },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- Phase 2: live tail -----------------------------------------
|
||||
// Backfill oldest-first so live events append below it (chat-log order).
|
||||
val seen = HashSet<HexKey>()
|
||||
for (ev in page.asReversed()) {
|
||||
seen.add(ev.id)
|
||||
Output.emitLine(ev.toNotificationMap("backfill"))
|
||||
}
|
||||
|
||||
val liveFilter =
|
||||
Filter(
|
||||
kinds = NotificationFeedKinds.toList(),
|
||||
tags = mapOf("p" to listOf(me)),
|
||||
since = TimeUtils.now(),
|
||||
)
|
||||
System.err.println("[cli] notifications --watch: streaming for ${durationSecs}s (Ctrl-C to stop)")
|
||||
ctx.stream(
|
||||
filters = relays.associateWith { listOf(liveFilter) },
|
||||
timeoutMs = durationSecs * 1000,
|
||||
onEvent = { _, event ->
|
||||
if (seen.add(event.id) && params.match(event)) {
|
||||
Output.emitLine(event.toNotificationMap("live"))
|
||||
}
|
||||
},
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON/stream shape for one notification. [phase] is set only for `--watch` lines. */
|
||||
private fun Event.toNotificationMap(phase: String? = null): Map<String, Any?> =
|
||||
buildMap {
|
||||
if (phase != null) put("phase", phase)
|
||||
put("id", id)
|
||||
put("pubkey", pubKey)
|
||||
put("created_at", createdAt)
|
||||
put("kind", kind)
|
||||
put("content", content)
|
||||
put("tags", tags.map { it.toList() })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.feeds.home
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
|
||||
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
||||
/*
|
||||
* Event-level home-feed logic, shared between the Android `LocalCache`/`Note`
|
||||
* graph (`HomeNewThreadFeedFilter`) and clients that only have raw `Event`s
|
||||
* (e.g. Amy CLI against a file-backed event store).
|
||||
*
|
||||
* The Android home feed is reactive and `Note`-graph-bound; the *inclusion
|
||||
* rules* underneath it are not. This file lifts those rules to `Event` so a
|
||||
* single definition decides what belongs on the home feed regardless of
|
||||
* whether a Note graph exists.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The event kinds the home feed requests and renders. Mirrors the type set
|
||||
* accepted by [isHomeFeedRenderableKind] and drives the relay REQ filter so a
|
||||
* subscription only pulls what the feed can show.
|
||||
*/
|
||||
val HomeFeedKinds: List<Int> =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
FundraiserEvent.KIND,
|
||||
BirdexEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
ZapPollEvent.KIND,
|
||||
PollEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
InteractiveStoryPrologueEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
MusicTrackEvent.KIND,
|
||||
MusicPlaylistEvent.KIND,
|
||||
PodcastEpisodeEvent.KIND,
|
||||
PodcastMetadataEvent.KIND,
|
||||
VoiceEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
ChessGameEvent.KIND,
|
||||
LiveChessGameEndEvent.KIND,
|
||||
AttestationEvent.KIND,
|
||||
AttestationRequestEvent.KIND,
|
||||
AttestorRecommendationEvent.KIND,
|
||||
AttestorProficiencyEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether an event's *type* (and, for long-form/wiki, non-empty content) is
|
||||
* renderable on the home feed. This is the kind half of
|
||||
* `HomeNewThreadFeedFilter.acceptableEvent`, lifted to [Event] so Android and
|
||||
* Amy share one definition. Mute / thread / author / time filtering is
|
||||
* applied separately (see [HomeFeedParams]).
|
||||
*
|
||||
* The `returns(true) implies non-null` contract lets it replace the
|
||||
* `is TextNoteEvent || …` allow-list in a feed's acceptance chain without
|
||||
* losing the chain's non-null smart-cast (the `&& filterParams.match(…)` tail
|
||||
* relies on it).
|
||||
*/
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
fun Event?.isHomeFeedRenderableKind(): Boolean {
|
||||
contract { returns(true) implies (this@isHomeFeedRenderableKind != null) }
|
||||
return this is TextNoteEvent ||
|
||||
this is ClassifiedsEvent ||
|
||||
this is FundraiserEvent ||
|
||||
this is BirdexEvent ||
|
||||
this.isRenderableRepost() ||
|
||||
(this is LongTextNoteEvent && this.content.isNotEmpty()) ||
|
||||
(this is WikiNoteEvent && this.content.isNotEmpty()) ||
|
||||
this is ZapPollEvent ||
|
||||
this is PollEvent ||
|
||||
this is HighlightEvent ||
|
||||
this is InteractiveStoryPrologueEvent ||
|
||||
this is CommentEvent ||
|
||||
this is AudioTrackEvent ||
|
||||
this is MusicTrackEvent ||
|
||||
this is MusicPlaylistEvent ||
|
||||
this is PodcastEpisodeEvent ||
|
||||
this is PodcastMetadataEvent ||
|
||||
this is VoiceEvent ||
|
||||
this is AudioHeaderEvent ||
|
||||
this is ChessGameEvent ||
|
||||
this is LiveChessGameEndEvent ||
|
||||
this is AttestationEvent ||
|
||||
this is AttestationRequestEvent ||
|
||||
this is AttestorRecommendationEvent ||
|
||||
this is AttestorProficiencyEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-level equivalent of `Note.isNewThread()`: top-level posts and reposts
|
||||
* only. A root note is its own thread root, so `threadRootIdOrSelf() == id`
|
||||
* detects "has no reply parent" without walking a Note graph; reposts are
|
||||
* always new threads; chat messages and root-scoped comments never are.
|
||||
*/
|
||||
fun Event.isNewThreadEvent(): Boolean =
|
||||
(
|
||||
this is RepostEvent ||
|
||||
this is GenericRepostEvent ||
|
||||
threadRootIdOrSelf() == id
|
||||
) &&
|
||||
!(this is CommentEvent && this.hasRootScopeIdentifier()) &&
|
||||
this !is ChannelMessageEvent &&
|
||||
this !is LiveActivitiesChatMessageEvent
|
||||
|
||||
/** Newest-first ordering with a stable `id` tiebreaker, matching the Android feed. */
|
||||
val HomeFeedOrder: Comparator<Event> =
|
||||
compareByDescending<Event> { it.createdAt }.thenBy { it.id }
|
||||
|
||||
/** Dedup by id and apply [HomeFeedOrder]. */
|
||||
fun Collection<Event>.sortedByHomeFeedOrder(): List<Event> = distinctBy { it.id }.sortedWith(HomeFeedOrder)
|
||||
|
||||
/**
|
||||
* The Event-level subset of `FilterByListParams` that the home feed needs:
|
||||
* author-follow membership + mute / muted-thread / future / excessive-hashtag
|
||||
* clamps. The Android `IFeedTopNavFilter` (followed hashtags / geohashes /
|
||||
* communities resolved through `LocalCache`'s outbox model) is intentionally
|
||||
* *not* reproduced here — see the plan's "known parity gaps".
|
||||
*
|
||||
* @param followedAuthors pubkeys whose top-level posts belong on the feed.
|
||||
* @param hidden mute / block / muted-thread state (NIP-51).
|
||||
* @param now upper time bound; events stamped after it are dropped.
|
||||
*/
|
||||
class HomeFeedParams(
|
||||
val followedAuthors: Set<HexKey>,
|
||||
val hidden: LiveHiddenUsers,
|
||||
val now: Long = TimeUtils.oneMinuteFromNow(),
|
||||
) {
|
||||
fun isAuthorAccepted(pubKey: HexKey): Boolean = pubKey in followedAuthors
|
||||
|
||||
fun isNotHidden(pubKey: HexKey): Boolean = !hidden.isUserHidden(pubKey)
|
||||
|
||||
fun isNotInMutedThread(event: Event): Boolean = hidden.mutedThreads.isEmpty() || !hidden.isThreadMuted(event.threadRootIdOrSelf())
|
||||
|
||||
fun isNotInTheFuture(event: Event): Boolean = event.createdAt <= now
|
||||
|
||||
fun hasExcessiveHashtags(event: Event): Boolean = hidden.maxHashtagLimit > 0 && event.hasMoreHashtagsThan(hidden.maxHashtagLimit)
|
||||
|
||||
/** Full home-feed acceptance for one event: renderable kind + new thread + list filters. */
|
||||
fun match(event: Event): Boolean =
|
||||
event.isHomeFeedRenderableKind() &&
|
||||
event.isNewThreadEvent() &&
|
||||
isAuthorAccepted(event.pubKey) &&
|
||||
isNotHidden(event.pubKey) &&
|
||||
isNotInMutedThread(event) &&
|
||||
isNotInTheFuture(event) &&
|
||||
!hasExcessiveHashtags(event)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.feeds.notifications
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
||||
/*
|
||||
* Event-level notification-feed logic, shared between the Android
|
||||
* `LocalCache`/`Note` graph (`NotificationFeedFilter`) and clients that only
|
||||
* have raw `Event`s (e.g. Amy CLI against a file-backed event store).
|
||||
*
|
||||
* A notification is, at heart, an event that p-tags you. Amethyst's "Global"
|
||||
* notification mode keeps exactly that — every event in the notification kind
|
||||
* set that tags the user, minus your own (zaps excepted) and muted authors.
|
||||
* Its "Selected" mode adds the per-kind `tagsAnEventByUser` relevance
|
||||
* heuristics, which walk the `Note` reply graph (reaction/repost target
|
||||
* authorship, reply parents, citations) and so are not reproducible against a
|
||||
* raw Event store; those stay Android-only. See the plan for the exact gap.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Addressable (parameterized-replaceable) kinds that can show up as
|
||||
* notifications. Kept distinct because Android scans the addressable cache
|
||||
* separately, but folded into [NotificationFeedKinds] for the relay REQ.
|
||||
*/
|
||||
val NotificationFeedAddressableKinds: List<Int> =
|
||||
listOf(
|
||||
AudioTrackEvent.KIND,
|
||||
MusicTrackEvent.KIND,
|
||||
MusicPlaylistEvent.KIND,
|
||||
PodcastEpisodeEvent.KIND,
|
||||
PodcastMetadataEvent.KIND,
|
||||
CalendarTimeSlotEvent.KIND,
|
||||
CalendarDateSlotEvent.KIND,
|
||||
CalendarRSVPEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
LiveActivitiesEvent.KIND,
|
||||
LiveChessGameAcceptEvent.KIND,
|
||||
LiveChessMoveEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
NipTextEvent.KIND,
|
||||
VideoVerticalEvent.KIND,
|
||||
VideoHorizontalEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
AttestationRequestEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* Every event kind the notifications feed admits — drives both the relay REQ
|
||||
* filter and the [isNotificationRenderableKind] membership test. Single source
|
||||
* of truth: the Android `NotificationFeedFilter.NOTIFICATION_KINDS` references
|
||||
* this.
|
||||
*/
|
||||
val NotificationFeedKinds: Set<Int> =
|
||||
setOf(
|
||||
BadgeAwardEvent.KIND,
|
||||
ChannelMessageEvent.KIND,
|
||||
ChatMessageEvent.KIND,
|
||||
ChatMessageEncryptedFileHeaderEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
GitIssueEvent.KIND,
|
||||
GitPatchEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
TextNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
NutzapEvent.KIND,
|
||||
OnchainZapEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
PictureEvent.KIND,
|
||||
PollEvent.KIND,
|
||||
ZapPollEvent.KIND,
|
||||
PrivateDmEvent.KIND,
|
||||
PublicMessageEvent.KIND,
|
||||
VideoNormalEvent.KIND,
|
||||
VideoShortEvent.KIND,
|
||||
VoiceEvent.KIND,
|
||||
VoiceReplyEvent.KIND,
|
||||
) + NotificationFeedAddressableKinds
|
||||
|
||||
/**
|
||||
* Whether an event's kind is one the notifications feed can show. The
|
||||
* `returns(true) implies non-null` contract lets it gate a chain whose tail
|
||||
* dereferences the (non-null) event.
|
||||
*/
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
fun Event?.isNotificationRenderableKind(): Boolean {
|
||||
contract { returns(true) implies (this@isNotificationRenderableKind != null) }
|
||||
return this != null && kind in NotificationFeedKinds
|
||||
}
|
||||
|
||||
/**
|
||||
* The Event-level subset of `NotificationFeedFilter.acceptableEvent` that
|
||||
* reproduces Amethyst's **Global** notification mode: a notification is an
|
||||
* in-kind event that p-tags [myPubkey], is not your own (zaps excepted, since
|
||||
* they can be self-directed through a provider), is not from a muted/blocked
|
||||
* author, and is not stamped in the future.
|
||||
*
|
||||
* The Note-graph parts — per-kind `tagsAnEventByUser` relevance (Selected
|
||||
* mode), muted-thread resolution for reaction/zap/repost targets, and
|
||||
* decrypted-DM-content hiding — are intentionally not reproduced here; see the
|
||||
* plan's "known parity gaps".
|
||||
*
|
||||
* @param myPubkey the account being notified.
|
||||
* @param hidden mute / block state (NIP-51); only the author sets are used.
|
||||
* @param now upper time bound; events stamped after it are dropped.
|
||||
*/
|
||||
class NotificationFeedParams(
|
||||
val myPubkey: HexKey,
|
||||
val hidden: LiveHiddenUsers,
|
||||
val now: Long = TimeUtils.oneMinuteFromNow(),
|
||||
) {
|
||||
fun tagsMe(event: Event): Boolean = event.isTaggedUser(myPubkey)
|
||||
|
||||
fun isNotSelf(event: Event): Boolean = event is LnZapEvent || event.pubKey != myPubkey
|
||||
|
||||
fun isNotHidden(event: Event): Boolean = !hidden.isUserHidden(event.pubKey)
|
||||
|
||||
fun isNotInTheFuture(event: Event): Boolean = event.createdAt <= now
|
||||
|
||||
/** Full Global-mode notification acceptance for one event. */
|
||||
fun match(event: Event): Boolean =
|
||||
event.isNotificationRenderableKind() &&
|
||||
tagsMe(event) &&
|
||||
isNotSelf(event) &&
|
||||
isNotHidden(event) &&
|
||||
isNotInTheFuture(event)
|
||||
}
|
||||
|
||||
/** Newest-first ordering with a stable `id` tiebreaker, matching the Android feed. */
|
||||
val NotificationFeedOrder: Comparator<Event> =
|
||||
compareByDescending<Event> { it.createdAt }.thenBy { it.id }
|
||||
|
||||
/** Dedup by id and apply [NotificationFeedOrder]. */
|
||||
fun Collection<Event>.sortedByNotificationFeedOrder(): List<Event> = distinctBy { it.id }.sortedWith(NotificationFeedOrder)
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.feeds.home
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class HomeFeedTest {
|
||||
private val author: HexKey = "a".repeat(64)
|
||||
private val stranger: HexKey = "b".repeat(64)
|
||||
private val sig: HexKey = "f".repeat(128)
|
||||
|
||||
private fun id(c: Char) = c.toString().repeat(64)
|
||||
|
||||
private fun rootNote(
|
||||
idChar: Char = '1',
|
||||
who: HexKey = author,
|
||||
createdAt: Long = 1000L,
|
||||
content: String = "hello",
|
||||
) = TextNoteEvent(id(idChar), who, createdAt, emptyArray(), content, sig)
|
||||
|
||||
private fun emptyHidden(
|
||||
hiddenUsers: Set<String> = emptySet(),
|
||||
mutedThreads: Set<String> = emptySet(),
|
||||
) = LiveHiddenUsers(
|
||||
showSensitiveContent = null,
|
||||
hiddenWordsCase = emptyList(),
|
||||
hiddenUsersHashCodes = emptySet(),
|
||||
spammersHashCodes = emptySet(),
|
||||
hiddenUsers = hiddenUsers,
|
||||
mutedThreads = mutedThreads,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun renderableKind_acceptsTextNote_rejectsReaction() {
|
||||
assertTrue(rootNote().isHomeFeedRenderableKind())
|
||||
// Reactions (kind 7) are not home-feed content.
|
||||
assertFalse(ReactionEvent(id('7'), author, 1000L, emptyArray(), "+", sig).isHomeFeedRenderableKind())
|
||||
// A null event is never renderable.
|
||||
assertFalse((null as Event?).isHomeFeedRenderableKind())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renderableKind_longFormNeedsContent() {
|
||||
assertFalse(LongTextNoteEvent(id('3'), author, 1000L, emptyArray(), "", sig).isHomeFeedRenderableKind())
|
||||
assertTrue(LongTextNoteEvent(id('4'), author, 1000L, emptyArray(), "body", sig).isHomeFeedRenderableKind())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newThread_rootIsNewThread_replyIsNot() {
|
||||
assertTrue(rootNote().isNewThreadEvent())
|
||||
// A reply carries a root marker e-tag, so its thread root != its own id.
|
||||
val reply = TextNoteEvent(id('5'), author, 1001L, arrayOf(arrayOf("e", id('1'), "", "root")), "re", sig)
|
||||
assertFalse(reply.isNewThreadEvent())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun params_acceptsFollowedRoot() {
|
||||
val params = HomeFeedParams(setOf(author), emptyHidden())
|
||||
assertTrue(params.match(rootNote()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun params_rejectsNonFollowedAuthor() {
|
||||
val params = HomeFeedParams(setOf(author), emptyHidden())
|
||||
assertFalse(params.match(rootNote(who = stranger)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun params_rejectsMutedAuthor() {
|
||||
val params = HomeFeedParams(setOf(author), emptyHidden(hiddenUsers = setOf(author)))
|
||||
assertFalse(params.match(rootNote()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun params_rejectsMutedThread() {
|
||||
// A root note is its own thread root, so muting its id mutes the thread.
|
||||
val params = HomeFeedParams(setOf(author), emptyHidden(mutedThreads = setOf(id('1'))))
|
||||
assertFalse(params.match(rootNote(idChar = '1')))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun params_rejectsFutureEvent() {
|
||||
val params = HomeFeedParams(setOf(author), emptyHidden())
|
||||
assertFalse(params.match(rootNote(createdAt = TimeUtils.now() + 3600)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortedByHomeFeedOrder_dedupsAndOrdersNewestFirst() {
|
||||
val older = rootNote(idChar = '1', createdAt = 1000L)
|
||||
val newer = rootNote(idChar = '2', createdAt = 2000L)
|
||||
val sorted = listOf(older, newer, older).sortedByHomeFeedOrder()
|
||||
assertEquals(listOf(id('2'), id('1')), sorted.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeFeedKinds_includesCoreKinds() {
|
||||
assertTrue(TextNoteEvent.KIND in HomeFeedKinds)
|
||||
assertTrue(LongTextNoteEvent.KIND in HomeFeedKinds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.feeds.notifications
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NotificationFeedTest {
|
||||
private val me: HexKey = "a".repeat(64)
|
||||
private val stranger: HexKey = "b".repeat(64)
|
||||
private val sig: HexKey = "f".repeat(128)
|
||||
|
||||
private fun id(c: Char) = c.toString().repeat(64)
|
||||
|
||||
private fun pTagMe(who: HexKey = me) = arrayOf(arrayOf("p", who))
|
||||
|
||||
private fun emptyHidden(hiddenUsers: Set<String> = emptySet()) =
|
||||
LiveHiddenUsers(
|
||||
showSensitiveContent = null,
|
||||
hiddenWordsCase = emptyList(),
|
||||
hiddenUsersHashCodes = emptySet(),
|
||||
spammersHashCodes = emptySet(),
|
||||
hiddenUsers = hiddenUsers,
|
||||
)
|
||||
|
||||
private fun params(hiddenUsers: Set<String> = emptySet()) = NotificationFeedParams(me, emptyHidden(hiddenUsers))
|
||||
|
||||
@Test
|
||||
fun reactionTaggingMe_isNotification() {
|
||||
// Someone else reacts to my note; NIP-25 puts a p-tag for the note author (me).
|
||||
val reaction = ReactionEvent(id('1'), stranger, 1000L, pTagMe(), "+", sig)
|
||||
assertTrue(params().match(reaction))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replyTaggingMe_isNotification() {
|
||||
val reply = TextNoteEvent(id('2'), stranger, 1000L, pTagMe(), "hi @me", sig)
|
||||
assertTrue(params().match(reply))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventNotTaggingMe_isNotNotification() {
|
||||
val reaction = ReactionEvent(id('3'), stranger, 1000L, pTagMe(stranger), "+", sig)
|
||||
assertFalse(params().match(reaction))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun myOwnEvent_isExcluded() {
|
||||
// My own note that p-tags me (e.g. a self-mention) is not a notification.
|
||||
val mine = TextNoteEvent(id('4'), me, 1000L, pTagMe(), "note to self", sig)
|
||||
assertFalse(params().match(mine))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun myOwnZap_isIncluded() {
|
||||
// Zaps are the self-exception: a self-directed zap still notifies.
|
||||
val zap = LnZapEvent(id('5'), me, 1000L, pTagMe(), "", sig)
|
||||
assertTrue(params().match(zap))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mutedAuthor_isExcluded() {
|
||||
val reaction = ReactionEvent(id('6'), stranger, 1000L, pTagMe(), "+", sig)
|
||||
assertFalse(params(hiddenUsers = setOf(stranger)).match(reaction))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun futureEvent_isExcluded() {
|
||||
val reaction = ReactionEvent(id('7'), stranger, TimeUtils.now() + 3600, pTagMe(), "+", sig)
|
||||
assertFalse(params().match(reaction))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonNotificationKind_isExcluded() {
|
||||
// kind:0 metadata is not in the notification kind set, even tagging me.
|
||||
val meta = MetadataEvent(id('8'), stranger, 1000L, pTagMe(), "{}", sig)
|
||||
assertFalse(meta.isNotificationRenderableKind())
|
||||
assertFalse(params().match(meta))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renderableKind_nullIsFalse() {
|
||||
assertFalse((null as Event?).isNotificationRenderableKind())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kinds_includeReactionsRepostsZaps() {
|
||||
assertTrue(ReactionEvent.KIND in NotificationFeedKinds)
|
||||
assertTrue(LnZapEvent.KIND in NotificationFeedKinds)
|
||||
assertTrue(TextNoteEvent.KIND in NotificationFeedKinds)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user