Skip to content

Vine proof of concept view + upload/record - #3354

Closed
alltheseas wants to merge 7 commits into
damus-io:masterfrom
alltheseas:vine-phase1
Closed

Vine proof of concept view + upload/record#3354
alltheseas wants to merge 7 commits into
damus-io:masterfrom
alltheseas:vine-phase1

Conversation

@alltheseas

@alltheseas alltheseas commented Nov 29, 2025

Copy link
Copy Markdown
Collaborator

Summary

Adds a Vine short-video viewer to Damus as an experimental Labs feature. Users can browse archived Vine videos served over Nostr (NIP-71 kind 34236) from the Divine relay, with full-screen playback, prefetch, pagination, and reporting.

Commits

  • eef7731d Add Vine video support: relay, parser, feed, and tab — Recognize vine_short Nostr events, implement VineFeedModel with relay subscription, parse VineVideo from event tags, handle reposts, add dedicated Vine tab
  • 79e08a66 Add Vine UI: full-screen pager, prefetch, pagination, and reporting — Full-screen vertical pager, network-aware video prefetch, cursor-based pagination, EventActionBar, reporting menu, Labs gate
  • c53a7656 Harden Vine: fix data races, strip GPS metadata, transcode imports — Replace locks with actors, fix async blocking, strip GPS from video attachments, task cancellation/timeout, fixture tests
  • 11851dbc Fix Vine bugs: relay ownership, threading, and test isolation — Relay ownership tracking, move state mutation out of SwiftUI body, video metadata off main thread, actor isolation fixes
  • 204438f9 Refactor Vine into separate files and address review feedback — Extract types from PostingTimelineView, @unchecked Sendable, @mainactor over NSLock, remove vine relay from bootstrap, docstrings, accessibility, unit tests
  • 6861eb9a Align VineVideo parser with NIP-71 spec — Per-variant imeta parsing, equal url/fallback weighting, remove non-spec tag extraction, add segment/text-track/bitrate support

Key design decisions

  • NIP-71 compliant: Each imeta tag parsed as a separate media variant; url and fallback weighted equally per spec; segment, text-track, and bitrate supported
  • Labs-gated: Vine tab only appears when enabled in Settings > Labs
  • On-demand relay: Divine relay connects via ensureRelayConnected when the Vine tab opens; removed from bootstrap list so non-Vine users are unaffected
  • Prefetch: Network-aware (respects cellular/constrained paths), with task lifecycle management and cancellation
  • GPS stripping: All video attachments have EXIF location metadata removed before upload
  • Thread safety: VineVideo is @unchecked Sendable (documented justification), NostrNetworkManager uses @MainActor isolation instead of NSLock

TODO

Checklist

Experimental Feature Checklist

  • I have read (or I am familiar with) the Contribution Guidelines.
  • I have done some testing on the changes in this PR to ensure it is at least functional.
  • I made sure that this new feature is only available when the user opts-in from the Damus Labs screen, and does not affect the rest of the app when turned off.
  • My PR is either small, or I have split it into smaller logical commits that are easier to review.
  • I have added the signoff line to all my commits. See Signing off your work.
  • I have added an appropriate changelog entry to my commit in this PR. See Adding changelog entries.
    • Example changelog entry: Changelog-Added: Added experimental feature <X> to Damus Labs

Test plan

  • Enable Vine in Settings > Labs, verify tab appears
  • Open Vine tab, verify videos load from Divine relay
  • Pull to refresh, verify feed reloads
  • Tap a card to open full-screen pager, swipe between videos
  • Verify prefetch works on Wi-Fi, respects cellular setting
  • Disable Vine in Settings > Labs, verify tab disappears and relay disconnects
  • Run damusTests — all VineVideo and VineFeedModel tests pass

@alltheseas alltheseas changed the title Vine phase1 Vine proof of concept view only Nov 29, 2025
@alltheseas

Copy link
Copy Markdown
Collaborator Author

WIP view only vine proof of concept screenshot

Screenshot 2025-11-29 at 3 24 53 PM

@alltheseas

Copy link
Copy Markdown
Collaborator Author

Tinkering with addition and publication of vines/videos. Its not ready just yet

@alltheseas

alltheseas commented Dec 1, 2025

Copy link
Copy Markdown
Collaborator Author

e054012 allows for publication of vines

Vine posted via iOS damus

https://damus.io/nevent1qqsr4tj4mk5ygaeryqdsqpxdfxvqtu665evjy5uczj3xmnke45leq8gg8p97g

@alltheseas alltheseas changed the title Vine proof of concept view only Vine proof of concept view + upload/record Dec 1, 2025
@danieldaquino danieldaquino added the pr-in-queue This PR is waiting in a queue behind their other PRs marked with the label `pr-active-review`. label Dec 1, 2025
@alltheseas

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Feb 10, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

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

@coderabbitai

coderabbitai Bot commented Feb 10, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds Vine short‑video support: new Nostr kind (34236), Vine UI (timeline, cards, full‑screen pager), feed model with relay subscription and prefetching, Vine composer and upload flow, async video processing (metadata stripping), feature toggles/settings, and feature‑managed relay controls.

Changes

Cohort / File(s) Summary
Asset Catalog
damus/Assets.xcassets/iconography/vine.imageset/Contents.json, damus/Assets.xcassets/iconography/vine.fill.imageset/Contents.json
Added vine icon assets (1x/2x/3x) with template rendering intent.
Nostr kinds & Relay URL
damus/Core/Nostr/NostrKind.swift, damus/Core/Nostr/RelayURL.swift
Added enum case vine_short = 34236 and RelayURL.vineRelay = wss://relay.divine.video.
Network manager (feature-managed relays)
damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift
New featureManagedRelays set plus ensureRelayConnected(_:) and disconnectRelay(_:) to connect/disconnect feature-driven relays.
Settings & Labs toggles
damus/Features/Settings/Models/UserSettingsStore.swift, damus/Features/Labs/Views/DamusLabsExperiments.swift
Added enable_vine_feature, enable_vine_relay, prefetch_vines_on_cellular, computed vines_feature_enabled, and Labs UI toggles/explainers.
Timeline/navigation
damus/ContentView.swift, damus/Features/Timeline/Views/MainTabView.swift, damus/Features/Timeline/Views/SideMenuView.swift
Introduced .vines timeline, immersiveTimeline gating, conditional tab rendering (vines in tab bar when enabled), and Messages button in side menu.
Vine UI components
damus/Features/Vines/Views/VineTimelineView.swift, .../VineCard.swift, .../VineFullScreenPage.swift, .../VineFullScreenPager.swift, .../VineMetadataRow.swift
New SwiftUI views: Vine feed, card, full‑screen page/pager, and metadata row.
Vine models & feed
damus/Features/Vines/Models/VineVideo.swift, damus/Features/Vines/Models/VineFeedModel.swift
New public VineVideo (parsing/prioritization/metadata) and VineFeedModel (subscribe, paginate, dedupe, prefetch, network‑aware behavior).
Vine creation & upload
damus/Features/Vines/Creation/VineComposerView.swift
New VineComposerView with media selection, optional MP4 conversion, upload to VineBlossom service, and event building/publishing.
Media processing & async changes
damus/Shared/Media/Images/ImageProcessing.swift, damus/Shared/Media/Models/MediaPicker.swift, share extension/ShareViewController.swift
Converted video processing and generateMediaUpload to async; added exportVideoStrippingSensitiveMetadata; updated media picker and share extension fallback APIs for async flows.
Posting & profile upload flows (async)
damus/Features/Posting/Views/PostView.swift, damus/Features/Profile/Views/EditPictureControl.swift
Updated call sites to await async media generation; made profile upload function async and invoked via Task.
Relays UI
damus/Features/Relays/Views/UserRelaysView.swift
Added "Divine Relay" toggle bound to settings and wired to async connect/disconnect via nostrNetwork.
Timeline view refactor
damus/Features/Timeline/Views/PostingTimelineView.swift
Refactored body into reusable timelineBody computed property.
Home model & events
damus/Features/Timeline/Models/HomeModel.swift, damus/Features/Events/Models/LoadableNostrEventView.swift
HomeModel recognizes vine_short in event processing (no-op); LoadableNostrEventView treats vine_short as unknown/unsupported.
Tests & fixtures
damusTests/Fixtures/VineFixtures.swift, damusTests/NostrEventTests.swift
Added vine fixtures and extensive tests for VineVideo and VineFeedModel behaviors.
Project files & misc
damus.xcodeproj/project.pbxproj, .beads/issues.jsonl, .beads/metadata.json, AGENTS.md, damus/Features/Follows/Models/Contacts.swift
Added VideoCache.swift to project references; renamed package ref to secp256k1.swift; added issue metadata file; expanded AGENTS.md; minor iteration style tweak in Contacts.

Sequence Diagrams

sequenceDiagram
    actor User
    participant ContentView
    participant VineTimelineView
    participant VineFeedModel
    participant NostrNetworkManager
    participant RelayPool
    participant Divine_Relay as "Divine Relay"

    User->>ContentView: Select Vines timeline
    ContentView->>VineTimelineView: init(damus_state)
    VineTimelineView->>VineFeedModel: create & subscribe()
    VineFeedModel->>NostrNetworkManager: ensureRelayConnected(vineRelay)
    NostrNetworkManager->>NostrNetworkManager: add to featureManagedRelays
    NostrNetworkManager->>RelayPool: connect to vineRelay
    RelayPool->>Divine_Relay: establish connection
    Divine_Relay-->>RelayPool: stream events (kind 34236)
    RelayPool-->>VineFeedModel: deliver events
    VineFeedModel->>VineFeedModel: dedupe, order, paginate
    VineFeedModel-->>VineTimelineView: update vines list
    VineTimelineView-->>User: display feed
Loading
sequenceDiagram
    actor User
    participant VineComposerView
    participant MediaPicker
    participant ImageProcessing
    participant VineBlossomUploadService
    participant VineEventBuilder
    participant NostrNetwork

    User->>VineComposerView: pick media
    VineComposerView->>MediaPicker: present
    MediaPicker-->>VineComposerView: picked URL
    VineComposerView->>ImageProcessing: generateMediaUpload (async)
    ImageProcessing->>ImageProcessing: processVideo async
    ImageProcessing->>ImageProcessing: exportVideoStrippingSensitiveMetadata
    ImageProcessing-->>VineComposerView: sanitized media URL
    VineComposerView->>VineBlossomUploadService: upload video
    VineBlossomUploadService-->>VineComposerView: upload response
    VineComposerView->>VineEventBuilder: build event
    VineEventBuilder-->>VineComposerView: signed event
    VineComposerView->>NostrNetwork: publish event
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

technical, relay, pr-active-review

Suggested reviewers

  • danieldaquino
  • jb55

Poem

🐰 I found a vine that sings in bytes,
Tiny clips that chase the nights,
Relays hum and caches grow,
Prefetch, upload, off they go —
Hooray — the vine garden's right! 🌿🎬

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description covers objectives, commits, design decisions, and key features comprehensively. However, it is missing required sections from the Experimental Feature Checklist template: signoff line status and changelog entry status are incomplete. Clarify whether commit signoffs and changelog entries have been added. Update the PR description to confirm all Experimental Feature Checklist items are complete or explicitly note any pending items with justification.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Vine proof of concept view + upload/record' accurately reflects the main changes: adding a Vine short-video viewer and upload/recording capabilities as an experimental feature.

✏️ 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

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 13

🤖 Fix all issues with AI agents
In `@damus/ContentView.swift`:
- Around line 166-170: Remove the in-body DispatchQueue.main.async state
mutation that checks selected_timeline and damus.settings.vines_feature_enabled
(the if block that sets self.selected_timeline = .home) and instead add an
.onChange modifier on the top-level view in MainContent's body that observes
either damus.settings.vines_feature_enabled or selected_timeline (or both) and
performs the timeline fallback there; reference the selected_timeline `@State` and
damus.settings.vines_feature_enabled within the onChange closure to set
selected_timeline = .home when vines are disabled and selected_timeline ==
.vines to avoid mutating state during body evaluation.

In `@damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift`:
- Around line 142-160: disconnectRelay currently removes any relay present in
pool (via pool.get_relay and pool.remove_relay) which can delete user-configured
relays; add ownership tracking in NostrNetworkManager by introducing a private
Set<RelayURL> (e.g., featureManagedRelays) and update ensureRelayConnected(_:
RelayURL) to insert the relay into featureManagedRelays when you call
pool.add_relay, then change disconnectRelay(_: RelayURL) to only call
pool.remove_relay if the relay is in featureManagedRelays and remove it from
that set after successful removal; reference the existing methods
ensureRelayConnected, disconnectRelay, pool.get_relay, pool.add_relay,
pool.remove_relay and the PostingTimelineView managedRelayConnection to locate
related behavior.

In `@damus/Features/Labs/Views/DamusLabsExperiments.swift`:
- Around line 16-17: Make the two SwiftUI `@State` properties private to satisfy
SwiftLint's private_swiftui_state rule: change the declarations of
show_vines_explainer and show_vine_prefetch_explainer in the
DamusLabsExperiments view to be private (e.g., `@State` private var ...), and
update any internal references within that view accordingly so external code is
not relying on those properties.

In `@damus/Features/Relays/Views/UserRelaysView.swift`:
- Around line 30-50: Wrap the "Divine Relay" Section containing Toggle(...)
(which uses state.settings.enable_vine_relay and setDivineRelayEnabled) in a
conditional check so it only renders when the Vines feature flag is enabled
(state.settings.vines_feature_enabled); i.e., in UserRelaysView guard the
Section with if state.settings.vines_feature_enabled { ... } so the toggle is
hidden for users who haven't enabled Vines.

In `@damus/Features/Timeline/Views/PostingTimelineView.swift`:
- Around line 889-898: The function normalizedURL(_:) silently rewrites
"apt.openvine.co" to "api.openvine.co", which is fragile; update the
implementation by either removing the hardcoded replacement and relying on
upstream data fixes, or if keeping it for backward compatibility, add a clear
comment above normalizedURL(_:) explaining why this specific replacement exists,
how long it should be retained, and referencing the upstream bug/relay/publisher
issue so future maintainers can remove it once fixed; ensure the rest of
normalizedURL(_:)'s behavior (trimming, URL(string:), and scheme check) remains
unchanged.
- Around line 181-572: The file contains seven Vine-related types bundled into
PostingTimelineView.swift which makes it unmaintainable; split them into focused
files under damus/Features/Vines/: create VineTimelineView.swift (containing
VineTimelineView), VineFeedModel.swift (VineFeedModel), VineVideo.swift
(VineVideo model), VineCard.swift (VineCard and VineMetadataRow), and
VineFullScreen.swift (VineFullScreenPager and VineFullScreenPage), move each
type into its new file preserving their implementation and imports, adjust
access levels (private/internal/public) as needed so PostingTimelineView still
compiles, update any references/usages (e.g., VineTimelineView initializer and
VineFeedModel usage) and run a build to fix import or visibility errors.
- Around line 336-350: noteAppeared(at:) currently starts Task.detached that
reads self.vines (a `@Published` main-actor property) off the main actor causing a
data race; fix by collecting the target playback URLs on the main actor before
creating the detached task: inside noteAppeared(at:) (while still on `@MainActor`)
compute targets = [index, index+1], read
damus_state.settings.prefetch_vines_on_cellular into allowCellular, iterate
targets to build an array of non-nil URLs from self.vines[target].playbackURL,
then start Task.detached { await self?.prefetch(url: url, allowCellular:
allowCellular) } iterating the pre-collected URLs so the detached task no longer
accesses vines directly.
- Around line 529-542: The prefetch(url:allowCellular:) function issues a
URLSession.data request and discards the result, relying on server HTTP caching
which is not reliable; change prefetch to write the downloaded data into the
app's disk video cache using VideoCache.standard (use its API such as
maybe_cached_url_for(video_url:) or the appropriate write method) so prefetched
content is persisted with the existing 1-day expiry; update prefetch to fetch
the data, store it into VideoCache.standard for the given URL, and still call
unmarkPrefetching(url) in the same finally/cleanup path to avoid leaving entries
marked prefetched.

