settings->get(Setting::CommentsScope); return (is_string($value) ? CommentScope::tryFrom($value) : null) ?? CommentScope::AllFiles; } public function authors(): CommentAuthors { $value = $this->settings->get(Setting::CommentsAuthors); return (is_string($value) ? CommentAuthors::tryFrom($value) : null) ?? CommentAuthors::StaffAndClients; } /** * Whether the feature exists at all on this install. Surfaces use this * to decide whether to render any comment affordance whatsoever. */ public function enabled(): bool { return $this->scope() !== CommentScope::None; } /** * Whether this particular file accepts new comments. Existing comments * on a file that no longer qualifies stay readable — turning the scope * down hides the composer, not the history. Only CommentScope::None * removes the feature's UI entirely. */ public function enabledFor(File $file): bool { return $this->scope()->allows($file); } public function publicCommentsEnabled(): bool { return $this->settings->get(Setting::PublicCommentsEnabled) === true; } /** * Whether this author has to solve a security check before posting. * * Only a visitor with no account: somebody signed in has already been * identified, is subject to everything else that governs an account, * and asking them to prove they are human on a page that already knows * who they are is friction with nothing behind it. * * The decision lives here because this class is where every surface * asks what commenting policy is; the machinery behind it belongs to * the Captcha module and stays there. */ public function captchaRequiredFor(?User $viewer): bool { return $viewer === null && app(Captcha::class)->protects(CaptchaForm::Comment); } /** * Whether anonymous comments wait for a moderator. Only reachable when * the author setting is `everyone`; an authenticated comment is never * held. */ public function moderatesGuests(): bool { return $this->settings->get(Setting::CommentsGuestModeration) === true; } public function editWindowMinutes(): int { return (int) $this->settings->get(Setting::CommentsEditWindowMinutes); } /** * Whether $viewer may post on $file. Null means an anonymous visitor. */ public function canPost(?User $viewer, File $file): bool { return $this->postingBlockedReason($viewer, $file) === null; } /** * Why this person may not write here, in a sentence addressed to them — * or null when they may, which is what canPost() above is. * * The permission answer and the explanation are the same walk on * purpose. They were briefly two: the composer simply vanished when * posting was refused, and the thread underneath said "No comments * yet", which reads as *nobody has commented* rather than *you cannot*. * A visitor on a public file with commenting closed had no way to tell * the difference. Deriving one from the other means a new reason to * refuse cannot ship without words for it. * * English text, also the translation key — same convention as the enum * labels in this module. * * The file-visibility check happens here rather than in the caller: an * authenticated writer must pass FilePolicy::view, and an anonymous one * requires the file to be reachable without logging in at all. */ public function postingBlockedReason(?User $viewer, File $file): ?string { if (! $this->enabledFor($file)) { return 'Comments are closed on this file.'; } if (! $this->authors()->allows($viewer?->type)) { return match ($this->authors()) { CommentAuthors::Staff => 'Only staff can comment here.', CommentAuthors::Clients => 'Only clients can comment here.', // StaffAndClients, whose only refusal is of a visitor. // Everyone refuses nobody and never reaches this. default => 'Only people who are signed in can comment here.', }; } if ($viewer === null) { // An anonymous author has exactly one possible visibility, so // if that visibility is switched off they cannot write at all. // Signing in is still a route to commenting, which is why the // wording points there rather than saying it is impossible. return $this->publicCommentsEnabled() && $file->isEffectivelyPublic() ? null : 'Only people who are signed in can comment here.'; } // FilePolicy::view is about assignment, which is the wrong question // for a public file: a logged-in client browsing the public listing // reaches it exactly as a visitor does, and being signed in should // not take away what being signed out allows. Their comment still // lands in their own thread, so staff see who asked. return Gate::forUser($viewer)->allows('view', $file) || $file->isEffectivelyPublic() ? null : 'You cannot comment on this file.'; } /** * Which visibilities the composer may offer, in the order they should * be listed. Empty means the composer must not be rendered. * * @return list */ public function allowedVisibilities(?User $viewer, File $file): array { if (! $this->canPost($viewer, $file)) { return []; } $everyone = $this->publicCommentsEnabled() && $file->isEffectivelyPublic(); if ($viewer === null) { return $everyone ? [CommentVisibility::Everyone] : []; } // Narrowest audience first — a list is easier to scan when it runs // one way. The default is chosen separately (see defaultVisibility) // rather than falling out of this order. $allowed = array_values(array_filter( [CommentVisibility::OnlyMe, CommentVisibility::StaffOnly, CommentVisibility::Clients], fn (CommentVisibility $visibility): bool => $viewer->isStaff() || $visibility->availableToClients(), )); if ($everyone) { $allowed[] = CommentVisibility::Everyone; } return $allowed; } /** * Every audience the composer shows, including the ones this file or * this installation does not currently allow. * * An unavailable option is shown disabled with the reason rather than * left out. Silently omitting it is indistinguishable from the feature * not existing — which is exactly how it read to the first person who * looked for "Everyone" and could not find it. Saying why also says * what to change. * * This is presentation only. allowedVisibilities() above stays the * authorization list, and FileComments::post still refuses anything * absent from it. * * @return list */ public function visibilityOptions(?User $viewer, File $file): array { $allowed = $this->allowedVisibilities($viewer, $file); if ($allowed === [] || $viewer === null) { // A visitor gets Everyone or nothing at all, and no explanation // is any use to them — there is nothing they could change. return array_map( fn (CommentVisibility $visibility): array => ['visibility' => $visibility, 'available' => true, 'reason' => null], $allowed, ); } $offered = array_values(array_filter( CommentVisibility::cases(), fn (CommentVisibility $visibility): bool => $viewer->isStaff() || $visibility->availableToClients(), )); return array_map(fn (CommentVisibility $visibility): array => [ 'visibility' => $visibility, 'available' => in_array($visibility, $allowed, true), 'reason' => in_array($visibility, $allowed, true) ? null : $this->unavailableReason($visibility, $file), ], $offered); } /** * Why an audience is not on offer, in words that name the thing to * change. Only Everyone is ever conditional today. */ private function unavailableReason(CommentVisibility $visibility, File $file): ?string { if ($visibility !== CommentVisibility::Everyone) { return null; } // The site switch first: it is the more general cause, and turning // it on is the step that unblocks every file at once. return $this->publicCommentsEnabled() ? 'Only a file that is publicly visible can have public comments.' : 'Public comments are turned off for this site.'; } /** * Which audience the composer starts on. * * The conversational one, not the narrowest. A comment box on a shared * file is for talking to the people it is shared with, and defaulting * to a private note has a silent failure behind it: a client asks a * question, never notices the dropdown, and writes it to themselves — * so nobody answers, and nobody knows there was anything to answer. * The reverse mistake is louder and easier to undo. */ public function defaultVisibility(?User $viewer, File $file): ?CommentVisibility { $allowed = $this->allowedVisibilities($viewer, $file); if ($allowed === []) { return null; } return in_array(CommentVisibility::Clients, $allowed, true) ? CommentVisibility::Clients : $allowed[0]; } }