Skip to content

SwiftUI performance optimizations#3626

Open
jb55 wants to merge 19 commits intodamus-io:masterfrom
jb55:opus-perf
Open

SwiftUI performance optimizations#3626
jb55 wants to merge 19 commits intodamus-io:masterfrom
jb55:opus-perf

Conversation

@jb55
Copy link
Copy Markdown
Collaborator

@jb55 jb55 commented Feb 14, 2026

Summary

Comprehensive SwiftUI performance overhaul targeting main-thread hitches during scrolling, profile viewing, and video playback. 20 commits across 19 files, each addressing a specific bottleneck identified via Instruments profiling.

View tree & layout optimizations

  • Remove AnyView type erasure from hot render paths — EventActionBar, EventView, EventShell, EventMutingContainerView, and ProfileView banner all replaced AnyView with @ViewBuilder or generics, restoring SwiftUI's structural identity diffing instead of full subtree teardown/rebuild
  • Flatten Route.view() — The 44-case @ViewBuilder switch created 44 levels of nested _ConditionalContent; replaced with explicit AnyView returns (appropriate here since navigation destinations are one-shot, not per-frame)
  • Remove unnecessary wrapper containers — VStack, Group wrappers in the event render path that added layout nodes with no purpose
  • Reduce GeometryReaders — ChatroomThreadView reduced from 5 to 3 GeometryReaders; TimelineView eliminated a redundant one by folding into an existing scroll callback
  • Replace paging TabView — PostingTimelineView's .page style TabView was pre-rendering both tabs (~129ms for the offscreen one); replaced with a simple conditional (REMOVED)

Caching & allocation reduction

  • Cache DateComponentsFormatter — Was allocating a new formatter per-note per-render (8ms in profiler); now a static singleton
  • Cache locale bundlesbundleForLocale was hitting disk (Bundle.main.path + Bundle(path:)) 12+ times per ProfileView render; now cached by locale identifier
  • Cache contact pubkeysfollows() was O(n) linear scan of contact event tags; now O(1) Set lookup built once on contact event arrival
  • Pre-render FriendIcon gradient — The gradient-masked SF Symbol was forcing offscreen compositing every frame (~8.8ms/frame); now blits a pre-rendered UIImage
  • Defer FollowingModel construction — Was iterating all contact tags on every ProfileView.body; now builds lazily at navigation time

ObservableObject / state invalidation fixes

  • Stop ProfileModel.objectWillChange on every event — EventHolder already notifies InnerTimelineView independently; the redundant notification was causing full ProfileView re-evaluation (~152ms) on every network event
  • Stop DamusVideoPlayer.current_time from triggering objectWillChange — The per-second timer was re-evaluating every visible video player's full body; replaced @Published with CurrentValueSubject scoped to controls only
  • Isolate scroll-driven updates in ProfileView — Replaced @State var yOffset with a reference-type ScrollOffsetTracker so scroll mutations don't trigger parent body re-evaluation (~112ms saved per frame)
  • Use reference-type storage in OffsetHelper@State value types were causing body re-evaluation on every scroll frame
  • Extract ProfileAboutSection — Was a computed property rebuilt on every body evaluation, doing NDB lookups and name resolution each time

UIKit drop-ins

  • Replace SwiftUI Menu with UIKit UIButton — SwiftUI Menu eagerly evaluates its content builder on every row (15ms+); UIButton with UIDeferredMenuElement.uncached defers to tap time
  • Always downsample images to target size — Images under the size threshold were going through deferred JPEG decode, causing 12ms+ main-thread decode in CA::Layer::prepare_commit; now all images decode at target resolution via CGImageSourceCreateThumbnailAtIndex
  • Skip redundant AVPlayerViewController.player assignmentsetPlayer rebuilds internal video layers (~13ms) even when assigned the same reference; added identity guard

Test plan

  • Scroll main timeline — verify smooth scrolling, no visual regressions in note cells (action bars, menus, friend icons, timestamps)
  • Open profile view — verify banner, pfp, about section, follow counts render correctly; scroll up/down and verify parallax/toolbar transitions
  • Tap "Following" on a profile — verify follower list loads correctly
  • Play a video in timeline — verify controls (play/pause, timestamp) work; verify no unnecessary flickering
  • Open chatroom thread — verify thread lines and scroll behavior
  • Test "Notes" / "Notes & Replies" tab switching on posting timeline
  • Verify context menus on notes still work (now UIKit-based)
  • Profile images and banners load and display at correct resolution

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Performance Improvements

    • Optimized video playback time synchronization for smoother scrubbing and display updates.
    • Enhanced profile page rendering to reduce lag during scrolling.
    • Pre-rendered profile icons for faster display.
    • Improved image caching and SVG detection to speed up media loading.
  • Bug Fixes

    • Fixed image downsampling to consistently apply across all data types.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 14, 2026

📝 Walkthrough

Walkthrough

This PR refactors SwiftUI view composition and state management across multiple features. Primary changes include removing AnyView wrappers in favor of ViewBuilder and explicit view returns, migrating EventMenu from SwiftUI to UIKit, generalizing EventMutingContainerView with proper typing, modularizing ProfileView with scroll tracking, and optimizing video playback state emissions and image rendering.

Changes

Cohort / File(s) Summary
AnyView Removal & ViewBuilder Refactoring
damus/Features/Actions/ActionBar/Views/EventActionBar.swift, damus/Features/Actions/Reposts/Views/RepostedEvent.swift, damus/Features/Chat/ChatroomThreadView.swift, damus/Features/Events/EventShell.swift, damus/Features/Events/EventView.swift
Replaced AnyView wrappers with explicit ViewBuilder methods and direct view returns. Introduced inner ViewBuilder properties and flattened conditional rendering logic to improve type safety and compile-time composition.
Event Menu & Muting System
damus/Features/Events/EventMenu.swift, damus/Features/Events/EventMutingContainerView.swift
Migrated EventMenu from SwiftUI Menu overlay to UIKit-based EventMenuButton (UIViewRepresentable) with deferred menu construction. Generalized EventMutingContainerView to support custom MuteBox view types via additional generic parameter, with dedicated initializers and refactored innerBody rendering logic.
Profile Components Refactoring
damus/Features/Profile/Models/ProfileModel.swift, damus/Features/Profile/Views/FriendIcon.swift, damus/Features/Profile/Views/ProfileView.swift
Added contactPubkeys tracking to ProfileModel for optimized follows() checks. Pre-rendered FriendIcon as static UIImage constants to avoid per-frame rendering. Significantly refactored ProfileView with ScrollOffsetTracker and modularized scroll-driven subcomponents (ProfileBannerSection, ProfileAboutSection, ProfilePicScrollEffect, ProfileToolbarLeading/Trailing).
Video Playback State Management
damus/Shared/Media/Video/DamusVideoControlsView.swift, damus/Shared/Media/Video/DamusVideoPlayer.swift
Replaced Published current_time with non-published scalar and CurrentValueSubject (currentTimeSubject) to avoid objectWillChange emissions on every tick. Added local state tracking and currentTimeBinding in DamusVideoControlsView to sync with video player updates.
UI & Layout Utilities
damus/Features/Timeline/Views/TimelineView.swift, damus/Shared/Extensions/OffsetExtension.swift, damus/Shared/Extensions/KFOptionSetter+.swift
Replaced GeometryReader-based side-effects with inline state updates in TimelineView. Consolidated OffsetExtension state management via private Storage class. Enhanced KFOptionSetter+ with data_looks_like_svg() check and unconditional downsampling logic for image processing.
Infrastructure & Utilities
damus/Shared/Utilities/LocalizationUtil.swift, damus/Shared/Utilities/TimeAgo.swift, damus/Shared/Utilities/Router.swift
Added bundle caching in LocalizationUtil for locale-specific lookups. Consolidated TimeAgo formatting to use shared formatter instance. Updated Route.Following to accept NostrEvent contacts and changed view() method return type to AnyView, wrapping all route view constructions.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant SwiftUI as EventMenuContext<br/>(SwiftUI)
    participant Menu as SwiftUI Menu
    participant ProfileMgr as ProfileModel<br/>Manager
    
    User->>SwiftUI: Tap event action
    SwiftUI->>Menu: Show Menu overlay
    Menu->>ProfileMgr: Allocate ProfileModel<br/>(on every open)
    ProfileMgr-->>Menu: Return model
    Menu->>User: Display menu items
Loading
sequenceDiagram
    actor User
    participant UIKit as EventMenuButton<br/>(UIKit)
    participant UIButton as UIButton
    participant MenuActions as menuActions<br/>(static)
    participant ProfileMgr as ProfileModel<br/>Manager
    
    User->>UIKit: Initialize button
    UIKit->>UIButton: Configure with deferred menu
    User->>UIButton: Tap ellipsis button
    UIButton->>MenuActions: Build menu (lazy)
    MenuActions->>ProfileMgr: Allocate ProfileModel<br/>(on interaction)
    ProfileMgr-->>MenuActions: Return model
    MenuActions-->>UIButton: Return UIMenu
    UIButton->>User: Display menu items
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • PR #3652: Both PRs modify damus/Shared/Utilities/Router.swift with Route case and view() signature changes affecting the navigation system.
  • PR #3509: Both PRs touch longform, reading progress, and sepia feature components with overlapping UI/composition refactoring patterns.

Suggested labels

technical, pr-active-review

Suggested reviewers

  • danieldaquino

Poem

🐰 ViewBuilder bunnies hopped so high,
Removed AnyView from every sky,
UIKit menus now defer with care,
ProfileView scrolls with scroll-aware flair—
State flows clean, no AnyView spare! 🎉

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is comprehensive and well-organized, covering summary, objectives, changes, and test plan. However, the Standard PR Checklist is incomplete: testing is not confirmed checked, profiling justification is missing, issue references are absent, changelog entries are not confirmed, and signoff completion is not verified. Complete the Standard PR Checklist by checking boxes for testing and profiling, adding issue references if applicable, confirming changelog entries, and verifying commit sign-offs before merge.
Docstring Coverage ⚠️ Warning Docstring coverage is 5.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'SwiftUI performance optimizations' accurately describes the main focus of the PR—a comprehensive performance overhaul across 19 files targeting render path optimization and memory allocation reduction.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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.

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: 4

🤖 Fix all issues with AI agents
In `@damus/Features/Events/EventMutingContainerView.swift`:
- Around line 47-59: The new_unmutes handler's condition is inverted: change the
check inside the onReceive for handle_notify(.new_unmutes) so it tests that
damus_state.mutelist_manager.event_muted_reason(event) == nil (meaning the event
is no longer muted) instead of != nil; when it is nil, set shown = true and
muted_reason = nil (leave existing behavior for shown/muted_reason assignments
intact) so events unmuted are un-hidden and still-muted events are not
incorrectly cleared.

In `@damus/Features/Profile/Views/FriendIcon.swift`:
- Around line 10-43: Remove the deprecated explicit scale assignment on the
renderer format in the _friendIcon initializer: delete the line assigning
UIScreen.main.scale to UIGraphicsImageRendererFormat.scale and rely on the
default format behavior instead (keep the creation of
UIGraphicsImageRendererFormat() and the renderer/image closure as-is); reference
symbols: _friendIcon, UIGraphicsImageRendererFormat, UIGraphicsImageRenderer.

