diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index de152c881..9f0592d4a 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -444,7 +444,7 @@ Sources: [safety settings](resources/js/pages/safety/edit.tsx), [mute action](ap ### Reporting commits -Report another person's visible, approved commit or reply privately from its controls. Choose a reason (spam or misleading links, harassment or hate, harmful or private information, or something else) and optionally add up to **500 characters** of detail. You can report a particular commit once, and file up to five reports a minute. Reporting does not immediately hide or reject the content. Moderators review reports without the reporter's identity and can dismiss the report or reject the commit, with an optional reason sent to its author. Deleting a reported commit keeps its reports. This is a content-safety report, not a software bug report; use `/bug` for app defects. +Report another person's visible, approved commit or reply privately from its controls. Choose a reason (spam or misleading links, harassment or hate, harmful or private information, or something else) and optionally add up to **500 characters** of detail. Attach up to **three JPEG, PNG, or WebP images**, each up to **10 MB**, using the image picker, paste, or drag and drop. Attachments can include descriptions and are visible only to you and moderators. You can report a particular commit once, and file up to five reports a minute. Reporting does not immediately hide or reject the content. Moderators review reports without the reporter's identity and can dismiss the report or reject the commit, with an optional reason sent to its author. Deleting a reported commit keeps its reports. This is a content-safety report, not a software bug report; use `/bug` for app defects. Sources: [report control](resources/js/components/commit-report-button.tsx), [report validation](app/Http/Requests/CreateCommitReportRequest.php), [reported-commit controller](app/Http/Controllers/ReportedCommitController.php). @@ -593,7 +593,7 @@ Choose **Download JSON** in Settings → Profile to export your account data. Th - SSH key fingerprints, approved devices, and your timeout history; - active-session metadata: IP address, browser information, and last activity. -Other people's replies and received messages are not included as your authored data. Message images, message reactions, reports you filed, scheduled commits, and ban records are not included either. Image and voice-note references are URLs, not an archive of the media files. Deleted commit entries do not restore content already erased after the undo window. +Other people's replies and received messages are not included as your authored data. Message images, message reactions, scheduled commits, and ban records are not included either. Reports you filed include their reason, details, and attachment references; reports filed by other people are excluded. Image and voice-note references are URLs, not an archive of the media files. Deleted commit entries do not restore content already erased after the undo window. The export does not expose session IDs, authentication tokens, private SSH keys, or other people's poll choices. Keep the downloaded JSON private because it includes personal account information. Exports are limited to **three per hour**. diff --git a/app/Actions/CreateCommitReport.php b/app/Actions/CreateCommitReport.php index 0cb9a2131..9f172c1ac 100644 --- a/app/Actions/CreateCommitReport.php +++ b/app/Actions/CreateCommitReport.php @@ -7,17 +7,40 @@ use App\Models\Commit; use App\Models\CommitReport; use App\Models\User; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\DB; final readonly class CreateCommitReport { - public function handle(User $user, Commit $commit, string $reason, ?string $details): void + public function __construct(private CreateImageFiles $createImageFiles) { - CommitReport::query()->createOrFirst([ - 'commit_id' => $commit->id, - 'reporter_id' => $user->id, - ], [ - 'reason' => $reason, - 'details' => $details, - ]); + // + } + + /** + * @param list $images + * @param array $imageDescriptions + */ + public function handle(User $user, Commit $commit, string $reason, ?string $details, array $images = [], array $imageDescriptions = []): void + { + DB::transaction(function () use ($user, $commit, $reason, $details, $images, $imageDescriptions): void { + // Match account deletion's lock order and recheck models that may have been deleted since the request began. + User::query()->whereKey($user->id)->sharedLock()->firstOrFail(); + Commit::query()->whereKey($commit->id)->sharedLock()->firstOrFail(); + + $report = CommitReport::query()->createOrFirst([ + 'commit_id' => $commit->id, + 'reporter_id' => $user->id, + ], [ + 'reason' => $reason, + 'details' => $details, + ]); + + if ($report->wasRecentlyCreated && $images !== []) { + $report->forceFill([ + 'images' => $this->createImageFiles->handle($images, $imageDescriptions, 'report-images', encrypted: true), + ])->save(); + } + }); } } diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 079d84250..8cae83d11 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -6,6 +6,7 @@ use App\Enums\NotificationType; use App\Models\CommitImage; +use App\Models\CommitReport; use App\Models\Conversation; use App\Models\ConversationParticipant; use App\Models\Device; @@ -25,6 +26,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; use stdClass; +use Traversable; /** * @phpstan-type ExportPoll array{commit_id: string, duration_minutes: int, opened_at: string|null, options: list, created_at: string} @@ -46,6 +48,7 @@ * dismissed_suggestions: Generator, * claimed_organizations: array, * messages: Generator, + * reports: Traversable>, * remote_pins: array, * remote_mutes: array, * notifications: Generator>, @@ -113,6 +116,13 @@ public function handle(User $user): array 'dismissed_suggestions' => $this->handles($user->dismissedSuggestions()), 'claimed_organizations' => $this->claimedOrganizations($user), 'messages' => $this->messages($user), + 'reports' => CommitReport::query()->where('reporter_id', $user->id)->oldest('id')->cursor()->map(fn (CommitReport $report): array => [ + 'commit_id' => $report->commit_id, + 'reason' => $report->reason, + 'details' => $report->details, + 'attachments' => $report->toArray()['attachments'], + 'created_at' => $report->created_at->toIso8601String(), + ])->getIterator(), 'remote_mutes' => Conversation::query() ->join('conversation_participants as mine', 'mine.conversation_id', '=', 'conversations.id') ->where('mine.user_id', $user->id) diff --git a/app/Actions/CreateImageFiles.php b/app/Actions/CreateImageFiles.php index e70b04714..dfddf31d1 100644 --- a/app/Actions/CreateImageFiles.php +++ b/app/Actions/CreateImageFiles.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use RuntimeException; final readonly class CreateImageFiles { @@ -46,10 +47,16 @@ public function handle(array $images, array $descriptions = [], string $director if ($encrypted) { $path = $directory.'/'.Str::uuid7()->toString(); $stored = Storage::disk()->put($path, $this->encryptedFiles->encrypt($image->toBytes())); - assert($stored); + + if (! $stored) { + throw new RuntimeException('Unable to store image.'); + } } else { $path = $image->storeAs($directory, Str::uuid7()->toString().'.webp'); - assert(is_string($path)); + + if (! is_string($path)) { + throw new RuntimeException('Unable to store image.'); + } } DB::afterRollBack(fn (): bool => Storage::disk()->delete($path)); diff --git a/app/Actions/DeleteOrgs.php b/app/Actions/DeleteOrgs.php index 48e24f1a8..e577d4c16 100644 --- a/app/Actions/DeleteOrgs.php +++ b/app/Actions/DeleteOrgs.php @@ -9,8 +9,6 @@ use App\Models\MessageImage; use App\Models\MessageVoice; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Storage; final readonly class DeleteOrgs { @@ -29,8 +27,8 @@ public function handle(Builder $orgs): void ...MessageVoice::query()->whereIn('message_id', $messageIds)->get(['path'])->map(fn (MessageVoice $voice): string => $voice->path), ]; - $orgs->delete(); + resolve(DeleteStoredFiles::class)->retain($paths); - DB::afterCommit(fn (): bool => Storage::disk()->delete($paths)); + $orgs->delete(); } } diff --git a/app/Actions/DeleteStoredFiles.php b/app/Actions/DeleteStoredFiles.php new file mode 100644 index 000000000..925391aed --- /dev/null +++ b/app/Actions/DeleteStoredFiles.php @@ -0,0 +1,76 @@ + $paths + */ + public function retain(array $paths): void + { + if ($paths === []) { + return; + } + + $id = DB::table('pending_file_deletions')->insertGetId([ + 'disk' => config()->string('filesystems.default'), + 'paths' => json_encode(array_values(array_unique($paths)), JSON_THROW_ON_ERROR), + ]); + + DB::afterCommit(fn () => $this->handle($id)); + } + + public function handle(int $id): void + { + $pending = DB::table('pending_file_deletions')->find($id); + + if ($pending === null) { + return; + } + + try { + if (! is_object($pending) + || ! isset($pending->paths, $pending->disk) + || ! is_string($pending->paths) + || ! is_string($pending->disk)) { + throw new UnexpectedValueException('Invalid pending file deletion record '.$id); + } + + $decodedPaths = json_decode($pending->paths, true, flags: JSON_THROW_ON_ERROR); + + if (! is_array($decodedPaths) || ! array_is_list($decodedPaths)) { + throw new UnexpectedValueException('Invalid paths for pending file deletion '.$id); + } + + $paths = []; + foreach ($decodedPaths as $path) { + if (! is_string($path)) { + throw new UnexpectedValueException('Invalid path for pending file deletion '.$id); + } + + $paths[] = $path; + } + + if (! Storage::disk($pending->disk)->delete($paths)) { + throw new RuntimeException('File cleanup failed for pending deletion '.$id); + } + + DB::table('pending_file_deletions')->where('id', $id)->delete(); + } catch (Throwable $throwable) { + // Keep the durable record for the scheduled retry, including when the disk throws. + report($throwable); + } + } +} diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 56513ed1f..60701e1e1 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -20,7 +20,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Storage; final readonly class DeleteUser { @@ -38,10 +37,10 @@ public function __construct(private UpdateCommitHotScores $updateCommitHotScores */ public function handle(User $user): void { - $imagePaths = DB::transaction(function () use ($user): array { - $this->updateCommitHotScores->handle($this->engagedCommitIds($user)); + DB::transaction(function () use ($user): void { + User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail(); - $imagePaths = [...$this->imagePaths($user), ...$this->scheduledImagePaths($user), ...$this->messageImagePaths($user), ...$this->messageVoicePaths($user)]; + $this->updateCommitHotScores->handle($this->engagedCommitIds($user)); $translationKeys = $this->touchedCommits($user) ->select(['id', 'body']) @@ -53,6 +52,10 @@ public function handle(User $user): void $user->commits()->withTrashed()->topLevel()->orderBy('id')->lockForUpdate()->pluck('id'); $this->touchedCommits($user)->orderBy('id')->lockForUpdate()->pluck('id'); + // Report creation holds shared reporter and commit locks until its attachment paths are saved. + $imagePaths = [...$this->reportImagePaths($user), ...$this->imagePaths($user), ...$this->scheduledImagePaths($user), ...$this->messageImagePaths($user), ...$this->messageVoicePaths($user)]; + resolve(DeleteStoredFiles::class)->retain($imagePaths); + DB::table('commit_topics')->whereIn('commit_id', $this->touchedCommits($user)->select('id'))->delete(); $user->commits()->withTrashed()->topLevel()->forceDelete(); CommitImage::query()->whereIn('commit_id', $this->replies($user)->select('id'))->delete(); @@ -75,11 +78,7 @@ public function handle(User $user): void foreach ($translationKeys as $key) { Cache::forget($key); } - - return $imagePaths; }); - - Storage::disk()->delete($imagePaths); } /** @@ -119,6 +118,18 @@ private function replies(User $user): HasMany return $user->commits()->withTrashed()->whereNotNull('parent_id'); } + /** @return array */ + private function reportImagePaths(User $user): array + { + return CommitReport::query() + ->where('reporter_id', $user->id) + ->orWhereIn('commit_id', $user->commits()->withTrashed()->select('id')) + ->orWhereIn('commit_id', Commit::query()->withTrashed()->whereIn('root_id', $user->commits()->withTrashed()->topLevel()->select('id'))->select('id')) + ->get(['images']) + ->flatMap(fn (CommitReport $report): array => array_column($report->images ?? [], 'path')) + ->all(); + } + /** * The images of the user's commits and of every reply in the threads they started, which go along with them. * diff --git a/app/Http/Controllers/CommitReportController.php b/app/Http/Controllers/CommitReportController.php index 267d0a5b1..f07230cfa 100644 --- a/app/Http/Controllers/CommitReportController.php +++ b/app/Http/Controllers/CommitReportController.php @@ -19,7 +19,7 @@ { public function store(CreateCommitReportRequest $request, #[CurrentUser] User $user, Commit $commit, CreateCommitReport $action): RedirectResponse { - $action->handle($user, $commit, $request->string('reason')->value(), $request->filled('details') ? $request->string('details')->value() : null); + $action->handle($user, $commit, $request->string('reason')->value(), $request->filled('details') ? $request->string('details')->value() : null, $request->images(), $request->imageDescriptions()); Inertia::flash('toast', ['type' => 'success', 'message' => 'Report received. Moderators will review it privately.']); diff --git a/app/Http/Controllers/CommitReportImageController.php b/app/Http/Controllers/CommitReportImageController.php new file mode 100644 index 000000000..5448d44e5 --- /dev/null +++ b/app/Http/Controllers/CommitReportImageController.php @@ -0,0 +1,28 @@ +images[$image] ?? null; + assert(is_array($file)); + + return response($encryptedFiles->decrypt((string) Storage::disk()->get($file['path'])), headers: [ + 'Content-Type' => 'image/webp', + 'Content-Disposition' => 'inline', + 'Content-Security-Policy' => "default-src 'none'; sandbox", + 'X-Content-Type-Options' => 'nosniff', + 'Cache-Control' => 'private, no-store', + ]); + } +} diff --git a/app/Http/Requests/CreateCommitReportRequest.php b/app/Http/Requests/CreateCommitReportRequest.php index ea45f920c..5af15eec4 100644 --- a/app/Http/Requests/CreateCommitReportRequest.php +++ b/app/Http/Requests/CreateCommitReportRequest.php @@ -9,8 +9,13 @@ use App\Models\CommitReport; use App\Models\User; use Illuminate\Auth\Access\Response; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Http\UploadedFile; use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\Dimensions; +use Illuminate\Validation\Rules\Enum; +use Illuminate\Validation\Rules\File; use Illuminate\Validation\Rules\In; final class CreateCommitReportRequest extends FormRequest @@ -36,13 +41,31 @@ public function authorize(): Response } /** - * @return array{reason: array, details: array} + * @return array> */ public function rules(): array { return [ + ...array_intersect_key(CreateCommitRequest::attachmentRules(), array_flip(['images', 'images.*', 'image_descriptions', 'image_descriptions.*'])), 'reason' => ['required', 'string', Rule::in(array_keys(CommitReport::REASONS))], 'details' => ['nullable', 'string', 'max:500'], ]; } + + /** @return list */ + public function images(): array + { + $images = $this->file('images'); + + return is_array($images) ? array_values($images) : []; + } + + /** @return list */ + public function imageDescriptions(): array + { + /** @var list $descriptions */ + $descriptions = $this->array('image_descriptions'); + + return $descriptions; + } } diff --git a/app/Http/Requests/ShowCommitReportImageRequest.php b/app/Http/Requests/ShowCommitReportImageRequest.php new file mode 100644 index 000000000..a25b8cfff --- /dev/null +++ b/app/Http/Requests/ShowCommitReportImageRequest.php @@ -0,0 +1,33 @@ +user(); + assert($user instanceof User); + $report = $this->route('report'); + assert($report instanceof CommitReport); + $image = $report->images[(int) $this->route('image')] ?? null; + + return ($user->isAdmin() || $report->reporter_id === $user->id) && $image !== null && Storage::disk()->exists($image['path']) + ? Response::allow() + : Response::denyAsNotFound(); + } + + /** @return array{} */ + public function rules(): array + { + return []; + } +} diff --git a/app/Models/CommitReport.php b/app/Models/CommitReport.php index 5705e10bf..258755fca 100644 --- a/app/Models/CommitReport.php +++ b/app/Models/CommitReport.php @@ -7,6 +7,7 @@ use Carbon\CarbonInterface; use Database\Factories\CommitReportFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -17,12 +18,14 @@ * @property-read string $commit_id * @property-read string $reporter_id * @property-read string $reason + * @property-read list|null $images * @property-read string|null $details * @property-read CarbonInterface|null $dismissed_at * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at * @property-read Commit|null $commit */ +#[Hidden(['images'])] #[Fillable(['commit_id', 'reporter_id', 'reason', 'details'])] final class CommitReport extends Model { @@ -41,7 +44,21 @@ final class CommitReport extends Model /** @return array */ public function casts(): array { - return ['dismissed_at' => 'datetime']; + return ['dismissed_at' => 'datetime', 'images' => 'encrypted:array']; + } + + /** @return array */ + public function toArray(): array + { + return [ + ...parent::toArray(), + 'attachments' => array_map(fn (array $image): array => [ + 'url' => route('commit-report-image.show', ['report' => $this->id, 'image' => $image['position']]), + 'description' => $image['description'], + 'width' => $image['width'], + 'height' => $image['height'], + ], $this->images ?? []), + ]; } /** @return BelongsTo */ diff --git a/app/Queries/ReportedCommitsQuery.php b/app/Queries/ReportedCommitsQuery.php index 85cc3a8b8..4a1a93abf 100644 --- a/app/Queries/ReportedCommitsQuery.php +++ b/app/Queries/ReportedCommitsQuery.php @@ -16,7 +16,7 @@ public function paginate(User $viewer): CursorPaginator { return CommitReport::query() - ->select(['id', 'commit_id', 'reason', 'details', 'created_at']) + ->select(['id', 'commit_id', 'reason', 'details', 'images', 'created_at']) ->whereNull('dismissed_at') ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->approved()->visibleTo($viewer)) ->with(['commit' => fn (BelongsTo $query): BelongsTo => $query diff --git a/database/migrations/2026_10_24_100000_add_images_to_commit_reports_table.php b/database/migrations/2026_10_24_100000_add_images_to_commit_reports_table.php new file mode 100644 index 000000000..a38c37c8f --- /dev/null +++ b/database/migrations/2026_10_24_100000_add_images_to_commit_reports_table.php @@ -0,0 +1,17 @@ +text('images')->nullable(); + }); + } +}; diff --git a/database/migrations/2026_10_29_120000_create_pending_file_deletions_table.php b/database/migrations/2026_10_29_120000_create_pending_file_deletions_table.php new file mode 100644 index 000000000..a55cc7d44 --- /dev/null +++ b/database/migrations/2026_10_29_120000_create_pending_file_deletions_table.php @@ -0,0 +1,19 @@ +id(); + $table->string('disk'); + $table->json('paths'); + }); + } +}; diff --git a/resources/js/components/commit-image-picker.tsx b/resources/js/components/commit-image-picker.tsx index 071453fee..05ceebad3 100644 --- a/resources/js/components/commit-image-picker.tsx +++ b/resources/js/components/commit-image-picker.tsx @@ -1,5 +1,5 @@ import { ImagePlus, X } from 'lucide-react'; -import { useId, useRef, useState } from 'react'; +import { useId, useLayoutEffect, useRef, useState } from 'react'; import { ActionTooltip } from '@/components/action-tooltip'; import type { ShownImage } from '@/components/commit-images'; import { ImageViewer } from '@/components/image-viewer'; @@ -84,6 +84,28 @@ export function revokeObjectUrls(images: File[]): void { }); } +/** Releases previews owned by a form whose files are not retained in the draft stash. */ +export function useImagePreviewCleanup(images: File[]): void { + const previous = useRef([]); + const mounted = useRef(false); + + useLayoutEffect(() => { + mounted.current = true; + revokeObjectUrls( + previous.current.filter((image) => !images.includes(image)), + ); + previous.current = images; + + return () => { + mounted.current = false; + // Strict Mode replays effects before this runs, keeping live previews valid. + queueMicrotask(() => { + if (!mounted.current) revokeObjectUrls(images); + }); + }; + }, [images]); +} + export function ImagePickerButton({ count, disabled, diff --git a/resources/js/components/commit-report-button.tsx b/resources/js/components/commit-report-button.tsx index 1bc434349..99d7cc950 100644 --- a/resources/js/components/commit-report-button.tsx +++ b/resources/js/components/commit-report-button.tsx @@ -3,6 +3,13 @@ import { Flag } from 'lucide-react'; import type { ReactNode } from 'react'; import { useId, useState } from 'react'; import { store } from '@/actions/App/Http/Controllers/CommitReportController'; +import { + ImageDropOverlay, + ImagePickerButton, + ImagePickerPreviews, + imageErrorFrom, + useImagePreviewCleanup, +} from '@/components/commit-image-picker'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; import { @@ -21,6 +28,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { useCommitNotFound } from '@/hooks/use-deleted-resources'; +import { useImageAttachments } from '@/hooks/use-image-attachments'; import type { Commit } from '@/types'; const reasons = [ @@ -117,30 +125,55 @@ function ReportForm({ const [failure, setFailure] = useState(''); const commitNotFound = useCommitNotFound(); const id = useId(); - const form = useForm({ reason: '', details: '' }); + const form = useForm({ + reason: '', + details: '', + images: [] as File[], + image_descriptions: [] as string[], + }); + useImagePreviewCleanup(form.data.images); + const attachments = useImageAttachments( + form.data.images, + form.data.image_descriptions, + (images, descriptions) => { + form.setData((data) => ({ + ...data, + images, + image_descriptions: descriptions, + })); + }, + `report:${commit.id}:${id}`, + form.processing, + ); return ( <> - + Report commit - Your report is private. Only moderators can review it. - Reporting does not hide this commit. + Your report is private. Attachments are visible only to you + and moderators. Reporting does not hide this commit.

{commit.body}

{ event.preventDefault(); + if (form.processing || attachments.isReading()) return; setFailure(''); form.submit(store(commit), { preserveScroll: true, onStart: () => onSendingChange(true), onFinish: () => onSendingChange(false), - onSuccess: onDone, + onSuccess: () => { + onDone(); + attachments.clear(); + form.reset(); + }, onHttpException: (response) => { if (commitNotFound(commit.id, response)) { onDone(); @@ -212,12 +245,13 @@ function ReportForm({ onChange={(event) => form.setData('details', event.target.value) } + onPaste={attachments.onPaste} maxLength={500} rows={3} disabled={form.processing} aria-invalid={!!form.errors.details} aria-describedby={`${id}-details-hint ${id}-details-error`} - className="w-full resize-y rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="w-full resize-y rounded-md border bg-background px-3 py-2 text-base outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-sm" />

+ {attachments.isDraggingFiles && } +

+ + + +
{failure && (

{failure}

)} - + diff --git a/resources/js/hooks/use-image-attachments.ts b/resources/js/hooks/use-image-attachments.ts index 147bf7358..2ee82606c 100644 --- a/resources/js/hooks/use-image-attachments.ts +++ b/resources/js/hooks/use-image-attachments.ts @@ -42,12 +42,13 @@ export function useImageAttachments( descriptions: string[], onChange: (images: File[], descriptions: string[]) => void, draftKey: string, + disabled = false, ): UseImageAttachmentsReturn { const [error, setError] = useState(null); const [dragDepth, setDragDepth] = useState(0); const attach = (files: File[]) => { - if (files.length === 0) { + if (disabled || files.length === 0) { return; } @@ -78,7 +79,7 @@ export function useImageAttachments( const pick = async (files: File[]) => { const available = Math.max(0, maxImages - images.length); - if (files.length === 0 || available === 0) return; + if (disabled || files.length === 0 || available === 0) return; setError(null); await readImagesForDraft(draftKey, files, { @@ -107,6 +108,8 @@ export function useImageAttachments( pick, attach, describe: (index, description) => { + if (disabled) return; + onChange( images, images.map((_, position) => @@ -117,6 +120,8 @@ export function useImageAttachments( ); }, remove: (index) => { + if (disabled) return; + onChange( images.filter((_, position) => position !== index), descriptions.filter((_, position) => position !== index), @@ -141,13 +146,15 @@ export function useImageAttachments( onDragEnter: (event) => { if (hasFiles(event.dataTransfer)) { event.preventDefault(); - setDragDepth((depth) => depth + 1); + if (!disabled) { + setDragDepth((depth) => depth + 1); + } } }, onDragOver: (event) => { if (hasFiles(event.dataTransfer)) { event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; + event.dataTransfer.dropEffect = disabled ? 'none' : 'copy'; } }, onDragLeave: (event) => { diff --git a/resources/js/pages/moderation/reports.tsx b/resources/js/pages/moderation/reports.tsx index 4d11e8dda..c63a3b3e3 100644 --- a/resources/js/pages/moderation/reports.tsx +++ b/resources/js/pages/moderation/reports.tsx @@ -18,6 +18,12 @@ type Report = { id: string; reason: string; details: string | null; + attachments: { + url: string; + description: string | null; + width: number; + height: number; + }[]; created_at: string; commit: Commit; }; @@ -115,6 +121,27 @@ export default function Reports({ {report.details}

)} + {report.attachments?.map((image) => ( + + { + + ))}

Reported{' '}