Skip to content

fix(notes): drop expired notes and match geohash tags case-insensitively - #896

Open
Chessing234 wants to merge 3 commits into
permissionlesstech:mainfrom
Chessing234:notes-expiry-and-case
Open

fix(notes): drop expired notes and match geohash tags case-insensitively#896
Chessing234 wants to merge 3 commits into
permissionlesstech:mainfrom
Chessing234:notes-expiry-and-case

Conversation

@Chessing234

Copy link
Copy Markdown

Two ways LocationNotesManager.handleEvent disagrees with the iOS handler on the same event.

NIP-40 expiry. iOS drops expired notes client-side, with the reason in the code:

// NIP-40: relays are not required to enforce expiration — drop
// expired notes client-side so 24h dead drops actually vanish.
let expiresAt = Self.expirationDate(of: event)
if let expiresAt, expiresAt <= self.dependencies.now() { return }

Android had no expiration check at all, so an expired note kept showing until the list was cleared or trimmed — the opposite of what a dead drop promises, and the kind of thing a user relies on rather than notices.

Tag case. Tag names and geohashes are case-insensitive; iOS lowercases both before matching:

tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())

Android compared them exactly (it[0] == "g", subscribedGeohashes.contains(eventGeohash)), so a note tagged ["G", "U4PRUYD"] appeared on iOS and was silently dropped on Android — two devices in the same cell showing different note lists.

Both now follow the iOS handler. The expiration helper mirrors LocationNotesManager.expirationDate on that side.

./gradlew :app:testDebugUnitTest :app:lintDebug passes locally (rc=0). The handler needs relay and Android dependencies to exercise end to end, so there is no unit test here; the change is a direct transcription of the iOS conditions.

Two ways the Android note handler disagrees with the iOS one, on the same
event:

- NIP-40. Relays are not required to drop expired events, so iOS checks
  the `expiration` tag client-side "so 24h dead drops actually vanish".
  Android had no such check, so an expired note kept showing until the
  list was cleared - the opposite of what a dead drop promises.
- Case. Tag names and geohashes are case-insensitive, and iOS lowercases
  both before matching. Android compared them exactly, so a note tagged
  ["G", "U4PRUYD"] was visible on iOS and invisible on Android.

Both now follow the iOS handler.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 293a751596

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +424 to 426
val geohashTag = event.tags.firstOrNull {
it.size >= 2 && it[0].lowercase() == "g" && validGeohashes.contains(it[1].lowercase())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize geohash tags before client filtering

For relay-delivered events with an uppercase G tag or uppercase geohash, this handler still will not run: LocationNotesInitializer subscribes with the original NostrFilter, and NostrRelayManager.handleMessage calls subInfo.filter.matches(response.event) before dispatch; NostrFilter.matches still compares tag names and values exactly (it[0] == tagName, eventValues.contains(requiredValue)). Because the lowercasing happens only here, those events are dropped in the relay layer and Android still diverges from the intended case-insensitive behavior unless the filter match/subscription path is normalized too.

AGENTS.md reference: AGENTS.md:L90-L91

Useful? React with 👍 / 👎.

// NIP-40: relays are not required to drop expired events, so enforce it
// here - otherwise a 24h dead drop stays visible past its expiry.
val expiresAt = expirationSeconds(event)
if (expiresAt != null && expiresAt * 1000L <= System.currentTimeMillis()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Schedule removal when expiration passes

When a note arrives before its NIP-40 expiration time, this check admits it into _notes, but the manager does not store expiresAt or schedule/poll any later removal. In a long-lived nearby-notes session, a 24h dead-drop received shortly before expiry therefore remains visible in the sheet/header until the user refreshes, moves cells, or stops the subscription, so the new expiration enforcement still misses the common “expires while displayed” case.

AGENTS.md reference: AGENTS.md:L90-L91

Useful? React with 👍 / 👎.

NostrRelayManager runs every incoming event through NostrFilter.matches
before any handler sees it, and that comparison was exact. So the
case-insensitive handling in the previous commit was unreachable over the
relay path: a note tagged ["G", "U4PRUYD"] was dropped at the gate.

iOS has no such client-side filter and lowercases the tag name and
geohash where it reads them, so the note is visible there. The values
this app filters on are hex ids or geohashes, both of which encode the
same value in either case.
A dead drop can cross its NIP-40 expiry while it is on screen. Filtering
at ingest alone kept it visible until the subscription was recreated, so
the note outlived its expiry exactly in the session where someone is
reading it.

Notes now carry their expiry, and a 60s job started with the
subscription (and cancelled with it) drops the ones that have passed --
the same interval and behaviour as the iOS manager. Ids stay in noteIDs
so a relay replay cannot resurrect a dropped note.
@Chessing234

Copy link
Copy Markdown
Author

both right, thanks — checked the relay path and the filter gate does run first (NostrRelayManager.handleMessage -> subInfo.filter.matches before dispatch), so the lowercasing in the handler was unreachable over a live subscription.

26100f4: NostrFilter.matches now compares tag names and values case-insensitively. the values this app filters on are hex ids (e, p) or geohashes (g) and both encode the same value in either case, and iOS has no client-side filter at all, so this is the behaviour android was supposed to have. added a test that a filter for u4pruyd accepts ["G", "U4PRUYD"] and still rejects a different geohash — it fails with the old exact comparison and passes now.

15af9bf: notes carry their expiry, and a 60s job started with the subscription and cancelled with it drops the ones that have passed — same interval as the iOS manager's prune timer. ids stay in noteIDs so a replay can't resurrect a dropped note. the pruning itself is a pure function so the boundary case (expiry exactly now) is covered in the tests.

full :app:testDebugUnitTest and :app:lintDebug green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant