diff --git a/App/Composition/AppEnvironment.swift b/App/Composition/AppEnvironment.swift index 4780be6..e0b38f5 100644 --- a/App/Composition/AppEnvironment.swift +++ b/App/Composition/AppEnvironment.swift @@ -553,7 +553,9 @@ final class AppEnvironment: ObservableObject { // endpoints are already routed by the shared `authTransport`. The // event bus is a singleton so the DM list, an open thread, and the // dock-badge coordinator all see the same stream. - let directMessages = DirectMessagesService(api: api) + // G22: DM photo upload runs through the same `ImagePrep` pipeline as + // the post composer, driven by the live `GET /api/limits` ceilings. + let directMessages = DirectMessagesService(api: api, contentLimits: contentLimits) let directMessagesEventBus = DirectMessagesEventBus() // Settings cluster (work-consolidation.md G17-G20). All reuse the shared // kit-layer `APIClient`. diff --git a/App/Features/DirectMessages/DMAttachmentDraft.swift b/App/Features/DirectMessages/DMAttachmentDraft.swift new file mode 100644 index 0000000..d6d9c32 --- /dev/null +++ b/App/Features/DirectMessages/DMAttachmentDraft.swift @@ -0,0 +1,157 @@ +// DMAttachmentDraft +// +// The photo-attachment half of a DM composer (work-consolidation.md G22), +// factored out so `DMThreadViewModel` and `NewMessageViewModel` share one +// implementation of the picking rules and one upload loop instead of two +// drifting copies. +// +// What the help docs specify, and what this enforces: +// • "You can attach photos to a direct message, up to 8 per message." +// → `DMLimits.maxImagesPerMessage`, refused client-side at pick time so +// a 9th file never reaches the server. +// • Photos only. DMs have no video route, so a picked movie is rejected +// with an explanation rather than silently dropped. +// • "Photos are resized automatically." → the resize happens in +// `DirectMessagesService.uploadImage` via the shared `ImagePrep` + +// `ContentLimits` path, not here and not with fresh constants. +// • "You'll need a verified email address to send images." → NOT checked +// here. The server's 403 carries the canonical wording and is surfaced +// verbatim. TODO(#41): issue #41 builds `CapabilityGate` +// (status → email verification → tier); when it merges, the composers +// should consult it to explain the refusal before the user picks a +// file. One gate, one owner — do not add a second check here. +// +// The upload loop is deliberately failure-tolerant: an upload that fails +// must not cost the user their draft. Successful uploads are kept, the +// first failure is returned for display, and the caller decides whether the +// message can still go out as text. +// +// Reuses `ComposerAttachment` (the post composer's local-file value type) +// rather than introducing a parallel one; DMs simply reject its `.video` +// kind. +// +// Per decision 0003, this consumes only `InterlinedDomain`. + +import Foundation +import InterlinedDomain + +// MARK: - DMAttachmentError + +/// Why a picked file was refused, before anything was uploaded. +enum DMAttachmentError: Error, Equatable { + /// One or more picked files were not images. DMs take photos only. + case notAnImage + /// The pick would exceed the documented per-message photo cap. + case tooMany(limit: Int) +} + +extension DMAttachmentError: LocalizedError, CustomStringConvertible { + var errorDescription: String? { description } + var description: String { + switch self { + case .notAnImage: + return "Only photos can be attached to a direct message." + case .tooMany(let limit): + return "You can attach up to \(limit) photos to a direct message." + } + } +} + +// MARK: - DMAttachmentUploadResult + +/// Outcome of uploading a draft's photos. +struct DMAttachmentUploadResult: Sendable { + /// Hosted URLs for the photos that uploaded successfully, in pick order. + let urls: [String] + /// The first failure encountered, if any. Surfaced to the user while the + /// message itself may still send. + let failure: Error? + /// How many photos failed to upload. + let failedCount: Int +} + +// MARK: - DMAttachmentDraft + +/// The pending photos on one DM composer. A value type: the owning view +/// model holds it and mutates it in place. +struct DMAttachmentDraft: Equatable, Sendable { + + /// Pending photos, in pick order. Local file URLs — bytes are read at + /// send time, so a big pick doesn't sit in memory while composing. + private(set) var attachments: [ComposerAttachment] = [] + + /// How many more photos this message can take. + var remainingSlots: Int { + max(0, DMLimits.maxImagesPerMessage - attachments.count) + } + + /// Whether the documented per-message cap is reached. + var isFull: Bool { remainingSlots == 0 } + + var isEmpty: Bool { attachments.isEmpty } + + /// Adds picked / dropped file URLs, keeping only images and only up to + /// the cap. Returns the reason any file was refused, or `nil` when all + /// were accepted. + /// + /// Partial acceptance is deliberate: picking ten photos onto an empty + /// draft attaches eight and explains the two that didn't fit, rather + /// than discarding the whole pick. + mutating func add(urls: [URL]) -> DMAttachmentError? { + var sawNonImage = false + var overflowed = false + for url in urls { + guard let attachment = ComposerAttachment(url: url), + attachment.kind == .image else { + sawNonImage = true + continue + } + guard !isFull else { + overflowed = true + continue + } + attachments.append(attachment) + } + // The cap is the more actionable message when both apply — it names a + // number the user can act on. + if overflowed { return .tooMany(limit: DMLimits.maxImagesPerMessage) } + if sawNonImage { return .notAnImage } + return nil + } + + /// Removes one pending photo by id. + mutating func remove(id: ComposerAttachment.ID) { + attachments.removeAll { $0.id == id } + } + + /// Clears the draft — called after a successful send. + mutating func removeAll() { + attachments.removeAll() + } + + /// Uploads every pending photo and returns their hosted URLs. + /// + /// Failure-tolerant by design: a failed upload does not abort the rest + /// and does not throw. The caller keeps the successful URLs, shows + /// `failure`, and decides whether the message can still be sent as text + /// — which is the behaviour the user needs, because losing a typed draft + /// to a flaky upload is worse than sending without one photo. + func upload( + using service: DirectMessagesServicing, + readData: @Sendable (URL) async throws -> Data + ) async -> DMAttachmentUploadResult { + var urls: [String] = [] + var failure: Error? + var failedCount = 0 + for attachment in attachments { + do { + let bytes = try await readData(attachment.url) + urls.append(try await service.uploadImage(bytes)) + } catch { + failedCount += 1 + if failure == nil { failure = error } + } + } + return DMAttachmentUploadResult(urls: urls, failure: failure, failedCount: failedCount) + } +} diff --git a/App/Features/DirectMessages/DMAttachmentStrip.swift b/App/Features/DirectMessages/DMAttachmentStrip.swift new file mode 100644 index 0000000..289f43f --- /dev/null +++ b/App/Features/DirectMessages/DMAttachmentStrip.swift @@ -0,0 +1,69 @@ +// DMAttachmentStrip +// +// The pending-photo row shared by the DM thread composer and the new-message +// sheet (work-consolidation.md G22). Renders each queued local file as a +// thumbnail with a remove affordance, plus an "n of 8" counter so the +// documented cap is visible before the user hits it. +// +// SwiftUI-only (Decision 0005): `AsyncImage(url:)` over the local file URL, +// no AppKit image loading. +// +// Per decision 0003, this view consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct DMAttachmentStrip: View { + + let attachments: [ComposerAttachment] + let limit: Int + let onRemove: (ComposerAttachment.ID) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Photos") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + Spacer() + Text("\(attachments.count) of \(limit)") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .accessibilityLabel("\(attachments.count) of \(limit) photos attached") + } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(attachments) { attachment in + thumbnail(attachment) + } + } + } + } + } + + private func thumbnail(_ attachment: ComposerAttachment) -> some View { + AsyncImage(url: attachment.url) { phase in + switch phase { + case .success(let image): + image.resizable().aspectRatio(contentMode: .fill) + default: + Image(systemName: "photo") + .foregroundStyle(.secondary) + } + } + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: ILMetric.radiusSm)) + .overlay(alignment: .topTrailing) { + Button { + onRemove(attachment.id) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.white, .black.opacity(0.6)) + } + .buttonStyle(.plain) + .padding(2) + .accessibilityLabel("Remove photo") + } + .accessibilityLabel("Attached photo \(attachment.url.lastPathComponent)") + } +} diff --git a/App/Features/DirectMessages/DMThreadView.swift b/App/Features/DirectMessages/DMThreadView.swift index 25118d1..dc921d0 100644 --- a/App/Features/DirectMessages/DMThreadView.swift +++ b/App/Features/DirectMessages/DMThreadView.swift @@ -16,6 +16,7 @@ // Per decision 0003, this view consumes only `InterlinedDomain`. import SwiftUI +import UniformTypeIdentifiers import InterlinedDomain struct DMThreadView: View { @@ -28,6 +29,9 @@ struct DMThreadView: View { @State private var viewModel: DMThreadViewModel? + /// Controls the `.fileImporter` sheet for picking photos (G22). + @State private var isPhotoImporterPresented = false + var body: some View { Group { if let viewModel { @@ -92,6 +96,21 @@ struct DMThreadView: View { } .padding(.horizontal, 16) .padding(.vertical, 10) + // SwiftUI-only file picking (Decision 0005 — no NSOpenPanel). + // Images only: DMs have no video route. + .fileImporter( + isPresented: $isPhotoImporterPresented, + allowedContentTypes: [.image], + allowsMultipleSelection: true + ) { result in + if case .success(let urls) = result { + viewModel.addAttachments(urls: urls) + } + } + .dropDestination(for: URL.self) { urls, _ in + viewModel.addAttachments(urls: urls) + return true + } } @ViewBuilder @@ -128,6 +147,11 @@ struct DMThreadView: View { HStack { if outgoing { Spacer(minLength: 40) } VStack(alignment: outgoing ? .trailing : .leading, spacing: 4) { + // G22: photos on a DM. Rendered above the text so a + // photos-only message is not an empty bubble. + if !message.imageURLs.isEmpty { + messagePhotos(message.imageURLs) + } if !message.body.isEmpty { Text(message.body) .font(.ilBody()) @@ -147,18 +171,89 @@ struct DMThreadView: View { if !outgoing { Spacer(minLength: 40) } } .accessibilityElement(children: .combine) - .accessibilityLabel("\(outgoing ? "You said" : "They said"): \(message.body)") + .accessibilityLabel(bubbleAccessibilityLabel(message: message, outgoing: outgoing)) + } + + /// The attached photos on a message, laid out as a wrapping strip. + /// SwiftUI-only (`AsyncImage`, Decision 0005). + @ViewBuilder + private func messagePhotos(_ urls: [URL]) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(urls, id: \.self) { url in + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().aspectRatio(contentMode: .fill) + case .failure: + Image(systemName: "photo.badge.exclamationmark") + .foregroundStyle(.secondary) + default: + ProgressView().controlSize(.small) + } + } + .frame(width: 160, height: 160) + .clipShape(RoundedRectangle(cornerRadius: ILMetric.radiusLg)) + .accessibilityLabel("Attached photo") + } + } + } + .frame(maxHeight: 160) + } + + /// Speaks the body, and says how many photos rode along so a + /// photos-only message isn't announced as an empty bubble. + private func bubbleAccessibilityLabel(message: DirectMessage, outgoing: Bool) -> String { + let who = outgoing ? "You said" : "They said" + let photos = message.imageURLs.count + let photoPhrase = photos == 1 ? "1 photo" : "\(photos) photos" + if message.body.isEmpty, photos > 0 { return "\(who): \(photoPhrase)" } + if photos > 0 { return "\(who): \(message.body), with \(photoPhrase)" } + return "\(who): \(message.body)" } @ViewBuilder private func composer(viewModel: DMThreadViewModel) -> some View { + let composerDisabled = !viewModel.isMutual || viewModel.isBlocked VStack(alignment: .leading, spacing: 4) { if let error = viewModel.error { Text(error.localizedDescription) .font(.ilSubtitle()) .foregroundStyle(.red) } + if !viewModel.attachments.isEmpty { + DMAttachmentStrip( + attachments: viewModel.attachments, + limit: viewModel.maxAttachments, + onRemove: { viewModel.removeAttachment(id: $0) } + ) + } + if viewModel.isOverBodyLimit { + Text("\(viewModel.draft.count) of \(viewModel.bodyCharacterLimit) characters") + .font(.ilMono(10)) + .foregroundStyle(.red) + } HStack(spacing: 8) { + // G22: photo attachments. Sending photos needs a verified + // email address; the server refuses with an explanation when + // it isn't, and that message is what the error line shows. + // TODO(#41): once issue #41's `CapabilityGate` merges, + // disable this and explain up front rather than after the + // attempt. + Button { + isPhotoImporterPresented = true + } label: { + Image(systemName: "photo.on.rectangle") + } + .buttonStyle(.bordered) + .disabled(composerDisabled || viewModel.attachmentsAreFull) + .help( + viewModel.attachmentsAreFull + ? "Up to \(viewModel.maxAttachments) photos per message" + : "Attach photos" + ) + .accessibilityLabel("Attach photos") + TextField( composerPlaceholder(viewModel: viewModel), text: Binding( @@ -169,7 +264,7 @@ struct DMThreadView: View { ) .textFieldStyle(.roundedBorder) .lineLimit(1...4) - .disabled(!viewModel.isMutual || viewModel.isBlocked) + .disabled(composerDisabled) .onSubmit { Task { await viewModel.send() } } @@ -191,6 +286,21 @@ struct DMThreadView: View { } .padding(.horizontal, 16) .padding(.vertical, 10) + // SwiftUI-only file picking (Decision 0005 — no NSOpenPanel). + // Images only: DMs have no video route. + .fileImporter( + isPresented: $isPhotoImporterPresented, + allowedContentTypes: [.image], + allowsMultipleSelection: true + ) { result in + if case .success(let urls) = result { + viewModel.addAttachments(urls: urls) + } + } + .dropDestination(for: URL.self) { urls, _ in + viewModel.addAttachments(urls: urls) + return true + } } private func composerPlaceholder(viewModel: DMThreadViewModel) -> String { diff --git a/App/Features/DirectMessages/DMThreadViewModel.swift b/App/Features/DirectMessages/DMThreadViewModel.swift index 21e1452..cb1d176 100644 --- a/App/Features/DirectMessages/DMThreadViewModel.swift +++ b/App/Features/DirectMessages/DMThreadViewModel.swift @@ -23,6 +23,14 @@ // `DirectMessage` (never trusting the local copy). On failure we remove // the placeholder, restore the draft, and surface the error. // +// Photo attachments (work-consolidation.md G22): up to 8 per message, held +// in a `DMAttachmentDraft` and uploaded through +// `DirectMessagesServicing.uploadImage` immediately before the send. An +// upload that fails does NOT abort the message — the text still goes out, +// the failure is surfaced, and nothing the user typed or picked is lost. +// Photo sending requires a verified email; the server's 403 is surfaced +// verbatim. TODO(#41): the verification gate is owned by issue #41. +// // Bubble alignment uses `DirectMessage.isOutgoing(currentUserId:)` with // the id from the injected `currentUserID` closure so the view model // always sees the latest session (mirrors `ProfileViewModel`). @@ -55,6 +63,10 @@ final class DMThreadViewModel { private let currentUserIDProvider: @MainActor () -> String? private let pollInterval: Duration + /// Reads an attachment's bytes. Injected so tests exercise the upload + /// path without touching the filesystem (mirrors `ComposerViewModel`). + private let readData: @Sendable (URL) async throws -> Data + // MARK: - Observable state /// The rendered thread, oldest-first (chat order — newest at the @@ -80,6 +92,26 @@ final class DMThreadViewModel { /// The composer draft. Two-way bound by the view. var draft: String = "" + /// Pending photo attachments for the next send (G22). + private(set) var attachmentDraft = DMAttachmentDraft() + + /// The pending photos, for the composer's thumbnail strip. + var attachments: [ComposerAttachment] { attachmentDraft.attachments } + + /// Whether the documented 8-photo cap is reached — disables the attach + /// affordance rather than letting a pick fail after the fact. + var attachmentsAreFull: Bool { attachmentDraft.isFull } + + /// The documented per-message photo cap, for the composer's counter. + var maxAttachments: Int { DMLimits.maxImagesPerMessage } + + /// The documented DM body ceiling — 10,000 characters, far larger than + /// the post composer's 5,000 (a different surface, a different limit). + var bodyCharacterLimit: Int { DMLimits.maxBodyCharacters } + + /// True when the draft exceeds the body ceiling. + var isOverBodyLimit: Bool { draft.count > bodyCharacterLimit } + /// True while the initial thread load is in flight. private(set) var isLoading: Bool = false @@ -96,7 +128,12 @@ final class DMThreadViewModel { /// Whether the composer can currently send: mutual, not blocked, and /// a non-blank draft. var canSend: Bool { - isMutual && !isBlocked && !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + guard isMutual, !isBlocked, !isOverBodyLimit else { return false } + // A photo alone is a valid message — the server accepts imageUrls + // with an empty body — so either a non-blank body or a queued photo + // is enough. + return !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !attachmentDraft.isEmpty } // MARK: - Internals @@ -119,13 +156,15 @@ final class DMThreadViewModel { service: DirectMessagesServicing, eventBus: DirectMessagesEventBus? = nil, currentUserID: @MainActor @escaping () -> String? = { nil }, - pollInterval: Duration = defaultPollInterval + pollInterval: Duration = defaultPollInterval, + readData: @escaping @Sendable (URL) async throws -> Data = { try Data(contentsOf: $0) } ) { self.username = username self.service = service self.bus = eventBus self.currentUserIDProvider = currentUserID self.pollInterval = pollInterval + self.readData = readData } // MARK: - Lifecycle @@ -175,14 +214,42 @@ final class DMThreadViewModel { // MARK: - Send - /// Sends the current draft. Blank drafts (no text, no images) are + /// Queues picked / dropped photos for the next send. Non-images and + /// anything past the documented 8-photo cap are refused here, before any + /// bytes are read — the "invalid input rejected before the service is + /// called" gate. + func addAttachments(urls: [URL]) { + if let rejection = attachmentDraft.add(urls: urls) { + error = rejection + } else { + error = nil + } + } + + /// Removes one queued photo. + func removeAttachment(id: ComposerAttachment.ID) { + attachmentDraft.remove(id: id) + } + + /// Sends the current draft. Blank drafts (no text, no photos) are /// rejected before the service is touched — `canSend` gates the UI but /// this guard makes the rejection authoritative. Optimistic: append a /// placeholder, call `send`, replace it with the server message on /// success, or remove it and restore the draft on failure. + /// + /// Photos upload first (G22). A failed upload is **not** fatal: whatever + /// uploaded is attached, the failure is surfaced, and a message with text + /// still goes out. Only a photos-only message whose every upload failed + /// has nothing left to send — then the draft and the picks are kept + /// intact so the user can retry. func send() async { let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + let pendingAttachments = attachmentDraft + guard !trimmed.isEmpty || !pendingAttachments.isEmpty else { return } + guard !isOverBodyLimit else { + error = DMThreadError.bodyTooLong(limit: bodyCharacterLimit) + return + } guard let recipientId = resolvedRecipientId() else { // No recipient id could be resolved (empty thread with no // resolved other user). Surface a typed error rather than @@ -194,7 +261,23 @@ final class DMThreadViewModel { isSending = true defer { isSending = false } - // Optimistic placeholder. + // 1. Upload photos, if any. Never throws — see `DMAttachmentDraft`. + var uploadFailure: Error? + var imageURLs: [String] = [] + if !pendingAttachments.isEmpty { + let result = await pendingAttachments.upload(using: service, readData: readData) + imageURLs = result.urls + uploadFailure = result.failure + } + + // Every photo failed on a photos-only message: there is nothing left + // to send. Keep the draft and the picks, surface the failure. + guard !trimmed.isEmpty || !imageURLs.isEmpty else { + error = uploadFailure + return + } + + // 2. Optimistic placeholder, now including any uploaded photos. optimisticSeq += 1 let tempId = "optimistic-\(optimisticSeq)" let me = currentUserIDProvider() @@ -203,7 +286,7 @@ final class DMThreadViewModel { senderId: me ?? "", recipientId: recipientId, body: trimmed, - imageURLs: [], + imageURLs: imageURLs.compactMap(URL.init(string:)), createdAt: Date(), readAt: nil, sender: nil, @@ -214,14 +297,21 @@ final class DMThreadViewModel { draft = "" do { - let sent = try await service.send(recipientId: recipientId, body: trimmed) + let sent = try await service.send( + recipientId: recipientId, + body: trimmed, + imageURLs: imageURLs + ) // Replace the placeholder with the authoritative server value. if let index = messages.firstIndex(where: { $0.id == tempId }) { messages[index] = sent } else { messages.append(sent) } - error = nil + attachmentDraft.removeAll() + // A partial photo failure is still worth reporting even though + // the message went out — otherwise a photo silently vanishes. + error = uploadFailure bus?.post(.messageSent(recipientUsername: username, message: sent)) } catch is CancellationError { // Cancelled mid-send — not a failure. Drop the optimistic bubble @@ -421,6 +511,12 @@ final class DMThreadViewModel { self.hasLoadedOnce = true cacheRecipientId() } + + /// Seeds pending photo attachments without going through the file + /// picker. For tests / previews. + func seedAttachmentsForTest(urls: [URL]) { + _ = attachmentDraft.add(urls: urls) + } } // MARK: - DMThreadError @@ -430,4 +526,18 @@ enum DMThreadError: Error, Equatable { /// `send` was invoked but no recipient id could be resolved (an empty /// thread with no resolved other user). case unknownRecipient + /// The draft exceeded the documented 10,000-character DM ceiling. + case bodyTooLong(limit: Int) +} + +extension DMThreadError: LocalizedError, CustomStringConvertible { + var errorDescription: String? { description } + var description: String { + switch self { + case .unknownRecipient: + return "We couldn't work out who this conversation is with." + case .bodyTooLong(let limit): + return "A direct message can be up to \(limit) characters." + } + } } diff --git a/App/Features/DirectMessages/DirectMessagesListViewModel.swift b/App/Features/DirectMessages/DirectMessagesListViewModel.swift index 49f39c2..59daee6 100644 --- a/App/Features/DirectMessages/DirectMessagesListViewModel.swift +++ b/App/Features/DirectMessages/DirectMessagesListViewModel.swift @@ -1,25 +1,33 @@ // DirectMessagesListViewModel // -// Drives the conversation-list column of `DirectMessagesRootView` (the- -// gaps.md G1). Owns the selected folder (Inbox / Sent / Deleted), the -// folder listing collapsed into per-conversation rows, pagination via -// the `DMPage.nextCursor`, the unread badge count, and the trash / -// restore actions. Reads through `DirectMessagesServicing` only — no -// direct API access — so unit tests substitute a stub service. +// Drives the conversation-list column of `DirectMessagesRootView` +// (work-consolidation.md G1, G22). Owns the selected folder (Inbox / Sent +// / Deleted), the conversation rows, pagination, the unread badge count, +// and the trash / restore actions. Reads through `DirectMessagesServicing` +// only — no direct API access — so unit tests substitute a stub service. // -// Conversation grouping: the folder listing is a flat, newest-first list -// of `DirectMessage`s. A conversation is "the other participant" — the -// non-current user on each message. We fold the flat list into one -// `DMConversation` per other-user, keeping the newest message as the -// preview and counting unread inbound messages. Grouping needs the -// current user id (to know which side is "other"); it comes from the -// injected `currentUserID` closure so the view model always sees the -// latest session (mirrors `ProfileViewModel`). +// Two sources, one rendered list (G22): +// +// • Inbox → `conversations(cursor:)`, the server-grouped feed. One row +// per conversation keyed by `pairKey`, newest first, with its own +// cursor. No client-side grouping is involved, so a conversation whose +// newest message would have fallen off the end of a folder page still +// appears — the failure mode the old inbox had. +// • Sent / Deleted → `folder(_:cursor:)`, still a flat newest-first list +// of `DirectMessage`s folded into one `DMConversation` per other +// participant. The conversations route does not replace these: it is +// the inbox, not a folder listing. +// +// Client-side grouping needs the current user id (to know which side of a +// message is "other"); it comes from the injected `currentUserID` closure +// so the view model always sees the latest session (mirrors +// `ProfileViewModel`). // // Optimistic trash / restore (per the swift-engineer skill): snapshot the -// affected messages, mutate locally, call the service, and on failure -// restore the snapshot and surface the error. A `pendingOperations` set -// keyed by message id debounces rapid re-taps. +// active source — the flat message list on the folder path, the summary +// list on the inbox path — mutate locally, call the service, and on +// failure restore the snapshot and surface the error. A +// `pendingOperations` set keyed by message id debounces rapid re-taps. // // Per decision 0003, this view model consumes only `InterlinedDomain`. @@ -41,11 +49,20 @@ struct DMConversation: Identifiable, Equatable, Sendable { /// other user's username; empty only in the degenerate no-user case. let otherUsername: String /// The newest message in the conversation, rendered as the preview. - let latestMessage: DirectMessage + /// + /// Optional because the server-grouped row's populated shape is + /// unverified (see `DMConversationDTO`): a row whose message we could + /// not decode still lists as a conversation rather than vanishing. + /// Always non-nil on the client-grouped Sent / Deleted path. + let latestMessage: DirectMessage? /// Count of inbound (received, unread) messages in this conversation. let unreadCount: Int /// All messages in the conversation from this folder page, newest-first. + /// Empty on the server-grouped inbox path, which reports only the newest. let messages: [DirectMessage] + + /// One-line row preview. + var preview: String { latestMessage?.body ?? "" } } @MainActor @@ -60,14 +77,20 @@ final class DirectMessagesListViewModel { // MARK: - Observable state - /// The folder whose listing is shown. Changing it triggers a reload. - var folder: DMFolder = .inbox { + /// The folder whose listing is shown. Changing it triggers a reload and + /// switches the backing source (Inbox → conversations, Sent / Deleted → + /// folder listing). + var folder: DMFolder { didSet { guard folder != oldValue else { return } Task { await load() } } } + /// Whether the current folder reads the server-grouped conversations + /// feed. Only the Inbox does; the route is the inbox, not a folder. + private var usesConversationsFeed: Bool { folder == .inbox } + /// The collapsed conversation rows for the current folder, newest-first. private(set) var conversations: [DMConversation] = [] @@ -98,20 +121,30 @@ final class DirectMessagesListViewModel { /// same message don't double-fire the service. private var pendingOperations: Set = [] - /// The flat, newest-first message list backing `conversations`. Kept - /// so trash/restore can mutate the source and re-group. + /// The flat, newest-first message list backing `conversations` on the + /// Sent / Deleted path. Kept so trash/restore can mutate the source and + /// re-group. Empty while the Inbox is shown. private var messages: [DirectMessage] = [] + /// The server-grouped rows backing `conversations` on the Inbox path. + /// Empty while Sent / Deleted is shown. + private var summaries: [DMConversationSummary] = [] + // MARK: - Init init( service: DirectMessagesServicing, eventBus: DirectMessagesEventBus? = nil, - currentUserID: @MainActor @escaping () -> String? = { nil } + currentUserID: @MainActor @escaping () -> String? = { nil }, + initialFolder: DMFolder = .inbox ) { self.service = service self.bus = eventBus self.currentUserIDProvider = currentUserID + // Assigned in the initializer, so the `didSet` reload does not fire — + // the caller owns the first `load()`. Lets a test start on Sent / + // Deleted without racing an unawaited reload task. + self.folder = initialFolder } // MARK: - Intents @@ -123,9 +156,17 @@ final class DirectMessagesListViewModel { isLoading = true defer { isLoading = false } do { - let page = try await service.folder(folder, cursor: nil) - messages = page.messages - nextCursor = page.nextCursor + if usesConversationsFeed { + let page = try await service.conversations(cursor: nil) + summaries = page.conversations + messages = [] + nextCursor = page.nextCursor + } else { + let page = try await service.folder(folder, cursor: nil) + messages = page.messages + summaries = [] + nextCursor = page.nextCursor + } regroup() error = nil hasLoadedOnce = true @@ -148,9 +189,15 @@ final class DirectMessagesListViewModel { isLoadingMore = true defer { isLoadingMore = false } do { - let page = try await service.folder(folder, cursor: cursor) - messages.append(contentsOf: page.messages) - nextCursor = page.nextCursor + if usesConversationsFeed { + let page = try await service.conversations(cursor: cursor) + summaries.append(contentsOf: page.conversations) + nextCursor = page.nextCursor + } else { + let page = try await service.folder(folder, cursor: cursor) + messages.append(contentsOf: page.messages) + nextCursor = page.nextCursor + } regroup() error = nil } catch is CancellationError { @@ -160,6 +207,56 @@ final class DirectMessagesListViewModel { } } + /// Resolves a bare DM id to the username of the conversation it belongs + /// to, so a deep link that names a *message* can open the right *thread* + /// (work-consolidation.md G22 — `GET /api/dm/{id}`). + /// + /// Answers from the loaded listing first and only calls the API for an id + /// the listing doesn't already hold — a deep link arriving while the + /// inbox is on screen shouldn't cost a round-trip. + /// + /// Returns `nil` when the message is unknown, is not readable by this + /// account, or names no resolvable participant; the caller leaves the + /// selection alone rather than opening an empty thread. + func conversationUsername(forMessageID id: String) async -> String? { + if let known = knownConversationUsername(forMessageID: id) { return known } + do { + return username(of: try await service.message(id: id)) + } catch { + // A deep link to a message we can't read is not a listing + // failure — don't blank the list with an error banner over it. + return nil + } + } + + /// The conversation username for `id` if the loaded listing already knows + /// the message, else `nil`. + private func knownConversationUsername(forMessageID id: String) -> String? { + if let summary = summaries.first(where: { $0.latestMessage?.id == id }) { + let name = summary.otherUsername + return name.isEmpty ? nil : name + } + if let message = messages.first(where: { $0.id == id }) { + return username(of: message) + } + return nil + } + + /// The other participant's username on a message, given the current user. + /// Without a resolved current user we fall back to the sender, which is + /// the correct side for the common inbound-deep-link case. + private func username(of message: DirectMessage) -> String? { + let me = currentUserIDProvider() + let name: String? + if let me, message.senderId == me { + name = message.recipient?.username + } else { + name = message.sender?.username ?? message.recipient?.username + } + guard let name, !name.isEmpty else { return nil } + return name + } + /// Re-reads the server unread count and publishes it on the bus so the /// dock badge / sidebar pip update. Soft-fails: a failed read leaves /// the prior count in place and does not surface an error. @@ -177,51 +274,78 @@ final class DirectMessagesListViewModel { /// Optimistic: drop it from the local listing, call `trash`, and on /// failure restore the snapshot and surface the error. func trash(messageID: String) async { - guard !pendingOperations.contains(messageID) else { return } - guard messages.contains(where: { $0.id == messageID }) else { return } - pendingOperations.insert(messageID) - defer { pendingOperations.remove(messageID) } - - let snapshot = messages - messages.removeAll { $0.id == messageID } - regroup() - do { + await mutate(messageID: messageID) { [service] in try await service.trash(id: messageID) - error = nil - await refreshUnreadCount() - } catch { - messages = snapshot - regroup() - self.error = error } } /// Restores a message out of the Deleted folder. Optimistic in the /// same shape as `trash`. func restore(messageID: String) async { + await mutate(messageID: messageID) { [service] in + try await service.restore(id: messageID) + } + } + + /// The shared optimistic body behind `trash` / `restore`. + /// + /// Snapshots whichever source is active — the flat message list on the + /// Sent / Deleted path, the summary list on the Inbox path — removes the + /// affected row, runs `action`, and restores the snapshot on failure. + /// Rejects an id that is not in the current listing *before* the service + /// is touched, so a stale row can't fire a doomed request. + private func mutate( + messageID: String, + action: @escaping () async throws -> Void + ) async { guard !pendingOperations.contains(messageID) else { return } - guard messages.contains(where: { $0.id == messageID }) else { return } + guard knowsMessage(id: messageID) else { return } pendingOperations.insert(messageID) defer { pendingOperations.remove(messageID) } - let snapshot = messages + let messageSnapshot = messages + let summarySnapshot = summaries messages.removeAll { $0.id == messageID } + summaries.removeAll { $0.latestMessage?.id == messageID } regroup() do { - try await service.restore(id: messageID) + try await action() error = nil await refreshUnreadCount() } catch { - messages = snapshot + messages = messageSnapshot + summaries = summarySnapshot regroup() self.error = error } } - /// Seeds the flat listing without going through the service. For tests - /// and previews. + /// Whether `id` names a message the current listing actually knows about, + /// in either source. + private func knowsMessage(id: String) -> Bool { + messages.contains { $0.id == id } || summaries.contains { $0.latestMessage?.id == id } + } + + /// Seeds the flat (folder-path) listing without going through the + /// service. For tests and previews. func seedForTest(messages: [DirectMessage], nextCursor: String? = nil, unreadCount: Int = 0) { self.messages = messages + self.summaries = [] + self.nextCursor = nextCursor + self.unreadCount = unreadCount + self.hasLoadedOnce = true + regroup() + } + + /// Seeds the server-grouped (Inbox-path) listing without going through + /// the service. For tests and previews. + func seedForTest( + conversations: [DMConversationSummary], + nextCursor: String? = nil, + unreadCount: Int = 0 + ) { + self.summaries = conversations + self.messages = [] self.nextCursor = nextCursor self.unreadCount = unreadCount self.hasLoadedOnce = true @@ -230,9 +354,40 @@ final class DirectMessagesListViewModel { // MARK: - Grouping + /// Rebuilds `conversations` from whichever source is active. + /// + /// On the Inbox path the server already did the grouping, so this is a + /// straight projection of `summaries` — no folding, no dependence on how + /// much of the listing we happen to have fetched. On Sent / Deleted it + /// folds the flat message list as before. + private func regroup() { + guard !usesConversationsFeed else { + conversations = summaries.map(Self.row(from:)) + return + } + regroupFolderListing() + } + + /// Projects one server-grouped summary into a rendered row. The server + /// owns identity, the other participant, and the unread count; nothing + /// here re-derives them from message contents. + private static func row(from summary: DMConversationSummary) -> DMConversation { + DMConversation( + id: summary.id, + otherUser: summary.otherUser, + otherUsername: summary.otherUsername, + latestMessage: summary.latestMessage, + unreadCount: summary.unreadCount, + // The conversations feed reports only the newest message per + // conversation; the full back-and-forth comes from the thread. + messages: summary.latestMessage.map { [$0] } ?? [] + ) + } + /// Folds the flat message list into one conversation per other-user, /// newest-first. Stable: ties keep the newest message's timestamp. - private func regroup() { + /// Sent / Deleted only — the Inbox is grouped server-side. + private func regroupFolderListing() { let me = currentUserIDProvider() var order: [String] = [] var buckets: [String: [DirectMessage]] = [:] diff --git a/App/Features/DirectMessages/DirectMessagesRootView.swift b/App/Features/DirectMessages/DirectMessagesRootView.swift index da76d14..be4b158 100644 --- a/App/Features/DirectMessages/DirectMessagesRootView.swift +++ b/App/Features/DirectMessages/DirectMessagesRootView.swift @@ -74,6 +74,16 @@ struct DirectMessagesRootView: View { guard let username = note.object as? String else { return } selectedUsername = username } + // G22: a deep link that names a *message* id resolves to the thread + // it belongs to via `GET /api/dm/{id}`. + .onReceive(NotificationCenter.default.publisher(for: .directMessagesOpenMessage)) { note in + guard let messageID = note.object as? String else { return } + Task { + if let username = await viewModel?.conversationUsername(forMessageID: messageID) { + selectedUsername = username + } + } + } } // MARK: - Body @@ -153,7 +163,7 @@ struct DirectMessagesRootView: View { Text(conversation.otherUser?.displayName ?? "@\(conversation.otherUsername)") .font(.body.weight(conversation.unreadCount > 0 ? .semibold : .regular)) .lineLimit(1) - Text(conversation.latestMessage.body) + Text(conversation.preview) .font(.ilSubtitle()) .foregroundStyle(.secondary) .lineLimit(1) @@ -171,17 +181,22 @@ struct DirectMessagesRootView: View { } .padding(.vertical, 4) .contextMenu { - if viewModel.folder == .deleted { - Button { - Task { await viewModel.restore(messageID: conversation.latestMessage.id) } - } label: { - Label("Restore", systemImage: "arrow.uturn.backward") - } - } else { - Button(role: .destructive) { - Task { await viewModel.trash(messageID: conversation.latestMessage.id) } - } label: { - Label("Move to Deleted", systemImage: "trash") + // Trash / restore act on a specific message. A row with no + // decodable newest message has nothing to act on, so the menu + // is empty rather than offering an action that cannot fire. + if let latest = conversation.latestMessage { + if viewModel.folder == .deleted { + Button { + Task { await viewModel.restore(messageID: latest.id) } + } label: { + Label("Restore", systemImage: "arrow.uturn.backward") + } + } else { + Button(role: .destructive) { + Task { await viewModel.trash(messageID: latest.id) } + } label: { + Label("Move to Deleted", systemImage: "trash") + } } } } @@ -272,4 +287,15 @@ extension Foundation.Notification.Name { /// Posted by the ⌥⌘M menu command / the Messages menu to route the /// sidebar to the Messages section. static let directMessagesShow = Foundation.Notification.Name("InterlinedList.directMessagesShow") + + /// Posted with a **direct-message id** `object` to open the conversation + /// that message belongs to (work-consolidation.md G22). The id is resolved + /// to a username by `DirectMessagesListViewModel`, from the loaded listing + /// when possible and otherwise `GET /api/dm/{id}`. + /// + /// This is the deep-link seam. Nothing posts it yet: the notification + /// feed has no direct-message kind (`NotificationKind` carries none), so + /// the producer arrives with whatever surface introduces DM notifications + /// or a `interlinedlist://dm/` URL scheme. + static let directMessagesOpenMessage = Foundation.Notification.Name("InterlinedList.directMessagesOpenMessage") } diff --git a/App/Features/DirectMessages/NewMessageSheet.swift b/App/Features/DirectMessages/NewMessageSheet.swift index 504d73b..77ca178 100644 --- a/App/Features/DirectMessages/NewMessageSheet.swift +++ b/App/Features/DirectMessages/NewMessageSheet.swift @@ -1,8 +1,13 @@ // NewMessageSheet // -// Modal composer for a brand-new conversation (work-consolidation.md G1). A -// recipient picker over the eligible-recipient set (`recipients()`, mutual -// followers) plus a body field. A thin shell over `NewMessageViewModel`. +// Modal composer for a brand-new conversation (work-consolidation.md G1, +// G22). A recipient picker over the eligible-recipient set +// (`recipients()`, mutual followers), a body field, and photo attachments. +// A thin shell over `NewMessageViewModel`. +// +// The recipient list is mutual-followers-only, so its empty state explains +// the rule rather than just reporting emptiness — the wording matches +// `/help/direct-messages`. // // On a successful send the sheet dismisses and reports the recipient's // username via `onSent` so the root view can select that conversation and @@ -11,6 +16,7 @@ // Per decision 0003, this view consumes only `InterlinedDomain`. import SwiftUI +import UniformTypeIdentifiers import InterlinedDomain struct NewMessageSheet: View { @@ -25,6 +31,9 @@ struct NewMessageSheet: View { @State private var viewModel: NewMessageViewModel? + /// Controls the `.fileImporter` sheet for picking photos (G22). + @State private var isPhotoImporterPresented = false + var body: some View { VStack(alignment: .leading, spacing: 16) { Text("New message") @@ -48,6 +57,21 @@ struct NewMessageSheet: View { await vm.loadRecipients(preselectUsername: preselectUsername) } } + // SwiftUI-only file picking (Decision 0005 — no NSOpenPanel). + // Images only: DMs have no video route. + .fileImporter( + isPresented: $isPhotoImporterPresented, + allowedContentTypes: [.image], + allowsMultipleSelection: true + ) { result in + if case .success(let urls) = result { + viewModel?.addAttachments(urls: urls) + } + } + .dropDestination(for: URL.self) { urls, _ in + viewModel?.addAttachments(urls: urls) + return true + } } @ViewBuilder @@ -56,12 +80,21 @@ struct NewMessageSheet: View { if viewModel.isLoadingRecipients { ProgressView("Loading recipients…") } else if viewModel.recipients.isEmpty, viewModel.hasLoadedRecipients { - Label( - "You have no mutual followers to message yet.", - systemImage: "person.2.slash" - ) + // Wording from `/help/direct-messages`: an empty list is not a + // failure, it means the mutual-follow condition is unmet — say + // so, otherwise the sheet reads as broken. + Label { + VStack(alignment: .leading, spacing: 2) { + Text("No one to message yet.") + Text("If the list is empty, it means no one who follows you also follows you back yet.") + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "person.2.slash") + } .font(.ilSubtitle()) - .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityElement(children: .combine) } else { Picker( "To", @@ -96,6 +129,47 @@ struct NewMessageSheet: View { .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) ) .accessibilityLabel("Message body") + HStack { + Text("Markdown supported") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + Spacer() + // The DM ceiling is 10,000 — not the post composer's + // 5,000. Shown only as it gets close, to stay quiet. + Text("\(viewModel.body.count) / \(viewModel.bodyCharacterLimit)") + .font(.ilMono(10)) + .foregroundStyle(viewModel.isOverBodyLimit ? .red : .secondary) + .accessibilityLabel( + "\(viewModel.body.count) of \(viewModel.bodyCharacterLimit) characters" + ) + } + } + + // G22: photo attachments, up to 8. Sending photos needs a + // verified email address; the server refuses with an explanation + // when it isn't, and that message is what the error line shows. + // TODO(#41): once issue #41's `CapabilityGate` merges, disable + // this and explain up front rather than after the attempt. + VStack(alignment: .leading, spacing: 6) { + if !viewModel.attachments.isEmpty { + DMAttachmentStrip( + attachments: viewModel.attachments, + limit: viewModel.maxAttachments, + onRemove: { viewModel.removeAttachment(id: $0) } + ) + } + Button { + isPhotoImporterPresented = true + } label: { + Label("Add photos", systemImage: "photo.on.rectangle") + } + .buttonStyle(.bordered) + .disabled(viewModel.attachmentsAreFull) + .help( + viewModel.attachmentsAreFull + ? "Up to \(viewModel.maxAttachments) photos per message" + : "Attach photos" + ) } if let error = viewModel.error { diff --git a/App/Features/DirectMessages/NewMessageViewModel.swift b/App/Features/DirectMessages/NewMessageViewModel.swift index c620a4b..f8acc10 100644 --- a/App/Features/DirectMessages/NewMessageViewModel.swift +++ b/App/Features/DirectMessages/NewMessageViewModel.swift @@ -5,13 +5,24 @@ // selection + body draft, and sends. Reads through // `DirectMessagesServicing` only so unit tests substitute a stub service. // -// Send validation: a blank body (whitespace-only, no images) is rejected +// Send validation: a blank body (whitespace-only, no photos) is rejected // before the service is touched — the "invalid input rejected before the // service is called" gate. A send with no selected recipient is likewise // rejected locally. On success the sheet reports the recipient's username // so the caller can open the thread; the bus is notified so open list / // thread surfaces update in place. // +// Photo attachments (work-consolidation.md G22): up to 8 per message via a +// shared `DMAttachmentDraft`, uploaded immediately before the send. A +// failed upload never costs the user their draft — the text still sends +// and the failure is surfaced. Photo sending requires a verified email; +// the server's 403 is surfaced verbatim. TODO(#41): the verification gate +// is owned by issue #41 — do not add a second check here. +// +// The recipient list is the mutual-follower set (`recipients()`), which is +// why the empty state explains the mutual-follow rule rather than just +// saying the list is empty. +// // Per decision 0003, this view model consumes only `InterlinedDomain`. import Foundation @@ -27,6 +38,10 @@ final class NewMessageViewModel { private let service: DirectMessagesServicing private let bus: DirectMessagesEventBus? + /// Reads an attachment's bytes. Injected so tests exercise the upload + /// path without touching the filesystem (mirrors `ComposerViewModel`). + private let readData: @Sendable (URL) async throws -> Data + // MARK: - Observable state /// The eligible recipients (mutual followers) from `recipients()`. @@ -39,6 +54,26 @@ final class NewMessageViewModel { /// The message body draft. Two-way bound by the sheet. var body: String = "" + /// Pending photo attachments for this message (G22). + private(set) var attachmentDraft = DMAttachmentDraft() + + /// The pending photos, for the sheet's thumbnail strip. + var attachments: [ComposerAttachment] { attachmentDraft.attachments } + + /// Whether the documented 8-photo cap is reached. + var attachmentsAreFull: Bool { attachmentDraft.isFull } + + /// The documented per-message photo cap, for the sheet's counter. + var maxAttachments: Int { DMLimits.maxImagesPerMessage } + + /// The documented DM body ceiling — 10,000 characters. Deliberately not + /// the post composer's `GET /api/limits` message length (5,000 live on + /// 2026-09-09): DMs are a separate, larger surface. + var bodyCharacterLimit: Int { DMLimits.maxBodyCharacters } + + /// True when the draft exceeds the body ceiling. + var isOverBodyLimit: Bool { body.count > bodyCharacterLimit } + /// True while the recipient list is loading. private(set) var isLoadingRecipients: Bool = false @@ -58,18 +93,23 @@ final class NewMessageViewModel { /// Whether send is currently possible: a recipient is picked and the /// body is non-blank. var canSend: Bool { - selectedRecipientId != nil - && !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + guard selectedRecipientId != nil, !isOverBodyLimit else { return false } + // A photo alone is a valid message, so either a non-blank body or a + // queued photo is enough. + return !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !attachmentDraft.isEmpty } // MARK: - Init init( service: DirectMessagesServicing, - eventBus: DirectMessagesEventBus? = nil + eventBus: DirectMessagesEventBus? = nil, + readData: @escaping @Sendable (URL) async throws -> Data = { try Data(contentsOf: $0) } ) { self.service = service self.bus = eventBus + self.readData = readData } // MARK: - Intents @@ -96,26 +136,75 @@ final class NewMessageViewModel { } } + /// Queues picked / dropped photos. Non-images and anything past the + /// documented 8-photo cap are refused here, before any bytes are read — + /// the "invalid input rejected before the service is called" gate. + func addAttachments(urls: [URL]) { + if let rejection = attachmentDraft.add(urls: urls) { + error = rejection + } else { + error = nil + } + } + + /// Removes one queued photo. + func removeAttachment(id: ComposerAttachment.ID) { + attachmentDraft.remove(id: id) + } + /// Sends the drafted message to the selected recipient. Rejects a - /// missing recipient or a blank body before the service is touched. - /// On success sets `sentMessage` and posts to the bus. + /// missing recipient, an over-long body, or a blank message before the + /// service is touched. On success sets `sentMessage` and posts to the bus. + /// + /// Photos upload first (G22). A failed upload is not fatal: whatever + /// uploaded is attached, the failure is surfaced, and a message with text + /// still goes out. A photos-only message whose every upload failed has + /// nothing to send — the draft and the picks are kept for a retry. func send() async { let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + let pendingAttachments = attachmentDraft guard let recipientId = selectedRecipientId, !recipientId.isEmpty else { error = NewMessageError.noRecipient return } - guard !trimmed.isEmpty else { + guard !trimmed.isEmpty || !pendingAttachments.isEmpty else { error = NewMessageError.emptyBody return } + guard !isOverBodyLimit else { + error = NewMessageError.bodyTooLong(limit: bodyCharacterLimit) + return + } guard !isSending else { return } isSending = true defer { isSending = false } + + // 1. Upload photos, if any. Never throws — see `DMAttachmentDraft`. + var uploadFailure: Error? + var imageURLs: [String] = [] + if !pendingAttachments.isEmpty { + let result = await pendingAttachments.upload(using: service, readData: readData) + imageURLs = result.urls + uploadFailure = result.failure + } + + // Every photo failed on a photos-only message: nothing left to send. + guard !trimmed.isEmpty || !imageURLs.isEmpty else { + error = uploadFailure + return + } + do { - let sent = try await service.send(recipientId: recipientId, body: trimmed) + let sent = try await service.send( + recipientId: recipientId, + body: trimmed, + imageURLs: imageURLs + ) sentMessage = sent - error = nil + attachmentDraft.removeAll() + // A partial photo failure is still reported even though the + // message went out — otherwise a photo silently vanishes. + error = uploadFailure if let username = recipients.first(where: { $0.id == recipientId })?.username { bus?.post(.messageSent(recipientUsername: username, message: sent)) } @@ -134,6 +223,12 @@ final class NewMessageViewModel { guard let id = selectedRecipientId else { return nil } return recipients.first(where: { $0.id == id })?.username } + + /// Seeds the recipient list without a service call. For tests / previews. + func seedRecipientsForTest(_ list: [UserSummary]) { + recipients = list + hasLoadedRecipients = true + } } // MARK: - NewMessageError @@ -142,6 +237,22 @@ final class NewMessageViewModel { enum NewMessageError: Error, Equatable { /// Send was invoked with no recipient selected. case noRecipient - /// Send was invoked with a blank body. + /// Send was invoked with a blank body and no photos. case emptyBody + /// The body exceeded the documented 10,000-character DM ceiling. + case bodyTooLong(limit: Int) +} + +extension NewMessageError: LocalizedError, CustomStringConvertible { + var errorDescription: String? { description } + var description: String { + switch self { + case .noRecipient: + return "Choose someone to message first." + case .emptyBody: + return "A message needs text or a photo before it can be sent." + case .bodyTooLong(let limit): + return "A direct message can be up to \(limit) characters." + } + } } diff --git a/AppTests/DMThreadViewModelTests.swift b/AppTests/DMThreadViewModelTests.swift index 192bdaa..405765b 100644 --- a/AppTests/DMThreadViewModelTests.swift +++ b/AppTests/DMThreadViewModelTests.swift @@ -14,9 +14,23 @@ // posts the events. // - poll cancellation: stopPolling() ends the loop; no further // threadUpdates land after teardown. +// +// G22 adds the photo-attachment quartet: +// - happy: attached photos upload and ride along on the send. +// - invalid input: a 9th photo and a non-image are refused client-side, +// with no upload call made. +// - upstream failure: an upload that fails does not lose the draft — the +// message still sends as text and the failure is surfaced. Sending +// photos needs a verified email; the server's 403 is what surfaces +// (the gate itself is issue #41's, not ours). +// - boundary: exactly 8 photos are accepted; a 10,000-character body is +// accepted and 10,001 is refused. import XCTest import InterlinedDomain +// The forbidden-refusal case asserts on the real `APIError`, so the +// server's wording is verified through the exact type production throws. +import InterlinedKit @testable import InterlinedList @MainActor @@ -27,7 +41,10 @@ final class DMThreadViewModelTests: XCTestCase { private let me = "user-me" private let otherId = "user-ada" - private func makeViewModel(pollInterval: Duration = .milliseconds(5)) -> (DMThreadViewModel, StubDirectMessagesService, DirectMessagesEventBus) { + private func makeViewModel( + pollInterval: Duration = .milliseconds(5), + readData: @escaping @Sendable (URL) async throws -> Data = { _ in Data([0x1]) } + ) -> (DMThreadViewModel, StubDirectMessagesService, DirectMessagesEventBus) { let service = StubDirectMessagesService() let bus = DirectMessagesEventBus() let vm = DMThreadViewModel( @@ -35,11 +52,22 @@ final class DMThreadViewModelTests: XCTestCase { service: service, eventBus: bus, currentUserID: { [me] in me }, - pollInterval: pollInterval + pollInterval: pollInterval, + readData: readData ) return (vm, service, bus) } + /// `n` distinct local image URLs. Nothing is read from disk — `readData` + /// is stubbed — so these need not exist. + private func photoURLs(_ n: Int) -> [URL] { + (0.. Int { + recorded.filter { if case .uploadImage = $0.kind { return true } else { return false } }.count + } + private var ada: UserSummary { UserSummary(id: otherId, username: "ada", displayName: "Ada", avatarURL: nil) } @@ -265,4 +293,196 @@ final class DMThreadViewModelTests: XCTestCase { XCTAssertEqual(countAfterStop, countLater, "No threadUpdates fire after stopPolling") } + + // MARK: - G22: photo attachments + + // Happy + + func test_givenAttachedPhotos_whenSending_thenUploadsEachAndSendsTheirURLs() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [inbound("m1", read: true, at: 100)], otherUser: ada, isMutual: true) + vm.seedAttachmentsForTest(urls: photoURLs(2)) + await service.enqueueUploadImage(success: "https://cdn/1.jpg") + await service.enqueueUploadImage(success: "https://cdn/2.jpg") + await service.enqueueSend(success: DirectMessage( + id: "server-1", senderId: me, recipientId: otherId, body: "look", + imageURLs: [URL(string: "https://cdn/1.jpg")!, URL(string: "https://cdn/2.jpg")!], + createdAt: Date(timeIntervalSince1970: 9_000), readAt: nil, sender: nil, recipient: ada + )) + vm.draft = "look" + + await vm.send() + + let recorded = await service.recorded + XCTAssertEqual(uploadCallCount(recorded), 2, "One upload per photo") + XCTAssertTrue(recorded.contains(.init(kind: .send( + recipientId: otherId, + body: "look", + imageURLs: ["https://cdn/1.jpg", "https://cdn/2.jpg"] + ))), "The hosted URLs ride along on the send, in pick order") + XCTAssertEqual(vm.messages.last?.id, "server-1") + XCTAssertEqual(vm.messages.last?.imageURLs.count, 2, "The photos appear in the thread") + XCTAssertTrue(vm.attachments.isEmpty, "A sent draft's picks are cleared") + XCTAssertNil(vm.error) + } + + func test_givenOnlyPhotosAndNoText_whenSending_thenTheMessageStillSends() async { + // A photo alone is a valid direct message. + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [inbound("m1", read: true, at: 100)], otherUser: ada, isMutual: true) + vm.seedAttachmentsForTest(urls: photoURLs(1)) + await service.enqueueUploadImage(success: "https://cdn/1.jpg") + await service.enqueueSend(success: serverMessage("server-2", body: "")) + + XCTAssertTrue(vm.canSend, "A queued photo is enough to enable send") + await vm.send() + + let recorded = await service.recorded + XCTAssertTrue(recorded.contains(.init(kind: .send( + recipientId: otherId, body: "", imageURLs: ["https://cdn/1.jpg"] + )))) + } + + // Invalid input — refused before any upload + + func test_givenNinthPhoto_whenAttaching_thenRefusedClientSideWithNoUploadCall() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [], otherUser: ada, isMutual: true) + + vm.addAttachments(urls: photoURLs(9)) + + XCTAssertEqual(vm.attachments.count, 8, "Only the documented 8 are kept") + XCTAssertTrue(vm.attachmentsAreFull) + XCTAssertEqual(vm.error as? DMAttachmentError, .tooMany(limit: 8)) + let recorded = await service.recorded + XCTAssertEqual(uploadCallCount(recorded), 0, "Nothing is uploaded at pick time") + } + + func test_givenNonImageFile_whenAttaching_thenRefusedWithNoUploadCall() async { + // DMs have no video route, so a movie is refused with an explanation + // rather than silently dropped. + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [], otherUser: ada, isMutual: true) + + vm.addAttachments(urls: [URL(fileURLWithPath: "/tmp/clip.mov")]) + + XCTAssertTrue(vm.attachments.isEmpty) + XCTAssertEqual(vm.error as? DMAttachmentError, .notAnImage) + let recorded = await service.recorded + XCTAssertEqual(uploadCallCount(recorded), 0) + } + + // Upstream failure — the draft survives + + func test_givenUploadFails_whenSendingWithText_thenMessageStillSendsAndFailureSurfaces() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [], otherUser: ada, isMutual: true) + vm.seedAttachmentsForTest(urls: photoURLs(1)) + await service.enqueueUploadImage(failure: TestError.upstream("upload-down")) + await service.enqueueSend(success: serverMessage("server-3", body: "text survives")) + vm.draft = "text survives" + + await vm.send() + + let recorded = await service.recorded + XCTAssertTrue( + recorded.contains(.init(kind: .send(recipientId: otherId, body: "text survives", imageURLs: []))), + "A failed photo must not cost the user their message" + ) + XCTAssertEqual(vm.messages.last?.body, "text survives") + XCTAssertEqual(vm.error as? TestError, .upstream("upload-down"), "The photo failure is still reported") + XCTAssertEqual(vm.draft, "", "The message went out, so the draft is consumed") + } + + func test_givenEveryUploadFailsOnPhotoOnlyMessage_whenSending_thenNothingIsSentAndPicksSurvive() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [], otherUser: ada, isMutual: true) + vm.seedAttachmentsForTest(urls: photoURLs(1)) + await service.enqueueUploadImage(failure: TestError.upstream("upload-down")) + + await vm.send() + + let recorded = await service.recorded + XCTAssertFalse( + recorded.contains(where: { if case .send = $0.kind { return true } else { return false } }), + "With nothing left to send, no send is attempted" + ) + XCTAssertEqual(vm.error as? TestError, .upstream("upload-down")) + XCTAssertEqual(vm.attachments.count, 1, "The pick survives for a retry") + } + + func test_givenServerRefusesUploadAsForbidden_whenSending_thenTheServerWordingIsSurfaced() async { + // Sending photos requires a verified email address. We do not + // pre-check that — issue #41 owns the gate — so the server's own + // explanation is what the user must see, verbatim. + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [], otherUser: ada, isMutual: true) + vm.seedAttachmentsForTest(urls: photoURLs(1)) + await service.enqueueUploadImage( + failure: APIError.forbidden(serverMessage: "Please verify your email address to send images.") + ) + await service.enqueueSend(success: serverMessage("server-4", body: "hi")) + vm.draft = "hi" + + await vm.send() + + XCTAssertEqual( + vm.error?.localizedDescription, + "Please verify your email address to send images.", + "The refusal reaches the UI unrewritten" + ) + } + + // Empty / boundary + + func test_givenExactlyEightPhotos_whenAttaching_thenAllAreAcceptedWithNoError() async { + let (vm, _, _) = makeViewModel() + vm.seedForTest(messages: [], otherUser: ada, isMutual: true) + + vm.addAttachments(urls: photoURLs(8)) + + XCTAssertEqual(vm.attachments.count, 8) + XCTAssertTrue(vm.attachmentsAreFull) + XCTAssertNil(vm.error, "Exactly at the cap is not an error") + } + + func test_givenNoAttachmentsAndBlankDraft_whenSending_thenServiceIsNotCalled() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [], otherUser: ada, isMutual: true) + vm.draft = " " + + await vm.send() + + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty, "Nothing to send → nothing is called") + } + + func test_givenTenThousandCharacterBody_whenSending_thenItIsAccepted() async { + // The DM ceiling is 10,000 — not the post composer's 5,000. + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [], otherUser: ada, isMutual: true) + let body = String(repeating: "a", count: 10_000) + await service.enqueueSend(success: serverMessage("server-5", body: body)) + vm.draft = body + + XCTAssertFalse(vm.isOverBodyLimit) + await vm.send() + + let recorded = await service.recorded + XCTAssertTrue(recorded.contains(.init(kind: .send(recipientId: otherId, body: body, imageURLs: [])))) + } + + func test_givenBodyOverTheLimit_whenSending_thenRefusedBeforeTheServiceIsCalled() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [], otherUser: ada, isMutual: true) + vm.draft = String(repeating: "a", count: 10_001) + + XCTAssertTrue(vm.isOverBodyLimit) + XCTAssertFalse(vm.canSend) + await vm.send() + + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty) + XCTAssertEqual(vm.error as? DMThreadError, .bodyTooLong(limit: 10_000)) + } } diff --git a/AppTests/DirectMessagesListViewModelTests.swift b/AppTests/DirectMessagesListViewModelTests.swift index 95e14e0..fd109d5 100644 --- a/AppTests/DirectMessagesListViewModelTests.swift +++ b/AppTests/DirectMessagesListViewModelTests.swift @@ -1,12 +1,21 @@ // DirectMessagesListViewModelTests // -// BDD-named tests for the DM conversation-list view model (work-consolidation.md -// G1). Covers the required quartet plus pagination and the optimistic -// trash/restore rollback: -// - happy: a folder load groups the flat listing into conversations. +// BDD-named tests for the DM conversation-list view model +// (work-consolidation.md G1, G22). Two sources are covered separately: +// +// • Sent / Deleted still fold a flat folder listing client-side, so the +// grouping tests run on `.sent` — the Inbox no longer takes that path. +// • The Inbox reads the server-grouped `conversations(cursor:)` feed. +// +// Covers the required quartet on both, plus pagination, the optimistic +// trash/restore rollback, and the G22 deep-link resolution: +// - happy: a folder load groups the flat listing into conversations; +// an inbox load paints rows straight from the server summaries. // - invalid input: trashing an id not in the list makes no service call. -// - upstream failure: a failing folder load surfaces the error. -// - empty / boundary: an empty page reports an empty list + hasLoadedOnce. +// - upstream failure: a failing load surfaces the error, on both sources. +// - empty / boundary: an empty page reports an empty list + hasLoadedOnce; +// a conversation whose newest message predates the first folder page is +// still listed by the conversations feed. // - pagination: nextCursor is surfaced (hasMore) and loadMore appends; // a zero-item page boundary clears hasMore. // - optimistic: trash drops the row locally; a failing trash restores it. @@ -25,17 +34,40 @@ final class DirectMessagesListViewModelTests: XCTestCase { private let me = "user-me" - private func makeViewModel() -> (DirectMessagesListViewModel, StubDirectMessagesService, DirectMessagesEventBus) { + /// Builds the view model on an explicit folder. `initialFolder` is set in + /// the initializer, so no unawaited `didSet` reload races the test. + private func makeViewModel( + initialFolder: DMFolder = .inbox + ) -> (DirectMessagesListViewModel, StubDirectMessagesService, DirectMessagesEventBus) { let service = StubDirectMessagesService() let bus = DirectMessagesEventBus() let vm = DirectMessagesListViewModel( service: service, eventBus: bus, - currentUserID: { [me] in me } + currentUserID: { [me] in me }, + initialFolder: initialFolder ) return (vm, service, bus) } + /// A server-grouped conversation row, as `GET /api/dm/conversations` + /// would project it. + private func summary( + pairKey: String, + with otherId: String, + username: String, + unread: Int, + latest: DirectMessage? + ) -> DMConversationSummary { + DMConversationSummary( + id: pairKey, + pairKey: pairKey, + otherUser: other(otherId, username), + unreadCount: unread, + latestMessage: latest + ) + } + private func other(_ id: String, _ username: String) -> UserSummary { UserSummary(id: id, username: username, displayName: username.capitalized, avatarURL: nil) } @@ -57,7 +89,8 @@ final class DirectMessagesListViewModelTests: XCTestCase { // MARK: - Happy path func test_givenFolderPage_whenLoading_thenGroupsMessagesIntoConversations() async { - let (vm, service, _) = makeViewModel() + // Sent still folds a flat listing client-side; the Inbox does not. + let (vm, service, _) = makeViewModel(initialFolder: .sent) await service.enqueueFolder(success: DMPage(messages: [ inbound("m3", from: "user-ada", username: "ada", at: 300), inbound("m2", from: "user-ada", username: "ada", read: true, at: 200), @@ -69,7 +102,7 @@ final class DirectMessagesListViewModelTests: XCTestCase { XCTAssertEqual(vm.conversations.count, 2, "Two distinct participants → two rows") XCTAssertEqual(vm.conversations.first?.otherUsername, "ada") - XCTAssertEqual(vm.conversations.first?.latestMessage.id, "m3", "Newest message is the preview") + XCTAssertEqual(vm.conversations.first?.latestMessage?.id, "m3", "Newest message is the preview") XCTAssertEqual(vm.conversations.first?.unreadCount, 1, "m3 unread, m2 read") XCTAssertTrue(vm.hasLoadedOnce) XCTAssertNil(vm.error) @@ -78,7 +111,7 @@ final class DirectMessagesListViewModelTests: XCTestCase { // MARK: - Invalid input (no-op trash of an absent id) func test_givenMessageIdNotInList_whenTrashing_thenServiceIsNotCalled() async { - let (vm, service, _) = makeViewModel() + let (vm, service, _) = makeViewModel(initialFolder: .sent) vm.seedForTest(messages: [inbound("m1", from: "user-ada", username: "ada", at: 100)]) await vm.trash(messageID: "does-not-exist") @@ -91,7 +124,7 @@ final class DirectMessagesListViewModelTests: XCTestCase { // MARK: - Upstream API failure func test_givenUpstreamFailure_whenLoading_thenSurfacesErrorAndHasLoadedOnce() async { - let (vm, service, _) = makeViewModel() + let (vm, service, _) = makeViewModel(initialFolder: .sent) await service.enqueueFolder(failure: TestError.upstream("net")) await service.enqueueUnreadCount(success: 0) @@ -105,7 +138,7 @@ final class DirectMessagesListViewModelTests: XCTestCase { // MARK: - Empty / boundary func test_givenEmptyPage_whenLoading_thenReportsEmptyAndHasLoadedOnce() async { - let (vm, service, _) = makeViewModel() + let (vm, service, _) = makeViewModel(initialFolder: .sent) await service.enqueueFolder(success: .empty) await service.enqueueUnreadCount(success: 0) @@ -120,7 +153,7 @@ final class DirectMessagesListViewModelTests: XCTestCase { // MARK: - Pagination func test_givenNextCursor_whenLoading_thenHasMoreIsSurfaced() async { - let (vm, service, _) = makeViewModel() + let (vm, service, _) = makeViewModel(initialFolder: .sent) await service.enqueueFolder(success: DMPage( messages: [inbound("m1", from: "user-ada", username: "ada", at: 100)], nextCursor: "cursor-2" @@ -134,7 +167,7 @@ final class DirectMessagesListViewModelTests: XCTestCase { } func test_givenNextCursor_whenLoadingMore_thenAppendsAndClearsCursorOnZeroItemPage() async { - let (vm, service, _) = makeViewModel() + let (vm, service, _) = makeViewModel(initialFolder: .sent) await service.enqueueFolder(success: DMPage( messages: [inbound("m1", from: "user-ada", username: "ada", at: 100)], nextCursor: "cursor-2" @@ -152,7 +185,7 @@ final class DirectMessagesListViewModelTests: XCTestCase { } func test_givenNextCursor_whenLoadingMore_thenSecondParticipantAppends() async { - let (vm, service, _) = makeViewModel() + let (vm, service, _) = makeViewModel(initialFolder: .sent) await service.enqueueFolder(success: DMPage( messages: [inbound("m1", from: "user-ada", username: "ada", at: 200)], nextCursor: "cursor-2" @@ -173,7 +206,7 @@ final class DirectMessagesListViewModelTests: XCTestCase { // MARK: - Optimistic trash / restore func test_givenConversation_whenTrashing_thenDropsRowAndCallsService() async { - let (vm, service, _) = makeViewModel() + let (vm, service, _) = makeViewModel(initialFolder: .sent) vm.seedForTest(messages: [ inbound("m1", from: "user-ada", username: "ada", at: 200), inbound("m2", from: "user-bob", username: "bob", at: 100) @@ -189,7 +222,7 @@ final class DirectMessagesListViewModelTests: XCTestCase { } func test_givenTrashFails_whenTrashing_thenRestoresSnapshotAndSurfacesError() async { - let (vm, service, _) = makeViewModel() + let (vm, service, _) = makeViewModel(initialFolder: .sent) vm.seedForTest(messages: [ inbound("m1", from: "user-ada", username: "ada", at: 200), inbound("m2", from: "user-bob", username: "bob", at: 100) @@ -203,8 +236,7 @@ final class DirectMessagesListViewModelTests: XCTestCase { } func test_givenRestoreFails_whenRestoring_thenRestoresSnapshotAndSurfacesError() async { - let (vm, service, _) = makeViewModel() - vm.folder = .deleted + let (vm, service, _) = makeViewModel(initialFolder: .deleted) vm.seedForTest(messages: [inbound("m1", from: "user-ada", username: "ada", at: 100)]) await service.enqueueRestore(failure: TestError.upstream("nope")) @@ -240,4 +272,263 @@ final class DirectMessagesListViewModelTests: XCTestCase { task.cancel() XCTAssertEqual(vm.unreadCount, 4) } + + // MARK: - G22: the server-grouped conversations inbox + // + // Quartet on the Inbox path, which reads `conversations(cursor:)` + // instead of collapsing a folder page client-side. + + // Happy + + func test_givenInboxFolder_whenLoading_thenReadsConversationsFeedNotFolderListing() async { + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + await service.enqueueConversations(success: DMConversationPage(conversations: [ + summary( + pairKey: "user-ada:user-me", + with: "user-ada", + username: "ada", + unread: 2, + latest: inbound("m9", from: "user-ada", username: "ada", at: 900) + ) + ], nextCursor: nil)) + await service.enqueueUnreadCount(success: 2) + + await vm.load() + + XCTAssertEqual(vm.conversations.map(\.otherUsername), ["ada"]) + XCTAssertEqual(vm.conversations.first?.unreadCount, 2, "The server's count is used verbatim") + XCTAssertEqual(vm.conversations.first?.latestMessage?.id, "m9") + let recorded = await service.recorded + XCTAssertTrue( + recorded.contains(.init(kind: .conversations(cursor: nil))), + "The inbox must read the server-grouped feed" + ) + XCTAssertFalse( + recorded.contains(.init(kind: .folder(folder: .inbox, cursor: nil))), + "The inbox must no longer collapse a folder page client-side" + ) + } + + func test_givenServerUnreadCountOfZero_whenLoadingInbox_thenRowIsNotBadged() async { + // The server owns the count on this path; nothing re-derives it from + // the preview message's read state. + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + await service.enqueueConversations(success: DMConversationPage(conversations: [ + summary( + pairKey: "p1", + with: "user-ada", + username: "ada", + unread: 0, + latest: inbound("m1", from: "user-ada", username: "ada", read: false, at: 100) + ) + ])) + await service.enqueueUnreadCount(success: 0) + + await vm.load() + + XCTAssertEqual(vm.conversations.first?.unreadCount, 0) + } + + // Invalid input + + func test_givenIdNotInConversationsFeed_whenTrashing_thenServiceIsNotCalled() async { + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + vm.seedForTest(conversations: [ + summary( + pairKey: "p1", + with: "user-ada", + username: "ada", + unread: 1, + latest: inbound("m1", from: "user-ada", username: "ada", at: 100) + ) + ]) + + await vm.trash(messageID: "not-here") + + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty, "Trashing an absent id must not call the service") + XCTAssertEqual(vm.conversations.count, 1, "The list is untouched") + } + + // Upstream failure + + func test_givenConversationsFeedFails_whenLoadingInbox_thenSurfacesErrorAndHasLoadedOnce() async { + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + await service.enqueueConversations(failure: TestError.upstream("conv-down")) + await service.enqueueUnreadCount(success: 0) + + await vm.load() + + XCTAssertEqual(vm.error as? TestError, .upstream("conv-down")) + XCTAssertTrue(vm.conversations.isEmpty) + XCTAssertTrue(vm.hasLoadedOnce) + } + + func test_givenTrashFailsOnConversationRow_whenTrashing_thenRestoresSnapshot() async { + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + vm.seedForTest(conversations: [ + summary(pairKey: "p1", with: "user-ada", username: "ada", unread: 1, + latest: inbound("m1", from: "user-ada", username: "ada", at: 200)), + summary(pairKey: "p2", with: "user-bob", username: "bob", unread: 0, + latest: inbound("m2", from: "user-bob", username: "bob", at: 100)) + ]) + await service.enqueueTrash(failure: TestError.upstream("boom")) + + await vm.trash(messageID: "m1") + + XCTAssertEqual(vm.conversations.count, 2, "The optimistic removal was rolled back") + XCTAssertEqual(vm.error as? TestError, .upstream("boom")) + } + + // Empty / boundary + + func test_givenEmptyConversationsPage_whenLoadingInbox_thenReportsEmpty() async { + // The shape the live route actually returned on 2026-09-09: + // {"items":[],"nextCursor":null}. + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + await service.enqueueConversations(success: .empty) + await service.enqueueUnreadCount(success: 0) + + await vm.load() + + XCTAssertTrue(vm.conversations.isEmpty) + XCTAssertFalse(vm.hasMore) + XCTAssertTrue(vm.hasLoadedOnce) + XCTAssertNil(vm.error) + } + + func test_givenConversationOlderThanAFolderPage_whenLoadingInbox_thenItIsStillListed() async { + // The whole point of G22: with client-side grouping, a conversation + // whose newest message falls off the end of the fetched folder page is + // invisible. Server-side grouping has no such failure mode — this row + // is far older than the others and still arrives on page one. + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + await service.enqueueConversations(success: DMConversationPage(conversations: [ + summary(pairKey: "p1", with: "user-ada", username: "ada", unread: 0, + latest: inbound("recent", from: "user-ada", username: "ada", at: 9_000)), + summary(pairKey: "p2", with: "user-zed", username: "zed", unread: 1, + latest: inbound("ancient", from: "user-zed", username: "zed", at: 1)) + ], nextCursor: nil)) + await service.enqueueUnreadCount(success: 1) + + await vm.load() + + XCTAssertEqual(vm.conversations.map(\.otherUsername), ["ada", "zed"]) + XCTAssertEqual(vm.conversations.last?.latestMessage?.id, "ancient") + } + + func test_givenRowWithNoDecodableMessage_whenLoadingInbox_thenRowStillListsWithEmptyPreview() async { + // The populated server shape is unverified, so the decoder is + // permissive. A row whose message we could not decode must still list + // rather than vanish or crash the column. + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + await service.enqueueConversations(success: DMConversationPage(conversations: [ + summary(pairKey: "p1", with: "user-ada", username: "ada", unread: 3, latest: nil) + ])) + await service.enqueueUnreadCount(success: 3) + + await vm.load() + + XCTAssertEqual(vm.conversations.count, 1) + XCTAssertEqual(vm.conversations.first?.preview, "") + XCTAssertNil(vm.conversations.first?.latestMessage) + XCTAssertEqual(vm.conversations.first?.unreadCount, 3) + } + + // Pagination + + func test_givenConversationsCursor_whenLoadingMore_thenAppendsRowsAndPassesCursor() async { + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + await service.enqueueConversations(success: DMConversationPage(conversations: [ + summary(pairKey: "p1", with: "user-ada", username: "ada", unread: 0, + latest: inbound("m1", from: "user-ada", username: "ada", at: 200)) + ], nextCursor: "cursor-2")) + await service.enqueueUnreadCount(success: 0) + await vm.load() + XCTAssertTrue(vm.hasMore) + + await service.enqueueConversations(success: DMConversationPage(conversations: [ + summary(pairKey: "p2", with: "user-bob", username: "bob", unread: 0, + latest: inbound("m2", from: "user-bob", username: "bob", at: 100)) + ], nextCursor: nil)) + await vm.loadMore() + + XCTAssertEqual(vm.conversations.map(\.otherUsername), ["ada", "bob"]) + XCTAssertFalse(vm.hasMore, "A nil next cursor exhausts pagination") + let recorded = await service.recorded + XCTAssertTrue(recorded.contains(.init(kind: .conversations(cursor: "cursor-2")))) + } + + func test_givenZeroItemConversationsPage_whenLoadingMore_thenClearsHasMore() async { + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + await service.enqueueConversations(success: DMConversationPage(conversations: [ + summary(pairKey: "p1", with: "user-ada", username: "ada", unread: 0, + latest: inbound("m1", from: "user-ada", username: "ada", at: 200)) + ], nextCursor: "cursor-2")) + await service.enqueueUnreadCount(success: 0) + await vm.load() + + await service.enqueueConversations(success: DMConversationPage(conversations: [], nextCursor: nil)) + await vm.loadMore() + + XCTAssertFalse(vm.hasMore) + XCTAssertEqual(vm.conversations.count, 1, "No new rows from the empty page") + } + + // MARK: - G22: single-message deep-link resolution + + func test_givenMessageAlreadyInListing_whenResolvingDeepLink_thenAnswersWithoutFetching() async { + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + vm.seedForTest(conversations: [ + summary(pairKey: "p1", with: "user-ada", username: "ada", unread: 1, + latest: inbound("m1", from: "user-ada", username: "ada", at: 100)) + ]) + + let username = await vm.conversationUsername(forMessageID: "m1") + + XCTAssertEqual(username, "ada") + let recorded = await service.recorded + XCTAssertFalse( + recorded.contains(.init(kind: .message(id: "m1"))), + "A message already on screen must not cost a round-trip" + ) + } + + func test_givenUnknownMessageId_whenResolvingDeepLink_thenFetchesAndResolvesUsername() async { + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + await service.enqueueMessage(success: inbound("m42", from: "user-zed", username: "zed", at: 500)) + + let username = await vm.conversationUsername(forMessageID: "m42") + + XCTAssertEqual(username, "zed") + let recorded = await service.recorded + XCTAssertTrue(recorded.contains(.init(kind: .message(id: "m42")))) + } + + func test_givenFetchFails_whenResolvingDeepLink_thenReturnsNilAndDoesNotErrorTheListing() async { + // A deep link we can't follow is not a listing failure — the column + // must not be replaced by an error banner over it. + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + await service.enqueueMessage(failure: TestError.upstream("404")) + + let username = await vm.conversationUsername(forMessageID: "gone") + + XCTAssertNil(username) + XCTAssertNil(vm.error) + } + + func test_givenMessageWithNoNamedParticipants_whenResolvingDeepLink_thenReturnsNil() async { + let (vm, service, _) = makeViewModel(initialFolder: .inbox) + await service.enqueueMessage(success: DirectMessage( + id: "bare", + senderId: "user-zed", + recipientId: me, + body: "hi", + createdAt: Date(timeIntervalSince1970: 10) + )) + + let username = await vm.conversationUsername(forMessageID: "bare") + + XCTAssertNil(username, "No username to open a thread with") + } } diff --git a/AppTests/NewMessageViewModelTests.swift b/AppTests/NewMessageViewModelTests.swift index 52bde00..ebddb59 100644 --- a/AppTests/NewMessageViewModelTests.swift +++ b/AppTests/NewMessageViewModelTests.swift @@ -8,21 +8,45 @@ // service. // - upstream failure: a failing send surfaces the error. // - empty / boundary: an empty recipient list is reported so the sheet -// can show its "no mutual followers" state. +// can show its mutual-follow explanation. +// +// G22 adds the photo-attachment quartet, mirroring `DMThreadViewModelTests`: +// - happy: attached photos upload and ride along on the send. +// - invalid input: a 9th photo is refused client-side, with no upload. +// - upstream failure: a failed upload does not lose the draft; the +// message still sends as text and the failure surfaces (photo sending +// needs a verified email — the server's 403 is what the user sees; +// the gate itself is issue #41's). +// - boundary: a 10,000-character body is accepted, 10,001 refused. import XCTest import InterlinedDomain +// The forbidden-refusal case asserts on the real `APIError`, so the +// server's wording is verified through the exact type production throws. +import InterlinedKit @testable import InterlinedList @MainActor final class NewMessageViewModelTests: XCTestCase { - private func makeViewModel() -> (NewMessageViewModel, StubDirectMessagesService) { + private func makeViewModel( + readData: @escaping @Sendable (URL) async throws -> Data = { _ in Data([0x1]) } + ) -> (NewMessageViewModel, StubDirectMessagesService) { let service = StubDirectMessagesService() - let vm = NewMessageViewModel(service: service) + let vm = NewMessageViewModel(service: service, readData: readData) return (vm, service) } + /// `n` distinct local image URLs. Nothing is read from disk — `readData` + /// is stubbed — so these need not exist. + private func photoURLs(_ n: Int) -> [URL] { + (0.. Int { + recorded.filter { if case .uploadImage = $0.kind { return true } else { return false } }.count + } + private func user(_ id: String, _ username: String) -> UserSummary { UserSummary(id: id, username: username, displayName: username.capitalized, avatarURL: nil) } @@ -134,4 +158,177 @@ final class NewMessageViewModelTests: XCTestCase { XCTAssertEqual(vm.error as? TestError, .upstream("net")) XCTAssertTrue(vm.hasLoadedRecipients) } + + // MARK: - G22: photo attachments + + // Happy + + func test_givenAttachedPhotos_whenSending_thenUploadsEachAndSendsTheirURLs() async { + let (vm, service) = makeViewModel() + vm.seedRecipientsForTest([user("u1", "ada")]) + vm.selectedRecipientId = "u1" + vm.body = "look" + vm.addAttachments(urls: photoURLs(2)) + await service.enqueueUploadImage(success: "https://cdn/1.jpg") + await service.enqueueUploadImage(success: "https://cdn/2.jpg") + await service.enqueueSend(success: sent("m1", to: "u1")) + + await vm.send() + + let recorded = await service.recorded + XCTAssertEqual(uploadCallCount(recorded), 2, "One upload per photo") + XCTAssertTrue(recorded.contains(.init(kind: .send( + recipientId: "u1", body: "look", imageURLs: ["https://cdn/1.jpg", "https://cdn/2.jpg"] + ))), "The hosted URLs ride along on the send, in pick order") + XCTAssertNotNil(vm.sentMessage) + XCTAssertTrue(vm.attachments.isEmpty, "A sent draft's picks are cleared") + XCTAssertNil(vm.error) + } + + func test_givenOnlyPhotosAndNoBody_whenSending_thenTheMessageStillSends() async { + let (vm, service) = makeViewModel() + vm.seedRecipientsForTest([user("u1", "ada")]) + vm.selectedRecipientId = "u1" + vm.addAttachments(urls: photoURLs(1)) + await service.enqueueUploadImage(success: "https://cdn/1.jpg") + await service.enqueueSend(success: sent("m1", to: "u1")) + + XCTAssertTrue(vm.canSend, "A queued photo is enough to enable send") + await vm.send() + + let recorded = await service.recorded + XCTAssertTrue(recorded.contains(.init(kind: .send( + recipientId: "u1", body: "", imageURLs: ["https://cdn/1.jpg"] + )))) + } + + // Invalid input + + func test_givenNinthPhoto_whenAttaching_thenRefusedClientSideWithNoUploadCall() async { + let (vm, service) = makeViewModel() + + vm.addAttachments(urls: photoURLs(9)) + + XCTAssertEqual(vm.attachments.count, 8, "Only the documented 8 are kept") + XCTAssertEqual(vm.error as? DMAttachmentError, .tooMany(limit: 8)) + let recorded = await service.recorded + XCTAssertEqual(uploadCallCount(recorded), 0, "Nothing is uploaded at pick time") + } + + func test_givenPhotosButNoRecipient_whenSending_thenRefusedBeforeAnyUpload() async { + let (vm, service) = makeViewModel() + vm.addAttachments(urls: photoURLs(1)) + + await vm.send() + + XCTAssertEqual(vm.error as? NewMessageError, .noRecipient) + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty, "No recipient → nothing is uploaded and nothing is sent") + } + + // Upstream failure + + func test_givenUploadFails_whenSendingWithBody_thenMessageStillSendsAndFailureSurfaces() async { + let (vm, service) = makeViewModel() + vm.seedRecipientsForTest([user("u1", "ada")]) + vm.selectedRecipientId = "u1" + vm.body = "text survives" + vm.addAttachments(urls: photoURLs(1)) + await service.enqueueUploadImage(failure: TestError.upstream("upload-down")) + await service.enqueueSend(success: sent("m1", to: "u1")) + + await vm.send() + + let recorded = await service.recorded + XCTAssertTrue( + recorded.contains(.init(kind: .send(recipientId: "u1", body: "text survives", imageURLs: []))), + "A failed photo must not cost the user their message" + ) + XCTAssertNotNil(vm.sentMessage) + XCTAssertEqual(vm.error as? TestError, .upstream("upload-down"), "The photo failure is still reported") + } + + func test_givenEveryUploadFailsOnPhotoOnlyMessage_whenSending_thenNothingIsSentAndPicksSurvive() async { + let (vm, service) = makeViewModel() + vm.seedRecipientsForTest([user("u1", "ada")]) + vm.selectedRecipientId = "u1" + vm.addAttachments(urls: photoURLs(1)) + await service.enqueueUploadImage(failure: TestError.upstream("upload-down")) + + await vm.send() + + let recorded = await service.recorded + XCTAssertFalse( + recorded.contains(where: { if case .send = $0.kind { return true } else { return false } }), + "With nothing left to send, no send is attempted" + ) + XCTAssertNil(vm.sentMessage) + XCTAssertEqual(vm.attachments.count, 1, "The pick survives for a retry") + } + + func test_givenServerRefusesUploadAsForbidden_whenSending_thenTheServerWordingIsSurfaced() async { + // Photo sending requires a verified email address. We do not + // pre-check that — issue #41 owns the gate — so the server's own + // explanation is what the user must see, verbatim. + let (vm, service) = makeViewModel() + vm.seedRecipientsForTest([user("u1", "ada")]) + vm.selectedRecipientId = "u1" + vm.body = "hi" + vm.addAttachments(urls: photoURLs(1)) + await service.enqueueUploadImage( + failure: APIError.forbidden(serverMessage: "Please verify your email address to send images.") + ) + await service.enqueueSend(success: sent("m1", to: "u1")) + + await vm.send() + + XCTAssertEqual( + vm.error?.localizedDescription, + "Please verify your email address to send images.", + "The refusal reaches the UI unrewritten" + ) + } + + // Empty / boundary + + func test_givenExactlyEightPhotos_whenAttaching_thenAllAreAcceptedWithNoError() { + let (vm, _) = makeViewModel() + + vm.addAttachments(urls: photoURLs(8)) + + XCTAssertEqual(vm.attachments.count, 8) + XCTAssertTrue(vm.attachmentsAreFull) + XCTAssertNil(vm.error, "Exactly at the cap is not an error") + } + + func test_givenTenThousandCharacterBody_whenSending_thenItIsAccepted() async { + // The DM ceiling is 10,000 — not the post composer's 5,000. + let (vm, service) = makeViewModel() + vm.seedRecipientsForTest([user("u1", "ada")]) + vm.selectedRecipientId = "u1" + let body = String(repeating: "a", count: 10_000) + vm.body = body + await service.enqueueSend(success: sent("m1", to: "u1")) + + XCTAssertFalse(vm.isOverBodyLimit) + await vm.send() + + let recorded = await service.recorded + XCTAssertTrue(recorded.contains(.init(kind: .send(recipientId: "u1", body: body, imageURLs: [])))) + } + + func test_givenBodyOverTheLimit_whenSending_thenRefusedBeforeTheServiceIsCalled() async { + let (vm, service) = makeViewModel() + vm.seedRecipientsForTest([user("u1", "ada")]) + vm.selectedRecipientId = "u1" + vm.body = String(repeating: "a", count: 10_001) + + XCTAssertTrue(vm.isOverBodyLimit) + XCTAssertFalse(vm.canSend) + await vm.send() + + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty) + XCTAssertEqual(vm.error as? NewMessageError, .bodyTooLong(limit: 10_000)) + } } diff --git a/AppTests/Support/StubDirectMessagesService.swift b/AppTests/Support/StubDirectMessagesService.swift index 4488daf..ce447cc 100644 --- a/AppTests/Support/StubDirectMessagesService.swift +++ b/AppTests/Support/StubDirectMessagesService.swift @@ -7,6 +7,11 @@ // log so tests can assert both the returned value and that the right // call was (or was not) made. // +// G22 adds `conversations` (the server-grouped inbox), `message(id:)` (the +// deep-link fetch), and `uploadImage` (photo attachments) — each with its +// own outcome queue and recorded-call kind. `uploadImage` records only the +// byte count so assertions stay readable. +// // The `send` guard (`DirectMessagesError.emptyMessage` on a blank body // with no images) lives in the concrete `DirectMessagesService`, not in // the protocol, so this stub does NOT re-implement it — a view model @@ -20,6 +25,9 @@ import InterlinedDomain struct RecordedDMCall: Sendable, Equatable { enum Kind: Sendable, Equatable { case folder(folder: DMFolder, cursor: String?) + case conversations(cursor: String?) + case message(id: String) + case uploadImage(byteCount: Int) case thread(username: String, cursor: String?) case threadUpdates(username: String, since: String?) case send(recipientId: String, body: String, imageURLs: [String]) @@ -35,6 +43,9 @@ struct RecordedDMCall: Sendable, Equatable { actor StubDirectMessagesService: DirectMessagesServicing { private var folderOutcomes: [Result] = [] + private var conversationsOutcomes: [Result] = [] + private var messageOutcomes: [Result] = [] + private var uploadImageOutcomes: [Result] = [] private var threadOutcomes: [Result] = [] private var threadUpdatesOutcomes: [Result] = [] private var sendOutcomes: [Result] = [] @@ -51,6 +62,15 @@ actor StubDirectMessagesService: DirectMessagesServicing { func enqueueFolder(success value: DMPage) { folderOutcomes.append(.success(value)) } func enqueueFolder(failure error: Error) { folderOutcomes.append(.failure(error)) } + func enqueueConversations(success value: DMConversationPage) { conversationsOutcomes.append(.success(value)) } + func enqueueConversations(failure error: Error) { conversationsOutcomes.append(.failure(error)) } + + func enqueueMessage(success value: DirectMessage) { messageOutcomes.append(.success(value)) } + func enqueueMessage(failure error: Error) { messageOutcomes.append(.failure(error)) } + + func enqueueUploadImage(success url: String) { uploadImageOutcomes.append(.success(url)) } + func enqueueUploadImage(failure error: Error) { uploadImageOutcomes.append(.failure(error)) } + func enqueueThread(success value: DMThread) { threadOutcomes.append(.success(value)) } func enqueueThread(failure error: Error) { threadOutcomes.append(.failure(error)) } @@ -82,6 +102,23 @@ actor StubDirectMessagesService: DirectMessagesServicing { return try take(&folderOutcomes, label: "folder") } + func conversations(cursor: String?) async throws -> DMConversationPage { + recorded.append(.init(kind: .conversations(cursor: cursor))) + return try take(&conversationsOutcomes, label: "conversations") + } + + func message(id: String) async throws -> DirectMessage { + recorded.append(.init(kind: .message(id: id))) + return try take(&messageOutcomes, label: "message") + } + + func uploadImage(_ data: Data) async throws -> String { + // Record the byte count rather than the bytes so assertions stay + // readable and the log doesn't carry image payloads. + recorded.append(.init(kind: .uploadImage(byteCount: data.count))) + return try take(&uploadImageOutcomes, label: "uploadImage") + } + func thread(username: String, cursor: String?) async throws -> DMThread { recorded.append(.init(kind: .thread(username: username, cursor: cursor))) return try take(&threadOutcomes, label: "thread") diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessage.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessage.swift index c626985..bce44e4 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessage.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessage.swift @@ -104,3 +104,90 @@ public struct DMThread: Sendable, Equatable { self.olderCursor = olderCursor } } + +// MARK: - DMLimits + +/// The documented Direct Message ceilings (work-consolidation.md G22). +/// +/// These are **not** in `GET /api/limits` — that endpoint reports the *public +/// post* limits (`message.maxContentLength` was 5000 live on 2026-09-09), +/// which is a different surface. The DM numbers come from +/// `https://interlinedlist.com/help/direct-messages`: +/// +/// "You can attach photos to a direct message, up to 8 per message." +/// "you can use Markdown for formatting, up to 10,000 characters" +/// +/// The per-image *size* ceilings are separate and DO come from the server — +/// they run through `ContentLimits.imagePrepLimits`, shared with the post +/// composer, so nothing here duplicates a server-driven value. +public enum DMLimits { + /// Maximum photos attachable to one direct message. + public static let maxImagesPerMessage = 8 + /// Maximum body length in characters. Markdown is counted raw. + public static let maxBodyCharacters = 10_000 +} + +// MARK: - DMConversationSummary + +/// One server-grouped conversation row from `GET /api/dm/conversations` +/// (work-consolidation.md G22). +/// +/// This is the domain projection of the *real* inbox. It replaces collapsing a +/// folder page client-side, which could only ever see the conversations whose +/// newest message happened to land on the fetched page. +/// +/// `latestMessage` is optional because the populated server shape is +/// unverified (the shared test account's inbox is empty and we may not write to +/// it) — a row whose message we could not decode still renders as a +/// conversation rather than vanishing or crashing. +public struct DMConversationSummary: Sendable, Equatable, Hashable, Identifiable { + /// Stable row identity: the `pairKey` when the server sends one, else the + /// other participant's id, else the newest message's id. Never empty. + public let id: String + /// The server's grouping key, when reported. + public let pairKey: String? + /// The other participant in the conversation. + public let otherUser: UserSummary? + /// Unread inbound messages in this conversation (0 when unreported). + public let unreadCount: Int + /// The newest message, rendered as the row preview. + public let latestMessage: DirectMessage? + + public init( + id: String, + pairKey: String? = nil, + otherUser: UserSummary? = nil, + unreadCount: Int = 0, + latestMessage: DirectMessage? = nil + ) { + self.id = id + self.pairKey = pairKey + self.otherUser = otherUser + self.unreadCount = unreadCount + self.latestMessage = latestMessage + } + + /// The username used to open the thread. Empty only when the server named + /// neither the other user nor a decodable message. + public var otherUsername: String { + otherUser?.username ?? latestMessage?.sender?.username ?? "" + } + + /// One-line preview for the row. + public var preview: String { latestMessage?.body ?? "" } +} + +// MARK: - DMConversationPage + +/// A cursor-paginated page of server-grouped conversations. +public struct DMConversationPage: Sendable, Equatable { + public let conversations: [DMConversationSummary] + public let nextCursor: String? + + public init(conversations: [DMConversationSummary], nextCursor: String? = nil) { + self.conversations = conversations + self.nextCursor = nextCursor + } + + public static let empty = DMConversationPage(conversations: [], nextCursor: nil) +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessageMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessageMappers.swift index 92eecec..2b7de6a 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessageMappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessageMappers.swift @@ -45,3 +45,46 @@ extension DMThread { ) } } + +// MARK: - G22: server-grouped conversations + +extension DMConversationSummary { + + /// Maps one `GET /api/dm/conversations` row. + /// + /// Identity falls through `pairKey` → other participant's id → newest + /// message id → `fallbackID`, so a row is never dropped, nor collides with + /// a sibling row, for want of an id — which matters because the populated + /// server shape is unverified and the decoder is deliberately permissive. + /// + /// `unreadCount` defaults to 0 rather than nil: an unreported count means + /// "nothing to badge", and a phantom badge is worse than a missing one. + /// + /// - Parameter fallbackID: last-resort identity, supplied by the page + /// mapper as the row's position so it is unique within the page. + public init(from dto: DMConversationDTO, fallbackID: String) { + let message = dto.lastMessage.map(DirectMessage.init(from:)) + let other = dto.otherUser.map(UserSummary.init(from:)) + let identity = [dto.pairKey, other?.id, message?.id] + .compactMap { $0 } + .first { !$0.isEmpty } ?? fallbackID + self.init( + id: identity, + pairKey: dto.pairKey, + otherUser: other, + unreadCount: dto.unreadCount ?? 0, + latestMessage: message + ) + } +} + +extension DMConversationPage { + public init(from dto: DMConversationsPage) { + self.init( + conversations: dto.items.enumerated().map { index, item in + DMConversationSummary(from: item, fallbackID: "dm-conversation-\(index)") + }, + nextCursor: dto.nextCursor + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DirectMessagesService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DirectMessagesService.swift index 7fca109..022cb99 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DirectMessagesService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DirectMessagesService.swift @@ -6,13 +6,28 @@ import InterlinedKit public enum DirectMessagesError: Error, Sendable, Equatable { /// Attempted to send a message with no text and no images. case emptyMessage + + /// More photos were queued than the documented per-message cap + /// (work-consolidation.md G22). Rejected client-side before any upload, so + /// we never burn a round-trip on bytes the server will refuse. + case tooManyImages(limit: Int) + + /// The body exceeded the documented DM character ceiling. Note this is + /// 10,000 for DMs — far larger than the 5,000 the *post* composer enforces + /// from `GET /api/limits`; the two surfaces are not the same limit. + case bodyTooLong(limit: Int) } extension DirectMessagesError: LocalizedError, CustomStringConvertible { public var errorDescription: String? { description } public var description: String { switch self { - case .emptyMessage: return "A message needs text or an image before it can be sent." + case .emptyMessage: + return "A message needs text or an image before it can be sent." + case .tooManyImages(let limit): + return "You can attach up to \(limit) photos to a direct message." + case .bodyTooLong(let limit): + return "A direct message can be up to \(limit) characters." } } } @@ -29,6 +44,30 @@ extension DirectMessagesError: LocalizedError, CustomStringConvertible { /// are fire-and-forget (`sendVoid`). public protocol DirectMessagesServicing: Sendable { func folder(_ folder: DMFolder, cursor: String?) async throws -> DMPage + + /// The server-grouped inbox (work-consolidation.md G22). One row per + /// conversation, keyed by `pairKey`, newest first, with its own cursor. + /// + /// This supersedes collapsing `folder(.inbox)` client-side for the inbox. + /// The folder listing is still the right call for Sent and Deleted, which + /// this route does not replace. + func conversations(cursor: String?) async throws -> DMConversationPage + + /// One message by id — the deep-link target (work-consolidation.md G22). + func message(id: String) async throws -> DirectMessage + + /// Prepares and uploads one photo, returning its hosted URL for + /// `send(recipientId:body:imageURLs:)` (work-consolidation.md G22). + /// + /// Sending photos requires a **verified email address**. This method does + /// NOT pre-check that: the server's 403 is surfaced verbatim instead. + /// TODO(#41): the email-verification gate is owned by issue #41, which + /// builds `CapabilityGate` (status → email verification → tier). When that + /// branch merges, have the DM composers ask the gate so the affordance is + /// explained *before* the user picks a file. Do not add a second check + /// here — one gate, one owner. + func uploadImage(_ data: Data) async throws -> String + func thread(username: String, cursor: String?) async throws -> DMThread func threadUpdates(username: String, since: String?) async throws -> DMThread func send(recipientId: String, body: String, imageURLs: [String]) async throws -> DirectMessage @@ -43,6 +82,9 @@ public extension DirectMessagesServicing { func folder(_ folder: DMFolder = .inbox) async throws -> DMPage { try await self.folder(folder, cursor: nil) } + func conversations() async throws -> DMConversationPage { + try await conversations(cursor: nil) + } func thread(username: String) async throws -> DMThread { try await thread(username: username, cursor: nil) } @@ -57,8 +99,14 @@ public final class DirectMessagesService: DirectMessagesServicing { private let api: APIClientProtocol - public init(api: APIClientProtocol) { + /// Source of the server-authoritative image size ceilings (G14). Optional + /// so hosts that do not wire it fall back to `ContentLimits.default` — the + /// same values `ImagePrep` bakes in — rather than failing to upload. + private let contentLimits: ContentLimitsProviding? + + public init(api: APIClientProtocol, contentLimits: ContentLimitsProviding? = nil) { self.api = api + self.contentLimits = contentLimits } public func folder(_ folder: DMFolder, cursor: String?) async throws -> DMPage { @@ -66,6 +114,36 @@ public final class DirectMessagesService: DirectMessagesServicing { return DMPage(from: dto) } + public func conversations(cursor: String?) async throws -> DMConversationPage { + let dto = try await api.send(DirectMessages.conversations(cursor: cursor)) + return DMConversationPage(from: dto) + } + + public func message(id: String) async throws -> DirectMessage { + let dto = try await api.send(DirectMessages.message(id: id)) + return DirectMessage(from: dto.message) + } + + public func uploadImage(_ data: Data) async throws -> String { + // Prefer the live `GET /api/limits` ceilings (G14 tail) so the prep + // budget is server-driven; `ContentLimits.default` when no provider is + // injected or the fetch failed. Identical to the post composer's path — + // deliberately the same `ImagePrep` pipeline, not fresh constants. + let limits = await contentLimits?.limits() ?? .default + let prepared = try ImagePrep.prepare(data, limits: limits.imagePrepLimits) + // A 403 here is the documented "verified email required" refusal. It + // propagates untouched: `APIError.forbidden` preserves the server's own + // wording, which is the canonical explanation for the user. + // TODO(#41): the email-verification gate is owned by issue #41 and + // arrives as `CapabilityGate`. When it lands, the composer should + // disable the attach affordance up front and explain why. Do not add a + // competing pre-check in this service. + let response = try await api.send( + DirectMessages.uploadImage(prepared.data, contentType: prepared.format.mimeType) + ) + return response.url + } + public func thread(username: String, cursor: String?) async throws -> DMThread { let dto = try await api.send(DirectMessages.thread(username: username, cursor: cursor)) return DMThread(from: dto) @@ -79,6 +157,14 @@ public final class DirectMessagesService: DirectMessagesServicing { public func send(recipientId: String, body: String, imageURLs: [String]) async throws -> DirectMessage { let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty || !imageURLs.isEmpty else { throw DirectMessagesError.emptyMessage } + // The documented DM ceilings (G22). Rejected before the request so a + // doomed payload never leaves the machine. + guard imageURLs.count <= DMLimits.maxImagesPerMessage else { + throw DirectMessagesError.tooManyImages(limit: DMLimits.maxImagesPerMessage) + } + guard trimmed.count <= DMLimits.maxBodyCharacters else { + throw DirectMessagesError.bodyTooLong(limit: DMLimits.maxBodyCharacters) + } let request = SendDirectMessageRequest( recipientId: recipientId, body: trimmed, diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DirectMessagesServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DirectMessagesServiceTests.swift index 7a748ce..5ded8c3 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DirectMessagesServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DirectMessagesServiceTests.swift @@ -179,4 +179,307 @@ final class DirectMessagesServiceTests: XCTestCase { let recorded = await api.recorded XCTAssertEqual(recorded.first?.path, "/api/dm/m1/restore") } + + // MARK: - G22: conversations, single message, image upload + // + // Routes probed live 2026-09-09, read-only (GET + OPTIONS). The populated + // conversations shape could not be captured — the shared test account's + // inbox is empty and writes were not permitted — so the decoder is + // permissive and these cases pin that tolerance. + + // MARK: conversations — happy + + func test_givenNestedConversations_whenLoading_thenMapsRowsAndHitsPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + {"items":[{"pairKey":"s:r","unreadCount":2, + "otherUser":{"id":"s","username":"messenger","displayName":"Messenger"}, + "lastMessage":\#(dmJSON(id: "m1"))}], + "nextCursor":"c2"} + """#) + let service = DirectMessagesService(api: api) + + let page = try await service.conversations() + + XCTAssertEqual(page.conversations.count, 1) + XCTAssertEqual(page.conversations.first?.id, "s:r", "pairKey is the row identity") + XCTAssertEqual(page.conversations.first?.otherUsername, "messenger") + XCTAssertEqual(page.conversations.first?.unreadCount, 2) + XCTAssertEqual(page.conversations.first?.latestMessage?.id, "m1") + XCTAssertEqual(page.conversations.first?.preview, "hi there") + XCTAssertEqual(page.nextCursor, "c2") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/dm/conversations") + XCTAssertEqual(recorded.first?.method, "GET") + } + + func test_givenFlattenedConversations_whenLoading_thenTreatsTheRowAsItsNewestMessage() async throws { + // A `GROUP BY pairKey` that returns the newest row per pair. + let api = StubAPIClient() + await api.enqueue(json: #"{"items":[\#(dmJSON(id: "m7"))],"nextCursor":null}"#) + let service = DirectMessagesService(api: api) + + let page = try await service.conversations() + + XCTAssertEqual(page.conversations.first?.latestMessage?.id, "m7") + XCTAssertEqual(page.conversations.first?.id, "s:r") + XCTAssertEqual(page.conversations.first?.otherUsername, "messenger", + "Falls back to the message's sender when no otherUser is named") + } + + func test_givenCursor_whenLoadingConversations_thenForwardsIt() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"items":[],"nextCursor":null}"#) + let service = DirectMessagesService(api: api) + + _ = try await service.conversations(cursor: "c9") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.query["cursor"], "c9") + } + + // MARK: conversations — invalid / unexpected shape + + func test_givenUnknownConversationKeys_whenLoading_thenRowSurvivesWithFallbackIdentity() async throws { + // Permissive by design: an unrecognised spelling degrades one field, + // it does not fail the page — and the row still gets a unique id so + // two such rows can't collide in a SwiftUI list. + let api = StubAPIClient() + await api.enqueue(json: #"{"items":[{"totallyUnknown":"x"},{"alsoUnknown":"y"}],"nextCursor":null}"#) + let service = DirectMessagesService(api: api) + + let page = try await service.conversations() + + XCTAssertEqual(page.conversations.map(\.id), ["dm-conversation-0", "dm-conversation-1"]) + XCTAssertNil(page.conversations.first?.latestMessage) + XCTAssertEqual(page.conversations.first?.unreadCount, 0, "An unreported count badges nothing") + XCTAssertEqual(page.conversations.first?.preview, "") + } + + // MARK: conversations — upstream failure + + func test_givenServerFailure_whenLoadingConversations_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = DirectMessagesService(api: api) + + do { + _ = try await service.conversations() + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + // MARK: conversations — empty / boundary + + func test_givenEmptyConversations_whenLoading_thenReturnsEmptyPage() async throws { + // The exact body the live route returned on 2026-09-09. + let api = StubAPIClient() + await api.enqueue(json: #"{"items":[],"nextCursor":null}"#) + let service = DirectMessagesService(api: api) + + let page = try await service.conversations() + + XCTAssertTrue(page.conversations.isEmpty) + XCTAssertNil(page.nextCursor) + } + + // MARK: message(id:) + + func test_givenWrappedMessage_whenFetchingById_thenMapsItAndHitsPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"message":\#(dmJSON(id: "m5"))}"#) + let service = DirectMessagesService(api: api) + + let message = try await service.message(id: "m5") + + XCTAssertEqual(message.id, "m5") + XCTAssertEqual(message.sender?.username, "messenger") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/dm/m5") + } + + func test_givenBareMessage_whenFetchingById_thenStillMapsIt() async throws { + let api = StubAPIClient() + await api.enqueue(json: dmJSON(id: "m6")) + let service = DirectMessagesService(api: api) + + let message = try await service.message(id: "m6") + + XCTAssertEqual(message.id, "m6") + } + + func test_givenNotFound_whenFetchingMessageById_thenThrowsNotFound() async throws { + // What the live route answered for an unknown id on 2026-09-09. + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "Message not found.")) + let service = DirectMessagesService(api: api) + + do { + _ = try await service.message(id: "nope") + XCTFail("Expected notFound") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "Message not found.")) + } + } + + func test_givenMessageWithNoImages_whenFetchingById_thenImageURLsIsEmptyNotNil() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + {"id":"m0","senderId":"s","recipientId":"r","body":"plain", + "createdAt":"2026-07-31T22:20:32.337Z"} + """#) + let service = DirectMessagesService(api: api) + + let message = try await service.message(id: "m0") + + XCTAssertTrue(message.imageURLs.isEmpty) + XCTAssertNil(message.readAt) + } + + // MARK: uploadImage — happy + + func test_givenValidImage_whenUploading_thenPreparesAndReturnsHostedURL() async throws { + let api = StubAPIClient() + await api.enqueue(json: Fixtures.mediaUploadResponse(url: "https://cdn/dm.png")) + let service = DirectMessagesService(api: api) + + let url = try await service.uploadImage(Fixtures.tinyPNGData) + + XCTAssertEqual(url, "https://cdn/dm.png") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/dm/images/upload") + XCTAssertEqual(recorded.first?.method, "POST") + } + + func test_givenLiveLimits_whenUploading_thenPrepUsesThemNotTheBuiltInConstants() async throws { + // G14 tail: the prep budget is server-driven, shared with the post + // composer. This same PNG uploads fine under the default 1.4 MB + // ceiling (the test above); under an injected 10-byte ceiling prep + // exhausts its ladder and refuses — which is only possible if the + // injected limits, not `ImagePrep`'s constants, drove the pipeline. + let api = StubAPIClient() + let limits = StubDMContentLimits(value: ContentLimits( + imageMaxBytes: 10, + imageMaxPixels: 1, + imageAcceptedFormats: ["png"], + videoMaxBytes: 1, + videoAcceptedFormats: [], + messageMaxContentLength: 5000 + )) + let service = DirectMessagesService(api: api, contentLimits: limits) + + do { + _ = try await service.uploadImage(Fixtures.tinyPNGData) + XCTFail("Expected the injected byte ceiling to be enforced") + } catch let error as ImagePrepError { + XCTAssertEqual(error, .tooLargeAfterAllAttempts) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "Nothing is uploaded when prep can't meet the budget") + } + + // MARK: uploadImage — invalid input + + func test_givenUndecodableBytes_whenUploading_thenThrowsBeforeAnyRequest() async throws { + let api = StubAPIClient() + let service = DirectMessagesService(api: api) + + do { + _ = try await service.uploadImage(Data("not an image".utf8)) + XCTFail("Expected ImagePrepError.undecodable") + } catch let error as ImagePrepError { + XCTAssertEqual(error, .undecodable) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "Prep fails before the upload is attempted") + } + + // MARK: uploadImage — upstream failure + + func test_givenUnverifiedEmailRefusal_whenUploading_thenSurfacesTheServerWordingVerbatim() async throws { + // Photo sending requires a verified email. There is deliberately no + // client-side pre-check here — issue #41 owns that gate — so the + // server's own explanation must reach the UI unrewritten. + let api = StubAPIClient() + await api.enqueue(failure: .forbidden(serverMessage: "Please verify your email address to send images.")) + let service = DirectMessagesService(api: api) + + do { + _ = try await service.uploadImage(Fixtures.tinyPNGData) + XCTFail("Expected forbidden") + } catch let error as APIError { + XCTAssertEqual(error, .forbidden(serverMessage: "Please verify your email address to send images.")) + XCTAssertEqual(error.userFacingMessage, "Please verify your email address to send images.") + } + } + + // MARK: send — the documented DM ceilings + + func test_givenNineImageURLs_whenSending_thenRejectsBeforeTheRequest() async throws { + let api = StubAPIClient() + let service = DirectMessagesService(api: api) + + do { + _ = try await service.send( + recipientId: "r", + body: "hi", + imageURLs: (0..<9).map { "https://cdn/\($0).png" } + ) + XCTFail("Expected tooManyImages") + } catch let error as DirectMessagesError { + XCTAssertEqual(error, .tooManyImages(limit: 8)) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "The cap is enforced before any HTTP call") + } + + func test_givenExactlyEightImageURLs_whenSending_thenTheRequestIsMade() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"message":\#(dmJSON(id: "m9"))}"#) + let service = DirectMessagesService(api: api) + + _ = try await service.send( + recipientId: "r", + body: "hi", + imageURLs: (0..<8).map { "https://cdn/\($0).png" } + ) + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/dm", "Exactly at the cap is allowed") + } + + func test_givenBodyOverTenThousandCharacters_whenSending_thenRejectsBeforeTheRequest() async throws { + let api = StubAPIClient() + let service = DirectMessagesService(api: api) + + do { + _ = try await service.send(recipientId: "r", body: String(repeating: "a", count: 10_001)) + XCTFail("Expected bodyTooLong") + } catch let error as DirectMessagesError { + XCTAssertEqual(error, .bodyTooLong(limit: 10_000)) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenExactlyTenThousandCharacters_whenSending_thenTheRequestIsMade() async throws { + // 10,000 for DMs — deliberately not the post composer's 5,000. + let api = StubAPIClient() + await api.enqueue(json: #"{"message":\#(dmJSON(id: "m9"))}"#) + let service = DirectMessagesService(api: api) + + _ = try await service.send(recipientId: "r", body: String(repeating: "a", count: 10_000)) + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/dm") + } +} + +/// Test double serving fixed content limits, so the DM upload's prep budget +/// can be asserted as server-driven (work-consolidation.md G14 tail / G22). +private struct StubDMContentLimits: ContentLimitsProviding { + let value: ContentLimits + func limits() async -> ContentLimits { value } } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DirectMessageDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DirectMessageDTO.swift index 71f90ad..1876d7d 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DirectMessageDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DirectMessageDTO.swift @@ -132,3 +132,154 @@ public struct DMActionResponse: Decodable, Sendable, Equatable { public let ok: Bool? public init(ok: Bool? = nil) { self.ok = ok } } + +// MARK: - G22: conversations inbox, single-message fetch, image upload +// +// Routes probed live 2026-09-09 (read-only — GET/OPTIONS only, no writes): +// +// GET /api/dm/conversations -> 200 {"items":[],"nextCursor":null} +// OPTIONS /api/dm/conversations -> 204 allow: GET, HEAD, OPTIONS +// GET /api/dm/{id} -> 404 {"error":"Message not found.", +// "code":"not_found"} for an +// unknown id, so the route exists +// OPTIONS /api/dm/{id} -> 204 allow: GET, HEAD, OPTIONS +// OPTIONS /api/dm/images/upload -> 204 allow: OPTIONS, POST +// GET /api/dm/images/upload -> 405 (POST-only, as advertised) +// +// ⚠️ The conversations listing was **empty** on the shared test account and we +// are not permitted to send a DM to populate it, so the *populated* `items[]` +// shape is unverified. Both decoders below are therefore deliberately +// permissive rather than guessing one spelling and shipping a silent all-nil +// decode (the G21 link-metadata defect). Once a populated payload is captured, +// tighten `DMConversationDTO` to the real keys and delete the alternates. + +/// One row of `GET /api/dm/conversations` — a conversation, server-grouped by +/// `pairKey`, newest first. +/// +/// **Tolerant by design.** Two plausible server shapes are accepted: +/// +/// 1. *Nested* — the row is a conversation envelope that carries its newest +/// message under `lastMessage` / `latestMessage` / `message`. +/// 2. *Flattened* — the row **is** the newest message per pair (the natural +/// output of a `GROUP BY pairKey`), i.e. a `DirectMessageDTO` with the +/// conversation extras alongside it. +/// +/// Every field is optional, so an unexpected spelling degrades to `nil` +/// instead of failing the whole page decode. +public struct DMConversationDTO: Decodable, Sendable, Equatable { + + /// `senderId:recipientId` conversation key — the server's grouping key. + public let pairKey: String? + /// The other participant, when the server names them on the row. + public let otherUser: UserSummaryDTO? + /// Unread inbound messages in this conversation, when reported. + public let unreadCount: Int? + /// The newest message in the conversation, from either shape above. + public let lastMessage: DirectMessageDTO? + + public init( + pairKey: String? = nil, + otherUser: UserSummaryDTO? = nil, + unreadCount: Int? = nil, + lastMessage: DirectMessageDTO? = nil + ) { + self.pairKey = pairKey + self.otherUser = otherUser + self.unreadCount = unreadCount + self.lastMessage = lastMessage + } + + /// Alternate key spellings, tried in order. The first that decodes wins. + private enum CodingKeys: String, CodingKey { + // pairKey + case pairKey, conversationKey, key + // otherUser + case otherUser, user, participant, withUser, other + // unreadCount + case unreadCount, unread, unreadMessages + // lastMessage + case lastMessage, latestMessage, message, newestMessage + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.pairKey = Self.first(String.self, in: container, keys: [.pairKey, .conversationKey, .key]) + self.otherUser = Self.first( + UserSummaryDTO.self, + in: container, + keys: [.otherUser, .user, .participant, .withUser, .other] + ) + self.unreadCount = Self.first(Int.self, in: container, keys: [.unreadCount, .unread, .unreadMessages]) + // Shape 1: a nested newest message. Shape 2: the row *is* the message, + // so re-decode the same container as a `DirectMessageDTO`. + if let nested = Self.first( + DirectMessageDTO.self, + in: container, + keys: [.lastMessage, .latestMessage, .message, .newestMessage] + ) { + self.lastMessage = nested + } else { + self.lastMessage = try? DirectMessageDTO(from: decoder) + } + } + + /// Decodes the first of `keys` that is present and well-typed, else `nil`. + /// A key that is present but the wrong type is skipped rather than fatal — + /// the point of this decoder is that no single guess can break the page. + private static func first( + _ type: T.Type, + in container: KeyedDecodingContainer, + keys: [CodingKeys] + ) -> T? { + for key in keys { + if let value = (try? container.decodeIfPresent(T.self, forKey: key)) ?? nil { + return value + } + } + return nil + } +} + +/// `GET /api/dm/conversations` response — the same `{items, nextCursor}` +/// envelope as the folder listing, verified live 2026-09-09 (empty page). +public struct DMConversationsPage: Decodable, Sendable, Equatable { + public let items: [DMConversationDTO] + public let nextCursor: String? + + public init(items: [DMConversationDTO], nextCursor: String? = nil) { + self.items = items + self.nextCursor = nextCursor + } +} + +/// `GET /api/dm/{id}` response — a single message (the deep-link target). +/// +/// **Tolerant by design.** This API has documented envelope drift (`POST +/// /api/messages` wraps under `data`; `POST /api/dm` wraps under `message`), +/// and the success body could not be captured — the test account has no +/// messages and we may not create one. So three shapes are accepted: wrapped +/// under `message`, wrapped under `data`, or the bare message object. +public struct DMMessageResponse: Decodable, Sendable, Equatable { + public let message: DirectMessageDTO + + public init(message: DirectMessageDTO) { self.message = message } + + private enum CodingKeys: String, CodingKey { + case message, data, dm + } + + public init(from decoder: Decoder) throws { + if let container = try? decoder.container(keyedBy: CodingKeys.self) { + for key in [CodingKeys.message, .data, .dm] { + if let nested = (try? container.decodeIfPresent(DirectMessageDTO.self, forKey: key)) ?? nil { + self.message = nested + return + } + } + } + // Bare object — decode the payload itself as the message. This is the + // one path that is allowed to throw, so a genuinely unreadable body + // still surfaces as `APIError.decoding` rather than a silent nil. + self.message = try DirectMessageDTO(from: decoder) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DirectMessagesEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DirectMessagesEndpoint.swift index fce1c6c..cfe6b04 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DirectMessagesEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DirectMessagesEndpoint.swift @@ -26,6 +26,40 @@ public enum DirectMessages { Request(method: .post, path: "/api/dm", body: .json(body), auth: .bearer) } + /// `GET /api/dm/conversations?cursor=…` — the **real inbox**: one row per + /// conversation, grouped server-side by `pairKey`, newest first + /// (work-consolidation.md G22). + /// + /// This is a correctness improvement over collapsing a `folder("inbox")` + /// page client-side: that grouping is only ever as complete as the page + /// fetched, so a conversation whose newest message falls off the end of the + /// page is simply invisible. This route has its own cursor and no such + /// failure mode. + /// + /// Verified live 2026-09-09: 200 `{"items":[],"nextCursor":null}`; accepts + /// Bearer; `OPTIONS` reports `GET, HEAD, OPTIONS`. The listing was empty on + /// the test account, so `DMConversationDTO` decodes permissively. + public static func conversations(cursor: String? = nil) -> Request { + Request( + method: .get, + path: "/api/dm/conversations", + query: [.string("cursor", cursor)], + auth: .bearer + ) + } + + /// `GET /api/dm/{id}` — one message by id, the deep-link target + /// (work-consolidation.md G22). + /// + /// Verified live 2026-09-09: an unknown id answers 404 + /// `{"error":"Message not found.","code":"not_found"}`, and `OPTIONS` + /// reports `GET, HEAD, OPTIONS`. The 200 envelope could not be captured + /// (empty test account, writes not permitted), so `DMMessageResponse` + /// accepts `{message}`, `{data}`, and the bare object. + public static func message(id: String) -> Request { + Request(method: .get, path: "/api/dm/\(id)", auth: .bearer) + } + /// `GET /api/dm/thread/{username}` — the conversation with `username` /// (chronological). Opening a thread marks received-unread messages read. public static func thread(username: String, cursor: String? = nil) -> Request { @@ -73,4 +107,30 @@ public enum DirectMessages { public static func restore(id: String) -> Request { Request(method: .post, path: "/api/dm/\(id)/restore", auth: .bearer) } + + // MARK: - Media uploads + + /// `POST /api/dm/images/upload` — upload one photo and receive its hosted + /// URL, to be passed back in `SendDirectMessageRequest.imageUrls` + /// (work-consolidation.md G22). + /// + /// Mirrors `Messages.uploadImage`: the caller supplies already-prepared + /// bytes plus their MIME type, so the body is `RequestBody.raw`. The + /// response reuses `MediaUploadResponse` (`{ "url": … }`). + /// + /// Verified live 2026-09-09 by method probe only: `OPTIONS` reports + /// `OPTIONS, POST` and a `GET` answers 405. No upload was performed — the + /// test account is shared and the probe budget is read-only. + /// + /// Sending photos requires a **verified email address**; the server + /// answers 403 with the explanation when it is not. That refusal is + /// surfaced verbatim — see `DirectMessagesService.uploadImage`. + public static func uploadImage(_ data: Data, contentType: String) -> Request { + Request( + method: .post, + path: "/api/dm/images/upload", + body: .raw(data, contentType: contentType), + auth: .bearer + ) + } } diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/DirectMessagesEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/DirectMessagesEndpointTests.swift index 19f4be9..30246de 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/DirectMessagesEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/DirectMessagesEndpointTests.swift @@ -135,4 +135,223 @@ final class DirectMessagesEndpointTests: XCTestCase { XCTAssertTrue(page.items.isEmpty) XCTAssertNil(page.nextCursor) } + + // MARK: - G22: conversations, single message, image upload + // + // Routes probed live 2026-09-09, read-only. The *populated* conversations + // shape could not be captured (empty inbox on a shared test account, + // writes not permitted), which is exactly why the decoder is permissive — + // these cases pin that tolerance so a shape change degrades a field + // instead of failing the page. + + // MARK: Builder shapes + + func test_givenG22Builders_whenConstructed_thenUseExpectedMethodPathAuth() { + XCTAssertEqual(DirectMessages.conversations().path, "/api/dm/conversations") + XCTAssertEqual(DirectMessages.conversations().method, .get) + XCTAssertEqual(DirectMessages.conversations().auth, .bearer) + XCTAssertEqual( + DirectMessages.conversations(cursor: "c9").query.first(where: { $0.name == "cursor" })?.value, + "c9" + ) + XCTAssertNil( + DirectMessages.conversations().query.first(where: { $0.name == "cursor" })?.value, + "A nil cursor carries no value, so the URL builder drops it" + ) + XCTAssertEqual(DirectMessages.message(id: "m1").path, "/api/dm/m1") + XCTAssertEqual(DirectMessages.message(id: "m1").method, .get) + XCTAssertEqual(DirectMessages.uploadImage(Data([0x1]), contentType: "image/png").path, + "/api/dm/images/upload") + XCTAssertEqual(DirectMessages.uploadImage(Data([0x1]), contentType: "image/png").method, .post) + } + + // MARK: Happy path + + func test_givenNestedConversationRow_whenSent_thenDecodesParticipantUnreadAndMessage() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"items":[{"pairKey":"s:r","unreadCount":3, + "otherUser":{"id":"s","username":"messenger","displayName":"Messenger","avatar":null}, + "lastMessage":\#(dmJSON(id: "m1"))}], + "nextCursor":"c2"} + """#)) + + let page = try await client.send(DirectMessages.conversations()) + + XCTAssertEqual(page.items.count, 1) + XCTAssertEqual(page.items.first?.pairKey, "s:r") + XCTAssertEqual(page.items.first?.unreadCount, 3) + XCTAssertEqual(page.items.first?.otherUser?.username, "messenger") + XCTAssertEqual(page.items.first?.lastMessage?.id, "m1") + XCTAssertEqual(page.nextCursor, "c2") + } + + func test_givenFlattenedConversationRow_whenSent_thenDecodesTheRowAsItsOwnMessage() async throws { + // The natural output of a `GROUP BY pairKey`: the row *is* the newest + // message. Both shapes must survive because we could not verify which + // one the server actually sends. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"items":[\#(dmJSON(id: "m7"))],"nextCursor":null}"#)) + + let page = try await client.send(DirectMessages.conversations()) + + XCTAssertEqual(page.items.first?.lastMessage?.id, "m7") + XCTAssertEqual(page.items.first?.pairKey, "s:r", "Read off the message itself") + XCTAssertEqual(page.items.first?.lastMessage?.sender?.username, "messenger") + } + + func test_givenAlternateKeySpellings_whenSent_thenStillDecodes() async throws { + // `latestMessage` / `user` / `unread` instead of the primary spellings. + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"items":[{"conversationKey":"a:b","unread":1, + "user":{"id":"s","username":"alt","displayName":"Alt","avatar":null}, + "latestMessage":\#(dmJSON(id: "m3"))}], + "nextCursor":null} + """#)) + + let page = try await client.send(DirectMessages.conversations()) + + XCTAssertEqual(page.items.first?.pairKey, "a:b") + XCTAssertEqual(page.items.first?.unreadCount, 1) + XCTAssertEqual(page.items.first?.otherUser?.username, "alt") + XCTAssertEqual(page.items.first?.lastMessage?.id, "m3") + } + + func test_givenWrappedSingleMessage_whenFetchingById_thenDecodesUnderMessageKey() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"message":\#(dmJSON(id: "m5"))}"#)) + + let response = try await client.send(DirectMessages.message(id: "m5")) + + XCTAssertEqual(response.message.id, "m5") + } + + func test_givenBareSingleMessage_whenFetchingById_thenDecodesTheObjectItself() async throws { + // Documented envelope drift on this API (`POST /api/messages` wraps + // under `data`), and the 200 body could not be captured — so bare, + // `data`-wrapped, and `message`-wrapped all decode. + let (client, transport) = makeClient() + await transport.enqueue(.json(dmJSON(id: "m6"))) + + let response = try await client.send(DirectMessages.message(id: "m6")) + + XCTAssertEqual(response.message.id, "m6") + } + + func test_givenDataWrappedSingleMessage_whenFetchingById_thenDecodesUnderDataKey() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"data":\#(dmJSON(id: "m8"))}"#)) + + let response = try await client.send(DirectMessages.message(id: "m8")) + + XCTAssertEqual(response.message.id, "m8") + } + + func test_givenImageBytes_whenUploading_thenPostsRawBodyWithItsContentType() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"url":"https://cdn.interlinedlist.com/dm/1.jpg"}"#)) + let bytes = Data([0xFF, 0xD8, 0xFF, 0xE0]) + + let response = try await client.send(DirectMessages.uploadImage(bytes, contentType: "image/jpeg")) + + XCTAssertEqual(response.url, "https://cdn.interlinedlist.com/dm/1.jpg") + let received = await transport.received + XCTAssertEqual(received[0].url?.path, "/api/dm/images/upload") + XCTAssertEqual(received[0].httpMethod, "POST") + XCTAssertEqual(received[0].httpBody, bytes, "Raw bytes, not re-encoded") + XCTAssertEqual(received[0].value(forHTTPHeaderField: "Content-Type"), "image/jpeg") + } + + // MARK: Invalid / unexpected shape + + func test_givenUnrecognisedConversationKeys_whenSent_thenRowDecodesWithNilFieldsNotAnError() async throws { + // The G21 link-metadata defect was a decoder that silently produced + // all-nil. Here nil is the *designed* outcome for an unknown spelling — + // what must not happen is the whole page failing to decode. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"items":[{"totallyUnknown":"x"}],"nextCursor":null}"#)) + + let page = try await client.send(DirectMessages.conversations()) + + XCTAssertEqual(page.items.count, 1, "The row survives") + XCTAssertNil(page.items.first?.pairKey) + XCTAssertNil(page.items.first?.lastMessage) + XCTAssertNil(page.items.first?.unreadCount) + } + + func test_givenWrongTypedUnreadCount_whenSent_thenSkipsItRatherThanFailingThePage() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"items":[{"pairKey":"s:r","unreadCount":"three","lastMessage":\#(dmJSON(id: "m1"))}], + "nextCursor":null} + """#)) + + let page = try await client.send(DirectMessages.conversations()) + + XCTAssertNil(page.items.first?.unreadCount, "A mistyped field degrades to nil") + XCTAssertEqual(page.items.first?.lastMessage?.id, "m1", "Its siblings still decode") + } + + func test_givenUnreadableSingleMessageBody_whenFetchingById_thenThrowsDecodingNotSilentNil() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"nothing":"useful"}"#)) + + do { + _ = try await client.send(DirectMessages.message(id: "m1")) + XCTFail("Expected a decoding failure") + } catch let error as APIError { + guard case .decoding = error else { + return XCTFail("Expected .decoding, got \(error)") + } + } + } + + // MARK: Upstream API failure + + func test_givenNotFound_whenFetchingMessageById_thenThrowsNotFoundWithServerMessage() async throws { + // Exactly what the live route answered for an unknown id on 2026-09-09. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"Message not found.","code":"not_found"}"#, status: 404)) + + do { + _ = try await client.send(DirectMessages.message(id: "nope")) + XCTFail("Expected notFound") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "Message not found.")) + } + } + + func test_givenForbidden_whenUploadingImage_thenThrowsForbiddenPreservingTheServerWording() async throws { + // The documented "verified email required" refusal. The wording must + // survive to the UI — the client does not paraphrase it. + let (client, transport) = makeClient() + await transport.enqueue( + .json(#"{"error":"Please verify your email address to send images."}"#, status: 403) + ) + + do { + _ = try await client.send(DirectMessages.uploadImage(Data([0x1]), contentType: "image/png")) + XCTFail("Expected forbidden") + } catch let error as APIError { + XCTAssertEqual( + error, + .forbidden(serverMessage: "Please verify your email address to send images.") + ) + XCTAssertEqual(error.userFacingMessage, "Please verify your email address to send images.") + } + } + + // MARK: Empty / boundary + + func test_givenEmptyConversationsPage_whenSent_thenReturnsNoItems() async throws { + // The exact body the live route returned on 2026-09-09. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"items":[],"nextCursor":null}"#)) + + let page = try await client.send(DirectMessages.conversations()) + + XCTAssertTrue(page.items.isEmpty) + XCTAssertNil(page.nextCursor) + } } diff --git a/work-consolidation.md b/work-consolidation.md index 77db669..414b811 100644 --- a/work-consolidation.md +++ b/work-consolidation.md @@ -148,8 +148,15 @@ Live and available to the test account: `GET /api/ai/status` returns `{"subscrib > Envelope note: `/api/link-metadata` answers `{"link":{…}}` (the single-resource convention) but `/api/messages/{id}/metadata` answers the **bare** `{"links":[…]}` with no envelope key. -**G22 · Direct-message completeness — MEDIUM. Size S.** -Three routes the DM feature shipped without: `GET /api/dm/conversations` (one row per conversation grouped by `pairKey`, newest first — verified live, `{"items":[],"nextCursor":null}` — this is the natural inbox list, versus today's folder-based `GET /api/dm`), `GET /api/dm/{id}` (single message), and `POST /api/dm/images/upload` (DM image attachments, which the DM composer advertises but cannot perform). +**G22 · Direct-message completeness** — ✅ **Shipped 2026-09-09 (issue #53, branch `feat/dm-inbox-photos-g22`).** All three routes are wired. + +- **`GET /api/dm/conversations`** — `DirectMessages.conversations(cursor:)` → domain `DMConversationPage`. The **Inbox now reads this feed**; Sent and Deleted keep the folder listing, which this route does not replace. That removes the old failure mode where a conversation whose newest message fell off the fetched folder page was simply invisible. +- **`GET /api/dm/{id}`** — `DirectMessages.message(id:)` → `DirectMessagesService.message(id:)`. Consumed by `DirectMessagesListViewModel.conversationUsername(forMessageID:)`, which answers from the loaded listing first and only fetches for an id it doesn't hold. Reachable via the new `.directMessagesOpenMessage` notification; **nothing posts it yet** — there is no direct-message `NotificationKind`, so the producer arrives with whatever surface introduces DM notifications or a URL scheme. +- **`POST /api/dm/images/upload`** — `DirectMessagesService.uploadImage` through the **shared `ImagePrep` + `ContentLimits` path** (G14 tail), not fresh constants. Both DM composers attach up to **8 photos** (`DMLimits.maxImagesPerMessage`) and enforce the **10,000-character** DM body ceiling (`DMLimits.maxBodyCharacters`) — deliberately *not* the post composer's `/api/limits` 5,000, which is a different surface. Thread bubbles now render `imageURLs`; previously received photos were not displayed at all. + +> **Probe note (2026-09-09, read-only — GET/OPTIONS only).** `GET /api/dm/conversations` → 200 `{"items":[],"nextCursor":null}`, Bearer accepted; `OPTIONS /api/dm/{id}` → `GET, HEAD, OPTIONS` and an unknown id → 404 `{"error":"Message not found.","code":"not_found"}`; `OPTIONS /api/dm/images/upload` → `OPTIONS, POST` (a `GET` is 405). **The populated `items[]` shape is still unverified** — the shared test account's inbox is empty and sending a DM to populate it was not permitted. `DMConversationDTO` and `DMMessageResponse` are therefore **deliberately permissive** (nested *and* flattened rows; `{message}` / `{data}` / bare), with tests pinning that tolerance so a shape change degrades one field rather than failing the page — the G21 all-nil decode defect is the lesson being applied. **Tighten both decoders to the real keys once a populated payload is captured.** + +> **Open dependency.** Photo sending requires a **verified email address**. No client-side gate is built here by design: the server's 403 is surfaced verbatim. `TODO(#41)` markers (7 sites) name issue #41's `CapabilityGate` as the single owner — wire the composers to it when that branch merges so the affordance is explained before the user picks a file. **G23 · Lists: shared-with-me, contributors, watcher add — MEDIUM. Size M.**