In `@damus/Features/Profile/Views/ProfileView.swift`:
- Line 83: The `@State` property scrollTracker in ProfileView should be made
private to prevent external mutation; change the declaration of scrollTracker
(of type ScrollOffsetTracker) from "@State var scrollTracker =
ScrollOffsetTracker()" to a private `@State` property and update any external
accesses to use internal bindings or expose controlled interfaces if needed
(search for scrollTracker usages in ProfileView to ensure visibility changes
don't break references).

In `@damus/Shared/Utilities/LocalizationUtil.swift`:
- Around line 10-24: The module-level dictionary _bundleCache is accessed
unsafely from bundleForLocale causing a potential data race; make access
thread-safe by introducing a synchronization primitive (e.g., a private
DispatchQueue or an NSLock) guarding all reads and writes to _bundleCache, wrap
the cache lookup/update in that lock/queue, and keep the critical section
minimal (check cache under lock, if not present then compute path and create
Bundle with either a second locked update or a dispatch_sync/lock around only
the write) so callers of bundleForLocale remain safe for concurrent use.
🧹 Nitpick comments (22)
damus/Shared/Utilities/TimeAgo.swift (1)

10-16: Good optimization — but note potential thread-safety concern with shared formatter.

DateComponentsFormatter (an NSFormatter subclass) is not documented as thread-safe by Apple. Since time_ago_since is a public function callable from any thread, concurrent access to _timeAgoFormatter could theoretically race.

In practice this is unlikely to manifest for string(from: DateComponents), but if you want to be safe you could use a @Sendable-friendly approach such as a per-thread formatter via a small actor or DispatchQueue-based accessor — or simply accept the (very low) risk given the measured perf win.

damus/Shared/Extensions/OffsetExtension.swift (1)

33-38: Intentional use of @State with a reference type to suppress view invalidation — add a brief doc comment.

The pattern of wrapping a class in @State to avoid SwiftUI re-renders on scroll-offset mutations is a well-known performance trick and clearly intentional here. However, since @State is officially designed for value types, a brief doc comment on Storage explaining why a reference type is used (to decouple offset bookkeeping from view invalidation) would help future maintainers understand the intent and avoid "fixing" it back to @State var currentOffset.

Suggested doc comment
-    private class Storage {
-        var currentOffset: CGFloat = 0
-        var previousOffset: CGFloat = 0
-    }
+    /// Reference-type wrapper so that offset mutations do not trigger
+    /// SwiftUI view invalidation.  Wrapped in `@State` only to preserve
+    /// identity across view re-renders; property changes intentionally
+    /// bypass the SwiftUI diff.
+    private class Storage {
+        var currentOffset: CGFloat = 0
+        var previousOffset: CGFloat = 0
+    }

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

damus/Shared/Media/Video/DamusVideoPlayer.swift (1)

209-218: Redundant DispatchQueue.main.async — the observer already fires on .main.

The periodic time observer is registered with queue: .main (line 210), so the closure already executes on the main queue. The inner DispatchQueue.main.async on line 212 adds an unnecessary extra hop, deferring the update to the next run-loop iteration.

Proposed simplification
     videoCurrentTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: 600), queue: .main) { [weak self] time in
         guard let self else { return }
-        DispatchQueue.main.async {
-            if self.is_editing_current_time == false {
-                self.current_time = time.seconds
-                self.currentTimeSubject.send(time.seconds)
-            }
+        if self.is_editing_current_time == false {
+            self.current_time = time.seconds
+            self.currentTimeSubject.send(time.seconds)
         }
     }
damus/Shared/Media/Video/DamusVideoControlsView.swift (1)

83-96: DateComponentsFormatter is allocated on every call — consider caching.

DateComponentsFormatter is expensive to create and this runs every second via onReceive. The PR objectives mention a "singleton DateComponentsFormatter" optimization, but this site still allocates a new one per invocation. A static let would eliminate the repeated allocation.

Proposed fix
+    private static let shortFormatter: DateComponentsFormatter = {
+        let f = DateComponentsFormatter()
+        f.unitsStyle = .positional
+        f.zeroFormattingBehavior = [.pad]
+        return f
+    }()
+
     func formatTimeInterval(_ interval: TimeInterval) -> String {
         if interval.isNaN {
             return "--:--"
         }
-        let formatter = DateComponentsFormatter()
-        formatter.allowedUnits = interval >= 60 * 60 ? [.hour, .minute, .second] : [.minute, .second]
-        formatter.unitsStyle = .positional
-        formatter.zeroFormattingBehavior = [.pad]
-
-        guard let formattedString = formatter.string(from: interval) else {
+        Self.shortFormatter.allowedUnits = interval >= 60 * 60 ? [.hour, .minute, .second] : [.minute, .second]
+        guard let formattedString = Self.shortFormatter.string(from: interval) else {
             return ""
         }
         return formattedString
     }

Note: Since allowedUnits is mutated per call, if thread safety is a concern you could use two separate static formatters (one for short, one for long durations) instead.

damus/Shared/Utilities/LocalizationUtil.swift (1)

10-27: Missing docstrings on added/modified symbols.

_bundleCache, bundleForLocale, and _enUSFallbackBundle lack documentation. As per coding guidelines: "Ensure docstring coverage for any code added or modified."

damus/Shared/Extensions/KFOptionSetter+.swift (2)

114-128: BOM bytes are skipped individually rather than as a 3-byte sequence.

The UTF-8 BOM is the 3-byte sequence EF BB BF, but the loop treats each byte as an independent skip character. This means a stray 0xBB or 0xBF appearing before < in non-BOM data would also be skipped. Since this is just a heuristic gating strict String/SVG parsing downstream, false positives are harmless — but worth noting for correctness.

Also, data[i] assumes zero-based indexing, which holds for non-slice Data but is technically not guaranteed by the Data API. Using data[data.startIndex + i] (or iterating via the collection directly) would be more robust.

Suggested more robust BOM handling
 private func data_looks_like_svg(_ data: Data) -> Bool {
-    // Skip leading whitespace and BOM
     let limit = min(data.count, 256)
-    var i = 0
+    var i = data.startIndex
+    let end = data.index(data.startIndex, offsetBy: limit)
+    // Skip UTF-8 BOM if present
+    if data.count >= 3 && data[data.startIndex] == 0xEF && data[data.startIndex+1] == 0xBB && data[data.startIndex+2] == 0xBF {
+        i = data.startIndex + 3
+    }
+    // Skip leading whitespace
-    while i < limit {
+    while i < end {
         let b = data[i]
-        if b == 0x20 || b == 0x09 || b == 0x0A || b == 0x0D || b == 0xEF || b == 0xBB || b == 0xBF {
+        if b == 0x20 || b == 0x09 || b == 0x0A || b == 0x0D {
             i += 1
             continue
         }
-        // Must start with '<'
         return b == 0x3C // '<'
     }
     return false
 }

130-166: maxSize property appears unused after this change.

The maxSize stored property on CustomImageProcessor (line 132) is no longer referenced in process(item:options:) now that downsampling is unconditional. The same applies to CustomCacheSerializer.maxSize (line 170). Consider removing these to avoid confusion, or mark them with a comment if they're kept for future use.

#!/bin/bash
# Verify that maxSize is not used anywhere in these structs beyond storage
rg -n 'maxSize' --type swift
damus/Features/Profile/Models/ProfileModel.swift (1)

41-41: Missing docstrings on new/modified members.

Per coding guidelines, added or modified code should have docstring coverage. The new contactPubkeys property (line 41), the modified follows(pubkey:) (line 51), handle_profile_contact_event (line 137), and the changed add_event path (line 154) all lack documentation. Brief doc comments explaining the caching strategy and why objectWillChange is intentionally suppressed would help future maintainers.

damus/Shared/Utilities/Router.swift (2)

56-61: Well-documented rationale for AnyView in navigation routing.

The comment clearly explains why AnyView is the right trade-off here: a one-time-per-push erasure avoids the deep _ConditionalContent nesting that a 44-branch @ViewBuilder would create. This is a sound approach for navigation destination factories.

One minor gap per coding guidelines: the modified function lacks a docstring.

📝 Suggested docstring
+    /// Resolves this route to the corresponding destination view, wrapped in `AnyView`
+    /// to avoid deep `_ConditionalContent` nesting from the large case switch.
+    ///
+    /// Called once per navigation push — not per frame — so the `AnyView` diffing
+    /// cost is negligible.
     // AnyView is intentional here: a 44-case `@ViewBuilder` switch creates 44
     // levels of nested _ConditionalContent that SwiftUI must evaluate on every
     // navigation push. AnyView flattens this to a single type. Navigation
     // destinations are created once per push (not per frame), so the usual
     // AnyView diffing penalty does not apply.
     func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> AnyView {

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


152-154: Pre-existing: == implemented via hashValue comparison is unsound.

This is not introduced by this PR, but the new Following case interacts with it. Comparing hashValue for equality means hash collisions produce false positives — two distinct routes could compare as equal, causing NavigationCoordinator.push (line 276) to silently drop a valid navigation. With 44 cases and string-prefixed hashing the practical collision risk is low, but this violates Hashable/Equatable semantics.

Not blocking, but flagging for awareness since this PR touches the enum extensively.

damus/Features/Chat/ChatroomThreadView.swift (3)

12-17: Add a docstring to UntrustedSectionMinYKey.

New types should have documentation explaining their purpose. As per coding guidelines, "Ensure docstring coverage for any code added or modified."

Proposed fix
+/// Preference key that tracks the global minimum-Y position of the untrusted-network
+/// section, used to trigger the sticky header when the section scrolls near the top.
 private struct UntrustedSectionMinYKey: PreferenceKey {
     static var defaultValue: CGFloat = .infinity

293-309: Scroll-driven @State updates on every frame may cause unnecessary view invalidations.

contentTopY and contentBottomY are @State properties that are updated on every scroll position change (line 297-298), but they are never read directly in body—they're only consumed inside updateReadingProgress() and updateChromeVisibility(), which are called right here in the closure. For non-longform events, those methods early-return without mutating any body-visible state, yet the @State writes to contentTopY/contentBottomY may still mark the view as dirty and trigger a body re-evaluation.

Given this PR's focus on reducing main-thread hitches during scrolling, consider storing these intermediate tracking values in a non-published reference type (similar to the ScrollOffsetTracker / OffsetHelper pattern mentioned in other files in this PR) so that only the final derived states (readingProgress, chromeHidden) drive view updates.

#!/bin/bash
# Check if ScrollOffsetTracker or OffsetHelper reference types exist in the repo
# to see if there's an established pattern to reuse here
rg -n "class ScrollOffsetTracker|class OffsetHelper" --type swift

327-334: Extract magic threshold 100 into a named constant.

The value 100 on line 328 controls when the sticky header appears. A named constant next to scrollThreshold (line 43) would improve readability and make it easier to tune both thresholds together.

Proposed fix
     /// Minimum scroll distance before triggering chrome hide/show
     private let scrollThreshold: CGFloat = 15
+    /// Global-Y threshold at which the untrusted-network sticky header becomes visible
+    private let stickyHeaderTriggerY: CGFloat = 100
     .onPreferenceChange(UntrustedSectionMinYKey.self) { newY in
-        let shouldShow = newY <= 100
+        let shouldShow = newY <= stickyHeaderTriggerY
         if shouldShow != showStickyHeader {
damus/Features/Events/EventMutingContainerView.swift (1)

21-23: @State properties should be private.

SwiftLint flags shown (line 21) and muted_reason (line 23) as non-private @State properties. Since they are only written via State(initialValue:) in the initializers and mutated internally, marking them private is safe and aligns with SwiftUI best practices.

Proposed fix
-    `@State` var shown: Bool
+    `@State` private var shown: Bool

-    `@State` var muted_reason: MuteItem?
+    `@State` private var muted_reason: MuteItem?
damus/Features/Timeline/Views/TimelineView.swift (1)

131-142: handle_scroll_queue may now be dead code.

The ScrollQueue protocol and handle_scroll_queue function remain at the bottom of this file, but the only apparent caller (the GeometryReader in MainContent) was just replaced with the inline logic on lines 103–106. If no other callers exist, consider removing this dead code.

#!/bin/bash
# Check if handle_scroll_queue or ScrollQueue is used elsewhere
rg -n 'handle_scroll_queue\|ScrollQueue' --type swift
damus/Features/Profile/Views/FriendIcon.swift (1)

45-56: Same deprecated UIScreen.main.scale usage in _fofIcon.

Same fix as above — remove the redundant and deprecated UIScreen.main.scale assignment.

Proposed fix
 private let _fofIcon: UIImage = {
     let size = CGSize(width: 21, height: 14)
     let format = UIGraphicsImageRendererFormat()
-    format.scale = UIScreen.main.scale
     let renderer = UIGraphicsImageRenderer(size: size, format: format)
damus/Features/Profile/Views/ProfileView.swift (6)

68-70: Missing docstring on ScrollOffsetTracker.

As per coding guidelines, added/modified code should have docstring coverage. A brief doc comment explaining the purpose and why it's a reference type (to isolate scroll-driven invalidations from the parent ProfileView) would help future readers.

Suggested doc
+/// Reference-type tracker for vertical scroll offset.
+///
+/// Used as `@State` (not `@StateObject`) in `ProfileView` so that changes to
+/// `yOffset` do **not** invalidate `ProfileView.body`. Only child subviews that
+/// declare `@ObservedObject var scrollTracker` will re-evaluate on scroll.
 class ScrollOffsetTracker: ObservableObject {
     `@Published` var yOffset: CGFloat = 0
 }

345-348: blurOpacity() is duplicated across three subviews.

The identical calculation appears in ProfileBannerSection, ProfileToolbarLeading, and ProfileToolbarTrailing. Extract it into a shared helper on ScrollOffsetTracker (or as a free function) to keep it DRY and ensure future changes stay consistent.

Suggested extraction
 class ScrollOffsetTracker: ObservableObject {
     `@Published` var yOffset: CGFloat = 0
+
+    /// Opacity for the blur/overlay effect based on scroll position.
+    func blurOpacity(navbarHeight: CGFloat) -> Double {
+        let progress = -(yOffset + navbarHeight) / 100
+        return Double(-yOffset > navbarHeight ? progress : 0)
+    }
 }

Then in each subview, replace blurOpacity() calls with scrollTracker.blurOpacity(navbarHeight: navbarHeight).

Also applies to: 618-621, 652-655


301-305: Commented-out followers.subscribe() is unexplained.

Line 304 has //followers.subscribe() with no explanation of why it was disabled. Either remove it or add a // TODO: / comment explaining the reasoning, to avoid confusion for future maintainers.


260-268: Consider using switch instead of three independent if blocks.

Since filter_state is a single enum value, these conditions are mutually exclusive. A switch (or if/else if) would make the exclusivity explicit and avoid evaluating all three predicates on every body evaluation.

Suggested refactor
-                        if filter_state == FilterState.posts {
-                            InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.posts))
-                        }
-                        if filter_state == FilterState.posts_and_replies {
-                            InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.posts_and_replies))
-                        }
-                        if filter_state == FilterState.conversations && !profile.conversation_events.isEmpty {
-                            InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.conversations))
-                        }
+                        switch filter_state {
+                        case .posts:
+                            InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(.posts))
+                        case .posts_and_replies:
+                            InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(.posts_and_replies))
+                        case .conversations:
+                            if !profile.conversation_events.isEmpty {
+                                InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(.conversations))
+                            }
+                        default:
+                            EmptyView()
+                        }