In `@damus/Features/Vines/Creation/VineComposerView.swift`:
- Around line 224-260: The call to videoMetadata(for:) runs on the `@MainActor` in
uploadSelectedMedia and blocks the main thread; move the metadata extraction
into the background Task (or a new Task.detached) so metadata is computed
off-main before uploadService.uploadVideo is awaited. Concretely, keep the
keypair check and uploadPhase changes on the main actor, but compute let
metadata = videoMetadata(for: media.localURL) inside the Task.detached (before
calling uploadService.uploadVideo), then pass that metadata into
makeDescriptor/response handling and back to MainActor to set mediaDescriptor,
vineIdentifier and uploadPhase.

In `@damus/Shared/Media/Images/ImageProcessing.swift`:
- Around line 43-54: processVideo currently falls back to
saveVideoToTemporaryFolder when exportVideoStrippingSensitiveMetadata fails,
which can return the original file with GPS metadata and violates the "never
leak location data" contract; change processVideo to stop silently copying raw
videos and instead return nil (or propagate an error) when
exportVideoStrippingSensitiveMetadata fails so callers know sanitization failed,
or implement a reliable secondary metadata-strip step before any fallback—update
processVideo, exportVideoStrippingSensitiveMetadata usage, and calling code to
handle a nil/error return rather than assuming the returned URL is sanitized.
- Around line 163-189: The exportVideoStrippingSensitiveMetadata function blocks
the calling thread with DispatchSemaphore.wait, causing UI freezes; make it
asynchronous and non-blocking by replacing the semaphore pattern with Swift
concurrency (e.g., withCheckedContinuation or an async wrapper around
AVAssetExportSession) so exportVideoStrippingSensitiveMetadata returns
asynchronously (async -> Bool) and callers (processVideo and
generateMediaUpload) are updated to async; then ensure EditPictureControl.upload
and the PostView button action invoke the async pipeline off the main thread
(wrap in Task { await ... } or propagate async up the call chain). Also add a
cancellable timeout inside the async export wrapper to call
exportSession.cancelExport() on timeout and return false as a safe fallback.

In `@damusTests/NostrEventTests.swift`:
- Around line 125-131: The test named testExpiredVineIsSkipped is misleading
because it creates an expired event via makeVineEvent(tags:
VineFixtures.expired) and then asserts VineVideo(event: expired) is non-nil and
checks expirationTimestamp; either rename the test to reflect that it verifies
parsing (suggested name: testExpiredVineParsesExpirationTimestamp) or change the
assertions to verify filtering behavior by invoking the feed/filtering function
(where expired items are dropped) and asserting the VineVideo is excluded;
update references to the test name and adjust assertions accordingly (look for
testExpiredVineIsSkipped, makeVineEvent, VineFixtures.expired, VineVideo, and
expirationTimestamp).
- Around line 101-123: The two tests (testReplacementKeepsNewestEvent and
testReplacementKeepsOldestWhenOlder) call the actor VineTestFeed from outside
its isolation; make each test async and add await when invoking feed.apply(...)
and when reading feed.vines (including feed.vines.first?.title) so all accesses
to the VineTestFeed actor (apply and vines) are awaited to satisfy actor
isolation under strict concurrency.
🧹 Nitpick comments (18)
damus/Features/Follows/Models/Contacts.swift (2)

17-24: Pre-existing: event and delegate are not guarded by the new lock.

These properties can be read/written from multiple threads and aren't protected. This is outside the scope of the current change (the lock targets friends/friend_of_friends/pubkey_to_our_friends), but worth noting for a future pass.


40-50: Missing docstrings on modified methods.

Per coding guidelines, added or modified code should have docstring coverage. Most of the lock-guarded methods (e.g., get_friend_list, get_friend_of_friends_list, add_friend_pubkey, is_friend, etc.) lack documentation. The names are self-documenting, but a brief note on thread-safety guarantees would be helpful for future contributors.

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

damus/Shared/Media/Images/ImageProcessing.swift (1)

98-100: .processed_video is now re-processed — naming is misleading.

The processed_video case name implies the video has already been fully processed, yet it now goes through processVideo (re-encode + metadata strip). This adds non-trivial latency (full re-encode at AVAssetExportPresetHighestQuality) for videos that may have already been transcoded.

If the intent is that all videos must be sanitized before upload regardless, consider renaming the enum case to clarify that it hasn't been metadata-stripped yet, or documenting why double-processing is acceptable.

damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift (1)

148-150: Silently swallowed error on add_relay — consider logging.

try? discards any failure from add_relay. While the only current error is RelayAlreadyExists (benign here given the TOCTOU race with the guard above), silently swallowing it makes debugging harder. A do/catch that logs unexpected errors would be more robust.

Proposed improvement
-        try? await pool.add_relay(descriptor)
-        await pool.connect(to: [relayURL])
+        do {
+            try await pool.add_relay(descriptor)
+            await pool.connect(to: [relayURL])
+        } catch RelayError.RelayAlreadyExists {
+            // Already present — just ensure it's connected
+            await pool.connect(to: [relayURL])
+        } catch {
+            Log.warning("Failed to add relay %@: %@", for: .relay_connectivity, relayURL.absoluteString, error.localizedDescription)
+        }
damusTests/Fixtures/VineFixtures.swift (1)

10-77: Fixture file looks solid with good scenario coverage.

Covers classic import, multi-imeta fallback, replacement, muted, expired, and repost scenarios — nice breadth for a POC.

