|
| 1 | +using InterlinedList.Models; |
| 2 | +using InterlinedList.Services; |
| 3 | + |
| 4 | +namespace InterlinedList.ViewModels; |
| 5 | + |
| 6 | +/// <summary> |
| 7 | +/// The account-status banner's content, computed from <c>accountStatus</c>. One |
| 8 | +/// instance per <see cref="CurrentUser"/> snapshot — it's immutable, so a |
| 9 | +/// session refresh replaces it rather than mutating it. |
| 10 | +/// </summary> |
| 11 | +/// <remarks> |
| 12 | +/// <para> |
| 13 | +/// The web shows this at the top of the home page. The shell |
| 14 | +/// (<c>MainWindow</c>) is the right home for it here too, but that file is in |
| 15 | +/// flight (#149), so #50 lands the banner at the top of Settings — a real, |
| 16 | +/// visible banner somewhere beats a correct one nowhere. Moving it is a matter |
| 17 | +/// of dropping the same block into the shell and binding to this type. |
| 18 | +/// </para> |
| 19 | +/// <para> |
| 20 | +/// <b>Not live-verified:</b> the shared test account is <c>active</c>, so every |
| 21 | +/// branch below except the hidden one was checked by constructing the status |
| 22 | +/// locally, not by observing a real restricted account. |
| 23 | +/// </para> |
| 24 | +/// </remarks> |
| 25 | +public sealed class AccountStatusViewModel |
| 26 | +{ |
| 27 | + /// <summary>Amber — a temporary state the user can work their way out of.</summary> |
| 28 | + public const string SeverityWarning = "warning"; |
| 29 | + |
| 30 | + /// <summary>Red — posting and creating are gone until someone intervenes.</summary> |
| 31 | + public const string SeverityDanger = "danger"; |
| 32 | + |
| 33 | + public AccountStatusViewModel(CurrentUser? user) |
| 34 | + { |
| 35 | + // Capability gates are populated for every account, including `active`, |
| 36 | + // so a consumer can bind to them unconditionally and get "allowed". |
| 37 | + CanPost = AccountCapabilities.IsAllowed(user, AccountCapability.Post); |
| 38 | + CanReply = AccountCapabilities.IsAllowed(user, AccountCapability.Reply); |
| 39 | + CanReact = AccountCapabilities.IsAllowed(user, AccountCapability.React); |
| 40 | + CanFollow = AccountCapabilities.IsAllowed(user, AccountCapability.Follow); |
| 41 | + CanDirectMessage = AccountCapabilities.IsAllowed(user, AccountCapability.DirectMessage); |
| 42 | + CanUploadMedia = AccountCapabilities.IsAllowed(user, AccountCapability.MediaUpload); |
| 43 | + CanCrossPost = AccountCapabilities.IsAllowed(user, AccountCapability.CrossPost); |
| 44 | + CanSchedulePosts = AccountCapabilities.IsAllowed(user, AccountCapability.ScheduledPost); |
| 45 | + CanCreateContent = AccountCapabilities.IsAllowed(user, AccountCapability.CreateContent); |
| 46 | + |
| 47 | + PostBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.Post); |
| 48 | + ReplyBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.Reply); |
| 49 | + ReactBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.React); |
| 50 | + FollowBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.Follow); |
| 51 | + DirectMessageBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.DirectMessage); |
| 52 | + MediaUploadBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.MediaUpload); |
| 53 | + CrossPostBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.CrossPost); |
| 54 | + ScheduledPostBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.ScheduledPost); |
| 55 | + CreateContentBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.CreateContent); |
| 56 | + |
| 57 | + IsVisible = user?.NeedsStatusBanner == true; |
| 58 | + if (user is null || !IsVisible) return; |
| 59 | + |
| 60 | + LockedFeatures = string.Join(" · ", AccountCapabilities.LockedFeatures(user)); |
| 61 | + |
| 62 | + if (user.IsBanned) |
| 63 | + { |
| 64 | + Severity = SeverityDanger; |
| 65 | + Headline = "This account is closed"; |
| 66 | + Detail = "Sign-in is disabled and nothing can be posted or changed. If you think this is a mistake, you can appeal."; |
| 67 | + ActionLabel = "Appeal on the web"; |
| 68 | + ActionUrl = HelpUrl; |
| 69 | + } |
| 70 | + else if (user.IsReadOnly) |
| 71 | + { |
| 72 | + var suspended = string.Equals(user.AccountStatus, "suspended", StringComparison.OrdinalIgnoreCase); |
| 73 | + Severity = SeverityDanger; |
| 74 | + Headline = suspended ? "This account is suspended" : "This account is restricted"; |
| 75 | + Detail = suspended |
| 76 | + ? "The team has put the account in read-only mode. You can sign in, read and browse, but posting, replying, digging, following, messaging and creating are turned off. This is appealable." |
| 77 | + : "The account is temporarily read-only while it's reviewed. You can sign in, read and browse, but posting, replying, digging, following, messaging and creating are turned off. This is appealable."; |
| 78 | + ActionLabel = "Appeal on the web"; |
| 79 | + ActionUrl = HelpUrl; |
| 80 | + } |
| 81 | + else if (user.IsProbationary) |
| 82 | + { |
| 83 | + Severity = SeverityWarning; |
| 84 | + Headline = "Your account is on probation"; |
| 85 | + Detail = user.EmailVerified |
| 86 | + ? "Reading, browsing, following, blocking, muting and reporting all work, and you can post a few times an hour. A handful of features stay locked until the account clears." |
| 87 | + : "Reading, browsing, following, blocking, muting and reporting all work, and you can post a few times an hour. Verifying your email address is the fastest way off probation."; |
| 88 | + // POST /api/auth/send-verification-email is cookie-session only per |
| 89 | + // the OpenAPI spec (`x-auth-type: session`), so a bearer-token |
| 90 | + // client structurally can't trigger it — same browser handoff the |
| 91 | + // app already uses for billing and OAuth linking. |
| 92 | + ActionLabel = user.EmailVerified ? "Read about account status" : "Verify your email on the web"; |
| 93 | + ActionUrl = user.EmailVerified ? HelpUrl : ApiConfig.BaseUrl; |
| 94 | + } |
| 95 | + else |
| 96 | + { |
| 97 | + // A status the server added since this was written. Say so plainly |
| 98 | + // rather than guessing what it restricts. |
| 99 | + Severity = SeverityWarning; |
| 100 | + Headline = $"Your account status is \"{user.AccountStatus}\""; |
| 101 | + Detail = "Some features may be limited. Check your account on the web for the details."; |
| 102 | + ActionLabel = "Read about account status"; |
| 103 | + ActionUrl = HelpUrl; |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + private const string HelpUrl = ApiConfig.BaseUrl + "help/account"; |
| 108 | + |
| 109 | + /// <summary>False for a normal <c>active</c> account — the banner collapses entirely.</summary> |
| 110 | + public bool IsVisible { get; } |
| 111 | + |
| 112 | + public string Severity { get; } = SeverityWarning; |
| 113 | + public string Headline { get; } = ""; |
| 114 | + public string Detail { get; } = ""; |
| 115 | + |
| 116 | + /// <summary>Everything the status takes away, pre-joined for display. Empty when nothing is.</summary> |
| 117 | + public string LockedFeatures { get; } = ""; |
| 118 | + |
| 119 | + public bool HasLockedFeatures => LockedFeatures.Length > 0; |
| 120 | + |
| 121 | + public string? ActionLabel { get; } |
| 122 | + public string? ActionUrl { get; } |
| 123 | + |
| 124 | + // ── Capability gates ──────────────────────────────────────────────────────── |
| 125 | + // Bind a locked action's IsEnabled to Can*, and its ToolTip to the matching |
| 126 | + // *BlockedReason (null when allowed, so the tooltip simply doesn't show). |
| 127 | + // This is the "disable up front with the reason" half of #50; the actions |
| 128 | + // themselves live in the feed/shell/compose files that #149 owns. |
| 129 | + |
| 130 | + public bool CanPost { get; } |
| 131 | + public bool CanReply { get; } |
| 132 | + public bool CanReact { get; } |
| 133 | + public bool CanFollow { get; } |
| 134 | + public bool CanDirectMessage { get; } |
| 135 | + public bool CanUploadMedia { get; } |
| 136 | + public bool CanCrossPost { get; } |
| 137 | + public bool CanSchedulePosts { get; } |
| 138 | + public bool CanCreateContent { get; } |
| 139 | + |
| 140 | + public string? PostBlockedReason { get; } |
| 141 | + public string? ReplyBlockedReason { get; } |
| 142 | + public string? ReactBlockedReason { get; } |
| 143 | + public string? FollowBlockedReason { get; } |
| 144 | + public string? DirectMessageBlockedReason { get; } |
| 145 | + public string? MediaUploadBlockedReason { get; } |
| 146 | + public string? CrossPostBlockedReason { get; } |
| 147 | + public string? ScheduledPostBlockedReason { get; } |
| 148 | + public string? CreateContentBlockedReason { get; } |
| 149 | +} |
0 commit comments