Skip to content
Open
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
28 changes: 26 additions & 2 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -547,9 +547,33 @@ public function olderMessages(string $threadPath, int $before): array
*/
public function reportCommit(string $id, ReportReason $reason, string $details = ''): void
{
$payload = $details === '' ? ['reason' => $reason->value] : ['reason' => $reason->value, 'details' => $details];
$this->send('POST', 'commits/'.rawurlencode($id).'/reports', payload: self::reportPayload($reason, $details));
}

/**
* Reports a Remotes message to devnull's moderators, privately. The
* message stays in the thread. Reporting one again keeps the first report.
*/
public function reportMessage(int $id, ReportReason $reason, string $details = ''): void
{
$this->send('POST', "remotes/messages/{$id}/reports", payload: self::reportPayload($reason, $details));
}

/**
* Reports someone to devnull's moderators, privately. They aren't told.
* Reporting them again keeps the first report.
*/
public function reportUser(string $username, ReportReason $reason, string $details = ''): void
{
$this->send('POST', 'users/'.rawurlencode($username).'/reports', payload: self::reportPayload($reason, $details));
}

$this->send('POST', 'commits/'.rawurlencode($id).'/reports', payload: $payload);
/**
* @return array{reason: string, details?: string}
*/
private static function reportPayload(ReportReason $reason, string $details): array
{
return $details === '' ? ['reason' => $reason->value] : ['reason' => $reason->value, 'details' => $details];
}

public function profile(string $username): Profile
Expand Down
12 changes: 9 additions & 3 deletions app/Devnull/MessageMenu.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ final class MessageMenu
/**
* $answerable is whether the message takes Reply and React, and $pinned whether it's pinned, or null when you
* can't pin it. A message's photos open the same menu as its bubble, so only one of the two names its items
* ($named), and tests find them.
* ($named), and tests find them. $reportable is whether it's someone else's message devnull has, which ends the
* menu with Report message, in red.
*
* @return list<TopBarAction>
*/
public static function items(Message $message, bool $answerable, ?bool $pinned, bool $named = true): array
public static function items(Message $message, bool $answerable, ?bool $pinned, bool $named = true, bool $reportable = false): array
{
$key = $message->id > 0 ? $message->id : $message->clientId;
$argument = $message->id > 0 ? (string) $message->id : "'{$message->clientId}'";
Expand All @@ -46,15 +47,20 @@ public static function items(Message $message, bool $answerable, ?bool $pinned,
: self::item('pin', 'Pin message', Lucide::Pin, "togglePin({$argument})", $key, $named);
}

if ($reportable && $message->id > 0) {
$items[] = self::item('report', 'Report message', Lucide::Flag, "reportMessage({$message->id})", $key, $named, destructive: true);
}

return $items;
}

private static function item(string $action, string $label, Lucide $icon, string $press, int|string $key, bool $named): TopBarAction
private static function item(string $action, string $label, Lucide $icon, string $press, int|string $key, bool $named, bool $destructive = false): TopBarAction
{
$item = NavAction::make($action)
->label($label)
->icon(ios: $icon->ios(), android: $icon->android())
->press($press)
->destructive($destructive)
->toElement();

return $named ? $item->ref("{$action}-menu-{$key}") : $item;
Expand Down
108 changes: 108 additions & 0 deletions app/NativeComponents/Concerns/ReportsToModerators.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

namespace App\NativeComponents\Concerns;

use App\Devnull\Client;
use App\Devnull\ReportReason;

/**
* The sheet that reports a Remotes message or a person to devnull's
* moderators (see partials/report-sheet), as the commit sheet reports a
* commit: a reason, optional details, and Send report, which waits for a
* reason. Once devnull has it, the sheet says so, with Done. If devnull says
* no, why shows above Send report and the form stays as it was.
*
* Reports are private: the person reported isn't told, and nothing in the
* thread or on the profile changes.
*/
trait ReportsToModerators
{
public bool $reportingOpen = false;

/**
* What's being reported: 'message' or 'user', or '' when nothing is.
*/
public string $reportingKind = '';

/**
* The message's id, or the person's handle.
*/
public string $reportingId = '';

/**
* What the sheet quotes: the message's text, or the person's handle.
*/
public string $reportingPreview = '';

public string $reportingReason = '';

public string $reportingDetails = '';

public bool $reportingSent = false;

public ?string $reportingError = null;

/**
* Opens the sheet for someone else's message.
*/
protected function openMessageReport(int $id, string $preview): void
{
$this->openReport('message', (string) $id, $preview);
}

/**
* Opens the sheet for someone else's profile.
*/
protected function openUserReport(string $username): void
{
$this->openReport('user', $username, '@'.$username);
}

public function sendReporting(): void
{
$reason = ReportReason::tryFrom($this->reportingReason);

if (! $this->reportingOpen || $this->reportingSent || $reason === null) {
return;
}

$kind = $this->reportingKind;
$id = $this->reportingId;
$details = trim($this->reportingDetails);
$screenError = $this->error;

$sent = $this->attempt(function (Client $devnull) use ($kind, $id, $reason, $details): bool {
$kind === 'message'
? $devnull->reportMessage((int) $id, $reason, $details)
: $devnull->reportUser($id, $reason, $details);

return true;
});

$this->reportingError = $sent === true ? null : $this->error;
$this->error = $screenError;
$this->reportingSent = $sent === true;
}

/**
* Done, or the sheet dragged away. What it shows stays until the next
* report opens, so it doesn't empty while it slides away, and iOS's late
* dismiss after Done changes nothing.
*/
public function closeReporting(): void
{
$this->reportingOpen = false;
}

private function openReport(string $kind, string $id, string $preview): void
{
$this->reportingKind = $kind;
$this->reportingId = $id;
$this->reportingPreview = $preview;
$this->reportingReason = '';
$this->reportingDetails = '';
$this->reportingSent = false;
$this->reportingError = null;
$this->reportingOpen = true;
}
}
30 changes: 28 additions & 2 deletions app/NativeComponents/Conversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use App\NativeComponents\Concerns\PlaysVoiceNotes;
use App\NativeComponents\Concerns\RecordsVoiceNotes;
use App\NativeComponents\Concerns\RefreshesInBackground;
use App\NativeComponents\Concerns\ReportsToModerators;
use App\NativeComponents\Concerns\SuggestsMentions;
use App\NativeComponents\Concerns\SyncsOutbox;
use App\NativeComponents\Concerns\TalksToDevnull;
Expand Down Expand Up @@ -107,6 +108,7 @@ class Conversation extends NativeComponent
use PlaysVoiceNotes;
use RecordsVoiceNotes;
use RefreshesInBackground;
use ReportsToModerators;
use SuggestsMentions;
use SyncsOutbox;
use TalksToDevnull;
Expand Down Expand Up @@ -822,6 +824,20 @@ public function reply(int $id): void
$this->focusField(onlyIfIdle: true);
}

/**
* Report message, from the menu a long press opens on someone else's message: the report sheet, quoting it.
*/
public function reportMessage(int $id): void
{
$message = $this->findMessage($id);

if ($message === null || ! $this->isReportable($message)) {
return;
}

$this->openMessageReport($id, $message->body !== '' ? $message->body : ($message->voice !== null ? 'Voice note' : 'Photo'));
}

/**
* Copy text, from the menu a long press opens: the message's text, as it was written, on the clipboard. A message
* still being pushed is named by its client id. iOS says so in a toast, in the web's words. Android says so itself,
Expand Down Expand Up @@ -1622,6 +1638,16 @@ private function canPin(): bool
return $this->isOrg() && $this->thread?->role === 'maintainer' && $this->thread->access === Thread::Open;
}

/**
* Someone else's message, sent, which devnull's moderators can be told about.
*/
private function isReportable(Message $message): bool
{
$me = $this->me();

return $me !== null && ! $message->isEvent() && ! $message->isPending() && $message->id > 0 && $message->userId !== $me->id;
}

