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
5 changes: 4 additions & 1 deletion App/Features/Compose/RepostSheetView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ struct RepostSheetView: View {
viewModel = RepostSheetViewModel(
messages: environment.messages,
eventBus: environment.composerEventBus,
originalMessageID: original.id
originalMessageID: original.id,
// Same account preference the composer seeds from, so a
// repost and a new message agree on the default.
initialVisibility: environment.defaultComposeVisibility
)
}
}
Expand Down
14 changes: 10 additions & 4 deletions App/Features/Compose/RepostSheetViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ final class RepostSheetViewModel {
/// `repost` convenience.
var commentary: String = ""

/// Visibility of the *repost*, not the original. Defaults to
/// public — a bare-repost is implicitly a share.
var visibility: Visibility = .public
/// Visibility of the *repost*, not the original. Seeded from the account's
/// "new posts are public by default" preference, like the composer — a
/// repost is a post, so an account that defaults to private must not have
/// its reposts silently go public.
var visibility: Visibility

// MARK: - Read-only state

Expand All @@ -54,11 +56,15 @@ final class RepostSheetViewModel {
init(
messages: MessagesServicing,
eventBus: ComposerEventBus,
originalMessageID: String
originalMessageID: String,
initialVisibility: Visibility = .public
) {
self.messages = messages
self.eventBus = eventBus
self.originalMessageID = originalMessageID
// Falls back to `.public` when no account has resolved, matching
// `UserSettings.default`.
self.visibility = initialVisibility
}

/// Submits the repost. `nil` commentary when the field is empty so
Expand Down
5 changes: 4 additions & 1 deletion App/Features/Timeline/MessageDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ struct MessageDetailView: View {
let model = MessageDetailViewModel(
messages: environment.messages,
messageID: messageID,
eventBus: environment.composerEventBus
eventBus: environment.composerEventBus,
// An inline reply is a post: it honours the same account
// default as the composer and the repost sheet.
defaultVisibility: environment.defaultComposeVisibility
)
viewModel = model
await model.load()
Expand Down
18 changes: 15 additions & 3 deletions App/Features/Timeline/MessageDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,23 @@ final class MessageDetailViewModel {
/// resolves.
private(set) var pendingMarkdownExport: MarkdownExportRequest?

/// The account's "new posts are public by default" preference, applied to
/// an inline reply. The reply composer has no visibility control of its own,
/// so this is the only thing standing between a private-by-default account
/// and a public reply. Falls back to `.public` when no account has resolved,
/// matching `UserSettings.default`.
private let defaultVisibility: Visibility

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

// MARK: - Read
Expand Down Expand Up @@ -152,10 +161,13 @@ final class MessageDetailViewModel {
/// without a full refetch; on failure surfaces `replyError` so
/// the composer's "Reply" button can show the error inline.
@discardableResult
/// `visibility` defaults to `nil`, meaning "use the account preference"
/// (`defaultVisibility`). An explicit value still wins, so a caller that
/// needs a specific visibility — or a test asserting one — can pass it.
func postReply(
body: String,
tags: [String] = [],
visibility: Visibility = .public
visibility: Visibility? = nil
) async -> Message? {
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !isPostingReply else { return nil }
Expand All @@ -169,7 +181,7 @@ final class MessageDetailViewModel {
to: messageID,
body: trimmed,
tags: tags,
visibility: visibility
visibility: visibility ?? defaultVisibility
)
replies.append(reply)
return reply
Expand Down
77 changes: 77 additions & 0 deletions AppTests/MessageDetailViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -345,4 +345,81 @@ final class MessageDetailViewModelTests: XCTestCase {

XCTAssertNil(viewModel.pendingMarkdownExport)
}

// MARK: - Reply visibility (account preference)
//
// The inline reply composer has no visibility control, so the account
// preference is the only thing keeping a private-by-default account from
// replying publicly. Quartet: happy, absent account, explicit override,
// and the invalid-input guard.

func test_givenPrivateAccountDefault_whenPostingReply_thenRepliesPrivately() async {
let stub = StubMessagesService()
await stub.enqueueReply(success: MessageFixtures.message(id: "r-1", text: "hi"))
let viewModel = MessageDetailViewModel(
messages: stub,
messageID: "m-1",
defaultVisibility: .private
)

// The view calls `postReply(body:)` with no visibility — exactly this.
_ = await viewModel.postReply(body: "hi")

let recorded = await stub.recorded
guard case .reply(_, _, _, let visibility) = recorded.first?.kind else {
return XCTFail("Expected a `reply` call, got \(String(describing: recorded.first))")
}
XCTAssertEqual(visibility, .private)
}

func test_givenNoResolvedAccount_whenPostingReply_thenFallsBackToPublic() async {
// Absent input: signed out / unresolved session.
let stub = StubMessagesService()
await stub.enqueueReply(success: MessageFixtures.message(id: "r-2", text: "hi"))
let viewModel = MessageDetailViewModel(messages: stub, messageID: "m-1")

_ = await viewModel.postReply(body: "hi")

let recorded = await stub.recorded
guard case .reply(_, _, _, let visibility) = recorded.first?.kind else {
return XCTFail("Expected a `reply` call, got \(String(describing: recorded.first))")
}
XCTAssertEqual(visibility, .public)
}

func test_givenPrivateAccountDefault_whenCallerPassesExplicitVisibility_thenExplicitWins() async {
// Boundary: an explicit argument still overrides the account default.
let stub = StubMessagesService()
await stub.enqueueReply(success: MessageFixtures.message(id: "r-3", text: "hi"))
let viewModel = MessageDetailViewModel(
messages: stub,
messageID: "m-1",
defaultVisibility: .private
)

_ = await viewModel.postReply(body: "hi", visibility: .public)

let recorded = await stub.recorded
guard case .reply(_, _, _, let visibility) = recorded.first?.kind else {
return XCTFail("Expected a `reply` call, got \(String(describing: recorded.first))")
}
XCTAssertEqual(visibility, .public)
}

func test_givenPrivateAccountDefaultAndEmptyBody_whenPostingReply_thenNoCallIsMade() async {
// Invalid input: the empty-body guard still short-circuits before the
// preference is ever consulted.
let stub = StubMessagesService()
let viewModel = MessageDetailViewModel(
messages: stub,
messageID: "m-1",
defaultVisibility: .private
)

let posted = await viewModel.postReply(body: " ")

XCTAssertNil(posted)
let recorded = await stub.recorded
XCTAssertTrue(recorded.isEmpty)
}
}
70 changes: 70 additions & 0 deletions AppTests/RepostSheetViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,74 @@ final class RepostSheetViewModelTests: XCTestCase {
}
XCTAssertTrue(viewModel.didFinish)
}

// MARK: - Default visibility (account preference)
//
// A repost is a post, so it seeds from the same `defaultPubliclyVisible`
// preference as the composer. Quartet: happy, absent account, boundary
// (explicit pick wins), and the wire assertion.

func test_givenPrivateAccountDefault_whenOpeningRepostSheet_thenSeedsPrivate() {
let viewModel = RepostSheetViewModel(
messages: StubMessagesService(),
eventBus: ComposerEventBus(),
originalMessageID: "m-1",
initialVisibility: .private
)

XCTAssertEqual(viewModel.visibility, .private)
}

func test_givenNoResolvedAccount_whenOpeningRepostSheet_thenFallsBackToPublic() {
// Absent input: signed out or session unresolved — the same path
// `AppEnvironment` takes when `currentUser` is nil.
let viewModel = RepostSheetViewModel(
messages: StubMessagesService(),
eventBus: ComposerEventBus(),
originalMessageID: "m-1"
)

XCTAssertEqual(viewModel.visibility, .public)
}

func test_givenPrivateAccountDefault_whenReposting_thenPostsPrivately() async {
// Happy path end-to-end: the seed reaches the wire, not just the picker.
let stub = StubMessagesService()
await stub.enqueueRepost(success: MessageFixtures.message(id: "r-1", text: ""))
let viewModel = RepostSheetViewModel(
messages: stub,
eventBus: ComposerEventBus(),
originalMessageID: "m-1",
initialVisibility: .private
)

await viewModel.submit()

let recorded = await stub.recorded
guard case .repost(_, _, let visibility) = recorded.first?.kind else {
return XCTFail("Expected a `repost` call, got \(String(describing: recorded.first))")
}
XCTAssertEqual(visibility, .private)
}

func test_givenSeededPrivate_whenUserPicksPublic_thenTheUserChoiceIsWhatPosts() async {
// Boundary: the seed is a default, not a lock.
let stub = StubMessagesService()
await stub.enqueueRepost(success: MessageFixtures.message(id: "r-2", text: ""))
let viewModel = RepostSheetViewModel(
messages: stub,
eventBus: ComposerEventBus(),
originalMessageID: "m-1",
initialVisibility: .private
)
viewModel.visibility = .public

await viewModel.submit()

let recorded = await stub.recorded
guard case .repost(_, _, let visibility) = recorded.first?.kind else {
return XCTFail("Expected a `repost` call, got \(String(describing: recorded.first))")
}
XCTAssertEqual(visibility, .public)
}
}