>, can_comment: bool, cannot_comment_reason: string|null, is_guest: bool, guest_moderated: bool, captcha_required: bool, visibilities: list>, default_visibility: string|null, edit_window_minutes: int} */ public function thread(?User $viewer, File $file): array { $forStaff = $viewer?->isStaff() === true; $comments = $this->scope->for($viewer, $file) ->with(['author', 'clientContext']) ->orderBy('created_at') ->orderBy('id') ->get(); /** @var list> $presented */ $presented = $comments->map(fn (FileComment $comment): array => $this->present($viewer, $comment))->values()->all(); return [ 'comments' => $presented, 'can_comment' => $this->rules->canPost($viewer, $file), // …and why not, when not. Without it the composer disappears // silently and the empty thread says "No comments yet", which // reads as nobody having written one rather than as the box // being closed — the two are indistinguishable to the person // looking for somewhere to type. 'cannot_comment_reason' => $this->rules->postingBlockedReason($viewer, $file), // The composer asks an anonymous author for a name and warns // that their comment is held; a signed-in one gets neither. // Decided here because the public page serves both, and the // page cannot tell them apart — it has no viewer. 'is_guest' => $viewer === null, // Whether an anonymous comment actually waits for a moderator. // The composer promises that it does, and the promise has to be // true: with moderation off the comment appears immediately, // and saying otherwise is simply a lie to the person writing it. 'guest_moderated' => $this->rules->moderatesGuests(), // Whether this author will be asked for a security check. Sent // per thread rather than read from the shared props, because // only the server knows whether this viewer counts as a // visitor — the composer is the same component either way. 'captcha_required' => $this->rules->captchaRequiredFor($viewer), // Labels depend on which end of the conversation is reading: // `clients` is one channel, and staff call it "Clients" while a // client calls it "Staff". Unavailable audiences are sent too, // so the composer can show them disabled with the reason rather // than leave a hole where an option used to be. 'visibilities' => array_map( fn (array $option): array => [ 'value' => $option['visibility']->value, 'label' => $option['visibility']->label($forStaff), 'description' => $option['visibility']->description($forStaff), 'available' => $option['available'], 'reason' => $option['reason'], ], $this->rules->visibilityOptions($viewer, $file), ), 'default_visibility' => $this->rules->defaultVisibility($viewer, $file)?->value, 'edit_window_minutes' => $this->rules->editWindowMinutes(), ]; } /** * @return array */ public function present(?User $viewer, FileComment $comment): array { return [ 'id' => $comment->id, 'body' => $comment->body, 'author_name' => $comment->authorName(), 'author_type' => $this->authorType($comment), 'is_mine' => $viewer !== null && $comment->author_id === $viewer->id, 'visibility' => $comment->visibility->value, 'visibility_label' => $comment->visibility->label($viewer?->isStaff() === true), // Whose conversation this is, when it is one client's rather // than every client's. Staff only: a client must not be able to // tell that their comment sits among others. 'conversation' => $viewer?->isStaff() === true && $comment->client_context_id !== null ? $comment->clientContext?->name : null, // Replying is how staff address one client — the audience is // inherited from here, never chosen. Only worth offering on a // comment that has a client behind it to answer. 'can_reply' => $viewer?->isStaff() === true && $comment->visibility === CommentVisibility::Clients && $comment->client_context_id !== null && $comment->author_id !== $viewer->id, 'pending' => $comment->isPending(), 'created_at' => $comment->created_at?->toIso8601String(), 'edited_at' => $comment->edited_at?->toIso8601String(), 'can_update' => $viewer !== null && Gate::forUser($viewer)->allows('update', $comment), 'can_delete' => $viewer !== null && Gate::forUser($viewer)->allows('delete', $comment), // A held comment is only actionable where it is seen. The // moderation queue is for working through a backlog; somebody // who followed the alert on a file row is already looking at // the one comment they came to decide about. 'can_approve' => $comment->isPending() && $viewer !== null && Gate::forUser($viewer)->allows('moderate', FileComment::class), ]; } private function authorType(FileComment $comment): string { $author = $comment->author; if ($author === null) { return 'guest'; } return $author->isStaff() ? 'staff' : 'client'; } }