415-608: ProfileAboutSection is still quite large — consider documenting its responsibilities.

At ~190 lines with 6 sub-methods, this struct is the largest private subview. A docstring on the struct and at least its body would satisfy the coding guidelines' docstring coverage requirement and help readers understand the layout contract (padding, spacing, etc.) at a glance. The same applies to the other new private structs (ProfileBannerSection, ProfilePicScrollEffect, ProfileToolbarLeading, ProfileToolbarTrailing). As per coding guidelines: "Ensure docstring coverage for any code added or modified."


657-659: showFollowBtn() conflates scroll position with UI visibility — worth a brief comment.

The condition blurOpacity() > 1.0 as a proxy for "user has scrolled past the profile header" is non-obvious. A one-line comment explaining the intent (e.g., "show follow button in navbar once the header has scrolled offscreen") would clarify the magic threshold.

Comment on lines +47 to 59
.onReceive(handle_notify(.new_mutes)) { mutes in
let new_muted_event_reason = damus_state.mutelist_manager.event_muted_reason(event)
if new_muted_event_reason != nil {
shown = false
muted_reason = new_muted_event_reason
}
else {
EventMutedBoxView(shown: $shown, reason: muted_reason)
}
.onReceive(handle_notify(.new_unmutes)) { unmutes in
if damus_state.mutelist_manager.event_muted_reason(event) != nil {
shown = true
muted_reason = nil
}
}
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

Bug: Inverted condition in new_unmutes handler will show still-muted events.

The new_mutes handler (line 49) correctly checks event_muted_reason(event) != nil to confirm the event is now muted. But the new_unmutes handler (line 55) uses the same != nil check — meaning it acts only when the event is still muted, which is the opposite of the intent. This will fail to un-hide events that were actually unmuted, and incorrectly clear muted_reason for events that remain muted for a different reason.

🐛 Proposed fix: invert the condition
             .onReceive(handle_notify(.new_unmutes)) { unmutes in
-                if damus_state.mutelist_manager.event_muted_reason(event) != nil {
+                if damus_state.mutelist_manager.event_muted_reason(event) == nil {
                     shown = true
                     muted_reason = nil
                 }
             }
📝 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
.onReceive(handle_notify(.new_mutes)) { mutes in
let new_muted_event_reason = damus_state.mutelist_manager.event_muted_reason(event)
if new_muted_event_reason != nil {
shown = false
muted_reason = new_muted_event_reason
}
else {
EventMutedBoxView(shown: $shown, reason: muted_reason)
}
.onReceive(handle_notify(.new_unmutes)) { unmutes in
if damus_state.mutelist_manager.event_muted_reason(event) != nil {
shown = true
muted_reason = nil
}
}
.onReceive(handle_notify(.new_mutes)) { mutes in
let new_muted_event_reason = damus_state.mutelist_manager.event_muted_reason(event)
if new_muted_event_reason != nil {
shown = false
muted_reason = new_muted_event_reason
}
}
.onReceive(handle_notify(.new_unmutes)) { unmutes in
if damus_state.mutelist_manager.event_muted_reason(event) == nil {
shown = true
muted_reason = nil
}
}
🤖 Prompt for AI Agents
In `@damus/Features/Events/EventMutingContainerView.swift` around lines 47 - 59,
The new_unmutes handler's condition is inverted: change the check inside the
onReceive for handle_notify(.new_unmutes) so it tests that
damus_state.mutelist_manager.event_muted_reason(event) == nil (meaning the event
is no longer muted) instead of != nil; when it is nil, set shown = true and
muted_reason = nil (leave existing behavior for shown/muted_reason assignments
intact) so events unmuted are un-hidden and still-muted events are not
incorrectly cleared.

Comment on lines +10 to +43
/// Pre-rendered friend icons to avoid per-frame gradient mask compositing.
/// The gradient mask + SF Symbol composition is expensive in Core Animation,
/// so we render once into a UIImage and blit the bitmap on each cell.
private let _friendIcon: UIImage = {
let size = CGSize(width: 20, height: 14)
let format = UIGraphicsImageRendererFormat()
format.scale = UIScreen.main.scale
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { rendererCtx in
let rect = CGRect(origin: .zero, size: size)
let cg = rendererCtx.cgContext

let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .regular)
guard let symbol = UIImage(systemName: "person.fill.checkmark", withConfiguration: config) else { return }

// Draw symbol into the context (acts as mask source)
symbol.withTintColor(.white, renderingMode: .alwaysOriginal).draw(in: rect)

// Composite gradient using sourceIn so it only fills where the symbol was drawn
cg.saveGState()
cg.setBlendMode(.sourceIn)
let colors: [CGColor] = [
UIColor(red: 204/255.0, green: 67/255.0, blue: 197/255.0, alpha: 1).cgColor, // DamusPurple
UIColor(red: 75/255.0, green: 77/255.0, blue: 255/255.0, alpha: 1).cgColor // DamusBlue
]
guard let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors as CFArray, locations: nil) else {
cg.restoreGState()
return
}
// topTrailing → bottomTrailing to match LINEAR_GRADIENT
cg.drawLinearGradient(gradient, start: CGPoint(x: rect.maxX, y: 0), end: CGPoint(x: rect.maxX, y: rect.maxY), options: [])
cg.restoreGState()
}
}()
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

🌐 Web query:

UIScreen.main deprecated iOS 16 Apple documentation

💡 Result:

UIScreen.main itself isn’t removed in iOS 16, but Xcode 14+ commonly emits a warning like:

“‘main’ will be deprecated in a future version of iOS: Use a UIScreen instance found through context instead: i.e. view.window.windowScene.screen” (stackoverflow.com)

Apple’s recommended direction is to stop treating “the screen” as a global singleton (because of multiple windows/scenes and external displays) and instead fetch the UIScreen from the relevant UIWindowScene (for the window/view you’re working with). (stackoverflow.com)

Common replacements:

  • From a view/controller context: view.window?.windowScene?.screen (stackoverflow.com)
  • More generally (iterate scenes/windows, then use window.screen): (stackoverflow.com)

Citations:


🌐 Web query:

UIGraphicsImageRendererFormat default scale behavior

💡 Result:

UIGraphicsImageRendererFormat’s scale defaults to the main screen’s display scale (i.e., UIScreen.main.scale, commonly 2.0 or 3.0). That means a renderer created with a size in points will rasterize to points × scale in pixels unless you override it. [1] [2]

To force a specific output pixel scale, set it explicitly:

let format = UIGraphicsImageRendererFormat()
format.scale = 1.0   // produce 1 px per point

let renderer = UIGraphicsImageRenderer(size: CGSize(width: 300, height: 300),
                                       format: format)
let image = renderer.image { _ in /* draw */ }

[1] [3]

Sources: [1] [2] [3]


Remove the redundant and deprecated UIScreen.main.scale assignment.

UIGraphicsImageRendererFormat() defaults to the main screen's scale, so line 16 is unnecessary. Additionally, UIScreen.main is deprecated in favor of fetching the screen from context (e.g., view.window?.windowScene?.screen), though in a global initializer this alternative isn't available. Simply removing the explicit assignment eliminates the deprecated API usage while preserving the intended behavior.

Proposed fix
 private let _friendIcon: UIImage = {
     let size = CGSize(width: 20, height: 14)
     let format = UIGraphicsImageRendererFormat()
-    format.scale = UIScreen.main.scale
     let renderer = UIGraphicsImageRenderer(size: size, format: format)
🤖 Prompt for AI Agents
In `@damus/Features/Profile/Views/FriendIcon.swift` around lines 10 - 43, Remove
the deprecated explicit scale assignment on the renderer format in the
_friendIcon initializer: delete the line assigning UIScreen.main.scale to
UIGraphicsImageRendererFormat.scale and rely on the default format behavior
instead (keep the creation of UIGraphicsImageRendererFormat() and the
renderer/image closure as-is); reference symbols: _friendIcon,
UIGraphicsImageRendererFormat, UIGraphicsImageRenderer.

@State var mute_dialog_presented: Bool = false
@State var filter_state : FilterState = .posts
@State var yOffset: CGFloat = 0
@State var scrollTracker = ScrollOffsetTracker()
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

@State property should be private.

SwiftLint flags this correctly. SwiftUI @State properties should be private to prevent external mutation that bypasses SwiftUI's state management.

-    `@State` var scrollTracker = ScrollOffsetTracker()
+    `@State` private var scrollTracker = ScrollOffsetTracker()
📝 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
@State var scrollTracker = ScrollOffsetTracker()
`@State` private var scrollTracker = ScrollOffsetTracker()
🧰 Tools
🪛 SwiftLint (0.63.2)

[Warning] 83-83: SwiftUI state properties should be private

(private_swiftui_state)

🤖 Prompt for AI Agents
In `@damus/Features/Profile/Views/ProfileView.swift` at line 83, The `@State`
property scrollTracker in ProfileView should be made private to prevent external
mutation; change the declaration of scrollTracker (of type ScrollOffsetTracker)
from "@State var scrollTracker = ScrollOffsetTracker()" to a private `@State`
property and update any external accesses to use internal bindings or expose
controlled interfaces if needed (search for scrollTracker usages in ProfileView
to ensure visibility changes don't break references).

Comment on lines +10 to +24
private var _bundleCache: [String: Bundle] = [:]

func bundleForLocale(locale: Locale) -> Bundle {
let path = Bundle.main.path(forResource: locale.identifier, ofType: "lproj")
return path != nil ? (Bundle(path: path!) ?? Bundle.main) : Bundle.main
let id = locale.identifier
if let cached = _bundleCache[id] {
return cached
}
let bundle: Bundle
if let path = Bundle.main.path(forResource: id, ofType: "lproj") {
bundle = Bundle(path: path) ?? Bundle.main
} else {
bundle = Bundle.main
}
_bundleCache[id] = bundle
return bundle
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

Data race on _bundleCache: unsynchronized read/write from multiple threads.

_bundleCache is a module-level var dictionary accessed without any synchronization. If bundleForLocale is called concurrently (e.g., from background localization tasks), simultaneous read/write on Dictionary is undefined behavior in Swift and can crash.

Wrap access in a lock or serial queue.

🔒 Proposed fix using a lock
-private var _bundleCache: [String: Bundle] = [:]
+private var _bundleCache: [String: Bundle] = [:]
+private let _bundleCacheLock = NSLock()

 func bundleForLocale(locale: Locale) -> Bundle {
     let id = locale.identifier
+    _bundleCacheLock.lock()
     if let cached = _bundleCache[id] {
+        _bundleCacheLock.unlock()
         return cached
     }
     let bundle: Bundle
     if let path = Bundle.main.path(forResource: id, ofType: "lproj") {
         bundle = Bundle(path: path) ?? Bundle.main
     } else {
         bundle = Bundle.main
     }
     _bundleCache[id] = bundle
+    _bundleCacheLock.unlock()
     return bundle
 }

Alternatively, use defer for cleaner unlock semantics (at the cost of holding the lock during Bundle.main.path lookup — acceptable given it's a fast filesystem metadata check):

-private var _bundleCache: [String: Bundle] = [:]
+private var _bundleCache: [String: Bundle] = [:]
+private let _bundleCacheLock = NSLock()

 func bundleForLocale(locale: Locale) -> Bundle {
     let id = locale.identifier
+    _bundleCacheLock.lock()
+    defer { _bundleCacheLock.unlock() }
     if let cached = _bundleCache[id] {
         return cached
     }

As per coding guidelines: "Never block the main thread: All network requests, database queries, and expensive computations must run on background threads/queues … UI updates must dispatch back to @MainActor" — concurrent access from these background paths makes synchronization essential here.

📝 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 _bundleCache: [String: Bundle] = [:]
func bundleForLocale(locale: Locale) -> Bundle {
let path = Bundle.main.path(forResource: locale.identifier, ofType: "lproj")
return path != nil ? (Bundle(path: path!) ?? Bundle.main) : Bundle.main
let id = locale.identifier
if let cached = _bundleCache[id] {
return cached
}
let bundle: Bundle
if let path = Bundle.main.path(forResource: id, ofType: "lproj") {
bundle = Bundle(path: path) ?? Bundle.main
} else {
bundle = Bundle.main
}
_bundleCache[id] = bundle
return bundle
private var _bundleCache: [String: Bundle] = [:]
private let _bundleCacheLock = NSLock()
func bundleForLocale(locale: Locale) -> Bundle {
let id = locale.identifier
_bundleCacheLock.lock()
if let cached = _bundleCache[id] {
_bundleCacheLock.unlock()
return cached
}
let bundle: Bundle
if let path = Bundle.main.path(forResource: id, ofType: "lproj") {
bundle = Bundle(path: path) ?? Bundle.main
} else {
bundle = Bundle.main
}
_bundleCache[id] = bundle
_bundleCacheLock.unlock()
return bundle
}
🤖 Prompt for AI Agents
In `@damus/Shared/Utilities/LocalizationUtil.swift` around lines 10 - 24, The
module-level dictionary _bundleCache is accessed unsafely from bundleForLocale
causing a potential data race; make access thread-safe by introducing a
synchronization primitive (e.g., a private DispatchQueue or an NSLock) guarding
all reads and writes to _bundleCache, wrap the cache lookup/update in that
lock/queue, and keep the critical section minimal (check cache under lock, if
not present then compute path and create Bundle with either a second locked
update or a dispatch_sync/lock around only the write) so callers of
bundleForLocale remain safe for concurrent use.

@danieldaquino
Copy link
Copy Markdown
Collaborator

@jb55, can you please add sign-offs and changelog entries on the commits?

@danieldaquino danieldaquino self-requested a review February 19, 2026 00:49
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.

@jb55
Copy link
Copy Markdown
Collaborator Author

jb55 commented Feb 19, 2026

the commits are fine, we don't need to be so anal about this

@danieldaquino
Copy link
Copy Markdown
Collaborator

the commits are fine, we don't need to be so anal about this

Should we make the sign-off requirement optional in the contribution guidelines?

@danieldaquino danieldaquino self-requested a review February 19, 2026 02:31
@danieldaquino
Copy link
Copy Markdown
Collaborator

danieldaquino commented Feb 19, 2026

There is an issue with the profile view banner heading:
IMG_0415

Expected behaviour (Taken from 1.17 before the PR):

Screenshot 2026-02-18 at 19 42 03

Other elements seem to be working as expected.

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.

Thanks @jb55, changes make sense and views seem to be working after changes. There only seems to be one new issue (as far as I have tested) with the profile banner top bar that we should address before merging (More details on the previous comment).

@danieldaquino
Copy link
Copy Markdown
Collaborator

Submitted to TestFlight for testing with users. Version and build number: v1.17 (1299)

@danieldaquino
Copy link
Copy Markdown
Collaborator

danieldaquino commented Mar 4, 2026

One issue raised by a user: The home view does not scroll/swipe between Notes and Replies and Notes.

Quote:

Oh wow, please fix this. I’m so used to sliding between notes / notes and replies.

@jb55, is it feasible to maintain that behaviour alongside the relevant performance improvement?

@jb55
Copy link
Copy Markdown
Collaborator Author

jb55 commented Mar 4, 2026

Do people use that? Apparently its a huge performance issue because it actually renders both views

jb55 and others added 16 commits March 4, 2026 15:04
Replace thread line background GeometryReader with overlay, consolidate
top+bottom scroll position trackers into a single overlay, and replace
untrusted section tracker with PreferenceKey. Remove dead
untrustedSectionOffset state variable.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Replace AnyView type erasure with direct view return. Replace
DispatchQueue.main.async yOffset update with .onChange(of: minY)
which fires after layout without the overhead of dispatching to
the run loop.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Fold handle_scroll_queue logic into the existing .offsetY closure
which already fires every scroll frame, eliminating an extra
GeometryReader that was doing redundant per-frame layout work.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Replace AnyView wrappers in space_if_spread and content with
@ViewBuilder, allowing SwiftUI to preserve structural identity
for the action bar rendered on every note cell.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Replace O(n) linear scan of contact event tags in follows() with
a Set<Pubkey> lookup. The set is built once when the contact event
arrives in handle_profile_contact_event.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…ocation

The formatter was being allocated on every call, which runs per-note on
every timeline cell render. Profiler showed 8ms in this path. Now uses a
static instance initialized once.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Array(contact.referenced_pubkeys) and Array(contact.referenced_hashtags)
were running on every ProfileView.body evaluation, iterating all contact
tags to build arrays for a FollowingModel that may never be used. Now
Route.Following stores the contacts event directly, and the model is
built lazily in the router when the user actually taps "Following".

Co-Authored-By: Claude Opus 4.6 <[email protected]>
bundleForLocale was calling Bundle.main.path(forResource:ofType:) and
Bundle(path:) on every call. With pluralizedString called 3+ times per
ProfileView body evaluation, this added up to 12+ bundle lookups per
render. Now caches bundles by locale identifier and pre-resolves the
en-US fallback bundle as a static.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
The @State value types for currentOffset and previousOffset were causing
SwiftUI to re-evaluate the OffsetHelper body on every scroll frame,
triggering cascading layout passes through the LazyVStack.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
The FriendIcon uses LINEAR_GRADIENT.mask(SF Symbol) which forces
Core Animation to perform offscreen gradient mask compositing on
every timeline cell, every frame. Profiling showed ~4.4ms avg per
OpacityRendererEffect hit (2 per scroll frame, ~8.8ms total) deep
in the cell render path at FriendIcon.

Pre-render both the gradient-masked friend icon and the gray fof
icon into UIImages once at first access using UIGraphicsImageRenderer.
The view now blits a cached bitmap instead of recompositing the
gradient mask per frame.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Every timeline cell renders through EventView, EventShell, and
EventMutingContainerView. Each had unnecessary wrapper containers
that add nodes to the SwiftUI view tree:

- EventView wrapped its conditional (boost/zap/text/etc) in a VStack
  that served no layout purpose since only one branch renders. The
  VStack forces SwiftUI to allocate a layout container and diff it.

- EventShell wrapped its Wide/Threaded conditional in a Group, which
  adds a node SwiftUI must track through its diffing pass.

- EventMutingContainerView used an AnyView-returning closure for
  custom mute boxes. AnyView type-erases the view, preventing
  SwiftUI from doing structural identity diffing -- it must tear down
  and rebuild the entire subtree instead of incremental updates.
  Changed to a generic MuteBox type parameter so SwiftUI knows the
  concrete view type at compile time.

All replaced with @ViewBuilder which emits _ConditionalContent
directly, letting SwiftUI structurally diff without extra containers.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
The 44-case @ViewBuilder switch created 44 levels of nested
_ConditionalContent that SwiftUI had to evaluate on every navigation
push. Replace with explicit AnyView returns to flatten the type.
Navigation destinations are one-shot constructions (not per-frame),
so the AnyView diffing penalty does not apply.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…ecode

The image processor and cache serializer were only downsampling when
data exceeded maxSize (5MiB for pfp, 20MiB for banner). Images under
that threshold went through DefaultImageProcessor which produces a
CGImage with deferred JPEG decoding — Core Animation then re-decodes
the full-resolution JPEG on the main thread during CA::Layer::prepare_commit
(12ms+ for progressive JPEGs).

Now all images go through CGImageSourceCreateThumbnailAtIndex which
decodes directly at the target resolution (200x200 pfp, 750x250 banner),
producing a fully materialized bitmap that CA can commit without any
additional decode.

Also adds a fast SVG pre-check (data_looks_like_svg) that scans for a
'<' byte in the first 256 bytes before attempting String(data:encoding:),
avoiding a speculative string allocation on every binary image.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…aversal

SwiftUI Menu eagerly evaluates its content builder on every row body,
creating deeply nested layout nodes that cost 15ms+ per row. UIButton
with UIDeferredMenuElement.uncached defers menu construction to tap time
and acts as an opaque leaf node with no recursive spacing traversal.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
AVPlayerViewController.setPlayer rebuilds internal video layers (~13ms)
even when assigned the same reference. Guard with identity check to
avoid unnecessary work during SwiftUI view updates.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…evaluation

Replace @State var yOffset with an ObservableObject (ScrollOffsetTracker)
held via @State (reference-type, so mutations do not trigger parent body).
Extract banner, pfp, and toolbar into subviews that @ObservedObject the
tracker, so only they re-render on scroll (~112ms body saved per frame).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
jb55 and others added 3 commits March 4, 2026 15:05
EventHolder.FilteredHolder already independently notifies
InnerTimelineView about new events, so the manual
objectWillChange.send() was redundant — it caused a full
ProfileView.body re-evaluation (~152ms) on every event from
the network.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
current_time is updated every second via addPeriodicTimeObserver, which
fires objectWillChange on every @ObservedObject subscriber — including
DamusVideoPlayerView on every visible video in the timeline. Each tick
re-evaluates the full body (VisibilityTracker GeometryReader + BaseView
updateUIViewController).

Replace @published with a plain var + CurrentValueSubject so only
DamusVideoControlsView (which actually displays the timestamp) observes
the updates, via .onReceive.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
aboutSection was a computed property on ProfileView, so it was rebuilt
on every body evaluation — doing NDB lookups, followedByString() name
resolution, and CondensedProfilePicturesView construction each time.

Extract it into a standalone struct with @ObservedObject on profile and
followers only. scrollTracker is passed as a plain let (not observed)
since ProfilePicScrollEffect already handles scroll observation
independently. This prevents aboutSection from re-evaluating on
unrelated state changes (filter_state, share sheets, etc.).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@jb55
Copy link
Copy Markdown
Collaborator Author

jb55 commented Mar 4, 2026 via email

@jb55
Copy link
Copy Markdown
Collaborator Author

jb55 commented Mar 4, 2026

the commits are fine, we don't need to be so anal about this

Should we make the sign-off requirement optional in the contribution guidelines?

Yeah

@jb55
Copy link
Copy Markdown
Collaborator Author

jb55 commented Mar 5, 2026

apparently i pushed github/experimental-pr-3626 instead of jb55/opus-perf, not sure why there are two branches for this

@jb55
Copy link
Copy Markdown
Collaborator Author

jb55 commented Mar 5, 2026

the visual glitch on profile is definitely annoying, but i want to fix the architectural perf issues first and fix those later

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: 12

Caution

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

⚠️ Outside diff range comments (2)
damus/Features/Profile/Views/ProfileView.swift (1)

531-535: ⚠️ Potential issue | 🟠 Major

Don’t run profile lookups directly in body.

Line 533 and Line 534 perform synchronous lookups during render. If storage-backed, this can add scroll hitches on the main thread. Load once via async/background and bind cached state.

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/Profile/Views/ProfileView.swift` around lines 531 - 535, The
body currently performs synchronous lookups (try?
damus_state.profiles.lookup_lnurl and try? damus_state.profiles.lookup(id:))
which can block rendering; move these lookups out of body into asynchronous
cached state: add `@State/`@StateObject properties on ProfileView (e.g.,
lnurlState and ndbProfileState), kick off a Task or DispatchQueue.global() in
onAppear or an init async helper to call
damus_state.profiles.lookup_lnurl(profile.pubkey) and
damus_state.profiles.lookup(id: profile.pubkey) off the main thread, then assign
results back on the main actor; update the View to read from the cached
lnurlState and ndbProfileState instead of calling lookup* directly in body.
damus/Shared/Extensions/KFOptionSetter+.swift (1)

137-179: ⚠️ Potential issue | 🟡 Minor

Add docstrings for modified protocol entry points.

CustomImageProcessor.process(item:options:) and CustomCacheSerializer.image(with:options:) were modified but still lack /// docs. Please add short behavior/contract docs (especially around SVG handling and downsampling policy).

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/Extensions/KFOptionSetter`+.swift around lines 137 - 179, Add
/// docstrings to CustomImageProcessor.process(item:options:) and
CustomCacheSerializer.image(with:options:) describing their behavior and
contract: state that process handles ImageProcessItem.data by first checking for
SVG via magic bytes and UTF-8 conversion and rendering SVG to a raster image
(then scaling by options.scaleFactor), otherwise it downsamples using
KingfisherWrapper.downsampledImage to the configured downsampleSize; also note
that .image case delegates to DefaultImageProcessor (and is not expected to
run), that SVG detection avoids full String allocation for binary data, and
document threading/return guarantees (returns a KFCrossPlatformImage? and may be
nil on failure). Keep the docs concise and mention downsampling policy and any
assumptions about options.scaleFactor and original data.
♻️ Duplicate comments (1)
damus/Features/Events/EventMutingContainerView.swift (1)

54-58: ⚠️ Potential issue | 🔴 Critical

Fix inverted unmute condition (still unresolved).

Line 55 checks != nil inside .new_unmutes, which means “still muted.” That prevents truly unmuted events from being restored correctly.

🐛 Proposed fix
             .onReceive(handle_notify(.new_unmutes)) { unmutes in
-                if damus_state.mutelist_manager.event_muted_reason(event) != nil {
+                if damus_state.mutelist_manager.event_muted_reason(event) == nil {
                     shown = true
                     muted_reason = nil
                 }
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Events/EventMutingContainerView.swift` around lines 54 - 58,
The unmute handler in EventMutingContainerView is using the wrong condition:
inside the .onReceive(handle_notify(.new_unmutes)) block it checks
damus_state.mutelist_manager.event_muted_reason(event) != nil (meaning still
muted) before restoring state; change the check to == nil so you detect a truly
unmuted event and then set shown = true and muted_reason = nil; update the
condition in the onReceive closure that references event_muted_reason(event)
accordingly.
🧹 Nitpick comments (8)
damus/Shared/Extensions/OffsetExtension.swift (1)

48-50: Skip no-op offset updates with an early return guard.

This callback runs in a hot scrolling path; avoid firing onChange when value hasn’t changed.

Proposed refactor
 .onPreferenceChange(OffsetKey.self) { value in
+    guard value != storage.currentOffset else { return }
     storage.previousOffset = storage.currentOffset
     storage.currentOffset = value
     onChange(storage.previousOffset, storage.currentOffset)
 }

As per coding guidelines, "Ensure nevernesting: favor early returns and guard clauses over deeply nested conditionals; simplify control flow by exiting early instead of wrapping logic in multiple layers of if statements".

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

In `@damus/Shared/Extensions/OffsetExtension.swift` around lines 48 - 50, Add an
early-return guard to skip no-op offset updates: check if the incoming value
equals storage.currentOffset and return immediately if so, instead of proceeding
to set storage.previousOffset/currentOffset and call onChange; update the logic
around storage.previousOffset, storage.currentOffset and onChange to only run
when the value actually differs.
damus/Features/Timeline/Views/TimelineView.swift (1)

103-106: Document and extract the new scroll-queue toggle logic.

The new behavior added at Line 103–106 is important but inline and undocumented. Please move it into a small helper with a docstring so the intent is explicit and easier to maintain.

Proposed refactor
-                        let new_should_queue = -current > 0
-                        if self.events.should_queue != new_should_queue {
-                            self.events.set_should_queue(new_should_queue)
-                        }
+                        updateScrollQueueState(currentOffset: current)
/// Toggles event queueing based on timeline content offset.
/// Queueing is enabled once content has moved above the top boundary.
private func updateScrollQueueState(currentOffset: CGFloat) {
    let shouldQueue = -currentOffset > 0
    guard events.should_queue != shouldQueue else { return }
    events.set_should_queue(shouldQueue)
}

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/TimelineView.swift` around lines 103 - 106,
Extract the inline scroll-queue toggle logic into a private helper named
updateScrollQueueState(currentOffset: CGFloat) with a docstring explaining that
it toggles event queueing when the timeline content offset moves above the top
boundary; inside the helper compute let shouldQueue = -currentOffset > 0,
early-return if events.should_queue == shouldQueue, otherwise call
events.set_should_queue(shouldQueue); then replace the original inline block
(the lines using current and calling events.set_should_queue) with a call to
updateScrollQueueState(currentOffset: current).
damus/Features/Chat/ChatroomThreadView.swift (1)

14-15: Use min in PreferenceKey.reduce to match key semantics if multiple values are emitted.

♻️ Suggested adjustment
     static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
-        value = nextValue()
+        value = min(value, nextValue())
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Chat/ChatroomThreadView.swift` around lines 14 - 15, The
PreferenceKey.reduce implementation currently overwrites value with nextValue();
change it to take the minimum of the existing value and nextValue() to match the
key's semantics — update the static func reduce(value: inout CGFloat, nextValue:
() -> CGFloat) in ChatroomThreadView.swift to set value = min(value,
nextValue()) so multiple emitted values are correctly aggregated.
damus/Features/Events/EventMenu.swift (2)

39-64: Add doc comments for the UIKit bridge lifecycle/helpers.

makeUIView, updateUIView, and buildDeferredMenu are central to this integration and should be explicitly documented.

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/Events/EventMenu.swift` around lines 39 - 64, Add doc comments
to the UIKit bridge functions to explain their lifecycle roles and usage:
document makeUIView(context:) to describe creating and configuring the UIButton
(image, tint, showsMenuAsPrimaryAction, and initial menu),
updateUIView(_:context:) to state it refreshes the button's menu when state
changes, and buildDeferredMenu() to explain deferred menu construction and the
use of UIDeferredMenuElement to call
EventMenuButton.menuActions(damus_state:event:). Place concise Swift doc
comments above each of these functions mentioning threading/main-authority
expectations (if any) and parameter/return semantics.

63-170: Avoid maintaining two menu-action implementations.

EventMenuButton.menuActions now duplicates behavior that still exists in MenuItems, which raises drift risk over time. Consider extracting one shared action builder and reusing it from both paths.

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

In `@damus/Features/Events/EventMenu.swift` around lines 63 - 170, The menu action
logic in EventMenu.menuActions is duplicated in
EventMenuButton.menuActions/MenuItems; extract the common action-building code
into a single shared function (e.g., EventMenu.sharedActions(damus_state:event:)
or EventMenu.buildCommonMenuActions) and replace the duplicated implementations
in EventMenuButton.menuActions and MenuItems to call that shared function. Move
the code that constructs the actions array (all UIAction/UIMenu creation and
uses of damus_state, profileModel, isBookmarked, isMutedThread,
muteDurationMenu, etc.) into the new shared function, keep muteDurationMenu and
helper functions private to EventMenu, and ensure callers simply return or wrap
the shared result so behavior remains identical.
damus/Features/Profile/Models/ProfileModel.swift (1)

41-53: Document the follow-cache lifecycle.

Please add doc comments for contactPubkeys, follows(pubkey:), and handle_profile_contact_event(_:) to clarify when the cache is refreshed and authoritative.

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

Also applies to: 137-151

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

In `@damus/Features/Profile/Models/ProfileModel.swift` around lines 41 - 53, Add
doc comments for the contactPubkeys property, follows(pubkey:) method, and
handle_profile_contact_event(_:) method that describe the follow-cache
lifecycle: state that contactPubkeys is an in-memory cache of follows for this
ProfileModel, initialized empty and populated/updated only via events processed
by EventHolder (mention preload_events/profile contact events), that
follows(pubkey:) reads this cache as the authoritative source for current follow
membership until a relevant contact/profile event updates it, and that
handle_profile_contact_event(_:) is responsible for applying updates
(adds/removes/refreshes) and invalidating or replacing the cache as events
arrive; mention any threading/queue context (EventHolder queue) so callers know
when the cache is safe to read.
damus/Features/Profile/Views/ProfileView.swift (1)

68-70: Add docstrings for the new scroll-tracking architecture.

ScrollOffsetTracker plus the extracted scroll-driven subviews are substantial additions and should have doc comments for intent/contract.

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

Also applies to: 334-675

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

In `@damus/Features/Profile/Views/ProfileView.swift` around lines 68 - 70, Add
docstrings for the new scroll-tracking architecture: annotate the
ScrollOffsetTracker class with a brief purpose description, the contract for its
`@Published` yOffset (units, coordinate origin, expected update frequency),
ownership/lifecycle expectations (who instantiates and observes it), and
thread/update behavior; also add similar header comments to each extracted
scroll-driven subview (describe what scroll input they consume, expected yOffset
ranges, how they react to values, and any public API/initializers). Ensure
comments are concise, follow project docstring style, and cover intent/usage for
consumers and future maintainers.
damus/Shared/Extensions/KFOptionSetter+.swift (1)

112-126: SVG pre-check is broader than the documented signature check.

At Line 125, the helper returns true for any payload whose first non-whitespace byte is <. That still triggers full UTF-8 conversion for non-SVG XML/HTML and weakens the fast-reject intent. Consider checking for <svg / <?xml byte prefixes before String(data:, encoding:).

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

In `@damus/Shared/Extensions/KFOptionSetter`+.swift around lines 112 - 126, The
helper data_looks_like_svg currently returns true for any first non-whitespace
byte of '<', which is too broad; modify it to peek at the subsequent bytes
(safely checking bounds) and only return true when the byte sequence matches the
SVG signatures like "<svg" or "<?xml" (case-insensitive if desired) — e.g.,
after skipping whitespace/BOM in data_looks_like_svg, check for the byte
patterns for "<svg" and "<?xml" (ensure you don't read past limit or data.count)
and return true only on those matches, otherwise return false so non-SVG
XML/HTML is rejected quickly without attempting full UTF-8 conversion.
🤖 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/Chat/ChatroomThreadView.swift`:
- Around line 12-17: Add a doc comment for the new UntrustedSectionMinYKey
explaining its purpose (tracks the minimum Y coordinate of the "untrusted"
section in the ChatroomThreadView), what the defaultValue (.infinity) means, and
how the reduce(_:, nextValue:) implementation works (it assigns the latest
reported value). Put the doc comment immediately above the
UntrustedSectionMinYKey declaration and mention the key is a PreferenceKey used
by SwiftUI layout preference propagation so future readers understand why it
exists and how it is consumed.

In `@damus/Features/Events/EventMutingContainerView.swift`:
- Around line 25-39: Add doc comments for the two initializers and the innerBody
property/func: document the purpose of each initializer (one default when
MuteBox == EmptyView and one custom with muteBox), describe parameters
(damus_state: DamusState, event: NostrEvent, content: `@ViewBuilder` content, and
muteBox: (Binding<Bool>, MuteItem?) -> MuteBox), explain the behavior of shown
state initialization (uses should_show_event(state:ev:)), and note any semantics
of customMuteBox and how innerBody composes the view and uses the binding/Muted
state; ensure comments are concise, include parameter descriptions and any
important preconditions/side effects for innerBody, init(damus_state:event:
`@ViewBuilder` content:) and init(damus_state:event: muteBox: `@ViewBuilder`
content:).

In `@damus/Features/Events/EventView.swift`:
- Around line 34-35: Add a doc comment above the `@ViewBuilder` var body: some
View property in EventView describing the render contract: what the view
renders, when it updates, and any expectations for inputs or side effects (e.g.,
which state/props drive rendering and whether it can return different subviews
based on conditions). Update the comment to reference EventView's role so
readers know what the body is responsible for and any invariants callers can
rely on.

In `@damus/Features/Profile/Views/FriendIcon.swift`:
- Around line 45-56: The new private UIImage constant `_fofIcon` lacks the
required doc comment; add a docstring above `_fofIcon` matching the style and
content level used for `_friendIcon` describing what the icon represents
(friend-of-friend indicator), where/how it is used, and any relevant rendering
or sizing notes (e.g., pointSize/weight and tint behavior), so the declaration
for `_fofIcon` has the same docstring coverage and conventions as `_friendIcon`.

In `@damus/Features/Profile/Views/ProfileView.swift`:
- Around line 657-659: showFollowBtn() currently returns true for the signed-in
user's profile because it only checks follow_state and blurOpacity; update the
predicate to also exclude the current account by confirming profile.pubkey !=
damus_state.account?.pubkey (or the equivalent stored signed-in pubkey) so the
method becomes: return false when profile.pubkey matches the signed-in pubkey,
otherwise evaluate damus_state.contacts.follow_state(profile.pubkey) ==
.unfollows && blurOpacity() > 1.0; keep the same symbols: showFollowBtn(),
damus_state.contacts.follow_state(profile.pubkey), profile.pubkey,
damus_state.account?.pubkey, and blurOpacity().

In `@damus/Shared/Extensions/KFOptionSetter`+.swift:
- Around line 177-179: The image(with:data:options:) path currently always calls
KingfisherWrapper.downsampledImage which differs from process(item:)’s SVG
rendering; update image(with:data:options:) to detect SVG data (same check used
in process(item:), e.g., looksLikeSVG or checking data header) and for SVGs
render into a raster UIImage using UIGraphicsImageRenderer at downsampleSize and
options.scaleFactor (matching process(item:)), otherwise fall back to
KingfisherWrapper.downsampledImage; ensure you reuse the same downsampleSize,
options.scaleFactor, and the same rendering logic to keep disk-cache decoding
consistent with downloads.

In `@damus/Shared/Extensions/OffsetExtension.swift`:
- Around line 33-39: Add concise docstrings explaining the purpose and fields of
the newly introduced Storage class and the `@State` storage property: document
that Storage tracks the current and previous scroll/content offset used for
offset calculations (describe currentOffset and previousOffset semantics), and
annotate the `@State` private var storage to clarify it persists offset state for
the view's lifecycle and why it's kept as a reference type rather than value
type; attach these comments directly above the Storage class and above the
storage property (referencing Storage and storage) so future maintainers
understand their hot-path role.

In `@damus/Shared/Media/Video/DamusVideoControlsView.swift`:
- Around line 15-22: The new playback-state members lack documentation: add
concise doc comments for the `@State` property current_time and the computed
Binding currentTimeBinding in DamusVideoControlsView explaining their purpose
and contract (e.g., that current_time is the local UI scrubbing state mirroring
the player's time, and currentTimeBinding syncs changes to video.current_time
and updates current_time), describe threading/ownership expectations and when
they should be updated, and note that the binding must propagate user scrubs
back to video.current_time to keep state in sync.

In `@damus/Shared/Media/Video/DamusVideoPlayer.swift`:
- Around line 60-64: Add a docstring to the didSet observer for the stored
property current_time describing that manual writes must propagate to
currentTimeSubject to keep observers in sync, then update the current_time's
didSet to call currentTimeSubject.send(current_time) so manual scrubs
immediately notify subscribers; finally remove the now-redundant explicit
currentTimeSubject.send(...) at the periodic tick location (the manual send in
DamusVideoPlayer where the comment noted redundancy). Ensure you reference the
current_time property, its didSet observer, and currentTimeSubject when making
the changes.

In `@damus/Shared/Utilities/Router.swift`:
- Line 17: Add missing Swift docstrings for the modified route API surface: add
a triple-slash (///) documentation comment above the enum case
Following(contacts: NostrEvent) describing what the case represents and the
meaning of the contacts parameter, and add a triple-slash docstring above the
view(...) method explaining its purpose, parameters, return value, and any side
effects; ensure wording follows existing project doc style and includes
parameter names exactly as in Following(contacts: NostrEvent) and the view(...)
signature to maintain doc coverage.

In `@damus/Shared/Utilities/TimeAgo.swift`:
- Around line 10-16: Add a doc comment above the new shared formatter
_timeAgoFormatter describing its purpose (a shared DateComponentsFormatter for
short “time ago” strings), intended usage, the units it supports (second,
minute, hour, day, weekOfMonth, month, year), and any thread-safety or lifecycle
notes (e.g., it is a shared immutable formatter instance used across the
module). Reference the symbol name _timeAgoFormatter and the fact it wraps
DateComponentsFormatter so reviewers can see this is the shared helper/state the
docstring covers.
- Around line 10-16: The shared DateComponentsFormatter `_timeAgoFormatter` is
not documented and is unsafe for concurrent access from `time_ago_since`; add a
docstring above `_timeAgoFormatter` describing its purpose and thread-safety
strategy, then protect all accesses to the formatter (or replace it with
thread-local storage) — e.g., create a private serial DispatchQueue or use
thread-local storage per thread and ensure `time_ago_since` uses that
synchronized queue or thread-local formatter for all formatting calls; update
the docstring to note why synchronization/thread-local is required.

---

Outside diff comments:
In `@damus/Features/Profile/Views/ProfileView.swift`:
- Around line 531-535: The body currently performs synchronous lookups (try?
damus_state.profiles.lookup_lnurl and try? damus_state.profiles.lookup(id:))
which can block rendering; move these lookups out of body into asynchronous
cached state: add `@State/`@StateObject properties on ProfileView (e.g.,
lnurlState and ndbProfileState), kick off a Task or DispatchQueue.global() in
onAppear or an init async helper to call
damus_state.profiles.lookup_lnurl(profile.pubkey) and
damus_state.profiles.lookup(id: profile.pubkey) off the main thread, then assign
results back on the main actor; update the View to read from the cached
lnurlState and ndbProfileState instead of calling lookup* directly in body.

In `@damus/Shared/Extensions/KFOptionSetter`+.swift:
- Around line 137-179: Add /// docstrings to
CustomImageProcessor.process(item:options:) and
CustomCacheSerializer.image(with:options:) describing their behavior and
contract: state that process handles ImageProcessItem.data by first checking for
SVG via magic bytes and UTF-8 conversion and rendering SVG to a raster image
(then scaling by options.scaleFactor), otherwise it downsamples using
KingfisherWrapper.downsampledImage to the configured downsampleSize; also note
that .image case delegates to DefaultImageProcessor (and is not expected to
run), that SVG detection avoids full String allocation for binary data, and
document threading/return guarantees (returns a KFCrossPlatformImage? and may be
nil on failure). Keep the docs concise and mention downsampling policy and any
assumptions about options.scaleFactor and original data.

---

Duplicate comments:
In `@damus/Features/Events/EventMutingContainerView.swift`:
- Around line 54-58: The unmute handler in EventMutingContainerView is using the
wrong condition: inside the .onReceive(handle_notify(.new_unmutes)) block it
checks damus_state.mutelist_manager.event_muted_reason(event) != nil (meaning
still muted) before restoring state; change the check to == nil so you detect a
truly unmuted event and then set shown = true and muted_reason = nil; update the
condition in the onReceive closure that references event_muted_reason(event)
accordingly.

---

Nitpick comments:
In `@damus/Features/Chat/ChatroomThreadView.swift`:
- Around line 14-15: The PreferenceKey.reduce implementation currently
overwrites value with nextValue(); change it to take the minimum of the existing
value and nextValue() to match the key's semantics — update the static func
reduce(value: inout CGFloat, nextValue: () -> CGFloat) in
ChatroomThreadView.swift to set value = min(value, nextValue()) so multiple
emitted values are correctly aggregated.

In `@damus/Features/Events/EventMenu.swift`:
- Around line 39-64: Add doc comments to the UIKit bridge functions to explain
their lifecycle roles and usage: document makeUIView(context:) to describe
creating and configuring the UIButton (image, tint, showsMenuAsPrimaryAction,
and initial menu), updateUIView(_:context:) to state it refreshes the button's
menu when state changes, and buildDeferredMenu() to explain deferred menu
construction and the use of UIDeferredMenuElement to call
EventMenuButton.menuActions(damus_state:event:). Place concise Swift doc
comments above each of these functions mentioning threading/main-authority
expectations (if any) and parameter/return semantics.
- Around line 63-170: The menu action logic in EventMenu.menuActions is
duplicated in EventMenuButton.menuActions/MenuItems; extract the common
action-building code into a single shared function (e.g.,
EventMenu.sharedActions(damus_state:event:) or EventMenu.buildCommonMenuActions)
and replace the duplicated implementations in EventMenuButton.menuActions and
MenuItems to call that shared function. Move the code that constructs the
actions array (all UIAction/UIMenu creation and uses of damus_state,
profileModel, isBookmarked, isMutedThread, muteDurationMenu, etc.) into the new
shared function, keep muteDurationMenu and helper functions private to
EventMenu, and ensure callers simply return or wrap the shared result so
behavior remains identical.

In `@damus/Features/Profile/Models/ProfileModel.swift`:
- Around line 41-53: Add doc comments for the contactPubkeys property,
follows(pubkey:) method, and handle_profile_contact_event(_:) method that
describe the follow-cache lifecycle: state that contactPubkeys is an in-memory
cache of follows for this ProfileModel, initialized empty and populated/updated
only via events processed by EventHolder (mention preload_events/profile contact
events), that follows(pubkey:) reads this cache as the authoritative source for
current follow membership until a relevant contact/profile event updates it, and
that handle_profile_contact_event(_:) is responsible for applying updates
(adds/removes/refreshes) and invalidating or replacing the cache as events
arrive; mention any threading/queue context (EventHolder queue) so callers know
when the cache is safe to read.

In `@damus/Features/Profile/Views/ProfileView.swift`:
- Around line 68-70: Add docstrings for the new scroll-tracking architecture:
annotate the ScrollOffsetTracker class with a brief purpose description, the
contract for its `@Published` yOffset (units, coordinate origin, expected update
frequency), ownership/lifecycle expectations (who instantiates and observes it),
and thread/update behavior; also add similar header comments to each extracted
scroll-driven subview (describe what scroll input they consume, expected yOffset
ranges, how they react to values, and any public API/initializers). Ensure
comments are concise, follow project docstring style, and cover intent/usage for
consumers and future maintainers.

In `@damus/Features/Timeline/Views/TimelineView.swift`:
- Around line 103-106: Extract the inline scroll-queue toggle logic into a
private helper named updateScrollQueueState(currentOffset: CGFloat) with a
docstring explaining that it toggles event queueing when the timeline content
offset moves above the top boundary; inside the helper compute let shouldQueue =
-currentOffset > 0, early-return if events.should_queue == shouldQueue,
otherwise call events.set_should_queue(shouldQueue); then replace the original
inline block (the lines using current and calling events.set_should_queue) with
a call to updateScrollQueueState(currentOffset: current).

In `@damus/Shared/Extensions/KFOptionSetter`+.swift:
- Around line 112-126: The helper data_looks_like_svg currently returns true for
any first non-whitespace byte of '<', which is too broad; modify it to peek at
the subsequent bytes (safely checking bounds) and only return true when the byte
sequence matches the SVG signatures like "<svg" or "<?xml" (case-insensitive if
desired) — e.g., after skipping whitespace/BOM in data_looks_like_svg, check for
the byte patterns for "<svg" and "<?xml" (ensure you don't read past limit or
data.count) and return true only on those matches, otherwise return false so
non-SVG XML/HTML is rejected quickly without attempting full UTF-8 conversion.

In `@damus/Shared/Extensions/OffsetExtension.swift`:
- Around line 48-50: Add an early-return guard to skip no-op offset updates:
check if the incoming value equals storage.currentOffset and return immediately
if so, instead of proceeding to set storage.previousOffset/currentOffset and
call onChange; update the logic around storage.previousOffset,
storage.currentOffset and onChange to only run when the value actually differs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 68dc0ef8-6d81-4034-8b7e-189a1078861b

📥 Commits

Reviewing files that changed from the base of the PR and between 7dae63b and 4d0f061.

📒 Files selected for processing (18)
  • damus/Features/Actions/ActionBar/Views/EventActionBar.swift
  • damus/Features/Actions/Reposts/Views/RepostedEvent.swift
  • damus/Features/Chat/ChatroomThreadView.swift
  • damus/Features/Events/EventMenu.swift
  • damus/Features/Events/EventMutingContainerView.swift
  • damus/Features/Events/EventShell.swift
  • damus/Features/Events/EventView.swift
  • damus/Features/Profile/Models/ProfileModel.swift
  • damus/Features/Profile/Views/FriendIcon.swift
  • damus/Features/Profile/Views/ProfileView.swift
  • damus/Features/Timeline/Views/TimelineView.swift
  • damus/Shared/Extensions/KFOptionSetter+.swift
  • damus/Shared/Extensions/OffsetExtension.swift
  • damus/Shared/Media/Video/DamusVideoControlsView.swift
  • damus/Shared/Media/Video/DamusVideoPlayer.swift
  • damus/Shared/Utilities/LocalizationUtil.swift
  • damus/Shared/Utilities/Router.swift
  • damus/Shared/Utilities/TimeAgo.swift
🚧 Files skipped from review as they are similar to previous changes (4)
  • damus/Shared/Utilities/LocalizationUtil.swift
  • damus/Features/Events/EventShell.swift
  • damus/Features/Actions/ActionBar/Views/EventActionBar.swift
  • damus/Features/Actions/Reposts/Views/RepostedEvent.swift

Comment on lines +12 to +17
private struct UntrustedSectionMinYKey: PreferenceKey {
static var defaultValue: CGFloat = .infinity
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
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 doc comment for the new UntrustedSectionMinYKey.

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/Chat/ChatroomThreadView.swift` around lines 12 - 17, Add a doc
comment for the new UntrustedSectionMinYKey explaining its purpose (tracks the
minimum Y coordinate of the "untrusted" section in the ChatroomThreadView), what
the defaultValue (.infinity) means, and how the reduce(_:, nextValue:)
implementation works (it assigns the latest reported value). Put the doc comment
immediately above the UntrustedSectionMinYKey declaration and mention the key is
a PreferenceKey used by SwiftUI layout preference propagation so future readers
understand why it exists and how it is consumed.

Comment on lines +25 to 39
init(damus_state: DamusState, event: NostrEvent, @ViewBuilder content: () -> Content) where MuteBox == EmptyView {
self.damus_state = damus_state
self.event = event
self.content = content()
self.customMuteBox = nil
self._shown = State(initialValue: should_show_event(state: damus_state, ev: event))
}

init(damus_state: DamusState, event: NostrEvent, muteBox: @escaping MuteBoxViewClosure, @ViewBuilder content: () -> Content) {
self.init(damus_state: damus_state, event: event, content: content)

init(damus_state: DamusState, event: NostrEvent, muteBox: @escaping (Binding<Bool>, MuteItem?) -> MuteBox, @ViewBuilder content: () -> Content) {
self.damus_state = damus_state
self.event = event
self.content = content()
self.customMuteBox = muteBox
self._shown = State(initialValue: should_show_event(state: damus_state, ev: event))
}
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 doc comments for newly added initializers and innerBody.

These newly added/modified declarations are undocumented.

📝 Suggested docs
+    /// Creates a muting container that uses the default `EventMutedBoxView`.
     init(damus_state: DamusState, event: NostrEvent, `@ViewBuilder` content: () -> Content) where MuteBox == EmptyView {
         ...
     }

+    /// Creates a muting container with a caller-provided mute box view.
     init(damus_state: DamusState, event: NostrEvent, muteBox: `@escaping` (Binding<Bool>, MuteItem?) -> MuteBox, `@ViewBuilder` content: () -> Content) {
         ...
     }

+    /// Renders mute UI and content visibility based on current mute/show state.
     `@ViewBuilder`
     private var innerBody: some View {
         ...
     }

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

Also applies to: 62-63

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

In `@damus/Features/Events/EventMutingContainerView.swift` around lines 25 - 39,
Add doc comments for the two initializers and the innerBody property/func:
document the purpose of each initializer (one default when MuteBox == EmptyView
and one custom with muteBox), describe parameters (damus_state: DamusState,
event: NostrEvent, content: `@ViewBuilder` content, and muteBox: (Binding<Bool>,
MuteItem?) -> MuteBox), explain the behavior of shown state initialization (uses
should_show_event(state:ev:)), and note any semantics of customMuteBox and how
innerBody composes the view and uses the binding/Muted state; ensure comments
are concise, include parameter descriptions and any important preconditions/side
effects for innerBody, init(damus_state:event: `@ViewBuilder` content:) and
init(damus_state:event: muteBox: `@ViewBuilder` content:).

Comment on lines +34 to 35
@ViewBuilder
var body: some 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

Document the modified body render contract.

The modified body declaration is missing a doc comment.

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/Events/EventView.swift` around lines 34 - 35, Add a doc
comment above the `@ViewBuilder` var body: some View property in EventView
describing the render contract: what the view renders, when it updates, and any
expectations for inputs or side effects (e.g., which state/props drive rendering
and whether it can return different subviews based on conditions). Update the
comment to reference EventView's role so readers know what the body is
responsible for and any invariants callers can rely on.

Comment on lines +45 to +56
private let _fofIcon: UIImage = {
let size = CGSize(width: 21, height: 14)
let format = UIGraphicsImageRendererFormat()
format.scale = UIScreen.main.scale
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { _ in
let rect = CGRect(origin: .zero, size: size)
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .regular)
guard let symbol = UIImage(systemName: "person.fill.and.arrow.left.and.arrow.right", withConfiguration: config) else { return }
symbol.withTintColor(.gray, renderingMode: .alwaysOriginal).draw(in: rect)
}
}()
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 doc comments for _fofIcon to match the repo docstring rule.

_fofIcon is newly added/modified code but lacks coverage equivalent to _friendIcon.

Proposed fix
+/// Pre-rendered friend-of-friend icon to avoid per-frame symbol drawing.
+/// Rendered once into a UIImage and reused during cell rendering.
 private let _fofIcon: UIImage = {

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/Profile/Views/FriendIcon.swift` around lines 45 - 56, The new
private UIImage constant `_fofIcon` lacks the required doc comment; add a
docstring above `_fofIcon` matching the style and content level used for
`_friendIcon` describing what the icon represents (friend-of-friend indicator),
where/how it is used, and any relevant rendering or sizing notes (e.g.,
pointSize/weight and tint behavior), so the declaration for `_fofIcon` has the
same docstring coverage and conventions as `_friendIcon`.

Comment on lines +657 to +659
private func showFollowBtn() -> Bool {
damus_state.contacts.follow_state(profile.pubkey) == .unfollows && blurOpacity() > 1.0
}
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

Prevent follow CTA from appearing on your own profile.

showFollowBtn() should exclude the signed-in user; otherwise the collapsed toolbar can show a follow button for self.

💡 Suggested fix
     private func showFollowBtn() -> Bool {
-        damus_state.contacts.follow_state(profile.pubkey) == .unfollows && blurOpacity() > 1.0
+        profile.pubkey != damus_state.pubkey &&
+        damus_state.contacts.follow_state(profile.pubkey) == .unfollows &&
+        blurOpacity() > 1.0
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@damus/Features/Profile/Views/ProfileView.swift` around lines 657 - 659,
showFollowBtn() currently returns true for the signed-in user's profile because
it only checks follow_state and blurOpacity; update the predicate to also
exclude the current account by confirming profile.pubkey !=
damus_state.account?.pubkey (or the equivalent stored signed-in pubkey) so the
method becomes: return false when profile.pubkey matches the signed-in pubkey,
otherwise evaluate damus_state.contacts.follow_state(profile.pubkey) ==
.unfollows && blurOpacity() > 1.0; keep the same symbols: showFollowBtn(),
damus_state.contacts.follow_state(profile.pubkey), profile.pubkey,
damus_state.account?.pubkey, and blurOpacity().

Comment on lines +33 to +39
private class Storage {
var currentOffset: CGFloat = 0
var previousOffset: CGFloat = 0
}

@State private var storage = Storage()

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 docstrings for newly introduced storage state.

Storage and its role in offset tracking were added without documentation. Please add brief doc comments to keep this hot-path behavior maintainable and explicit.

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/Extensions/OffsetExtension.swift` around lines 33 - 39, Add
concise docstrings explaining the purpose and fields of the newly introduced
Storage class and the `@State` storage property: document that Storage tracks the
current and previous scroll/content offset used for offset calculations
(describe currentOffset and previousOffset semantics), and annotate the `@State`
private var storage to clarify it persists offset state for the view's lifecycle
and why it's kept as a reference type rather than value type; attach these
comments directly above the Storage class and above the storage property
(referencing Storage and storage) so future maintainers understand their
hot-path role.

Comment on lines +15 to +22
@State private var current_time: TimeInterval = .zero

private var currentTimeBinding: Binding<TimeInterval> {
Binding(
get: { current_time },
set: { video.current_time = $0; current_time = $0 }
)
}
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 doc comments for newly introduced playback-state members.

Line 15 and Line 17 introduce new state/binding members without doc comments, which makes the scrubbing/state-sync contract less clear for future edits.

📝 Suggested patch
-    `@State` private var current_time: TimeInterval = .zero
+    /// Local UI copy of playback time used to update controls without forcing full `DamusVideoPlayer` invalidation.
+    `@State` private var current_time: TimeInterval = .zero
 
+    /// Binding used by the scrubber to keep local state and `DamusVideoPlayer.current_time` in sync.
     private var currentTimeBinding: Binding<TimeInterval> {
         Binding(
             get: { current_time },
             set: { video.current_time = $0; current_time = $0 }
         )
     }

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/Media/Video/DamusVideoControlsView.swift` around lines 15 - 22,
The new playback-state members lack documentation: add concise doc comments for
the `@State` property current_time and the computed Binding currentTimeBinding in
DamusVideoControlsView explaining their purpose and contract (e.g., that
current_time is the local UI scrubbing state mirroring the player's time, and
currentTimeBinding syncs changes to video.current_time and updates
current_time), describe threading/ownership expectations and when they should be
updated, and note that the binding must propagate user scrubs back to
video.current_time to keep state in sync.

Comment on lines +60 to +64
var current_time: TimeInterval = .zero

/// Combine subject for views that need to observe current_time changes
/// without triggering objectWillChange on every tick.
let currentTimeSubject = CurrentValueSubject<TimeInterval, Never>(.zero)
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
set -euo pipefail

echo "Assignments to current_time:"
rg -nP --type=swift '\bcurrent_time\s*=' \
  damus/Shared/Media/Video/DamusVideoPlayer.swift \
  damus/Shared/Media/Video/DamusVideoControlsView.swift

echo
echo "Publish sites for currentTimeSubject:"
rg -nP --type=swift 'currentTimeSubject\.send\s*\(' \
  damus/Shared/Media/Video/DamusVideoPlayer.swift \
  damus/Shared/Media/Video/DamusVideoControlsView.swift

Repository: damus-io/damus

Length of output: 590


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== DamusVideoPlayer.swift around lines 60-64 (property definition) ==="
sed -n '55,70p' damus/Shared/Media/Video/DamusVideoPlayer.swift

echo
echo "=== DamusVideoPlayer.swift around lines 209-216 (periodic observer) ==="
sed -n '205,220p' damus/Shared/Media/Video/DamusVideoPlayer.swift

echo
echo "=== DamusVideoControlsView.swift around line 20 (scrubbing) ==="
sed -n '15,30p' damus/Shared/Media/Video/DamusVideoControlsView.swift

Repository: damus-io/damus

Length of output: 2230


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== All current_time assignments in entire codebase ==="
rg -n '\bcurrent_time\s*=' --type=swift damus/ | grep -v test | head -20

Repository: damus-io/damus

Length of output: 463


Add docstring to didSet observer for current_time and sync currentTimeSubject on manual writes.

The synchronization issue is confirmed. Direct writes to current_time at DamusVideoControlsView.swift:20 (scrubbing) do not notify currentTimeSubject until the next periodic tick at DamusVideoPlayer.swift:215. The proposed didSet solution is functionally correct, but it must include a docstring explaining the synchronization behavior to meet coding guidelines. Update the patch:

    var current_time: TimeInterval = .zero {
        didSet {
+           /// Synchronize currentTimeSubject whenever current_time is manually updated
+           /// (e.g., during scrubbing). The guard prevents redundant sends on no-op assignments.
            guard oldValue != current_time else { return }
            currentTimeSubject.send(current_time)
        }
    }

Also remove the now-redundant manual send at line 215.

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

In `@damus/Shared/Media/Video/DamusVideoPlayer.swift` around lines 60 - 64, Add a
docstring to the didSet observer for the stored property current_time describing
that manual writes must propagate to currentTimeSubject to keep observers in
sync, then update the current_time's didSet to call
currentTimeSubject.send(current_time) so manual scrubs immediately notify
subscribers; finally remove the now-redundant explicit
currentTimeSubject.send(...) at the periodic tick location (the manual send in
DamusVideoPlayer where the comment noted redundancy). Ensure you reference the
current_time property, its didSet observer, and currentTimeSubject when making
the changes.

case Relay(relay: RelayURL, showActionButtons: Binding<Bool>)
case RelayDetail(relay: RelayURL, metadata: RelayMetadata?)
case Following(following: FollowingModel)
case Following(contacts: NostrEvent)
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 docstrings for modified route API surface.

The modified Following route case (Line 17) and view(...) method (Line 63) are missing /// docstrings.

Suggested update
 enum Route: Hashable {
+    /// Destination for viewing followed contacts/hashtags extracted from a contacts event.
     case Following(contacts: NostrEvent)
@@
+    /// Builds the destination view for this route.
+    /// - Parameters:
+    ///   - navigationCoordinator: Coordinator used by auth/onboarding destinations.
+    ///   - damusState: Shared app state for destination construction.
+    /// - Returns: A type-erased destination view for NavigationStack.
     func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> AnyView {

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

Also applies to: 63-63

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

In `@damus/Shared/Utilities/Router.swift` at line 17, Add missing Swift docstrings
for the modified route API surface: add a triple-slash (///) documentation
comment above the enum case Following(contacts: NostrEvent) describing what the
case represents and the meaning of the contacts parameter, and add a
triple-slash docstring above the view(...) method explaining its purpose,
parameters, return value, and any side effects; ensure wording follows existing
project doc style and includes parameter names exactly as in Following(contacts:
NostrEvent) and the view(...) signature to maintain doc coverage.

Comment on lines +10 to +16
private let _timeAgoFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated
formatter.maximumUnitCount = 1
formatter.allowedUnits = [.second, .minute, .hour, .day, .weekOfMonth, .month, .year]
return formatter
}()
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 doc comments for the new shared formatter helper/state.

Newly added/modified symbols in this file should have docstring coverage per repo rules.

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/Utilities/TimeAgo.swift` around lines 10 - 16, Add a doc comment
above the new shared formatter _timeAgoFormatter describing its purpose (a
shared DateComponentsFormatter for short “time ago” strings), intended usage,
the units it supports (second, minute, hour, day, weekOfMonth, month, year), and
any thread-safety or lifecycle notes (e.g., it is a shared immutable formatter
instance used across the module). Reference the symbol name _timeAgoFormatter
and the fact it wraps DateComponentsFormatter so reviewers can see this is the
shared helper/state the docstring covers.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n damus/Shared/Utilities/TimeAgo.swift

Repository: damus-io/damus

Length of output: 2595


🌐 Web query:

DateComponentsFormatter thread-safe Apple documentation Foundation

💡 Result:

Apple doesn’t appear to explicitly state “DateComponentsFormatter is thread-safe” (or not) in a dedicated spot that’s easily accessible in the documentation archive. What Apple does document is broader guidance that applies to formatter-style objects:

  • In Apple’s Threading Programming Guide → Thread Safety Summary (Documentation Archive), NSDateFormatter and NSFormatter are listed among the “generally considered to be thread-safe” Foundation classes, and Apple reiterates the general rule that mutable objects are generally not thread-safe and must be synchronized if mutated concurrently. [1]
  • In Apple’s WWDC 2010 performance slides, Apple explicitly warns: “Date and number formatters are not thread-safe.” [2]

Practical takeaway for DateComponentsFormatter (Foundation): don’t share and mutate a single DateComponentsFormatter instance concurrently across threads. If you need cross-thread use, either (a) create separate instances per thread/queue, or (b) serialize access (e.g., a lock or a dedicated queue), especially if you change its properties (allowedUnits, unitsStyle, etc.). [1][2]

Sources:
[1] Apple Documentation Archive — Thread Safety Summary (Threading Programming Guide)
[2] Apple WWDC 2010 Session 147 slides — Advanced Performance Optimization on iPhone OS, Part 2 (warning that date/number formatters are not thread-safe)


🏁 Script executed:

rg "time_ago_since" --type swift -B 2 -A 2 | head -100

Repository: damus-io/damus

Length of output: 3303


🏁 Script executed:

# Check the actual usage context - look for dispatch, async/await patterns around calls
rg "time_ago_since" --type swift -B 5 -A 2 | head -150

Repository: damus-io/damus

Length of output: 3853


Add documentation and synchronize access to the shared formatter.

DateComponentsFormatter is not thread-safe for concurrent access (confirmed by Apple WWDC 2010 guidance). The shared mutable instance _timeAgoFormatter lacks synchronization and documentation, creating a data race risk when time_ago_since is called from multiple threads (as can happen with SwiftUI views rendering from different contexts). Additionally, the variable lacks a docstring required by coding guidelines.

Recommended fix: either (1) use thread-local storage per the suggested approach, or (2) synchronize access with a lock or dedicated DispatchQueue. Also add a docstring explaining the formatter's purpose and thread-safety handling.

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

In `@damus/Shared/Utilities/TimeAgo.swift` around lines 10 - 16, The shared
DateComponentsFormatter `_timeAgoFormatter` is not documented and is unsafe for
concurrent access from `time_ago_since`; add a docstring above
`_timeAgoFormatter` describing its purpose and thread-safety strategy, then
protect all accesses to the formatter (or replace it with thread-local storage)
— e.g., create a private serial DispatchQueue or use thread-local storage per
thread and ensure `time_ago_since` uses that synchronized queue or thread-local
formatter for all formatting calls; update the docstring to note why
synchronization/thread-local is required.

@danieldaquino
Copy link
Copy Markdown
Collaborator

the visual glitch on profile is definitely annoying, but i want to fix the architectural perf issues first and fix those later

Have you had a chance to fix these? Please let me know how to proceed with this PR.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants