Skip to content

grouped UI feeds, nip-05, favorites#3392

Open
alltheseas wants to merge 11 commits intodamus-io:masterfrom
alltheseas:nip05-grouped-ui
Open

grouped UI feeds, nip-05, favorites#3392
alltheseas wants to merge 11 commits intodamus-io:masterfrom
alltheseas:nip05-grouped-ui

Conversation

@alltheseas
Copy link
Copy Markdown
Collaborator

@alltheseas alltheseas commented Dec 8, 2025

Summary

Adds a fevela-style grouped view mode to NIP-05 domain feeds, allowing users to see posts organized by author rather than chronologically.

Depends on: #3376 (Fix nip05 discovery and double filtering)

Closes: #3379

Features

  • Grouped mode: Shows one row per author with post count and preview
  • Filter settings sheet: Time range (24h/7d), keyword exclusion, short note filtering
  • Compact header: Reduced height, stacked avatars with text below
  • Local filtering: Keyword and short note filters apply client-side without refetch

Commits

  1. Add configurable size parameter to CondensedProfilePicturesView

    • Enables smaller avatars (24pt) for compact layouts
  2. Add NIP-05 filter settings UI with grouped mode support

    • NIP05FilterSettings ObservableObject with all filter options
    • NIP05FilterButton compact pill-style button
    • NIP05FilterSettingsView full settings sheet
  3. Add grouped list view for NIP-05 domain feeds

    • NIP05GroupedAuthorRow with profile, preview, and count badge
    • NIP05GroupedListView with fevela-style filtering logic
  4. Improve NIP-05 header layout with stacked avatars

    • Avatars above text description
    • Reduced avatar size (24pt vs 32pt)
    • 2-line text for longer author lists
  5. Integrate grouped mode into NIP-05 domain timeline

    • Toggle between grouped and timeline modes
    • Filter button in header row
    • Reduced header height (160pt vs 200pt)

Standard PR Checklist

  • I have read (or I am familiar with) the Contribution Guidelines
  • I have tested the changes in this PR
  • I have profiled the changes to ensure there are no performance regressions, or I do not need to profile the changes.
    • If not needed, provide reason: UI-only changes, filtering is O(n) on existing events
  • I have opened or referred to an existing github issue related to this change: nip-05 grouped UI frequent poster grouping #3379
  • My PR is either small, or I have split it into smaller logical commits that are easier to review
  • I have added the signoff line to all my commits
  • I have added appropriate changelog entries for the changes in this PR
    • I do not need to add a changelog entry. Reason: Feature addition, will add changelog when merged
  • I have added appropriate Closes: or Fixes: tags in the commit messages wherever applicable

Test report

  1. Open any user's profile with a NIP-05 identifier
  2. Tap on their NIP-05 domain (e.g., @damus.io)
  3. Observe grouped view with author rows and post counts
  4. Tap filter button (pill in header)
  5. Try different settings: time range, keyword filter, hide short notes
  6. Toggle between Grouped and Timeline modes

Results:

  • PASS

Screenshots

image

Summary by CodeRabbit

Release Notes

  • New Features

    • Added grouped timeline mode organizing posts by author with customizable filters
    • Enhanced domain discovery and filtering capabilities
    • New filter controls for time range, keywords, and reply inclusion
    • Improved domain timeline header display
  • Bug Fixes

    • Added database transaction validation to prevent invalid operations
  • Tests

    • Added comprehensive test coverage for grouped timeline features

@alltheseas
Copy link
Copy Markdown
Collaborator Author

Inspired by @dtonon

@danieldaquino danieldaquino added the pr-in-queue This PR is waiting in a queue behind their other PRs marked with the label `pr-active-review`. label Dec 10, 2025
@alltheseas
Copy link
Copy Markdown
Collaborator Author

Closing in favor of consolidated Linux-style topic branches. This work will be audited against AGENTS.md requirements and cherry-picked into ordered topic branches. See #3624 for tracking.

@alltheseas alltheseas closed this Feb 13, 2026
@alltheseas
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review please

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 16, 2026

@alltheseas: I'll review the changes in this PR for you.


🧠 Learnings used
Learnt from: CR
Repo: damus-io/damus PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-08T01:12:51.605Z
Learning: Review and follow pull_request_template.md when creating PRs for iOS Damus

Learnt from: CR
Repo: damus-io/damus PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-08T01:12:51.605Z
Learning: Code should be human reviewable
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 16, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR introduces a grouped timeline feature that organizes Nostr events by author. It adds filtering, pagination, and backfilling capabilities to timeline views, with domain-based author discovery for NIP-05 integration, comprehensive filtering options, and new modular UI components for grouped and filtered event display.

Changes

Cohort / File(s) Summary
GroupedTimeline Components
damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift, GroupedFilterButton.swift, GroupedFilterSettingsView.swift, GroupedListView.swift, GroupedTimelineGrouper.swift, GroupedModeQueueManager.swift
New SwiftUI components and logic for grouped feed display. Includes author row rendering with preview text and post count badge, filter button with time-range enum, filter settings model and form view, grouping and filtering logic with word-based and short-note filtering, and queue management utility for grouped mode transitions.
NIP-05 Domain Model & Helpers
damus/Features/NIP05/Models/NIP05DomainEventsModel.swift, damus/Features/NIP05/Models/NIP05.swift
Extends domain events model with @MainActor, pagination state (loading_more, has_more, last_loaded_count), friend-filter support, and load_more/fetch_older methods. Introduces internal NIP05DomainHelpers with domain matching, validated author lookup, and cached profile scanning utilities for domain-based author discovery in friend-filtered and all-author modes.
NIP-05 Domain Timeline Integration
damus/Features/NIP05/Views/NIP05DomainTimelineView.swift, damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift
Reworks NIP05DomainTimelineView to support grouped and chronological layouts via filterSettings.enableGroupedMode; adds filter controls and backfill support. Modularizes header into NIP05DomainTitleView and NIP05DomainFriendsView with favicon and domain navigation; introduces localized string helpers for author notes text.
Timeline Integration
damus/Features/Timeline/Views/PostingTimelineView.swift, damus/Features/Timeline/Models/HomeModel.swift
Adds grouped mode UI with GroupedListView and filter settings persistence; introduces backfilling and refresh helpers (refreshGroupedFromNdb, backfillForGroupedMode, refreshSingleAuthorEvents) with author-chunked fetching and per-author refresh support; exposes backfilling state for shimmer display.
Profile Component Refinement
damus/Features/Profile/Views/CondensedProfilePicturesView.swift
Adds configurable size parameter (default 32.0) to CondensedProfilePicturesView initializer with derived pictureOffset calculation; reverses rendering order and adjusts padding based on size and max pictures.
Database Utilities
nostrdb/src/nostrdb.c
Adds ndb_txn_is_valid() helper for early transaction validation in ndb_get_tsid, ndb_lookup_by_key, ndb_get_profile_by_pubkey, ndb_get_note_by_id, ndb_get_indexkey_by_id, and ndb_read_last_profile_fetch to prevent invalid pointer dereferences.
Project Configuration
damus.xcodeproj/project.pbxproj
Registers new GroupedTimeline component files and GroupedModeQueueManager.swift as PBXFileReferences; organizes them under a new GroupedTimeline group; updates secp256k1 dependency references from "secp256k1" to "secp256k1.swift".
Test Suite
damusTests/GroupedModeQueueManagerTests.swift, damusTests/GroupedTimelineGrouperTests.swift
Comprehensive tests for queue flushing behavior and grouping/filtering logic covering empty inputs, author grouping, sorting, reply/time/keyword/short-note filtering, max notes per user, and parseFilteredWords normalization.

Sequence Diagram

sequenceDiagram
    participant User
    participant TimelineView as TimelineView<br/>(Grouped Mode)
    participant Model as NIP05DomainEventsModel
    participant Helpers as NIP05DomainHelpers
    participant DB as Nostrdb
    participant Grouper as GroupedTimelineGrouper
    participant Display as GroupedListView

    User->>TimelineView: Enable grouped mode
    TimelineView->>Model: subscribe(domain, friend_filter)
    Model->>Helpers: authors_for_domain(domain, friend_filter)
    
    alt Friend-filtered mode
        Helpers->>DB: scan_all_cached_profiles(domain)
        DB-->>Helpers: matching pubkeys
    else All authors mode
        Helpers->>Helpers: validated_authors_for_domain(domain)
    end
    
    Helpers-->>Model: [Pubkey, ...]
    Model->>Model: streamItems() with filter.authors
    
    loop Receive events
        Model->>Model: add_event(ev)
        Model->>Display: insert via EventHolder
    end
    
    Display->>Grouper: group(events, filter, settings)
    Grouper-->>Display: [AuthorGroup, ...]
    Display->>User: Render grouped list by author
    
    User->>TimelineView: Load more / scroll
    TimelineView->>Model: load_more()
    Model->>Model: fetch_older(until)
    Model-->>Display: Insert older events
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

technical, pr-active-review

Suggested reviewers

  • danieldaquino

Poem

🐰 A grouped timeline sprouts, organized by author's hand,
With filtering words and time ranges to command,
Backfilling speeds the discovery, friends-of-friends align,
Each pubkey grouped in rows, a harvest so fine! 🌿✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'grouped UI feeds, nip-05, favorites' is vague and uses generic terms that do not clearly convey the main changes. Revise to a more specific, single-sentence title that highlights the primary change, e.g., 'Add grouped view mode to NIP-05 domain feeds' or 'Implement author-grouped timeline view for NIP-05 domains'.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description includes a comprehensive summary of changes, linked issues, test report with device/steps, and completed standard checklist items covering contribution guidelines, testing, and issue references.
Linked Issues check ✅ Passed The PR successfully implements the grouped UI feature for NIP-05 domain feeds as specified in issue #3379, with grouped mode rendering one row per author with post count and preview, filter settings, and toggle between grouped and timeline modes.
Out of Scope Changes check ✅ Passed All changes are directly related to the grouped view mode feature for NIP-05 domain feeds. Supporting changes to project configuration, NDB validation, HomeModel backfilling, and PostingTimelineView grouped integration are all within scope for enabling grouped mode functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@alltheseas alltheseas reopened this Feb 16, 2026
@alltheseas
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review please

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 16, 2026

@alltheseas: I'll review the changes in this PR. Even though it's closed, the review will help inform the cherry-picking process as you prepare to reintroduce this work in ordered topic branches per AGENTS.md.


🧠 Learnings used
Learnt from: CR
Repo: damus-io/damus PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-08T01:12:51.605Z
Learning: Review and follow pull_request_template.md when creating PRs for iOS Damus

Learnt from: CR
Repo: damus-io/damus PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-08T01:12:51.605Z
Learning: Code should be human reviewable
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🤖 Fix all issues with AI agents
Verify each finding against the current code and only fix it if needed.