Minor: per coding guidelines, consider adding a brief docstring to the enum and each fixture describing the scenario it represents, to help future contributors understand the test intent at a glance. As per coding guidelines, **/*.swift: "Ensure docstring coverage for any code added or modified."

damus/Features/Timeline/Models/HomeModel.swift (1)

645-647: Unstructured fire-and-forget tasks lose event ordering guarantees.

Spawning an independent Task { @mainactor in … } per event means the main-actor executor can reorder completions relative to arrival order. For the home timeline this is likely acceptable because EventHolder.insert sorts by timestamp, but it's worth noting that any logic in process_event that depends on sequential delivery (e.g. dedup via already_reposted) could see subtle races when two events for the same repost target arrive back-to-back.

If ordering becomes an issue, consider an AsyncStream-based serial queue feeding the main actor instead of per-event tasks.

damus/Features/Timeline/Views/MainTabView.swift (1)

82-86: Keyboard shortcuts have a gap when Vines is disabled.

When vines_feature_enabled is false, the tab bar renders Home (⌘1), Search (⌘3), Notifications (⌘4) — skipping ⌘2. Consider assigning shortcuts dynamically or using sequential values to avoid confusing keyboard/iPad users.

damus/Features/Relays/Views/UserRelaysView.swift (1)

56-65: Missing docstring on setDivineRelayEnabled.

As per coding guidelines, added or modified code should have docstring coverage.

Proposed fix
+    /// Updates the Divine Relay setting and connects or disconnects the relay accordingly.
     private func setDivineRelayEnabled(_ enabled: Bool) {
damus/ContentView.swift (2)

165-165: immersiveTimeline is computed identically in both MainContent and body.

Consider extracting this into a computed property on ContentView to avoid duplication.

Proposed refactor
+    private var immersiveTimeline: Bool {
+        selected_timeline == .home || selected_timeline == .vines
+    }

Then replace the local let immersiveTimeline = … in both MainContent and body.

Also applies to: 249-249


185-190: Redundant fallback for .vines when feature is disabled.

The guard on line 166 already redirects to .home when vines is disabled, so the else branch rendering PostingTimelineView should be unreachable in steady state. If you move the guard to .onChange as suggested above, consider simplifying this to only render VineTimelineView (since the tab won't be selectable when disabled).

damusTests/NostrEventTests.swift (1)

150-169: Consider whether VineTestFeed needs to be an actor.

Since the synchronous test helpers (testReplacementKeeps…) don't benefit from actor isolation, and the only async test (testMutedAuthorFiltered) could work with a simple class, a plain class with @MainActor isolation (or no isolation) would simplify the test code and avoid the await ceremony. The production VineFeedModel.handle(event:) already runs on @MainActor.

damus/Features/Settings/Models/UserSettingsStore.swift (1)

115-148: Missing docstrings on new Vine settings.

The coding guidelines require docstring coverage for added code. The three new @Setting properties and the vines_feature_enabled computed property lack documentation. Other settings in this file (e.g., nozaps, undistractMode, enable_experimental_local_relay_model) set a good precedent with /// doc comments.

Suggested docstrings
+    /// Whether the Vine feature is enabled by the user (Damus Labs experiment).
     `@Setting`(key: "enable_vine_feature", default_value: false)
     var enable_vine_feature: Bool
     
+    /// Whether the app should connect to the Divine relay for Vine video content.
     `@Setting`(key: "enable_vine_relay", default_value: true)
     var enable_vine_relay: Bool
     ...
+    /// Whether to allow prefetching Vine videos on cellular/expensive network connections.
     `@Setting`(key: "prefetch_vines_on_cellular", default_value: false)
     var prefetch_vines_on_cellular: Bool
     
+    /// Resolved flag: always `true` in DEBUG builds; otherwise defers to `enable_vine_feature`.
     var vines_feature_enabled: Bool {

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

damus/Features/Vines/Creation/VineComposerView.swift (4)

12-28: Manual Equatable conformance is unnecessary.

Swift can auto-synthesize Equatable for enums whose associated values are themselves Equatable. Since String is Equatable, this entire == (lhs:rhs:) implementation can be removed by simply declaring enum UploadPhase: Equatable.


173-173: .autocapitalization(.none) is deprecated.

Use .textInputAutocapitalization(.never) instead, which is the non-deprecated replacement available since iOS 15.


330-357: publishVine() dismisses immediately without confirming the post was dispatched.

notify(.post(.post(post))) is fire-and-forget; dismiss() is called right after. If there's any async processing downstream that could fail, the user won't see an error. Acceptable for a POC, but worth noting for future hardening — consider awaiting confirmation or at minimum adding a brief delay / completion callback before dismissing.


262-271: Replace deprecated AVURLAsset APIs with async alternatives to prevent main-thread blocking.

asset.tracks(withMediaType:) and asset.duration were deprecated in iOS 16 in favor of async APIs: try await asset.loadTracks(withMediaType:) and try await asset.load(.duration). The function is currently called on the main thread (line 233) and should be converted to async, allowing the call site to properly await it within the existing Task.detached block.

damus/Features/Timeline/Views/PostingTimelineView.swift (2)

415-426: O(n log n) sort on every incoming event during streaming.

vines.sort(...) is called for each event received via handle(event:). With the initial filter.limit = 200, this means up to 200 sorts on a growing array. Consider using binary-search insertion (Array.insert(at:)) to maintain sort order in O(log n) per event, or batch events and sort once at EOSE.

Example: binary insertion
// Replace the sort with an insertion at the correct position:
let insertionIndex = vines.firstIndex(where: { $0.createdAt < video.createdAt }) ?? vines.endIndex
vines.insert(video, at: insertionIndex)

1190-1195: RelativeDateTimeFormatter re-created on every view render.

Both VineCard.relativeDate and VineFullScreenPage.relativeDate allocate a new RelativeDateTimeFormatter each time they're evaluated. Since these are computed properties in SwiftUI views, they run on every re-render. Use a shared static let formatter to avoid repeated allocations.

Suggested fix
+private let sharedRelativeDateFormatter: RelativeDateTimeFormatter = {
+    let f = RelativeDateTimeFormatter()
+    f.unitsStyle = .short
+    return f
+}()
+
 private var relativeDate: String {
-    let formatter = RelativeDateTimeFormatter()
-    formatter.unitsStyle = .short
     let date = Date(timeIntervalSince1970: TimeInterval(vine.createdAt))
-    return formatter.localizedString(for: date, relativeTo: Date())
+    return sharedRelativeDateFormatter.localizedString(for: date, relativeTo: Date())
 }

Also applies to: 1362-1367

Comment thread damus/ContentView.swift Outdated
Comment thread damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift Outdated
Comment thread damus/Features/Labs/Views/DamusLabsExperiments.swift Outdated
Comment thread damus/Features/Relays/Views/UserRelaysView.swift
Comment on lines +181 to +572
struct VineTimelineView: View {
let damus_state: DamusState
@StateObject private var model: VineFeedModel
@State private var presentingFullScreen = false
@State private var fullScreenIndex = 0

init(damus_state: DamusState) {
self.damus_state = damus_state
_model = StateObject(wrappedValue: VineFeedModel(damus_state: damus_state))
}

var body: some View {
ScrollView {
LazyVStack(spacing: 24) {
if let message = model.relayMessage {
infoBanner(text: message)
}
ForEach(Array(model.vines.enumerated()), id: \.1.id) { index, vine in
VineCard(
vine: vine,
damus_state: damus_state,
onAppear: { model.noteAppeared(at: index) },
onOpenFullScreen: {
fullScreenIndex = index
presentingFullScreen = true
}
)
}
if model.vines.isEmpty && !model.isLoading && model.relayMessage == nil {
Text("No Vine videos yet. Pull down to refresh.")
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 32)
}
}
.padding(.horizontal)
.padding(.bottom, 32)
}
.background(DamusColors.adaptableWhite)
.refreshable { await model.refresh() }
.overlay {
if model.isLoading {
ProgressView()
.padding()
.background(RoundedRectangle(cornerRadius: 14).fill(Color(uiColor: .systemBackground)))
.shadow(radius: 4)
}
}
.onAppear { model.subscribe() }
.onDisappear { model.stop(disconnect: true) }
.onReceive(damus_state.settings.objectWillChange) { _ in
model.handleSettingsChange()
}
.damus_full_screen_cover($presentingFullScreen, damus_state: damus_state) {
VineFullScreenPager(
model: model,
damus_state: damus_state,
initialIndex: fullScreenIndex,
onClose: { presentingFullScreen = false }
)
}
}

private func infoBanner(text: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "bolt.horizontal.circle")
.foregroundColor(.purple)
Text(text)
.font(.footnote)
.foregroundColor(.secondary)
Spacer()
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(uiColor: .secondarySystemBackground))
)
}
}

private final class VineFeedModel: ObservableObject {
@Published private(set) var vines: [VineVideo] = []
@Published var isLoading: Bool = false
@Published var relayMessage: String? = nil

private let pageSize = 40
private let damus_state: DamusState
private var streamTask: Task<Void, Never>?
private var lastSeenTimestamp: UInt32?
private var managedRelayConnection = false
private let pathMonitor = NWPathMonitor()
private let pathQueue = DispatchQueue(label: "io.damus.vines.network")
@MainActor private var pathIsExpensive = false
@MainActor private var pathIsConstrained = false
@MainActor private var prefetchingURLs: Set<URL> = []
@MainActor private var oldestTimestamp: UInt32?
@MainActor private var isLoadingOlder = false
@MainActor private var hasMoreOlder = true

init(damus_state: DamusState) {
self.damus_state = damus_state
pathMonitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in
self?.pathIsExpensive = path.isExpensive
self?.pathIsConstrained = path.isConstrained
}
}
pathMonitor.start(queue: pathQueue)
}

func subscribe() {
stop()
streamTask = Task {
await self.loadInitialPage()
await self.stream()
}
}

func stop(disconnect: Bool = false) {
streamTask?.cancel()
streamTask = nil
if disconnect {
Task {
await self.disconnectManagedRelayIfNeeded()
}
}
}

func refresh() async {
await MainActor.run {
vines.removeAll()
lastSeenTimestamp = nil
oldestTimestamp = nil
hasMoreOlder = true
}
subscribe()
}

func handleSettingsChange() {
guard damus_state.settings.enable_vine_relay else {
stop(disconnect: true)
Task { @MainActor in
relayMessage = NSLocalizedString("Enable the Divine relay in Settings ▸ Relays to see Vine videos.", comment: "Message shown when the Vine relay is disabled.")
vines.removeAll()
isLoading = false
}
return
}

if streamTask == nil {
subscribe()
}
}

@MainActor
func noteAppeared(at index: Int) {
maybeLoadOlder(after: index)
guard shouldPrefetchVideos else { return }
let targets = [index, index + 1]
let allowCellular = damus_state.settings.prefetch_vines_on_cellular
Task.detached(priority: .background) { [weak self] in
guard let self else { return }
for target in targets {
guard self.vines.indices.contains(target),
let url = self.vines[target].playbackURL else { continue }
await self.prefetch(url: url, allowCellular: allowCellular)
}
}
}

private func stream() async {
guard damus_state.settings.enable_vine_relay else {
await MainActor.run {
relayMessage = NSLocalizedString("Enable the Divine relay in Settings ▸ Relays to see Vine videos.", comment: "Message shown when the Vine relay is disabled.")
isLoading = false
}
return
}

let alreadyConnected = await MainActor.run {
damus_state.nostrNetwork.getRelay(.vineRelay) != nil
}
await damus_state.nostrNetwork.ensureRelayConnected(.vineRelay)
if !alreadyConnected {
await MainActor.run {
self.managedRelayConnection = true
}
}

await MainActor.run {
relayMessage = nil
isLoading = true
}

var filter = NostrFilter(kinds: [.vine_short])
filter.limit = 200
let now = UInt32(Date().timeIntervalSince1970)
filter.until = now
if let lastSeenTimestamp {
filter.since = lastSeenTimestamp
} else {
filter.since = now > 604800 ? now - 604800 : 0
}

for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [filter], to: [.vineRelay]) {
if Task.isCancelled { break }
switch item {
case .event(let lender):
await lender.justUseACopy({ await self.handle(event: $0) })
case .ndbEose, .networkEose, .eose:
await MainActor.run { self.isLoading = false }
}
}

await MainActor.run {
self.isLoading = false
}
}

private func handle(event: NostrEvent) async {
let canonical = canonicalEvent(for: event)
guard let video = VineVideo(event: canonical.base, repostSource: canonical.repost) else {
Log.debug("Skipping Vine event %s (failed to parse)", for: .timeline, canonical.base.id.hex())
return
}
let shouldInclude = await MainActor.run {
should_show_event(state: damus_state, ev: canonical.base)
}
guard shouldInclude else {
Log.debug("Filtered Vine event %s via should_show_event", for: .timeline, canonical.base.id.hex())
return
}

await MainActor.run {
if let index = vines.firstIndex(where: { $0.dedupeKey == video.dedupeKey }) {
if vines[index].createdAt >= video.createdAt {
return
}
vines[index] = video
} else {
vines.append(video)
}
vines.sort { $0.createdAt > $1.createdAt }
lastSeenTimestamp = max(lastSeenTimestamp ?? 0, video.createdAt)
}
}

private func canonicalEvent(for event: NostrEvent) -> (base: NostrEvent, repost: NostrEvent?) {
guard event.known_kind == .boost else {
return (event, nil)
}

if let inner = event.get_inner_event(cache: damus_state.events),
inner.known_kind == .vine_short {
return (inner, event)
}
return (event, nil)
}

private func disconnectManagedRelayIfNeeded() async {
let shouldDisconnect = await MainActor.run { self.managedRelayConnection }
guard shouldDisconnect else { return }
await damus_state.nostrNetwork.disconnectRelay(.vineRelay)
await MainActor.run { self.managedRelayConnection = false }
}

private func loadInitialPage() async {
let start = CFAbsoluteTimeGetCurrent()
await MainActor.run {
isLoading = true
vines.removeAll()
}
let events = await fetchPage(before: nil)
await MainActor.run {
applyPage(events, reset: true)
isLoading = false
Log.info("Vines initial page loaded %d events in %.2fs", for: .timeline, events.count, CFAbsoluteTimeGetCurrent() - start)
}
}

private func loadOlderPage() async {
let before = await MainActor.run { self.oldestTimestamp }
guard let before else { return }
let start = CFAbsoluteTimeGetCurrent()
let events = await fetchPage(before: before > 0 ? before - 1 : 0)
if events.isEmpty {
await MainActor.run {
self.hasMoreOlder = false
self.isLoadingOlder = false
Log.debug("Vines older page empty at timestamp %u", for: .timeline, before)
}
return
}
await MainActor.run {
applyPage(events, reset: false)
Log.info("Vines older page loaded %d events in %.2fs", for: .timeline, events.count, CFAbsoluteTimeGetCurrent() - start)
}
}

private func fetchPage(before: UInt32?) async -> [NostrEvent] {
var filter = NostrFilter(kinds: [.vine_short])
filter.limit = UInt32(pageSize)
let now = UInt32(Date().timeIntervalSince1970)
filter.until = before ?? now
return await damus_state.nostrNetwork.reader.query(filters: [filter], to: [.vineRelay], timeout: .seconds(10))
}

@MainActor
private func applyPage(_ events: [NostrEvent], reset: Bool) {
var videos = events.compactMap { VineVideo(event: $0) }
videos.sort { $0.createdAt > $1.createdAt }
if reset {
vines = videos
} else {
let newVideos = videos.filter { video in
!vines.contains(where: { $0.dedupeKey == video.dedupeKey })
}
vines.append(contentsOf: newVideos)
vines.sort { $0.createdAt > $1.createdAt }
if newVideos.isEmpty {
hasMoreOlder = false
}
Log.debug("Vines older page appended %d new events (filtered %d duplicates)", for: .timeline, newVideos.count, videos.count - newVideos.count)
}
if let newest = vines.first?.createdAt {
lastSeenTimestamp = max(lastSeenTimestamp ?? 0, newest)
}
if let oldest = vines.last?.createdAt {
oldestTimestamp = oldest
}
if hasMoreOlder {
hasMoreOlder = videos.count == pageSize
}
isLoadingOlder = false
}

@MainActor
private var shouldPrefetchVideos: Bool {
if pathIsConstrained {
return false
}
if pathIsExpensive && !damus_state.settings.prefetch_vines_on_cellular {
return false
}
return true
}

@MainActor
private func prefetch(url: URL, allowCellular: Bool) async {
guard markPrefetching(url) else { return }
var request = URLRequest(url: url)
request.allowsExpensiveNetworkAccess = allowCellular
request.allowsConstrainedNetworkAccess = allowCellular
request.timeoutInterval = 15
do {
_ = try await URLSession.shared.data(for: request)
} catch {
Log.debug("Vine prefetch failed for %s: %s", for: .timeline, url.absoluteString, error.localizedDescription)
}
unmarkPrefetching(url)
}

@MainActor
private func markPrefetching(_ url: URL) -> Bool {
if prefetchingURLs.contains(url) {
return false
}
prefetchingURLs.insert(url)
return true
}

@MainActor
private func unmarkPrefetching(_ url: URL) {
prefetchingURLs.remove(url)
}

@MainActor
private func maybeLoadOlder(after index: Int) {
guard hasMoreOlder, !isLoadingOlder else { return }
if index >= vines.count - 5 {
isLoadingOlder = true
Task {
await self.loadOlderPage()
}
}
}

deinit {
pathMonitor.cancel()
}
}

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.

🛠️ Refactor suggestion | 🟠 Major

~1,200 lines of new Vine types packed into PostingTimelineView.swift.

This file now contains 7 new types (VineTimelineView, VineFeedModel, VineVideo, VineCard, VineMetadataRow, VineFullScreenPager, VineFullScreenPage) that have nothing to do with PostingTimelineView itself. This makes the file very hard to navigate, test in isolation, and maintain.

Consider splitting into the existing damus/Features/Vines/ directory structure, e.g.:

  • VineTimelineView.swift + VineFeedModel.swift
  • VineVideo.swift (model)
  • VineCard.swift, VineFullScreenPager.swift, VineFullScreenPage.swift (views)

Also applies to: 574-1018, 1020-1368

🤖 Prompt for AI Agents
In `@damus/Features/Timeline/Views/PostingTimelineView.swift` around lines 181 -
572, The file contains seven Vine-related types bundled into
PostingTimelineView.swift which makes it unmaintainable; split them into focused
files under damus/Features/Vines/: create VineTimelineView.swift (containing
VineTimelineView), VineFeedModel.swift (VineFeedModel), VineVideo.swift
(VineVideo model), VineCard.swift (VineCard and VineMetadataRow), and
VineFullScreen.swift (VineFullScreenPager and VineFullScreenPage), move each
type into its new file preserving their implementation and imports, adjust
access levels (private/internal/public) as needed so PostingTimelineView still
compiles, update any references/usages (e.g., VineTimelineView initializer and
VineFeedModel usage) and run a build to fix import or visibility errors.

Comment thread damus/Features/Vines/Creation/VineComposerView.swift
Comment thread damus/Shared/Media/Images/ImageProcessing.swift
Comment thread damus/Shared/Media/Images/ImageProcessing.swift Outdated
Comment thread damusTests/NostrEventTests.swift Outdated
Comment thread damusTests/NostrEventTests.swift Outdated
@jb55

jb55 commented Feb 10, 2026

Copy link
Copy Markdown
Collaborator

the first commit I see adds locks to places that doesn't need locks... not a good start

@alltheseas

Copy link
Copy Markdown
Collaborator Author

rebasing, addressing this, all other feedback

the first commit I see adds locks to places that doesn't need locks... not a good start

- Recognize vine_short Nostr events (kind 34236)
- Add Vine relay setting and move VideoCache to shared location
- Implement VineFeedModel with relay subscription and event handling
- Parse VineVideo from Nostr event tags (imeta, title, stats, origin)
- Handle Vine reposts and surface metadata in UI
- Add dedicated Vine tab and timeline to Home
- Guard Contacts mutations with lock for concurrency safety

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
- Expand Vine parsing with fixture tests and fallback URL handling
- Add EventActionBar, profile display names, and reporting menu
- Implement full-screen vertical pager with video playback
- Add network-aware video prefetch with URLSession fallback
- Implement cursor-based pagination for older Vine events
- Gate Vine tab behind Labs toggle
- Disconnect Divine relay when Vine tab closes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
- Fix P0 critical issues: replace locks with actors, fix async blocking
- Strip GPS metadata from video attachments before upload
- Reprocess imported videos to remove EXIF location data
- Handle picker dismissal and transcode Vine videos
- Add task cancellation and timeout to video export
- Add Vine integration fixture tests and pagination telemetry
- Force Vine feature on in debug builds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
- Add relay ownership tracking to prevent deleting user-configured relays
- Move state mutation out of SwiftUI body evaluation
- Move video metadata extraction off main thread
- Fix Vine prefetch to persist to VideoCache correctly
- Fix actor isolation in Vine replacement tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
- Extract Vine types from PostingTimelineView into dedicated files
- Add @unchecked Sendable to VineVideo with documented justification
- Replace NSLock with @mainactor isolation in NostrNetworkManager
- Remove vine relay from bootstrap list (on-demand via ensureRelayConnected)
- Track and cancel prefetch tasks in VineFeedModel.stop()
- Add docstrings, accessibility annotations, and unit tests
- Address CodeRabbit and AGENTS.md review feedback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Closes damus-io#3619
Changelog-Added: Added experimental Vine video viewer to Damus Labs
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
@alltheseas
alltheseas force-pushed the vine-phase1 branch 2 times, most recently from b779b61 to 204438f Compare February 11, 2026 08:02

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 17

🤖 Fix all issues with AI agents
In `@damus.xcodeproj/project.pbxproj`:
- Around line 4271-4272: There are duplicate Creation group and
VineComposerView.swift file references (group IDs 048E0CF9 and 048E0CDD,
fileRefs 048E0CF8 and 048E0CDC); remove the stray duplicate group (048E0CF9) and
its fileRef (048E0CF8) so only the intended Creation group (048E0CDD) and
fileRef (048E0CDC) remain under the Vines hierarchy, then add a PBXBuildFile
entry for the remaining VineComposerView.swift (referencing 048E0CDC) and
include that PBXBuildFile entry in the appropriate target’s PBXSourcesBuildPhase
so the file is compiled.
- Line 1527: Remove the orphaned PBXBuildFile entry with ID
D73E5EFE2C6A97F4007EB227 from the PBXBuildFile section and also delete the
matching reference to that ID from the Sources build phase where it appears (the
duplicate entry in the build phase). Ensure no other references to
D73E5EFE2C6A97F4007EB227 remain in the project file so the project.pbxproj stays
consistent.

In `@damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift`:
- Around line 272-287: ensureRelayConnected currently marks relays that already
exist in the pool as feature-managed (via featureManagedRelays.insert(relayURL))
and so user-configured relays can be later removed by disconnectRelay; change
the logic in ensureRelayConnected(_:): if await pool.get_relay(relayURL) != nil
just return without inserting into featureManagedRelays, and only insert into
featureManagedRelays after you actually add the relay (i.e. after a successful
try await pool.add_relay(descriptor) or by verifying the relay was newly present
post-add/connect); reference ensureRelayConnected, pool.get_relay(_:),
pool.add_relay(_:), pool.connect(to:), featureManagedRelays, and
disconnectRelay(_:) when making this change.

In `@damus/Features/Settings/Models/UserSettingsStore.swift`:
- Around line 118-119: The default for the relay setting is inconsistent with
the vine feature; change the `@Setting` for enable_vine_relay so its default_value
is false (matching enable_vine_feature's opt-in behavior) to make both features
opt-in by default; update the `@Setting` declaration for enable_vine_relay in
UserSettingsStore (the property named enable_vine_relay) accordingly and run
tests/verify UI behavior since the relay toggle remains hidden until
enable_vine_feature is true.

In `@damus/Features/Timeline/Views/MainTabView.swift`:
- Around line 82-86: The keyboard shortcuts are hardcoded per TabButton so when
settings.vines_feature_enabled is false the sequence skips "2"; to fix, build
the visible tab list dynamically in MainTabView (or compute an incremental index
when creating each TabButton) and assign each TabButton.keyboardShortcut using
the position in that visibleTabs array (e.g., index + 1 as the numeric shortcut
string) instead of fixed "2"/"3"/"4"; update the TabButton creation logic that
references timeline: .vines / .search / .notifications, selected, nstatus,
settings, action to iterate visible tabs and set keyboardShortcut based on their
computed order.

In `@damus/Features/Vines/Creation/VineComposerView.swift`:
- Around line 274-296: convertVideoToMP4IfNeeded currently calls
exporter.exportAsynchronously with no timeout, so a stalled AVAssetExportSession
can never resume the continuation; add a 30s timeout: after creating exporter,
schedule a timeout task (e.g., DispatchQueue.main.asyncAfter or a
Task.sleep-backed Task) that calls exporter.cancelExport() and resumes the
continuation with nil if the exporter hasn't completed, and in the
exportAsynchronously completion branch resume with destinationURL on .completed
and nil on other statuses; ensure you guard against double-resume (track a
Boolean flag or use continuation.resume only once) so continuation is always
resumed exactly once.

In `@damus/Features/Vines/Models/VineFeedModel.swift`:
- Line 24: lastSeenTimestamp is currently written inside MainActor.run but read
off-main in stream(), causing a potential race; fix by making lastSeenTimestamp
MainActor-isolated (annotate the property with `@MainActor` private var
lastSeenTimestamp: UInt32? and update callers to await reads) or, alternatively,
ensure stream() reads it inside await MainActor.run { ... } before building the
filter; reference the property lastSeenTimestamp, the stream() method, and the
existing MainActor.run write locations when applying the change.
- Around line 22-23: The data race occurs because prefetchTasks is mutated from
both `@MainActor` code (noteAppeared) and non-isolated code (stop called from
subscribe/refresh/handleSettingsChange); fix by making stop()
MainActor-isolated: add `@MainActor` to the stop() declaration so all mutations of
prefetchTasks happen on the MainActor (or alternatively annotate the
prefetchTasks property with `@MainActor` and wrap the cleanup in MainActor.run if
you prefer async cleanup) — update the stop() implementation (and any direct
accesses to prefetchTasks inside it) to assume MainActor isolation and adjust
callers if necessary.
- Around line 301-331: In prefetch(url:allowCellular:) replace
URLSession.shared.data(for:) with URLSession.shared.download(for: request) so
the response is streamed to a temporary file instead of buffered in memory; on
success check the HTTPURLResponse status code, get VideoCache.standard and its
url_to_cached_url(url:) target, then move (or copy) the temporary downloaded
file to the cachedURL using FileManager (ensuring atomic move and proper error
handling), and still call unmarkPrefetching in the defer; preserve
request.allowsExpensiveNetworkAccess, request.allowsConstrainedNetworkAccess,
and timeoutInterval and log errors similarly.
- Around line 111-118: Currently completed prefetch tasks are never removed
because isCancelled only marks cancellations; change the tracking to remove
tasks when they finish by mapping URLs to their Task (e.g., a Dictionary<URL,
Task<Void, Never>> or a Set of in-flight URLs) and inside the Task.detached
closure (the work started in Task.detached and the call to
prefetch(url:allowCellular:)) remove the entry for that URL on normal completion
(use defer or a finally-style removal) so finished tasks are cleared from
prefetchTasks/inFlight map; also keep any existing cancellation cleanup (remove
cancelled tasks) as a fallback.

In `@damus/Features/Vines/Models/VineVideo.swift`:
- Around line 439-460: The format detection in imetaEntries(in:) incorrectly
treats the whole payload as inline if any payload element contains a space;
update the heuristic to decide inline vs alternating by inspecting the first
payload element (e.g., check if payload.first contains a space and splits into
exactly two parts) or attempt to parse both formats in one pass (try splitting
each element into key/value when it matches "key value", otherwise fall back to
consuming alternating pairs from the iterator); modify the logic around
usesInlineFormat and the subsequent parsing branches in imetaEntries to use this
more accurate check so values with spaces (like alt-text) aren’t misclassified.

In `@damus/Features/Vines/Views/VineCard.swift`:
- Around line 34-35: The top-level accessibility modifier
.accessibilityElement(children: .combine) applied alongside
accessibilityLabel(Text(vine.altText ?? vine.title)) on the VineCard is
collapsing the whole card into one element and hiding interactive children;
remove or relocate that modifier so interactive controls remain individually
accessible: delete or move .accessibilityElement(children: .combine) from the
top-level view in VineCard.swift, then, if needed, apply
.accessibilityElement(children: .combine) only to non-interactive subviews
(e.g., image/description areas) while leaving the overflow menu, the "Reveal"
button, the "Open backup stream" button and the EventActionBar as separate
accessibility elements.
- Around line 201-206: The computed property authorDisplayName currently calls
damus_state.profiles.lookup(id: vine.event.pubkey) synchronously on the main
thread; move that DB lookup out of the view body by removing the synchronous
lookup from authorDisplayName and instead introduce a state-backed async load
(e.g. `@State` private var authorDisplayName: String = vine.authorDisplay), then
in init/onAppear/.task start a background Task (or
Task.detached/DispatchQueue.global) that calls damus_state.profiles.lookup(id:
vine.event.pubkey) off the main thread, compute
Profile.displayName(profile:pubkey) there, and update the `@State` value via
MainActor.run so the view updates; ensure authorDisplayName property now just
returns the `@State` value and no longer performs the blocking
damus_state.profiles.lookup call.

In `@damus/Features/Vines/Views/VineFullScreenPage.swift`:
- Around line 44-53: The fallback-button is shown whenever vine.fallbackURL
exists even if the primary video is playing; update the UI so the Button is only
shown when the primary stream is unavailable or not playing (e.g., guard on
vine.primaryURL == nil or check the playback state like player.isPlaying ==
false) or alternatively rename the Label text from NSLocalizedString("Open
backup stream", ...) to something neutral like "Open in Browser" to avoid
implying an error; modify the conditional around the Button in
VineFullScreenPage (the block referencing vine.fallbackURL and the Label/Button)
to implement one of these fixes.

In `@damus/Features/Vines/Views/VineFullScreenPager.swift`:
- Around line 15-16: The current index-based `@State` private var selection: Int
in VineFullScreenPager breaks when model.vines mutates; change selection to
store a stable identifier (e.g. `@State` private var selectionID: Vine.ID or
selectionKey: String using vine.dedupeKey), update all Page/TabView tags from
the enumerated index to the vine.id/dedupeKey, and update any places that
read/write selection to map between the ID and the current array index (use
model.vines.firstIndex(where: { $0.id == selectionID }) or similar) so selection
remains stable across inserts/removals; adjust functions/logic that previously
relied on selection (e.g., computing currentVine, jumping pages) to use the
stable ID-to-index lookup.

In `@damus/Features/Vines/Views/VineTimelineView.swift`:
- Around line 28-37: fullScreenIndex is vulnerable to array mutations because it
uses the enumerated offset from the ForEach; instead pass a stable identifier
and/or clamp the index when opening the pager: change VineCard's
onOpenFullScreen to send the tapped vine's id (vine.id) rather than the current
enumerated index, then update the presentation logic in VineFullScreenPager to
resolve that id to a current index (find the index in model.vines by comparing
vine.id and clamp to 0..<model.vines.count) before using it; alternatively, if
you keep fullScreenIndex, ensure VineFullScreenPager validates and clamps
fullScreenIndex against model.vines.count on appear to avoid out-of-bounds or
stale selections.

In `@damus/Shared/Media/Models/MediaPicker.swift`:
- Around line 141-153: In attemptAcquireResourceAndChooseMedia, ensure
dispatchGroup.leave() is always called even when the async fallback returns nil:
when url.startAccessingSecurityScopedResource() succeeds call chooseMedia as
before and leave the group; when using Task and fallback(url) returns nil make
sure to call dispatchGroup.leave() before exiting the Task (or use a
defer/ensure pattern inside the Task) so the group is balanced; update the Task
fallback path accordingly and audit other early-return guards (e.g., the guard
let url = item as? URL) to likewise always call dispatchGroup.leave() on all
exit paths.
🧹 Nitpick comments (20)
.beads/issues.jsonl (1)

1-25: Consider whether .beads/ should be committed to the repository.

This JSONL file tracks agent-internal issue state (all closed or in-progress items from the development session). Committing it adds ~25 records of transient development metadata to the repo. Unless .beads/ is intended as a permanent project artifact, consider adding it to .gitignore or removing it before merge to keep the repository clean.

#!/bin/bash
# Check if .beads is in .gitignore or if there's a metadata file explaining its purpose
cat .gitignore 2>/dev/null | grep -i bead
cat .beads/metadata.json 2>/dev/null
damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift (1)

284-284: Silently discarding add_relay errors with try?.

While RelayPool.add_relay currently only throws RelayAlreadyExists, silently ignoring the error means the code proceeds to connect and marks the relay as feature-managed even if addition failed for an unexpected reason. Consider at least logging the error.

Proposed improvement
-    try? await pool.add_relay(descriptor)
+    do {
+        try await pool.add_relay(descriptor)
+    } catch {
+        Log.warning("Failed to add relay %s: %s", for: .networking, relayURL.id as CVarArg, error.localizedDescription as CVarArg)
+        return
+    }
damus/Features/Relays/Views/UserRelaysView.swift (1)

57-67: No feedback on toggle failure — consider showing an error if relay connect/disconnect fails.

setDivineRelayEnabled fires the async relay operation in a Task but doesn't handle errors. If ensureRelayConnected or disconnectRelay fails silently, the toggle will show the relay as enabled/disabled while the actual connection state differs. For a POC this is acceptable, but worth a TODO.

damus/Features/Vines/Creation/VineComposerView.swift (2)

12-28: Custom Equatable conformance is unnecessary — Swift auto-synthesizes it for enums with Equatable associated values.

String is Equatable, so UploadPhase gets synthesis for free. This manual implementation can be removed.

Proposed simplification
-    enum UploadPhase: Equatable {
+    enum UploadPhase: Equatable {
         case idle
         case uploading
         case uploaded
         case failed(String)
-        
-        static func == (lhs: UploadPhase, rhs: UploadPhase) -> Bool {
-            switch (lhs, rhs) {
-            case (.idle, .idle), (.uploading, .uploading), (.uploaded, .uploaded):
-                return true
-            case let (.failed(lhsMessage), .failed(rhsMessage)):
-                return lhsMessage == rhsMessage
-            default:
-                return false
-            }
-        }
     }

263-272: Use async/await AVURLAsset APIs to avoid deprecation warnings.

The synchronous .tracks(withMediaType:) and .duration properties are deprecated as of iOS 16. Since this code already runs on a background thread (Task.detached), replace these with the async equivalents:

  • asset.tracks(withMediaType:)await asset.loadTracks(withMediaType:)
  • asset.durationawait asset.load(.duration)
  • track.naturalSizeawait track.load(.naturalSize)
  • track.preferredTransformawait track.load(.preferredTransform)

The function would need to become async to accommodate these changes.

damus/Features/Settings/Models/UserSettingsStore.swift (1)

115-148: Missing docstrings on new settings properties.

Per coding guidelines, all added or modified code should have docstring coverage. The three new @Setting properties and the vines_feature_enabled computed property lack documentation. Consider adding brief /// comments like the existing ones (e.g., Line 260–261 for undistractMode). As per coding guidelines, "Ensure docstring coverage for any code added or modified."

damus/Shared/Media/Images/ImageProcessing.swift (2)

165-212: Async export with timeout — well structured, but add a docstring.

The withTaskGroup + withCheckedContinuation pattern correctly races the export against a 30-second timeout, and cancelAll() properly tears down the losing task. The continuation safety is maintained because withTaskGroup awaits all children before returning.

One note: exportSession.cancelExport() is called on line 206 even when the export already failed (not just on timeout). This is harmless but could be guarded with a more precise condition.

As per coding guidelines, "Ensure docstring coverage for any code added or modified" — this private function lacks a /// docstring.


58-68: Remove saveVideoToTemporaryFolder — it is unused dead code.

The function is never called anywhere in the codebase and can be safely deleted.

damus.xcodeproj/project.pbxproj (1)

7039-7045: Remove Vine UI files from ShareExtension target.

The Vine UI files (VineCard, VineFullScreenPage, VineFullScreenPager, VineMetadataRow, VineTimelineView, VineFeedModel) are included in ShareExtension's Sources build phase but are not used anywhere in the extension code. Share extensions have minimal footprint requirements, and including these views unnecessarily increases the extension's binary size.

damus/Features/Vines/Models/VineVideo.swift (3)

409-417: Redundant key == "pm-report" check.

"pm-report".hasPrefix("pm-") is already true, so the || key == "pm-report" clause is dead logic.

Proposed simplification
-            if key == "proof" || key.hasPrefix("pm-") || key == "pm-report" {
+            if key == "proof" || key.hasPrefix("pm-") {

267-278: NSDataDetector instantiation on every collectContentURLs call.

NSDataDetector(types:) allocates an NSRegularExpression internally. Since VineVideo init may be called in a tight loop (e.g., applyPage with 40 events), consider hoisting the detector to a static let to avoid repeated allocation.

Proposed change
+    private static let linkDetector: NSDataDetector? = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
+
     private static func collectContentURLs(from content: String?, into candidates: inout [URL: MediaCandidate]) {
         guard let content, !content.isEmpty else { return }
-        guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { return }
+        guard let detector = linkDetector else { return }
         let range = NSRange(content.startIndex..<content.endIndex, in: content)

280-288: collectFallbackURLs iterates every value in every tag — intentionally broad.

When candidateMap is empty, this scans all tag values as potential URLs. While normalizedURL filters out non-http(s) strings, this could inadvertently pick up unrelated URLs (e.g., relay URLs in "r" tags, profile links). The behavior is guarded by the candidateMap.isEmpty check on Line 172, so the blast radius is limited.

Worth a brief inline comment explaining the intent to future maintainers.

damusTests/Fixtures/VineFixtures.swift (2)

5-5: AI tool attribution in the file header.

The Created by OpenAI Codex line is atypical for project file headers. Consider replacing with the actual author or contributor name for consistency with the rest of the codebase.


73-77: repost fixture doesn't model an actual Nostr repost event.

This fixture is a standard vine event, identical in structure to replacementOriginal. A Nostr repost (kind 6 / .boost) wraps an inner event. To test the canonicalEvent(for:) / repost path in VineFeedModel, you'd need a fixture that represents the outer boost event containing the inner vine event. The tests currently use VineVideo(event:, repostSource:) with a separate event, which is valid, but naming this fixture repost is misleading since it's just a regular vine.

damus/Features/Vines/Models/VineFeedModel.swift (1)

184-195: O(n log n) sort on every incoming event during streaming.

vines.sort is called inside handle(event:) for each event received. During the initial stream, this could process up to 200 events (Line 146), resulting in quadratic total work. Consider deferring the sort or using binary insertion.

damusTests/NostrEventTests.swift (2)

219-222: Duplicated makeVineEvent helper across both test classes.

The identical helper appears in VineVideoTests (Line 219) and VineFeedModelTests (Line 308). Extract it to a shared file-level or extension-level helper to avoid duplication.

Also, the force unwrap NostrEvent(...)! will crash without a useful message if event creation fails. Consider using XCTUnwrap in a throwing helper for better test diagnostics.

Proposed extraction
+// MARK: - Shared Vine Test Helpers
+
+private func makeVineEvent(content: String = "", tags: [[String]], createdAt: UInt32 = UInt32(Date().timeIntervalSince1970)) -> NostrEvent {
+    let keypair = generate_new_keypair().to_keypair()
+    return NostrEvent(content: content, keypair: keypair, kind: NostrKind.vine_short.rawValue, tags: tags, createdAt: createdAt)!
+}

Then remove the per-class makeVineEvent methods.

Also applies to: 308-311


329-367: VineTestFeed mirrors VineFeedModel core logic — divergence risk.

The apply and applyPage methods duplicate the dedup/sort logic from VineFeedModel. If the production logic changes (e.g., dedup strategy, sort order), these tests could pass while production behavior differs. This is acceptable for a POC but consider adding a comment or a TODO to keep these in sync.

damus/ContentView.swift (1)

165-166: immersiveTimeline is computed in two separate places with identical logic.

The same expression selected_timeline == .home || selected_timeline == .vines appears in both MainContent (Line 165) and body (Line 256). If the set of immersive timelines grows, one site could be updated while the other is missed.

Consider extracting this into a single computed property on ContentView.

♻️ Proposed refactor
+    /// Whether the currently selected timeline should use immersive (edge-to-edge) layout.
+    private var immersiveTimeline: Bool {
+        selected_timeline == .home || selected_timeline == .vines
+    }
+
     func MainContent(damus: DamusState) -> some View {
-        let immersiveTimeline = selected_timeline == .home || selected_timeline == .vines
-        return VStack {
+        VStack {

And in body:

     var body: some View {
-        let immersiveTimeline = selected_timeline == .home || selected_timeline == .vines
-        
-        return VStack(alignment: .leading, spacing: 0) {
+        VStack(alignment: .leading, spacing: 0) {

Also applies to: 256-258

damus/Features/Vines/Views/VineTimelineView.swift (2)

61-62: Disconnecting from the relay on every disappear may hurt perceived performance.

onDisappear fires on every tab switch, so each visit to Vines incurs a reconnect cost. Consider deferring disconnect (e.g., with a short delay that is cancelled if the user returns quickly) or disconnecting only when the parent view is truly torn down.


17-20: Missing docstring on init. As per coding guidelines, ensure docstring coverage for any code added or modified.

Comment thread damus.xcodeproj/project.pbxproj
Comment on lines +4271 to +4272
048E0CF92F3C5D2E00106E91 /* Creation */,
048E0CE72F3C5C9500106E91 /* Vines */,

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

Duplicate Creation group and VineComposerView.swift file references.

Under the Features group, two Creation groups are added:

  1. 048E0CF9 (Line 4271) — a standalone sibling of Vines, containing its own VineComposerView.swift (048E0CF8, Line 2052).
  2. 048E0CDD — nested inside Vines/Creation (Lines 3045–3052), containing a different VineComposerView.swift reference (048E0CDC, Line 2044).

This results in two Creation folders and two separate file references for VineComposerView.swift. Additionally, neither VineComposerView.swift appears in any PBXBuildFile section, so neither is compiled. This looks like a rebasing artifact — remove the duplicate group and consolidate to a single file reference, and add the appropriate build file entry if the file should be compiled.

🤖 Prompt for AI Agents
In `@damus.xcodeproj/project.pbxproj` around lines 4271 - 4272, There are
duplicate Creation group and VineComposerView.swift file references (group IDs
048E0CF9 and 048E0CDD, fileRefs 048E0CF8 and 048E0CDC); remove the stray
duplicate group (048E0CF9) and its fileRef (048E0CF8) so only the intended
Creation group (048E0CDD) and fileRef (048E0CDC) remain under the Vines
hierarchy, then add a PBXBuildFile entry for the remaining
VineComposerView.swift (referencing 048E0CDC) and include that PBXBuildFile
entry in the appropriate target’s PBXSourcesBuildPhase so the file is compiled.

