diff --git a/App/Features/Compose/RepostSheetView.swift b/App/Features/Compose/RepostSheetView.swift index d921ac7..52cdd1e 100644 --- a/App/Features/Compose/RepostSheetView.swift +++ b/App/Features/Compose/RepostSheetView.swift @@ -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 ) } } diff --git a/App/Features/Compose/RepostSheetViewModel.swift b/App/Features/Compose/RepostSheetViewModel.swift index 43c9e8a..7186780 100644 --- a/App/Features/Compose/RepostSheetViewModel.swift +++ b/App/Features/Compose/RepostSheetViewModel.swift @@ -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 @@ -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 diff --git a/App/Features/Timeline/MessageDetailView.swift b/App/Features/Timeline/MessageDetailView.swift index b919d5f..db77ca2 100644 --- a/App/Features/Timeline/MessageDetailView.swift +++ b/App/Features/Timeline/MessageDetailView.swift @@ -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() diff --git a/App/Features/Timeline/MessageDetailViewModel.swift b/App/Features/Timeline/MessageDetailViewModel.swift index a8ef8fa..a5703b8 100644 --- a/App/Features/Timeline/MessageDetailViewModel.swift +++ b/App/Features/Timeline/MessageDetailViewModel.swift @@ -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 @@ -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 } @@ -169,7 +181,7 @@ final class MessageDetailViewModel { to: messageID, body: trimmed, tags: tags, - visibility: visibility + visibility: visibility ?? defaultVisibility ) replies.append(reply) return reply diff --git a/AppTests/MessageDetailViewModelTests.swift b/AppTests/MessageDetailViewModelTests.swift index a8e1fae..cfc63dd 100644 --- a/AppTests/MessageDetailViewModelTests.swift +++ b/AppTests/MessageDetailViewModelTests.swift @@ -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) + } } diff --git a/AppTests/RepostSheetViewModelTests.swift b/AppTests/RepostSheetViewModelTests.swift index 9b2e6d5..8c41ce7 100644 --- a/AppTests/RepostSheetViewModelTests.swift +++ b/AppTests/RepostSheetViewModelTests.swift @@ -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) + } }