Skip to content

feat(timeline): web-parity message actions — Reply, I Dig!, Link, Push, Push & Comment - #32

Merged
Adron merged 6 commits into
devfrom
feat/message-actions-parity
Sep 7, 2026
Merged

feat(timeline): web-parity message actions — Reply, I Dig!, Link, Push, Push & Comment#32
Adron merged 6 commits into
devfrom
feat/message-actions-parity

Conversation

@Adron

@Adron Adron commented Sep 7, 2026

Copy link
Copy Markdown
Member

Issue: #27

Implements the five web message actions on the native client: Reply, I Dig!, Link, Push, and Push & Comment.

The short version

No new networking was needed. Kit and Domain already implemented every verb — the wire has spoken pushedMessageId all along. The gap was entirely App-layer affordances plus naming. That's why this is four small commits rather than a feature build.

Action Before After
Reply only inside MessageDetailView's composer on every row; routes to the thread with the composer open and focused
I Dig! worked, labelled by count only unchanged behaviour, VoiceOver now says "I Dig!" / "Undo I Dig!"
Link did not exist SwiftUI.ShareLink over a new domain permalink
Push "Repost", context-menu only, always opened the commentary sheet one-tap bare push in the action bar
Push & Comment not a distinct action its own action; same sheet, now correctly named

Commits

  1. 28c2dfcdomain permalink. Promotes the permalink builder out of CreateIssueFromMessageViewModel, where it was private, into InterlinedDomain. Stricter than what it replaces: trims the id, returns nil for a blank one instead of a half-formed URL, and percent-encodes / so a malformed id can't forge path segments outside /messages/.
  2. d8e7476MessageRowActions refactor. The row took eight closure parameters and this PR adds three more. Collapsed into one value. Provably behaviour-neutral: 627/627 before and after.
  3. d07c152the five affordances, mirrored into the context menu, each with a VoiceOver label.
  4. be26f57the rename to web vocabulary.

Decisions flagged for review

  • Copy Link uses SwiftUI.ShareLink (spelled with the module prefix — InterlinedDomain exports its own ShareLink). Its share sheet includes Copy, so no NSPasteboard and no AppKit. Follows the precedent in ShareLinksView. This PR adds no AppKit imports; the four existing ones are untouched.
  • One-tap Push posts at .public, matching RepostSheetViewModel's existing default.
  • Push & Comment reuses RepostSheetView. No new ComposerMode case.
  • Reply routes to the detail composer rather than adding a second inline write surface on the feed. In the detail view the replies list is flat, so Reply from any row opens the one thread composer.
  • Type and file names were left aloneRepostSheetView, ComposerEvent.messageReposted, and the domain's repostCount (which mirrors the wire field). Only user-facing strings and onRepostonPushAndComment changed. Renaming the types would balloon the diff for no user-visible gain; happy to do it as a follow-up if you'd rather.

Bug fixed along the way

TimelineViewModel and MessageDetailViewModel each carried a private byTogglingDig() that rebuilt the Message without crossPostResults, crossPostLocations, or linkPreviews — so digging a post visibly dropped its link-preview cards and cross-post pills until the next refetch. Push needed a second such helper, so both were folded into a shared Message+OptimisticUpdates that copies every field. Not in the issue's scope, but it was the exact code being extended.

Verification — observed, not assumed

