Compare commits

...

2 Commits

Author SHA1 Message Date
Claude
fb50866650 feat(cli): add Amethyst notifications feed to amy (snapshot + live --watch)
`amy notifications` (alias `notifs`) brings Amethyst's Notifications feed
to the CLI: 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.

A notification is, at heart, an event that p-tags you, so the whole feed is
one `{"#p":[me], "kinds":[…]}` REQ against your inbox relays. This
reproduces Amethyst's Global notification mode exactly. Its Selected mode
adds the per-kind `tagsAnEventByUser` relevance heuristics, which walk the
Note reply graph + LocalCache and so can't run against Amy's raw Event
store; those stay Android-only (documented in the plan).

Reuses the home-feed drawing model: snapshot (one JSON object) by default,
opt-in live JSONL tail under --watch, via the existing Context.stream /
Output.emitLine primitives.

Extraction (Rule 5): the notification kind taxonomy moves to
commons/.../ui/feeds/notifications/NotificationFeed.kt as the single source
of truth (NotificationFeedKinds + addressable subset), and Android's
NotificationFeedFilter.NOTIFICATION_KINDS now references it (pruning the
base-kind imports it no longer needs). New shared Event-level pieces:
isNotificationRenderableKind() (with returns(true)->non-null contract),
NotificationFeedParams.match() (Global-mode predicate), and
sortedByNotificationFeedOrder().

Also lifts mute-list loading out of HomeFeedCommand into a shared
Context.hiddenUsers() (public + private mutes via the signer), now used by
both feeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HqoV3FqwaiVfJud6hZXiTK
2026-06-20 03:14:24 +00:00
Claude
8220f4155b feat(cli): add Amethyst home feed to amy (snapshot + live --watch)
`amy notes home` brings Amethyst's home feed to the CLI: top-level posts
and reposts from the accounts you follow, across the full
HomeNewThreadFeedFilter kind set, with muted authors and threads removed.

The hard part is drawing a feed in a non-interactive CLI: relay events
arrive at any time and a terminal stream is append-only — you can't
re-sort lines you've already printed. So the command has two phases:

- snapshot (default): drain to EOSE, filter + sort newest-first, cap at
  --limit, emit one JSON object. Keeps amy's single-object contract.
- --watch: print the backfill page oldest-first, then keep the
  subscription open and emit each new live event as its own JSONL line
  (tail -f style) until --duration elapses or SIGINT.

Inclusion parity is shared, not duplicated: the kind-acceptance OR is
lifted out of HomeNewThreadFeedFilter into
commons/.../ui/feeds/home/HomeFeed.kt as Event?.isHomeFeedRenderableKind()
(with the same returns(true)->non-null contract so the Android call site
keeps its smart-cast), and the Android filter now calls it. Event-level
isNewThreadEvent(), HomeFeedParams (author-follow + mutes + future +
hashtag clamp) and sortedByHomeFeedOrder() round out the shared logic so
amy reproduces the feed against a raw Event store with no Note graph.

New CLI plumbing:
- Context.stream(): long-lived subscription that calls back per event
  past EOSE (drain returns at EOSE; requestResponse returns on first).
- Output.emitLine(): JSONL streaming contract, distinct from emit().
- Context.muteListOf(): cache-first kind:10000 read.

Known parity gaps (documented in cli/plans/2026-06-19-home-feed.md):
followed-hashtag/geo/community membership and repost-collapse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HqoV3FqwaiVfJud6hZXiTK
2026-06-20 00:01:04 +00:00
18 changed files with 1473 additions and 88 deletions

View File

@@ -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()
}

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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

View 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).

View 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).

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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>,

View File

@@ -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> {

View File

@@ -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() })
}
}

View File

@@ -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]}")
}
}

View File

@@ -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() })
}
}

View File

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

View File

@@ -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)

View File

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

View File

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