Comment on lines +272 to +287
/// Ensures the relay pool is connected to a specific relay, adding it if necessary.
///
/// Used by feature-specific code (e.g., Vines) to connect to relays that are
/// not part of the user's configured relay list. Tracks the relay so that
/// ``disconnectRelay(_:)`` can safely remove it later.
func ensureRelayConnected(_ relayURL: RelayURL) async {
if await pool.get_relay(relayURL) != nil {
await MainActor.run { featureManagedRelays.insert(relayURL) }
return
}

let descriptor = RelayPool.RelayDescriptor(url: relayURL, info: .readWrite)
try? await pool.add_relay(descriptor)
await pool.connect(to: [relayURL])
await MainActor.run { featureManagedRelays.insert(relayURL) }
}

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

ensureRelayConnected marks pre-existing (user-configured) relays as feature-managed, allowing disconnectRelay to remove them.

When the relay already exists in the pool (Lines 278-280), it's unconditionally added to featureManagedRelays. If the user independently configured wss://relay.divine.video, toggling the Vine feature off would call disconnectRelay and remove their relay.

Only insert into featureManagedRelays when you actually added the relay:

Proposed fix
 func ensureRelayConnected(_ relayURL: RelayURL) async {
     if await pool.get_relay(relayURL) != nil {
-        await MainActor.run { featureManagedRelays.insert(relayURL) }
+        // Relay already exists — don't claim ownership
         return
     }
 
     let descriptor = RelayPool.RelayDescriptor(url: relayURL, info: .readWrite)
     try? await pool.add_relay(descriptor)
     await pool.connect(to: [relayURL])
     await MainActor.run { featureManagedRelays.insert(relayURL) }
 }
📝 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
/// Ensures the relay pool is connected to a specific relay, adding it if necessary.
///
/// Used by feature-specific code (e.g., Vines) to connect to relays that are
/// not part of the user's configured relay list. Tracks the relay so that
/// ``disconnectRelay(_:)`` can safely remove it later.
func ensureRelayConnected(_ relayURL: RelayURL) async {
if await pool.get_relay(relayURL) != nil {
await MainActor.run { featureManagedRelays.insert(relayURL) }
return
}
let descriptor = RelayPool.RelayDescriptor(url: relayURL, info: .readWrite)
try? await pool.add_relay(descriptor)
await pool.connect(to: [relayURL])
await MainActor.run { featureManagedRelays.insert(relayURL) }
}
/// Ensures the relay pool is connected to a specific relay, adding it if necessary.
///
/// Used by feature-specific code (e.g., Vines) to connect to relays that are
/// not part of the user's configured relay list. Tracks the relay so that
/// ``disconnectRelay(_:)`` can safely remove it later.
func ensureRelayConnected(_ relayURL: RelayURL) async {
if await pool.get_relay(relayURL) != nil {
// Relay already exists — don't claim ownership
return
}
let descriptor = RelayPool.RelayDescriptor(url: relayURL, info: .readWrite)
try? await pool.add_relay(descriptor)
await pool.connect(to: [relayURL])
await MainActor.run { featureManagedRelays.insert(relayURL) }
}
🤖 Prompt for AI Agents
In `@damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift` around
lines 272 - 287, ensureRelayConnected currently marks relays that already exist
in the pool as feature-managed (via featureManagedRelays.insert(relayURL)) and
so user-configured relays can be later removed by disconnectRelay; change the
logic in ensureRelayConnected(_:): if await pool.get_relay(relayURL) != nil just
return without inserting into featureManagedRelays, and only insert into
featureManagedRelays after you actually add the relay (i.e. after a successful
try await pool.add_relay(descriptor) or by verifying the relay was newly present
post-add/connect); reference ensureRelayConnected, pool.get_relay(_:),
pool.add_relay(_:), pool.connect(to:), featureManagedRelays, and
disconnectRelay(_:) when making this change.

Comment on lines +118 to +119
@Setting(key: "enable_vine_relay", default_value: true)
var enable_vine_relay: Bool

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "enable_vine_relay" --type swift -C3

Repository: damus-io/damus

Length of output: 3650


🏁 Script executed:

# Also check how enable_vine_feature is used for comparison
rg -n "enable_vine_feature" --type swift -C3

Repository: damus-io/damus

Length of output: 2554


🏁 Script executed:

# Search for relay-related code that might use these settings
rg -n "vine.*relay|relay.*vine" --type swift -i -C2

Repository: damus-io/damus

Length of output: 10334


Consider aligning enable_vine_relay default with enable_vine_feature for consistency.

Since enable_vine_feature defaults to false (opt-in) but enable_vine_relay defaults to true (opt-out), users who enable the vine feature will have the Divine Relay auto-connected by default. While the relay toggle is hidden in the UI unless the vine feature is enabled, the asymmetric defaults means the relay becomes opt-out rather than opt-in. For consistency, consider defaulting enable_vine_relay to false so both features follow the same opt-in pattern.