Re-run after merging origin/dev (PRs #24 and #25 landed mid-branch):

App build ......................... ** BUILD SUCCEEDED **
App tests ......................... 673/673
InterlinedKit ..................... 365/365
InterlinedDomain .................. 661/661
InterlinedPersistence ............. 135/135
Decision 0003 grep ................ zero real hits
AppKit imports .................... 2 files (AppDelegate.swift, ILColor.swift), both pre-existing

New BDD quartets: MessagePermalinkTests (9), MessageRowActionsTests (5), plus push coverage on both view models — happy, invalid, upstream-failure, and boundary including the double-click de-bounce.

Not done: the interactive pass over all three hosts (timeline / detail / search) needs a signed-in session, which I don't have. Worth a few minutes before merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Tm5htvRQsWxNeq7cmaAakW

Adron and others added 6 commits September 6, 2026 22:47
The "Link" message action (#27) needs a canonical web URL for a post, but
the only permalink logic in the tree was private to
CreateIssueFromMessageViewModel. Promote it to InterlinedDomain so the row's
Link action, Push & Comment, and the create-issue body all agree on one shape.

The builder is stricter than the code it replaces: it trims the id, returns
nil for a blank one rather than emitting a half-formed URL, and percent-encodes
"/" so a malformed id can never forge extra path segments outside /messages/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm5htvRQsWxNeq7cmaAakW
…ctions

MessageRowView took eight individual closure parameters and #27 adds three
more (Reply, Push, Link), which pushes the call sites past readable. Gather
them into one MessageRowActions value instead.

Behaviour-neutral: every handler stays optional with the same "nil means the
row hides that affordance" contract, and the row stays passive. SearchRootView
needs no change — it already relied on the defaults, which are now `.none`.

`none` is computed rather than a static let: the handlers are non-Sendable
closures, so a shared static instance fails Swift 6 concurrency checking.

Verified: App suite 627/627 green, unchanged from before the refactor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm5htvRQsWxNeq7cmaAakW
Closes the web-parity gap in #27. No new networking — Kit and Domain already
implemented every verb; only the App-layer affordances were missing.

- Reply: navigates to the thread and opens its composer already expanded and
  focused, so there is one reply write surface rather than two.
- Push: one-tap bare repost (nil commentary, public), matching the web. The
  API returns the new push rather than an updated original, so the source
  row's count is nudged locally and the push is prepended.
- Push & Comment: unchanged path through RepostSheetView, now a distinct
  action instead of the only way to repost.
- Link: SwiftUI.ShareLink over the new domain permalink. Unconditional, since
  it needs no host wiring. No NSPasteboard, no AppKit.

All five are mirrored into the context menu and carry VoiceOver labels.
Reply/Dig/Push degrade to plain count labels in read-only hosts.

Also folds the two duplicated private `byTogglingDig` helpers into a shared
Message+OptimisticUpdates. Both copies dropped crossPostResults,
crossPostLocations and linkPreviews, so digging a post visibly lost its link
preview cards until the next refetch; the shared version copies every field.

Verified: App suite 636/636 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm5htvRQsWxNeq7cmaAakW
Approved by the repo owner on #27. User-facing strings only, plus the one
action name that sat awkwardly next to its new sibling:

- "Repost" -> "Push"; the commentary sheet is now titled "Push & Comment"
- "Reposted from @x" -> "Pushed from @x"
- dig VoiceOver labels -> "I Dig!" / "Undo I Dig!"
- MessageRowActions.onRepost -> onPushAndComment

Type and file names (RepostSheetView, ComposerEvent.messageReposted) and the
domain's repostCount are deliberately untouched: repostCount mirrors the wire
field, and renaming the types would balloon the diff for no user-visible gain.

Verified: App 636/636, Kit 314/314, Domain 628/628, Persistence 135/135.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm5htvRQsWxNeq7cmaAakW
…arity

Resolves the conflict `dev` created by landing "Create from…" (G16, PR #31)
on `MessageRowView` while this branch was collapsing that same row's handler
list into `MessageRowActions`.

The two sides disagreed about call *shape*, not behaviour: `dev` added an
eleventh standalone closure parameter, this branch replaced the parameter
tail with one value. Keeping either side alone would have been wrong —
`dev`'s form reintroduces exactly the long tail this branch removes, and
this branch's form silently drops a feature `dev` just shipped.

So `onCreateFrom` is folded into `MessageRowActions` alongside the other
host-sheet handlers, `MessageRowView` reads it through `actions`, and
`TimelineRootView.rowActions(_:)` wires it to the existing
`createFromTarget` sheet. The affordance behaves exactly as it does on
`dev`; only where its closure is declared has changed.

Note that git auto-merged `MessageRowView.swift` without a conflict into a
hybrid — the collected `actions` value *and* a leftover standalone
`onCreateFrom` property. It compiled, which is why it needed catching by
reading rather than by the gate.

Verified on the merged tree: build succeeded; 710 App tests, 388 Kit,
699 Domain, 135 Persistence, all passing; Decision 0003 grep clean;
project.pbxproj untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYA3FQuyhHgvpbbeMFuYrx
@Adron
Adron merged commit e8cadce into dev Sep 7, 2026
8 checks passed
@Adron
Adron deleted the feat/message-actions-parity branch September 7, 2026 06:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant