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
11 changes: 11 additions & 0 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ This file documents the platform's features, stack, and stories. Feature entries
- [Quoting commits](#quoting-commits)
- [Polls and voting](#polls-and-voting)
- [Scheduled commits](#scheduled-commits)
- [Events](#events)
- [Deleting commits and undo](#deleting-commits-and-undo)
- [Stars](#stars)
- [Starred commits](#starred-commits)
Expand Down Expand Up @@ -308,6 +309,16 @@ The scheduler checks due posts every minute. Scheduling submits the post for the

Sources: [schedule validation](app/Http/Requests/CreateScheduledCommitRequest.php), [outstanding limit](app/Rules/ScheduledCommitLimit.php), [scheduled-commit controller](app/Http/Controllers/ScheduledCommitController.php), [scheduler](routes/console.php).

### Events

Host a meetup, a stream, a workshop or a hackathon from **Events** in the navigation (in the **Menu** on mobile) with **New event**. An event has a title of up to **100 characters**, a description, a start and an end at most **7 days** apart and less than a year ahead, and a place, a link, or both: it is labelled **In person**, **Online**, or **Hybrid** accordingly. An optional agenda holds up to **12 timed talks**, each during the event.

The description is posted as an ordinary commit announcing the event, so the event goes through the same moderation as any commit, title, place, link and agenda included, and shows up in logs as a small card on that commit. Until it is approved, only its host sees it. Replies to the announcement are the event's discussion, and deleting the announcement deletes the event. Event details cannot be edited, like commits: the host can only **cancel** an event before it ends, which keeps its page and discussion and marks it **Cancelled**.

Event pages are laid out like a GitHub issue: a state badge (**Upcoming**, **Happening now**, **Ended**, or **Cancelled**), the details, the people going, and the agenda as a milestone whose progress bar ticks off each talk once the next one starts. Signed-in readers answer with a button shaped like GitHub's Watch button: **Going** puts their avatar among the people going, **Interested** is only visible to them, and **Not going** removes their answer. Responses close once an event ends or is cancelled, and the host, shown as hosting, isn't counted among the people going. People across a block are left out of the avatars. The **Events** page lists upcoming events soonest first and past or cancelled ones latest first, without muted hosts; each profile has an **Events** tab with the events that person hosts. Hosted events and your own responses are in your data export and are deleted with your account.

Sources: [event controller](app/Http/Controllers/EventController.php), [event validation](app/Http/Requests/CreateEventRequest.php), [response rules](app/Rules/OpenEvent.php), [event page](resources/js/pages/event/show.tsx).

### Deleting commits and undo

Delete your own commit or reply from its controls. An **Undo** action is available for **30 seconds**, restoring the same commit without sending its notifications again. Deletion removes the post from normal lists, but visible replies can remain under an authorless `[deleted]` placeholder. After the undo window, the deleted post's text, mentions, images, and poll are erased permanently; restoring them is no longer possible. Link previews of the deleted post are cleared too. Deleting a post is different from permanently deleting your account and all its top-level threads.
Expand Down
1 change: 1 addition & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ What exists, one line each (details in the knowledge base):
- **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries).
- **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube.
- **Organization pages:** GitHub organizations get a page mirrored from GitHub once an approved commit mentions them, and `@organization` mentions link to it.
- **Events:** meetups and streams announced by a commit, with an issue-like page, an agenda, and Going or Interested responses.
- **Remotes:** private one-to-one and group messages (orgs), and calls, one-to-one or in an org.
- **Notifications:** in-app activity and on-site Remotes alerts by default; off-site browser and PWA notifications require a separate opt-in. Sound is controlled independently and plays while the site is open.
- **Search:** developers by name, commits by meaning.
Expand Down
45 changes: 45 additions & 0 deletions app/Actions/CreateEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Dto\NewEvent;
use App\Models\Event;
use App\Models\User;
use App\Services\EmojiShortcodes;
use Illuminate\Support\Facades\DB;

final readonly class CreateEvent
{
public function __construct(private CreateCommit $createCommit, private EmojiShortcodes $emojiShortcodes)
{
//
}

/**
* An event is announced by a commit, whose body is its description: the event goes through moderation with it, is
* discussed in its replies, and is deleted along with it.
*/
public function handle(User $user, string $body, NewEvent $newEvent): Event
{
return DB::transaction(function () use ($user, $body, $newEvent): Event {
$commit = $this->createCommit->handle($user, $body);

$event = $commit->event()->create([
'title' => $this->emojiShortcodes->replace($newEvent->title),
'starts_at' => $newEvent->startsAt,
'ends_at' => $newEvent->endsAt,
'location' => $newEvent->location,
'url' => $newEvent->url,
]);

$event->agendaItems()->createMany(array_map(fn (array $item): array => [
'label' => $this->emojiShortcodes->replace($item['label']),
'starts_at' => $item['starts_at'],
], $newEvent->agenda));

return $event->setRelation('commit', $commit);
});
}
}
25 changes: 25 additions & 0 deletions app/Actions/CreateEventCancellation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Event;
use Illuminate\Support\Facades\DB;

final readonly class CreateEventCancellation
{
public function __construct(private CreateEventCancellationNotifications $createEventCancellationNotifications)
{
//
}

public function handle(Event $event): void
{
DB::transaction(function () use ($event): void {
$event->update(['cancelled_at' => now()]);

$this->createEventCancellationNotifications->handle($event);
});
}
}
41 changes: 41 additions & 0 deletions app/Actions/CreateEventCancellationNotifications.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\EventResponse;
use App\Enums\NotificationType;
use App\Models\Event;
use App\Models\EventAttendee;
use App\Models\Notification;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;

final readonly class CreateEventCancellationNotifications
{
/**
* Only the people going hear about it: an interested answer is private, so nothing acts on it. The host is the actor,
* and the announcement commit is the subject, so the notification goes once the commit does.
*/
public function handle(Event $event): void
{
$hostId = $event->commit?->user_id;

// A statement takes at most 65,535 bound values, so a large event's notifications go in chunks.
$event->attendees()
->where('response', EventResponse::Going)
->chunkById(500, fn (Collection $attendees): int => Notification::query()->insertOrIgnore($attendees
->map(fn (EventAttendee $attendee): array => [
'id' => Str::uuid7()->toString(),
'recipient_id' => $attendee->user_id,
'actor_id' => $hostId,
'type' => NotificationType::EventCancelled->value,
'commit_id' => $event->commit_id,
'key' => 'event_cancelled:'.$event->id,
'created_at' => now(),
'updated_at' => now(),
])
->all()));
}
}
53 changes: 53 additions & 0 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Device;
use App\Models\Event;
use App\Models\EventAgendaItem;
use App\Models\Message;
use App\Models\Poll;
use App\Models\PollOption;
Expand All @@ -31,6 +33,8 @@
/**
* @phpstan-type ExportPoll array{commit_id: string, duration_minutes: int, opened_at: string|null, options: list<string>, created_at: string}
* @phpstan-type ExportPollVote array{commit_id: string, option: string, created_at: string}
* @phpstan-type ExportEvent array{commit_id: string, title: string, starts_at: string, ends_at: string, location: string|null, url: string|null, agenda: list<array{label: string, starts_at: string}>, cancelled_at: string|null, created_at: string}
* @phpstan-type ExportEventResponse array{commit_id: string, response: string, created_at: string, updated_at: string}
* @phpstan-type ExportMessage array{to: string|null, body: string, voice: string|null, created_at: string}
* @phpstan-type ExportUser array{handle: string}
* @phpstan-type UserExport array{
Expand All @@ -41,6 +45,8 @@
* commits: Generator<int, array<string, mixed>>,
* polls: Generator<int, ExportPoll>,
* poll_votes: Generator<int, ExportPollVote>,
* events: Generator<int, ExportEvent>,
* event_responses: Generator<int, ExportEventResponse>,
* stars: Generator<int, string>,
* following: Generator<int, ExportUser>,
* followers: Generator<int, ExportUser>,
Expand Down Expand Up @@ -111,6 +117,8 @@ public function handle(User $user): array
'commits' => $this->commits($user),
'polls' => $this->polls($user),
'poll_votes' => $this->pollVotes($user),
'events' => $this->events($user),
'event_responses' => $this->eventResponses($user),
'stars' => $this->stars($user),
'following' => $this->handles($user->following()),
'followers' => $this->handles($user->followers()),
Expand Down Expand Up @@ -262,6 +270,51 @@ private function activeDays(User $user): Generator
}
}

/**
* @return Generator<int, ExportEvent>
*/
private function events(User $user): Generator
{
$events = Event::query()
->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->where('user_id', $user->id))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
->with('agendaItems')
->lazyById(500);

foreach ($events as $event) {
yield [
'commit_id' => $event->commit_id,
'title' => $event->title,
'starts_at' => $event->starts_at->toIso8601String(),
'ends_at' => $event->ends_at->toIso8601String(),
'location' => $event->location,
'url' => $event->url,
'agenda' => array_values($event->agendaItems->map(fn (EventAgendaItem $item): array => [
'label' => $item->label,
'starts_at' => $item->starts_at->toIso8601String(),
])->all()),
'cancelled_at' => $event->cancelled_at?->toIso8601String(),
'created_at' => $event->created_at->toIso8601String(),
];
}
}

/**
* Only the user's own responses: never who else is going.
*
* @return Generator<int, ExportEventResponse>
*/
private function eventResponses(User $user): Generator
{
foreach ($user->eventAttendances()->with('event')->lazyById(500) as $attendance) {
yield [
'commit_id' => $attendance->event->commit_id,
'response' => $attendance->response->value,
'created_at' => $attendance->created_at->toIso8601String(),
'updated_at' => $attendance->updated_at->toIso8601String(),
];
}
}

/**
* @return Generator<int, string>
*/
Expand Down
3 changes: 2 additions & 1 deletion app/Actions/DeleteCommitContent.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
/**
* Only a commit whose undo window has closed loses its content, so one restored in time, or deleted again since, keeps it.
* The row stays as a tombstone with its author, for its replies, quotes and reports, but its body, mentions, embedding,
* link preview, poll and images are erased for good. So are the previews of links to it, which hold its body as their
* link preview, poll, event and images are erased for good. So are the previews of links to it, which hold its body as their
* title and description, and fall off whatever linked to it.
*/
public function handle(Commit $commit): void
Expand All @@ -30,6 +30,7 @@ public function handle(Commit $commit): void

$commit->images()->delete();
$commit->poll()->delete();
$commit->event()->delete();

LinkPreview::query()->where('url', 'like', "%/commits/{$commit->id}%")->delete();

Expand Down
16 changes: 16 additions & 0 deletions app/Actions/DeleteEventAttendance.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Event;
use App\Models\User;

final readonly class DeleteEventAttendance
{
public function handle(User $user, Event $event): void
{
$event->attendees()->where('user_id', $user->id)->delete();
}
}
5 changes: 4 additions & 1 deletion app/Actions/UpdateCommitAiDecision.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Enums\CommitStatus;
use App\Models\Commit;
use App\Models\CommitImage;
use App\Models\EventAgendaItem;
use App\Models\PollOption;
use Illuminate\Support\Facades\Storage;

Expand All @@ -26,7 +27,7 @@ public function __construct(
}

/**
* Asks the AI whether the commit, images included, should be approved. The decision is mostly a hint for
* Asks the AI whether the commit, images and any event it announces included, should be approved. The decision is mostly a hint for
* moderators: a clean commit is approved, and it is rejected, without a reason, when the AI is all but certain.
* Failures, such as timeouts or rate limits, are thrown, so the caller can try again later.
*/
Expand Down Expand Up @@ -69,6 +70,8 @@ private function text(Commit $commit): string
return collect([$commit->body])
->merge($commit->images->map(fn (CommitImage $image): ?string => $image->description))
->merge($commit->poll?->options->map(fn (PollOption $option): string => $option->label) ?? [])
->merge([$commit->event?->title, $commit->event?->location, $commit->event?->url])
->merge($commit->event?->agendaItems->map(fn (EventAgendaItem $item): string => $item->label) ?? [])
->filter(fn (?string $part): bool => $part !== null && $part !== '')
->implode("\n\n");
}
Expand Down
22 changes: 22 additions & 0 deletions app/Actions/UpdateEventAttendance.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\EventResponse;
use App\Models\Event;
use App\Models\EventAttendee;
use App\Models\User;

final readonly class UpdateEventAttendance
{
public function handle(User $user, Event $event, EventResponse $response): void
{
EventAttendee::query()->upsert(
[['event_id' => $event->id, 'user_id' => $user->id, 'response' => $response->value, 'created_at' => now(), 'updated_at' => now()]],
['event_id', 'user_id'],
['response', 'updated_at'],
);
}
}
24 changes: 24 additions & 0 deletions app/Dto/NewEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace App\Dto;

use Carbon\CarbonImmutable;

final readonly class NewEvent
{
/**
* @param list<array{label: string, starts_at: CarbonImmutable}> $agenda
*/
public function __construct(
public string $title,
public CarbonImmutable $startsAt,
public CarbonImmutable $endsAt,
public ?string $location,
public ?string $url,
public array $agenda = [],
) {
//
}
}
11 changes: 11 additions & 0 deletions app/Enums/EventResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum EventResponse: string
{
case Going = 'going';
case Interested = 'interested';
}
13 changes: 13 additions & 0 deletions app/Enums/EventState.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum EventState: string
{
case Upcoming = 'upcoming';
case Live = 'live';
case Ended = 'ended';
case Cancelled = 'cancelled';
}
1 change: 1 addition & 0 deletions app/Enums/NotificationType.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ enum NotificationType: string
case Starred = 'starred';
case LeveledUp = 'leveled_up';
case FirstCommit = 'first_commit';
case EventCancelled = 'event_cancelled';
}
Loading