/**
* React, Reply and Pin are for messages devnull has, not an org's events, while the thread takes messages.
*/
Expand Down Expand Up @@ -1913,8 +1939,8 @@ private function items(?User $viewer): array
'author' => $message->user ?? $this->authorOf($message->userId),
'pan' => $this->panFor($message),
'slide' => $this->slideOf($message),
'menu' => MessageMenu::items($message, $answerable, $pinned),
'photosMenu' => $message->images === [] || $message->body === '' ? [] : MessageMenu::items($message, $answerable, $pinned, named: false),
'menu' => MessageMenu::items($message, $answerable, $pinned, reportable: $this->isReportable($message)),
'photosMenu' => $message->images === [] || $message->body === '' ? [] : MessageMenu::items($message, $answerable, $pinned, named: false, reportable: $this->isReportable($message)),
];
}

Expand Down
24 changes: 20 additions & 4 deletions app/NativeComponents/Profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use App\NativeComponents\Concerns\OpensInTab;
use App\NativeComponents\Concerns\OpensLiveStream;
use App\NativeComponents\Concerns\RefreshesInBackground;
use App\NativeComponents\Concerns\ReportsToModerators;
use App\NativeComponents\Concerns\ShowsCommits;
use App\NativeComponents\Concerns\TalksToDevnull;
use App\NativeComponents\Concerns\WatchesForCalls;
Expand Down Expand Up @@ -77,6 +78,7 @@ class Profile extends NativeComponent
}
use OpensLiveStream;
use RefreshesInBackground;
use ReportsToModerators;
use ShowsCommits;
use TalksToDevnull;
use WatchesForCalls;
Expand Down Expand Up @@ -228,9 +230,9 @@ private function shareAction(): NavAction
}

/**
* The web's safety menu: Mute or Unmute, then Block, in red. A profile you
* blocked offers only Unblock, as the web's blocked page does, and one that
* blocked you offers nothing. It waits for devnull to say whether you mute
* The web's safety menu: Mute or Unmute, then Block and Report, in red. A
* profile you blocked offers Unblock and Report, and one that blocked you
* offers nothing. It waits for devnull to say whether you mute
* them.
*
* @return list<NavAction>
Expand All @@ -239,8 +241,10 @@ private function safetyActions(): array
{
$handle = '@'.$this->username;

$report = NavAction::make('report')->label("Report {$handle}")->icon(ios: Lucide::Flag->ios(), android: Lucide::Flag->android())->destructive()->press('reportUser');

if ($this->isBlocking()) {
return [NavAction::make('unblock')->label("Unblock {$handle}")->icon(ios: Lucide::Ban->ios(), android: Lucide::Ban->android())->press('confirmBlock')];
return [NavAction::make('unblock')->label("Unblock {$handle}")->icon(ios: Lucide::Ban->ios(), android: Lucide::Ban->android())->press('confirmBlock'), $report];
}

if (! $this->canChangeSafety()) {
Expand All @@ -253,9 +257,21 @@ private function safetyActions(): array
return [
NavAction::make($muting ? 'unmute' : 'mute')->label(($muting ? 'Unmute ' : 'Mute ').$handle)->icon(ios: $muteIcon->ios(), android: $muteIcon->android())->press('confirmMute'),
NavAction::make('block')->label("Block {$handle}")->icon(ios: Lucide::Ban->ios(), android: Lucide::Ban->android())->destructive()->press('confirmBlock'),
$report,
];
}

/**
* Report, from the safety menu: the report sheet, for someone you could
* mute or block, or have blocked.
*/
public function reportUser(): void
{
if ($this->canChangeSafety() || $this->isBlocking()) {
$this->openUserReport($this->username);
}
}

/**
* Pull to refresh. You're at the top, so new commits go straight in.
*/
Expand Down
1 change: 1 addition & 0 deletions resources/views/native/conversation.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<native:column class="w-full h-full bg-theme-background">
@include('native.partials.error')
@include('native.partials.refresh-failed')
@include('native.partials.report-sheet')

