Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions App/Features/Compose/RepostSheetView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// RepostSheetView
//
// Small sheet opened from a message row's "Repost" context-menu item
// Small sheet opened from a message row's "Push & Comment" action
// (PLAN.md §6 M2). Collects optional commentary + visibility, then
// calls into `RepostSheetViewModel.submit()`. UI is intentionally
// minimal — a multi-line commentary field and a visibility segment.
Expand Down Expand Up @@ -45,7 +45,7 @@ struct RepostSheetView: View {
@ViewBuilder
private func sheetBody(viewModel: RepostSheetViewModel) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Repost")
Text("Push & Comment")
.font(.ilSubtitle())

originalPreview
Expand All @@ -66,7 +66,7 @@ struct RepostSheetView: View {
RoundedRectangle(cornerRadius: ILMetric.radiusSm)
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
)
.accessibilityLabel("Repost commentary")
.accessibilityLabel("Push commentary")
}

Picker("Visibility", selection: Binding(
Expand All @@ -78,7 +78,7 @@ struct RepostSheetView: View {
}
.pickerStyle(.segmented)
.frame(maxWidth: 280)
.accessibilityLabel("Repost visibility")
.accessibilityLabel("Push visibility")

if let error = viewModel.error {
Label(error.localizedDescription, systemImage: "exclamationmark.triangle.fill")
Expand Down Expand Up @@ -120,11 +120,11 @@ struct RepostSheetView: View {
if viewModel.isSubmitting {
ProgressView()
.controlSize(.small)
.accessibilityLabel("Submitting repost")
.accessibilityLabel("Submitting push")
.padding(.trailing, 8)
}

Button("Repost") {
Button("Push") {
Task { await viewModel.submit() }
}
.buttonStyle(.borderedProminent)
Expand All @@ -138,7 +138,7 @@ struct RepostSheetView: View {
Image(systemName: "wrench.adjustable")
.font(.ilDisplay(36))
.foregroundStyle(.secondary)
Text("Repost unavailable")
Text("Push unavailable")
.font(.ilSubtitle())
Text("AppEnvironment is not injected into the view tree.")
.foregroundStyle(.secondary)
Expand Down
13 changes: 7 additions & 6 deletions App/Features/Timeline/CreateIssueFromMessageViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
// a link back to the post), and creates the issue in the chosen repo via the
// domain `GitHubServicing` surface.
//
// The permalink woven into the issue body comes from the shared
// `MessagePermalink` builder in `InterlinedDomain` (GitHub #27) rather than a
// private copy here, so the row's Link action, Push & Comment, and this issue
// body all agree on one URL shape.
//
// Like `GitHubIssuesViewModel`, the unlinked-account state is first-class:
// `GitHubServiceError.notLinked` flips `linkState` so the view can show a
// "Link GitHub" CTA instead of a raw error. Depends only on `GitHubServicing`
Expand Down Expand Up @@ -50,12 +55,12 @@ final class CreateIssueFromMessageViewModel {
init(
github: GitHubServicing,
message: Message,
webBaseURL: URL = URL(string: "https://interlinedlist.com")!
webBaseURL: URL = MessagePermalink.defaultWebBaseURL
) {
self.github = github
self.message = message
self.title = Self.suggestedTitle(from: message)
self.body = Self.suggestedBody(from: message, permalink: Self.permalink(for: message, base: webBaseURL))
self.body = Self.suggestedBody(from: message, permalink: message.permalink(base: webBaseURL))
}

// MARK: - Loading
Expand Down Expand Up @@ -139,10 +144,6 @@ final class CreateIssueFromMessageViewModel {
return parts.joined(separator: "\n\n")
}

static func permalink(for message: Message, base: URL) -> URL? {
base.appendingPathComponent("messages").appendingPathComponent(message.id)
}

// MARK: - Helpers

private func handle(_ error: GitHubServiceError) {
Expand Down
63 changes: 63 additions & 0 deletions App/Features/Timeline/Message+OptimisticUpdates.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Message+OptimisticUpdates
//
// Local, non-networking copies of a `Message` used to paint an optimistic
// change before the round-trip resolves. Shared by `TimelineViewModel` and
// `MessageDetailViewModel`, which previously each carried a private copy of
// the dig helper (GitHub #27 needed a second one for Push, so the duplication
// was consolidated rather than tripled).
//
// Both helpers route through `replacing(...)`, which copies EVERY field. The
// two private versions this replaces omitted `crossPostResults`,
// `crossPostLocations` and `linkPreviews`, so digging a post visibly dropped
// its link preview cards and cross-post pills until the next refetch. Copying
// exhaustively fixes that.

import Foundation
import InterlinedDomain

extension Message {

/// A copy with the dig state flipped — boolean toggled and the count
/// nudged ±1, floored at zero so a stale count can't go negative.
func byTogglingDig() -> Message {
let newDidDig = !didDig
let delta = newDidDig ? 1 : -1
return replacing(digCount: max(0, digCount + delta), didDig: newDidDig)
}

/// A copy with the push ("repost") count incremented by one. Used by the
/// one-tap Push action: the API returns the *new* push message, not an
/// updated original, so the original's count is nudged locally.
func byIncrementingPushCount() -> Message {
replacing(repostCount: repostCount + 1)
}

/// Field-wise copy. Only the named counters vary; everything else —
/// including the fetch-time-only `linkPreviews` and `crossPostLocations`
/// — is carried across untouched.
private func replacing(
digCount: Int? = nil,
didDig: Bool? = nil,
repostCount: Int? = nil
) -> Message {
Message(
id: id,
author: author,
text: text,
createdAt: createdAt,
updatedAt: updatedAt,
tags: tags,
visibility: visibility,
digCount: digCount ?? self.digCount,
didDig: didDig ?? self.didDig,
repostCount: repostCount ?? self.repostCount,
replyCount: replyCount,
parentID: parentID,
repost: repost,
scheduledAt: scheduledAt,
crossPostResults: crossPostResults,
crossPostLocations: crossPostLocations,
linkPreviews: linkPreviews
)
}
}
68 changes: 55 additions & 13 deletions App/Features/Timeline/MessageDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ struct MessageDetailView: View {

let messageID: Message.ID

/// GitHub #27 — row-level Reply intent handed down by `TimelineRootView`.
/// When it matches this screen's `messageID`, the composer opens already
/// expanded and focused. Cleared on consumption so returning to the same
/// thread by a plain tap does not re-open it.
@Binding private var pendingReplyMessageID: String?

init(
messageID: Message.ID,
pendingReplyMessageID: Binding<String?> = .constant(nil)
) {
self.messageID = messageID
self._pendingReplyMessageID = pendingReplyMessageID
}

@Environment(\.appEnvironment) private var environment
@Environment(\.dismiss) private var dismiss

Expand All @@ -43,6 +57,10 @@ struct MessageDetailView: View {
@State private var replyBody: String = ""
@State private var isReplyExpanded: Bool = false

/// Focuses the reply editor when the composer is opened from a row's
/// Reply action, so the user can start typing without a second click.
@FocusState private var replyFieldFocused: Bool

var body: some View {
Group {
if let viewModel {
Expand Down Expand Up @@ -78,10 +96,20 @@ struct MessageDetailView: View {
}
.task {
if viewModel == nil, let environment {
let model = MessageDetailViewModel(messages: environment.messages, messageID: messageID)
let model = MessageDetailViewModel(
messages: environment.messages,
messageID: messageID,
eventBus: environment.composerEventBus
)
viewModel = model
await model.load()
}
// Consume a pending Reply intent from the timeline row.
if pendingReplyMessageID == messageID {
pendingReplyMessageID = nil
isReplyExpanded = true
replyFieldFocused = true
}
}
.task(id: environmentEventBusToken) {
guard let environment, let viewModel else { return }
Expand Down Expand Up @@ -147,12 +175,7 @@ struct MessageDetailView: View {
MessageRowView(
message: message,
canEdit: viewModel.canEdit(message, currentUserID: currentUserID),
onToggleDig: { tapped in
Task { await viewModel.toggleDig(on: tapped) }
},
onRepost: { tapped in repostTarget = tapped },
onEdit: { tapped in editTarget = tapped },
onDelete: { tapped in deleteTarget = tapped }
actions: rowActions(viewModel: viewModel)
)
.padding(.horizontal, 16)
Divider()
Expand All @@ -178,6 +201,29 @@ struct MessageDetailView: View {
}
}

/// Shared action set for the header row and every reply row, so both
/// behave identically (each reply can be dug, pushed, edited, deleted).
private func rowActions(viewModel: MessageDetailViewModel) -> MessageRowActions {
MessageRowActions(
onToggleDig: { tapped in
Task { await viewModel.toggleDig(on: tapped) }
},
onReply: { _ in
// The replies list is flat, so Reply from any row \u{2014} the
// header or a reply \u{2014} opens this thread's one composer,
// which posts against the root message.
isReplyExpanded = true
replyFieldFocused = true
},
onPush: { tapped in
Task { await viewModel.push(tapped) }
},
onPushAndComment: { tapped in repostTarget = tapped },
onEdit: { tapped in editTarget = tapped },
onDelete: { tapped in deleteTarget = tapped }
)
}

@ViewBuilder
private func repliesSection(viewModel: MessageDetailViewModel, currentUserID: String?) -> some View {
VStack(alignment: .leading, spacing: 8) {
Expand All @@ -194,12 +240,7 @@ struct MessageDetailView: View {
MessageRowView(
message: reply,
canEdit: viewModel.canEdit(reply, currentUserID: currentUserID),
onToggleDig: { tapped in
Task { await viewModel.toggleDig(on: tapped) }
},
onRepost: { tapped in repostTarget = tapped },
onEdit: { tapped in editTarget = tapped },
onDelete: { tapped in deleteTarget = tapped }
actions: rowActions(viewModel: viewModel)
)
.padding(.horizontal, 16)
Divider()
Expand All @@ -217,6 +258,7 @@ struct MessageDetailView: View {
content: {
VStack(alignment: .leading, spacing: 8) {
TextEditor(text: $replyBody)
.focused($replyFieldFocused)
.font(.ilBody())
.frame(minHeight: 80)
.scrollContentBackground(.hidden)
Expand Down
70 changes: 41 additions & 29 deletions App/Features/Timeline/MessageDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ final class MessageDetailViewModel {
static let pageSize: Int = 50

private let messages: MessagesServicing

/// Optional cross-window bus. When wired, a successful one-tap Push
/// publishes `.messageReposted` so the timeline prepends it. Defaults
/// to nil so unit tests construct the view model unchanged.
private let eventBus: ComposerEventBus?
private let messageID: String

private(set) var message: Message?
Expand All @@ -50,6 +55,9 @@ final class MessageDetailViewModel {
/// the view knows to dismiss / pop the detail screen.
private(set) var didDeleteRoot: Bool = false

/// Message IDs with a Push in flight, de-bouncing a double-click.
private var pendingPushOperations: Set<String> = []

/// IDs of messages with an in-flight dig toggle. Prevents the
/// same row from firing twice while the round-trip resolves.
private var pendingDigOperations: Set<String> = []
Expand All @@ -60,9 +68,14 @@ final class MessageDetailViewModel {
/// resolves.
private(set) var pendingMarkdownExport: MarkdownExportRequest?

init(messages: MessagesServicing, messageID: String) {
init(
messages: MessagesServicing,
messageID: String,
eventBus: ComposerEventBus? = nil
) {
self.messages = messages
self.messageID = messageID
self.eventBus = eventBus
}

// MARK: - Read
Expand Down Expand Up @@ -197,6 +210,33 @@ final class MessageDetailViewModel {
}
}


// MARK: - Push (bare repost)

/// One-tap Push from the detail header or any reply row: reposts with no
/// commentary (GitHub #27). "Push & Comment" stays the separate sheet.
///
/// Unlike the timeline there is no list to prepend into — the pushed
/// message belongs on the feed, not in this thread — so the local effect
/// is just the source row's count, plus the bus event for the timeline.
func push(_ message: Message) async {
let id = message.id
guard !pendingPushOperations.contains(id) else { return }
pendingPushOperations.insert(id)
defer { pendingPushOperations.remove(id) }

do {
let pushed = try await messages.repost(id, commentary: nil, visibility: .public)
if let current = currentCopy(of: id) {
replace(id: id, with: current.byIncrementingPushCount())
}
eventBus?.post(.messageReposted(pushed))
error = nil
} catch {
self.error = error
}
}

// MARK: - M2 — Delete root message

/// Deletes the loaded root message. The view confirms via a
Expand Down Expand Up @@ -267,31 +307,3 @@ final class MessageDetailViewModel {
}
}
}

// MARK: - Optimistic dig helper

private extension Message {
/// Returns a copy with the dig state flipped (boolean toggled and
/// the count nudged ±1). Used by `toggleDig` to apply the
/// optimistic local change before the round-trip resolves.
func byTogglingDig() -> Message {
let newDidDig = !didDig
let delta = newDidDig ? 1 : -1
return Message(
id: id,
author: author,
text: text,
createdAt: createdAt,
updatedAt: updatedAt,
tags: tags,
visibility: visibility,
digCount: max(0, digCount + delta),
didDig: newDidDig,
repostCount: repostCount,
replyCount: replyCount,
parentID: parentID,
repost: repost,
scheduledAt: scheduledAt
)
}
}
Loading