diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 5e735c48bc..2145e74ee4 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -232,6 +232,8 @@ A **commit** is a post on /dev/null, not a Git commit in a repository. Open the Commits, replies, quotes, scheduling, terminal submissions, and app (signed API) commits share a limit of 10 attempts per minute per user. Posts go through [moderation](#moderation), so a newly submitted commit may be pending rather than public immediately. +Commit cards show where a post was submitted when that source is known: the website, iOS, Android, the lite mobile app, macOS, the terminal, or an app whose platform is unknown. Posting sources reuse the same request classification as daily app-usage metrics: NativePHP is identified by its approved device ID, the Mac app by its user agent, and the lite app by its device ID or web-view user agent. Only recognized NativePHP devices are further distinguished as iOS or Android. The label comes from the posting surface, not a value supplied in the post. Older posts and posts scheduled before source tracking was added may have no label, even when submitted later. New posts from apps without a recognized platform are labeled β€œvia App.” The source is included in the author's data export and cleared if account deletion leaves the post as a tombstone. + Approved top-level commits about specific developments in technology, current affairs or substantive real-world events can receive brief, shared headlines. A headline identifies a particular release, feature, event, change, problem or finding, not a broad category such as "Laravel" or "AI tools". Technical and developer developments carry the most weight. /dev/null community activity also qualifies, including livestreams, Tom, app betas and feature updates. Quote commits include the directly quoted post's text when it is approved and still available, so a brief reaction can share the development of the post it quotes. Replies are not used for headline classification. Casual small talk, broad technology chat without a specific development, and evergreen lifestyle or hobby advice are skipped. OpenAI's Decisions API assesses this text for suitability and checks the closest existing headline names, shortlisted by semantic similarity; when none match, DeepSeek through OpenCode generates a headline from the same text. Posts can be processed together, and newly generated headlines about the same development share the first headline rather than creating duplicates. Different developments involving the same product do not share a headline merely because of that product. No account metadata is sent. A headline expires after fourteen days without a new commit link. This categorization does not change the original post and does not yet add visible headline labels to the interface. The desktop sidebar shows signed-in members up to five **Hot topics** above **Who to follow**, with bold headlines and a truncated recap underneath when one is available. These aggregate approved top-level commits posted in the past eight hours, with extra weight for trending discussions. A topic needs at least two eligible top-level commits visible to you; replies do not count, and the list can show fewer than five topics or none. Only each author's strongest post contributes to a topic's ranking, so repeated posts from one profile do not lift it. The list respects mutes and blocks. The sidebar no longer repeats your own profile summary; your profile is still available from the main navigation. diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index a08c3c009b..deae9a84fe 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -6,6 +6,7 @@ use App\Dto\NewPoll; use App\Dto\ThreadEngagement; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Enums\CommitUpdate; use App\Enums\UserUpdate; @@ -62,7 +63,7 @@ public function __construct( * @param array $imageDescriptions * @param array $replyRecipients */ - public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, array $replyRecipients = [], ?Organization $organization = null): Commit + public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, array $replyRecipients = [], ?Organization $organization = null, ?CommitSource $source = null): Commit { $url = match ($linkPreview) { true => $this->urls->first($body), @@ -70,7 +71,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit default => $linkPreview, }; - return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $replyRecipients, $organization): Commit { + return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $replyRecipients, $organization, $source): Commit { $linkPreview = $this->linkPreviewFor($url); $createdAt = now(); $hasGoodReputation = $user->hasGoodReputation(); @@ -79,6 +80,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $commit = Commit::query()->create([ 'user_id' => $user->id, 'author_id' => $organization->id ?? $user->id, + 'source' => $source, 'body' => $this->emojiShortcodes->replace($body), 'mentions' => [...$replyRecipients, ...$mentions], 'parent_id' => $parent?->id, diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 13b11294ce..b20d2712ac 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -76,7 +76,7 @@ public function handle(User $user): array { return [ - 'schema_version' => 8, + 'schema_version' => 9, 'exported_at' => now()->toIso8601String(), 'profile' => [ 'github_id' => $user->github_id, @@ -198,7 +198,7 @@ private function commits(User $user): Generator { $commits = $user->commits() ->withTrashed() - ->select(['id', 'parent_id', 'quoted_commit_id', 'body', 'status', 'pasteis_count', 'created_at', 'updated_at', 'deleted_at']) + ->select(['id', 'parent_id', 'quoted_commit_id', 'body', 'status', 'source', 'pasteis_count', 'created_at', 'updated_at', 'deleted_at']) ->with('images:id,commit_id') ->lazyById(500); @@ -215,6 +215,7 @@ private function commits(User $user): Generator 'body' => $commit->body, 'cached_translations' => $cached[$translations->key($commit)], 'status' => $commit->status->value, + 'source' => $commit->source?->value, 'pasteis_count' => $commit->pasteis_count, 'images' => $commit->images->map(fn (CommitImage $image): string => $image->url())->all(), 'created_at' => $commit->created_at->toIso8601String(), @@ -248,6 +249,7 @@ private function organizationCommits(User $user): Generator 'quoted_commit_id' => $commit->quoted_commit_id, 'body' => $commit->body, 'status' => $commit->status->value, + 'source' => $commit->source?->value, 'images' => $commit->images->map(fn (CommitImage $image): string => $image->url())->all(), 'created_at' => $commit->created_at->toIso8601String(), 'deleted_at' => $commit->deleted_at?->toIso8601String(), diff --git a/app/Actions/CreateScheduledCommit.php b/app/Actions/CreateScheduledCommit.php index e8a3009adf..94af40447d 100644 --- a/app/Actions/CreateScheduledCommit.php +++ b/app/Actions/CreateScheduledCommit.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Dto\NewPoll; +use App\Enums\CommitSource; use App\Enums\UserUpdate; use App\Events\UsersUpdated; use App\Models\Organization; @@ -29,14 +30,15 @@ public function __construct(private CreateImageFiles $createImageFiles, private * @param list $images * @param array $imageDescriptions */ - public function handle(User $user, string $body, CarbonImmutable $scheduledAt, array $images = [], array $imageDescriptions = [], ?NewPoll $poll = null, bool|string $linkPreview = true, ?Organization $organization = null): ScheduledCommit + public function handle(User $user, string $body, CarbonImmutable $scheduledAt, array $images = [], array $imageDescriptions = [], ?NewPoll $poll = null, bool|string $linkPreview = true, ?Organization $organization = null, ?CommitSource $source = null): ScheduledCommit { $body = mb_trim($body); - return DB::transaction(function () use ($user, $organization, $body, $poll, $images, $imageDescriptions, $linkPreview, $scheduledAt): ScheduledCommit { + return DB::transaction(function () use ($user, $organization, $body, $poll, $images, $imageDescriptions, $linkPreview, $scheduledAt, $source): ScheduledCommit { $scheduled = ScheduledCommit::query()->create([ 'user_id' => $user->id, 'author_id' => $organization?->id, + 'source' => $source, 'body' => $body, 'poll' => $poll instanceof NewPoll ? ['options' => $poll->options, 'duration' => $poll->duration->value] : null, 'images' => $images === [] ? null : $this->createImageFiles->handle($images, $imageDescriptions), diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index e711d5438f..5b1bb2a3b6 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -89,10 +89,10 @@ public function handle(User $user): void } $user->commits()->delete(); - $user->commits()->withTrashed()->update(['user_id' => null, 'author_id' => null, 'body' => '', 'mentions' => null, 'embedding' => null, 'link_preview_id' => null, 'headlines_classified_at' => null]); + $user->commits()->withTrashed()->update(['user_id' => null, 'author_id' => null, 'body' => '', 'mentions' => null, 'embedding' => null, 'link_preview_id' => null, 'headlines_classified_at' => null, 'source' => null]); // What they posted for organizations is the organizations' own, so it stays, without who posted it. - Commit::query()->withTrashed()->where('user_id', $user->id)->update(['user_id' => null]); + Commit::query()->withTrashed()->where('user_id', $user->id)->update(['user_id' => null, 'source' => null]); $this->deleteOrgs->handle($this->emptyOrgs($user)); diff --git a/app/Actions/UpdateScheduledCommitSubmittedAt.php b/app/Actions/UpdateScheduledCommitSubmittedAt.php index e0cf77973c..1c111cfa95 100644 --- a/app/Actions/UpdateScheduledCommitSubmittedAt.php +++ b/app/Actions/UpdateScheduledCommitSubmittedAt.php @@ -59,7 +59,7 @@ public function handle(string $id, bool $early = false): bool $scheduled = ScheduledCommit::query()->with(['user', 'author'])->findOrFail($id); assert(is_string($scheduled->body)); $poll = $scheduled->poll === null ? null : new NewPoll($scheduled->poll['options'], PollDuration::from($scheduled->poll['duration'])); - $commit = $this->createCommit->handle($scheduled->user, $scheduled->body, linkPreview: $scheduled->link_preview_url ?? false, poll: $poll, organization: $scheduled->author?->organization); + $commit = $this->createCommit->handle($scheduled->user, $scheduled->body, linkPreview: $scheduled->link_preview_url ?? false, poll: $poll, organization: $scheduled->author?->organization, source: $scheduled->source); $commit->images()->createMany($scheduled->images ?? []); $scheduled->forceFill(['commit_id' => $commit->id, 'body' => null, 'poll' => null, 'images' => null, 'link_preview_url' => null])->save(); diff --git a/app/Enums/CommitSource.php b/app/Enums/CommitSource.php new file mode 100644 index 0000000000..73baa1de63 --- /dev/null +++ b/app/Enums/CommitSource.php @@ -0,0 +1,42 @@ +attributes->get(Device::class); + $appId = $device instanceof Device ? $device->app_id : null; + + return match (ActiveDayApp::forRequest($request)) { + ActiveDayApp::Web => self::Web, + ActiveDayApp::LiteMobile => self::LiteMobile, + ActiveDayApp::Mac => self::Macos, + ActiveDayApp::Other => self::Api, + ActiveDayApp::NativeMobile => match (explode(':', (string) $appId, 2)[0]) { + 'ios' => self::Ios, + 'android' => self::Android, + default => self::Api, + }, + }; + } +} diff --git a/app/Http/Controllers/Api/CommitController.php b/app/Http/Controllers/Api/CommitController.php index 146be1c58b..0c47ddd779 100644 --- a/app/Http/Controllers/Api/CommitController.php +++ b/app/Http/Controllers/Api/CommitController.php @@ -6,6 +6,7 @@ use App\Actions\CreateCommit; use App\Actions\DeleteCommit; +use App\Enums\CommitSource; use App\Http\Requests\CreateApiCommitRequest; use App\Http\Requests\DeleteApiCommitRequest; use App\Http\Requests\ShowApiCommitRequest; @@ -51,13 +52,16 @@ public function show(ShowApiCommitRequest $request, #[CurrentUser] ?User $curren */ public function store(CreateApiCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, CommitPageQuery $page): JsonResponse { - $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), $request->images(), $request->imageDescriptions(), poll: $request->poll(), replyRecipients: $request->replyRecipients(), organization: $request->organization()); + $source = CommitSource::forRequest($request); + + $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), $request->images(), $request->imageDescriptions(), poll: $request->poll(), replyRecipients: $request->replyRecipients(), organization: $request->organization(), source: $source); return CommitResource::make($page->get($commit, $user)) ->response() ->setStatusCode(201); } + /** Deletes an API commit and returns an empty success response. */ public function destroy(DeleteApiCommitRequest $request, Commit $commit, DeleteCommit $action): Response { $action->handle($commit); diff --git a/app/Http/Controllers/Api/ScheduledCommitController.php b/app/Http/Controllers/Api/ScheduledCommitController.php index ec9b1fe5d9..f6496f9dec 100644 --- a/app/Http/Controllers/Api/ScheduledCommitController.php +++ b/app/Http/Controllers/Api/ScheduledCommitController.php @@ -6,6 +6,7 @@ use App\Actions\CreateScheduledCommit; use App\Actions\DeleteScheduledCommit; +use App\Enums\CommitSource; use App\Http\Requests\CreateApiScheduledCommitRequest; use App\Http\Requests\DeleteApiScheduledCommitRequest; use App\Http\Resources\ScheduledCommitResource; @@ -33,7 +34,9 @@ public function index(#[CurrentUser] User $user, ScheduledCommitsQuery $schedule */ public function store(CreateApiScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): JsonResponse { - $scheduled = $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization()); + $source = CommitSource::forRequest($request); + + $scheduled = $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization(), source: $source); return ScheduledCommitResource::make($scheduled) ->response() diff --git a/app/Http/Controllers/Cli/CommitController.php b/app/Http/Controllers/Cli/CommitController.php index a2e10a1d17..610da058f4 100644 --- a/app/Http/Controllers/Cli/CommitController.php +++ b/app/Http/Controllers/Cli/CommitController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Cli; use App\Actions\CreateCommit; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Http\Requests\CreateCliCommitRequest; use App\Models\User; @@ -13,9 +14,12 @@ final readonly class CommitController { + /** + * Creates a terminal commit and returns its URL, keeping the CLI source separate from signed app posts. + */ public function store(CreateCliCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action): Response { - $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), images: $request->images()); + $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), images: $request->images(), source: CommitSource::Cli); $url = route('commit.show', [$user, $commit]); diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index 02fc2bf2f7..c6b5362612 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -7,6 +7,7 @@ use App\Actions\CreateCommit; use App\Actions\DeleteCommit; use App\Dto\ThreadContinuation; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Http\Requests\CreateCommitRequest; use App\Http\Requests\DeleteCommitRequest; @@ -63,12 +64,15 @@ public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUs ])->withViewData(['preview' => $preview]); } + /** + * Creates a web commit with its request-derived source, then flashes the commit and any new achievements. + */ public function store(CreateCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, UserAchievementsQuery $achievements): RedirectResponse { $before = $achievements->unlockedBadgeNames($user); $organization = $request->organization(); - $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll(), organization: $organization); + $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll(), organization: $organization, source: CommitSource::forRequest($request)); $unlocked = $achievements->unlockedBadgeNames($user)->diff($before)->values(); @@ -92,6 +96,7 @@ public function store(CreateCommitRequest $request, #[CurrentUser] User $user, C return back(); } + /** Deletes a commit and returns its restoration window to the frontend. */ public function destroy(DeleteCommitRequest $request, Commit $commit, DeleteCommit $action): RedirectResponse { $commit = $action->handle($commit); diff --git a/app/Http/Controllers/CommitReplyController.php b/app/Http/Controllers/CommitReplyController.php index afb50bbefb..f27304a4a8 100644 --- a/app/Http/Controllers/CommitReplyController.php +++ b/app/Http/Controllers/CommitReplyController.php @@ -6,6 +6,7 @@ use App\Actions\CreateCommit; use App\Actions\DeleteCommit; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Http\Requests\CreateCommitReplyRequest; use App\Http\Requests\DeleteCommitReplyRequest; @@ -40,7 +41,7 @@ public function index(ListCommitRepliesRequest $request, #[CurrentUser] ?User $u public function store(CreateCommitReplyRequest $request, #[CurrentUser] User $user, Commit $commit, CreateCommit $action, CommitThreadQuery $thread): JsonResponse { $organization = $request->organization(); - $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), replyRecipients: $request->replyRecipients(), organization: $organization); + $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), replyRecipients: $request->replyRecipients(), organization: $organization, source: CommitSource::forRequest($request)); $parent = $thread->get($commit, $user); return response()->json([ @@ -61,6 +62,7 @@ public function store(CreateCommitReplyRequest $request, #[CurrentUser] User $us ], 201); } + /** Deletes a reply and returns its updated state to the thread. */ public function destroy(DeleteCommitReplyRequest $request, Commit $commit, Commit $reply, DeleteCommit $action): JsonResponse { $reply = $action->handle($reply); diff --git a/app/Http/Controllers/ScheduledCommitController.php b/app/Http/Controllers/ScheduledCommitController.php index 12cf0ac1f8..df9924d764 100644 --- a/app/Http/Controllers/ScheduledCommitController.php +++ b/app/Http/Controllers/ScheduledCommitController.php @@ -6,6 +6,7 @@ use App\Actions\CreateScheduledCommit; use App\Actions\DeleteScheduledCommit; +use App\Enums\CommitSource; use App\Enums\UserUpdate; use App\Http\Requests\CreateScheduledCommitRequest; use App\Http\Requests\DeleteScheduledCommitRequest; @@ -24,6 +25,7 @@ final readonly class ScheduledCommitController { + /** Lists the user's scheduled posts with live updates when the queue changes. */ public function index(Request $request, #[CurrentUser] User $user, ScheduledCommitsQuery $scheduled): Response { return Inertia::render('scheduled-commits/index', [ @@ -32,9 +34,12 @@ public function index(Request $request, #[CurrentUser] User $user, ScheduledComm ]); } + /** + * Schedules a post with the current app source so it can retain that source when published later. + */ public function store(CreateScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): RedirectResponse { - $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization()); + $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization(), source: CommitSource::forRequest($request)); Inertia::flash('scheduled', true); Inertia::flash('toast', ['type' => 'success', 'message' => 'Commit scheduled. Only you can see it until it is submitted.']); @@ -42,6 +47,7 @@ public function store(CreateScheduledCommitRequest $request, #[CurrentUser] User return back(); } + /** Cancels a scheduled post, or explains why it can no longer be canceled. */ public function destroy(DeleteScheduledCommitRequest $request, #[CurrentUser] User $user, ScheduledCommit $scheduledCommit, DeleteScheduledCommit $action): RedirectResponse { $canceled = $action->handle($user, $scheduledCommit); diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index 31dc2a0585..7e4bec8878 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -59,6 +59,7 @@ public function toArray(Request $request): array 'parent_id' => $isSigned ? $this->parent_id : $this->parent?->id, 'html_url' => route('commit.show', [$this->author, $this->resource]), 'created_at' => $this->created_at->toIso8601ZuluString(), + ...($this->source === null ? [] : ['source' => $this->source->value]), 'stars_count' => $this->whenCounted('stars'), 'replies_count' => $this->whenCounted('replies'), 'link_preview' => $this->whenLoaded('linkPreview'), diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 805af50de6..c011e31ec2 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Queries\CommitBuilder; use App\Services\CommitBodies; @@ -33,6 +34,7 @@ * @property-read string $body * @property-read array|null $mentions * @property-read array|null $organization_mentions + * @property-read CommitSource|null $source * @property-read CommitStatus $status * @property-read string|null $link_preview_id * @property-read string|null $quoted_commit_id @@ -127,6 +129,7 @@ public function casts(): array 'mentions' => 'array', 'organization_mentions' => 'array', 'status' => CommitStatus::class, + 'source' => CommitSource::class, 'link_preview_id' => 'string', 'quoted_commit_id' => 'string', 'ai_decision' => CommitStatus::class, diff --git a/app/Models/ScheduledCommit.php b/app/Models/ScheduledCommit.php index 5bd7170cef..9f18e55185 100644 --- a/app/Models/ScheduledCommit.php +++ b/app/Models/ScheduledCommit.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\CommitSource; use Carbon\CarbonInterface; use Database\Factories\ScheduledCommitFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -17,6 +18,7 @@ * @property-read string $user_id * @property-read string|null $author_id * @property-read string|null $commit_id + * @property-read CommitSource|null $source * @property-read string|null $body * @property-read array{options: list, duration: int}|null $poll * @property-read list|null $images @@ -28,7 +30,7 @@ * @property-read Profile|null $author * @property-read Commit|null $commit */ -#[Fillable(['user_id', 'author_id', 'body', 'poll', 'images', 'link_preview_url', 'scheduled_at'])] +#[Fillable(['user_id', 'author_id', 'body', 'poll', 'images', 'link_preview_url', 'source', 'scheduled_at'])] final class ScheduledCommit extends Model { /** @use HasFactory */ @@ -41,6 +43,7 @@ public function casts(): array { return [ 'poll' => 'array', + 'source' => CommitSource::class, 'images' => 'array', 'scheduled_at' => 'immutable_datetime', 'submitted_at' => 'immutable_datetime', diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 34f06f2918..497950a80e 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -29,7 +29,7 @@ final class CommitBuilder extends Builder * Every column but the embedding, in the table's order. */ private const array COLUMNS_BUT_EMBEDDING = [ - 'id', 'user_id', 'body', 'created_at', 'updated_at', 'status', 'parent_id', 'deleted_at', 'link_preview_id', + 'id', 'user_id', 'body', 'created_at', 'updated_at', 'status', 'source', 'parent_id', 'deleted_at', 'link_preview_id', 'quoted_commit_id', 'mentions', 'ai_decision', 'ai_categories', 'root_id', 'hot_score', 'pasteis_count', 'author_id', 'headlines_classified_at', ]; diff --git a/database/migrations/2026_11_02_120000_add_source_to_commits_and_scheduled_commits.php b/database/migrations/2026_11_02_120000_add_source_to_commits_and_scheduled_commits.php new file mode 100644 index 0000000000..ca750225ed --- /dev/null +++ b/database/migrations/2026_11_02_120000_add_source_to_commits_and_scheduled_commits.php @@ -0,0 +1,21 @@ +string('source', 16)->nullable(); + }); + + Schema::table('scheduled_commits', function (Blueprint $table): void { + $table->string('source', 16)->nullable(); + }); + } +}; diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index edd740d31f..a02dd1cc44 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -63,8 +63,24 @@ import { shortHash, } from '@/lib/utils'; import { show } from '@/routes/user'; -import type { Commit, CursorPaginated, ThreadCommit } from '@/types'; +import type { + Commit, + CommitSource, + CursorPaginated, + ThreadCommit, +} from '@/types'; +const sourceLabels: Record = { + web: 'Web', + ios: 'iOS', + android: 'Android', + lite_mobile: 'Lite', + macos: 'macOS', + cli: 'CLI', + api: 'App', +}; + +/** Draws a commit with its thread graph, actions, and posting source when available. */ export function CommitCard({ commit, previousLane, @@ -404,6 +420,16 @@ export function CommitCard({ + {commit.source && ( + + Β· via {sourceLabels[commit.source]} + + )} + diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 73c8df690e..0b8b2e0b4c 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -3,6 +3,8 @@ import type { Organization } from '@/types/organization'; export type CommitStatus = 'pending' | 'approved' | 'rejected'; +export type CommitSource = 'web' | 'ios' | 'android' | 'lite_mobile' | 'macos' | 'cli' | 'api'; + export type EmbedKind = 'video' | 'audio' | 'code'; export type Embed = { @@ -95,6 +97,7 @@ export type Commit = { preview_segments: BodySegment[] | null; translation?: CommitTranslation | null; status: CommitStatus; + source?: CommitSource | null; is_checking?: boolean; created_at: string; updated_at: string; diff --git a/tests/Feature/CommitSourceTest.php b/tests/Feature/CommitSourceTest.php new file mode 100644 index 0000000000..a4996fae4a --- /dev/null +++ b/tests/Feature/CommitSourceTest.php @@ -0,0 +1,104 @@ +create(); + $parent = Commit::factory()->create(); + $browser = ['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)']; + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Created in Safari.', + 'source' => 'ios', + ], $browser)->assertSessionHasNoErrors(); + + $this->postJson(route('commit-reply.store', $parent), [ + 'body' => 'Replied in Safari.', + 'source' => 'android', + ], $browser)->assertCreated(); + + expect($user->commits()->oldest()->get()->pluck('source')->all()) + ->toBe([CommitSource::Web, CommitSource::Web]); +}); + + +it('recognizes the lite mobile web view as distinct from a normal browser', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user)->withHeader('User-Agent', 'Mozilla/5.0 DevNullLite/1.0.0') + ->post(route('commit.store'), ['body' => 'Sent from the lite app.']) + ->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->source)->toBe(CommitSource::LiteMobile); +}); + +it('records the approved app platform rather than a client-provided source', function (?string $appId, CommitSource $source): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + Device::query()->where('user_id', $user->id)->sole()->forceFill(['app_id' => $appId])->save(); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), [ + 'body' => 'Posted from the app.', + 'source' => 'web', + ])->assertCreated()->assertJsonPath('data.source', $source->value); + + expect($user->commits()->sole()->source)->toBe($source); +})->with([ + 'iOS NativePHP' => ['ios:com.nativephp.devnull', CommitSource::Ios], + 'Android NativePHP' => ['android:com.nativephp.devnull', CommitSource::Android], + 'unrecognized iOS app' => ['ios:sh.dev-null.app', CommitSource::Api], + 'mac app id alone is not enough' => ['macos:sh.dev-null.mac', CommitSource::Api], + 'older app without an id' => [null, CommitSource::Api], + 'unknown app platform' => ['windows:sh.dev-null.app', CommitSource::Api], +]); + +it('keeps the original posting source when a web commit is scheduled', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('scheduled-commit.store'), [ + 'body' => 'Scheduled from the browser.', + 'scheduled_at' => now()->addHour()->toIso8601String(), + ])->assertSessionHasNoErrors(); + + $scheduled = ScheduledCommit::query()->sole(); + + expect($scheduled->source)->toBe(CommitSource::Web); + + expect(resolve(UpdateScheduledCommitSubmittedAt::class)->handle($scheduled->id, early: true))->toBeTrue(); + expect(Commit::query()->sole()->source)->toBe(CommitSource::Web); +}); + +it('keeps the approved app platform when a native commit is scheduled', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + Device::query()->where('user_id', $user->id)->sole()->forceFill(['app_id' => 'android:com.nativephp.devnull'])->save(); + + signedApiRequest($keyPair, 'POST', route('api-scheduled-commit.store'), [ + 'body' => 'Scheduled from Android.', + 'scheduled_at' => now()->addHour()->toIso8601String(), + ])->assertCreated(); + + $scheduled = ScheduledCommit::query()->sole(); + + expect($scheduled->source)->toBe(CommitSource::Android); + + expect(resolve(UpdateScheduledCommitSubmittedAt::class)->handle($scheduled->id, early: true))->toBeTrue(); + expect(Commit::query()->sole()->source)->toBe(CommitSource::Android); +}); + +it('keeps the source of existing commits unknown without guessing', function (): void { + $commit = Commit::factory()->create(); + + expect($commit->source)->toBeNull(); + + $this->getJson(route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonMissingPath('data.source'); +}); diff --git a/tests/Feature/Controllers/Cli/CommitControllerTest.php b/tests/Feature/Controllers/Cli/CommitControllerTest.php index 295675a23e..5686ef9888 100644 --- a/tests/Feature/Controllers/Cli/CommitControllerTest.php +++ b/tests/Feature/Controllers/Cli/CommitControllerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Contracts\Services\GitHubProvider; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Enums\ModerationReason; use App\Http\Middleware\AuthenticateSshSignature; @@ -82,7 +83,8 @@ function imageHashes(array $images): array ->assertCookieMissing(config('session.cookie')); expect($commit->body)->toBe('It was DNS.') - ->and($commit->status)->toBe(CommitStatus::Pending); + ->and($commit->status)->toBe(CommitStatus::Pending) + ->and($commit->source)->toBe(CommitSource::Cli); }); it('collapses runs of blank lines in a signed commit', function (): void { diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 1b043d513a..f999e91cfd 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -5,6 +5,7 @@ use App\Actions\CreateCommitReport; use App\Actions\CreateCommitTranslation; use App\Actions\CreateExport; +use App\Enums\CommitSource; use App\Actions\DeleteUser; use App\Actions\UpdateDevicePushToken; use App\Contracts\Services\TranslationProvider; @@ -92,7 +93,7 @@ $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); expect($data)->toBe([ - 'schema_version' => 8, + 'schema_version' => 9, 'exported_at' => now()->toIso8601String(), 'profile' => [ 'github_id' => $user->github_id, @@ -224,7 +225,7 @@ $user = User::factory()->create(); $other = User::factory()->create(); $parent = Commit::factory()->for($other)->rejected()->create(['body' => 'Another developer private parent body']); - $approved = Commit::factory()->for($user)->create(['body' => "Shipped Unicode support πŸš€\nNext: docs.", 'pasteis_count' => 3]); + $approved = Commit::factory()->for($user)->create(['body' => "Shipped Unicode support πŸš€\nNext: docs.", 'pasteis_count' => 3, 'source' => CommitSource::Web]); $approved->headlines()->create(['name' => 'Unicode support ships']); $pending = Commit::factory()->for($user)->pending()->create(); $rejected = Commit::factory()->for($user)->rejected()->create(); @@ -259,6 +260,7 @@ 'translated_at' => now()->getTimestampMs(), ], 'status' => 'approved', + 'source' => 'web', 'pasteis_count' => 3, 'images' => [], 'created_at' => $approved->created_at->toIso8601String(), diff --git a/tests/Feature/OrganizationCommitsTest.php b/tests/Feature/OrganizationCommitsTest.php index f014940322..b4a477c189 100644 --- a/tests/Feature/OrganizationCommitsTest.php +++ b/tests/Feature/OrganizationCommitsTest.php @@ -8,6 +8,7 @@ use App\Actions\DeleteOrganization; use App\Actions\DeleteUser; use App\Actions\UpdateCommitStatuses; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Enums\Feature; use App\Enums\Log; @@ -179,7 +180,7 @@ }); it('exports the commits someone posted for an organization', function (): void { - $commit = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id, 'body' => 'Laravel 13 is out']); + $commit = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id, 'body' => 'Laravel 13 is out', 'source' => CommitSource::Ios]); $export = resolve(CreateExport::class)->handle($this->member); @@ -190,6 +191,7 @@ 'quoted_commit_id' => null, 'body' => 'Laravel 13 is out', 'status' => 'approved', + 'source' => 'ios', 'images' => [], 'created_at' => $commit->created_at->toIso8601String(), 'deleted_at' => null, diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index 8a0ed8401e..921bfb7955 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -9,6 +9,7 @@ use App\Actions\UpdateCommitHotScores; use App\Contracts\Services\TranslationProvider; use App\Dto\ThreadEngagement; +use App\Enums\CommitSource; use App\Enums\OrganizationRole; use App\Models\Commit; use App\Models\CommitImage; @@ -54,6 +55,11 @@ $reply = Commit::factory()->for($commit, 'parent')->create(); $unrelated = Commit::factory()->create(); $organization = Organization::factory()->create(); + $organizationCommit = Commit::factory()->create([ + 'user_id' => $user->id, + 'author_id' => $organization->id, + 'source' => CommitSource::Android, + ]); resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $organization->id]); resolve(CreateCommitTopics::class)->handle($reply, ['laravel' => $organization->id]); resolve(CreateCommitTopics::class)->handle($unrelated, ['laravel' => $organization->id]); @@ -63,7 +69,10 @@ resolve(DeleteUser::class)->handle($user); - expect(Commit::withTrashed()->pluck('id')->all())->toBe([$unrelated->id]) + expect(Commit::withTrashed()->pluck('id')->sort()->values()->all())->toBe(collect([$unrelated->id, $organizationCommit->id])->sort()->values()->all()) + ->and($organizationCommit->refresh()->user_id)->toBeNull() + ->and($organizationCommit->author_id)->toBe($organization->id) + ->and($organizationCommit->source)->toBeNull() ->and($headline->commits->modelKeys())->toBe([$unrelated->id]) ->and($headline->refresh()->summary)->toBeNull() ->and($headline->summary_created_at)->toBeNull() @@ -129,6 +138,7 @@ 'mentions' => [$mentioned->handle => $mentioned->id], 'embedding' => embeddingVector(0), 'headlines_classified_at' => now(), + 'source' => CommitSource::Android, ]); resolve(CreateCommitTopics::class)->handle($reply, ['laravel' => Organization::factory()->create()->id]); $reply->headlines()->create(['name' => 'Laravel adds a new feature']); @@ -148,7 +158,8 @@ ->and($reply->headlines()->exists())->toBeFalse() ->and($tombstones->pluck('embedding')->all())->toBe([null, null]) ->and($tombstones->pluck('headlines_classified_at')->all())->toBe([null, null]) - ->and($tombstones->pluck('link_preview_id')->all())->toBe([null, null]); + ->and($tombstones->pluck('link_preview_id')->all())->toBe([null, null]) + ->and($tombstones->pluck('source')->all())->toBe([null, null]); }); it('erases what deleted accounts wrote from the tombstones they already left', function (): void { @@ -368,7 +379,7 @@ Storage::fake(); $user = User::factory()->create(); - $commits = Commit::factory()->for($user)->count(10)->create(); + $commits = Commit::factory()->for($user)->count(11)->create(); $commits->each(fn (Commit $commit): Commit => Commit::factory()->for($commit, 'parent')->create()); $repliedTo = Commit::factory()->create(); @@ -378,7 +389,7 @@ resolve(DeleteUser::class)->handle($user); - expect(collect(DB::getQueryLog())->max(fn (array $query): int => count($query['bindings'])))->toBeLessThan(10) + expect(collect(DB::getQueryLog())->max(fn (array $query): int => count($query['bindings'])))->toBeLessThan($commits->count()) ->and(Commit::query()->pluck('id')->sort()->values()->all())->toBe(collect([$repliedTo->id, $otherReply->id])->sort()->values()->all()); }); diff --git a/tests/Unit/Enums/CommitSourceTest.php b/tests/Unit/Enums/CommitSourceTest.php new file mode 100644 index 0000000000..783e909af2 --- /dev/null +++ b/tests/Unit/Enums/CommitSourceTest.php @@ -0,0 +1,33 @@ + $agent]); + + if ($appId !== null) { + $request->attributes->set(Device::class, new Device(['app_id' => $appId])); + } + + expect(ActiveDayApp::forRequest($request))->toBe($app) + ->and(CommitSource::forRequest($request))->toBe($source); +})->with([ + 'web browser' => ['/log/latest', null, 'Mozilla/5.0 (iPhone)', ActiveDayApp::Web, CommitSource::Web], + 'mac user-agent on a web route is still web' => ['/log/latest', null, 'DevNull-Mac/1.0', ActiveDayApp::Web, CommitSource::Web], + 'lite web view' => ['/log/latest', null, 'Mozilla/5.0 DevNullLite/1.0', ActiveDayApp::LiteMobile, CommitSource::LiteMobile], + 'native iOS app' => ['/api/v1/me', 'ios:com.nativephp.devnull', null, ActiveDayApp::NativeMobile, CommitSource::Ios], + 'native Android app' => ['/api/v1/me', 'android:com.nativephp.devnull', null, ActiveDayApp::NativeMobile, CommitSource::Android], + 'lite signed app' => ['/api/v1/me', MobileAuthorization::LiteMobileAppId, null, ActiveDayApp::LiteMobile, CommitSource::LiteMobile], + 'mac desktop user-agent' => ['/api/v1/me', null, 'DevNull-Mac/1.0', ActiveDayApp::Mac, CommitSource::Macos], + 'mac app id alone without user-agent' => ['/api/v1/me', 'macos:sh.dev-null.mac', null, ActiveDayApp::Other, CommitSource::Api], + 'unrecognized iOS app' => ['/api/v1/me', 'ios:sh.dev-null.app', null, ActiveDayApp::Other, CommitSource::Api], + 'older device without an app id' => ['/api/v1/me', null, 'curl/8', ActiveDayApp::Other, CommitSource::Api], + 'spoofed lite user-agent on API' => ['/api/v1/me', null, 'DevNullLite/1.0', ActiveDayApp::Other, CommitSource::Api], + 'other app' => ['/api/v1/me', 'windows:sh.dev-null.app', null, ActiveDayApp::Other, CommitSource::Api], +]); diff --git a/tests/Unit/Models/CommitTest.php b/tests/Unit/Models/CommitTest.php index 17baaae31e..a6d7a6fcdb 100644 --- a/tests/Unit/Models/CommitTest.php +++ b/tests/Unit/Models/CommitTest.php @@ -21,6 +21,7 @@ 'created_at', 'updated_at', 'status', + 'source', 'parent_id', 'deleted_at', 'link_preview_id',