{{--
Each row is a child of the scroll view itself, not of one column inside it: SwiftUI keeps a reader's place by
Expand Down
58 changes: 58 additions & 0 deletions resources/views/native/partials/report-sheet.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{{--
The sheet that reports a Remotes message or a person (see ReportsToModerators), as the commit sheet's Report commit
form: the title, a not-hidden line, what's reported, the four reasons, optional details, and Send report, which waits
for a reason. Once devnull has the report, the sheet says so, with Done. If devnull says no, why shows above Send
report.
--}}
<native:bottom-sheet native:key="report-sheet" ref="report-sheet" :visible="$reportingOpen" :detents="$reportingSent ? '0.3' : 'large'" @dismiss="closeReporting">
@if ($reportingSent)
<native:column ref="reporting-received" class="w-full px-5 pt-6 pb-6 gap-5">
<native:row class="w-full gap-3 items-center">
<native:icon :ios="\App\Icons\Lucide::Check->ios()" :android="\App\Icons\Lucide::Check->android()" :size="20" class="text-theme-primary" />
<native:text class="flex-1 text-base text-theme-ink">Report received. Moderators will review it privately.</native:text>
</native:row>
<native:button ref="reporting-done" variant="secondary" size="lg" @press="closeReporting" class="w-full">Done</native:button>
</native:column>
@elseif ($reportingKind !== '')
<native:scroll-view ref="reporting-form" class="w-full flex-1">
<native:column class="w-full px-5 pt-6 pb-10 gap-5">
<native:column class="w-full gap-2">
<native:text font="semibold" class="text-lg text-theme-ink">{{ $reportingKind === 'message' ? 'Report message' : 'Report '.$reportingPreview }}</native:text>
<native:text class="text-sm leading-relaxed text-theme-ink-muted">{{ $reportingKind === 'message' ? 'Your report is private. Only moderators can review it, and they will see this message. Reporting does not hide it.' : 'Your report is private. Only moderators can review it. They will not be told. To stop seeing them, block them too.' }}</native:text>
</native:column>

@if ($reportingKind === 'message')
<native:column class="w-full">
<native:column class="w-full h-[1] bg-theme-hairline" />
<native:text ref="reporting-body" class="w-full py-3 text-sm leading-relaxed text-theme-ink" :max-lines="3">{{ $reportingPreview }}</native:text>
<native:column class="w-full h-[1] bg-theme-hairline" />
</native:column>
@endif

<native:radio-group ref="reporting-reason" label="{{ $reportingKind === 'message' ? 'Why are you reporting this message?' : 'Why are you reporting them?' }}" native:model="reportingReason">
@foreach (\App\Devnull\ReportReason::cases() as $reportOption)
<native:radio value="{{ $reportOption->value }}" label="{{ $reportOption->label() }}" />
@endforeach
</native:radio-group>

<native:outlined-text-input
ref="reporting-details"
class="w-full"
label="Details (optional)"
supporting="Add context for moderators. Up to {{ \App\Devnull\ReportReason::DetailsMaxLength }} characters."
native:model.debounce.150ms="reportingDetails"
multiline
:min-lines="3"
:max-lines="6"
:max-length="\App\Devnull\ReportReason::DetailsMaxLength"
/>

@if ($reportingError)
<native:text ref="reporting-error" class="w-full text-sm text-theme-destructive">{{ $reportingError }}</native:text>
@endif

<native:button ref="send-reporting" variant="primary" size="lg" :disabled="$reportingReason === ''" @press="sendReporting" class="w-full">Send report</native:button>
</native:column>
</native:scroll-view>
@endif
</native:bottom-sheet>
1 change: 1 addition & 0 deletions resources/views/native/profile.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@include('native.partials.error')
@include('native.partials.refresh-failed')
@include('native.partials.commit-sheet')
@include('native.partials.report-sheet')
@include('native.partials.achievements-sheet')
@include('native.partials.live-stream-sheet')
@include('native.partials.stargazers-sheet')
Expand Down
Loading
Loading