In `@damus/Features/NIP05/Models/NIP05.swift`:
- Around line 121-193: scan_all_cached_profiles is marked `@MainActor` but does
expensive ndb.query work — move it off the main actor by making
scan_all_cached_profiles async (remove `@MainActor`) and perform the DB work on a
background executor (e.g., Task.detached or
DispatchQueue.global()/withCheckedContinuation) so ndb.query and per-note
processing do not block the UI; change its signature to async -> Set<Pubkey>,
ensure any UI-bound profile access is hopped back to `@MainActor` only when
needed, and update authors_for_domain to be async and await
scan_all_cached_profiles (and propagate async/await to callers), keeping names
scan_all_cached_profiles and authors_for_domain to locate the changes.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift`:
- Around line 166-171: fetch_older currently leaves moreFilter.authors nil when
friend_filter == .all, causing uncontrolled queries; update fetch_older to
always populate moreFilter.authors using the same domain helper used by
streamItems (the domain helpers/author list generation logic referenced near
streamItems()) instead of setting it to nil — remove the conditional that
assigns nil and call the helper to set moreFilter.authors (preserving the
existing friend_filter == .friends_of_friends behavior when that case needs a
different list) so pagination queries always constrain by the domain author
list.
- Around line 76-106: The method resolve_domain_match(pubkey: Pubkey) is unused
and contains a flawed fire-and-forget cleanup (the defer spawns a Task to remove
from requesting_profiles) and lacks the required docstring; remove the entire
nonisolated private func resolve_domain_match implementation (including its
defer and use of requesting_profiles and metaFilter/timedStream logic) from
NIP05DomainEventsModel.swift to eliminate dead code and the misleading async
cleanup; if you intend to keep similar behavior later, replace with a
documented, properly awaited MainActor removal of requesting_profiles and add a
docstring then.
- Around line 143-147: The DispatchQueue.main.async blocks inside the `@MainActor`
class NIP05DomainEventsModel are redundant and should be removed; directly set
the properties (self.loading = false, self.used_initial_page = true,
self.last_loaded_count = self.events.all_events.count) on the main actor without
re-dispatching, and apply the same simplification for the other occurrences that
update properties (e.g., the blocks around where you modify self.loading,
self.used_initial_page, and any assignments to self.events or
self.last_loaded_count at the other two locations referenced).
- Around line 29-37: The constructors and public/internal methods in
NIP05DomainEventsModel lack docstrings; add concise Swift documentation comments
(///) above the init(state:domain:friend_filter:), subscribe(...),
set_friend_filter(...), load_more(), add_event(...), and streamItems(...)
declarations explaining purpose, parameters, return values, and
thread/side-effect notes as appropriate to meet coding guidelines; ensure the
docstrings describe the role of DamusState, domain, FriendFilter, EventHolder
behavior and any async/queue semantics so reviewers can quickly understand
usage.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift`:
- Around line 102-127: notesFromAuthorsString and friendsOfFriendsString
duplicate the same logic; consolidate into one reusable function (e.g.,
notesFromAuthorsString or a new helper like authorsSummaryString(authors:
[Pubkey], ndb: Ndb, emptyMessageKey: String, locale: Locale = .current)) that:
looks up up to three display names using ndb.lookup_profile and
Profile.displayName, truncates names, and builds the localized string via
bundleForLocale, NSLocalizedString/localizedStringFormat exactly as the current
switch does; add a parameter for the empty-state key/message so callers
(formerly friendsOfFriendsString) can pass their own NSLocalizedString key or
message, and replace both original functions with thin wrappers that call the
new helper (preserving ndb, Profile.displayName usage and
localizedStringFormat).
- Around line 58-65: The computed property allAuthors uses NSMutableOrderedSet
which can fail to bridge non-ObjC Pubkey values; replace the bridging with a
pure-Swift dedupe that preserves order: combine model.events.all_events.map {
$0.pubkey } and model.filter.authors ?? [], then iterate adding each pubkey to a
result array only if a seen Set (e.g., Set<Pubkey> using Pubkey’s Hashable
conformance) doesn't already contain it. Update the allAuthors getter to build
and return this ordered, deduplicated [Pubkey] (referencing allAuthors, Pubkey,
model.events.all_events, and model.filter.authors).

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift`:
- Line 50: Add a concise docstring above the `@Environment`(\.presentationMode)
var presentationMode declaration in NIP05DomainTimelineView describing what
presentationMode represents (the view's presentation/dismissal binding), how
it's used (to dismiss the view or check presentation state), and any important
behavior or threading assumptions; keep it one or two sentences following
project docstring style and place it directly above the presentationMode
property declaration.
- Around line 19-25: Add a docstring for the computed property `localizedTitle`
on the enum (the property returning NSLocalizedString for `.grouped` and
`.timeline`) describing its purpose (provides the localized tab label for the
domain timeline view), including what values it returns and that it is used for
UI tab labels; place the doc comment immediately above `var localizedTitle:
String` and keep it brief and user-facing, mentioning the `.grouped` and
`.timeline` cases and that strings are localized via NSLocalizedString.

In `@damus/Features/NIP05/Views/NIP05FilterBar.swift`:
- Around line 43-45: The struct NIP05FilterButton is missing docstrings for its
properties; add concise documentation comments above the `@ObservedObject` var
settings: NIP05FilterSettings and the `@Binding` var showFilterSheet: Bool
describing their purpose and ownership (e.g., that settings holds the filter
configuration and is observed for UI updates, and showFilterSheet is a bound
flag controlling presentation of the filter sheet). Use standard Swift doc
comment style (///) and keep descriptions short and focused so the new/modified
properties meet docstring coverage requirements.
- Around line 75-79: The filterSummary property uses hardcoded English strings
("Grouped"/"Timeline"); change it to use the view mode's localized title
instead: derive the mode label from the view-mode enum or a localized property
(e.g., use settings.viewMode.localizedTitle or a localized string lookup keyed
by settings.enableGroupedMode) and keep time as
settings.timeRange.localizedTitle so the returned string is fully localized;
update references in filterSummary to use that localized value rather than the
hardcoded "Grouped"/"Timeline".

In `@damus/Features/NIP05/Views/NIP05FilterSettingsView.swift`:
- Around line 111-118: The Picker is using a hardcoded array [3, 5, 10, 20]
instead of the declared maxNotesOptions; update the ForEach to iterate over
maxNotesOptions (e.g., ForEach(maxNotesOptions, id: \.self)) and keep the
selection binding settings.maxNotesPerUser and tag(count as Int?) so the types
remain Int? and the options stay in sync with the static array defined on the
type.
- Line 5: Remove or replace the atypical AI tool attribution in the file header
of NIP05FilterSettingsView.swift (the "Created by Claude" comment) so it matches
project conventions; update the header comment to the real author name or
project-standard metadata (or remove the line entirely) in the top-of-file
comment block so the file attribution is consistent with other source files.

In `@damus/Features/NIP05/Views/NIP05GroupedAuthorRow.swift`:
- Around line 52-67: Add a brief docstring above the computed property
postCountBadge describing its purpose and behavior: mention that it renders the
author's post count as a stylized Capsule badge, that it adapts font size and
horizontal padding when postCount is >= 100, and that it uses DamusColors.purple
for foreground and background styling; place the comment immediately above the
postCountBadge property so future readers and docs understand what the view
represents and how the layout changes with large counts.

In `@damus/Features/NIP05/Views/NIP05GroupedListView.swift`:
- Around line 115-140: The computed property authorGroups performs an expensive
grouping over events.all_events on each render; move this work off the main
thread by changing authorGroups from a computed property into an
asynchronously-updated published/state-backed property (e.g., `@State` or
`@Published` in the model) and compute its value in a background Task or
DispatchQueue.global() worker that filters via shouldIncludeEvent, applies
settings.maxNotesPerUser, and builds the [AuthorGroup] array (using
AuthorGroup(pubkey:..., latestEvent:..., postCount:...)); when the background
work completes assign the result back to the UI-bound property on the MainActor
so the view reads the precomputed array instead of recomputing on render.

In `@damus/Features/Profile/Views/CondensedProfilePicturesView.swift`:
- Around line 19-22: Add a docstring to the initializer init(state: DamusState,
pubkeys: [Pubkey], maxPictures: Int, size: CGFloat = 32.0) on
CondensedProfilePicturesView describing the purpose of the initializer and
documenting each parameter (state, pubkeys, maxPictures, size) and any defaults
or behavior (e.g., how maxPictures and size affect rendering); place the comment
immediately above the init declaration using the project's Swift doc comment
style (///) so it's picked up by documentation tools and satisfies the
coding-guideline requirement.
🧹 Nitpick comments (5)
🤖 Fix all nitpicks with AI agents
Verify each finding against the current code and only fix it if needed.


In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift`:
- Around line 143-147: The DispatchQueue.main.async blocks inside the `@MainActor`
class NIP05DomainEventsModel are redundant and should be removed; directly set
the properties (self.loading = false, self.used_initial_page = true,
self.last_loaded_count = self.events.all_events.count) on the main actor without
re-dispatching, and apply the same simplification for the other occurrences that
update properties (e.g., the blocks around where you modify self.loading,
self.used_initial_page, and any assignments to self.events or
self.last_loaded_count at the other two locations referenced).
- Around line 29-37: The constructors and public/internal methods in
NIP05DomainEventsModel lack docstrings; add concise Swift documentation comments
(///) above the init(state:domain:friend_filter:), subscribe(...),
set_friend_filter(...), load_more(), add_event(...), and streamItems(...)
declarations explaining purpose, parameters, return values, and
thread/side-effect notes as appropriate to meet coding guidelines; ensure the
docstrings describe the role of DamusState, domain, FriendFilter, EventHolder
behavior and any async/queue semantics so reviewers can quickly understand
usage.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift`:
- Around line 102-127: notesFromAuthorsString and friendsOfFriendsString
duplicate the same logic; consolidate into one reusable function (e.g.,
notesFromAuthorsString or a new helper like authorsSummaryString(authors:
[Pubkey], ndb: Ndb, emptyMessageKey: String, locale: Locale = .current)) that:
looks up up to three display names using ndb.lookup_profile and
Profile.displayName, truncates names, and builds the localized string via
bundleForLocale, NSLocalizedString/localizedStringFormat exactly as the current
switch does; add a parameter for the empty-state key/message so callers
(formerly friendsOfFriendsString) can pass their own NSLocalizedString key or
message, and replace both original functions with thin wrappers that call the
new helper (preserving ndb, Profile.displayName usage and
localizedStringFormat).
- Around line 58-65: The computed property allAuthors uses NSMutableOrderedSet
which can fail to bridge non-ObjC Pubkey values; replace the bridging with a
pure-Swift dedupe that preserves order: combine model.events.all_events.map {
$0.pubkey } and model.filter.authors ?? [], then iterate adding each pubkey to a
result array only if a seen Set (e.g., Set<Pubkey> using Pubkey’s Hashable
conformance) doesn't already contain it. Update the allAuthors getter to build
and return this ordered, deduplicated [Pubkey] (referencing allAuthors, Pubkey,
model.events.all_events, and model.filter.authors).

In `@damus/Features/NIP05/Views/NIP05FilterSettingsView.swift`:
- Line 5: Remove or replace the atypical AI tool attribution in the file header
of NIP05FilterSettingsView.swift (the "Created by Claude" comment) so it matches
project conventions; update the header comment to the real author name or
project-standard metadata (or remove the line entirely) in the top-of-file
comment block so the file attribution is consistent with other source files.
damus/Features/NIP05/Models/NIP05DomainEventsModel.swift (2)

143-147: Redundant DispatchQueue.main.async inside @MainActor class.

Since NIP05DomainEventsModel is @MainActor, all instance methods already run on the main actor. Using DispatchQueue.main.async re-dispatches to the next run-loop iteration, which introduces unnecessary delay and can cause subtle ordering issues with SwiftUI observation. The same pattern repeats at lines 180 and 195.

♻️ Proposed fix (line 143-147)
             case .eose:
-                DispatchQueue.main.async {
-                    self.loading = false
-                    self.used_initial_page = true
-                    self.last_loaded_count = self.events.all_events.count
-                }
+                self.loading = false
+                self.used_initial_page = true
+                self.last_loaded_count = self.events.all_events.count
                 continue

Apply the same simplification at lines 180-188 and 195-197.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift` around lines 143 -
147, The DispatchQueue.main.async blocks inside the `@MainActor` class
NIP05DomainEventsModel are redundant and should be removed; directly set the
properties (self.loading = false, self.used_initial_page = true,
self.last_loaded_count = self.events.all_events.count) on the main actor without
re-dispatching, and apply the same simplification for the other occurrences that
update properties (e.g., the blocks around where you modify self.loading,
self.used_initial_page, and any assignments to self.events or
self.last_loaded_count at the other two locations referenced).

29-37: Missing docstrings on public/internal methods.

Per coding guidelines, docstring coverage is required for added/modified code. Key methods like init, subscribe, set_friend_filter, load_more, add_event, and streamItems lack documentation. As per coding guidelines: "Ensure docstring coverage for any code added or modified."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift` around lines 29 -
37, The constructors and public/internal methods in NIP05DomainEventsModel lack
docstrings; add concise Swift documentation comments (///) above the
init(state:domain:friend_filter:), subscribe(...), set_friend_filter(...),
load_more(), add_event(...), and streamItems(...) declarations explaining
purpose, parameters, return values, and thread/side-effect notes as appropriate
to meet coding guidelines; ensure the docstrings describe the role of
DamusState, domain, FriendFilter, EventHolder behavior and any async/queue
semantics so reviewers can quickly understand usage.
damus/Features/NIP05/Views/NIP05FilterSettingsView.swift (1)

5-5: AI tool attribution in file header.

The "Created by Claude" attribution is atypical for a project's source files. Consider replacing with the actual author's name or handle for consistency with other files in the codebase.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05FilterSettingsView.swift` at line 5, Remove
or replace the atypical AI tool attribution in the file header of
NIP05FilterSettingsView.swift (the "Created by Claude" comment) so it matches
project conventions; update the header comment to the real author name or
project-standard metadata (or remove the line entirely) in the top-of-file
comment block so the file attribution is consistent with other source files.
damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift (2)

102-127: notesFromAuthorsString and friendsOfFriendsString are near-duplicates.

These two functions share identical logic (name lookup, switch on count, same format strings) — the only differences are the case 0 fallback and localization comments. Consider consolidating into a single function with a parameter for the empty-state message.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift` around lines
102 - 127, notesFromAuthorsString and friendsOfFriendsString duplicate the same
logic; consolidate into one reusable function (e.g., notesFromAuthorsString or a
new helper like authorsSummaryString(authors: [Pubkey], ndb: Ndb,
emptyMessageKey: String, locale: Locale = .current)) that: looks up up to three
display names using ndb.lookup_profile and Profile.displayName, truncates names,
and builds the localized string via bundleForLocale,
NSLocalizedString/localizedStringFormat exactly as the current switch does; add
a parameter for the empty-state key/message so callers (formerly
friendsOfFriendsString) can pass their own NSLocalizedString key or message, and
replace both original functions with thin wrappers that call the new helper
(preserving ndb, Profile.displayName usage and localizedStringFormat).

58-65: NSMutableOrderedSet bridge may silently fail for non-ObjC types.

If Pubkey doesn't bridge to NSObject, NSMutableOrderedSet(array:) will box the values as opaque objects and the as? [Pubkey] downcast may fail, silently returning []. A pure-Swift approach avoids this risk entirely.

♻️ Proposed fix
     private var allAuthors: [Pubkey] {
         let eventAuthors = model.events.all_events.map { $0.pubkey }
         let filterAuthors = model.filter.authors ?? []
         let combined = eventAuthors + filterAuthors
-
-        // NSMutableOrderedSet removes duplicates while preserving insertion order
-        return NSMutableOrderedSet(array: combined).array as? [Pubkey] ?? []
+
+        var seen = Set<Pubkey>()
+        return combined.filter { seen.insert($0).inserted }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift` around lines
58 - 65, The computed property allAuthors uses NSMutableOrderedSet which can
fail to bridge non-ObjC Pubkey values; replace the bridging with a pure-Swift
dedupe that preserves order: combine model.events.all_events.map { $0.pubkey }
and model.filter.authors ?? [], then iterate adding each pubkey to a result
array only if a seen Set (e.g., Set<Pubkey> using Pubkey’s Hashable conformance)
doesn't already contain it. Update the allAuthors getter to build and return
this ordered, deduplicated [Pubkey] (referencing allAuthors, Pubkey,
model.events.all_events, and model.filter.authors).

Comment thread damus/Features/NIP05/Models/NIP05.swift Outdated
Comment thread damus/Features/NIP05/Models/NIP05DomainEventsModel.swift Outdated
Comment thread damus/Features/NIP05/Models/NIP05DomainEventsModel.swift Outdated
Comment on lines +19 to +25
var localizedTitle: String {
switch self {
case .grouped:
return NSLocalizedString("Grouped", comment: "Tab label for grouped view of posts by author")
case .timeline:
return NSLocalizedString("Timeline", comment: "Tab label for chronological timeline view")
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add a docstring for localizedTitle.

This is a new computed property on a user-facing enum and should be documented.

📝 Suggested docstring
-    var localizedTitle: String {
+    /// Localized label used in mode-selection UI.
+    var localizedTitle: String {

As per coding guidelines, Ensure docstring coverage for any code added or modified.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift` around lines 19 -
25, Add a docstring for the computed property `localizedTitle` on the enum (the
property returning NSLocalizedString for `.grouped` and `.timeline`) describing
its purpose (provides the localized tab label for the domain timeline view),
including what values it returns and that it is used for UI tab labels; place
the doc comment immediately above `var localizedTitle: String` and keep it brief
and user-facing, mentioning the `.grouped` and `.timeline` cases and that
strings are localized via NSLocalizedString.

/// Controls visibility of the filter settings sheet.
@State private var showFilterSheet: Bool = false

@Environment(\.presentationMode) var presentationMode
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add a docstring for presentationMode.

This is a newly added environment property and should be documented.

📝 Suggested docstring
-    `@Environment`(\.presentationMode) var presentationMode
+    /// Presentation mode for dismissing the timeline view.
+    `@Environment`(\.presentationMode) var presentationMode

As per coding guidelines, Ensure docstring coverage for any code added or modified.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Environment(\.presentationMode) var presentationMode
/// Presentation mode for dismissing the timeline view.
`@Environment`(\.presentationMode) var presentationMode
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift` at line 50, Add a
concise docstring above the `@Environment`(\.presentationMode) var
presentationMode declaration in NIP05DomainTimelineView describing what
presentationMode represents (the view's presentation/dismissal binding), how
it's used (to dismiss the view or check presentation state), and any important
behavior or threading assumptions; keep it one or two sentences following
project docstring style and place it directly above the presentationMode
property declaration.

Comment thread damus/Shared/Components/GroupedTimeline/GroupedFilterButton.swift
Comment on lines +52 to +67
var postCountBadge: some View {
let fontSize: Font = postCount >= 100 ? .caption2.weight(.medium) : .caption.weight(.medium)
return Text("\(postCount)")
.font(fontSize)
.foregroundColor(DamusColors.purple)
.frame(minWidth: 28, minHeight: 28)
.padding(.horizontal, postCount >= 100 ? 4 : 0)
.background(
Capsule()
.fill(DamusColors.purple.opacity(0.1))
.overlay(
Capsule()
.stroke(DamusColors.purple.opacity(0.2), lineWidth: 1)
)
)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add a docstring for postCountBadge.

This is a new computed property and should be documented.

📝 Suggested docstring
-    var postCountBadge: some View {
+    /// Badge displaying the author’s post count in the grouped list.
+    var postCountBadge: some View {

As per coding guidelines, Ensure docstring coverage for any code added or modified.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05GroupedAuthorRow.swift` around lines 52 - 67,
Add a brief docstring above the computed property postCountBadge describing its
purpose and behavior: mention that it renders the author's post count as a
stylized Capsule badge, that it adapts font size and horizontal padding when
postCount is >= 100, and that it uses DamusColors.purple for foreground and
background styling; place the comment immediately above the postCountBadge
property so future readers and docs understand what the view represents and how
the layout changes with large counts.

Comment on lines +115 to +140
/// Groups all events by author, applying filters and tracking the latest event per author.
var authorGroups: [AuthorGroup] {
var groupsByAuthor: [Pubkey: (latest: NostrEvent, count: Int)] = [:]

for event in events.all_events {
guard shouldIncludeEvent(event) else { continue }

if let existing = groupsByAuthor[event.pubkey] {
// Update count; keep the more recent event as "latest"
let newLatest = event.created_at > existing.latest.created_at ? event : existing.latest
groupsByAuthor[event.pubkey] = (latest: newLatest, count: existing.count + 1)
} else {
groupsByAuthor[event.pubkey] = (latest: event, count: 1)
}
}

// Apply max notes per user filter (exclude prolific posters)
if let maxNotes = settings.maxNotesPerUser {
groupsByAuthor = groupsByAuthor.filter { $0.value.count <= maxNotes }
}

// Convert to array sorted by most recent activity
return groupsByAuthor
.map { AuthorGroup(pubkey: $0.key, latestEvent: $0.value.latest, postCount: $0.value.count) }
.sorted { $0.latestEvent.created_at > $1.latestEvent.created_at }
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid computing authorGroups on the main thread.

This grouping walks the entire event list on every render and can block UI for large feeds. Move the computation to a background task and publish the result back to the view (e.g., via @State or the model).

As per coding guidelines, Never block the main thread: All network requests, database queries, and expensive computations must run on background threads/queues using Task { }, DispatchQueue.global(), or Swift concurrency (async/await). UI updates must dispatch back to @MainActor.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05GroupedListView.swift` around lines 115 -
140, The computed property authorGroups performs an expensive grouping over
events.all_events on each render; move this work off the main thread by changing
authorGroups from a computed property into an asynchronously-updated
published/state-backed property (e.g., `@State` or `@Published` in the model) and
compute its value in a background Task or DispatchQueue.global() worker that
filters via shouldIncludeEvent, applies settings.maxNotesPerUser, and builds the
[AuthorGroup] array (using AuthorGroup(pubkey:..., latestEvent:...,
postCount:...)); when the background work completes assign the result back to
the UI-bound property on the MainActor so the view reads the precomputed array
instead of recomputing on render.

Comment on lines +19 to +22
init(state: DamusState, pubkeys: [Pubkey], maxPictures: Int, size: CGFloat = 32.0) {
self.model = CondensedProfilePicturesViewModel(state: state, pubkeys: pubkeys, maxPictures: maxPictures)
self.size = size
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add a docstring for the initializer.

This is a modified API surface and should be documented.

📝 Suggested docstring
-    init(state: DamusState, pubkeys: [Pubkey], maxPictures: Int, size: CGFloat = 32.0) {
+    /// Creates a condensed, overlapping stack of profile pictures.
+    init(state: DamusState, pubkeys: [Pubkey], maxPictures: Int, size: CGFloat = 32.0) {

As per coding guidelines, Ensure docstring coverage for any code added or modified.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
init(state: DamusState, pubkeys: [Pubkey], maxPictures: Int, size: CGFloat = 32.0) {
self.model = CondensedProfilePicturesViewModel(state: state, pubkeys: pubkeys, maxPictures: maxPictures)
self.size = size
}
/// Creates a condensed, overlapping stack of profile pictures.
init(state: DamusState, pubkeys: [Pubkey], maxPictures: Int, size: CGFloat = 32.0) {
self.model = CondensedProfilePicturesViewModel(state: state, pubkeys: pubkeys, maxPictures: maxPictures)
self.size = size
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Profile/Views/CondensedProfilePicturesView.swift` around lines
19 - 22, Add a docstring to the initializer init(state: DamusState, pubkeys:
[Pubkey], maxPictures: Int, size: CGFloat = 32.0) on
CondensedProfilePicturesView describing the purpose of the initializer and
documenting each parameter (state, pubkeys, maxPictures, size) and any defaults
or behavior (e.g., how maxPictures and size affect rendering); place the comment
immediately above the init declaration using the project's Swift doc comment
style (///) so it's picked up by documentation tools and satisfies the
coding-guideline requirement.

@alltheseas alltheseas force-pushed the nip05-grouped-ui branch 2 times, most recently from e581d31 to 651a654 Compare February 20, 2026 04:59
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🧹 Nitpick comments (14)
damus/Features/Timeline/Models/HomeModel.swift (4)

766-772: Add docstrings for backfillTask, ndbRefreshTask, and backfillPageLimit.

These new properties lack documentation. As per coding guidelines, ensure docstring coverage for any code added or modified.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Models/HomeModel.swift` around lines 766 - 772, Add
docstrings above the three properties describing their purpose and usage: for
backfillTask explain it is a Task<Void, Never>? that performs relay backfills to
fill older events for grouped mode and mention its lifecycle/cancellation
behavior; for ndbRefreshTask explain it is a Task<Void, Never>? responsible for
refreshing the ndb (or network DB) cache/state and when it runs; for
backfillPageLimit (UInt32) document that it is the maximum number of events
fetched per backfill page, include the unit and default value (5000) and any
constraints. Place the comments directly above the declarations for
backfillTask, ndbRefreshTask, and backfillPageLimit and keep them concise.

854-866: The double-break EOSE pattern is hard to follow.

The break on line 861 exits the switch, then line 865 re-checks item to break the for await loop. This works but is fragile. A labeled loop is the idiomatic Swift approach:

♻️ Labeled loop
-                for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [chunkFilter], streamMode: .ndbAndNetworkParallel(networkOptimization: nil)) {
+                streamLoop: for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [chunkFilter], streamMode: .ndbAndNetworkParallel(networkOptimization: nil)) {
                     switch item {
                     case .event(let lender):
                         ...
                     case .eose:
-                        break
+                        break streamLoop
                     case .ndbEose, .networkEose:
                         continue
                     }
-                    if case .eose = item { break }
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Models/HomeModel.swift` around lines 854 - 866, The
current double-break pattern (a `break` inside the `switch` for `.eose` and then
`if case .eose = item { break }` after the switch) is fragile; convert the
surrounding `for await` loop into a labeled loop (e.g., timelineLoop:) and
replace both the post-switch `if case .eose` check and the inner switch `case
.eose: break` behavior with a single `break timelineLoop` from inside the
`.eose` switch arm so that `lender.justUseACopy`, `should_show_event(state:
self.damus_state, ev:)`, and appending to `allEvents` remain unchanged but loop
exit is explicit and idiomatic.

869-878: Bulk insert loop and redundant DispatchQueue.main.async.

HomeModel is @MainActor and the Task inherits actor context, so self.backfilling = false can be set directly without DispatchQueue.main.async. The extra hop is unnecessary.

Also, the bulk insert iterates all events sequentially on the main actor. For large result sets (up to 5000 per chunk × N chunks), this could cause a UI hitch. Consider batching or yielding periodically.

♻️ Remove redundant dispatch
-            DispatchQueue.main.async { self.backfilling = false }
+            self.backfilling = false
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Models/HomeModel.swift` around lines 869 - 878, The
loop is doing sequential main-actor work and then uses a redundant
DispatchQueue.main.async to set self.backfilling; since HomeModel is `@MainActor`
the DispatchQueue hop should be removed and set backfilling directly (reference
HomeModel and backfilling). To avoid UI hitches when inserting large chunks,
change the insertion loop around damus_state.events /
favoriteEvents.insert(_:)/insert_home_event(_:) to perform batched inserts or
periodically yield to the executor (e.g., process N events then await
Task.yield() or insert in a batch) so the main actor can process UI work between
batches.

885-914: refreshSingleAuthorEvents spawns a fire-and-forget Task with no cancellation handle.

Unlike backfillTask and ndbRefreshTask, this task is not stored. Rapid calls (e.g., navigating between author profiles) will accumulate concurrent streams. Consider storing and cancelling the previous task, or document that concurrent calls are safe due to idempotent inserts.

♻️ Store the task for cancellation
+    private var singleAuthorRefreshTask: Task<Void, Never>?
+
     func refreshSingleAuthorEvents(pubkey: Pubkey, since: UInt32, source: TimelineSource) {
+        singleAuthorRefreshTask?.cancel()
         ...
-        Task {
+        singleAuthorRefreshTask = Task {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Models/HomeModel.swift` around lines 885 - 914, The
refreshSingleAuthorEvents function spawns a fire-and-forget Task (via
damus_state.nostrNetwork.reader.advancedStream) without keeping a handle,
causing concurrent streams to accumulate; change it to store the Task in a
cancellable property (e.g., singleAuthorRefreshTask) on the HomeModel, cancel
any existing task before creating a new one, and assign the new Task to that
property; ensure you cancel/cleanup this stored task where other stored tasks
are handled (similar to backfillTask and ndbRefreshTask) and on deinit or when
the stream should stop.
damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift (1)

91-95: Add docstrings to internal filtering methods.

parseFilteredWords, shouldIncludeEvent, containsFilteredWords, and isTooShort lack docstrings. As per coding guidelines, ensure docstring coverage for any code added or modified.

Also applies to: 97-110, 112-116

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift` around
lines 91 - 95, Add missing docstrings for the internal filtering helpers
parseFilteredWords, shouldIncludeEvent, containsFilteredWords, and isTooShort:
for each function add a brief Swift doc comment that states the purpose,
describes parameters and types, explains return value, and any important
behavior or side‑effects (e.g., trimming, lowercasing, minimum length). Place
the comments immediately above the function declarations so they follow project
docstring style and cover the functions at/around parseFilteredWords,
shouldIncludeEvent, containsFilteredWords, and isTooShort.
damus/Features/NIP05/Models/NIP05DomainEventsModel.swift (1)

82-132: Several methods lack docstrings.

streamItems, load_more, fetch_older, add_event, set_friend_filter, and matches_domain are all missing docstrings. As per coding guidelines, ensure docstring coverage for any code added or modified.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift` around lines 82 -
132, Add missing Swift docstrings for the listed methods: streamItems(),
load_more(), fetch_older(), add_event(_:), set_friend_filter(_:), and
matches_domain(_:). For each method add a brief triple-slash (///) comment
immediately above the function that states its purpose, describes parameters and
return value (or that it returns Void), notes async behavior or side effects
(e.g., updates state.loading, mutates events, triggers network streams, or
validates NIP-05), and any important invariants (e.g., when since is set use
different limits, or that matches_domain checks NIP-05 domain matching). Follow
the repository’s existing docstring style (brief summary line, parameter tags if
used) and keep comments concise.
damus/Features/NIP05/Models/NIP05.swift (2)

220-244: ordered_domain_authors uses NSMutableOrderedSet — consider a Swift-native approach.

NSMutableOrderedSet works but loses type safety (requires the as? [Pubkey] cast on line 231 with a fallback to []). A simple seen set with filter preserves order and avoids the Obj-C bridge:

♻️ Swift-native dedup
-        let pubkeys = eventPubkeys + (filterAuthors ?? [])
-        let orderedUnique = NSMutableOrderedSet(array: pubkeys).array as? [Pubkey] ?? []
+        let pubkeys = eventPubkeys + (filterAuthors ?? [])
+        var seen = Set<Pubkey>()
+        let orderedUnique = pubkeys.filter { seen.insert($0).inserted }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Models/NIP05.swift` around lines 220 - 244,
ordered_domain_authors currently uses NSMutableOrderedSet which loses Swift
type-safety; replace the Obj-C collection with a Swift-native dedup by iterating
the concatenated array (eventPubkeys + (filterAuthors ?? [])), maintaining a
Set<Pubkey> seen to skip duplicates while building a new [Pubkey] orderedUnique,
then proceed to filter with matches_domain(domain:profiles:) and the
friend_filter branch (contacts.is_in_friendosphere) unchanged; ensure Pubkey
remains Hashable or use an appropriate unique key when populating seen so the
behavior and order match the original.

184-201: Friends-of-friends loop on @MainActor may stall the UI for large social graphs.

The .friends_of_friends branch iterates contacts.get_friend_of_friends_list() — which can be thousands of pubkeys — calling matches_domain (profile lookups) for each, all on the main actor. Consider offloading this to a background task similar to how the .all case uses Task.detached for the scan.

♻️ Sketch
         case .friends_of_friends:
-            // WOT mode: Only show users in your trusted network
-            for pubkey in contacts.get_friend_of_friends_list() where matches_domain(pubkey, domain: domain, profiles: profiles) {
-                authors.insert(pubkey)
-            }
+            // WOT mode: capture the set, then filter on background
+            let fofList = contacts.get_friend_of_friends_list()
+            // matches_domain needs `@MainActor` for profile lookups,
+            // so batch the check to minimize main-thread time
+            for pubkey in fofList {
+                if matches_domain(pubkey, domain: domain, profiles: profiles) {
+                    authors.insert(pubkey)
+                }
+            }

A more thorough fix would make matches_domain not require @MainActor by snapshotting the needed profile data first.

As per coding guidelines, Never block the main thread: All network requests, database queries, and expensive computations must run on background threads/queues.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Models/NIP05.swift` around lines 184 - 201, The
friends-of-friends branch runs a potentially large loop on the main actor
(iterating contacts.get_friend_of_friends_list() and calling matches_domain),
which can stall the UI; move that work to a background task similar to the .all
case by performing the iteration inside Task.detached and returning the authors
set, or snapshot the minimal profile data off-main-thread first and make
matches_domain operate on that snapshot; update the .friends_of_friends case to
await the detached task and fall back to validated_authors_for_domain if the
result is empty.
damus/Shared/Components/GroupedTimeline/GroupedListView.swift (1)

29-35: group() recomputed synchronously in body on every render.

This is the call site for the O(n) grouping pass discussed in PostingTimelineView. Since events is @ObservedObject, any mutation (new event insertion, flush) triggers a body re-evaluation and a full regroup. Consider lifting this computation out of body — e.g., into a @State/view-model property that updates via .onChange(of:) or Combine, so re-renders that don't change inputs skip the grouping work.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedListView.swift` around lines
29 - 35, GroupedTimelineGrouper.group(...) is being called synchronously inside
GroupedListView.body (using events.all_events, filter, settings.filterValues,
Date()), causing an O(n) regroup on every SwiftUI body recompute; move this
heavy computation out of body into a stored state or view-model property (e.g.,
`@State` or `@StateObject/`@ObservedObject) such as a `grouped` property on
GroupedListView or its VM, and update that property only when inputs change by
using .onChange(of:) for events.all_events, filter, and settings.filterValues
(or subscribe to events' publisher via Combine) so renders that don't change
those inputs skip the grouping work.
damus/Features/Timeline/Views/PostingTimelineView.swift (1)

256-272: Add docstrings to new helper methods.

Per coding guidelines, all added or modified code should have docstring coverage. These private helpers (flushActiveSource, backfillForGroupedMode, refreshFromNdb) lack documentation.

📝 Suggested docstrings
+    /// Flushes queued events from the active timeline source (follows or favorites)
+    /// into the main events list and disables further queueing.
     `@MainActor`
     private func flushActiveSource() {
         ...
     }

+    /// Triggers a relay backfill for the grouped mode time window.
+    /// - Parameter showLoading: Whether to display a loading/shimmer state during the fetch.
     private func backfillForGroupedMode(showLoading: Bool = true) {
         ...
     }

+    /// Refreshes grouped mode data from the local nostrdb cache for the current time window.
     private func refreshFromNdb() {
         ...
     }

As per coding guidelines: "Ensure docstring coverage for any code added or modified."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Views/PostingTimelineView.swift` around lines 256 -
272, Add docstrings for the three private helper methods to satisfy docstring
coverage: document flushActiveSource() (annotated `@MainActor`) with a one-line
summary of what it flushes and any threading/actor expectations, document
backfillForGroupedMode(showLoading:) describing the purpose, the meaning and
default of the showLoading parameter, how the since timestamp is computed and
that it calls home.backfillForGroupedMode, and document refreshFromNdb()
summarizing the since computation and that it refreshes grouped data from NDB;
keep each docstring concise and follow existing project style (one-line summary
+ brief param/side-effect notes).
damus.xcodeproj/project.pbxproj (1)

1976-1981: Synthetic UUIDs are unusual but functional — consider regenerating via Xcode for consistency

The new GroupedTimeline entries use hand-crafted sequential UUIDs (e.g., A1B2C3D4E5F6A7B8C9D0E1F2, B1B2B3B4B5B6B7B8B9B0C1C2) rather than Xcode-generated random ones, which is unconventional. However, these UUIDs are:

  • Unique within the project (no collisions detected)
  • Properly referenced and functional
  • All associated source and test files exist on disk

While Xcode-generated UUIDs are the standard practice and preferable for long-term maintainability, the current entries pose no immediate functional risk. If consistency with the rest of the project is desired, re-add these files via Xcode's File → Add Files to "damus"… dialog to generate proper UUIDs; otherwise, these entries are stable as-is.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus.xcodeproj/project.pbxproj` around lines 1976 - 1981, The PBXBuildFile
entries for GroupedTimelineGrouper.swift and GroupedModeQueueManager.swift use
hand-crafted sequential UUIDs (e.g., A1B2C3D4E5F6A7B8C9D0E1F2 and
B1B2B3B4B5B6B7B8B9B0C1C2) which is unconventional; to fix, remove these manual
entries and re-add the files using Xcode's File → Add Files to "damus"… (or
close Xcode, run Xcode to re-generate UUIDs) so Xcode will create standard
random UUIDs for GroupedTimelineGrouper.swift and GroupedModeQueueManager.swift
and update the PBXBuildFile references consistently with the rest of the
project.
damus/Features/NIP05/Views/NIP05DomainTimelineView.swift (1)

56-58: contentFilters is re-created on every view body evaluation.

This computed property allocates a new ContentFilters and its closure array each time the view re-renders. Consider caching it or moving it to a stored property/model if profiling shows this view re-renders frequently (e.g., during streaming events).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift` around lines 56 -
58, The computed property contentFilters currently creates a new ContentFilters
(and its closures) every time the view re-evaluates; change it to a
stored/cached value to avoid repeated allocation — for example, initialize a
stored property once (e.g., a private let or lazy var contentFilters) or move it
into the view's model/@StateObject so ContentFilters.defaults(damus_state:) is
called only once; update references to use that stored contentFilters and ensure
it is recalculated only when damus_state actually changes.
damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift (2)

56-64: Profile DB lookups and full-event iteration run synchronously on the main thread.

allAuthors iterates every event in model.events.all_events on each body evaluation, and notesFromAuthorsString performs up to 3 ndb.lookup_profile_and_copy calls. Both execute synchronously within the view body (main actor). While LMDB reads are fast, the coding guidelines state: "All … database queries … must run on background threads." For small event counts this is fine in practice, but as the event set grows, allAuthors's O(n) pass on every render could become noticeable.

Consider pre-computing allAuthors in the model or caching it via @State / .task {}, and moving the profile lookups into the same async path.

As per coding guidelines, "Never block the main thread: All network requests, database queries, and expensive computations must run on background threads/queues."

Also applies to: 74-74

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift` around lines
56 - 64, The computed property allAuthors and the synchronous profile lookups
(ndb.lookup_profile_and_copy invoked by notesFromAuthorsString) run on the main
actor and iterate model.events.all_events on every view evaluation; move these
expensive DB reads and the O(n) event scan off the main thread by computing and
caching the authors list in the model (e.g., add a precomputed property or
method on the view model that updates on events change) or in the view using
`@State` plus a .task to populate it, and perform profile lookups from
notesFromAuthorsString in the same background task/queue (or expose an async
lookup method on the model) so the view body only reads cheap, precomputed data
(refer to allAuthors, notesFromAuthorsString, model.events.all_events, and
ndb.lookup_profile_and_copy).

101-126: Near-identical logic between notesFromAuthorsString and friendsOfFriendsString.

These two functions share the same structure — profile lookup, name truncation, and switch over count with the same format strings. The only real differences are the case 0 fallback text and the profile-lookup call (though friendsOfFriendsString manually inlines what lookup_profile_and_copy does). Consider extracting a shared helper parameterized by the zero-case string.

♻️ Sketch of a shared helper
+/// Shared implementation for building "Notes from X, Y & Z others" strings.
+private func notesFromString(_ pubkeys: [Pubkey], ndb: Ndb, locale: Locale, emptyMessage: String) -> String {
+    let bundle = bundleForLocale(locale: locale)
+    let names: [String] = pubkeys.prefix(3).map { pk in
+        let profile = try? ndb.lookup_profile_and_copy(pk)
+        return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 20)
+    }
+    switch pubkeys.count {
+    case 0:  return emptyMessage
+    case 1:  return String(format: NSLocalizedString("Notes from %@", bundle: bundle, comment: ""), locale: locale, names[0])
+    case 2:  return String(format: NSLocalizedString("Notes from %@ & %@", bundle: bundle, comment: ""), locale: locale, names[0], names[1])
+    case 3:  return String(format: NSLocalizedString("Notes from %@, %@ & %@", bundle: bundle, comment: ""), locale: locale, names[0], names[1], names[2])
+    default:
+        let format = localizedStringFormat(key: "notes_from_three_and_others", locale: locale)
+        return String(format: format, locale: locale, pubkeys.count - 3, names[0], names[1], names[2])
+    }
+}

Also applies to: 149-177

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift` around lines
101 - 126, Refactor the near-duplicate logic in notesFromAuthorsString and
friendsOfFriendsString into a single helper that returns the localized summary
string: extract the shared steps (profile lookup via a provided lookup function,
name extraction/truncation to 20 chars, and the switch on authors.count that
formats 0/1/2/3/+ cases) into a new function (e.g.,
summarizeAuthors(_:lookup:zeroCaseKey:ndb:locale:) or similar) and have both
notesFromAuthorsString and friendsOfFriendsString call it; parameterize the
helper with the zero-case localization key/string and a profile-lookup closure
(to accommodate ndb.lookup_profile_and_copy vs the inline logic), reuse
localizedStringFormat for the default case, and ensure you pass
Profile.displayName(...).username.truncate(maxLength: 20) as the name
transformation so behavior stays identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@damus.xcodeproj/project.pbxproj`:
- Line 2040: The PBXFileReference entries for the new GroupedTimeline files
(e.g., GroupedFilterButton.swift) incorrectly include the non-standard attribute
includeInIndex = 1; remove that attribute from each PBXFileReference (the
entries named for GroupedFilterButton.swift and the other GroupedTimeline files)
or, preferably, re-add the files via Xcode so Xcode generates proper
PBXFileReference entries without includeInIndex; update the PBX entries (the
PBXFileReference blocks) to match existing source file references by deleting
includeInIndex = 1.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift`:
- Around line 44-61: The subscribe(...) method currently resets pagination state
(used_initial_page, has_more, last_loaded_count) even when resetEvents is false,
causing duplicate fetches; change it so that when resetEvents is false (used for
set_friend_filter), you preserve pagination state instead of resetting it—either
skip resetting used_initial_page/has_more/last_loaded_count altogether or set
last_loaded_count to events.count and update a highWaterMark (e.g.,
highestEventTimestamp or similar) so streamItems() can continue from the correct
point; locate the subscribe(...) function and adjust the branch around
events.reset()/events.flush() and the subsequent
used_initial_page/has_more/last_loaded_count assignments so preserved events
aren’t re-fetched unnecessarily.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift`:
- Around line 80-88: The tap-target is implemented with .onTapGesture which
isn't exposed as a button to VoiceOver; replace the tap handler with a proper
Button (or, if keeping the visual, wrap the existing VStack inside a Button and
apply .buttonStyle(.plain) / PlainButtonStyle()) and move the guard
!authors.isEmpty and the damus_state.nav.push(Route.NIP05DomainPubkeys(domain:
model.domain, nip05_domain_favicon: nip05_domain_favicon, pubkeys: authors))
into the Button's action so the control is announced as interactive
(alternatively add .accessibilityAddTraits(.isButton) to the VStack, but prefer
using Button to match the title view).

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift`:
- Around line 108-111: NIP05DomainTimelineView uses hard-coded paddings
(.padding(.top, 50) and .padding(.leading, 45)); replace them with
safe-area-aware and measured values: wrap the view in a GeometryReader and use
proxy.safeAreaInsets.top (or
UIApplication.shared.windows.first?.safeAreaInsets.top) instead of the constant
50 for the top offset, and replace the leading magic number by measuring the
back button's frame via a custom PreferenceKey/AnchorPreference (attach a
background GeometryReader to the back button to publish its maxX) and apply that
measured value as the leading padding/alignment; update NIP05DomainTimelineView
to consume those values and remove the hard-coded constants.
- Around line 189-192: The appear handler currently skips subscribing if cached
events exist; update handleOnAppear to always call model.subscribe(...) (using
the same groupedSince) so the relay connection is restored after
handleOnDisappear unsubscribed; mirror the pattern used in
ZapsView.swift/timeline views by removing the guard that checks
model.events.all_events.isEmpty and call model.subscribe(since: groupedSince)
unconditionally (or call subscribe(resetEvents: false, since: groupedSince) if
that overload exists) so cached events are preserved while re-establishing the
live subscription.
- Around line 169-178: The subscribe() method currently assigns a new
loadingTask without canceling any prior task, risking concurrent tasks; update
NIP05DomainTimelineView's model.subscribe implementation to cancel any existing
loadingTask before creating a new one by calling loadingTask?.cancel()
immediately prior to assigning loadingTask = Task { await streamItems() },
following the EventsModel/LiveEventModel pattern so
subscribe(resetEvents:since:) safely replaces the previous task.

In `@damus/Features/Timeline/Views/PostingTimelineView.swift`:
- Around line 224-254: The file is missing docstrings for private helpers and is
recomputing grouping on every SwiftUI render; add concise doc comments to
groupedContentView, flushActiveSource(), backfillForGroupedMode(), and
refreshFromNdb() per project guidelines, and avoid the O(n) grouping in
GroupedListView.body by memoizing the grouped result in PostingTimelineView: add
a `@State` (or `@StateObject`) property to hold the grouped/timestamped groups,
compute it only when events.all_events, groupedFilterSettings.filterValues, or
the passed filter change (use onChange, .task, or a Combine publisher to update
the `@State`), then pass the memoized grouped data into GroupedListView instead of
letting GroupedListView call GroupedTimelineGrouper.group on every render (refer
to groupedContentView, GroupedListView, GroupedTimelineGrouper.group, and
groupedFilterSettings to locate the spots to change).

In `@damus/Shared/Components/GroupedTimeline/GroupedListView.swift`:
- Around line 62-75: The empty-state view is using hardcoded English strings;
update the Text initializers in the emptyStateView to use localized keys instead
(e.g., replace "No matching posts" and "Try adjusting your filters" with
NSLocalizedString or SwiftUI LocalizedStringKey usages) so they integrate with
the app’s i18n system; ensure the conditional message that depends on
hasActiveFilters continues to show the localized secondary string and keep the
same font/foreground modifiers on the localized Text views.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift`:
- Around line 58-61: The subtraction assigning cutoff can underflow when
converting TimeInterval to UInt32; update the calculation in the function that
returns [AuthorGroup] (around parseFilteredWords/values.filteredWords) to
perform a saturating subtraction: compute the epoch seconds as
UInt32(now.timeIntervalSince1970) safely (or cast after checking) and subtract
values.timeRangeSeconds using a safe method (e.g., use max(0, Int64(epoch) -
Int64(values.timeRangeSeconds)) or UInt32.max-limited logic) so cutoff never
underflows; adjust the variable named cutoff to be derived from this saturating
result (keeping its UInt32 type if required).
- Around line 118-138: The isTooShort(_ event: NostrEvent, hideShortNotes: Bool)
function currently rejects any single-word note via the words.count == 1 check,
which hides long single-token posts; change that check so it only flags very
short single-word notes (e.g., require words.count == 1 && content.count < 10)
or remove the single-word check entirely—update the condition using the existing
content/words variables in isTooShort to preserve long single-word notes like
long hashtags.
- Around line 125-128: The current text filtering (creating textWithoutEmojis
from content using !$0.properties.isEmoji) removes digits and certain symbols;
change the predicate to use !$0.properties.isEmojiPresentation so only scalars
that render as emoji by default are stripped, preserving numeric and symbol
characters used in content length validation (update the filter applied to
content.unicodeScalars in the GroupedTimelineGrouper.swift code where
textWithoutEmojis is constructed).

---

Duplicate comments:
In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift`:
- Around line 12-27: Add a documentation comment for the localizedTitle computed
property in the NIP05ViewMode enum: describe what localizedTitle returns (a
user-facing localized label for each view mode), mention it's used for tab
labels, and reference that it supports .grouped and .timeline cases; update the
docstring above the var localizedTitle to follow the project's doc comment
style.

In `@damus/Features/Profile/Views/CondensedProfilePicturesView.swift`:
- Around line 19-22: Add a Swift docstring for the initializer of
CondensedProfilePicturesView describing its purpose and parameters; update the
init(state:pubkeys:maxPictures:size:) initializer to include a brief summary
line and `@param` descriptions for state (DamusState), pubkeys ([Pubkey]),
maxPictures (Int) and size (CGFloat, default 32.0), and mention any important
behavior (e.g., that it constructs CondensedProfilePicturesViewModel) to satisfy
documentation guidelines.

In `@damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift`:
- Around line 50-65: Add a docstring for the computed view property
postCountBadge describing its purpose and behavior: explain that it returns a
Text view showing the post count with conditional font sizing and padding, the
purple foreground and capsule background/overlay styling, and note the threshold
logic (postCount >= 100) that alters font and padding; place this brief comment
immediately above the postCountBadge property to satisfy documentation
guidelines.

In `@damus/Shared/Components/GroupedTimeline/GroupedFilterSettingsView.swift`:
- Around line 97-104: Update the Picker to iterate over the canonical options:
use GroupedFilterSettings.maxNotesOptions.compactMap({ $0 }) in the ForEach (as
done) and ensure the selection binding settings.maxNotesPerUser and the tag
types match optional Ints (Disabled -> tag(nil as Int?) and each option ->
tag(count as Int?)); no other changes needed beyond keeping the ForEach
reference to GroupedFilterSettings.maxNotesOptions and the tag types consistent
with settings.maxNotesPerUser.

---

Nitpick comments:
In `@damus.xcodeproj/project.pbxproj`:
- Around line 1976-1981: The PBXBuildFile entries for
GroupedTimelineGrouper.swift and GroupedModeQueueManager.swift use hand-crafted
sequential UUIDs (e.g., A1B2C3D4E5F6A7B8C9D0E1F2 and B1B2B3B4B5B6B7B8B9B0C1C2)
which is unconventional; to fix, remove these manual entries and re-add the
files using Xcode's File → Add Files to "damus"… (or close Xcode, run Xcode to
re-generate UUIDs) so Xcode will create standard random UUIDs for
GroupedTimelineGrouper.swift and GroupedModeQueueManager.swift and update the
PBXBuildFile references consistently with the rest of the project.

In `@damus/Features/NIP05/Models/NIP05.swift`:
- Around line 220-244: ordered_domain_authors currently uses NSMutableOrderedSet
which loses Swift type-safety; replace the Obj-C collection with a Swift-native
dedup by iterating the concatenated array (eventPubkeys + (filterAuthors ??
[])), maintaining a Set<Pubkey> seen to skip duplicates while building a new
[Pubkey] orderedUnique, then proceed to filter with
matches_domain(domain:profiles:) and the friend_filter branch
(contacts.is_in_friendosphere) unchanged; ensure Pubkey remains Hashable or use
an appropriate unique key when populating seen so the behavior and order match
the original.
- Around line 184-201: The friends-of-friends branch runs a potentially large
loop on the main actor (iterating contacts.get_friend_of_friends_list() and
calling matches_domain), which can stall the UI; move that work to a background
task similar to the .all case by performing the iteration inside Task.detached
and returning the authors set, or snapshot the minimal profile data
off-main-thread first and make matches_domain operate on that snapshot; update
the .friends_of_friends case to await the detached task and fall back to
validated_authors_for_domain if the result is empty.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift`:
- Around line 82-132: Add missing Swift docstrings for the listed methods:
streamItems(), load_more(), fetch_older(), add_event(_:), set_friend_filter(_:),
and matches_domain(_:). For each method add a brief triple-slash (///) comment
immediately above the function that states its purpose, describes parameters and
return value (or that it returns Void), notes async behavior or side effects
(e.g., updates state.loading, mutates events, triggers network streams, or
validates NIP-05), and any important invariants (e.g., when since is set use
different limits, or that matches_domain checks NIP-05 domain matching). Follow
the repository’s existing docstring style (brief summary line, parameter tags if
used) and keep comments concise.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift`:
- Around line 56-64: The computed property allAuthors and the synchronous
profile lookups (ndb.lookup_profile_and_copy invoked by notesFromAuthorsString)
run on the main actor and iterate model.events.all_events on every view
evaluation; move these expensive DB reads and the O(n) event scan off the main
thread by computing and caching the authors list in the model (e.g., add a
precomputed property or method on the view model that updates on events change)
or in the view using `@State` plus a .task to populate it, and perform profile
lookups from notesFromAuthorsString in the same background task/queue (or expose
an async lookup method on the model) so the view body only reads cheap,
precomputed data (refer to allAuthors, notesFromAuthorsString,
model.events.all_events, and ndb.lookup_profile_and_copy).
- Around line 101-126: Refactor the near-duplicate logic in
notesFromAuthorsString and friendsOfFriendsString into a single helper that
returns the localized summary string: extract the shared steps (profile lookup
via a provided lookup function, name extraction/truncation to 20 chars, and the
switch on authors.count that formats 0/1/2/3/+ cases) into a new function (e.g.,
summarizeAuthors(_:lookup:zeroCaseKey:ndb:locale:) or similar) and have both
notesFromAuthorsString and friendsOfFriendsString call it; parameterize the
helper with the zero-case localization key/string and a profile-lookup closure
(to accommodate ndb.lookup_profile_and_copy vs the inline logic), reuse
localizedStringFormat for the default case, and ensure you pass
Profile.displayName(...).username.truncate(maxLength: 20) as the name
transformation so behavior stays identical.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift`:
- Around line 56-58: The computed property contentFilters currently creates a
new ContentFilters (and its closures) every time the view re-evaluates; change
it to a stored/cached value to avoid repeated allocation — for example,
initialize a stored property once (e.g., a private let or lazy var
contentFilters) or move it into the view's model/@StateObject so
ContentFilters.defaults(damus_state:) is called only once; update references to
use that stored contentFilters and ensure it is recalculated only when
damus_state actually changes.

In `@damus/Features/Timeline/Models/HomeModel.swift`:
- Around line 766-772: Add docstrings above the three properties describing
their purpose and usage: for backfillTask explain it is a Task<Void, Never>?
that performs relay backfills to fill older events for grouped mode and mention
its lifecycle/cancellation behavior; for ndbRefreshTask explain it is a
Task<Void, Never>? responsible for refreshing the ndb (or network DB)
cache/state and when it runs; for backfillPageLimit (UInt32) document that it is
the maximum number of events fetched per backfill page, include the unit and
default value (5000) and any constraints. Place the comments directly above the
declarations for backfillTask, ndbRefreshTask, and backfillPageLimit and keep
them concise.
- Around line 854-866: The current double-break pattern (a `break` inside the
`switch` for `.eose` and then `if case .eose = item { break }` after the switch)
is fragile; convert the surrounding `for await` loop into a labeled loop (e.g.,
timelineLoop:) and replace both the post-switch `if case .eose` check and the
inner switch `case .eose: break` behavior with a single `break timelineLoop`
from inside the `.eose` switch arm so that `lender.justUseACopy`,
`should_show_event(state: self.damus_state, ev:)`, and appending to `allEvents`
remain unchanged but loop exit is explicit and idiomatic.
- Around line 869-878: The loop is doing sequential main-actor work and then
uses a redundant DispatchQueue.main.async to set self.backfilling; since
HomeModel is `@MainActor` the DispatchQueue hop should be removed and set
backfilling directly (reference HomeModel and backfilling). To avoid UI hitches
when inserting large chunks, change the insertion loop around damus_state.events
/ favoriteEvents.insert(_:)/insert_home_event(_:) to perform batched inserts or
periodically yield to the executor (e.g., process N events then await
Task.yield() or insert in a batch) so the main actor can process UI work between
batches.
- Around line 885-914: The refreshSingleAuthorEvents function spawns a
fire-and-forget Task (via damus_state.nostrNetwork.reader.advancedStream)
without keeping a handle, causing concurrent streams to accumulate; change it to
store the Task in a cancellable property (e.g., singleAuthorRefreshTask) on the
HomeModel, cancel any existing task before creating a new one, and assign the
new Task to that property; ensure you cancel/cleanup this stored task where
other stored tasks are handled (similar to backfillTask and ndbRefreshTask) and
on deinit or when the stream should stop.

In `@damus/Features/Timeline/Views/PostingTimelineView.swift`:
- Around line 256-272: Add docstrings for the three private helper methods to
satisfy docstring coverage: document flushActiveSource() (annotated `@MainActor`)
with a one-line summary of what it flushes and any threading/actor expectations,
document backfillForGroupedMode(showLoading:) describing the purpose, the
meaning and default of the showLoading parameter, how the since timestamp is
computed and that it calls home.backfillForGroupedMode, and document
refreshFromNdb() summarizing the since computation and that it refreshes grouped
data from NDB; keep each docstring concise and follow existing project style
(one-line summary + brief param/side-effect notes).

In `@damus/Shared/Components/GroupedTimeline/GroupedListView.swift`:
- Around line 29-35: GroupedTimelineGrouper.group(...) is being called
synchronously inside GroupedListView.body (using events.all_events, filter,
settings.filterValues, Date()), causing an O(n) regroup on every SwiftUI body
recompute; move this heavy computation out of body into a stored state or
view-model property (e.g., `@State` or `@StateObject/`@ObservedObject) such as a
`grouped` property on GroupedListView or its VM, and update that property only
when inputs change by using .onChange(of:) for events.all_events, filter, and
settings.filterValues (or subscribe to events' publisher via Combine) so renders
that don't change those inputs skip the grouping work.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift`:
- Around line 91-95: Add missing docstrings for the internal filtering helpers
parseFilteredWords, shouldIncludeEvent, containsFilteredWords, and isTooShort:
for each function add a brief Swift doc comment that states the purpose,
describes parameters and types, explains return value, and any important
behavior or side‑effects (e.g., trimming, lowercasing, minimum length). Place
the comments immediately above the function declarations so they follow project
docstring style and cover the functions at/around parseFilteredWords,
shouldIncludeEvent, containsFilteredWords, and isTooShort.

Comment thread damus.xcodeproj/project.pbxproj Outdated
Comment thread damus/Features/NIP05/Models/NIP05DomainEventsModel.swift
Comment thread damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift Outdated
Comment on lines +108 to +111
.padding(.leading, 45) // Align with domain title (after back button width)
}
.padding(.horizontal, 16)
.padding(.top, 50) // Account for status bar / notch
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Hard-coded top padding is fragile across device form factors.

.padding(.top, 50) assumes a fixed status bar / notch height. Devices with Dynamic Island, classic notch, or no notch will have different safe area insets. Similarly, .padding(.leading, 45) on line 108 couples to the back-button width.

Consider using GeometryReader with safeAreaInsets.top for the status bar offset, and computing the leading alignment from the actual button frame rather than a magic number.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift` around lines 108 -
111, NIP05DomainTimelineView uses hard-coded paddings (.padding(.top, 50) and
.padding(.leading, 45)); replace them with safe-area-aware and measured values:
wrap the view in a GeometryReader and use proxy.safeAreaInsets.top (or
UIApplication.shared.windows.first?.safeAreaInsets.top) instead of the constant
50 for the top offset, and replace the leading magic number by measuring the
back button's frame via a custom PreferenceKey/AnchorPreference (attach a
background GeometryReader to the back button to publish its maxX) and apply that
measured value as the leading padding/alignment; update NIP05DomainTimelineView
to consume those values and remove the hard-coded constants.

Comment thread damus/Features/NIP05/Views/NIP05DomainTimelineView.swift
Comment on lines +224 to +254
private var groupedContentView: some View {
let eventsSource = timeline_source == .favorites
? home.favoriteEvents : home.events
return ScrollView {
GroupedListView(
damus_state: damus_state,
events: eventsSource,
filter: content_filter(.posts_and_replies),
settings: groupedFilterSettings,
onProfileTapped: { pubkey in
lastVisitedGroupedPubkey = pubkey
}
)
.redacted(reason: home.backfilling ? .placeholder : [])
.shimmer(home.backfilling)
.padding(.top, headerHeight)
}
.onAppear {
// When returning from a profile, do a targeted refresh for just that
// author. Single-author queries avoid the dilution problem of querying
// all 500+ friends at once, ensuring the author's full event history
// within the time window is fetched.
if let pubkey = lastVisitedGroupedPubkey {
lastVisitedGroupedPubkey = nil
let since = UInt32(Date().timeIntervalSince1970) - groupedFilterSettings.timeRange.seconds
home.refreshSingleAuthorEvents(pubkey: pubkey, since: since, source: timeline_source)
} else if !home.backfilling {
refreshFromNdb()
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find GroupedListView and GroupedTimelineGrouper
fd -e swift | xargs rg -l "GroupedListView\|GroupedTimelineGrouper" | head -20

Repository: damus-io/damus

Length of output: 798


🏁 Script executed:

# Search for GroupedListView definition
rg -n "struct GroupedListView\|class GroupedListView" --type=swift -A 30

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

# Search for GroupedTimelineGrouper.group method
rg -n "func group\(" --type=swift -B 2 -A 15 | grep -A 15 "GroupedTimelineGrouper"

Repository: damus-io/damus

Length of output: 2176


🏁 Script executed:

# Check PostingTimelineView for docstrings on new methods
rg -n "private var groupedContentView\|private func flushActiveSource\|private func backfillForGroupedMode\|private func refreshFromNdb" --type=swift damus/Features/Timeline/Views/PostingTimelineView.swift -A 2

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

# Find all Swift files in GroupedTimeline directory
find damus -type f -name "*GroupedTimeline*" -o -name "*GroupedList*" | head -20

Repository: damus-io/damus

Length of output: 188


🏁 Script executed:

# Look for GroupedListView definition
rg -n "GroupedListView" --type=swift -B 2 -A 20 | head -100

Repository: damus-io/damus

Length of output: 9237


🏁 Script executed:

# Check the full GroupedTimelineGrouper implementation
cat damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift

Repository: damus-io/damus

Length of output: 4937


🏁 Script executed:

# Check PostingTimelineView.swift for the specific methods without docstrings
sed -n '220,280p' damus/Features/Timeline/Views/PostingTimelineView.swift

Repository: damus-io/damus

Length of output: 2449


Add missing docstrings and optimize grouping recomputation.

Two issues in the new grouped mode methods:

  1. Missing docstrings: The private methods groupedContentView, flushActiveSource(), backfillForGroupedMode(), and refreshFromNdb() lack docstrings. Per coding guidelines, any code added or modified must have documentation.

  2. Grouping recalculated on every render: GroupedListView.body calls GroupedTimelineGrouper.group(events:filter:values:now:) on every SwiftUI render cycle. With large event sets from 500+ followed authors, this O(n) pass can cause performance issues during scrolling. Consider memoizing the grouped result in a @State property, recomputing only when events.all_events, settings.filterValues, or filter actually change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Views/PostingTimelineView.swift` around lines 224 -
254, The file is missing docstrings for private helpers and is recomputing
grouping on every SwiftUI render; add concise doc comments to
groupedContentView, flushActiveSource(), backfillForGroupedMode(), and
refreshFromNdb() per project guidelines, and avoid the O(n) grouping in
GroupedListView.body by memoizing the grouped result in PostingTimelineView: add
a `@State` (or `@StateObject`) property to hold the grouped/timestamped groups,
compute it only when events.all_events, groupedFilterSettings.filterValues, or
the passed filter change (use onChange, .task, or a Combine publisher to update
the `@State`), then pass the memoized grouped data into GroupedListView instead of
letting GroupedListView call GroupedTimelineGrouper.group on every render (refer
to groupedContentView, GroupedListView, GroupedTimelineGrouper.group, and
groupedFilterSettings to locate the spots to change).

Comment on lines +62 to +75
private var emptyStateView: some View {
VStack(spacing: 8) {
Text("No matching posts")
.font(.headline)
.foregroundColor(.gray)

if hasActiveFilters {
Text("Try adjusting your filters")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(.top, 40)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Localize empty-state strings.

"No matching posts" and "Try adjusting your filters" are user-facing strings that should use NSLocalizedString (or SwiftUI's LocalizedStringKey) for i18n consistency with the rest of the codebase.

🌍 Proposed fix
-            Text("No matching posts")
+            Text("No matching posts", comment: "Empty state message when no grouped posts match filters")
                 .font(.headline)
                 .foregroundColor(.gray)

             if hasActiveFilters {
-                Text("Try adjusting your filters")
+                Text("Try adjusting your filters", comment: "Hint shown when active filters produce no results")
                     .font(.subheadline)
                     .foregroundColor(.secondary)
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private var emptyStateView: some View {
VStack(spacing: 8) {
Text("No matching posts")
.font(.headline)
.foregroundColor(.gray)
if hasActiveFilters {
Text("Try adjusting your filters")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(.top, 40)
}
private var emptyStateView: some View {
VStack(spacing: 8) {
Text("No matching posts", comment: "Empty state message when no grouped posts match filters")
.font(.headline)
.foregroundColor(.gray)
if hasActiveFilters {
Text("Try adjusting your filters", comment: "Hint shown when active filters produce no results")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(.top, 40)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedListView.swift` around lines
62 - 75, The empty-state view is using hardcoded English strings; update the
Text initializers in the emptyStateView to use localized keys instead (e.g.,
replace "No matching posts" and "Try adjusting your filters" with
NSLocalizedString or SwiftUI LocalizedStringKey usages) so they integrate with
the app’s i18n system; ensure the conditional message that depends on
hasActiveFilters continues to show the localized secondary string and keep the
same font/foreground modifiers on the localized Text views.

Comment on lines +118 to +138
static func isTooShort(_ event: NostrEvent, hideShortNotes: Bool) -> Bool {
guard hideShortNotes else { return false }

let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)

if content.count < 10 { return true }

let textWithoutEmojis = content.unicodeScalars
.filter { !$0.properties.isEmoji }
.map { String($0) }
.joined()
.replacingOccurrences(of: " ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)

if textWithoutEmojis.count < 2 { return true }

let words = content.split(whereSeparator: { $0.isWhitespace }).filter { !$0.isEmpty }
if words.count == 1 { return true }

return false
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Single-word notes ≥10 chars are filtered as "too short".

Line 135 rejects any content with exactly one word regardless of length. A note like "cryptocurrency" (14 chars, clearly non-trivial) or a single long hashtag would be hidden. Consider removing the single-word check, or only applying it to very short single words:

🛡️ Possible adjustment
         let words = content.split(whereSeparator: { $0.isWhitespace }).filter { !$0.isEmpty }
-        if words.count == 1 { return true }
+        if words.count == 1 && content.count < 20 { return true }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static func isTooShort(_ event: NostrEvent, hideShortNotes: Bool) -> Bool {
guard hideShortNotes else { return false }
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
if content.count < 10 { return true }
let textWithoutEmojis = content.unicodeScalars
.filter { !$0.properties.isEmoji }
.map { String($0) }
.joined()
.replacingOccurrences(of: " ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if textWithoutEmojis.count < 2 { return true }
let words = content.split(whereSeparator: { $0.isWhitespace }).filter { !$0.isEmpty }
if words.count == 1 { return true }
return false
}
static func isTooShort(_ event: NostrEvent, hideShortNotes: Bool) -> Bool {
guard hideShortNotes else { return false }
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
if content.count < 10 { return true }
let textWithoutEmojis = content.unicodeScalars
.filter { !$0.properties.isEmoji }
.map { String($0) }
.joined()
.replacingOccurrences(of: " ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if textWithoutEmojis.count < 2 { return true }
let words = content.split(whereSeparator: { $0.isWhitespace }).filter { !$0.isEmpty }
if words.count == 1 && content.count < 20 { return true }
return false
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift` around
lines 118 - 138, The isTooShort(_ event: NostrEvent, hideShortNotes: Bool)
function currently rejects any single-word note via the words.count == 1 check,
which hides long single-token posts; change that check so it only flags very
short single-word notes (e.g., require words.count == 1 && content.count < 10)
or remove the single-word check entirely—update the condition using the existing
content/words variables in isTooShort to preserve long single-word notes like
long hashtags.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift (1)

163-163: ⚠️ Potential issue | 🟡 Minor

Hardcoded English string not wrapped in NSLocalizedString.

The case 0: path in friendsOfFriendsString returns a bare string literal while all other cases use NSLocalizedString. This string will not be translated.

🐛 Proposed fix
-    case 0:
-        return "No one in your trusted network is associated with this domain."
+    case 0:
+        return NSLocalizedString(
+            "No one in your trusted network is associated with this domain.",
+            bundle: bundle,
+            comment: "Text when no one in the trusted network is associated with this NIP-05 domain."
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift` at line 163,
The string returned in friendsOfFriendsString's `case 0:` is hardcoded; replace
it with an NSLocalizedString call consistent with the other cases (e.g.
NSLocalizedString("No one in your trusted network is associated with this
domain.", comment: "")) so the message is localizable, keeping the exact message
text and comment format used elsewhere in NIP05DomainTimelineHeaderView.
🧹 Nitpick comments (7)
damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift (1)

68-85: eventPreviewText doesn't need self — could be a free function or static.

The method doesn't reference any instance properties. Minor nit, but making it static or a module-level helper would clarify that and make it reusable outside this view.

More importantly, the regex nostr:[a-zA-Z0-9]+ won't strip references containing underscores or other characters that could appear in NIP-19 TLV-encoded identifiers (e.g., nprofile, nevent bech32 strings). In practice bech32 only uses [a-z0-9], so uppercase is over-broad but harmless, and the pattern should suffice for display-preview stripping.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift` around lines
68 - 85, Make eventPreviewText a static (or free) function since it doesn't use
instance state: change func eventPreviewText(_ event: NostrEvent) to static func
eventPreviewText(_ event: NostrEvent) (or move it out of the type) and update
references accordingly; also broaden the regex that strips nostr identifiers to
include bech32-like characters and underscores by replacing "nostr:[a-zA-Z0-9]+"
with something like "nostr:[a-z0-9_]+" (or the precise bech32 charset) and use
appropriate regex options (no need for uppercase A-Z) so nprofile/nevent bech32
references are removed correctly.
damus/Features/NIP05/Models/NIP05.swift (1)

220-244: ordered_domain_authors uses NSMutableOrderedSet for deduplication.

This works correctly but is somewhat non-idiomatic Swift. The as? [Pubkey] ?? [] fallback silently produces an empty array if the cast fails (which shouldn't happen in practice, but masks bugs if it does). Consider a Swift-native ordered-dedup approach for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Models/NIP05.swift` around lines 220 - 244, The function
ordered_domain_authors currently uses NSMutableOrderedSet and an unsafe cast
(orderedUnique = NSMutableOrderedSet(array: pubkeys).array as? [Pubkey] ?? [])
which can silently yield an empty array; replace this with a Swift-native
ordered dedup: iterate over pubkeys, keep a Set of seen Pubkey (or seen
identifiers if Pubkey is not Hashable) and append each pk to a new array only if
not already seen, then use that deduped array for matching; update references to
pubkeys and orderedUnique in ordered_domain_authors and ensure Pubkey is
Hashable or use a stable unique key (e.g., hex string) for the seen Set.
damus/Features/Timeline/Models/HomeModel.swift (2)

845-866: Convoluted double-break to exit for await on EOSE.

The case .eose: break on line 860 breaks the switch, then line 865 re-checks the same item to break the for await loop. This works but is unnecessarily confusing.

A labeled loop is cleaner and idiomatic:

♻️ Proposed simplification
-                for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [chunkFilter], streamMode: .ndbAndNetworkParallel(networkOptimization: nil)) {
-                    switch item {
-                    case .event(let lender):
-                        await lender.justUseACopy({ ev in
-                            guard should_show_event(state: self.damus_state, ev: ev) else { return }
-                            allEvents.append(ev)
-                        })
-                    case .eose:
-                        break
-                    case .ndbEose, .networkEose:
-                        continue
-                    }
-                    if case .eose = item { break }
-                }
+                chunkStream: for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [chunkFilter], streamMode: .ndbAndNetworkParallel(networkOptimization: nil)) {
+                    switch item {
+                    case .event(let lender):
+                        await lender.justUseACopy({ ev in
+                            guard should_show_event(state: self.damus_state, ev: ev) else { return }
+                            allEvents.append(ev)
+                        })
+                    case .eose:
+                        break chunkStream
+                    case .ndbEose, .networkEose:
+                        continue
+                    }
+                }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Models/HomeModel.swift` around lines 845 - 866, The
code uses a confusing double-break to exit the `for await` loop over
`damus_state.nostrNetwork.reader.advancedStream` by breaking the `switch` on
`.eose` and then separately checking `if case .eose = item { break }`; replace
this with a single labeled loop to clearly break out from inside the `switch`:
give the `for await` loop a label (e.g., outerLoop) and in `case .eose` perform
`break outerLoop`, removing the later `if case .eose = item { break }` check;
apply this change around the loop handling in HomeModel (the `for chunk in
chunks { ... for await item in ... { switch item { ... } } }` block) so the
intent to exit the stream on EOSE is explicit and not duplicated.

885-914: Fire-and-forget Task with no cancellation handle.

Unlike backfillForGroupedMode and refreshGroupedFromNdb, this method creates an untracked Task with no stored handle. Rapid calls (e.g., user scrolling through grouped rows) will spawn concurrent network subscriptions that cannot be cancelled, potentially piling up relay requests.

Store a task handle and cancel the previous one, consistent with the pattern used by the sibling methods.

♻️ Proposed fix

Add a private task handle:

 private var ndbRefreshTask: Task<Void, Never>?
+private var singleAuthorRefreshTask: Task<Void, Never>?

Then use it:

 func refreshSingleAuthorEvents(pubkey: Pubkey, since: UInt32, source: TimelineSource) {
+    singleAuthorRefreshTask?.cancel()
     let kinds: [NostrKind] = [.text, .longform, .boost, .highlight]
     let isFavorites = source == .favorites
     ...
-    Task {
+    singleAuthorRefreshTask = Task {
         for await item in ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Models/HomeModel.swift` around lines 885 - 914, The
refreshSingleAuthorEvents function spawns an untracked Task; instead add a
private Task? property (e.g., refreshSingleAuthorTask) on HomeModel, cancel any
existing task before creating a new one, assign the new Task to that property,
run the existing advancedStream logic inside that Task, and ensure you clear the
handle (set to nil) when the stream returns or finishes (EOSE/return) so it can
be cancelled next time—follow the same cancel-then-replace pattern used by
backfillForGroupedMode and refreshGroupedFromNdb and reference
damus_state.nostrNetwork.reader.advancedStream and the refreshSingleAuthorEvents
method when updating the code.
damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift (1)

91-95: Internal helper methods lack docstrings.

parseFilteredWords, shouldIncludeEvent, containsFilteredWords, and isTooShort are all undocumented. While the group method has a docstring, these supporting methods would benefit from brief descriptions of their purpose and return semantics.

As per coding guidelines, "Ensure docstring coverage for any code added or modified."

Also applies to: 97-110

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift` around
lines 91 - 95, Add Swift doc comments (///) for the helper methods
parseFilteredWords(_:), shouldIncludeEvent(_:),
containsFilteredWords(_:filteredWords:), and isTooShort(_:) describing each
method's purpose, parameters and return semantics; for example, state that
parseFilteredWords takes a comma-separated raw string and returns a lowercased
array of trimmed words (minimum length 2), shouldIncludeEvent decides whether an
event belongs in a group and what boolean/criteria it returns,
containsFilteredWords checks if a given text contains any of the parsed filtered
words, and isTooShort determines if an event's text is too short to include;
follow the same docstring style used on group(_:), keep comments concise, and
ensure parameter names and return types are documented.
damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift (2)

101-126: ndb.lookup_profile_and_copy is called from the SwiftUI body on the main thread.

notesFromAuthorsString is invoked at Line 74 inside body. It performs up to three ndb.lookup_profile_and_copy database reads per render. While NDB reads are typically fast (memory-mapped), calling database queries inside body is not idiomatic and fires on every @ObservedObject change — which can be frequent as events stream in.

Consider computing the string in a @State or a task(id:) modifier that runs off the hot render path, or at minimum cache the result with a Combine pipeline attached to model.$events.

As per coding guidelines: "All network requests, database queries, and expensive computations must run on background threads/queues."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift` around lines
101 - 126, notesFromAuthorsString currently calls ndb.lookup_profile_and_copy
from the SwiftUI body (via its call site at line 74), triggering synchronous DB
reads on the main thread on every render; move this work off the hot render path
by computing and caching the resulting string asynchronously (e.g., create a
`@State` String property like notesFromAuthors, populate it in a
Task/Task.detached or a task(id: model.$events) modifier that performs
ndb.lookup_profile_and_copy on a background queue, or subscribe to model.$events
with Combine to update the cached value), and have body simply read the cached
`@State` value; update any callers to use the cached variable instead of directly
invoking notesFromAuthorsString and ensure thread-safe access to Ndb by
performing all lookups off the main thread.

58-64: allAuthors is re-evaluated on every body render.

Each body pass allocates two arrays (eventAuthors, filterAuthors) and a Set<Pubkey> for deduplication. As the model streams events, @ObservedObject triggers frequent re-renders. For large event lists this accumulates avoidable allocations.

Memoising via a stored property updated in add_event (or via a Combine sink) would remove the per-render allocation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift` around lines
58 - 64, The computed property allAuthors currently rebuilds eventAuthors,
filterAuthors, and a deduplication Set on every SwiftUI body render; change this
to a stored cached authors collection on NIP05DomainTimelineHeaderView that is
incrementally updated instead: add a stored var (e.g., cachedAuthors: [Pubkey]
or Set<Pubkey>) and update it when new events arrive by handling
model.events.all_events changes (hook into add_event or attach a Combine sink to
the model's events publisher) and also merge model.filter.authors when the
filter changes; then replace the allAuthors computed property to return the
cached value to avoid per-render allocations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.beads/issues.jsonl:
- Line 5: The JSON entry with "id":"damus-ir1" is missing the created_by field;
update that object in .beads/issues.jsonl to include a created_by key with the
same format used by other entries (e.g., a string or user identifier), ensuring
the schema matches other records so downstream tooling can rely on a consistent
field presence.

In `@damus.xcodeproj/project.pbxproj`:
- Around line 1976-1981: The PBX project contains hand-crafted sequential UUIDs
for new GroupedTimeline entries (e.g., PBXBuildFile entries referencing
GroupedTimelineGrouper.swift and GroupedModeQueueManager.swift, PBXFileReference
entries, and the GroupedTimeline PBXGroup) which risks collisions and
corruption; remove all manually-added GroupedTimeline-related PBXBuildFile,
PBXFileReference and PBXGroup entries (the ones using patterns like B1B2B3...,
C1C2C3..., A1B2C3..., F2E1D0..., A1A2A3...) from the project.pbxproj and then
re-add the six source files and two test files using Xcode’s “Add Files to…”
dialog so Xcode will generate proper random 24-hex UUIDs and correct attributes
(includeInIndex, etc.). Ensure you delete both the duplicate build-file entries
and their matching fileRef PBXFileReference and PBXGroup nodes before re-adding.
- Around line 6379-6384: The six GroupedTimeline source files
(GroupedFilterButton.swift, GroupedFilterSettingsView.swift,
GroupedAuthorRow.swift, GroupedListView.swift, GroupedTimelineGrouper.swift,
GroupedModeQueueManager.swift) are currently assigned to the ShareExtension and
HighlighterActionExtension targets; open the project.pbxproj and remove their
file reference entries (e.g., CB20AABE3C7B960961C0F914,
CF86249414FD714F8E45AA55, D93D3D7A501A49D1F86669A3, 91E25E8CB81B797E7FCC4D25,
B1B2B3B4B5B6B7B8B9B0C1C2, C1C2C3C4C5C6C7C8C9C0D1D2) from the PBXNativeTarget
build phase sections for ShareExtension and HighlighterActionExtension so they
remain only in the main damus app target; ensure you only remove the target
membership entries and do not delete the file references themselves from the
project if the main app still needs them.

In `@damus/Features/NIP05/Models/NIP05.swift`:
- Around line 180-204: The .friends_of_friends branch in authors_for_domain
blocks the MainActor by calling matches_domain (which does ndb.lookup_profile
via withNdb) for each pubkey; move the entire iteration and domain-matching work
into a background task (use Task.detached) similar to the .all branch, perform
profile lookup and NIP-05 parsing there (either by inlining the logic instead of
calling `@MainActor` matches_domain or by refactoring matches_domain to accept
off-main-thread inputs), collect the filtered Set<Pubkey> inside the detached
task, then await and return the result on the MainActor (e.g. via await
Task.detached { ... }.value and if needed await MainActor.run { authors = result
}) so the heavy loop no longer runs on authors_for_domain's MainActor context;
keep validated_authors_for_domain and scan_all_cached_profiles fallback logic
unchanged.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift`:
- Around line 69-76: set_friend_filter currently calls subscribe(resetEvents:
false) which causes subscribe to set self.since = since (nil) and drop any
active time-window; change the call so subscribe preserves the existing since
value when toggling filters — either by calling subscribe(resetEvents: false,
since: self.since) or by modifying subscribe to only overwrite self.since when
an explicit non-nil since is provided; update the call in set_friend_filter
and/or subscribe implementation to ensure the grouped 24h/7d since window is
retained across friend_filter changes.
- Around line 83-90: The grouped-mode hardcoded filter.limit = 5000 can cause
memory/relay pressure; change this to a smaller configurable constant (e.g.
GROUPED_MODE_LIMIT = 1000) or a stored property on NIP05DomainEventsModel and
use that instead of the literal, and ensure the grouped view uses load_more() to
fetch more history if needed; update the block that sets filter.since and
filter.limit (the since/used_initial_page/self.limit/self.initial_limit logic)
to reference the new constant/property so the limit is tunable rather than a
magic 5000.
- Around line 134-143: Add triple-slash docstrings for the three new methods:
load_more(), fetch_older(until:), and add_event(_:). For each, provide a
one-line summary of purpose, brief description of parameters (e.g., until:
cutoff timestamp for fetch_older, event param for add_event), mention
concurrency/side-effects (that load_more/ fetch_older run async Tasks and mutate
loading_more/ events), and any return/throw behavior (if none, state “no return
value”). Place the docstrings directly above the corresponding function
declarations (load_more, fetch_older, add_event) using the /// style to satisfy
the coding guidelines.
- Around line 145-173: fetch_older can exit the for-await loop without receiving
an .eose (e.g. timeout, disconnect, cancellation) leaving has_more=true and
causing repeated empty fetches; after the loop completes normally ensure you set
has_more = false (and keep loading_more = false) so the UI won't repeatedly
retry—update the end of fetch_older (after the for-await over
state.nostrNetwork.reader.advancedStream) to set has_more = false (optionally
also update last_loaded_count if you want to prevent future redundant work)
before returning.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift`:
- Around line 14-27: Remove the unused NIP05ViewMode enum and its localizedTitle
property: locate the enum declaration named NIP05ViewMode and delete it
(including the cases .grouped and .timeline and the var localizedTitle), and
ensure there are no remaining references—note the view currently toggles modes
via filterSettings.enableGroupedMode, so update any references that might have
intended to use NIP05ViewMode to rely on filterSettings.enableGroupedMode
instead if necessary.

In `@damus/Features/Timeline/Models/HomeModel.swift`:
- Around line 869-878: The loop in backfillForGroupedMode causes up to 5000
main-actor switches and unsynchronized access to EventCache: collect allEvents
(or build two arrays: favoritesBatch and homeBatch) on the background task, then
perform a single await MainActor.run { ... } (or call one `@MainActor` method) to
1) insert the batch into favoriteEvents via favoriteEvents.insert(contentsOf:)
or similar and 2) call insert_home_event for the batch; also move the
damus_state.events.insert calls into that same main-actor block (or protect
EventCache by annotating EventCache as `@MainActor` or serializing its mutations
on a dedicated queue) so EventCache mutations are done safely and context
switches are minimized.

In `@damus/Features/Timeline/Views/PostingTimelineView.swift`:
- Around line 179-195: The onAppear setting of
groupedFilterSettings.enableGroupedMode can trigger onChange which calls
flushActiveSource() and backfillForGroupedMode(), and groupedContentView's
refreshFromNdb() may also run causing duplicate work; to fix, add a guard so
refreshFromNdb() checks the same in-flight/backfilling state (e.g., use
home.backfilling or a new isBackfilling flag) and returns early if a grouped
backfill is already running, or alternatively suppress the onChange trigger
during initial onAppear by comparing old vs new and skipping the first synthetic
change; update the logic around groupedFilterSettings.enableGroupedMode,
groupedModeStored, flushActiveSource(), backfillForGroupedMode(), and
groupedContentView.refreshFromNdb() to use the chosen guard so backfill work
runs only once.

---

Outside diff comments:
In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift`:
- Line 163: The string returned in friendsOfFriendsString's `case 0:` is
hardcoded; replace it with an NSLocalizedString call consistent with the other
cases (e.g. NSLocalizedString("No one in your trusted network is associated with
this domain.", comment: "")) so the message is localizable, keeping the exact
message text and comment format used elsewhere in NIP05DomainTimelineHeaderView.

---

Duplicate comments:
In `@damus.xcodeproj/project.pbxproj`:
- Line 2040: The PBXFileReference entries for the new GroupedTimeline source
files (e.g., GroupedFilterButton.swift and the other GroupedTimeline .swift
entries) incorrectly include the non-standard includeInIndex = 1 attribute;
remove that attribute from those PBXFileReference blocks in the project.pbxproj
(or re-add the affected files via Xcode so Xcode regenerates the references
without includeInIndex). Locate the PBXFileReference entries referencing
GroupedFilterButton.swift and the other GroupedTimeline filenames and either
delete the includeInIndex = 1 line from each block or recreate the file
references through Xcode to resolve the inconsistency.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift`:
- Around line 44-61: In subscribe(resetEvents:since:) the pagination flags
used_initial_page, has_more, and loading_more are being reset unconditionally
which causes duplicate fetches when resetEvents is false; change the logic in
the subscribe method (function subscribe) so that events.reset() and the
pagination state resets (used_initial_page, has_more, loading_more, and any
other initial pagination counters like last_loaded_count if intended) are
performed only inside the if resetEvents { ... } branch, leaving the else {
events.flush() } branch to preserve pagination state when resetEvents is false;
keep loadingTask, loading, and assigning self.since outside the conditional as
currently done.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift`:
- Around line 80-88: The VStack's onTapGesture handler (the block that calls
damus_state.nav.push(route: Route.NIP05DomainPubkeys(domain: model.domain,
nip05_domain_favicon: nip05_domain_favicon, pubkeys: authors))) is not exposed
as an accessible control; replace the gesture with an actual Button (wrapping
the VStack content) or add .accessibilityAddTraits(.isButton) to the tappable
view so VoiceOver announces it as interactive, preserving the guard
!authors.isEmpty check and the same navigation call.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift`:
- Around line 188-192: handleOnAppear currently skips calling model.subscribe if
model.events.all_events is non-empty, which leaves the relay closed after
handleOnDisappear/unsubscribe; remove that guard and always call model.subscribe
when the view appears, but preserve cached events by passing resetEvents: false
(i.e. call model.subscribe(since: groupedSince, resetEvents: false)) so the
relay connection is restored without clearing existing events; this mirrors the
approach used in filterSettingsSheet and ensures re-subscription after
navigation.
- Around line 108-111: The hard-coded .padding(.leading, 45) and .padding(.top,
50) in NIP05DomainTimelineView are brittle; replace them with values derived
from the real back-button width and the view's safe-area inset. Measure the back
button frame (e.g., add a width preference using a custom PreferenceKey on the
BackButton view and read it into a `@State` like backButtonWidth) and use that
state to compute the leading padding, and read the safe area top inset from a
GeometryReader (or the environment safeAreaInsets) to compute the top padding
instead of 50. Update the view modifiers in NIP05DomainTimelineView to use these
dynamic values so alignment adapts to Dynamic Island/notch/device variations.

In `@damus/Features/Timeline/Views/PostingTimelineView.swift`:
- Around line 224-272: Add concise Swift docstrings for the private grouped-mode
members: groupedContentView, flushActiveSource(),
backfillForGroupedMode(showLoading:), and refreshFromNdb(). For each, add a ///
comment immediately above the declaration describing what the property or method
does, its important side effects (e.g. triggers refresh/backfill, clears
lastVisitedGroupedPubkey, uses groupedFilterSettings.timeRange), any parameters
(e.g. showLoading: Bool) and concurrency/actor constraints where relevant (mark
flushActiveSource with its `@MainActor` behavior). Keep comments brief,
imperative, and consistent with project doc style.

In `@damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift`:
- Around line 50-65: Add a concise docstring above the computed property
postCountBadge describing its purpose and behavior: state that it returns a
SwiftUI View showing the author's post count as a capsule-styled badge, note how
font size and horizontal padding change when postCount is >= 100, and mention
any important visual choices (foreground/background colors and min frame). Put
the docstring immediately above the postCountBadge declaration in
GroupedAuthorRow (GroupedAuthorRow.swift) so it documents the property for
future readers and satisfies docstring coverage.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift`:
- Around line 125-128: The current filtering that builds textWithoutEmojis uses
content.unicodeScalars.filter { !$0.properties.isEmoji } which also strips
digits and symbols; change the predicate to check isEmojiPresentation instead so
only glyphs explicitly emoji-presented are removed (i.e., replace use of isEmoji
with isEmojiPresentation when building textWithoutEmojis), preserving numeric
and symbol characters for the "too short" check in GroupedTimelineGrouper.
- Around line 58-61: The subtraction for cutoff can underflow: when computing
cutoff in GroupedTimelineGrouper (the function returning [AuthorGroup] that
calls parseFilteredWords), ensure you perform a safe subtraction between
now.timeIntervalSince1970 and values.timeRangeSeconds by doing the math in a
signed type or by checking bounds and clamping to zero before converting to
UInt32; e.g., compute a Double or Int64 delta = now.timeIntervalSince1970 -
Double(values.timeRangeSeconds) and then set cutoff = UInt32(max(0, delta)) (or
guard that now >= timeRangeSeconds and use 0 as the fallback) so the subtraction
cannot trap.
- Around line 134-135: The single-word rejection unconditionally drops notes if
words.count == 1; change this to only filter out single-word notes that are
shorter than the minimum length check used earlier (use the same threshold
referenced around line 123), e.g. replace the unconditional "if words.count == 1
{ return true }" with a check that returns true only when words.count == 1 &&
content.count (or words[0].count) < minLengthThreshold; locate this in
GroupedTimelineGrouper where `content` and `words` are computed and ensure you
reuse the same min length constant/variable used by the earlier length check.

---

Nitpick comments:
In `@damus/Features/NIP05/Models/NIP05.swift`:
- Around line 220-244: The function ordered_domain_authors currently uses
NSMutableOrderedSet and an unsafe cast (orderedUnique =
NSMutableOrderedSet(array: pubkeys).array as? [Pubkey] ?? []) which can silently
yield an empty array; replace this with a Swift-native ordered dedup: iterate
over pubkeys, keep a Set of seen Pubkey (or seen identifiers if Pubkey is not
Hashable) and append each pk to a new array only if not already seen, then use
that deduped array for matching; update references to pubkeys and orderedUnique
in ordered_domain_authors and ensure Pubkey is Hashable or use a stable unique
key (e.g., hex string) for the seen Set.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift`:
- Around line 101-126: notesFromAuthorsString currently calls
ndb.lookup_profile_and_copy from the SwiftUI body (via its call site at line
74), triggering synchronous DB reads on the main thread on every render; move
this work off the hot render path by computing and caching the resulting string
asynchronously (e.g., create a `@State` String property like notesFromAuthors,
populate it in a Task/Task.detached or a task(id: model.$events) modifier that
performs ndb.lookup_profile_and_copy on a background queue, or subscribe to
model.$events with Combine to update the cached value), and have body simply
read the cached `@State` value; update any callers to use the cached variable
instead of directly invoking notesFromAuthorsString and ensure thread-safe
access to Ndb by performing all lookups off the main thread.
- Around line 58-64: The computed property allAuthors currently rebuilds
eventAuthors, filterAuthors, and a deduplication Set on every SwiftUI body
render; change this to a stored cached authors collection on
NIP05DomainTimelineHeaderView that is incrementally updated instead: add a
stored var (e.g., cachedAuthors: [Pubkey] or Set<Pubkey>) and update it when new
events arrive by handling model.events.all_events changes (hook into add_event
or attach a Combine sink to the model's events publisher) and also merge
model.filter.authors when the filter changes; then replace the allAuthors
computed property to return the cached value to avoid per-render allocations.

In `@damus/Features/Timeline/Models/HomeModel.swift`:
- Around line 845-866: The code uses a confusing double-break to exit the `for
await` loop over `damus_state.nostrNetwork.reader.advancedStream` by breaking
the `switch` on `.eose` and then separately checking `if case .eose = item {
break }`; replace this with a single labeled loop to clearly break out from
inside the `switch`: give the `for await` loop a label (e.g., outerLoop) and in
`case .eose` perform `break outerLoop`, removing the later `if case .eose = item
{ break }` check; apply this change around the loop handling in HomeModel (the
`for chunk in chunks { ... for await item in ... { switch item { ... } } }`
block) so the intent to exit the stream on EOSE is explicit and not duplicated.
- Around line 885-914: The refreshSingleAuthorEvents function spawns an
untracked Task; instead add a private Task? property (e.g.,
refreshSingleAuthorTask) on HomeModel, cancel any existing task before creating
a new one, assign the new Task to that property, run the existing advancedStream
logic inside that Task, and ensure you clear the handle (set to nil) when the
stream returns or finishes (EOSE/return) so it can be cancelled next time—follow
the same cancel-then-replace pattern used by backfillForGroupedMode and
refreshGroupedFromNdb and reference
damus_state.nostrNetwork.reader.advancedStream and the refreshSingleAuthorEvents
method when updating the code.

In `@damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift`:
- Around line 68-85: Make eventPreviewText a static (or free) function since it
doesn't use instance state: change func eventPreviewText(_ event: NostrEvent) to
static func eventPreviewText(_ event: NostrEvent) (or move it out of the type)
and update references accordingly; also broaden the regex that strips nostr
identifiers to include bech32-like characters and underscores by replacing
"nostr:[a-zA-Z0-9]+" with something like "nostr:[a-z0-9_]+" (or the precise
bech32 charset) and use appropriate regex options (no need for uppercase A-Z) so
nprofile/nevent bech32 references are removed correctly.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift`:
- Around line 91-95: Add Swift doc comments (///) for the helper methods
parseFilteredWords(_:), shouldIncludeEvent(_:),
containsFilteredWords(_:filteredWords:), and isTooShort(_:) describing each
method's purpose, parameters and return semantics; for example, state that
parseFilteredWords takes a comma-separated raw string and returns a lowercased
array of trimmed words (minimum length 2), shouldIncludeEvent decides whether an
event belongs in a group and what boolean/criteria it returns,
containsFilteredWords checks if a given text contains any of the parsed filtered
words, and isTooShort determines if an event's text is too short to include;
follow the same docstring style used on group(_:), keep comments concise, and
ensure parameter names and return types are documented.

Comment thread .beads/issues.jsonl Outdated
{"id":"damus-289","title":"Fix no-op settings, remove dead toggles, extract testable grouping logic","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-16T16:33:50.98876-06:00","updated_at":"2026-02-16T22:45:43.789297-06:00","closed_at":"2026-02-16T22:45:43.789297-06:00","created_by":"e"}
{"id":"damus-b9i","title":"Investigate if grouped view makes sense in universe view","description":"Research and evaluate whether the grouped view mode would be useful in the universe view. Consider: volume of posts (universe is high-traffic), UX implications of grouping unknown authors, performance with large datasets, and whether WOT filtering in grouped mode adds value for universe discovery.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-16T15:16:47.509285-06:00","updated_at":"2026-02-16T23:13:15.653028-06:00","closed_at":"2026-02-16T23:13:15.653028-06:00","created_by":"e","dependencies":[{"issue_id":"damus-b9i","depends_on_id":"damus-ku4","type":"blocks","created_at":"2026-02-16T15:16:58.815499-06:00","created_by":"daemon"}]}
{"id":"damus-8wm","title":"Add grouped mode to home timeline (follows and favorites)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-16T16:33:53.249035-06:00","updated_at":"2026-02-16T22:57:24.113733-06:00","closed_at":"2026-02-16T22:57:24.113733-06:00","created_by":"e","dependencies":[{"issue_id":"damus-8wm","depends_on_id":"damus-4yl","type":"blocks","created_at":"2026-02-16T16:33:57.822835-06:00","created_by":"daemon"}]}
{"id":"damus-ir1","title":"fix nip-05","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-04T10:48:44.424341-06:00","updated_at":"2026-02-16T23:12:15.889032-06:00","closed_at":"2026-02-16T23:12:15.889032-06:00"}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Minor schema inconsistency: created_by field is missing.

Every other entry in this file includes a created_by field, but damus-ir1 omits it. If downstream tooling or queries rely on a consistent schema, this could cause issues.

-{"id":"damus-ir1","title":"fix nip-05","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-04T10:48:44.424341-06:00","updated_at":"2026-02-16T23:12:15.889032-06:00","closed_at":"2026-02-16T23:12:15.889032-06:00"}
+{"id":"damus-ir1","title":"fix nip-05","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-04T10:48:44.424341-06:00","updated_at":"2026-02-16T23:12:15.889032-06:00","closed_at":"2026-02-16T23:12:15.889032-06:00","created_by":"e"}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{"id":"damus-ir1","title":"fix nip-05","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-04T10:48:44.424341-06:00","updated_at":"2026-02-16T23:12:15.889032-06:00","closed_at":"2026-02-16T23:12:15.889032-06:00"}
{"id":"damus-ir1","title":"fix nip-05","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-04T10:48:44.424341-06:00","updated_at":"2026-02-16T23:12:15.889032-06:00","closed_at":"2026-02-16T23:12:15.889032-06:00","created_by":"e"}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.beads/issues.jsonl at line 5, The JSON entry with "id":"damus-ir1" is
missing the created_by field; update that object in .beads/issues.jsonl to
include a created_by key with the same format used by other entries (e.g., a
string or user identifier), ensuring the schema matches other records so
downstream tooling can rely on a consistent field presence.

Comment thread damus.xcodeproj/project.pbxproj Outdated
Comment on lines +1976 to +1981
B1B2B3B4B5B6B7B8B9B0C1C2 /* GroupedTimelineGrouper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* GroupedTimelineGrouper.swift */; };
B2B3B4B5B6B7B8B9B0C1C2C3 /* GroupedTimelineGrouper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* GroupedTimelineGrouper.swift */; };
B3B4B5B6B7B8B9B0C1C2C3C4 /* GroupedTimelineGrouper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* GroupedTimelineGrouper.swift */; };
C1C2C3C4C5C6C7C8C9C0D1D2 /* GroupedModeQueueManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2E1D0C9B8A7F6E5D4C3B2A1 /* GroupedModeQueueManager.swift */; };
C2C3C4C5C6C7C8C9C0D1D2D3 /* GroupedModeQueueManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2E1D0C9B8A7F6E5D4C3B2A1 /* GroupedModeQueueManager.swift */; };
C3C4C5C6C7C8C9C0D1D2D3D4 /* GroupedModeQueueManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2E1D0C9B8A7F6E5D4C3B2A1 /* GroupedModeQueueManager.swift */; };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python3 - << 'EOF'
import re
from collections import Counter

with open('damus.xcodeproj/project.pbxproj', 'r') as f:
    content = f.read()

sequential = [
    'A1A2A3A4A5A6A7A8A9A0B1B2',
    'A1B2C3D4E5F6A7B8C9D0E1F2',
    'F2E1D0C9B8A7F6E5D4C3B2A1',
    'B1B2B3B4B5B6B7B8B9B0C1C2', 'B2B3B4B5B6B7B8B9B0C1C2C3', 'B3B4B5B6B7B8B9B0C1C2C3C4',
    'C1C2C3C4C5C6C7C8C9C0D1D2', 'C2C3C4C5C6C7C8C9C0D1D2D3', 'C3C4C5C6C7C8C9C0D1D2D3D4',
    'D1D2D3D4D5D6D7D8D9D0E1E2', 'D2D3D4D5D6D7D8D9D0E1E2E3',
    'E1E2E3E4E5E6E7E8E9E0F1F2', 'E2E3E4E5E6E7E8E9E0F1F2F3',
]

print("=== Sequential UUID occurrence counts ===")
for uuid in sequential:
    count = content.count(uuid)
    # Each UUID should appear exactly as many times as it is cross-referenced (typically 2-3).
    # Any count > expected references suggests a collision with a pre-existing entry.
    print(f"  {uuid}: {count} occurrence(s)")

# Check for any UUID that appears as a *definition key* (left-hand side of an assignment)
# more than once, which would be a definitive collision.
defs = re.findall(r'^\s+([A-F0-9]{24})\s*/\*', content, re.MULTILINE)
dupes = {k: v for k, v in Counter(defs).items() if v > 1}
if dupes:
    print("\n=== COLLISION: UUIDs used as definition keys more than once ===")
    for uuid, cnt in dupes.items():
        print(f"  {uuid}: {cnt} definitions")
else:
    print("\nNo definition-key collisions detected (does not rule out cross-reference collisions).")
EOF

Repository: damus-io/damus

Length of output: 50371


🏁 Script executed:

# Check actual lines mentioned in the review
echo "=== Lines 1976-1981 ==="
sed -n '1976,1981p' damus.xcodeproj/project.pbxproj

echo ""
echo "=== Lines 138-139 ==="
sed -n '138,139p' damus.xcodeproj/project.pbxproj

echo ""
echo "=== Lines 2231-2232 ==="
sed -n '2231,2232p' damus.xcodeproj/project.pbxproj

echo ""
echo "=== Lines 2949-2950 ==="
sed -n '2949,2950p' damus.xcodeproj/project.pbxproj

echo ""
echo "=== Line 3833 ==="
sed -n '3833p' damus.xcodeproj/project.pbxproj

echo ""
echo "=== Line 5440 ==="
sed -n '5440p' damus.xcodeproj/project.pbxproj

Repository: damus-io/damus

Length of output: 2513


Remove hand-crafted sequential UUIDs and re-add all GroupedTimeline files through Xcode

Every UUID assigned to the new GroupedTimeline objects follows an obvious decimal-counting pattern rather than the cryptographically random 24-hex-char identifiers Xcode generates:

Location UUID Entry
Lines 1976–1978 B1B2B3…, B2B3B4…, B3B4B5… GroupedTimelineGrouper.swift build files
Lines 1979–1981 C1C2C3…, C2C3C4…, C3C4C5… GroupedModeQueueManager.swift build files
Lines 138–139 D2D3D4…, E2E3E4… test-file build entries
Lines 2231–2232 D1D2D3…, E1E2E3… test-file PBXFileReference
Lines 2949–2950 A1B2C3D4E5F6A7B8C9D0E1F2, F2E1D0C9B8A7F6E5D4C3B2A1 GroupedTimelineGrouper/GroupedModeQueueManager PBXFileReference (with includeInIndex = 1)
Lines 3833 / 5440 A1A2A3A4A5A6A7A8A9A0B1B2 GroupedTimeline PBXGroup

This hand-authored pattern creates three risks:

  • UUID collision — future Xcode-generated entries can randomly collide with these low-entropy values, silently corrupting references.
  • Merge fragility — patterned UUIDs are more likely to conflict when rebasing or cherry-picking.
  • Xcode corruption — on next project save Xcode may rewrite or deduplicate these, breaking the build.

Remove all manually-added GroupedTimeline entries and re-add the six source files and two test files through Xcode's "Add Files to…" dialog so Xcode generates proper random UUIDs and correct PBXFileReference attributes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus.xcodeproj/project.pbxproj` around lines 1976 - 1981, The PBX project
contains hand-crafted sequential UUIDs for new GroupedTimeline entries (e.g.,
PBXBuildFile entries referencing GroupedTimelineGrouper.swift and
GroupedModeQueueManager.swift, PBXFileReference entries, and the GroupedTimeline
PBXGroup) which risks collisions and corruption; remove all manually-added
GroupedTimeline-related PBXBuildFile, PBXFileReference and PBXGroup entries (the
ones using patterns like B1B2B3..., C1C2C3..., A1B2C3..., F2E1D0..., A1A2A3...)
from the project.pbxproj and then re-add the six source files and two test files
using Xcode’s “Add Files to…” dialog so Xcode will generate proper random 24-hex
UUIDs and correct attributes (includeInIndex, etc.). Ensure you delete both the
duplicate build-file entries and their matching fileRef PBXFileReference and
PBXGroup nodes before re-adding.

Comment on lines +6379 to +6384
CB20AABE3C7B960961C0F914 /* GroupedFilterButton.swift in Sources */,
CF86249414FD714F8E45AA55 /* GroupedFilterSettingsView.swift in Sources */,
D93D3D7A501A49D1F86669A3 /* GroupedAuthorRow.swift in Sources */,
91E25E8CB81B797E7FCC4D25 /* GroupedListView.swift in Sources */,
B1B2B3B4B5B6B7B8B9B0C1C2 /* GroupedTimelineGrouper.swift in Sources */,
C1C2C3C4C5C6C7C8C9C0D1D2 /* GroupedModeQueueManager.swift in Sources */,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
cd damus.xcodeproj && python3 - << 'EOF'
import re

with open('project.pbxproj', 'r') as f:
    content = f.read()

# All GroupedTimeline build-file UUIDs added in this PR
grouped_build_uuids = {
    # GroupedAuthorRow
    '0A4E465299FD22F7FF9ABC4B', '343D40E5239706F50DB4B7A1', 'D93D3D7A501A49D1F86669A3',
    # GroupedFilterButton
    '15816FA4C2C558EB93B8E06B', 'CB20AABE3C7B960961C0F914', 'D2491E9E616838CC8A66A5A9',
    # GroupedFilterSettingsView
    '1695A2396ABB4CA359EA5E02', 'CF86249414FD714F8E45AA55', 'E51876D3421296DECB2DEE22',
    # GroupedListView
    '91E25E8CB81B797E7FCC4D25', 'A3D3CD9B73EB7E2020F93345', 'C49C1BB6E2EE0DC7C7D28FC4',
    # GroupedTimelineGrouper (sequential UUIDs)
    'B1B2B3B4B5B6B7B8B9B0C1C2', 'B2B3B4B5B6B7B8B9B0C1C2C3', 'B3B4B5B6B7B8B9B0C1C2C3C4',
    # GroupedModeQueueManager (sequential UUIDs)
    'C1C2C3C4C5C6C7C8C9C0D1D2', 'C2C3C4C5C6C7C8C9C0D1D2D3', 'C3C4C5C6C7C8C9C0D1D2D3D4',
}

# Parse Sources build phases and find those containing any GroupedTimeline UUID
phase_re = re.compile(
    r'([A-F0-9]{24})\s*/\* Sources \*/\s*=\s*\{.*?isa\s*=\s*PBXSourcesBuildPhase;.*?files\s*=\s*\(([^)]+)\)',
    re.DOTALL)

phase_hits = {}
for m in phase_re.finditer(content):
    found = grouped_build_uuids & set(re.findall(r'([A-F0-9]{24})', m.group(2)))
    if found:
        phase_hits[m.group(1)] = found

# Map each phase back to its native target
target_re = re.compile(
    r'([A-F0-9]{24})\s*/\*[^*]*\*/\s*=\s*\{[^{]*?isa\s*=\s*PBXNativeTarget;'
    r'[^{]*?name\s*=\s*"?([^";\n]+)"?\s*;.*?buildPhases\s*=\s*\(([^)]+)\)',
    re.DOTALL)

print("Native targets that compile GroupedTimeline files:")
for m in target_re.finditer(content):
    target_name = m.group(2).strip()
    phase_uuids = set(re.findall(r'([A-F0-9]{24})', m.group(3)))
    if phase_uuids & set(phase_hits.keys()):
        print(f"  → {target_name}")
EOF

Repository: damus-io/damus

Length of output: 125


🏁 Script executed:

#!/bin/bash
cd damus.xcodeproj && grep -o 'name = "[^"]*"[^{]*isa = PBXNativeTarget' project.pbxproj | head -20

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

#!/bin/bash
cd damus.xcodeproj && python3 << 'EOF'
import re

with open('project.pbxproj', 'r') as f:
    content = f.read()

# Find all native targets
target_re = re.compile(
    r'([A-F0-9]{24})\s*/\*([^*]+)\s*\*/\s*=\s*\{[^{]*?isa\s*=\s*PBXNativeTarget;',
    re.DOTALL)

print("All native targets in project:")
for m in target_re.finditer(content):
    print(f"  {m.group(2).strip()}")
EOF

Repository: damus-io/damus

Length of output: 197


🏁 Script executed:

#!/bin/bash
cd damus.xcodeproj && python3 << 'EOF'
import re

with open('project.pbxproj', 'r') as f:
    content = f.read()

grouped_build_uuids = {
    # GroupedAuthorRow
    '0A4E465299FD22F7FF9ABC4B', '343D40E5239706F50DB4B7A1', 'D93D3D7A501A49D1F86669A3',
    # GroupedFilterButton
    '15816FA4C2C558EB93B8E06B', 'CB20AABE3C7B960961C0F914', 'D2491E9E616838CC8A66A5A9',
    # GroupedFilterSettingsView
    '1695A2396ABB4CA359EA5E02', 'CF86249414FD714F8E45AA55', 'E51876D3421296DECB2DEE22',
    # GroupedListView
    '91E25E8CB81B797E7FCC4D25', 'A3D3CD9B73EB7E2020F93345', 'C49C1BB6E2EE0DC7C7D28FC4',
    # GroupedTimelineGrouper
    'B1B2B3B4B5B6B7B8B9B0C1C2', 'B2B3B4B5B6B7B8B9B0C1C2C3', 'B3B4B5B6B7B8B9B0C1C2C3C4',
    # GroupedModeQueueManager
    'C1C2C3C4C5C6C7C8C9C0D1D2', 'C2C3C4C5C6C7C8C9C0D1D2D3', 'C3C4C5C6C7C8C9C0D1D2D3D4',
}

# Parse Sources build phases and find those containing any GroupedTimeline UUID
phase_re = re.compile(
    r'([A-F0-9]{24})\s*/\* Sources \*/\s*=\s*\{.*?isa\s*=\s*PBXSourcesBuildPhase;.*?files\s*=\s*\(([^)]+)\)',
    re.DOTALL)

phase_hits = {}
for m in phase_re.finditer(content):
    found = grouped_build_uuids & set(re.findall(r'([A-F0-9]{24})', m.group(2)))
    if found:
        phase_hits[m.group(1)] = found

# Map each phase back to its native target
target_re = re.compile(
    r'([A-F0-9]{24})\s*/\*([^*]+)\*/\s*=\s*\{[^{]*?isa\s*=\s*PBXNativeTarget;'
    r'[^{]*?buildPhases\s*=\s*\(([^)]+)\)',
    re.DOTALL)

print("Detailed target analysis:")
for m in target_re.finditer(content):
    target_name = m.group(2).strip()
    phase_uuids = set(re.findall(r'([A-F0-9]{24})', m.group(3)))
    has_grouped = bool(phase_uuids & set(phase_hits.keys()))
    status = "✓ HAS GroupedTimeline files" if has_grouped else "✗ NO GroupedTimeline files"
    print(f"  {target_name}: {status}")
EOF

Repository: damus-io/damus

Length of output: 363


Remove GroupedTimeline files from ShareExtension and HighlighterActionExtension targets

Verification confirms the GroupedTimeline source files are incorrectly wired into two app extension targets:

  • ShareExtension
  • HighlighterActionExtension

These extensions should not include SwiftUI views and filter-settings logic—they will cause linker failures or unnecessarily bloat the extension binaries. Remove the six GroupedTimeline files from these extension targets; only the main damus app target should build them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus.xcodeproj/project.pbxproj` around lines 6379 - 6384, The six
GroupedTimeline source files (GroupedFilterButton.swift,
GroupedFilterSettingsView.swift, GroupedAuthorRow.swift, GroupedListView.swift,
GroupedTimelineGrouper.swift, GroupedModeQueueManager.swift) are currently
assigned to the ShareExtension and HighlighterActionExtension targets; open the
project.pbxproj and remove their file reference entries (e.g.,
CB20AABE3C7B960961C0F914, CF86249414FD714F8E45AA55, D93D3D7A501A49D1F86669A3,
91E25E8CB81B797E7FCC4D25, B1B2B3B4B5B6B7B8B9B0C1C2, C1C2C3C4C5C6C7C8C9C0D1D2)
from the PBXNativeTarget build phase sections for ShareExtension and
HighlighterActionExtension so they remain only in the main damus app target;
ensure you only remove the target membership entries and do not delete the file
references themselves from the project if the main app still needs them.

Comment thread damus/Features/NIP05/Models/NIP05.swift
Comment thread damus/Features/NIP05/Models/NIP05DomainEventsModel.swift
Comment on lines +134 to +143
func load_more() {
guard !loading_more, has_more else { return }
guard let oldest = events.all_events.last?.created_at else { return }

loading_more = true
let until = oldest > 0 ? oldest &- 1 : 0
Task {
await self.fetch_older(until: until)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing docstrings on load_more, fetch_older, and add_event.

All three methods are added in this PR with no /// documentation. The coding guidelines require docstring coverage for any code added or modified.

♻️ Skeleton to add
+    /// Requests the next page of older events, guarded by `loading_more`/`has_more`.
+    /// Computes `until` from the oldest currently loaded event timestamp.
     func load_more() { ... }

+    /// Fetches events older than `until` using a one-shot relay query.
+    /// Updates `has_more` and `last_loaded_count` on EOSE.
+    /// - Parameter until: Upper-bound Unix timestamp (exclusive) for the query.
     private func fetch_older(until: UInt32) async { ... }

+    /// Validates and inserts a single relay event into the holder.
+    /// Skips metadata, filter mismatches, and muted/NSFW content.
+    /// - Parameter ev: The Nostr event received from the relay.
     func add_event(_ ev: NostrEvent) async { ... }

As per coding guidelines: "Ensure docstring coverage for any code added or modified."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func load_more() {
guard !loading_more, has_more else { return }
guard let oldest = events.all_events.last?.created_at else { return }
loading_more = true
let until = oldest > 0 ? oldest &- 1 : 0
Task {
await self.fetch_older(until: until)
}
}
/// Requests the next page of older events, guarded by `loading_more`/`has_more`.
/// Computes `until` from the oldest currently loaded event timestamp.
func load_more() {
guard !loading_more, has_more else { return }
guard let oldest = events.all_events.last?.created_at else { return }
loading_more = true
let until = oldest > 0 ? oldest &- 1 : 0
Task {
await self.fetch_older(until: until)
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift` around lines 134 -
143, Add triple-slash docstrings for the three new methods: load_more(),
fetch_older(until:), and add_event(_:). For each, provide a one-line summary of
purpose, brief description of parameters (e.g., until: cutoff timestamp for
fetch_older, event param for add_event), mention concurrency/side-effects (that
load_more/ fetch_older run async Tasks and mutate loading_more/ events), and any
return/throw behavior (if none, state “no return value”). Place the docstrings
directly above the corresponding function declarations (load_more, fetch_older,
add_event) using the /// style to satisfy the coding guidelines.

Comment thread damus/Features/NIP05/Models/NIP05DomainEventsModel.swift
Comment on lines +14 to +27
/// Display modes for the NIP-05 domain timeline.
enum NIP05ViewMode: String, CaseIterable {
case grouped // One row per author with post count
case timeline // Chronological list of all posts

var localizedTitle: String {
switch self {
case .grouped:
return NSLocalizedString("Grouped", comment: "Tab label for grouped view of posts by author")
case .timeline:
return NSLocalizedString("Timeline", comment: "Tab label for chronological timeline view")
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "NIP05ViewMode" --type=swift

Repository: damus-io/damus

Length of output: 160


Remove unused NIP05ViewMode enum.

The enum defines .grouped and .timeline cases with localizedTitle property but is never referenced in the codebase. The view uses filterSettings.enableGroupedMode (a boolean) to toggle between modes instead, making this enum dead code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift` around lines 14 -
27, Remove the unused NIP05ViewMode enum and its localizedTitle property: locate
the enum declaration named NIP05ViewMode and delete it (including the cases
.grouped and .timeline and the var localizedTitle), and ensure there are no
remaining references—note the view currently toggles modes via
filterSettings.enableGroupedMode, so update any references that might have
intended to use NIP05ViewMode to rely on filterSettings.enableGroupedMode
instead if necessary.

Comment on lines +869 to +878
// Insert all events at once, then clear shimmer
for ev in allEvents {
self.damus_state.events.insert(ev)
if isFavorites {
await self.favoriteEvents.insert(ev)
} else {
await self.insert_home_event(ev)
}
}
DispatchQueue.main.async { self.backfilling = false }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the containing method around lines 869-878
fd "HomeModel.swift" --type f

Repository: damus-io/damus

Length of output: 155


🏁 Script executed:

# Check the method signature and context around lines 869-878
rg -n "backfillPageLimit\|EventCache\|EventHolder" damus --type swift | head -40

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

# Find the definition of backfillPageLimit
rg -n "backfillPageLimit" damus --type swift -B2 -A2

Repository: damus-io/damus

Length of output: 1062


🏁 Script executed:

# Check if the method containing this code has `@MainActor` annotation
sed -n '850,885p' damus/Features/Timeline/Models/HomeModel.swift

Repository: damus-io/damus

Length of output: 1620


🏁 Script executed:

# Look for EventCache.insert implementation
rg -n "class EventCache" damus --type swift -A50 | grep -A20 "func insert"

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

# Look for EventHolder.insert implementation
rg -n "class EventHolder\|struct EventHolder" damus --type swift -A100 | grep -B5 -A10 "func insert"

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

# Get more context to find the method signature
sed -n '820,885p' damus/Features/Timeline/Models/HomeModel.swift

Repository: damus-io/damus

Length of output: 2911


🏁 Script executed:

# Search for damus_state.events definition
rg -n "var events" damus --type swift -B2 -A5

Repository: damus-io/damus

Length of output: 21627


🏁 Script executed:

# Search for EventCache class definition
rg -n "class EventCache" damus --type swift -A30

Repository: damus-io/damus

Length of output: 2402


🏁 Script executed:

# Search for insert method more broadly
rg -n "\.insert\(" damus/Features/Timeline/Models/HomeModel.swift -B2 -A2

Repository: damus-io/damus

Length of output: 2857


🏁 Script executed:

# Get the insert method from EventHolder
rg -n "func insert" damus/Shared/Utilities/EventHolder.swift -B2 -A15

Repository: damus-io/damus

Length of output: 1672


🏁 Script executed:

# Check the full EventHolder class to see actor/isolation annotations
sed -n '11,150p' damus/Shared/Utilities/EventHolder.swift

Repository: damus-io/damus

Length of output: 3778


🏁 Script executed:

# Check if insert_home_event and favoriteEvents.insert are on the main thread
grep -n "@MainActor\|class EventHolder\|func insert" damus/Shared/Utilities/EventHolder.swift | head -20

Repository: damus-io/damus

Length of output: 515


🏁 Script executed:

# Check if damus_state.events is EventCache or EventHolder
rg -n "damus_state.events" damus/Features/Timeline/Models/HomeModel.swift | head -5

Repository: damus-io/damus

Length of output: 442


🏁 Script executed:

# Check what damus_state.events actually is - look at DamusState definition
rg -n "class DamusState\|struct DamusState" damus --type swift -A20 | grep -A20 "var events"

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

# Check if there are any batch or bulk insert methods available
rg -n "func.*insert\|batch\|bulk" damus/Shared/Utilities/EventHolder.swift

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

# Find DamusState definition
fd "DamusState" damus --type f

Repository: damus-io/damus

Length of output: 141


🏁 Script executed:

# Search for DamusState class/struct definition
rg -n "class DamusState\|struct DamusState" damus --type swift -A50 | head -100

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

# Check what type events is in the context
rg -n "var events:" damus --type swift -B3 -A3

Repository: damus-io/damus

Length of output: 17350


🏁 Script executed:

# Look at the DamusState definition
head -100 damus/Core/Storage/DamusState.swift

Repository: damus-io/damus

Length of output: 4060


🏁 Script executed:

# Search for events property specifically in DamusState
grep -n "var events" damus/Core/Storage/DamusState.swift

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

# Get the insert method in EventCache
rg -n "func insert" damus/Shared/Utilities/EventCache.swift -B2 -A15

Repository: damus-io/damus

Length of output: 536


🏁 Script executed:

# Check if EventCache is an actor or has MainActor annotation
head -200 damus/Shared/Utilities/EventCache.swift | tail -100

Repository: damus-io/damus

Length of output: 3082


🏁 Script executed:

# Check if backfillForGroupedMode has `@MainActor` annotation
rg -n "func backfillForGroupedMode" damus/Features/Timeline/Models/HomeModel.swift -B5

Repository: damus-io/damus

Length of output: 371


🏁 Script executed:

# Check the full context of the method to see if it's isolated to MainActor
sed -n '820,825p' damus/Features/Timeline/Models/HomeModel.swift

Repository: damus-io/damus

Length of output: 308


🏁 Script executed:

# Check what actor or MainActor annotation HomeModel has
rg -n "class HomeModel\|@MainActor" damus/Features/Timeline/Models/HomeModel.swift | head -10

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

# Verify if EventCache has any actor or thread-safety annotations
head -105 damus/Shared/Utilities/EventCache.swift | tail -15

Repository: damus-io/damus

Length of output: 595


🏁 Script executed:

# Check insert_home_event signature
rg -n "func insert_home_event" damus/Features/Timeline/Models/HomeModel.swift -B2 -A5

Repository: damus-io/damus

Length of output: 263


Bulk insertion of up to 5000 events with repeated main-actor context switches.

The backfillForGroupedMode task executes on a background thread and loops through allEvents (up to backfillPageLimit = 5000), calling await self.favoriteEvents.insert(ev) and await self.insert_home_event(ev) for each iteration. Both methods are @MainActor, causing 5000 context switches to the main thread.

Additionally, self.damus_state.events.insert(ev) (EventCache.insert) is called directly from the background thread without thread safety, as EventCache is not thread-isolated and maintains a non-atomic dictionary.

Consider batching EventHolder inserts or restructuring to minimize main-actor context switches. Ensure EventCache modifications are properly synchronized if accessed from multiple threads.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Models/HomeModel.swift` around lines 869 - 878, The
loop in backfillForGroupedMode causes up to 5000 main-actor switches and
unsynchronized access to EventCache: collect allEvents (or build two arrays:
favoritesBatch and homeBatch) on the background task, then perform a single
await MainActor.run { ... } (or call one `@MainActor` method) to 1) insert the
batch into favoriteEvents via favoriteEvents.insert(contentsOf:) or similar and
2) call insert_home_event for the batch; also move the damus_state.events.insert
calls into that same main-actor block (or protect EventCache by annotating
EventCache as `@MainActor` or serializing its mutations on a dedicated queue) so
EventCache mutations are done safely and context switches are minimized.

Comment thread damus/Features/Timeline/Views/PostingTimelineView.swift
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (10)
damus/Features/Timeline/Views/PostingTimelineView.swift (1)

264-272: Potential UInt32 wrapping if timeRange.seconds exceeds the current timestamp is not guarded.

While in practice the current Unix timestamp (~1.7B) vastly exceeds the max timeRange.seconds (7 days ≈ 604800), a defensive max(0, ...) or saturating subtraction would harden this against future additions of larger time ranges.

     private func backfillForGroupedMode(showLoading: Bool = true) {
-        let since = UInt32(Date().timeIntervalSince1970) - groupedFilterSettings.timeRange.seconds
+        let now = UInt32(Date().timeIntervalSince1970)
+        let since = now > groupedFilterSettings.timeRange.seconds ? now - groupedFilterSettings.timeRange.seconds : 0
         home.backfillForGroupedMode(since: since, source: timeline_source, showLoading: showLoading)
     }
 
     private func refreshFromNdb() {
-        let since = UInt32(Date().timeIntervalSince1970) - groupedFilterSettings.timeRange.seconds
+        let now = UInt32(Date().timeIntervalSince1970)
+        let since = now > groupedFilterSettings.timeRange.seconds ? now - groupedFilterSettings.timeRange.seconds : 0
         home.refreshGroupedFromNdb(since: since, source: timeline_source)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Views/PostingTimelineView.swift` around lines 264 -
272, The subtraction to produce a UInt32 timestamp in backfillForGroupedMode and
refreshFromNdb can wrap if groupedFilterSettings.timeRange.seconds is larger
than the current time; guard by computing a non-negative difference before
casting: compute let delta = max(0, Int(Date().timeIntervalSince1970) -
Int(groupedFilterSettings.timeRange.seconds)) (or use a saturating/subtraction
helper) and then pass UInt32(delta) to home.backfillForGroupedMode(...) and
home.refreshGroupedFromNdb(...), referencing the functions
backfillForGroupedMode, refreshFromNdb and the
groupedFilterSettings.timeRange.seconds value to locate the change.
damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift (1)

68-85: eventPreviewText regex won't strip nostr: references containing _ (e.g., NIP-19 TLV hex segments).

The current regex nostr:[a-zA-Z0-9]+ covers standard bech32-encoded references well. However, some clients embed raw hex or extended identifiers that can include underscores or other characters after the nostr: prefix. A slightly broader character class like [a-zA-Z0-9_]+ or the commonly used \\S+ would be safer:

-            .replacingOccurrences(of: "nostr:[a-zA-Z0-9]+", with: "", options: .regularExpression)
+            .replacingOccurrences(of: "nostr:\\S+", with: "", options: .regularExpression)

This prevents leftover fragments from partially-matched references appearing in previews.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift` around lines
68 - 85, The regex in eventPreviewText currently only removes alphanumerics
after "nostr:" and can leave fragments (update the replacement to match broader
identifiers); change the replacement pattern used on content (the
.replacingOccurrences(of: "nostr:[a-zA-Z0-9]+", ...)) to a pattern that captures
underscores or any non-whitespace sequence (e.g., "nostr:[a-zA-Z0-9_]+" or
"nostr:\\S+"), so the cleaned string removes full nostr: references before
trimming and length-checking.
damus/Shared/Components/GroupedTimeline/GroupedFilterSettingsView.swift (1)

12-39: Consider annotating GroupedFilterSettings with @MainActor.

This ObservableObject is mutated from SwiftUI views (main thread) and from onChange/onAppear callbacks. Adding @MainActor to the class ensures the compiler enforces main-thread-only access, preventing accidental background mutations as the codebase grows.

-class GroupedFilterSettings: ObservableObject {
+@MainActor
+class GroupedFilterSettings: ObservableObject {

Also, reset() (line 31) is missing a docstring per coding guidelines.

+    /// Restores all filter properties to their initial defaults.
     func reset() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedFilterSettingsView.swift`
around lines 12 - 39, Annotate the ObservableObject class GroupedFilterSettings
with `@MainActor` to ensure all published-property mutations are main-thread
enforced, and add a short docstring above the reset() method describing its
purpose (restores defaults) per coding guidelines; update the class declaration
(GroupedFilterSettings) and the reset() method doc comment accordingly so the
compiler and reviewers can verify main-thread safety and documentation.
damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift (1)

92-139: Internal static methods are missing doc comments — required by coding guidelines.

parseFilteredWords, shouldIncludeEvent, containsFilteredWords, and isTooShort each lack a doc comment (///), while group() and the types above them are documented. As per coding guidelines, docstring coverage is required for any code added or modified.

📝 Suggested doc stubs
+    /// Parses a raw comma-separated filter string into a trimmed, lowercased word list.
+    /// Words shorter than 2 characters are discarded.
     static func parseFilteredWords(_ raw: String) -> [String] {

+    /// Returns `true` if the event passes all active filters and should appear in the grouped view.
     static func shouldIncludeEvent(

+    /// Returns `true` if the event's content contains any of the words in `wordsList` (substring match).
     static func containsFilteredWords(_ event: NostrEvent, wordsList: [String]) -> Bool {

+    /// Returns `true` when `hideShortNotes` is enabled and the event's non-emoji text content is below
+    /// the minimum meaningful length threshold.
     static func isTooShort(_ event: NostrEvent, hideShortNotes: Bool) -> Bool {

As per coding guidelines, "Ensure docstring coverage for any code added or modified."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift` around
lines 92 - 139, Add missing doc comments for the four internal static methods:
parseFilteredWords(_:), shouldIncludeEvent(_:filter:values:wordsList:cutoff:),
containsFilteredWords(_:wordsList:), and isTooShort(_:hideShortNotes:). For each
method add a /// comment that briefly describes the method's purpose, lists and
documents parameters (e.g., raw, event, filter, values, wordsList, cutoff,
hideShortNotes) and states the return value (what the Bool or [String]
represents); keep comments concise and consistent with the existing style used
for group() and surrounding types.
damus/Features/NIP05/Views/NIP05DomainTimelineView.swift (1)

183-186: groupedSince silently returns nil when grouped mode is off, but handleOnAppear always passes it.

If the user toggles from grouped → timeline while the view is alive, then navigates away and back, handleOnAppear passes groupedSince (now nil) to subscribe. This is correct for timeline mode, but the interaction is subtle and undocumented. A brief inline comment would help future readers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift` around lines 183 -
186, The computed property groupedSince returns nil when
filterSettings.enableGroupedMode is false but handleOnAppear always forwards
groupedSince into subscribe, which can silently change behavior when the user
toggles modes; add a concise inline comment next to the groupedSince property
explaining that it intentionally returns nil for timeline mode and that
handleOnAppear/subscribe rely on nil to indicate timeline (so toggling
grouped→timeline while the view is alive is expected), and mention the
interaction between groupedSince, handleOnAppear, and subscribe so future
readers understand the intended contract.
damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift (2)

58-64: allAuthors recomputes a deduplication pass over all events on every SwiftUI render.

model.events.all_events.map { $0.pubkey } allocates a temporary array proportional to the event count (up to 5000 in grouped mode), plus a Set for dedup, on every body evaluation. Consider caching the ordered unique authors list in the model or in a @State/computed property that only updates when model.events changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift` around lines
58 - 64, The computed property allAuthors currently maps model.events.all_events
to extract pubkeys and deduplicates them on every SwiftUI render, causing large
temporary allocations; change this so the ordered unique authors list is cached
and only recomputed when events change — either move the deduplication into the
backing model (e.g., expose a cached property on the model like uniqueAuthors)
or store it in the view as a `@State/`@StateObject/@ObservedObject-backed property
that you update in an onReceive or didSet when model.events changes; ensure you
use the same ordering (event order + filter authors) and keep references to
model.filter.authors and model.events.all_events when updating so UI reads
allAuthors cheaply.

103-128: notesFromAuthorsString and friendsOfFriendsString are near-identical — consider consolidating.

Both functions share the same structure: look up 3 author names, then switch on count with identical format strings. The only differences are the profile lookup method (lookup_profile_and_copy vs inline lookup_profile borrow), and the case 0 string. A single parameterized helper (e.g., accepting an emptyMessage parameter and a name-resolver closure) would eliminate the duplication.

♻️ Sketch
-func notesFromAuthorsString(_ authors: [Pubkey], ndb: Ndb, locale: Locale = Locale.current) -> String {
+func notesFromAuthorsString(_ authors: [Pubkey], ndb: Ndb, locale: Locale = Locale.current, emptyMessage: String? = nil) -> String {
     let bundle = bundleForLocale(locale: locale)
     let names: [String] = authors.prefix(3).map { pk in
         let profile = try? ndb.lookup_profile_and_copy(pk)
         return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 20)
     }
     switch authors.count {
     case 0:
-        return NSLocalizedString("No notes yet", bundle: bundle, comment: "...")
+        return emptyMessage ?? NSLocalizedString("No notes yet", bundle: bundle, comment: "...")
     // ... rest unchanged
     }
 }

Then friendsOfFriendsString becomes a thin wrapper calling notesFromAuthorsString with a custom emptyMessage.

Also applies to: 151-179

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift` around lines
103 - 128, The two near-identical functions notesFromAuthorsString and
friendsOfFriendsString should be consolidated into a single parameterized
helper: extract the shared logic (fetch up to 3 display names, truncate, and
switch on authors.count to format localized strings) into a new function (e.g.,
authorsSummaryString(authors: [Pubkey], ndb: Ndb, locale: Locale, emptyMessage:
String, nameResolver: (Pubkey, Ndb) -> Profile?)), and update
notesFromAuthorsString and friendsOfFriendsString to call that helper passing
the appropriate emptyMessage and a name-resolver closure that uses either
lookup_profile_and_copy or lookup_profile as needed; ensure the helper uses
Profile.displayName(...).username.truncate(maxLength: 20) and preserves the
localized format keys (including notes_from_three_and_others).
damus/Features/Timeline/Models/HomeModel.swift (3)

853-866: Confusing double-check for .eose in the inner for await loop — use a labeled break instead.

In Swift, break inside a switch only exits the switch, not the enclosing loop. The current code works around this with a post-switch if case .eose = item { break }, while .ndbEose/.networkEose use continue to skip that check. The duplication and the interaction between break/continue and the if guard are non-obvious.

A labeled break removes the ambiguity entirely:

-            for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [chunkFilter], streamMode: .ndbAndNetworkParallel(networkOptimization: nil)) {
-                switch item {
-                case .event(let lender):
-                    await lender.justUseACopy({ ev in
-                        guard should_show_event(state: self.damus_state, ev: ev) else { return }
-                        allEvents.append(ev)
-                    })
-                case .eose:
-                    break
-                case .ndbEose, .networkEose:
-                    continue
-                }
-                if case .eose = item { break }
-            }
+            chunkStream: for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [chunkFilter], streamMode: .ndbAndNetworkParallel(networkOptimization: nil)) {
+                switch item {
+                case .event(let lender):
+                    await lender.justUseACopy({ ev in
+                        guard should_show_event(state: self.damus_state, ev: ev) else { return }
+                        allEvents.append(ev)
+                    })
+                case .eose:
+                    break chunkStream
+                case .ndbEose, .networkEose:
+                    break   // continue waiting for .eose
+                }
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Models/HomeModel.swift` around lines 853 - 866, The
inner for-await loop over damus_state.nostrNetwork.reader.advancedStream should
use a labeled loop to avoid the confusing post-switch check for .eose; add a
label (e.g., streamLoop:) before the for await, then inside the switch case
.eose use break streamLoop to exit the loop directly, remove the trailing if
case .eose = item { break } check, and keep the existing .event handling (await
lender.justUseACopy { ... }) and the .ndbEose/.networkEose continue behavior
unchanged so semantics via should_show_event and allEvents remain the same.

766-772: Docstring is placed on the backfillTask variable but describes the backfilling operation.

Lines 766–768 read like a method-level docstring ("Backfills the given EventHolder with events from the relay going back to since…") yet they are attached to private var backfillTask: Task<Void, Never>?. ndbRefreshTask and backfillPageLimit also lack any documentation.

Move the descriptive comment to backfillForGroupedMode (which is the method it actually describes) and replace the variable comments with concise property-level docs:

-/// Backfills the given EventHolder with events from the relay going back to `since`.
-/// Used by grouped mode to ensure the full time window is represented,
-/// since the normal subscription only fetches the most recent N events.
-private var backfillTask: Task<Void, Never>?
-
-private var ndbRefreshTask: Task<Void, Never>?
-private let backfillPageLimit: UInt32 = 5000
+/// Handle for the active background backfill task; cancel before re-creating.
+private var backfillTask: Task<Void, Never>?
+/// Handle for the active NDB-only local refresh task; cancel before re-creating.
+private var ndbRefreshTask: Task<Void, Never>?
+/// Maximum number of events fetched per author-chunk subscription during backfill.
+private let backfillPageLimit: UInt32 = 5000

As per coding guidelines, docstring coverage is required for any code added or modified.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Models/HomeModel.swift` around lines 766 - 772, The
comment describing the backfilling behavior should be moved from the property
declaration to the actual method it documents: remove the method-level docstring
currently above private var backfillTask and paste it above the
backfillForGroupedMode(...) function so the description matches the
implementation; then add concise property-level docs for private var
backfillTask: Task<Void, Never>? (e.g., "Task managing backfill work for grouped
mode"), private var ndbRefreshTask: Task<Void, Never>? (e.g., "Task for
refreshing ndb state"), and private let backfillPageLimit: UInt32 (e.g.,
"Maximum events to request per backfill page") to satisfy doc coverage and
coding guidelines.

894-913: refreshSingleAuthorEvents creates an unmanaged, non-cancellable Task.

Every invocation creates a new background task with no stored handle. Unlike homeHandlerTask, favoritesHandlerTask, backfillTask, and ndbRefreshTask (all cancelled before re-creation), this task cannot be cancelled. Rapid repeated calls — e.g., tapping into then back out of profiles quickly — accumulate concurrent tasks that all write to the same EventHolder, producing interleaved event insertions and redundant relay subscriptions.

♻️ Proposed fix — store and cancel the task handle

Add a stored property near the other task handles:

 private var ndbRefreshTask: Task<Void, Never>?
+private var singleAuthorRefreshTask: Task<Void, Never>?
 private let backfillPageLimit: UInt32 = 5000

Then cancel before re-creating:

 func refreshSingleAuthorEvents(pubkey: Pubkey, since: UInt32, source: TimelineSource) {
     let kinds: [NostrKind] = [.text, .longform, .boost, .highlight]
     let isFavorites = source == .favorites

     var filter = NostrFilter(kinds: kinds)
     filter.authors = [pubkey]
     filter.since = since
     filter.limit = 500

-    Task {
+    singleAuthorRefreshTask?.cancel()
+    singleAuthorRefreshTask = Task {
         for await item in damus_state.nostrNetwork.reader.advancedStream(...) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Models/HomeModel.swift` around lines 894 - 913,
refreshSingleAuthorEvents currently spawns an unmanaged Task that cannot be
cancelled; add a stored Task? property (e.g., refreshSingleAuthorTask or
authorEventsTask) alongside homeHandlerTask/favoritesHandlerTask, cancel the
existing task (.cancel()) before creating a new one, assign the newly created
Task to that property, and ensure the Task's loop honors cancellation (check
Task.isCancelled or Task.checkCancellation() inside the for-await loop that
iterates damus_state.nostrNetwork.reader.advancedStream(filters: [filter],
streamMode: .ndbAndNetworkParallel(...)) so it exits promptly when cancelled.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift`:
- Around line 116-118: The loop in streamItems() that calls
check_nip05_validity(pubkey:damus_state:) for each pubkey from
scan_all_cached_profiles launches uncontrolled Task.detached bursts and must be
throttled: replace the per-pubkey fire-and-forget with a limited-concurrency
executor (e.g., an AsyncSemaphore or an OperationQueue with
maxConcurrentOperationCount) that batches or gates requests (e.g., process
authors in chunks or await semaphore.acquire/release around each validation
Task), deduplicate the authors list before scheduling, and ensure subscribe()
does not re-trigger validations unnecessarily (cache validated pubkeys and
short-circuit in the scheduler). Use the existing check_nip05_validity function
name and the streamItems()/subscribe()/scan_all_cached_profiles call sites to
locate where to implement the semaphore/batching and caching logic.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift`:
- Around line 76-78: The current use of maxNotesPerUser with groupsByAuthor =
groupsByAuthor.filter { $0.value.count <= maxNotes } removes entire author
groups when they exceed the threshold; change this so the setting caps displayed
notes per author instead of excluding them by: when values.maxNotesPerUser is
set, transform groupsByAuthor by truncating each author’s value array to at most
maxNotes (e.g., map each entry to keep only the first N notes) rather than
filtering out authors entirely; alternatively, if the real intention is to
exclude prolific posters, rename maxNotesPerUser to spamThreshold and add a doc
comment on the new name clarifying exclusion semantics so callers aren’t misled.
- Around line 53-88: The grouping work in GroupedTimelineGrouper.group(...)
(which calls parseFilteredWords(...) and containsFilteredWords(...)) is
CPU-heavy and must be moved off the main thread; update GroupedListView.body to
kick off an async background task (Task { } or DispatchQueue.global().async)
that calls GroupedTimelineGrouper.group(events:filter:values:now:), store the
result in a `@State` or `@StateObject` cache (e.g. groupedResults) and then dispatch
the final AuthorGroup array back to the main thread to assign the state for
rendering; ensure you still use values.maxNotesPerUser and other
GroupedFilterValues inside the background task, debounce or re-run the task when
events/filter/values change, and avoid doing lowercasing/splitting/searching on
the main thread by keeping those operations inside group().

---

Duplicate comments:
In @.beads/issues.jsonl:
- Line 9: The JSON object with id "damus-ir1" is missing the required created_by
field; update the record in .beads/issues.jsonl by adding a created_by property
(e.g., a user identifier or system actor) to the object with id "damus-ir1" so
the schema includes created_by alongside existing fields like id, title, status,
priority, issue_type, created_at, updated_at, and closed_at; ensure the new
created_by value follows the same format used elsewhere in the file.

In `@damus.xcodeproj/project.pbxproj`:
- Around line 6379-6384: The six GroupedTimeline source entries
(GroupedFilterButton.swift, GroupedFilterSettingsView.swift,
GroupedAuthorRow.swift, GroupedListView.swift, GroupedTimelineGrouper.swift,
GroupedModeQueueManager.swift) are incorrectly present in the ShareExtension and
HighlighterActionExtension PBXSourcesBuildPhase sections; remove their
PBXBuildFile entries from those extension Sources build phases so only the main
damus target's PBXSourcesBuildPhase contains them, ensuring you delete the
corresponding build-file UUID lines (e.g., CB20AABE3C7B960961C0F914,
CF86249414FD714F8E45AA55, D93D3D7A501A49D1F86669A3, 91E25E8CB81B797E7FCC4D25,
B1B2B3B4B5B6B7B8B9B0C1C2, C1C2C3C4C5C6C7C8C9C0D1D2) from the extension
PBXSourcesBuildPhase blocks and leave them only in the main target's Sources
build phase.
- Around line 138-139: The project.pbxproj contains hand-crafted patterned UUIDs
for GroupedTimelineGrouper.swift, GroupedModeQueueManager.swift and their test
files (and related PBXBuildFile/PBXFileReference/PBXGroup entries like the
B1…/C1…/A1… groups listed), so remove the existing references for
GroupedTimelineGrouper.swift, GroupedModeQueueManager.swift,
GroupedTimelineGrouperTests.swift and GroupedModeQueueManagerTests.swift from
the project (delete their PBXBuildFile and PBXFileReference entries and any
group children entries referencing those IDs), then re-add those four files
using Xcode’s “Add Files to…” dialog so Xcode generates proper random UUIDs;
verify no patterned IDs remain in PBXBuildFile, PBXFileReference, PBXGroup or
PBXSourcesBuildPhase entries (search for the D1D2D3…, D2D3D4…, E1E2E3…, E2E3E4…
style IDs) before committing.

In `@damus/Features/NIP05/Models/NIP05.swift`:
- Around line 184-189: The .friends_of_friends branch is performing per-pubkey
synchronous profile checks on the `@MainActor` via matches_domain (which calls
profiles.is_validated and profiles.lookup); move the expensive per-pubkey work
off the main actor by collecting contacts.get_friend_of_friends_list(), then run
the domain-matching loop inside a Task.detached (or other background Task) that
calls matches_domain for each pubkey and accumulates matching pubkeys, and
finally switch back to the main actor to insert the results into authors (or
call await MainActor.run { authors.formUnion(matching) }); reference
contacts.get_friend_of_friends_list(), the .friends_of_friends switch arm,
matches_domain, profiles.is_validated, profiles.lookup, and
authors.insert(pubkey) when making the change.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift`:
- Around line 135-144: Add concise docstrings to the methods load_more(),
fetch_older(until:), and add_event(_: ) describing their purpose, parameters,
return behavior, and side effects: for load_more explain it triggers loading
older events when not already loading and has_more is true; for
fetch_older(until:) document the meaning of the until parameter, that it
performs async fetch and updates model state (e.g., loading_more, has_more,
events), and that it runs on Task; for add_event(_: ) describe what kind of
Event it accepts, how it mutates events collection and any threading/ordering
guarantees. Attach these comments directly above each function signature in
NIP05DomainEventsModel so future readers understand inputs, outputs, and state
changes.
- Around line 170-174: fetch_older currently only clears loading_more when
advancedStream terminates, leaving has_more true and causing repeated empty
fetches; modify fetch_older to track whether an .eose was received (e.g., a
local flag) and in the stream termination/finally path set has_more = false if
no .eose was seen (or if the stream ended/cancelled), so that load_more() stops
requesting when the stream closes without EOSE; update references in the
fetch_older function to use this flag and ensure loading_more is still cleared.
- Around line 70-77: set_friend_filter is resetting the active time-window
because it calls subscribe(resetEvents: false) which causes subscribe to set
self.since = nil; update set_friend_filter so it preserves and forwards the
current since to subscribe (e.g. call subscribe(resetEvents: false, since:
self.since) or ensure subscribe(resetEvents: false) does not clear self.since),
referencing set_friend_filter(...) and subscribe(...) and the self.since
property when making the change.
- Around line 44-62: The subscribe(resetEvents: Bool = true, since: UInt32? =
nil) call currently resets pagination state even when resetEvents is false,
causing streamItems() to re-fetch overlapping events that EventHolder.insert
deduplicates; change the logic so that events.reset(), used_initial_page,
has_more, last_loaded_count (and any other pagination flags) are only reset when
resetEvents is true, and when resetEvents is false call events.flush() and
preserve used_initial_page, has_more, last_loaded_count so streamItems()
continues from the current pagination state; keep cancelling loadingTask and
starting a new Task to call streamItems(), but avoid clobbering pagination
variables when resetEvents == false.
- Around line 86-88: The hardcoded "filter.limit = 5000" in
NIP05DomainEventsModel (the block that sets filter.since and filter.limit when
"since" is present) can cause memory pressure for popular domains; change it to
use a configurable constant or adaptive cap (e.g., a new property like
maxEventsPerDomain or fetchBatchSize on NIP05DomainEventsModel or a settings
object) instead of the literal 5000, and/or implement pagination (fetch in
smaller chunks and accumulate) when grouped mode is enabled; update the code
that assigns filter.limit to use that constant or computed value and add tests
or comments where the grouped-mode behavior is handled.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift`:
- Around line 14-27: The NIP05ViewMode enum is unused because the view mode is
controlled by filterSettings.enableGroupedMode; remove the dead enum
NIP05ViewMode or refactor code to use it consistently: either replace usages of
filterSettings.enableGroupedMode with NIP05ViewMode (e.g., add a property on
filterSettings of type NIP05ViewMode and update view toggles) or delete the
NIP05ViewMode declaration and any references to it to avoid dead code; locate
the enum NIP05ViewMode in NIP05DomainTimelineView.swift and update the
filterSettings usage (enableGroupedMode) or the view toggle code to ensure only
one source of truth remains.
- Around line 189-192: handleOnAppear currently returns early when
model.events.all_events is non-empty, so after handleOnDisappear calls
unsubscribe() the view never re-establishes the relay; remove or change that
guard so handleOnAppear calls model.subscribe(since: groupedSince) whenever the
model is not currently subscribed. Specifically, update handleOnAppear to call
model.subscribe(since: groupedSince) unconditionally or guard on a subscription
flag (e.g., model.isSubscribed / model.isActive) instead of
model.events.all_events, and ensure handleOnDisappear still calls
model.unsubscribe() to cancel the stream.
- Around line 108-111: The hard-coded paddings in NIP05DomainTimelineView
(.padding(.leading, 45) and .padding(.top, 50)) are fragile; replace them with
values derived from the view's safe area insets (use GeometryReader to read
geometry.safeAreaInsets or use
UIApplication.shared.windows.first?.safeAreaInsets) and apply .padding(.leading,
safeAreaInsets.leading + X) and .padding(.top, safeAreaInsets.top + Y) so the
alignment after the back button and top spacing adapt to Dynamic
Island/notch/no-notch devices; update the layout code around the block
containing .padding(.leading, 45) and .padding(.top, 50) accordingly.

In `@damus/Features/Profile/Views/CondensedProfilePicturesView.swift`:
- Around line 19-22: The initializer for CondensedProfilePicturesView
(init(state:pubkeys:maxPictures:size:)) is missing its own triple-slash
docstring; add a /// docstring directly above this init that documents each
parameter (state, pubkeys, maxPictures, size) with brief descriptions and any
defaults/constraints, matching the existing project's docstring style and tone
so the init's parameters are covered per the coding guidelines.

In `@damus/Features/Timeline/Models/HomeModel.swift`:
- Line 846: The task cancellation race occurs in backfillForGroupedMode: after
backfillTask?.cancel() a previous task T1 can resume, hit the guard and break
out of the streaming loop but still proceed to insert allEvents and set
backfilling = false, clearing state while a new task T2 runs; to fix, ensure the
cancelled task aborts before performing any insertion or mutating backfilling by
checking Task.isCancelled (or comparing backfillTask identity) immediately after
collection: if cancelled, return early without touching EventHolder/EventCache
or backfilling. Also minimize actor hops by moving the per-event awaits
(favoriteEvents.insert and insert_home_event) into a single MainActor.run block
that performs batched insertion and then updates backfilling, so heavy work runs
off-main-thread and state mutation happens atomically on the main actor.

In `@damus/Features/Timeline/Views/PostingTimelineView.swift`:
- Around line 222-272: Add concise docstrings for the private grouped-mode
helpers: groupedContentView, flushActiveSource(),
backfillForGroupedMode(showLoading:), and refreshFromNdb(). For
groupedContentView describe that it builds the grouped timeline ScrollView, the
source selection logic (favorites vs home), and the onAppear targeted refresh
behavior; for flushActiveSource explain it calls GroupedModeQueueManager.flush
for the current timeline_source; for backfillForGroupedMode(showLoading:) state
what since is computed from groupedFilterSettings and that it invokes
home.backfillForGroupedMode with the source and showLoading flag; for
refreshFromNdb state that it computes since similarly and calls
home.refreshGroupedFromNdb with the current source. Include `@MainActor` where
present and keep each comment short (one or two sentences) and placed
immediately above the corresponding declaration.
- Around line 224-254: groupedContentView currently passes raw events and filter
into GroupedListView causing GroupedTimelineGrouper.group(...) to run on every
SwiftUI render; memoize the grouped result in this view using a `@State` (or
`@StateObject`) property and only recompute when eventsSource or
groupedFilterSettings change (e.g., compute in onChange or didSet and assign to
the `@State`), then pass the already-grouped collection into GroupedListView so
grouping work occurs only when the input events or filter actually update.
- Around line 179-195: The current onAppear sets
groupedFilterSettings.enableGroupedMode which triggers the onChange handler and
may double-run backfillForGroupedMode; add a short-lived isInitialAppear Bool
(e.g. isInitialAppear = true) and set it true immediately before you change
groupedModeStored/onAppear, then in the onChange(of:
groupedFilterSettings.enableGroupedMode) handler ignore the change if
isInitialAppear is true (and reset isInitialAppear = false) so
flushActiveSource() and backfillForGroupedMode() only run once; reference
groupedFilterSettings.enableGroupedMode, groupedModeStored, onAppear, onChange,
flushActiveSource(), backfillForGroupedMode(), and home.backfilling when
applying this guard.

In `@damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift`:
- Around line 50-65: Add a concise docstring above the computed property
postCountBadge describing its purpose and behavior: state that it returns a View
showing the user's post count as a capsule-styled badge, mention that it adapts
font size and horizontal padding based on the postCount value and uses
DamusColors for foreground/background/stroke styling; reference the related
symbol postCount and note that the badge is a Capsule with fill and stroked
overlay so future maintainers understand the visual and conditional logic.

---

Nitpick comments:
In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift`:
- Around line 58-64: The computed property allAuthors currently maps
model.events.all_events to extract pubkeys and deduplicates them on every
SwiftUI render, causing large temporary allocations; change this so the ordered
unique authors list is cached and only recomputed when events change — either
move the deduplication into the backing model (e.g., expose a cached property on
the model like uniqueAuthors) or store it in the view as a
`@State/`@StateObject/@ObservedObject-backed property that you update in an
onReceive or didSet when model.events changes; ensure you use the same ordering
(event order + filter authors) and keep references to model.filter.authors and
model.events.all_events when updating so UI reads allAuthors cheaply.
- Around line 103-128: The two near-identical functions notesFromAuthorsString
and friendsOfFriendsString should be consolidated into a single parameterized
helper: extract the shared logic (fetch up to 3 display names, truncate, and
switch on authors.count to format localized strings) into a new function (e.g.,
authorsSummaryString(authors: [Pubkey], ndb: Ndb, locale: Locale, emptyMessage:
String, nameResolver: (Pubkey, Ndb) -> Profile?)), and update
notesFromAuthorsString and friendsOfFriendsString to call that helper passing
the appropriate emptyMessage and a name-resolver closure that uses either
lookup_profile_and_copy or lookup_profile as needed; ensure the helper uses
Profile.displayName(...).username.truncate(maxLength: 20) and preserves the
localized format keys (including notes_from_three_and_others).

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift`:
- Around line 183-186: The computed property groupedSince returns nil when
filterSettings.enableGroupedMode is false but handleOnAppear always forwards
groupedSince into subscribe, which can silently change behavior when the user
toggles modes; add a concise inline comment next to the groupedSince property
explaining that it intentionally returns nil for timeline mode and that
handleOnAppear/subscribe rely on nil to indicate timeline (so toggling
grouped→timeline while the view is alive is expected), and mention the
interaction between groupedSince, handleOnAppear, and subscribe so future
readers understand the intended contract.

In `@damus/Features/Timeline/Models/HomeModel.swift`:
- Around line 853-866: The inner for-await loop over
damus_state.nostrNetwork.reader.advancedStream should use a labeled loop to
avoid the confusing post-switch check for .eose; add a label (e.g., streamLoop:)
before the for await, then inside the switch case .eose use break streamLoop to
exit the loop directly, remove the trailing if case .eose = item { break }
check, and keep the existing .event handling (await lender.justUseACopy { ... })
and the .ndbEose/.networkEose continue behavior unchanged so semantics via
should_show_event and allEvents remain the same.
- Around line 766-772: The comment describing the backfilling behavior should be
moved from the property declaration to the actual method it documents: remove
the method-level docstring currently above private var backfillTask and paste it
above the backfillForGroupedMode(...) function so the description matches the
implementation; then add concise property-level docs for private var
backfillTask: Task<Void, Never>? (e.g., "Task managing backfill work for grouped
mode"), private var ndbRefreshTask: Task<Void, Never>? (e.g., "Task for
refreshing ndb state"), and private let backfillPageLimit: UInt32 (e.g.,
"Maximum events to request per backfill page") to satisfy doc coverage and
coding guidelines.
- Around line 894-913: refreshSingleAuthorEvents currently spawns an unmanaged
Task that cannot be cancelled; add a stored Task? property (e.g.,
refreshSingleAuthorTask or authorEventsTask) alongside
homeHandlerTask/favoritesHandlerTask, cancel the existing task (.cancel())
before creating a new one, assign the newly created Task to that property, and
ensure the Task's loop honors cancellation (check Task.isCancelled or
Task.checkCancellation() inside the for-await loop that iterates
damus_state.nostrNetwork.reader.advancedStream(filters: [filter], streamMode:
.ndbAndNetworkParallel(...)) so it exits promptly when cancelled.

In `@damus/Features/Timeline/Views/PostingTimelineView.swift`:
- Around line 264-272: The subtraction to produce a UInt32 timestamp in
backfillForGroupedMode and refreshFromNdb can wrap if
groupedFilterSettings.timeRange.seconds is larger than the current time; guard
by computing a non-negative difference before casting: compute let delta =
max(0, Int(Date().timeIntervalSince1970) -
Int(groupedFilterSettings.timeRange.seconds)) (or use a saturating/subtraction
helper) and then pass UInt32(delta) to home.backfillForGroupedMode(...) and
home.refreshGroupedFromNdb(...), referencing the functions
backfillForGroupedMode, refreshFromNdb and the
groupedFilterSettings.timeRange.seconds value to locate the change.

In `@damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift`:
- Around line 68-85: The regex in eventPreviewText currently only removes
alphanumerics after "nostr:" and can leave fragments (update the replacement to
match broader identifiers); change the replacement pattern used on content (the
.replacingOccurrences(of: "nostr:[a-zA-Z0-9]+", ...)) to a pattern that captures
underscores or any non-whitespace sequence (e.g., "nostr:[a-zA-Z0-9_]+" or
"nostr:\\S+"), so the cleaned string removes full nostr: references before
trimming and length-checking.

In `@damus/Shared/Components/GroupedTimeline/GroupedFilterSettingsView.swift`:
- Around line 12-39: Annotate the ObservableObject class GroupedFilterSettings
with `@MainActor` to ensure all published-property mutations are main-thread
enforced, and add a short docstring above the reset() method describing its
purpose (restores defaults) per coding guidelines; update the class declaration
(GroupedFilterSettings) and the reset() method doc comment accordingly so the
compiler and reviewers can verify main-thread safety and documentation.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift`:
- Around line 92-139: Add missing doc comments for the four internal static
methods: parseFilteredWords(_:),
shouldIncludeEvent(_:filter:values:wordsList:cutoff:),
containsFilteredWords(_:wordsList:), and isTooShort(_:hideShortNotes:). For each
method add a /// comment that briefly describes the method's purpose, lists and
documents parameters (e.g., raw, event, filter, values, wordsList, cutoff,
hideShortNotes) and states the return value (what the Bool or [String]
represents); keep comments concise and consistent with the existing style used
for group() and surrounding types.

Comment thread damus/Features/NIP05/Models/NIP05DomainEventsModel.swift
Comment on lines +53 to +88
static func group(
events: [NostrEvent],
filter: (NostrEvent) -> Bool,
values: GroupedFilterValues,
now: Date = Date()
) -> [AuthorGroup] {
let wordsList = parseFilteredWords(values.filteredWords)
let epoch = UInt32(now.timeIntervalSince1970)
let cutoff = epoch > values.timeRangeSeconds ? epoch - values.timeRangeSeconds : 0

var groupsByAuthor: [Pubkey: (latest: NostrEvent, count: Int)] = [:]

for event in events {
guard shouldIncludeEvent(event, filter: filter, values: values, wordsList: wordsList, cutoff: cutoff) else { continue }

if let existing = groupsByAuthor[event.pubkey] {
let newLatest = event.created_at > existing.latest.created_at ? event : existing.latest
groupsByAuthor[event.pubkey] = (latest: newLatest, count: existing.count + 1)
} else {
groupsByAuthor[event.pubkey] = (latest: event, count: 1)
}
}

if let maxNotes = values.maxNotesPerUser {
groupsByAuthor = groupsByAuthor.filter { $0.value.count <= maxNotes }
}

return groupsByAuthor
.map { AuthorGroup(pubkey: $0.key, latestEvent: $0.value.latest, postCount: $0.value.count) }
.sorted {
if $0.latestEvent.created_at != $1.latestEvent.created_at {
return $0.latestEvent.created_at > $1.latestEvent.created_at
}
return $0.pubkey.id.lexicographicallyPrecedes($1.pubkey.id)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all call sites of GroupedTimelineGrouper.group and their surrounding concurrency context
rg -n "GroupedTimelineGrouper\.group\b" --type=swift -B 10 -A 5

Repository: damus-io/damus

Length of output: 19771


🏁 Script executed:

#!/bin/bash
# Find shouldIncludeEvent implementation
rg -n "func shouldIncludeEvent" --type=swift -A 20

# Also get more context around GroupedListView line 30
cat -n damus/Shared/Components/GroupedTimeline/GroupedListView.swift | sed -n '15,45p'

Repository: damus-io/damus

Length of output: 3807


Move expensive grouping computation off the main thread.

The group() method is called inside GroupedListView.body (line 30), which is main-thread-bound in SwiftUI. With large event sets, the O(n × w × k) string operations—lowercasing event content, splitting filter words, and searching for word matches in containsFilteredWords()—will block the UI. Per coding guidelines, expensive computations must run on background threads using Task {}, DispatchQueue.global(), or Swift concurrency (async/await).

Refactor GroupedListView.body to compute groups asynchronously and cache the result, dispatching the computed groups back to the main thread for rendering.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift` around
lines 53 - 88, The grouping work in GroupedTimelineGrouper.group(...) (which
calls parseFilteredWords(...) and containsFilteredWords(...)) is CPU-heavy and
must be moved off the main thread; update GroupedListView.body to kick off an
async background task (Task { } or DispatchQueue.global().async) that calls
GroupedTimelineGrouper.group(events:filter:values:now:), store the result in a
`@State` or `@StateObject` cache (e.g. groupedResults) and then dispatch the final
AuthorGroup array back to the main thread to assign the state for rendering;
ensure you still use values.maxNotesPerUser and other GroupedFilterValues inside
the background task, debounce or re-run the task when events/filter/values
change, and avoid doing lowercasing/splitting/searching on the main thread by
keeping those operations inside group().

Comment on lines +76 to +78
if let maxNotes = values.maxNotesPerUser {
groupsByAuthor = groupsByAuthor.filter { $0.value.count <= maxNotes }
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

maxNotesPerUser filter silently excludes prolific authors rather than capping them.

filter { $0.value.count <= maxNotes } removes the entire author group when their post count exceeds the threshold. A user who sets maxNotesPerUser = 3 expecting to see at most 3 posts per author will instead see zero posts from anyone who posted 4 or more times. The parameter name implies a display cap, not a spam-exclusion gate.

If the intent is spam filtering, rename to spamThreshold (or similar) and add a doc comment clarifying the exclusion semantics. If the intent is a display cap, change the logic to retain the author and truncate the count:

🛠 Proposed display-cap approach
-        if let maxNotes = values.maxNotesPerUser {
-            groupsByAuthor = groupsByAuthor.filter { $0.value.count <= maxNotes }
-        }
+        // Cap displayed post count per author rather than excluding prolific authors.
+        if let maxNotes = values.maxNotesPerUser {
+            groupsByAuthor = groupsByAuthor.mapValues { entry in
+                (latest: entry.latest, count: min(entry.count, maxNotes))
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift` around
lines 76 - 78, The current use of maxNotesPerUser with groupsByAuthor =
groupsByAuthor.filter { $0.value.count <= maxNotes } removes entire author
groups when they exceed the threshold; change this so the setting caps displayed
notes per author instead of excluding them by: when values.maxNotesPerUser is
set, transform groupsByAuthor by truncating each author’s value array to at most
maxNotes (e.g., map each entry to keep only the first N notes) rather than
filtering out authors entirely; alternatively, if the real intention is to
exclude prolific posters, rename maxNotesPerUser to spamThreshold and add a doc
comment on the new name clarifying exclusion semantics so callers aren’t misled.

alltheseas and others added 10 commits February 19, 2026 23:28
Add ndb_txn_is_valid() helper and validation checks across multiple
nostrdb functions to prevent crashes from invalid/null transactions.

This adds defensive guards in:
- ndb_get_tsid
- ndb_lookup_by_key
- ndb_get_profile_by_pubkey
- ndb_get_note_by_id
- ndb_get_indexkey_by_id
- ndb_read_last_profile_fetch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Consolidates duplicate domain-matching logic into NIP05DomainHelpers struct
to improve code maintainability and reduce duplication across timeline views.

Key additions:
- matches_domain: Check if pubkey has matching NIP-05 (validated or raw)
- validated_authors_for_domain: Get all validated authors for a domain
- scan_all_cached_profiles: Scan nostrdb cache for domain discovery
- authors_for_domain: Get authors based on WOT mode (friends vs all)
- ordered_domain_authors: Order authors with events first for responsive UI

The scan_all_cached_profiles function enables discovery of users by NIP-05
domain without requiring them to be in your social graph. It queries locally
cached metadata events (kind:0) which is fast and doesn't require network
requests.

Two modes of operation:
- WOT ON: Filters to friends-of-friends with matching domain
- WOT OFF: Scans all cached profiles for discovery

Signed-off-by: alltheseas
Closes: damus-io#3375

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add a `size` parameter to control profile picture diameter, defaulting
to 32pt for backwards compatibility. The horizontal offset between
stacked pictures now scales proportionally (62.5% of diameter).

This enables compact layouts like the NIP-05 domain header to use
smaller avatars (24pt) while maintaining proper overlap and spacing.

Closes: damus-io#3379
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add filter settings infrastructure for NIP-05 domain feeds:

NIP05FilterSettings (ObservableObject):
- enableGroupedMode: Toggle between grouped and timeline views
- timeRange: 24h or 7d filter window
- compactView/showLastNotePreview: Display options
- includeReplies: Toggle reply visibility
- hideShortNotes: Filter short/emoji-only notes
- filteredWords: Comma-separated keyword exclusions
- maxNotesPerUser: Limit prolific posters

NIP05FilterButton:
- Compact pill-style button showing current state (e.g., "Grouped · 24h")
- Opens settings sheet on tap
- Shows loading indicator during fetches

NIP05FilterSettingsView:
- Full settings sheet with all filter options
- Reset and Apply buttons
- Matches fevela-style grouping behavior

Closes: damus-io#3379
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add views for displaying domain posts grouped by author:

NIP05GroupedAuthorRow:
- Shows author profile pic, name, and post count badge
- Displays preview of most recent post
- Relative timestamp for latest activity
- Tapping navigates to author's profile

NIP05GroupedListView:
- Groups all events by author pubkey
- Applies filter settings (time range, keywords, short notes)
- Shows authors sorted by most recent activity
- Empty state with helpful filter adjustment hint

Filtering logic (fevela-style):
- Time range: 24h or 7d window
- Keyword exclusion: Case-insensitive, min 2 chars
- Short note detection: <10 chars, emoji-only, single word
- Max notes per user: Exclude prolific posters

Closes: damus-io#3379
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Update timeline view to support grouped mode and filter settings:

Layout changes:
- Reduce header height from 200pt to 160pt
- Move filter button to title row (next to domain name)
- Give friends view full width for text display

Mode support:
- Grouped mode: Shows NIP05GroupedListView with author rows
- Timeline mode: Shows chronological TimelineView
- Filter settings sheet accessible from header button

Code organization:
- Extract subviews into private computed properties
- Extract event handlers into named functions
- Add MARK sections for logical grouping

Also adds new Swift files to Xcode project targets.

Closes: damus-io#3379
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Move and rename NIP05-prefixed grouped view components to
damus/Shared/Components/GroupedTimeline/ for reuse across feeds:

- NIP05GroupedListView → GroupedListView
- NIP05GroupedAuthorRow → GroupedAuthorRow
- NIP05FilterButton → GroupedFilterButton
- NIP05FilterSettings → GroupedFilterSettings
- NIP05FilterSettingsView → GroupedFilterSettingsView
- NIP05TimeRange → GroupedTimeRange
- Extract GroupedTimelineGrouper into standalone file
- Extract GroupedModeQueueManager into standalone file

Update NIP05DomainTimelineView to use new shared type names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add GroupedFilterButton pill in header next to SignalView
- Hide Notes/Notes+Replies picker when grouped mode is active
- Switch body between TabView (timeline) and GroupedListView (grouped)
- Persist grouped mode via @SceneStorage bidirectional sync
- Reset headerOffset to 0 when toggling grouped mode
- Flush EventHolder queue on mode toggle and source switch
- Use .posts_and_replies base filter; reply filtering handled by settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GroupedTimelineGrouperTests (15 tests):
- Empty input returns empty groups
- Events correctly grouped by author with accurate counts
- Groups sorted by most recent activity
- Replies excluded when includeReplies is false
- Replies included when includeReplies is true
- Events outside time range excluded
- Events with filtered keywords excluded
- Short filter words (< 2 chars) ignored
- Max notes per user excludes prolific authors
- Short notes excluded/included based on setting
- Single-word notes excluded when hideShortNotes enabled
- Content filter passthrough blocks events
- parseFilteredWords trims, lowercases, and filters short words

GroupedModeQueueManagerTests (3 tests):
- Flush moves queued events to main list
- Flush disables queueing
- Flush is idempotent (no crash/duplicates on second call)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alltheseas alltheseas force-pushed the nip05-grouped-ui branch 2 times, most recently from 1fab4f0 to 2cefb11 Compare February 20, 2026 05:31
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (9)
damusTests/GroupedModeQueueManagerTests.swift (2)

61-77: Expand idempotency coverage — flush on a never-queued EventHolder is untested.

testFlushIsIdempotent only tests the "flush-flush" case where the holder already went through one successful flush. The default state of EventHolder has should_queue = false and incoming empty; calling GroupedModeQueueManager.flush(source:) directly on that state (no prior set_should_queue(true)) is not covered and could surface a different code path in EventHolder.flush().

Consider adding a test case like:

func testFlushOnDefaultHolderIsNoOp() {
    let holder = EventHolder()
    // should_queue defaults to false; no events added
    GroupedModeQueueManager.flush(source: holder)
    XCTAssertEqual(holder.events.count, 0)
    XCTAssertFalse(holder.should_queue)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damusTests/GroupedModeQueueManagerTests.swift` around lines 61 - 77, Add a
new unit test that calls GroupedModeQueueManager.flush(source:) on a fresh
EventHolder (without calling set_should_queue or inserting events) to ensure the
flush is a no-op; create a test method (e.g., testFlushOnDefaultHolderIsNoOp)
that constructs EventHolder, invokes GroupedModeQueueManager.flush(source:
holder), then asserts holder.events.count == 0 and holder.should_queue == false
to cover the default-holder code path in EventHolder.flush().

16-26: Add docstring coverage to makeEvent per coding guidelines.

The helper makeEvent has no documentation comment. As per coding guidelines, all added or modified code must have docstring coverage.

✏️ Proposed docstring
+    /// Creates a signed `NostrEvent` for use in unit tests.
+    /// - Parameters:
+    ///   - content: The note body; defaults to a string long enough to pass short-note filters.
+    ///   - keypair: Signing keypair; defaults to the shared `test_keypair` fixture.
+    ///   - secondsAgo: How far in the past to backdate `createdAt`; defaults to 100 s.
+    /// - Returns: A valid `NostrEvent`, force-unwrapped (crashes on signing failure).
     private func makeEvent(

As per coding guidelines: "Ensure docstring coverage for any code added or modified."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damusTests/GroupedModeQueueManagerTests.swift` around lines 16 - 26, The
helper function makeEvent lacks a documentation comment; add a concise docstring
above makeEvent describing its purpose, parameters (content: String default,
keypair: Keypair default test_keypair, secondsAgo: UInt32 default 100) and the
return value (NostrEvent), and note that the createdAt uses current time minus
secondsAgo; update the docstring format to match project style (brief summary
line, parameter descriptions, and return description) for the makeEvent helper.
damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift (1)

68-68: eventPreviewText(_:) should be private.

The method is only used within body; leaving it without an access modifier exposes it as internal unnecessarily.

-    func eventPreviewText(_ event: NostrEvent) -> String {
+    private func eventPreviewText(_ event: NostrEvent) -> String {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift` at line 68,
The method eventPreviewText(_:) is declared without an access modifier and
should be made private since it's only used within the view's body; update the
declaration of eventPreviewText(_ event: NostrEvent) to be private (e.g.,
private func eventPreviewText(_ event: NostrEvent) -> String) so the helper is
not exposed beyond GroupedAuthorRow and locate it near the body implementation
to keep related code together.
damus/Features/Timeline/Models/HomeModel.swift (1)

894-913: refreshSingleAuthorEvents spawns an unmanaged, uncancellable Task.

The bare Task { } created here is not stored in any handle. Rapid back-and-forth navigation (profile visit → timeline → profile visit) will stack multiple concurrent subscriptions for the same author, each holding an open relay connection until EOSE arrives.

♻️ Proposed fix — store the handle for single-author refresh
+    private var singleAuthorRefreshTask: Task<Void, Never>?
+
     func refreshSingleAuthorEvents(pubkey: Pubkey, since: UInt32, source: TimelineSource) {
         ...
-        Task {
+        singleAuthorRefreshTask?.cancel()
+        singleAuthorRefreshTask = Task {
             for await item in ... {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Timeline/Models/HomeModel.swift` around lines 894 - 913, The
code spawns an unmanaged Task for the single-author subscription; create a
cancellable Task handle property on HomeModel (e.g., refreshSingleAuthorTask:
Task<Void, Never>?), cancel and nil out any existing handle before assigning the
new Task, and inside the Task ensure you clear the handle when you return on
.eose or on cancellation; reference the existing subscription creation using
damus_state.nostrNetwork.reader.advancedStream(filters: [filter], streamMode:
.ndbAndNetworkParallel(...)) and the surrounding logic in
refreshSingleAuthorEvents (or the method containing that Task), use weak self to
avoid retain cycles and check Task.isCancelled if needed.
damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift (2)

58-64: allAuthors recomputed on every SwiftUI body evaluation.

This computed property iterates all events, allocates a Set, and deduplicates on every view render triggered by any @Published change on model. For domains with many events this could introduce frame drops.

Consider caching the result in the model or using a @State/memoization pattern updated only when model.events actually changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift` around lines
58 - 64, The computed property allAuthors iterates model.events, allocates a
Set, and deduplicates on every SwiftUI body evaluation which is expensive; move
this work out of the view by caching it and only recomputing when events change:
either add a cached property/method on the underlying model (e.g.,
NIP05DomainTimelineModel.cachedAuthors or recomputeAuthors() that updates when
model.events mutates) or make the view hold `@State` private var allAuthors and
update that state in an onChange(or onReceive) handler watching model.events or
model.events.all_events; preserve the current deduplication logic (combining
eventAuthors + filter.authors and filtering by seen Set) when populating the
cache/state.

103-128: notesFromAuthorsString and friendsOfFriendsString are near-duplicates.

The two functions share identical switch logic for cases 1–default, differing only in the ndb lookup API, the zero-case string, and localization comments. This violates DRY and makes future localization changes error-prone.

Additionally, notesFromAuthorsString uses ndb.lookup_profile_and_copy (allocates a copy) while the older friendsOfFriendsString uses the zero-copy ndb.lookup_profile(borrow:) API. Per coding guidelines, prefer nostrdb facilities — the borrow pattern is more efficient and should be used consistently.

Consider extracting the name-resolution and switch formatting into a shared helper, passing only the zero-case string as a parameter.

Also applies to: 151-179

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift` around lines
103 - 128, Extract the shared formatting and name-resolution logic from
notesFromAuthorsString and friendsOfFriendsString into a single helper (e.g.,
formatAuthorsString(authors: [Pubkey], ndb: Ndb, zeroCaseKey: String,
zeroCaseComment: String, locale: Locale)) that: 1) resolves up to three display
names using the zero-copy API ndb.lookup_profile(borrow:) and
Profile.displayName, truncates to 20 chars, 2) chooses the correct zero-case
localized string passed in (instead of hardcoding), and 3) runs the same
switch/format logic including localizedStringFormat for the default case; then
update notesFromAuthorsString and friendsOfFriendsString to call this helper
(passing their respective zero-case strings/comments) and remove the duplicated
switch and the use of ndb.lookup_profile_and_copy.
damus/Features/NIP05/Models/NIP05DomainEventsModel.swift (3)

44-62: loading = true is set after loadingTask is created — ordering is safe but non-obvious.

Because the class is @MainActor, the Task body won't start until the current synchronous scope yields, so loading is set to true before streamItems() begins. However, placing loading = true before the Task creation would make the intent clearer and prevent confusion during future refactors.

♻️ Suggested reorder
+        loading = true
+        last_loaded_count = 0
         loadingTask?.cancel()
         loadingTask = Task {
             await streamItems()
         }
-        loading = true
-        last_loaded_count = 0
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift` around lines 44 -
62, Move the assignment of loading to true before creating the asynchronous
loadingTask in subscribe so the intent is explicit; specifically, in
NIP05DomainEventsModel.subscribe set loading = true (and any related state like
last_loaded_count if desired) prior to creating/loadingTask = Task { await
streamItems() }, so the visible state already reflects that loading will start
and avoids reliance on `@MainActor` scheduling semantics.

79-81: Remove unused private method matches_domain.

The method at lines 79–81 has no call sites in this file. The add_event method explicitly skips domain checking (line 192 comment), and streamItems uses NIP05DomainHelpers.matches_domain directly. Remove to avoid confusion.

Suggested removal
-    private func matches_domain(_ pubkey: Pubkey) -> Bool {
-        NIP05DomainHelpers.matches_domain(pubkey, domain: domain, profiles: state.profiles)
-    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift` around lines 79 -
81, The private helper function matches_domain(_ pubkey: Pubkey) is unused and
causes confusion; remove the entire method declaration from
NIP05DomainEventsModel (the unused matches_domain function) and ensure any
callers use NIP05DomainHelpers.matches_domain directly (note add_event
intentionally skips domain checking and streamItems already calls
NIP05DomainHelpers.matches_domain), then run tests/compile to confirm no
references remain.

116-118: Remove redundant NIP-05 validation at line 190.

check_nip05_validity already validates all authors upfront at lines 116–118 before streaming events. The function has built-in deduplication (it returns early if a pubkey is already validated via profiles.is_validated(pubkey) == nil). The per-event call at line 190 in add_event is redundant since all events in the stream are guaranteed to come from the pre-validated authors, causing the function to exit early each time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift` around lines 116 -
118, The loop in the initializer already calls
check_nip05_validity(pubkey:pubkey, damus_state:state) for each author (with
dedup via profiles.is_validated(pubkey)), so remove the redundant per-event
validation inside add_event (the call to check_nip05_validity at the noted
location) — either delete that call or replace it with a short comment noting
validation is pre-done; keep all other logic in add_event unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift`:
- Around line 135-136: The single-word check in
GroupedTimelineGrouper.isTooShort currently uses "if words.count == 1 &&
content.count < 20 { return true }", which lets very long single words pass;
change it so any single-word note is considered too short when hideShortNotes is
enabled. Locate the isTooShort (or equivalent) method in GroupedTimelineGrouper
and replace the conditional with an unconditional single-word check (e.g., if
words.count == 1 { return true }) so single-word content is rejected regardless
of character count.

In `@damusTests/GroupedModeQueueManagerTests.swift`:
- Around line 11-12: Add missing Swift docstrings for the test helper and tests:
add a /// doc comment above makeEvent(content:keypair:secondsAgo:) describing
its purpose and documenting parameters and return value, and add /// doc
comments to each test method inside GroupedModeQueueManagerTests (the three test
methods present) briefly stating the scenario being tested and expected outcome;
keep wording concise and follow the project's docstring style (short summary
line, `@param` for parameters if used, and `@return` if applicable).

In `@damusTests/GroupedTimelineGrouperTests.swift`:
- Around line 232-243: The test testSingleWordExcludedWhenHideShortEnabled fails
because GroupedTimelineGrouper.isTooShort currently checks both words.count == 1
and content.count < 20, allowing long single-word notes (like
"Supercalifragilisticexpialidocious") to slip through; change isTooShort to
treat any single-word note as too short when hideShortNotes is enabled by
removing the content.count < 20 guard (i.e., return true when words.count == 1),
so GroupedTimelineGrouper.group will exclude single-word events as the test
expects.

---

Duplicate comments:
In `@damus.xcodeproj/project.pbxproj`:
- Around line 134-135: The PBX project contains hand-crafted, patterned UUIDs
for GroupedTimeline source entries (e.g. the GroupedTimeline file UUIDs like
A1A2A3A4…/A1B2C3D4…/B1B2B3B4…/C1C2C3C4…/F2E1D0C9…) and newly added test
build-file refs (e.g. fileRef identifiers referencing
GroupedTimelineGrouperTests.swift, GroupedModeQueueManagerTests.swift, etc. such
as D1D2D3D4…, D2D3D4D5…, E1E2E3E4…, E2E3E4E5…), which must be replaced with
Xcode-generated UUIDs; to fix, remove the hand-edited PBX entries for those
GroupedTimeline source files and their test build-file entries and re-add the
files using Xcode’s “Add Files to…” dialog so Xcode will generate proper random
24-hex identifiers for the items (ensure you update any references that pointed
to the old patterned UUID symbols like GroupedTimelineGrouperTests.swift and
GroupedModeQueueManagerTests.swift).
- Around line 6367-6372: The six GroupedTimeline Swift files
(GroupedFilterButton.swift, GroupedFilterSettingsView.swift,
GroupedAuthorRow.swift, GroupedListView.swift, GroupedTimelineGrouper.swift,
GroupedModeQueueManager.swift) are still listed in the Sources build phases for
extension targets; remove their PBXBuildFile entries from the
PBXSourcesBuildPhase blocks of the ShareExtension and HighlighterActionExtension
PBXNativeTarget definitions so the extensions no longer compile them, but keep
their PBXFileReference entries (and the PBXBuildFile entries) only in the main
damus app target’s Sources build phase; locate the PBXSourcesBuildPhase blocks
that contain the listed UUID lines and delete those specific file UUID lines
(and corresponding PBXBuildFile entries that reference those UUIDs under the
extension targets) to avoid linker/bloat issues, then re-run the verification
script to confirm only the app target compiles them.

In `@damus/Features/NIP05/Models/NIP05.swift`:
- Around line 185-189: The .friends_of_friends scan is running synchronously on
the MainActor and calls matches_domain (which triggers profiles.lookup →
ndb.lookup_profile), so offload the whole scan to a background task like the
.all case: call Task.detached to iterate contacts.get_friend_of_friends_list(),
perform matches_domain checks and build a local Set of pubkeys off-main-thread,
then switch back to the MainActor only once to merge that local set into the
actor-isolated authors (e.g., via await MainActor.run {
authors.formUnion(localSet) }); ensure matches_domain/profile lookups run
off-main-thread and avoid calling authors.insert from the background task.

In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift`:
- Around line 70-77: Add missing docstrings for the modified/added methods:
set_friend_filter(_:), streamItems, load_more, fetch_older, and add_event, and
complete the partial docstring on subscribe to cover all parameters and
behavior; each docstring should briefly describe the method purpose, parameters
(including types like FriendFilter and since), return value or async behavior,
and notable side-effects (e.g., unsubscribe/subscribe, whether events are reset,
pagination behavior), matching the project's docstring style and placed
immediately above the corresponding function declarations.
- Around line 83-91: Replace the magic number 5000 in streamItems() with a named
constant to clarify intent and make it configurable: define a constant (e.g.
GROUPED_MODE_LIMIT or maxGroupedLimit) near the top of the file or in a relevant
constants/Config scope and use that constant instead of hardcoding 5000 when
setting filter.limit; keep the existing behavior where when let since is present
you set filter.since = since and filter.limit = GROUPED_MODE_LIMIT, and leave
the else branch using used_initial_page / initial_limit unchanged.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineView.swift`:
- Around line 91-94: The view NIP05DomainTimelineView currently uses magic
numbers .padding(.top, 50) and .padding(.leading, 45); replace these with
computed values so layouts adapt to Dynamic Island/notch devices: wrap the
content in a GeometryReader (or use proxy in the parent) to read safe area
insets and compute topPadding = geometry.safeAreaInsets.top +
desiredExtraSpacing, then replace .padding(.top, 50) with .padding(.top,
topPadding); for the leading alignment replace the hard-coded 45 by either using
a named constant/backButtonWidth (match the actual back button frame) or an
alignmentGuide on the back button and title so the content aligns automatically
instead of .padding(.leading, 45). Ensure you update the references to
.padding(.top, 50) and .padding(.leading, 45) in NIP05DomainTimelineView to use
the computed values.
- Around line 172-175: handleOnAppear currently returns early when
model.events.all_events is non-empty, which prevents re-establishing the live
subscription after handleOnDisappear cancelled the streaming task; change the
logic so handleOnAppear calls model.subscribe(since: groupedSince) when the
streaming subscription is not active (e.g., check the model/owner's subscription
state or streamingTask is nil/cancelled) instead of basing it solely on
model.events.all_events, ensuring you reference and use the existing
model.subscribe(since:) and the cancellation in handleOnDisappear/streamingTask
to decide when to resubscribe.

In `@damus/Features/Profile/Views/CondensedProfilePicturesView.swift`:
- Around line 19-22: Add a /// docstring to the CondensedProfilePicturesView
initializer describing its purpose and each parameter: state (DamusState),
pubkeys ([Pubkey]), maxPictures (Int), and size (CGFloat, default 32.0); ensure
the comment explains what the initializer configures (e.g., creates a
CondensedProfilePicturesViewModel with given pubkeys and limits) and documents
the default value for size so it satisfies the project's docstring coverage
guideline.

In `@damus/Features/Timeline/Models/HomeModel.swift`:
- Around line 869-878: The loop does per-event awaits (await
self.favoriteEvents.insert(ev), await self.insert_home_event(ev)) and unsafely
mutates self.damus_state.events from outside an actor, causing many suspension
points and race conditions; fix by batching: collect allEvents into an array,
add a batch insertion API (e.g., insert_home_events(_: [Event]) and a bulk
insert on favoriteEvents like insertMany(_:)), call those once (await once)
instead of per-event awaits, and perform the UI/state mutation to
damus_state.events inside actor isolation (e.g., await MainActor.run {
self.damus_state.events.formUnion(allEvents) } or implement a MainActor-isolated
method) and only set backfilling = false after the batched awaits complete.

In `@damus/Features/Timeline/Views/PostingTimelineView.swift`:
- Around line 224-272: Add concise docstrings for the private helpers
groupedContentView, flushActiveSource(), backfillForGroupedMode(showLoading:),
and refreshFromNdb() describing their purpose and behavior: for
groupedContentView note it builds the ScrollView of grouped events, source
selection (timeline_source vs favorites), redaction/shimmer behavior and the
onAppear refresh logic for single-author vs full refresh; for
flushActiveSource() state that it forces GroupedModeQueueManager.flush for the
current source and note the `@MainActor` context; for
backfillForGroupedMode(showLoading:) explain it computes the since timestamp
from groupedFilterSettings and delegates to home.backfillForGroupedMode with the
source and showLoading flag; for refreshFromNdb() explain it computes the same
since and calls home.refreshGroupedFromNdb with the source — keep each docstring
short (1–2 lines) and placed immediately above each symbol.

In `@damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift`:
- Around line 50-65: The computed view property postCountBadge lacks a
docstring; add a concise Swift documentation comment immediately above the var
postCountBadge describing its purpose (renders a capsule badge showing
postCount), any important behavior (uses different Font sizes when postCount >=
100, applies padding, colors from DamusColors, and the fixed
minWidth/minHeight), and note expected input/state (relies on postCount). Keep
it brief (one-two sentences) and use Swift doc comment format (///) so IDEs and
generated docs pick it up.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift`:
- Around line 76-78: The current logic using maxNotesPerUser on groupsByAuthor
filters out entire authors when they exceed the limit; instead, cap each
author's visible posts to maxNotesPerUser so prolific authors remain but with
their post list truncated. Locate the maxNotesPerUser check and replace the
filter on groupsByAuthor with logic that maps/truncates each group's array to at
most maxNotesPerUser items (use groupsByAuthor's values/arrays, e.g.
prefix/truncate), preserving the author keys while limiting their note arrays.

---

Nitpick comments:
In `@damus/Features/NIP05/Models/NIP05DomainEventsModel.swift`:
- Around line 44-62: Move the assignment of loading to true before creating the
asynchronous loadingTask in subscribe so the intent is explicit; specifically,
in NIP05DomainEventsModel.subscribe set loading = true (and any related state
like last_loaded_count if desired) prior to creating/loadingTask = Task { await
streamItems() }, so the visible state already reflects that loading will start
and avoids reliance on `@MainActor` scheduling semantics.
- Around line 79-81: The private helper function matches_domain(_ pubkey:
Pubkey) is unused and causes confusion; remove the entire method declaration
from NIP05DomainEventsModel (the unused matches_domain function) and ensure any
callers use NIP05DomainHelpers.matches_domain directly (note add_event
intentionally skips domain checking and streamItems already calls
NIP05DomainHelpers.matches_domain), then run tests/compile to confirm no
references remain.
- Around line 116-118: The loop in the initializer already calls
check_nip05_validity(pubkey:pubkey, damus_state:state) for each author (with
dedup via profiles.is_validated(pubkey)), so remove the redundant per-event
validation inside add_event (the call to check_nip05_validity at the noted
location) — either delete that call or replace it with a short comment noting
validation is pre-done; keep all other logic in add_event unchanged.

In `@damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift`:
- Around line 58-64: The computed property allAuthors iterates model.events,
allocates a Set, and deduplicates on every SwiftUI body evaluation which is
expensive; move this work out of the view by caching it and only recomputing
when events change: either add a cached property/method on the underlying model
(e.g., NIP05DomainTimelineModel.cachedAuthors or recomputeAuthors() that updates
when model.events mutates) or make the view hold `@State` private var allAuthors
and update that state in an onChange(or onReceive) handler watching model.events
or model.events.all_events; preserve the current deduplication logic (combining
eventAuthors + filter.authors and filtering by seen Set) when populating the
cache/state.
- Around line 103-128: Extract the shared formatting and name-resolution logic
from notesFromAuthorsString and friendsOfFriendsString into a single helper
(e.g., formatAuthorsString(authors: [Pubkey], ndb: Ndb, zeroCaseKey: String,
zeroCaseComment: String, locale: Locale)) that: 1) resolves up to three display
names using the zero-copy API ndb.lookup_profile(borrow:) and
Profile.displayName, truncates to 20 chars, 2) chooses the correct zero-case
localized string passed in (instead of hardcoding), and 3) runs the same
switch/format logic including localizedStringFormat for the default case; then
update notesFromAuthorsString and friendsOfFriendsString to call this helper
(passing their respective zero-case strings/comments) and remove the duplicated
switch and the use of ndb.lookup_profile_and_copy.

In `@damus/Features/Timeline/Models/HomeModel.swift`:
- Around line 894-913: The code spawns an unmanaged Task for the single-author
subscription; create a cancellable Task handle property on HomeModel (e.g.,
refreshSingleAuthorTask: Task<Void, Never>?), cancel and nil out any existing
handle before assigning the new Task, and inside the Task ensure you clear the
handle when you return on .eose or on cancellation; reference the existing
subscription creation using
damus_state.nostrNetwork.reader.advancedStream(filters: [filter], streamMode:
.ndbAndNetworkParallel(...)) and the surrounding logic in
refreshSingleAuthorEvents (or the method containing that Task), use weak self to
avoid retain cycles and check Task.isCancelled if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedAuthorRow.swift`:
- Line 68: The method eventPreviewText(_:) is declared without an access
modifier and should be made private since it's only used within the view's body;
update the declaration of eventPreviewText(_ event: NostrEvent) to be private
(e.g., private func eventPreviewText(_ event: NostrEvent) -> String) so the
helper is not exposed beyond GroupedAuthorRow and locate it near the body
implementation to keep related code together.

In `@damusTests/GroupedModeQueueManagerTests.swift`:
- Around line 61-77: Add a new unit test that calls
GroupedModeQueueManager.flush(source:) on a fresh EventHolder (without calling
set_should_queue or inserting events) to ensure the flush is a no-op; create a
test method (e.g., testFlushOnDefaultHolderIsNoOp) that constructs EventHolder,
invokes GroupedModeQueueManager.flush(source: holder), then asserts
holder.events.count == 0 and holder.should_queue == false to cover the
default-holder code path in EventHolder.flush().
- Around line 16-26: The helper function makeEvent lacks a documentation
comment; add a concise docstring above makeEvent describing its purpose,
parameters (content: String default, keypair: Keypair default test_keypair,
secondsAgo: UInt32 default 100) and the return value (NostrEvent), and note that
the createdAt uses current time minus secondsAgo; update the docstring format to
match project style (brief summary line, parameter descriptions, and return
description) for the makeEvent helper.

Comment on lines +135 to +136
let words = content.split(whereSeparator: { $0.isWhitespace }).filter { !$0.isEmpty }
if words.count == 1 && content.count < 20 { return true }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

content.count < 20 threshold is inconsistent with the test expectation for single-word notes.

isTooShort returns false for any single-word note whose character count is ≥ 20. "Supercalifragilisticexpialidocious" (34 chars) will therefore pass the filter even with hideShortNotes: true. See testSingleWordExcludedWhenHideShortEnabled in GroupedTimelineGrouperTests.swift — that test will fail against the current threshold.

The original behavior (reject every single-word note when hideShortNotes is set) aligns with both the test's assertion and typical user expectations.

🛡️ Proposed fix
-        if words.count == 1 && content.count < 20 { return true }
+        if words.count == 1 { return true }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Shared/Components/GroupedTimeline/GroupedTimelineGrouper.swift` around
lines 135 - 136, The single-word check in GroupedTimelineGrouper.isTooShort
currently uses "if words.count == 1 && content.count < 20 { return true }",
which lets very long single words pass; change it so any single-word note is
considered too short when hideShortNotes is enabled. Locate the isTooShort (or
equivalent) method in GroupedTimelineGrouper and replace the conditional with an
unconditional single-word check (e.g., if words.count == 1 { return true }) so
single-word content is rejected regardless of character count.

Comment on lines +11 to +12
@MainActor
final class GroupedModeQueueManagerTests: XCTestCase {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find EventHolder and GroupedModeQueueManager definitions
echo "=== Searching for EventHolder definition ==="
rg -n "class EventHolder|struct EventHolder" --type swift -A3

echo ""
echo "=== Searching for GroupedModeQueueManager definition ==="
rg -n "class GroupedModeQueueManager|struct GroupedModeQueueManager" --type swift -A3

echo ""
echo "=== Searching for `@MainActor` on EventHolder ==="
rg -B2 "class EventHolder|struct EventHolder" --type swift | head -20

echo ""
echo "=== Searching for `@MainActor` on GroupedModeQueueManager ==="
rg -B2 "class GroupedModeQueueManager|struct GroupedModeQueueManager" --type swift | head -20

Repository: damus-io/damus

Length of output: 2184


🏁 Script executed:

#!/bin/bash
# Get the full test file to review structure
echo "=== Full content of GroupedModeQueueManagerTests.swift ==="
cat -n damusTests/GroupedModeQueueManagerTests.swift

Repository: damus-io/damus

Length of output: 2986


🏁 Script executed:

#!/bin/bash
# Search for ObservableObject or other concurrency markers on these types
echo "=== Checking for ObservableObject or other markers ==="
rg -A10 "class EventHolder|struct EventHolder" --type swift
echo ""
rg -A10 "class GroupedModeQueueManager|struct GroupedModeQueueManager" --type swift

Repository: damus-io/damus

Length of output: 2600


Add docstrings to makeEvent() and all test methods.

The test helper makeEvent(content:keypair:secondsAgo:) and the three test methods lack docstrings as required by the coding guidelines for all code added or modified.

The @MainActor annotation on the test class is correct and necessary: GroupedModeQueueManager.flush(source:) is explicitly marked @MainActor, so the test class must run on the main thread to satisfy this constraint. No thread-safety concern exists here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damusTests/GroupedModeQueueManagerTests.swift` around lines 11 - 12, Add
missing Swift docstrings for the test helper and tests: add a /// doc comment
above makeEvent(content:keypair:secondsAgo:) describing its purpose and
documenting parameters and return value, and add /// doc comments to each test
method inside GroupedModeQueueManagerTests (the three test methods present)
briefly stating the scenario being tested and expected outcome; keep wording
concise and follow the project's docstring style (short summary line, `@param` for
parameters if used, and `@return` if applicable).

Comment on lines +232 to +243
func testSingleWordExcludedWhenHideShortEnabled() {
let singleWordEvent = makeEvent(content: "Supercalifragilisticexpialidocious", keypair: test_keypair)

let groups = GroupedTimelineGrouper.group(
events: [singleWordEvent],
filter: { _ in true },
values: defaultValues(hideShortNotes: true),
now: now
)

XCTAssertTrue(groups.isEmpty, "Single-word note should be excluded")
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

testSingleWordExcludedWhenHideShortEnabled will fail against the current implementation.

"Supercalifragilisticexpialidocious" is 34 characters. isTooShort applies words.count == 1 && content.count < 20; since 34 ≥ 20, it returns false and the event is included in groups. XCTAssertTrue(groups.isEmpty) will therefore fail.

The fix belongs in GroupedTimelineGrouper.isTooShort — remove the content.count < 20 guard for the single-word case (see comment on GroupedTimelineGrouper.swift line 135–136). Alternatively, use content shorter than 20 characters in this test if the intent is to only exclude genuinely short single-word notes:

-        let singleWordEvent = makeEvent(content: "Supercalifragilisticexpialidocious", keypair: test_keypair)
-
-        ...
-        XCTAssertTrue(groups.isEmpty, "Single-word note should be excluded")
+        let singleWordEvent = makeEvent(content: "gm", keypair: test_keypair)
+
+        ...
+        XCTAssertTrue(groups.isEmpty, "Single-word short note should be excluded")

(Prefer fixing the implementation rather than weakening the test so that long single-word notes are also excluded.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damusTests/GroupedTimelineGrouperTests.swift` around lines 232 - 243, The
test testSingleWordExcludedWhenHideShortEnabled fails because
GroupedTimelineGrouper.isTooShort currently checks both words.count == 1 and
content.count < 20, allowing long single-word notes (like
"Supercalifragilisticexpialidocious") to slip through; change isTooShort to
treat any single-word note as too short when hideShortNotes is enabled by
removing the content.count < 20 guard (i.e., return true when words.count == 1),
so GroupedTimelineGrouper.group will exclude single-word events as the test
expects.

@alltheseas alltheseas changed the title Add grouped view mode to NIP-05 domain feeds grouped UI feeds, nip-05, favorites Mar 19, 2026
Copy link
Copy Markdown
Collaborator

@danieldaquino danieldaquino left a comment

Choose a reason for hiding this comment

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

Thank you @alltheseas! Given this is a new feature and the size of the code changes, can we please place this behind a Damus Purple feature flag and minimize changes to existing code as much as possible (to reduce regression risk and make review faster)?

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

Labels

pr-in-queue This PR is waiting in a queue behind their other PRs marked with the label `pr-active-review`. ui usability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

nip-05 grouped UI frequent poster grouping

2 participants