🤖 Prompt for AI Agents
In `@damus/Features/Settings/Models/UserSettingsStore.swift` around lines 118 -
119, The default for the relay setting is inconsistent with the vine feature;
change the `@Setting` for enable_vine_relay so its default_value is false
(matching enable_vine_feature's opt-in behavior) to make both features opt-in by
default; update the `@Setting` declaration for enable_vine_relay in
UserSettingsStore (the property named enable_vine_relay) accordingly and run
tests/verify UI behavior since the relay toggle remains hidden until
enable_vine_feature is true.

Comment on lines +82 to 86
if settings.vines_feature_enabled {
TabButton(timeline: .vines, img: "vine", selected: $selected, nstatus: nstatus, settings: settings, action: action).keyboardShortcut("2")
}
TabButton(timeline: .search, img: "search", selected: $selected, nstatus: nstatus, settings: settings, action: action).keyboardShortcut("3")
TabButton(timeline: .notifications, img: "notification-bell", selected: $selected, nstatus: nstatus, settings: settings, action: action).keyboardShortcut("4")

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

Keyboard shortcuts skip "2" when vines tab is hidden.

When vines_feature_enabled is false, the tab bar goes from shortcut "1" (home) to "3" (search) to "4" (notifications), skipping "2". Consider dynamically assigning shortcuts so they remain sequential when the vines tab is absent.

🤖 Prompt for AI Agents
In `@damus/Features/Timeline/Views/MainTabView.swift` around lines 82 - 86, The
keyboard shortcuts are hardcoded per TabButton so when
settings.vines_feature_enabled is false the sequence skips "2"; to fix, build
the visible tab list dynamically in MainTabView (or compute an incremental index
when creating each TabButton) and assign each TabButton.keyboardShortcut using
the position in that visibleTabs array (e.g., index + 1 as the numeric shortcut
string) instead of fixed "2"/"3"/"4"; update the TabButton creation logic that
references timeline: .vines / .search / .notifications, selected, nstatus,
settings, action to iterate visible tabs and set keyboardShortcut based on their
computed order.

Comment on lines +201 to +206
private var authorDisplayName: String {
if let profile = try? damus_state.profiles.lookup(id: vine.event.pubkey) {
return Profile.displayName(profile: profile, pubkey: vine.event.pubkey).displayName
}
return vine.authorDisplay
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for the profiles.lookup implementation
rg -n "func lookup" --type swift -A 10 | head -100

Repository: damus-io/damus

Length of output: 6454


🏁 Script executed:

#!/bin/bash
# Find the Profiles class definition
rg -n "class Profiles" --type swift -B 2 -A 20

Repository: damus-io/damus

Length of output: 3833


🏁 Script executed:

#!/bin/bash
# Search for where profiles is initialized/created to understand its type
rg -n "var profiles" --type swift | head -20

Repository: damus-io/damus

Length of output: 532


🏁 Script executed:

#!/bin/bash
# Find lookup methods specifically in Profiles.swift
rg -n "func lookup" damus/Core/Nostr/Profiles.swift -A 15

Repository: damus-io/damus

Length of output: 2182


🏁 Script executed:

#!/bin/bash
# Get full Profiles.swift file to understand its structure
wc -l damus/Core/Nostr/Profiles.swift

Repository: damus-io/damus

Length of output: 93


🏁 Script executed:

#!/bin/bash
# Find ndb.lookup_profile implementation
rg -n "func lookup_profile\(" damus/Core/Nostr -A 20 --type swift

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

#!/bin/bash
# Also check if lookup operations use SafeNdbTxn which might indicate safe concurrency handling
rg -n "lookup_profile" nostrdb/Ndb.swift -A 12 --type swift

Repository: damus-io/damus

Length of output: 4941


🏁 Script executed:

#!/bin/bash
# Find withNdb implementation
rg -n "func withNdb" nostrdb/Ndb.swift -A 8 --type swift

Repository: damus-io/damus

Length of output: 588


🏁 Script executed:

#!/bin/bash
# Find SafeNdbTxn definition and implementation
rg -n "struct SafeNdbTxn\|class SafeNdbTxn" --type swift -A 15

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

#!/bin/bash
# Check what ndb_get_profile_by_pubkey is (likely C function binding)
rg -n "ndb_get_profile_by_pubkey" --type swift

Repository: damus-io/damus

Length of output: 175


🏁 Script executed:

#!/bin/bash
# Find SafeNdbTxn with different search
rg -n "SafeNdbTxn" --type swift | head -20

Repository: damus-io/damus

Length of output: 1752


🏁 Script executed:

#!/bin/bash
# Understand what keepNdbOpen does
rg -n "keepNdbOpen" --type swift -B 2 -A 15 | head -50

Repository: damus-io/damus

Length of output: 4818


🏁 Script executed:

#!/bin/bash
# Check the actual view body evaluation context - is this called on main thread?
rg -n "authorDisplayName" damus/Features/Vines/Views/VineCard.swift -B 5 -A 5

Repository: damus-io/damus

Length of output: 1199


🏁 Script executed:

#!/bin/bash
# Check if ndb operates as an in-memory cache or if it actually performs I/O
rg -n "mmap\|disk\|file\|sqlite" nostrdb/Ndb.swift -i | head -20

Repository: damus-io/damus

Length of output: 40


🏁 Script executed:

#!/bin/bash
# Look at the actual C binding to understand the nature of ndb_get_profile_by_pubkey
fd -e h -e swift -path "*nostrdb*" | xargs grep -l "ndb_get_profile_by_pubkey" 2>/dev/null | head -5

Repository: damus-io/damus

Length of output: 226


🏁 Script executed:

#!/bin/bash
# Double check: is keepNdbOpen dispatching to background queue or executing synchronously?
cat nostrdb/NdbUseLock.swift | sed -n '37,45p'

Repository: damus-io/damus

Length of output: 695


Move database lookup to background thread to avoid blocking main thread during view rendering.

The profiles.lookup(id:) call in authorDisplayName executes database queries synchronously on the main thread. When this computed property is evaluated during SwiftUI view body rendering (especially during scrolling), it blocks the UI. The entire call chain—profiles.lookup()ndb.lookup_profile()withNdb()keepNdbOpen()—executes synchronously on the current thread and directly accesses nostrdb via the C function ndb_get_profile_by_pubkey().

Use @MainActor on the computed property or dispatch the lookup to a background task to prevent main thread blocking:

private var authorDisplayName: String {
    if let profile = try? damus_state.profiles.lookup(id: vine.event.pubkey) {
        return Profile.displayName(profile: profile, pubkey: vine.event.pubkey).displayName
    }
    return vine.authorDisplay
}

Should be refactored to load profile asynchronously outside the view body evaluation.

🤖 Prompt for AI Agents
In `@damus/Features/Vines/Views/VineCard.swift` around lines 201 - 206, The
computed property authorDisplayName currently calls
damus_state.profiles.lookup(id: vine.event.pubkey) synchronously on the main
thread; move that DB lookup out of the view body by removing the synchronous
lookup from authorDisplayName and instead introduce a state-backed async load
(e.g. `@State` private var authorDisplayName: String = vine.authorDisplay), then
in init/onAppear/.task start a background Task (or
Task.detached/DispatchQueue.global) that calls damus_state.profiles.lookup(id:
vine.event.pubkey) off the main thread, compute
Profile.displayName(profile:pubkey) there, and update the `@State` value via
MainActor.run so the view updates; ensure authorDisplayName property now just
returns the `@State` value and no longer performs the blocking
damus_state.profiles.lookup call.

Comment on lines +44 to +53
if let fallback = vine.fallbackURL {
Button {
openURL(fallback)
} label: {
Label(NSLocalizedString("Open backup stream", comment: "Action to open a fallback Vine video URL when the main stream fails."), systemImage: "arrow.up.right.square")
.font(.caption)
}
.buttonStyle(.borderedProminent)
.tint(.white.opacity(0.2))
}

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

"Open backup stream" button appears even when the primary video is playing fine.

The button is shown whenever vine.fallbackURL is non-nil, regardless of primary playback status. This may confuse users who see a working video alongside a button that implies something is broken. Consider gating this on the primary URL being nil, or renaming to something like "Open in browser" to clarify it's an alternative rather than a recovery action.

🤖 Prompt for AI Agents
In `@damus/Features/Vines/Views/VineFullScreenPage.swift` around lines 44 - 53,
The fallback-button is shown whenever vine.fallbackURL exists even if the
primary video is playing; update the UI so the Button is only shown when the
primary stream is unavailable or not playing (e.g., guard on vine.primaryURL ==
nil or check the playback state like player.isPlaying == false) or alternatively
rename the Label text from NSLocalizedString("Open backup stream", ...) to
something neutral like "Open in Browser" to avoid implying an error; modify the
conditional around the Button in VineFullScreenPage (the block referencing
vine.fallbackURL and the Label/Button) to implement one of these fixes.

Comment on lines +15 to +16
@State private var selection: Int

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

Index-based selection breaks when model.vines changes during full-screen playback.

selection is an Int index (Line 15) and pages are tagged with their enumerated index (Line 31). When the vines array is mutated — e.g., a new vine arrives via streaming or an older page is loaded and inserted — the indices shift. The selection value becomes stale: the user sees a different vine jump into view, or the index falls out of bounds.

Use the vine's stable id (or dedupeKey) as the selection type and tag instead:

Proposed fix — use stable ID for selection
-    `@State` private var selection: Int
+    `@State` private var selection: String

     init(model: VineFeedModel, damus_state: DamusState, initialIndex: Int, onClose: `@escaping` () -> Void) {
         self._model = ObservedObject(wrappedValue: model)
         self.damus_state = damus_state
-        self._selection = State(initialValue: initialIndex)
+        let initialID = model.vines.indices.contains(initialIndex) ? model.vines[initialIndex].id : ""
+        self._selection = State(initialValue: initialID)
         self.onClose = onClose
     }

     ...
-                ForEach(Array(model.vines.enumerated()), id: \.1.id) { index, vine in
+                ForEach(model.vines) { vine in
                     VineFullScreenPage(vine: vine, damus_state: damus_state)
                         .frame(width: geo.size.width, height: geo.size.height)
                         .rotationEffect(.degrees(-90))
-                        .tag(index)
+                        .tag(vine.id)
                 }
     ...
         .onAppear {
-            model.noteAppeared(at: selection)
+            if let idx = model.vines.firstIndex(where: { $0.id == selection }) {
+                model.noteAppeared(at: idx)
+            }
         }
-        .onChange(of: selection) { idx in
-            model.noteAppeared(at: idx)
+        .onChange(of: selection) { id in
+            if let idx = model.vines.firstIndex(where: { $0.id == id }) {
+                model.noteAppeared(at: idx)
+            }
         }

Also applies to: 26-32, 50-55

🤖 Prompt for AI Agents
In `@damus/Features/Vines/Views/VineFullScreenPager.swift` around lines 15 - 16,
The current index-based `@State` private var selection: Int in VineFullScreenPager
breaks when model.vines mutates; change selection to store a stable identifier
(e.g. `@State` private var selectionID: Vine.ID or selectionKey: String using
vine.dedupeKey), update all Page/TabView tags from the enumerated index to the
vine.id/dedupeKey, and update any places that read/write selection to map
between the ID and the current array index (use model.vines.firstIndex(where: {
$0.id == selectionID }) or similar) so selection remains stable across
inserts/removals; adjust functions/logic that previously relied on selection
(e.g., computing currentVine, jumping pages) to use the stable ID-to-index
lookup.

Comment on lines +28 to +37
ForEach(Array(model.vines.enumerated()), id: \.1.id) { index, vine in
VineCard(
vine: vine,
damus_state: damus_state,
onAppear: { model.noteAppeared(at: index) },
onOpenFullScreen: {
fullScreenIndex = index
presentingFullScreen = true
}
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential stale fullScreenIndex if vines array mutates before pager renders.

fullScreenIndex is set from the enumerated() offset. If model.vines changes (e.g., a refresh completing, deduplication removing items) between the tap and the full-screen cover appearing, the index could point to a different vine or be out of bounds. Consider clamping it in VineFullScreenPager or passing the vine's ID instead.

🤖 Prompt for AI Agents
In `@damus/Features/Vines/Views/VineTimelineView.swift` around lines 28 - 37,
fullScreenIndex is vulnerable to array mutations because it uses the enumerated
offset from the ForEach; instead pass a stable identifier and/or clamp the index
when opening the pager: change VineCard's onOpenFullScreen to send the tapped
vine's id (vine.id) rather than the current enumerated index, then update the
presentation logic in VineFullScreenPager to resolve that id to a current index
(find the index in model.vines by comparing vine.id and clamp to
0..<model.vines.count) before using it; alternatively, if you keep
fullScreenIndex, ensure VineFullScreenPager validates and clamps fullScreenIndex
against model.vines.count on appear to avoid out-of-bounds or stale selections.

Comment on lines +141 to 153
private func attemptAcquireResourceAndChooseMedia(url: URL, fallback: @escaping (URL) async -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: @escaping (URL) -> PreUploadedMedia, orderId: String) {
if url.startAccessingSecurityScopedResource() {
// Have permission from system to use url out of scope
print("Acquired permission to security scoped resource")
self.chooseMedia(unprocessedEnum(url), orderId: orderId)
} else {
// Need to copy URL to non-security scoped location
guard let newUrl = fallback(url) else { return }
self.chooseMedia(processedEnum(newUrl), orderId: orderId)
Task {
guard let newUrl = await fallback(url) else { return }
self.chooseMedia(processedEnum(newUrl), orderId: orderId)
}
}
}

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

dispatchGroup.leave() is skipped when the async fallback returns nil.

If fallback(url) returns nil, the guard exits the Task without calling chooseMedia, so dispatchGroup.leave() is never invoked for that item. This means dispatchGroup.notify on Line 122 will never fire, silently dropping all selected media.

This is a pre-existing issue (the synchronous path had the same problem), but the new async fallback makes failure more likely (e.g., video transcoding errors, cancellation). Consider ensuring the group is always left:

🐛 Proposed fix
             Task {
-                guard let newUrl = await fallback(url) else { return }
-                self.chooseMedia(processedEnum(newUrl), orderId: orderId)
+                guard let newUrl = await fallback(url) else {
+                    self.dispatchGroup.leave()
+                    return
+                }
+                self.chooseMedia(processedEnum(newUrl), orderId: orderId)
             }

You may also want to audit the other early-return paths (e.g., guard let url = item as? URL on Line 67) for the same issue.

🤖 Prompt for AI Agents
In `@damus/Shared/Media/Models/MediaPicker.swift` around lines 141 - 153, In
attemptAcquireResourceAndChooseMedia, ensure dispatchGroup.leave() is always
called even when the async fallback returns nil: when
url.startAccessingSecurityScopedResource() succeeds call chooseMedia as before
and leave the group; when using Task and fallback(url) returns nil make sure to
call dispatchGroup.leave() before exiting the Task (or use a defer/ensure
pattern inside the Task) so the group is balanced; update the Task fallback path
accordingly and audit other early-return guards (e.g., the guard let url = item
as? URL) to likewise always call dispatchGroup.leave() on all exit paths.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 5

Caution

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

⚠️ Outside diff range comments (1)
damus/Shared/Media/Images/ImageProcessing.swift (1)

58-68: ⚠️ Potential issue | 🟡 Minor

Remove saveVideoToTemporaryFolder function.

This function is defined but never called anywhere in the codebase. It should be removed.

🤖 Fix all issues with AI agents
In @.beads/metadata.json:
- Around line 1-4: Add a gitignore entry to prevent committing the SQLite DB
referenced in .beads/metadata.json by updating the repository .gitignore to
ignore beads.db (and preferably any database files under the .beads directory);
add either "beads.db" and/or ".beads/beads.db" (or a broader pattern like
".beads/*.db") to .gitignore, save, and commit the change so the generated
SQLite file cannot be accidentally checked in.

In `@damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift`:
- Around line 283-286: The code silently swallows errors from pool.add_relay
which can leave state inconsistent; change the try? await
pool.add_relay(descriptor) to explicit error handling: call await
pool.add_relay(descriptor) inside do/catch, handle the specific
RelayAlreadyExists error by treating it as success (so you can still proceed to
connect), rethrow or log and abort for other errors, and only insert relayURL
into featureManagedRelays after add_relay either succeeded or you confirmed the
relay already exists; keep pool.connect(to:) execution gated on successful
add/confirmed-existence. Use the symbols RelayPool.RelayDescriptor,
pool.add_relay(...), pool.connect(to:), and
featureManagedRelays.insert(relayURL) to locate and update the logic.

In `@damus/Features/Vines/Creation/VineComposerView.swift`:
- Around line 11-387: The file lacks required docstrings; add concise Swift
documentation comments (/// ...) to the VineComposerView type and the specified
methods: publishVine(), handlePickedMedia(_:), uploadSelectedMedia(_:), and
convertVideoToMP4IfNeeded(localURL:) describing their purpose, parameters (where
applicable), and return behavior; place the docstrings immediately above the
struct declaration and each method/ computed property (e.g., above private func
publishVine(), private func handlePickedMedia(_:), `@MainActor` private func
uploadSelectedMedia(_:), and private func convertVideoToMP4IfNeeded(localURL:)
-> URL?) following the existing comment style used elsewhere so the new file
meets the docstring coverage guideline.
- Around line 173-174: In VineComposerView, replace the deprecated Text modifier
call `.autocapitalization(.none)` with the iOS 15+ API
`.textInputAutocapitalization(.never)` where the view chain currently also uses
`.disableAutocorrection(true)`; locate the modifier chain on the text input in
VineComposerView and swap the autocapitalization modifier name and value to
`.textInputAutocapitalization(.never)` while leaving
`.disableAutocorrection(true)` intact.

In `@damus/Features/Vines/Models/VineVideo.swift`:
- Around line 280-288: collectFallbackURLs currently treats every tag value as a
possible media URL which yields many false-positive playback candidates;
restrict it to only consider tag entries that are likely videos by first
checking tag identifiers and URL path extensions before calling normalizedURL
and addCandidate. Update collectFallbackURLs to: inspect the tag key/name (from
NostrEvent.tags) and only attempt parsing values for known video-related tags
(e.g., "video", "media", "file", "source") or when the URL's pathExtension
(lowercased) matches a whitelist of video extensions (mp4, webm, mov, m3u8, ts,
mkv, avi); then call normalizedURL and mediaKind(for:) and addCandidate as
before, skipping common image/audio extensions to avoid thumbnails/profile
images.
🧹 Nitpick comments (15)
.beads/issues.jsonl (1)

1-29: Consider whether this issue-tracking export belongs in the repository.

This file is a development workflow artifact from a "beads" issue tracker containing 29 internal task records (commit SHAs, line-number references, close reasons, developer decisions). It has no impact on the application but adds noise to the repo history. Additionally, line 24 contains an in_progress issue (damus-g0f — "Squash vine-phase1 into 5 logical commits"), suggesting this was exported before work was complete.

If this is intentionally version-controlled as a lightweight project management tool, consider adding .beads/ to the project's documentation so contributors understand its purpose. Otherwise, consider adding .beads/ to .gitignore.

AGENTS.md (1)

49-73: Consider documenting the bd sync command.

The new "Landing the Plane" workflow references bd sync (Line 62) without explanation. Contributors (or agents) unfamiliar with this tool won't know what it does or how to install it. A brief inline note or link to documentation would help.

damus/Features/Vines/Creation/VineComposerView.swift (1)

12-28: Custom Equatable on UploadPhase is unnecessary.

Swift auto-synthesizes Equatable for enums whose associated values are all Equatable. Since String is Equatable, you can remove the manual conformance:

Proposed fix
-    enum UploadPhase: Equatable {
+    enum UploadPhase: Equatable {
         case idle
         case uploading
         case uploaded
         case failed(String)
-        
-        static func == (lhs: UploadPhase, rhs: UploadPhase) -> Bool {
-            switch (lhs, rhs) {
-            case (.idle, .idle), (.uploading, .uploading), (.uploaded, .uploaded):
-                return true
-            case let (.failed(lhsMessage), .failed(rhsMessage)):
-                return lhsMessage == rhsMessage
-            default:
-                return false
-            }
-        }
     }
damus/Features/Vines/Models/VineVideo.swift (2)

409-417: Redundant condition: key == "pm-report" is already matched by key.hasPrefix("pm-").

"pm-report".hasPrefix("pm-") is always true, so the third condition is dead code.

Suggested simplification
-            if key == "proof" || key.hasPrefix("pm-") || key == "pm-report" {
+            if key == "proof" || key.hasPrefix("pm-") {

267-278: NSDataDetector is re-created on every VineVideo init — consider caching or documenting the cost.

NSDataDetector(types:) compilation is not free. Since collectContentURLs is called from init? for every event, consider making the detector a static let to avoid repeated allocation. As per coding guidelines, docstring coverage should be ensured for added code.

Proposed optimization
+    private static let linkDetector: NSDataDetector? = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
+
     private static func collectContentURLs(from content: String?, into candidates: inout [URL: MediaCandidate]) {
         guard let content, !content.isEmpty else { return }
-        guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { return }
+        guard let detector = linkDetector else { return }
         let range = NSRange(content.startIndex..<content.endIndex, in: content)
damus/Features/Settings/Models/UserSettingsStore.swift (1)

115-148: Missing docstrings for new vine-related settings.

The three new @Setting properties and the vines_feature_enabled computed property lack documentation. Other settings in this file (e.g., enable_experimental_local_relay_model at line 278) follow the pattern of having /// docstrings. As per coding guidelines, docstring coverage should be ensured for any added code.

damusTests/NostrEventTests.swift (2)

219-222: makeVineEvent helper is duplicated across both test classes.

VineVideoTests.makeVineEvent (line 219) and VineFeedModelTests.makeVineEvent (line 308) are identical. Extract into a shared file-level helper or a common extension to avoid drift.

Proposed consolidation
+// MARK: - Shared Test Helpers
+
+private func makeVineEvent(content: String = "", tags: [[String]], createdAt: UInt32 = UInt32(Date().timeIntervalSince1970)) -> NostrEvent {
+    let keypair = generate_new_keypair().to_keypair()
+    return NostrEvent(content: content, keypair: keypair, kind: NostrKind.vine_short.rawValue, tags: tags, createdAt: createdAt)!
+}

 final class VineVideoTests: XCTestCase {
-    // MARK: - Helpers
-    private func makeVineEvent(...) -> NostrEvent { ... }
 }

 final class VineFeedModelTests: XCTestCase {
-    // MARK: - Helpers
-    private func makeVineEvent(...) -> NostrEvent { ... }
 }

Also applies to: 308-311


354-366: applyPage (non-reset) always keeps existing entries over incoming duplicates, unlike apply which replaces with newer.

In apply, a newer event replaces an older one with the same dedupeKey. In applyPage(reset: false), existing entries always win regardless of timestamp. This asymmetry appears intentional for cursor-based pagination (older pages appended later), but worth documenting in the VineTestFeed docstring to prevent future confusion.

damus/Features/Vines/Views/VineFullScreenPager.swift (1)

17-22: Missing docstring on init.

As per coding guidelines, added code should have docstring coverage. The initializer lacks documentation explaining its parameters (especially initialIndex semantics and onClose lifecycle).

damus/Features/Vines/Views/VineCard.swift (2)

208-219: formatCount displays "1000.0K" for values near 999,950.

Values in the range [999,950, 999,999] will round to "1000.0K" due to %.1f formatting, which looks odd. Consider switching to integer formatting or handling this boundary:

Suggested fix
 private func formatCount(_ value: Int) -> String {
     let number = Double(value)
     let thousand = number / 1_000
     let million = number / 1_000_000
     if million >= 1.0 {
-        return String(format: "%.1fM", million)
+        return million >= 10 ? String(format: "%.0fM", million) : String(format: "%.1fM", million)
     } else if thousand >= 1.0 {
-        return String(format: "%.1fK", thousand)
+        return thousand >= 100 ? String(format: "%.0fK", thousand) : String(format: "%.1fK", thousand)
     } else {
         return "\(value)"
     }
 }

10-11: Missing docstrings on private members.

The struct-level docstring is present, but per coding guidelines, added/modified code should have docstring coverage. Several private computed properties and methods (header, videoBody, metadataRows, shouldBlurContent, authorDisplayName, formatCount, reportVine) lack documentation.

damus/Features/Vines/Views/VineTimelineView.swift (2)

61-62: Rapid tab switching causes connect/disconnect churn on the Divine relay.

onAppear calls subscribe() (which connects the relay) and onDisappear calls stop(disconnect: true) (which disconnects). If the user rapidly switches tabs, this triggers repeated relay connection cycles. Consider debouncing the disconnect or only disconnecting after a brief delay.

Suggested approach
-.onDisappear { model.stop(disconnect: true) }
+.onDisappear {
+    // Delay disconnect to avoid churn on rapid tab switches
+    model.stop(disconnect: false)
+    Task { `@MainActor` in
+        try? await Task.sleep(nanoseconds: 2_000_000_000)
+        // Only disconnect if still not subscribed
+        if model.streamTask == nil {
+            await model.disconnectManagedRelayIfNeeded()
+        }
+    }
+}

This is a rough sketch — the exact approach depends on exposing the right API from VineFeedModel.


76-91: Missing docstring on infoBanner.

Per coding guidelines, added code should have docstring coverage.

damus/Shared/Media/Images/ImageProcessing.swift (1)

165-212: Timeout logic with withTaskGroup is well-structured, but has a subtle concern on cancellation draining.

The async export with a 30-second timeout via withTaskGroup is a solid replacement for the old DispatchSemaphore. One consideration:

After group.next() returns, withTaskGroup won't return to the caller until all child tasks complete (the group drains). If the timeout fires first and exportSession.cancelExport() is called, the export task's withCheckedContinuation blocks until AVAssetExportSession fires its completion handler in response to the cancel. In practice this should be near-instant, but if the export session hangs, the function will be stuck despite the "timeout."

If robustness against a hung export session matters, consider adding an additional escape hatch (e.g., resuming the continuation with false after a secondary delay), though in practice AVAssetExportSession.cancelExport() is reliable.

damus/Features/Vines/Models/VineFeedModel.swift (1)

17-18: isLoading and relayMessage lack private(set) unlike vines.

vines on Line 16 correctly uses @Published private(set), but isLoading and relayMessage are fully writable from outside. For consistency and encapsulation, restrict external mutation:

Suggested fix
-    `@Published` var isLoading: Bool = false
-    `@Published` var relayMessage: String? = nil
+    `@Published` private(set) var isLoading: Bool = false
+    `@Published` private(set) var relayMessage: String? = nil

Comment thread .beads/metadata.json
Comment on lines +1 to +4
{
"database": "beads.db",
"jsonl_export": "issues.jsonl"
} No newline at end of file

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if beads.db exists in repo
echo "=== Checking if beads.db exists in repo ==="
fd 'beads.db' 2>/dev/null || echo "beads.db not found"

echo ""
echo "=== Checking .gitignore for beads patterns ==="
if [ -f .gitignore ]; then
  rg -n 'beads' .gitignore || echo "No beads pattern found in .gitignore"
else
  echo ".gitignore file not found"
fi

echo ""
echo "=== Full .gitignore content ==="
cat .gitignore 2>/dev/null || echo ".gitignore not accessible"

Repository: damus-io/damus

Length of output: 357


Add beads.db to .gitignore.

The .beads/metadata.json file references beads.db, which is a SQLite database that should not be committed to version control. While beads.db doesn't currently exist in the repository, it's not excluded in .gitignore. Add a pattern to prevent accidental commits if the beads tool generates this database during development.

🤖 Prompt for AI Agents
In @.beads/metadata.json around lines 1 - 4, Add a gitignore entry to prevent
committing the SQLite DB referenced in .beads/metadata.json by updating the
repository .gitignore to ignore beads.db (and preferably any database files
under the .beads directory); add either "beads.db" and/or ".beads/beads.db" (or
a broader pattern like ".beads/*.db") to .gitignore, save, and commit the change
so the generated SQLite file cannot be accidentally checked in.

Comment on lines +283 to +286
let descriptor = RelayPool.RelayDescriptor(url: relayURL, info: .readWrite)
try? await pool.add_relay(descriptor)
await pool.connect(to: [relayURL])
await MainActor.run { featureManagedRelays.insert(relayURL) }

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

Silently swallowing add_relay errors may leave inconsistent state.

try? await pool.add_relay(descriptor) discards the error. If a TOCTOU race causes RelayAlreadyExists, the method still proceeds to connect and marks the relay as feature-managed without confirming it was actually added. Consider handling the error or at least guarding the subsequent lines:

Proposed fix
     let descriptor = RelayPool.RelayDescriptor(url: relayURL, info: .readWrite)
-    try? await pool.add_relay(descriptor)
-    await pool.connect(to: [relayURL])
-    await MainActor.run { featureManagedRelays.insert(relayURL) }
+    do {
+        try await pool.add_relay(descriptor)
+        await pool.connect(to: [relayURL])
+        await MainActor.run { featureManagedRelays.insert(relayURL) }
+    } catch {
+        Log.warning("Failed to add feature relay %s: %s", for: .networking, relayURL.absoluteString, error.localizedDescription)
+    }
 }
📝 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
let descriptor = RelayPool.RelayDescriptor(url: relayURL, info: .readWrite)
try? await pool.add_relay(descriptor)
await pool.connect(to: [relayURL])
await MainActor.run { featureManagedRelays.insert(relayURL) }
let descriptor = RelayPool.RelayDescriptor(url: relayURL, info: .readWrite)
do {
try await pool.add_relay(descriptor)
await pool.connect(to: [relayURL])
await MainActor.run { featureManagedRelays.insert(relayURL) }
} catch {
Log.warning("Failed to add feature relay %s: %s", for: .networking, relayURL.absoluteString, error.localizedDescription)
}
🤖 Prompt for AI Agents
In `@damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift` around
lines 283 - 286, The code silently swallows errors from pool.add_relay which can
leave state inconsistent; change the try? await pool.add_relay(descriptor) to
explicit error handling: call await pool.add_relay(descriptor) inside do/catch,
handle the specific RelayAlreadyExists error by treating it as success (so you
can still proceed to connect), rethrow or log and abort for other errors, and
only insert relayURL into featureManagedRelays after add_relay either succeeded
or you confirmed the relay already exists; keep pool.connect(to:) execution
gated on successful add/confirmed-existence. Use the symbols
RelayPool.RelayDescriptor, pool.add_relay(...), pool.connect(to:), and
featureManagedRelays.insert(relayURL) to locate and update the logic.

Comment on lines +11 to +387
struct VineComposerView: View {
enum UploadPhase: Equatable {
case idle
case uploading
case uploaded
case failed(String)

static func == (lhs: UploadPhase, rhs: UploadPhase) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle), (.uploading, .uploading), (.uploaded, .uploaded):
return true
case let (.failed(lhsMessage), .failed(rhsMessage)):
return lhsMessage == rhsMessage
default:
return false
}
}
}

@Environment(\.dismiss) private var dismiss

let damus_state: DamusState

@State private var showingMediaPicker = false
@State private var selectedUpload: MediaUpload?
@State private var mediaDescriptor: VineMediaDescriptor?
@State private var uploadPhase: UploadPhase = .idle
@State private var showingCamera = false

@State private var vineIdentifier: String = ""
@State private var titleText: String = ""
@State private var captionText: String = ""
@State private var summaryText: String = ""
@State private var hashtagsInput: String = ""
@State private var contentWarning: String = ""
@State private var altText: String = ""
@State private var originSource: String = ""
@State private var originIdentifier: String = ""
@State private var originDetail: String = ""
@State private var referenceURL: String = ""

@State private var isPublishing = false

private let uploadService = VineBlossomUploadService()

var body: some View {
NavigationStack {
Form {
clipSection
metadataSection
advancedSection
}
.navigationTitle(NSLocalizedString("New Vine", comment: "Navigation title for the Vine composer view."))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(NSLocalizedString("Cancel", comment: "Button title to dismiss the Vine composer without publishing.")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(NSLocalizedString("Publish", comment: "Button title to publish the new Vine event.")) {
publishVine()
}
.disabled(!canPublish)
}
}
.sheet(isPresented: $showingMediaPicker) {
MediaPicker(
mediaPickerEntry: .postView,
onMediaSelected: nil,
onMediaPicked: { media in
handlePickedMedia(media)
}
)
}
.sheet(isPresented: $showingCamera) {
CameraController(
uploader: damus_state.settings.default_media_uploader,
imagesOnly: false,
mode: .handle_video { url in
showingCamera = false
handlePickedMedia(.processed_video(url))
}
)
}
}
}

private var clipSection: some View {
Section(NSLocalizedString("Clip", comment: "Section title for the selected Vine clip.")) {
if let descriptor = mediaDescriptor,
let url = descriptor.sources.first?.url {
VStack(alignment: .leading, spacing: 6) {
Text(url.lastPathComponent)
.font(.headline)
if let duration = descriptor.duration {
Text(String(format: NSLocalizedString("Duration: %.1fs", comment: "Label describing the video duration."), duration))
.font(.caption)
.foregroundColor(.secondary)
}
if let size = descriptor.dimensions {
Text("\(Int(size.width))×\(Int(size.height))")
.font(.caption2)
.foregroundColor(.secondary)
}
}
} else {
Text(NSLocalizedString("Attach a vertical clip (mp4/m3u8).", comment: "Placeholder text before a Vine clip is selected."))
.foregroundColor(.secondary)
}

Button {
showingMediaPicker = true
} label: {
Label(NSLocalizedString("Choose Video", comment: "Button to open the media picker for Vine clips."), systemImage: "film")
}

Button {
showingCamera = true
} label: {
Label(NSLocalizedString("Record Video", comment: "Button to open the Vine camera recorder."), systemImage: "video.fill")
}
.disabled(isUploadingVideo)

switch uploadPhase {
case .idle:
EmptyView()
case .uploading:
HStack(spacing: 8) {
ProgressView()
Text(NSLocalizedString("Uploading to Blossom…", comment: "Status label shown while uploading Vine video to Blossom."))
}
case .uploaded:
Label(NSLocalizedString("Upload complete.", comment: "Status label shown when Vine upload finishes successfully."), systemImage: "checkmark.circle")
.foregroundColor(.green)
case .failed(let message):
Label(message, systemImage: "exclamationmark.triangle")
.foregroundColor(.red)
}
}
}

private var metadataSection: some View {
Section(NSLocalizedString("Details", comment: "Section title for Vine metadata fields.")) {
TextField(NSLocalizedString("Title", comment: "Placeholder for Vine title field."), text: $titleText)
TextField(NSLocalizedString("Caption", comment: "Placeholder for Vine caption field."), text: $captionText, prompt: Text(NSLocalizedString("Describe your Vine…", comment: "Prompt for Vine caption field.")))
TextField(NSLocalizedString("Summary (optional)", comment: "Placeholder for Vine summary field."), text: $summaryText)
TextField(NSLocalizedString("Hashtags (comma separated)", comment: "Placeholder for Vine hashtag field."), text: $hashtagsInput)
}
}

private var advancedSection: some View {
Section(NSLocalizedString("Advanced", comment: "Section title for optional Vine metadata fields.")) {
TextField(NSLocalizedString("Identifier (optional)", comment: "Placeholder for Vine identifier/d-tag field."), text: $vineIdentifier)
TextField(NSLocalizedString("Content warning (optional)", comment: "Placeholder for the Vine content warning field."), text: $contentWarning)
TextField(NSLocalizedString("Alt text (optional)", comment: "Placeholder for Vine alternative text field."), text: $altText)
TextField(NSLocalizedString("Origin source", comment: "Placeholder for Vine origin source field."), text: $originSource)
TextField(NSLocalizedString("Origin identifier", comment: "Placeholder for Vine origin identifier field."), text: $originIdentifier)
TextField(NSLocalizedString("Origin detail", comment: "Placeholder for Vine origin detail field."), text: $originDetail)
TextField(NSLocalizedString("Reference link", comment: "Placeholder for Vine reference link field."), text: $referenceURL)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
}
}

private var canPublish: Bool {
guard damus_state.keypair.privkey != nil else { return false }
guard mediaDescriptor != nil else { return false }
guard case .uploaded = uploadPhase else { return false }
return !titleText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isPublishing
}

private var isUploadingVideo: Bool {
if case .uploading = uploadPhase {
return true
}
return false
}

private func handlePickedMedia(_ media: PreUploadedMedia) {
Task {
guard var upload = await generateMediaUpload(media) else {
await MainActor.run {
uploadPhase = .failed(NSLocalizedString("Unable to process media.", comment: "Error shown when Vine composer cannot process selected media."))
}
return
}

guard case .video(let localURL) = upload else {
await MainActor.run {
uploadPhase = .failed(NSLocalizedString("Please select a video clip.", comment: "Error shown when user selects a non-video media for Vine composer."))
}
return
}

if let convertedURL = await convertVideoToMP4IfNeeded(localURL: localURL) {
upload = .video(convertedURL)
} else if localURL.pathExtension.lowercased() != "mp4" {
await MainActor.run {
uploadPhase = .failed(NSLocalizedString("Unable to convert clip to MP4.", comment: "Error shown when Vine composer cannot transcode to MP4 for upload."))
}
return
}

await MainActor.run {
selectedUpload = upload
}
await uploadSelectedMedia(upload)
}
}

@MainActor
private func uploadSelectedMedia(_ media: MediaUpload) async {
guard let keypair = damus_state.keypair.privkey != nil ? damus_state.keypair : nil else {
uploadPhase = .failed(NSLocalizedString("A signing key is required to upload.", comment: "Error shown when trying to upload a Vine without a private key."))
return
}
uploadPhase = .uploading
mediaDescriptor = nil

Task.detached {
// Extract metadata on background thread to avoid blocking main thread
let metadata = self.videoMetadata(for: media.localURL)

do {
let response = try await uploadService.uploadVideo(
fileURL: media.localURL,
mimeType: media.mime_type,
keypair: keypair
)
let descriptor = self.makeDescriptor(from: response, mimeType: media.mime_type, videoMetadata: metadata)
await MainActor.run {
self.mediaDescriptor = descriptor
if self.vineIdentifier.isEmpty {
self.vineIdentifier = response.videoID
}
self.uploadPhase = .uploaded
}
} catch {
await MainActor.run {
if let vineError = error as? VineBlossomUploadError {
self.uploadPhase = .failed(vineError.localizedDescription)
} else {
self.uploadPhase = .failed(error.localizedDescription)
}
}
}
}
}

private func videoMetadata(for url: URL) -> (duration: TimeInterval?, dimensions: CGSize?) {
let asset = AVURLAsset(url: url)
let durationSeconds = CMTimeGetSeconds(asset.duration)
var dimensions: CGSize?
if let track = asset.tracks(withMediaType: .video).first {
let size = track.naturalSize.applying(track.preferredTransform)
dimensions = CGSize(width: abs(size.width), height: abs(size.height))
}
return (durationSeconds.isFinite ? durationSeconds : nil, dimensions)
}

private func convertVideoToMP4IfNeeded(localURL: URL) async -> URL? {
return await withCheckedContinuation { continuation in
let asset = AVAsset(url: localURL)
guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
continuation.resume(returning: localURL.pathExtension.lowercased() == "mp4" ? localURL : nil)
return
}

let destinationURL = generateUniqueTemporaryMediaURL(fileExtension: "mp4")
exporter.outputURL = destinationURL
exporter.outputFileType = .mp4
exporter.shouldOptimizeForNetworkUse = true
exporter.metadataItemFilter = AVMetadataItemFilter.forSharing()
exporter.exportAsynchronously {
switch exporter.status {
case .completed:
continuation.resume(returning: destinationURL)
default:
continuation.resume(returning: nil)
}
}
}
}

private func makeDescriptor(from response: VineBlossomUploadResponse, mimeType: String, videoMetadata: (duration: TimeInterval?, dimensions: CGSize?)) -> VineMediaDescriptor {
var sources: [VineMediaDescriptor.Source] = []
func append(_ url: URL?, kind: VineMediaDescriptor.Source.Kind) {
guard let url else { return }
sources.append(.init(url: url, kind: kind))
}
append(response.primaryURL, kind: sourceKind(for: response.primaryURL))
append(response.streamingMP4URL, kind: .mp4)
append(response.streamingHLSURL, kind: .hls)
append(response.fallbackURL, kind: .fallback)
return VineMediaDescriptor(
sources: sources,
mimeType: mimeType,
thumbnailURL: response.thumbnailURL,
blurhash: nil,
dimensions: videoMetadata.dimensions,
duration: videoMetadata.duration,
fileSize: nil,
sha256: response.videoID
)
}

private func sourceKind(for url: URL) -> VineMediaDescriptor.Source.Kind {
switch url.pathExtension.lowercased() {
case "m3u8":
return .hls
case "mp4":
return .mp4
default:
return .legacy
}
}

private func publishVine() {
guard let descriptor = mediaDescriptor else { return }
let metadata = VineDraftMetadata(
identifier: vineIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : vineIdentifier,
title: titleText.trimmingCharacters(in: .whitespacesAndNewlines),
caption: captionText.trimmingCharacters(in: .whitespacesAndNewlines),
summary: summaryText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : summaryText,
hashtags: parsedHashtags(),
publishedAt: Date(),
contentWarning: contentWarning.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : contentWarning,
altText: altText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : altText,
origin: originDescriptor(),
references: parsedReferences(),
participants: [],
expiration: nil,
extraTags: []
)

let builder = VineEventBuilder(metadata: metadata, media: descriptor)
guard let post = builder.makePost() else {
uploadPhase = .failed(NSLocalizedString("Failed to build Vine event.", comment: "Error shown when Vine builder fails to produce an event."))
return
}

isPublishing = true
notify(.post(.post(post)))
dismiss()
}

private func parsedHashtags() -> [String] {
let characterSet = CharacterSet(charactersIn: ",")
return hashtagsInput
.components(separatedBy: characterSet.union(.whitespacesAndNewlines))
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}

private func parsedReferences() -> [URL] {
guard let url = URL(string: referenceURL.trimmingCharacters(in: .whitespacesAndNewlines)),
!referenceURL.isEmpty else {
return []
}
return [url]
}

private func originDescriptor() -> VineOriginDescriptor? {
let trimmedSource = originSource.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSource.isEmpty else { return nil }
let identifier = originIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
let detail = originDetail.trimmingCharacters(in: .whitespacesAndNewlines)
return VineOriginDescriptor(
source: trimmedSource,
identifier: identifier.isEmpty ? nil : identifier,
detail: detail.isEmpty ? nil : detail
)
}
}

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.

🛠️ Refactor suggestion | 🟠 Major

Missing docstrings on the entire new file.

The coding guidelines require "Ensure docstring coverage for any code added or modified." This new file has no docstrings on the struct, its computed properties, or any of its methods. At minimum, add a brief docstring to VineComposerView, publishVine(), handlePickedMedia(_:), uploadSelectedMedia(_:), and convertVideoToMP4IfNeeded(localURL:).

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

🤖 Prompt for AI Agents
In `@damus/Features/Vines/Creation/VineComposerView.swift` around lines 11 - 387,
The file lacks required docstrings; add concise Swift documentation comments
(/// ...) to the VineComposerView type and the specified methods: publishVine(),
handlePickedMedia(_:), uploadSelectedMedia(_:), and
convertVideoToMP4IfNeeded(localURL:) describing their purpose, parameters (where
applicable), and return behavior; place the docstrings immediately above the
struct declaration and each method/ computed property (e.g., above private func
publishVine(), private func handlePickedMedia(_:), `@MainActor` private func
uploadSelectedMedia(_:), and private func convertVideoToMP4IfNeeded(localURL:)
-> URL?) following the existing comment style used elsewhere so the new file
meets the docstring coverage guideline.

Comment on lines +173 to +174
.autocapitalization(.none)
.disableAutocorrection(true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

SwiftUI autocapitalization deprecated iOS 15 textInputAutocapitalization replacement

💡 Result:

In SwiftUI, TextField/TextEditor’s .autocapitalization(_:) (which takes a UITextAutocapitalizationType) is deprecated as of iOS 15—use .textInputAutocapitalization(_:) instead. [1]

TextField("Email", text: $email)
  .textInputAutocapitalization(.never)   // iOS 15+

TextInputAutocapitalization values are: .sentences (default), .words, .characters, and .never. [2]

For TextEditor on iOS 15+:

TextEditor(text: $comment)
  .textInputAutocapitalization(.never)

[3]

Sources: [1] [2] [3]


🏁 Script executed:

cat -n damus/Features/Vines/Creation/VineComposerView.swift | sed -n '165,180p'

Repository: damus-io/damus

Length of output: 1462


Replace deprecated .autocapitalization(.none) with .textInputAutocapitalization(.never).

The autocapitalization(_:) modifier was deprecated in iOS 15. Use .textInputAutocapitalization(.never) instead.

Proposed fix
             .keyboardType(.URL)
-                .autocapitalization(.none)
+                .textInputAutocapitalization(.never)
                 .disableAutocorrection(true)
📝 Committable suggestion

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

Suggested change
.autocapitalization(.none)
.disableAutocorrection(true)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
🤖 Prompt for AI Agents
In `@damus/Features/Vines/Creation/VineComposerView.swift` around lines 173 - 174,
In VineComposerView, replace the deprecated Text modifier call
`.autocapitalization(.none)` with the iOS 15+ API
`.textInputAutocapitalization(.never)` where the view chain currently also uses
`.disableAutocorrection(true)`; locate the modifier chain on the text input in
VineComposerView and swap the autocapitalization modifier name and value to
`.textInputAutocapitalization(.never)` while leaving
`.disableAutocorrection(true)` intact.

Comment thread damus/Features/Vines/Models/VineVideo.swift Outdated
- Parse each imeta tag as a separate media variant (was flattened)
- Weight url and fallback URLs equally per spec
- Remove non-spec video source extraction (standalone url, streaming,
  r/e/i tags)
- Add segment, text-track, and bitrate/service parsing
- Content URL fallback now only matches video file extensions
- Remove video resolution from card UI, fix duration display
- Space out EventActionBar icons in VineCard

Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@damus/Features/Vines/Views/VineCard.swift`:
- Around line 204-215: The formatCount(_:) function can produce "1000.0K" for
values just under 1,000,000; change the logic in formatCount to detect when the
thousand-based rounding would hit 1000.0K and instead return "1.0M". For
example, before returning the "%.1fK" result in formatCount, compute the
rounded-to-one-decimal thousand value (or test thousand >= 999.95) and if that
rounded value >= 1000.0, return the million-format string (e.g., "1.0M");
otherwise fall back to the existing K or raw value branches.
🧹 Nitpick comments (5)
damus/Features/Vines/Models/VineVideo.swift (3)

366-374: Redundant "pm-report" check in proof tag filter.

key == "pm-report" is already covered by key.hasPrefix("pm-"). The extra check is harmless but unnecessary.

♻️ Suggested simplification
-            if key == "proof" || key.hasPrefix("pm-") || key == "pm-report" {
+            if key == "proof" || key.hasPrefix("pm-") {

390-402: Missing docstrings on intTagValue and tagValue helpers.

Per coding guidelines, all added code should have docstring coverage. These two helpers lack documentation.

📝 Suggested docstrings
+    /// Returns the integer value of the first tag matching `key`, or `nil` if absent or non-numeric.
     private static func intTagValue(_ key: String, in event: NostrEvent) -> Int? {
         guard let value = tagValue(key, in: event) else { return nil }
         return Int(value)
     }

+    /// Returns the first value associated with a tag whose key matches `key`, or `nil` if not found.
     private static func tagValue(_ key: String, in event: NostrEvent) -> String? {

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


39-60: VineOrigin and VineProof lack doc comments.

VideoSegment and TextTrack (lines 62–74) have doc comments, but VineOrigin and VineProof do not. As per coding guidelines: "Ensure docstring coverage for any code added or modified".

damusTests/NostrEventTests.swift (2)

219-222: makeVineEvent helper is duplicated across both test classes.

The identical helper exists in both VineVideoTests (line 219) and VineFeedModelTests (line 308). Consider extracting it to a shared extension or free function in the test target.

♻️ Extract shared helper

Add a shared helper at file scope or in a test utilities file:

/// Creates a minimal Vine event for testing.
private func makeVineEvent(content: String = "", tags: [[String]], createdAt: UInt32 = UInt32(Date().timeIntervalSince1970)) -> NostrEvent {
    let keypair = generate_new_keypair().to_keypair()
    return NostrEvent(content: content, keypair: keypair, kind: NostrKind.vine_short.rawValue, tags: tags, createdAt: createdAt)!
}

Then remove the duplicate from both classes.

Also applies to: 308-311


225-228: Test doubles mirror production logic — consider testing production code directly.

VineTestFeed and PrefetchGate duplicate core algorithms from VineFeedModel rather than exercising the production code paths. If the production deduplication or prefetch gating logic diverges, these tests won't catch regressions. The docstrings acknowledge this tradeoff. When feasible, consider refactoring VineFeedModel to expose testable units that can be exercised without requiring DamusState.

Also applies to: 314-327, 329-366

Comment thread damus/Features/Vines/Views/VineCard.swift
@alltheseas
alltheseas marked this pull request as draft February 11, 2026 08:34
- Add .beads/*.db to .gitignore to prevent committing SQLite files
- Replace try? with explicit do/catch in ensureRelayConnected to avoid
  silent failures and inconsistent state
- Add docstrings to VineComposerView and key methods
- Replace deprecated .autocapitalization(.none) with
  .textInputAutocapitalization(.never)
- Note: collectFallbackURLs was already removed in NIP-71 refactor

Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alltheseas

Copy link
Copy Markdown
Collaborator Author

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

@alltheseas alltheseas closed this Feb 13, 2026
@alltheseas alltheseas reopened this Feb 21, 2026
@danieldaquino

Copy link
Copy Markdown
Collaborator

Closing this as #3619 was closed.

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants