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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions App/Composition/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ final class AppEnvironment: ObservableObject {
EntitlementsService(user: currentUserStore.currentUser)
}

/// The composed capability gate — account status, email verification, and
/// subscription tier answered as one question (GitHub #40 / #41 / #42).
///
/// Features should prefer this over `liveEntitlements`: a subscriber who is
/// `restricted`, or who has not verified their email, is entitled but still
/// cannot post, and only the composed gate knows that. Derived live from
/// `currentUserStore.currentUser`, exactly like `liveEntitlements`.
var liveCapabilities: CapabilityGate {
CapabilityGate(user: currentUserStore.currentUser)
}

/// The visibility a new-message composer draft opens on — the signed-in
/// account's "new posts are public by default" preference. Derived live from
/// `currentUserStore.currentUser`, exactly like `liveEntitlements` above, so
Expand Down Expand Up @@ -505,10 +516,10 @@ final class AppEnvironment: ObservableObject {
store: documentStore,
// Live image ceilings for `uploadImage` prep (G14 tail).
contentLimits: contentLimits,
// work-consolidation.md G24 — `POST /api/documents/folders/{id}/
// documents` is subscriber-gated upstream. Same live box the
// Gate for document *creation* only (#40 matrix) — moving,
// editing and deleting stay free on every tier. Same live box the
// messages gate reads, so a mid-session subscribe or lapse re-gates
// creating documents in a folder without a relaunch.
// without a relaunch.
entitlementsProvider: { liveEntitlements.current() }
)
// Server document templates (work-consolidation.md G12). Reuses the same
Expand Down Expand Up @@ -539,7 +550,8 @@ final class AppEnvironment: ObservableObject {
// decision-0001 session allowlist, both already routed by the shared
// `authTransport`. `UserService` takes the default production base URL
// for the browser-handoff OAuth link flow.
let orgService = OrgService(api: api)
// Subscriber gate for organization *creation* only (GitHub #40).
let orgService = OrgService(api: api, entitlements: { liveEntitlements.current() })
// Org-memberships cache (work-consolidation.md) — the Organizations switcher's
// initial-view data. On-disk in Application Support with disposable /
// auto-rebuild semantics; falls back to `NullOrgStore` if the container
Expand Down
124 changes: 124 additions & 0 deletions App/Features/Account/AccountStatusBanner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// AccountStatusBanner
//
// The home-timeline banner that explains a limited account (GitHub #42).
//
// `/help/account`: "When your account is new or locked, a banner at the top of
// your home page explains the current status and, where relevant, links you to
// verify your email or to contact support."
//
// The banner renders nothing for an `active` account, and nothing for an
// unrecognised status — an unknown value must never scare a user with a
// warning the client cannot explain. `banned` never reaches here either: a
// banned account cannot sign in, so it is a sign-in failure path.
//
// Decision 0003: consumes `InterlinedDomain` only.

import SwiftUI
import InterlinedDomain

struct AccountStatusBanner: View {

let status: AccountStatus
let isEmailVerified: Bool

@Environment(\.openURL) private var openURL

var body: some View {
if status.warrantsBanner {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Image(systemName: iconName)
.foregroundStyle(tint)
.accessibilityHidden(true)

VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.callout.weight(.semibold))
Text(explanation)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}

Spacer(minLength: 8)

if let action {
Button(action.title) {
openURL(action.destination.url())
}
.buttonStyle(.link)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(tint.opacity(0.10))
.overlay(alignment: .bottom) { Divider() }
.accessibilityElement(children: .combine)
.accessibilityLabel("\(title). \(explanation)")
}
}

// MARK: - Copy

private var title: String {
switch status {
case .new: return "Your account is new"
case .restricted: return "Your account is temporarily read-only"
case .suspended: return "Your account is suspended"
case .active, .banned, .unknown: return ""
}
}

private var explanation: String {
switch status {
case .new:
// Posting works but is rate-limited, so the copy must not imply the
// user cannot post at all.
return isEmailVerified
? "Posting is limited while your account is reviewed. Direct messages, media, "
+ "cross-posting, scheduling, and creating lists, documents, and organizations "
+ "unlock once it is active."
: "Posting is limited while your account is new. Verifying your email is the "
+ "fastest way to unlock direct messages, media, cross-posting, scheduling, "
+ "and creating lists, documents, and organizations."
case .restricted:
return "You can read and browse as usual. Posting, replying, reacting, following, "
+ "messaging, and creating content are paused while your account is reviewed."
case .suspended:
return "You can read and browse as usual. If you think this is a mistake, you can appeal."
case .active, .banned, .unknown:
return ""
}
}

/// The next step, mirroring the web: verify email for a new account,
/// contact support for a locked one.
private var action: (title: String, destination: AccountWebDestination)? {
switch status {
case .new:
// Nothing useful to offer once the email is already verified — the
// remaining wait is the platform's own review.
return isEmailVerified ? nil : ("Verify email", .settings)
case .restricted, .suspended:
return ("Contact support", .support)
case .active, .banned, .unknown:
return nil
}
}

private var iconName: String {
switch status {
case .new: return "clock.badge.checkmark"
case .restricted, .suspended: return "exclamationmark.triangle.fill"
case .active, .banned, .unknown: return "info.circle"
}
}

private var tint: Color {
switch status {
case .new: return .accentColor
case .restricted, .suspended: return .orange
case .active, .banned, .unknown: return .secondary
}
}
}
64 changes: 62 additions & 2 deletions App/Features/Compose/ComposerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ final class ComposerViewModel {
/// domain `MessagesService` enforces the same status as a backstop.
private(set) var entitlements: EntitlementsService

/// The composed capability gate — account status, email verification, and
/// subscription tier as one answer (GitHub #40 / #41 / #42).
///
/// `entitlements` alone cannot tell the composer why a post will fail: a
/// subscriber who is `restricted`, or who has not verified their email, is
/// entitled but still cannot post. This is the gate the Post button and the
/// media affordance consult.
private(set) var capabilities: CapabilityGate

/// Reads a local file's bytes at send time. Injected so tests can supply
/// bytes without touching the filesystem; production reads the file URL.
private let readData: @Sendable (URL) async throws -> Data
Expand Down Expand Up @@ -181,6 +190,31 @@ final class ComposerViewModel {
entitlements.isSubscriber
}

/// Why this draft cannot be posted right now, or `nil` if it can.
///
/// An edit republishes an existing message rather than posting a new one,
/// but the platform gates both the same way, so the same action is asked
/// about in either mode.
var postDenial: CapabilityDenial? {
capabilities.denial(for: .postMessage)
}

/// Why media cannot be attached right now, or `nil` if it can.
var attachmentDenial: CapabilityDenial? {
capabilities.denial(for: .mediaAttachments)
}

/// The inline, non-modal explanation shown under the composer when posting
/// is blocked. Never blocks typing — the draft must survive (GitHub #41).
var postBlockedMessage: String? {
postDenial?.message
}

/// The next step to offer beside ``postBlockedMessage``, if any.
var postBlockedRemedy: CapabilityRemedy? {
postDenial?.remedy
}

/// Whether the M6 controls should appear at all. Edits don't expose media /
/// schedule / cross-post — those apply to a fresh message only.
var showsSubscriberControls: Bool {
Expand Down Expand Up @@ -279,6 +313,11 @@ final class ComposerViewModel {
if showsSubscriberControls, isScheduled, scheduledAt <= Date() {
return false
}
// Account status / email verification / tier. Asked before the user
// clicks Post rather than discovered from a server error afterwards.
if postDenial != nil {
return false
}
return true
}

Expand All @@ -289,6 +328,8 @@ final class ComposerViewModel {
eventBus: ComposerEventBus,
mode: ComposerMode = .newPost,
entitlements: EntitlementsService = EntitlementsService(customerStatus: .free),
accountStatus: AccountStatus = .active,
isEmailVerified: Bool = true,
readData: @escaping @Sendable (URL) async throws -> Data = { try Data(contentsOf: $0) },
onSubscriberLapse: (@MainActor () async -> Void)? = nil,
userService: UserServicing? = nil,
Expand All @@ -301,6 +342,11 @@ final class ComposerViewModel {
self.eventBus = eventBus
self.mode = mode
self.entitlements = entitlements
self.capabilities = CapabilityGate(
accountStatus: accountStatus,
entitlements: entitlements,
isEmailVerified: isEmailVerified
)
self.readData = readData
self.onSubscriberLapse = onSubscriberLapse
self.userService = userService
Expand Down Expand Up @@ -373,8 +419,16 @@ final class ComposerViewModel {
/// non-subscribers (the affordance is disabled in the view, but this is
/// defence-in-depth so a programmatic add can't bypass the gate's intent).
func addAttachments(urls: [URL]) {
guard canUseSubscriberFeatures else {
error = MessagesError.subscriberRequired(.mediaAttachments)
if let denial = attachmentDenial {
// A tier denial keeps surfacing as `MessagesError.subscriberRequired`
// — the type the rest of the app already treats as "subscription
// lapse". Status and verification denials are a different problem
// with a different remedy, so they carry the denial itself.
if case .subscriberRequired(let feature) = denial {
error = MessagesError.subscriberRequired(feature)
} else {
error = ComposerError.blocked(denial)
}
return
}
var rejected = false
Expand Down Expand Up @@ -621,10 +675,16 @@ enum ComposerError: Error, LocalizedError, Equatable {
/// A picked / dropped file isn't a supported image or video type.
case unsupportedAttachment

/// The account may not perform this action — because of its status or an
/// unverified email, rather than its subscription tier (GitHub #41 / #42).
case blocked(CapabilityDenial)

var errorDescription: String? {
switch self {
case .unsupportedAttachment:
return "That file isn't a supported image or video."
case .blocked(let denial):
return denial.message
}
}
}
43 changes: 43 additions & 0 deletions App/Features/Compose/ComposerWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ struct ComposerWindowView: View {
// B): a subscriber sees the M6 controls enabled, a free /
// signed-out account sees them disabled with an upsell.
entitlements: environment.liveEntitlements,
// GitHub #42 / #41 — the other two reasons a post can be
// refused. Passed alongside the tier so the composer can
// say *which* one applies before the user writes anything.
accountStatus: environment.currentUserStore.currentUser?.accountStatus ?? .active,
isEmailVerified: environment.currentUserStore.currentUser?.isEmailVerified ?? true,
// PLAN.md §8 — a gated 403 mid-flow re-fetches the
// customerStatus so the composer re-gates.
onSubscriberLapse: { await environment.refreshEntitlements() },
Expand Down Expand Up @@ -682,6 +687,44 @@ struct ComposerWindowView: View {

@ViewBuilder
private func footer(viewModel: ComposerViewModel) -> some View {
VStack(alignment: .leading, spacing: 8) {
// Why Post is disabled, said before the user clicks it (GitHub
// #41 / #42). Inline and non-modal on purpose: the draft keeps its
// text, and the user can still type, copy, and save it elsewhere.
if let message = viewModel.postBlockedMessage {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "exclamationmark.circle")
.foregroundStyle(.secondary)
.accessibilityHidden(true)
Text(message)
.font(.ilSubtitle())
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let destination = viewModel.postBlockedRemedy?.webDestination {
Link(remedyLabel(for: viewModel.postBlockedRemedy), destination: destination.url())
.font(.ilSubtitle())
}
Spacer(minLength: 0)
}
.accessibilityElement(children: .combine)
}

footerControls(viewModel: viewModel)
}
}

/// The label for the remedy link beside a blocked-post explanation.
private func remedyLabel(for remedy: CapabilityRemedy?) -> String {
switch remedy {
case .verifyEmail: return "Verify email"
case .contactSupport: return "Contact support"
case .upgrade: return "Manage subscription"
case .noneAvailable, nil: return ""
}
}

@ViewBuilder
private func footerControls(viewModel: ComposerViewModel) -> some View {
HStack {
Button("Cancel", role: .cancel) {
dismiss()
Expand Down
12 changes: 6 additions & 6 deletions App/Features/DirectMessages/DMAttachmentDraft.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
// • "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.
// • "You'll need a verified email address to send images." → still NOT
// checked here, deliberately. Since #41 the *composers* consult
// `CapabilityGate.evaluate(.directMessageImages)` and disable the attach
// affordance with the reason up front; this draft holds picked files and
// has no business re-deciding entitlement. One gate, one owner — do not
// add a second check here. The server's 403 remains the backstop.
//
// The upload loop is deliberately failure-tolerant: an upload that fails
// must not cost the user their draft. Successful uploads are kept, the
Expand Down
Loading