From 860e27c4e0b68c082becc9279191db18863cc932 Mon Sep 17 00:00:00 2001 From: Houssem Eddine BENABDALLAH Date: Thu, 22 Oct 2026 22:26:44 +0200 Subject: [PATCH 1/6] feat: host events with an issue-like page, a Watch-style going button and a milestone agenda --- KNOWLEDGE_BASE.md | 11 + PRODUCT.md | 1 + app/Actions/CreateEvent.php | 45 ++ app/Actions/CreateEventCancellation.php | 25 + .../CreateEventCancellationNotifications.php | 39 ++ app/Actions/CreateExport.php | 53 +++ app/Actions/DeleteCommitContent.php | 3 +- app/Actions/DeleteEventAttendance.php | 16 + app/Actions/UpdateCommitAiDecision.php | 5 +- app/Actions/UpdateEventAttendance.php | 22 + app/Dto/NewEvent.php | 24 + app/Enums/EventResponse.php | 11 + app/Enums/EventState.php | 13 + app/Enums/NotificationType.php | 1 + .../Controllers/EventAttendanceController.php | 31 ++ .../EventCancellationController.php | 20 + app/Http/Controllers/EventController.php | 86 ++++ .../Controllers/NotificationController.php | 2 +- app/Http/Controllers/UserController.php | 15 +- .../CreateEventCancellationRequest.php | 42 ++ app/Http/Requests/CreateEventRequest.php | 92 ++++ .../Requests/DeleteEventAttendanceRequest.php | 31 ++ app/Http/Requests/ListEventsRequest.php | 30 ++ app/Http/Requests/ShowEventRequest.php | 34 ++ app/Http/Requests/ShowUserRequest.php | 3 +- .../Requests/UpdateEventAttendanceRequest.php | 49 ++ app/Models/Commit.php | 9 + app/Models/Event.php | 141 ++++++ app/Models/EventAgendaItem.php | 51 +++ app/Models/EventAttendee.php | 59 +++ app/Models/User.php | 9 + app/Queries/CommitBuilder.php | 2 +- app/Queries/EventAttendeesQuery.php | 34 ++ app/Queries/EventsQuery.php | 68 +++ app/Queries/NotificationBuilder.php | 6 + app/Queries/NotificationGroupsQuery.php | 13 + app/Rules/EventDuration.php | 36 ++ app/Rules/OpenEvent.php | 31 ++ database/factories/EventAgendaItemFactory.php | 27 ++ database/factories/EventAttendeeFactory.php | 36 ++ database/factories/EventFactory.php | 55 +++ .../2026_10_22_120000_create_events_table.php | 27 ++ ...120001_create_event_agenda_items_table.php | 23 + ...22_120002_create_event_attendees_table.php | 24 + rector.php | 7 + resources/js/components/app-nav.tsx | 13 +- resources/js/components/commit-card.tsx | 7 + resources/js/components/commit-event.tsx | 92 ++++ resources/js/components/event-list.tsx | 117 +++++ .../js/components/event-response-button.tsx | 172 +++++++ resources/js/components/event-state-badge.tsx | 51 +++ resources/js/components/event-tile.tsx | 29 ++ resources/js/lib/event-dates.ts | 65 +++ resources/js/lib/event.ts | 61 +++ resources/js/pages/event/create.tsx | 317 +++++++++++++ resources/js/pages/event/index.tsx | 87 ++++ resources/js/pages/event/show.tsx | 429 ++++++++++++++++++ resources/js/pages/notification/index.tsx | 33 +- resources/js/pages/user/show.tsx | 26 +- resources/js/types/commit.ts | 2 + resources/js/types/event.ts | 30 ++ resources/js/types/index.ts | 1 + resources/js/types/notification.ts | 7 + routes/web.php | 22 + .../EventAttendanceControllerTest.php | 85 ++++ .../EventCancellationControllerTest.php | 69 +++ .../Controllers/EventControllerTest.php | 246 ++++++++++ .../Controllers/ExportControllerTest.php | 37 ++ .../NotificationControllerTest.php | 27 ++ .../Controllers/UserControllerTest.php | 20 +- .../Unit/Actions/DeleteCommitContentTest.php | 3 + tests/Unit/Actions/DeleteUserTest.php | 17 + tests/Unit/Enums/NotificationTypeTest.php | 1 + tests/Unit/EventProgress.test.ts | 82 ++++ tests/Unit/Jobs/ModerateCommitTest.php | 15 + tests/Unit/NotificationIndex.test.tsx | 29 ++ 76 files changed, 3531 insertions(+), 23 deletions(-) create mode 100644 app/Actions/CreateEvent.php create mode 100644 app/Actions/CreateEventCancellation.php create mode 100644 app/Actions/CreateEventCancellationNotifications.php create mode 100644 app/Actions/DeleteEventAttendance.php create mode 100644 app/Actions/UpdateEventAttendance.php create mode 100644 app/Dto/NewEvent.php create mode 100644 app/Enums/EventResponse.php create mode 100644 app/Enums/EventState.php create mode 100644 app/Http/Controllers/EventAttendanceController.php create mode 100644 app/Http/Controllers/EventCancellationController.php create mode 100644 app/Http/Controllers/EventController.php create mode 100644 app/Http/Requests/CreateEventCancellationRequest.php create mode 100644 app/Http/Requests/CreateEventRequest.php create mode 100644 app/Http/Requests/DeleteEventAttendanceRequest.php create mode 100644 app/Http/Requests/ListEventsRequest.php create mode 100644 app/Http/Requests/ShowEventRequest.php create mode 100644 app/Http/Requests/UpdateEventAttendanceRequest.php create mode 100644 app/Models/Event.php create mode 100644 app/Models/EventAgendaItem.php create mode 100644 app/Models/EventAttendee.php create mode 100644 app/Queries/EventAttendeesQuery.php create mode 100644 app/Queries/EventsQuery.php create mode 100644 app/Rules/EventDuration.php create mode 100644 app/Rules/OpenEvent.php create mode 100644 database/factories/EventAgendaItemFactory.php create mode 100644 database/factories/EventAttendeeFactory.php create mode 100644 database/factories/EventFactory.php create mode 100644 database/migrations/2026_10_22_120000_create_events_table.php create mode 100644 database/migrations/2026_10_22_120001_create_event_agenda_items_table.php create mode 100644 database/migrations/2026_10_22_120002_create_event_attendees_table.php create mode 100644 resources/js/components/commit-event.tsx create mode 100644 resources/js/components/event-list.tsx create mode 100644 resources/js/components/event-response-button.tsx create mode 100644 resources/js/components/event-state-badge.tsx create mode 100644 resources/js/components/event-tile.tsx create mode 100644 resources/js/lib/event-dates.ts create mode 100644 resources/js/lib/event.ts create mode 100644 resources/js/pages/event/create.tsx create mode 100644 resources/js/pages/event/index.tsx create mode 100644 resources/js/pages/event/show.tsx create mode 100644 resources/js/types/event.ts create mode 100644 tests/Feature/Controllers/EventAttendanceControllerTest.php create mode 100644 tests/Feature/Controllers/EventCancellationControllerTest.php create mode 100644 tests/Feature/Controllers/EventControllerTest.php create mode 100644 tests/Unit/EventProgress.test.ts diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index dc159419a8..1232b94ea2 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -29,6 +29,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) @@ -337,6 +338,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 is always 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. diff --git a/PRODUCT.md b/PRODUCT.md index 13dbe7437e..fb150d3cab 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -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. Their members post commits as the organization, which readers see from the organization alone. +- **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. diff --git a/app/Actions/CreateEvent.php b/app/Actions/CreateEvent.php new file mode 100644 index 0000000000..ae9c9b3b29 --- /dev/null +++ b/app/Actions/CreateEvent.php @@ -0,0 +1,45 @@ +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); + }); + } +} diff --git a/app/Actions/CreateEventCancellation.php b/app/Actions/CreateEventCancellation.php new file mode 100644 index 0000000000..bb14086f9b --- /dev/null +++ b/app/Actions/CreateEventCancellation.php @@ -0,0 +1,25 @@ +update(['cancelled_at' => now()]); + + $this->createEventCancellationNotifications->handle($event); + }); + } +} diff --git a/app/Actions/CreateEventCancellationNotifications.php b/app/Actions/CreateEventCancellationNotifications.php new file mode 100644 index 0000000000..e05eb6bda3 --- /dev/null +++ b/app/Actions/CreateEventCancellationNotifications.php @@ -0,0 +1,39 @@ +commit?->user_id; + + Notification::query()->insertOrIgnore($event->attendees() + ->where('response', EventResponse::Going) + ->get() + ->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()); + } +} diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index acab064116..45736e84c2 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -11,6 +11,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; @@ -32,6 +34,8 @@ /** * @phpstan-type ExportPoll array{commit_id: string, duration_minutes: int, opened_at: string|null, options: list, 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, 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{ @@ -43,6 +47,8 @@ * organization_commits: Generator>, * polls: Generator, * poll_votes: Generator, + * events: Generator, + * event_responses: Generator, * stars: Generator, * following: Generator, * followers: Generator, @@ -115,6 +121,8 @@ public function handle(User $user): array 'organization_commits' => $this->organizationCommits($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()), @@ -301,6 +309,51 @@ private function activeDays(User $user): Generator } } + /** + * @return Generator + */ + private function events(User $user): Generator + { + $events = Event::query() + ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->where('user_id', $user->id)) + ->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 + */ + 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 */ diff --git a/app/Actions/DeleteCommitContent.php b/app/Actions/DeleteCommitContent.php index 8e1882c326..a2b03a459e 100644 --- a/app/Actions/DeleteCommitContent.php +++ b/app/Actions/DeleteCommitContent.php @@ -15,7 +15,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 @@ -31,6 +31,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(); diff --git a/app/Actions/DeleteEventAttendance.php b/app/Actions/DeleteEventAttendance.php new file mode 100644 index 0000000000..f7d4b5074e --- /dev/null +++ b/app/Actions/DeleteEventAttendance.php @@ -0,0 +1,16 @@ +attendees()->where('user_id', $user->id)->delete(); + } +} diff --git a/app/Actions/UpdateCommitAiDecision.php b/app/Actions/UpdateCommitAiDecision.php index 195817be9b..6a755d48c7 100644 --- a/app/Actions/UpdateCommitAiDecision.php +++ b/app/Actions/UpdateCommitAiDecision.php @@ -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; @@ -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. */ @@ -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"); } diff --git a/app/Actions/UpdateEventAttendance.php b/app/Actions/UpdateEventAttendance.php new file mode 100644 index 0000000000..25090e13d2 --- /dev/null +++ b/app/Actions/UpdateEventAttendance.php @@ -0,0 +1,22 @@ +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'], + ); + } +} diff --git a/app/Dto/NewEvent.php b/app/Dto/NewEvent.php new file mode 100644 index 0000000000..e53bc9c1be --- /dev/null +++ b/app/Dto/NewEvent.php @@ -0,0 +1,24 @@ + $agenda + */ + public function __construct( + public string $title, + public CarbonImmutable $startsAt, + public CarbonImmutable $endsAt, + public ?string $location, + public ?string $url, + public array $agenda = [], + ) { + // + } +} diff --git a/app/Enums/EventResponse.php b/app/Enums/EventResponse.php new file mode 100644 index 0000000000..526fa6677a --- /dev/null +++ b/app/Enums/EventResponse.php @@ -0,0 +1,11 @@ +handle($user, $event, $request->eventResponse()); + + return back(); + } + + public function destroy(DeleteEventAttendanceRequest $request, #[CurrentUser] User $user, Event $event, DeleteEventAttendance $action): RedirectResponse + { + $action->handle($user, $event); + + return back(); + } +} diff --git a/app/Http/Controllers/EventCancellationController.php b/app/Http/Controllers/EventCancellationController.php new file mode 100644 index 0000000000..23c1a6708b --- /dev/null +++ b/app/Http/Controllers/EventCancellationController.php @@ -0,0 +1,20 @@ +handle($event); + + return back(); + } +} diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php new file mode 100644 index 0000000000..e7d0dd68f8 --- /dev/null +++ b/app/Http/Controllers/EventController.php @@ -0,0 +1,86 @@ + $request->tab(...), + 'events' => Inertia::scroll(fn (): CursorPaginator => $events->paginate($currentUser, past: $request->tab() === 'past')), + ]); + } + + public function create(): Response + { + return Inertia::render('event/create', [ + 'limits' => [ + 'title' => Event::MAX_TITLE_LENGTH, + 'location' => Event::MAX_LOCATION_LENGTH, + 'url' => Event::MAX_URL_LENGTH, + 'agendaItems' => Event::MAX_AGENDA_ITEMS, + 'agendaLabel' => Event::MAX_AGENDA_LABEL_LENGTH, + 'durationDays' => Event::MAX_DURATION_DAYS, + ], + ]); + } + + public function store(CreateEventRequest $request, #[CurrentUser] User $user, CreateEvent $action): RedirectResponse + { + $event = $action->handle($user, $request->string('body')->value(), $request->newEvent()); + + if ($event->commit?->status === CommitStatus::Pending) { + Inertia::flash('toast', [ + 'type' => 'info', + 'message' => 'Your event is pending review. Everyone will see it once it is approved.', + ]); + } + + return to_route('event.show', $event); + } + + /** + * The event reads like an issue: its details on top, and the commit announcing it, with its replies, as the discussion. + */ + public function show(ShowEventRequest $request, #[CurrentUser] ?User $currentUser, Event $event, CommitPageQuery $page, CommitRepliesQuery $replies, EventAttendeesQuery $attendees): Response + { + $commit = $event->commit; + assert($commit instanceof Commit); + + return Inertia::render('event/show', [ + 'event' => fn (): Event => $event->load('agendaItems')->loadCount(['attendees as going_count' => fn (Builder $query): Builder => $query->where('response', EventResponse::Going)]), + 'host' => fn (): ?User => $commit->user, + 'response' => fn (): ?string => $event->responseOf($currentUser)?->value, + 'attendees' => fn (): Collection => $attendees->get($event, $currentUser), + 'commit' => fn (): Commit => $page->get($commit, $currentUser), + 'replies' => fn (): CursorPaginator => $replies->paginate($commit, $currentUser, limit: $request->repliesPerPage()), + ]); + } +} diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php index ab7c8034f0..712b7e7920 100644 --- a/app/Http/Controllers/NotificationController.php +++ b/app/Http/Controllers/NotificationController.php @@ -20,7 +20,7 @@ public function index(ListNotificationsRequest $request, #[CurrentUser] User $us $type = $request->type($user); return Inertia::render('notification/index', [ - 'notifications' => Inertia::scroll(fn (): CursorPaginator => $query->withStarredCommitImages($query->withQuotedCommits($user, $query->paginate($user, $type, $request->through()))))->matchOn('data.group_id'), + 'notifications' => Inertia::scroll(fn (): CursorPaginator => $query->withCancelledEvents($query->withStarredCommitImages($query->withQuotedCommits($user, $query->paginate($user, $type, $request->through())))))->matchOn('data.group_id'), 'filters' => ['type' => $type], 'readAt' => CarbonImmutable::now()->startOfSecond()->toJSON(), ]); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index f54a2dd861..1eca42b586 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -11,6 +11,7 @@ use App\Models\Commit; use App\Models\User; use App\Queries\BioProfilesQuery; +use App\Queries\EventsQuery; use App\Queries\FollowersYouKnowQuery; use App\Queries\GitHubReadmeQuery; use App\Queries\PinnedCommitQuery; @@ -33,7 +34,7 @@ /** * Profiles are public, so guests and crawlers read the same page, with the profile's link preview. */ - public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, BioProfilesQuery $bioProfiles, PageMetadata $metadata): Response + public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, EventsQuery $events, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, BioProfilesQuery $bioProfiles, PageMetadata $metadata): Response { $blockerId = $currentUser?->blockerIdWith($user); @@ -74,12 +75,14 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser 'tab' => $request->tab(...), 'pinnedCommit' => fn (): ?Commit => $request->tab() === 'commits' ? $pinned->get($user, $currentUser) : null, 'readme' => fn (): string|false|null => $request->tab() === 'readme' ? $readme->get($user) : null, - // The README tab shows no commits, so it doesn't look for any. - 'commits' => Inertia::scroll(fn () => $request->tab() === 'readme' - ? new CursorPaginator([], 20) - : $commits->builder($user, $currentUser, $request->tab() === 'replies') + 'commits' => Inertia::scroll(fn () => match ($request->tab()) { + // The README tab shows no commits, so it doesn't look for any. + 'readme' => new CursorPaginator([], 20), + 'events' => $events->paginateHostedBy($user, $currentUser), + default => $commits->builder($user, $currentUser, $request->tab() === 'replies') ->cursorPaginate(20) - ->appends(['tab' => $request->tab()])), + ->appends(['tab' => $request->tab()]), + }), ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->user($getCountedUser())]); } diff --git a/app/Http/Requests/CreateEventCancellationRequest.php b/app/Http/Requests/CreateEventCancellationRequest.php new file mode 100644 index 0000000000..464788ae12 --- /dev/null +++ b/app/Http/Requests/CreateEventCancellationRequest.php @@ -0,0 +1,42 @@ +user(); + assert($user instanceof User); + + $event = $this->route('event'); + assert($event instanceof Event); + + if (! $event->isVisibleTo($user) || ! $event->isHostedBy($user)) { + return Response::denyAsNotFound(); + } + + return in_array($event->state(), [EventState::Upcoming, EventState::Live], true) + ? Response::allow() + : Response::deny('This event is already over.'); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/CreateEventRequest.php b/app/Http/Requests/CreateEventRequest.php new file mode 100644 index 0000000000..ba77f4f4c4 --- /dev/null +++ b/app/Http/Requests/CreateEventRequest.php @@ -0,0 +1,92 @@ +> + */ + public function rules(): array + { + return [ + 'body' => CreateCommitRequest::bodyRules(), + 'title' => ['required', 'string', 'max:'.Event::MAX_TITLE_LENGTH], + 'starts_at' => ['required', 'date', 'after:now', 'before:+1 year'], + 'ends_at' => ['required', 'date', 'after:starts_at', new EventDuration($this->input('starts_at'))], + 'location' => ['nullable', 'string', 'max:'.Event::MAX_LOCATION_LENGTH, 'required_without:url'], + 'url' => ['nullable', 'string', 'url:http,https', 'max:'.Event::MAX_URL_LENGTH, 'required_without:location'], + 'agenda' => ['nullable', 'list', 'max:'.Event::MAX_AGENDA_ITEMS], + 'agenda.*' => ['array:label,starts_at'], + 'agenda.*.label' => ['required', 'string', 'max:'.Event::MAX_AGENDA_LABEL_LENGTH], + 'agenda.*.starts_at' => ['required', 'date', 'after_or_equal:starts_at', 'before:ends_at'], + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'body' => 'description', + 'starts_at' => 'start', + 'ends_at' => 'end', + 'url' => 'link', + 'agenda.*.label' => 'agenda item', + 'agenda.*.starts_at' => 'agenda time', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'location.required_without' => 'Add a place, a link, or both.', + 'url.required_without' => 'Add a place, a link, or both.', + 'agenda.*.starts_at.after_or_equal' => 'Agenda items happen during the event.', + 'agenda.*.starts_at.before' => 'Agenda items happen during the event.', + ]; + } + + public function newEvent(): NewEvent + { + /** @var list $agenda */ + $agenda = $this->array('agenda'); + + return new NewEvent( + title: $this->string('title')->trim()->value(), + startsAt: CarbonImmutable::parse($this->string('starts_at')->value()), + endsAt: CarbonImmutable::parse($this->string('ends_at')->value()), + location: $this->filled('location') ? $this->string('location')->trim()->value() : null, + url: $this->filled('url') ? $this->string('url')->value() : null, + agenda: array_map(fn (array $item): array => [ + 'label' => mb_trim($item['label']), + 'starts_at' => CarbonImmutable::parse($item['starts_at']), + ], $agenda), + ); + } + + public function prepareForValidation(): void + { + CreateCommitRequest::normalizeBody($this); + } +} diff --git a/app/Http/Requests/DeleteEventAttendanceRequest.php b/app/Http/Requests/DeleteEventAttendanceRequest.php new file mode 100644 index 0000000000..100b54ffdf --- /dev/null +++ b/app/Http/Requests/DeleteEventAttendanceRequest.php @@ -0,0 +1,31 @@ +route('event'); + assert($event instanceof Event); + + return $event->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ListEventsRequest.php b/app/Http/Requests/ListEventsRequest.php new file mode 100644 index 0000000000..a1049b9b35 --- /dev/null +++ b/app/Http/Requests/ListEventsRequest.php @@ -0,0 +1,30 @@ +> + */ + public function rules(): array + { + return [ + 'tab' => ['nullable', 'string', 'in:upcoming,past'], + ]; + } + + public function tab(): string + { + return $this->input('tab') === 'past' ? 'past' : 'upcoming'; + } +} diff --git a/app/Http/Requests/ShowEventRequest.php b/app/Http/Requests/ShowEventRequest.php new file mode 100644 index 0000000000..e76acf7c89 --- /dev/null +++ b/app/Http/Requests/ShowEventRequest.php @@ -0,0 +1,34 @@ +route('event'); + assert($event instanceof Event); + + return $event->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound(); + } + + public function repliesPerPage(): int + { + return RepliesPerPage::of($this)->value; + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ShowUserRequest.php b/app/Http/Requests/ShowUserRequest.php index a6cef64f61..aac3e476ef 100644 --- a/app/Http/Requests/ShowUserRequest.php +++ b/app/Http/Requests/ShowUserRequest.php @@ -19,7 +19,7 @@ public function authorize(): bool public function rules(): array { return [ - 'tab' => ['nullable', 'string', 'in:commits,replies,readme'], + 'tab' => ['nullable', 'string', 'in:commits,replies,readme,events'], ]; } @@ -28,6 +28,7 @@ public function tab(): string return match ($this->input('tab')) { 'replies' => 'replies', 'readme' => 'readme', + 'events' => 'events', default => 'commits', }; } diff --git a/app/Http/Requests/UpdateEventAttendanceRequest.php b/app/Http/Requests/UpdateEventAttendanceRequest.php new file mode 100644 index 0000000000..060533c072 --- /dev/null +++ b/app/Http/Requests/UpdateEventAttendanceRequest.php @@ -0,0 +1,49 @@ +event()->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array> + */ + public function rules(): array + { + $user = $this->user(); + assert($user instanceof User); + + return [ + 'response' => ['required', Rule::enum(EventResponse::class), new OpenEvent($this->event(), $user)], + ]; + } + + public function eventResponse(): EventResponse + { + return EventResponse::from($this->string('response')->value()); + } + + private function event(): Event + { + $event = $this->route('event'); + assert($event instanceof Event); + + return $event; + } +} diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 805af50de6..4dbb3f3440 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -56,6 +56,7 @@ * @property-read string|null $continuation_id * @property-read LinkPreview|null $linkPreview * @property-read Poll|null $poll + * @property-read Event|null $event * @property-read Commit|null $quotedCommit * @property-read Collection $quotes * @property-read Collection $images @@ -311,6 +312,14 @@ public function poll(): HasOne return $this->hasOne(Poll::class); } + /** + * @return HasOne + */ + public function event(): HasOne + { + return $this->hasOne(Event::class); + } + /** * @return BelongsTo */ diff --git a/app/Models/Event.php b/app/Models/Event.php new file mode 100644 index 0000000000..31dbb298f4 --- /dev/null +++ b/app/Models/Event.php @@ -0,0 +1,141 @@ + $agendaItems + * @property-read Collection $attendees + */ +#[Visible(['id', 'commit_id', 'title', 'starts_at', 'ends_at', 'location', 'url', 'cancelled_at', 'going_count', 'agendaItems', 'commit'])] +final class Event extends Model +{ + /** @use HasFactory */ + use HasFactory; + + use HasUuids; + + public const int MAX_TITLE_LENGTH = 100; + + public const int MAX_LOCATION_LENGTH = 100; + + public const int MAX_URL_LENGTH = 255; + + public const int MAX_AGENDA_ITEMS = 12; + + public const int MAX_AGENDA_LABEL_LENGTH = 100; + + /** + * Long enough for a conference or a hackathon week, short enough that an event is something you attend. + */ + public const int MAX_DURATION_DAYS = 7; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'string', + 'commit_id' => 'string', + 'title' => 'string', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'location' => 'string', + 'url' => 'string', + 'cancelled_at' => 'datetime', + 'going_count' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return array + */ + public function toArray(): array + { + return [...parent::toArray(), 'state' => $this->state()]; + } + + /** + * @return BelongsTo + */ + public function commit(): BelongsTo + { + return $this->belongsTo(Commit::class); + } + + /** + * @return HasMany + */ + public function agendaItems(): HasMany + { + return $this->hasMany(EventAgendaItem::class)->oldest('starts_at')->orderBy('id'); + } + + /** + * @return HasMany + */ + public function attendees(): HasMany + { + return $this->hasMany(EventAttendee::class); + } + + public function state(): EventState + { + return match (true) { + $this->cancelled_at !== null => EventState::Cancelled, + now()->greaterThanOrEqualTo($this->ends_at) => EventState::Ended, + now()->greaterThanOrEqualTo($this->starts_at) => EventState::Live, + default => EventState::Upcoming, + }; + } + + /** + * An event follows its announcement commit: visible once approved, to its host before, and gone with it. + */ + public function isVisibleTo(?User $user): bool + { + return $this->commit instanceof Commit && $this->commit->isVisibleTo($user); + } + + public function isHostedBy(User $user): bool + { + return $this->commit?->user_id === $user->id; + } + + public function responseOf(?User $user): ?EventResponse + { + if (! $user instanceof User) { + return null; + } + + return $this->attendees()->where('user_id', $user->id)->first()?->response; + } +} diff --git a/app/Models/EventAgendaItem.php b/app/Models/EventAgendaItem.php new file mode 100644 index 0000000000..eb5355c3cf --- /dev/null +++ b/app/Models/EventAgendaItem.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'integer', + 'event_id' => 'string', + 'label' => 'string', + 'starts_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } +} diff --git a/app/Models/EventAttendee.php b/app/Models/EventAttendee.php new file mode 100644 index 0000000000..569d986d91 --- /dev/null +++ b/app/Models/EventAttendee.php @@ -0,0 +1,59 @@ + */ + use HasFactory; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'integer', + 'event_id' => 'string', + 'user_id' => 'string', + 'response' => EventResponse::class, + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 8f8f264b5f..f4021524ba 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -81,6 +81,7 @@ * @property-read Collection $notifications * @property-read Collection $sshKeys * @property-read Collection $pollVotes + * @property-read Collection $eventAttendances * @property-read Collection $devices * @property-read Collection $timeouts * @property-read Ban|null $ban @@ -274,6 +275,14 @@ public function pollVotes(): HasMany return $this->hasMany(PollVote::class); } + /** + * @return HasMany + */ + public function eventAttendances(): HasMany + { + return $this->hasMany(EventAttendee::class); + } + /** * @return HasMany */ diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 9ce30e753d..72743c45a8 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -294,7 +294,7 @@ public function withPollFor(?User $viewer): self public function withCardDataFor(?User $viewer): self { return $this - ->with(['author', 'linkPreview', 'images']) + ->with(['author', 'linkPreview', 'images', 'event.agendaItems']) ->withQuotedCommitFor($viewer) ->withStarsFor($viewer) ->withPollFor($viewer) diff --git a/app/Queries/EventAttendeesQuery.php b/app/Queries/EventAttendeesQuery.php new file mode 100644 index 0000000000..c212274df8 --- /dev/null +++ b/app/Queries/EventAttendeesQuery.php @@ -0,0 +1,34 @@ + + */ + public function get(Event $event, ?User $viewer): Collection + { + return User::query() + ->select('users.*') + ->join('event_attendees', 'event_attendees.user_id', '=', 'users.id') + ->where('event_attendees.event_id', $event->id) + ->where('event_attendees.response', EventResponse::Going) + ->when($viewer, fn (Builder $query, User $viewer): Builder => $query->whereNotIn('users.id', $viewer->idsAcrossBlocks())) + ->orderByDesc('event_attendees.id') + ->limit(self::LIMIT) + ->get(); + } +} diff --git a/app/Queries/EventsQuery.php b/app/Queries/EventsQuery.php new file mode 100644 index 0000000000..0454ef7336 --- /dev/null +++ b/app/Queries/EventsQuery.php @@ -0,0 +1,68 @@ + + */ + public function paginate(?User $viewer, bool $past = false): CursorPaginator + { + return $this->builder($viewer) + ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->withoutMutedAuthorsFor($viewer)) + ->when( + $past, + fn (Builder $query): Builder => $query + ->where(fn (Builder $query): Builder => $query->where('ends_at', '<=', now())->orWhereNotNull('cancelled_at')) + ->latest('starts_at') + ->orderByDesc('id'), + fn (Builder $query): Builder => $query + ->where('ends_at', '>', now()) + ->whereNull('cancelled_at') + ->oldest('starts_at') + ->orderBy('id'), + ) + ->cursorPaginate(self::PER_PAGE) + ->withQueryString(); + } + + /** + * The events a user hosts, as the viewer may see them, latest first. + * + * @return CursorPaginator + */ + public function paginateHostedBy(User $host, ?User $viewer): CursorPaginator + { + return $this->builder($viewer) + ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->whereBelongsTo($host)) + ->latest('starts_at') + ->orderByDesc('id') + ->cursorPaginate(self::PER_PAGE) + ->withQueryString(); + } + + /** + * @return Builder + */ + private function builder(?User $viewer): Builder + { + return Event::query() + ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($viewer)) + ->with('commit.user') + ->withCount(['attendees as going_count' => fn (Builder $query): Builder => $query->where('response', EventResponse::Going)]); + } +} diff --git a/app/Queries/NotificationBuilder.php b/app/Queries/NotificationBuilder.php index 15c1a6bed0..9edef95c2a 100644 --- a/app/Queries/NotificationBuilder.php +++ b/app/Queries/NotificationBuilder.php @@ -48,6 +48,12 @@ public function relevant(): self ->whereColumn('organization_members.organization_id', 'notifications.organization_id') ->whereColumn('organization_members.user_id', 'notifications.recipient_id'))) ->offset(0))) + ->orWhere(fn (Builder $query): Builder => $query + ->where('type', NotificationType::EventCancelled) + ->whereHas('commit', fn (CommitBuilder $commit): CommitBuilder => $commit + ->where('status', CommitStatus::Approved) + ->has('event') + ->offset(0))) ->orWhere('type', NotificationType::LeveledUp)) ->withoutSilencedActors(); } diff --git a/app/Queries/NotificationGroupsQuery.php b/app/Queries/NotificationGroupsQuery.php index 60fb07cdf7..cd574a6683 100644 --- a/app/Queries/NotificationGroupsQuery.php +++ b/app/Queries/NotificationGroupsQuery.php @@ -88,6 +88,19 @@ public function withStarredCommitImages(CursorPaginator $groups): CursorPaginato return $groups; } + /** + * A cancellation names the event, which goes with the commit announcing it. + * + * @param CursorPaginator $groups + * @return CursorPaginator + */ + public function withCancelledEvents(CursorPaginator $groups): CursorPaginator + { + new Collection($groups->items())->where('type', NotificationType::EventCancelled)->loadMissing('commit.event'); + + return $groups; + } + /** * Whether two unread notifications share a group only depends on what happened between them, so grouping from the * oldest unread one onward counts the same groups as grouping the whole history, which every page would pay for. diff --git a/app/Rules/EventDuration.php b/app/Rules/EventDuration.php new file mode 100644 index 0000000000..158d5a0ad1 --- /dev/null +++ b/app/Rules/EventDuration.php @@ -0,0 +1,36 @@ +startsAt) ? $this->startsAt : ''); + $endsAt = CarbonImmutable::parse(is_string($value) ? $value : ''); + } catch (Throwable) { + return; + } + + if ($endsAt->greaterThan($startsAt->addDays(Event::MAX_DURATION_DAYS))) { + $fail('An event lasts at most '.Event::MAX_DURATION_DAYS.' days.'); + } + } +} diff --git a/app/Rules/OpenEvent.php b/app/Rules/OpenEvent.php new file mode 100644 index 0000000000..7a758a6431 --- /dev/null +++ b/app/Rules/OpenEvent.php @@ -0,0 +1,31 @@ +event->isHostedBy($this->user)) { + $fail('You host this event.'); + } elseif (! in_array($this->event->state(), [EventState::Upcoming, EventState::Live], true)) { + $fail('This event no longer takes responses.'); + } + } +} diff --git a/database/factories/EventAgendaItemFactory.php b/database/factories/EventAgendaItemFactory.php new file mode 100644 index 0000000000..a6e3ca16ed --- /dev/null +++ b/database/factories/EventAgendaItemFactory.php @@ -0,0 +1,27 @@ + + */ +final class EventAgendaItemFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'event_id' => Event::factory(), + 'label' => fake()->sentence(4), + 'starts_at' => now()->addDays(7)->startOfHour(), + ]; + } +} diff --git a/database/factories/EventAttendeeFactory.php b/database/factories/EventAttendeeFactory.php new file mode 100644 index 0000000000..c0ba988131 --- /dev/null +++ b/database/factories/EventAttendeeFactory.php @@ -0,0 +1,36 @@ + + */ +final class EventAttendeeFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'event_id' => Event::factory(), + 'user_id' => User::factory(), + 'response' => EventResponse::Going, + ]; + } + + public function interested(): self + { + return $this->state(fn (array $attributes): array => [ + 'response' => EventResponse::Interested, + ]); + } +} diff --git a/database/factories/EventFactory.php b/database/factories/EventFactory.php new file mode 100644 index 0000000000..2ce44988e6 --- /dev/null +++ b/database/factories/EventFactory.php @@ -0,0 +1,55 @@ + + */ +final class EventFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $startsAt = now()->addDays(7)->startOfHour(); + + return [ + 'commit_id' => Commit::factory(), + 'title' => fake()->words(3, true), + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->copy()->addHours(2), + 'location' => fake()->city(), + 'url' => null, + ]; + } + + public function live(): self + { + return $this->state(fn (array $attributes): array => [ + 'starts_at' => now()->subHour(), + 'ends_at' => now()->addHour(), + ]); + } + + public function ended(): self + { + return $this->state(fn (array $attributes): array => [ + 'starts_at' => now()->subDay(), + 'ends_at' => now()->subDay()->addHours(2), + ]); + } + + public function cancelled(): self + { + return $this->state(fn (array $attributes): array => [ + 'cancelled_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_10_22_120000_create_events_table.php b/database/migrations/2026_10_22_120000_create_events_table.php new file mode 100644 index 0000000000..8df1a51204 --- /dev/null +++ b/database/migrations/2026_10_22_120000_create_events_table.php @@ -0,0 +1,27 @@ +uuid('id')->primary(); + $table->foreignUuid('commit_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('title', 100); + $table->timestamp('starts_at'); + $table->timestamp('ends_at'); + $table->string('location', 100)->nullable(); + $table->string('url', 255)->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->timestamps(); + + $table->index(['starts_at', 'id']); + }); + } +}; diff --git a/database/migrations/2026_10_22_120001_create_event_agenda_items_table.php b/database/migrations/2026_10_22_120001_create_event_agenda_items_table.php new file mode 100644 index 0000000000..6c0cd9ddc4 --- /dev/null +++ b/database/migrations/2026_10_22_120001_create_event_agenda_items_table.php @@ -0,0 +1,23 @@ +id(); + $table->foreignUuid('event_id')->constrained()->cascadeOnDelete(); + $table->string('label', 100); + $table->timestamp('starts_at'); + $table->timestamps(); + + $table->index(['event_id', 'starts_at']); + }); + } +}; diff --git a/database/migrations/2026_10_22_120002_create_event_attendees_table.php b/database/migrations/2026_10_22_120002_create_event_attendees_table.php new file mode 100644 index 0000000000..f8f84fc331 --- /dev/null +++ b/database/migrations/2026_10_22_120002_create_event_attendees_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignUuid('event_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('response', 16); + $table->timestamps(); + + $table->unique(['event_id', 'user_id']); + $table->index(['user_id', 'id']); + }); + } +}; diff --git a/rector.php b/rector.php index 7b92f146ca..8afa40f59e 100644 --- a/rector.php +++ b/rector.php @@ -6,6 +6,7 @@ use Rector\CodingStyle\Rector\ClassMethod\MakeInheritedMethodVisibilitySameAsParentRector; use Rector\Config\RectorConfig; use Rector\Php85\Rector\Property\AddOverrideAttributeToOverriddenPropertiesRector; +use Rector\Renaming\Rector\Name\RenameClassRector; use RectorLaravel\Rector\Class_\RemoveDefaultAuthorizeTrueFromFormRequestRector; use RectorLaravel\Rector\If_\ThrowIfRector; use RectorLaravel\Set\LaravelSetList; @@ -47,6 +48,12 @@ ThrowIfRector::class => [__DIR__.'/app/Actions/*'], // Generated by Octane, and runs before the autoloader exists... __DIR__.'/public/frankenphp-worker.php', + // The Event model shares its name with Laravel's Event facade alias, which these models refer to it by... + RenameClassRector::class => [ + __DIR__.'/app/Models/Commit.php', + __DIR__.'/app/Models/EventAgendaItem.php', + __DIR__.'/app/Models/EventAttendee.php', + ], ]) ->withPreparedSets( deadCode: true, diff --git a/resources/js/components/app-nav.tsx b/resources/js/components/app-nav.tsx index 31cd9a640b..facdc4fa11 100644 --- a/resources/js/components/app-nav.tsx +++ b/resources/js/components/app-nav.tsx @@ -2,6 +2,7 @@ import { Link, usePage } from '@inertiajs/react'; import { Activity, Bell, + CalendarDays, Star, GitCommitVertical, History, @@ -17,6 +18,7 @@ import { import type { ReactElement, ReactNode } from 'react'; import { useLayoutEffect, useRef, useState } from 'react'; import ConversationController from '@/actions/App/Http/Controllers/ConversationController'; +import EventController from '@/actions/App/Http/Controllers/EventController'; import LogController from '@/actions/App/Http/Controllers/LogController'; import ModerationController from '@/actions/App/Http/Controllers/ModerationController'; import NotificationController from '@/actions/App/Http/Controllers/NotificationController'; @@ -94,6 +96,11 @@ function useNavItems(): (NavItem & { isActive: boolean })[] { }, ] : []), + { + title: 'Events', + href: EventController.index(), + icon: CalendarDays, + }, { title: 'Profile', href: hrefFor((user) => show(user)), @@ -138,7 +145,9 @@ function useNavItems(): (NavItem & { isActive: boolean })[] { ? isCurrentOrParentUrl('/remotes') : item.title === 'Operations' ? isCurrentOrParentUrl('/operations') - : isCurrentUrl(item.href)), + : item.title === 'Events' + ? isCurrentOrParentUrl('/events') + : isCurrentUrl(item.href)), })); } @@ -535,7 +544,7 @@ export function MobileNav({ children }: { children?: ReactNode }) { const [isChangelogOpen, setIsChangelogOpen] = useState(false); const allItems = useNavItems(); const menuItems = allItems.filter((item) => - ['Profile', 'Settings', 'Operations'].includes(item.title), + ['Events', 'Profile', 'Settings', 'Operations'].includes(item.title), ); const items = allItems.filter( (item) => diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 94eea0222c..f12ba8fe8d 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -5,6 +5,7 @@ import { useCallback, useContext, useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import CommitLinkPreviewController from '@/actions/App/Http/Controllers/CommitLinkPreviewController'; import { FoldedCommitBody } from '@/components/commit-body'; +import { CommitEvent } from '@/components/commit-event'; import { CommitImages } from '@/components/commit-images'; import { CommitPastelButton } from '@/components/commit-pastel-button'; import { CommitPoll } from '@/components/commit-poll'; @@ -475,6 +476,12 @@ export function CommitCard({ poll={commit.poll} /> )} + {commit.event && ( + + )} {commit.quoted_commit_id !== null && ( ; + event: Event; +}) { + const { auth } = usePage().props; + const now = useNow(); + const timeZone = auth.user?.timezone ?? undefined; + const state = eventStateAt(event, now); + const isOnlyOnline = + event.location === null || + event.location.trim().toLowerCase() === 'online'; + const where = + event.url === null + ? (event.location ?? '') + : isOnlyOnline + ? 'Online' + : `${event.location} · Online`; + + return ( +
+ + + + + + {event.title} + + + + + {formatEventWhen(event, timeZone)} + + + {eventFormat(event) === 'Online' ? ( + + ) : ( + + )} + + {where} + + + + + {commit.status !== 'approved' && + event.agenda_items !== undefined && + event.agenda_items.length > 0 && ( +
    + {event.agenda_items.map((item) => ( +
  1. + + {formatEventTime(item.starts_at, timeZone)} + + + {item.label} + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/resources/js/components/event-list.tsx b/resources/js/components/event-list.tsx new file mode 100644 index 0000000000..6d071f1311 --- /dev/null +++ b/resources/js/components/event-list.tsx @@ -0,0 +1,117 @@ +import { InfiniteScroll, Link, usePage } from '@inertiajs/react'; +import { + CalendarCheck2, + CalendarClock, + CalendarX2, + Radio, + Users, +} from 'lucide-react'; +import type { ReactNode } from 'react'; +import EventController from '@/actions/App/Http/Controllers/EventController'; +import { Spinner } from '@/components/ui/spinner'; +import { useNow } from '@/hooks/use-now'; +import { eventFormat, eventStateAt, eventStateLabels } from '@/lib/event'; +import { formatEventWhen } from '@/lib/event-dates'; +import { exactCountTitle, formatCount } from '@/lib/numbers'; +import { cn, shortHash } from '@/lib/utils'; +import type { CursorPaginated, EventState, EventWithHost } from '@/types'; + +const stateIcons = { + upcoming: CalendarClock, + live: Radio, + ended: CalendarCheck2, + cancelled: CalendarX2, +} satisfies Record; + +const stateColors: Record = { + upcoming: 'text-emerald-600 dark:text-emerald-400', + live: 'text-red-600 dark:text-red-400', + ended: 'text-violet-600 dark:text-violet-400', + cancelled: 'text-muted-foreground', +}; + +/** + * Events listed the way GitHub lists issues: a state icon, the title, and one line of metadata. + */ +export function EventList({ + events, + data, + empty, +}: { + events: CursorPaginated; + data: string; + empty: ReactNode; +}) { + const { auth } = usePage().props; + const now = useNow(); + const timeZone = auth.user?.timezone ?? undefined; + + if (events.data.length === 0) { + return
{empty}
; + } + + return ( + + + + } + > +
    + {events.data.map((event) => { + const state = eventStateAt(event, now); + const Icon = stateIcons[state]; + + return ( +
  • + +
    +

    + + {event.title} + + + {eventFormat(event)} + +

    +

    + #{shortHash(event.id)} ·{' '} + {formatEventWhen(event, timeZone)} +

    +

    + {event.location !== null && + `${event.location} · `} + hosted by @{event.commit.user.username} +

    +
    + + + {formatCount(event.going_count)} + +
  • + ); + })} +
+
+ ); +} diff --git a/resources/js/components/event-response-button.tsx b/resources/js/components/event-response-button.tsx new file mode 100644 index 0000000000..63e87a8162 --- /dev/null +++ b/resources/js/components/event-response-button.tsx @@ -0,0 +1,172 @@ +import { Link, router, usePage } from '@inertiajs/react'; +import { CalendarCheck, Check, ChevronDown, Star, X } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import EventAttendanceController from '@/actions/App/Http/Controllers/EventAttendanceController'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { exactCountTitle, formatCount } from '@/lib/numbers'; +import { cn } from '@/lib/utils'; +import { login } from '@/routes'; +import type { EventResponse } from '@/types'; + +type Choice = EventResponse | null; + +const choices: { + value: Choice; + label: string; + description: string; + icon: typeof Check; +}[] = [ + { + value: null, + label: 'Not going', + description: 'You are not listed and nothing is kept.', + icon: X, + }, + { + value: 'going', + label: 'Going', + description: 'You show up among the people going.', + icon: CalendarCheck, + }, + { + value: 'interested', + label: 'Interested', + description: 'Keep an eye on it. Only you see this.', + icon: Star, + }, +]; + +/** + * A response picker in the shape of GitHub's Watch button: the current choice opens a menu of all of them, each with + * what it means, beside the number of people going. + */ +export function EventResponseButton({ + eventId, + response, + goingCount, + isHost, + isOpen, + className, +}: { + eventId: string; + response: Choice; + goingCount: number; + isHost: boolean; + isOpen: boolean; + className?: string; +}) { + const { auth } = usePage().props; + const [isSaving, setIsSaving] = useState(false); + const current = choices.find((choice) => choice.value === response); + const Icon = isHost ? CalendarCheck : (current?.icon ?? X); + const label = isHost ? 'Hosting' : (current?.label ?? 'Not going'); + + const choose = (value: Choice) => { + if (value === response) { + return; + } + + const options = { + preserveScroll: true, + only: ['event', 'response', 'attendees'], + onStart: () => setIsSaving(true), + onFinish: () => setIsSaving(false), + onError: (errors: Record) => + toast.error( + Object.values(errors)[0] ?? 'Could not save your response.', + ), + }; + + if (value === null) { + router.delete(EventAttendanceController.destroy(eventId), options); + + return; + } + + router.put( + EventAttendanceController.update(eventId), + { response: value }, + options, + ); + }; + + const trigger = ( + + ); + + return ( +
+ {auth.user === null ? ( + + ) : isHost || !isOpen ? ( + trigger + ) : ( + + {trigger} + + {choices.map((choice) => ( + choose(choice.value)} + className="items-start gap-2 py-2" + data-test={`event-response-${choice.value ?? 'none'}`} + > + + + + {choice.label} + + + {choice.description} + + + + ))} + + + )} + + {formatCount(goingCount)} + +
+ ); +} diff --git a/resources/js/components/event-state-badge.tsx b/resources/js/components/event-state-badge.tsx new file mode 100644 index 0000000000..30a1b56dc9 --- /dev/null +++ b/resources/js/components/event-state-badge.tsx @@ -0,0 +1,51 @@ +import { CalendarCheck2, CalendarClock, CalendarX2, Radio } from 'lucide-react'; +import { eventStateLabels } from '@/lib/event'; +import { cn } from '@/lib/utils'; +import type { EventState } from '@/types'; + +const styles: Record = { + upcoming: + 'border-emerald-600/30 bg-emerald-600/10 text-emerald-700 dark:text-emerald-400', + live: 'border-red-600/30 bg-red-600/10 text-red-700 dark:text-red-400', + ended: 'border-violet-600/30 bg-violet-600/10 text-violet-700 dark:text-violet-400', + cancelled: 'border-border bg-muted text-muted-foreground', +}; + +const icons = { + upcoming: CalendarClock, + live: Radio, + ended: CalendarCheck2, + cancelled: CalendarX2, +} satisfies Record; + +/** + * Like an issue's Open and Closed badge: the one place an event's state is spelled out. + */ +export function EventStateBadge({ + state, + size = 'default', + className, +}: { + state: EventState; + size?: 'default' | 'small'; + className?: string; +}) { + const Icon = icons[state]; + + return ( + + + {eventStateLabels[state]} + + ); +} diff --git a/resources/js/components/event-tile.tsx b/resources/js/components/event-tile.tsx new file mode 100644 index 0000000000..55e802faeb --- /dev/null +++ b/resources/js/components/event-tile.tsx @@ -0,0 +1,29 @@ +import { eventTile } from '@/lib/event-dates'; +import { cn } from '@/lib/utils'; + +export function EventTile({ + date, + timeZone, + className, +}: { + date: string; + timeZone?: string; + className?: string; +}) { + const { month, day } = eventTile(date, timeZone); + + return ( + + + {month} + + {day} + + ); +} diff --git a/resources/js/lib/event-dates.ts b/resources/js/lib/event-dates.ts new file mode 100644 index 0000000000..2eca3d0e02 --- /dev/null +++ b/resources/js/lib/event-dates.ts @@ -0,0 +1,65 @@ +import { dateLocale } from '@/lib/utils'; +import type { Event } from '@/types'; + +function sameDay(a: Date, b: Date, timeZone?: string): boolean { + const day = (date: Date) => + date.toLocaleDateString(dateLocale, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + timeZone, + }); + + return day(a) === day(b); +} + +export function formatEventTime(date: string, timeZone?: string): string { + return new Date(date).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + timeZone, + }); +} + +export function formatEventDay(date: string, timeZone?: string): string { + return new Date(date).toLocaleDateString(dateLocale, { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone, + }); +} + +/** + * "Tue, Nov 4, 2026 · 6:00 PM – 8:00 PM", or both days when it spans several. + */ +export function formatEventWhen( + event: Pick, + timeZone?: string, +): string { + const start = `${formatEventDay(event.starts_at, timeZone)} · ${formatEventTime(event.starts_at, timeZone)}`; + + if (sameDay(new Date(event.starts_at), new Date(event.ends_at), timeZone)) { + return `${start} – ${formatEventTime(event.ends_at, timeZone)}`; + } + + return `${start} – ${formatEventDay(event.ends_at, timeZone)} · ${formatEventTime(event.ends_at, timeZone)}`; +} + +/** + * A short calendar tile: "NOV" over "4". + */ +export function eventTile( + date: string, + timeZone?: string, +): { month: string; day: string } { + const value = new Date(date); + + return { + month: value + .toLocaleDateString(dateLocale, { month: 'short', timeZone }) + .toUpperCase(), + day: value.toLocaleDateString(dateLocale, { day: 'numeric', timeZone }), + }; +} diff --git a/resources/js/lib/event.ts b/resources/js/lib/event.ts new file mode 100644 index 0000000000..c05ee9fd84 --- /dev/null +++ b/resources/js/lib/event.ts @@ -0,0 +1,61 @@ +import type { Event, EventAgendaItem, EventState } from '@/types'; + +export const eventStateLabels: Record = { + upcoming: 'Upcoming', + live: 'Happening now', + ended: 'Ended', + cancelled: 'Cancelled', +}; + +/** + * The state as of now, since a page left open keeps the state it was served with. + */ +export function eventStateAt(event: Event, now: Date): EventState { + if (event.cancelled_at !== null) { + return 'cancelled'; + } + + if (now >= new Date(event.ends_at)) { + return 'ended'; + } + + return now >= new Date(event.starts_at) ? 'live' : 'upcoming'; +} + +/** + * How far along the agenda is, like a milestone: an item is done once the next one has started, and the last one once + * the event has ended. + */ +export function agendaProgress( + items: EventAgendaItem[], + event: Pick, + now: Date, +): { done: number; current: number | null } { + let done = 0; + let current: number | null = null; + + items.forEach((item, index) => { + const next = items[index + 1]?.starts_at ?? event.ends_at; + + if (now >= new Date(next)) { + done++; + } else if (now >= new Date(item.starts_at)) { + current = item.id; + } + }); + + return { done, current }; +} + +/** + * A place, a link, or both, as GitHub would label it. + */ +export function eventFormat( + event: Pick, +): 'In person' | 'Online' | 'Hybrid' { + if (event.location !== null && event.url !== null) { + return 'Hybrid'; + } + + return event.url !== null ? 'Online' : 'In person'; +} diff --git a/resources/js/pages/event/create.tsx b/resources/js/pages/event/create.tsx new file mode 100644 index 0000000000..9fb3916ea6 --- /dev/null +++ b/resources/js/pages/event/create.tsx @@ -0,0 +1,317 @@ +import { Head, Link, useForm } from '@inertiajs/react'; +import { ArrowLeft, Plus, Trash2 } from 'lucide-react'; +import type { FormEvent } from 'react'; +import EventController from '@/actions/App/Http/Controllers/EventController'; +import InputError from '@/components/input-error'; +import { MentionTextarea } from '@/components/mention-textarea'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import AppLayout from '@/layouts/app-layout'; +import { characterCount, cn } from '@/lib/utils'; + +const inputClass = + 'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30'; + +const maxBodyLength = 4000; + +type AgendaDraft = { label: string; starts_at: string }; + +/** + * A datetime-local value is in the browser's time zone, so it is sent as an instant the server can't misread. + */ +function toInstant(value: string): string { + return value === '' ? '' : new Date(value).toISOString(); +} + +export default function Create({ + limits, +}: { + limits: { + title: number; + location: number; + url: number; + agendaItems: number; + agendaLabel: number; + durationDays: number; + }; +}) { + const form = useForm({ + title: '', + body: '', + starts_at: '', + ends_at: '', + location: '', + url: '', + agenda: [] as AgendaDraft[], + }); + const { data, setData, errors, processing } = form; + const errorFor = (key: string): string | undefined => + (errors as Record)[key]; + + const submit = (event: FormEvent) => { + event.preventDefault(); + + form.transform((values) => ({ + ...values, + starts_at: toInstant(values.starts_at), + ends_at: toInstant(values.ends_at), + location: values.location.trim() === '' ? null : values.location, + url: values.url.trim() === '' ? null : values.url, + agenda: values.agenda.map((item) => ({ + label: item.label, + starts_at: toInstant(item.starts_at), + })), + })); + form.post(EventController.store.url()); + }; + + const updateAgenda = (index: number, item: Partial) => + setData( + 'agenda', + data.agenda.map((existing, position) => + position === index ? { ...existing, ...item } : existing, + ), + ); + + return ( + <> + + +
+ + + +

New event

+
+ +
+
+ + + setData('title', event.target.value) + } + maxLength={limits.title} + required + autoFocus + placeholder="Laravel Lisbon meetup #12" + className={`${inputClass} h-10 text-base md:text-base`} + data-test="event-title-input" + /> + +
+ +
+ + setData('body', value)} + rows={6} + placeholder="What's happening, who it's for, what to bring. It's also the commit announcing your event." + className="block min-h-32 w-full resize-y overflow-x-hidden rounded-md border bg-transparent px-3 py-2 text-base outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 md:text-[15px]" + data-test="event-body-input" + /> +
+ + Replies to it become the event's discussion. + + + {maxBodyLength - characterCount(data.body)} + +
+ +
+ +
+
+ + + setData('starts_at', event.target.value) + } + required + className={inputClass} + data-test="event-starts-at-input" + /> + +
+
+ + + setData('ends_at', event.target.value) + } + required + className={inputClass} + data-test="event-ends-at-input" + /> + +
+
+ +
+
+ + + setData('location', event.target.value) + } + maxLength={limits.location} + placeholder="Venue, city" + className={inputClass} + data-test="event-location-input" + /> + +
+
+ + + setData('url', event.target.value) + } + maxLength={limits.url} + placeholder="https://…" + className={inputClass} + data-test="event-url-input" + /> + +
+

+ A place for an in-person event, a link for an online + one, or both for a hybrid. +

+
+ +
+ + Agenda{' '} + + (optional) + + +

+ Talks are ticked off as the event goes, like issues in a + milestone. +

+ {data.agenda.map((item, index) => ( +
+
+ + updateAgenda(index, { + starts_at: event.target.value, + }) + } + required + className={cn(inputClass, 'row-start-1')} + /> + + updateAgenda(index, { + label: event.target.value, + }) + } + maxLength={limits.agendaLabel} + required + placeholder="Talk title, @speaker" + className={cn( + inputClass, + 'col-span-2 row-start-2 sm:col-span-1 sm:row-start-1', + )} + /> + +
+ +
+ ))} + {data.agenda.length < limits.agendaItems && ( + + )} + +
+ +
+ + +
+
+ + ); +} + +Create.layout = AppLayout; diff --git a/resources/js/pages/event/index.tsx b/resources/js/pages/event/index.tsx new file mode 100644 index 0000000000..3c20d33471 --- /dev/null +++ b/resources/js/pages/event/index.tsx @@ -0,0 +1,87 @@ +import { Head, Link, usePage } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import EventController from '@/actions/App/Http/Controllers/EventController'; +import { EmptyFeed } from '@/components/commit-feed'; +import { EventList } from '@/components/event-list'; +import { PageTabs } from '@/components/page-tabs'; +import { Button } from '@/components/ui/button'; +import AppLayout from '@/layouts/app-layout'; +import { login } from '@/routes'; +import type { CursorPaginated, EventWithHost } from '@/types'; + +export default function Index({ + tab, + events, +}: { + tab: 'upcoming' | 'past'; + events: CursorPaginated; +}) { + const { auth } = usePage().props; + + return ( + <> + + +
+

Events

+ +
+ +
+ + + + Nothing scheduled yet. Host a meetup, a stream + or a hackathon, and it shows up here. + + ) : ( + + Events that ended or were cancelled show up + here. + + ) + } + /> +
+ + ); +} + +Index.layout = AppLayout; diff --git a/resources/js/pages/event/show.tsx b/resources/js/pages/event/show.tsx new file mode 100644 index 0000000000..8e32d8957f --- /dev/null +++ b/resources/js/pages/event/show.tsx @@ -0,0 +1,429 @@ +import { Head, Link, router, usePage } from '@inertiajs/react'; +import { + ArrowLeft, + CalendarDays, + Circle, + CircleCheck, + CircleDot, + Globe, + Link2, + MapPin, + Tag, + Users, +} from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useState } from 'react'; +import EventCancellationController from '@/actions/App/Http/Controllers/EventCancellationController'; +import EventController from '@/actions/App/Http/Controllers/EventController'; +import { show as userShow } from '@/actions/App/Http/Controllers/UserController'; +import { CommitCard } from '@/components/commit-card'; +import { EventResponseButton } from '@/components/event-response-button'; +import { EventStateBadge } from '@/components/event-state-badge'; +import { GraphCanvas } from '@/components/graph-canvas'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@/components/ui/dialog'; +import { UserAvatar } from '@/components/user-avatar'; +import { useNow } from '@/hooks/use-now'; +import AppLayout from '@/layouts/app-layout'; +import { agendaProgress, eventFormat, eventStateAt } from '@/lib/event'; +import { formatEventTime, formatEventWhen } from '@/lib/event-dates'; +import { cn, formatRelativeTime, shortHash } from '@/lib/utils'; +import type { + Commit, + CursorPaginated, + Event, + EventAgendaItem, + EventResponse, + ThreadCommit, + User, +} from '@/types'; + +export default function Show({ + event, + host, + response, + attendees, + commit, + replies, +}: { + event: Event & { going_count: number; agenda_items: EventAgendaItem[] }; + host: User | null; + response: EventResponse | null; + attendees: User[]; + commit: Commit; + replies: CursorPaginated; +}) { + const { auth } = usePage().props; + const now = useNow(); + const timeZone = auth.user?.timezone ?? undefined; + const state = eventStateAt(event, now); + const isHost = auth.user !== null && host?.id === auth.user.id; + const isOpen = state === 'upcoming' || state === 'live'; + + const goBack = () => { + if (window.history.length > 1) { + window.history.back(); + + return; + } + + router.visit(EventController.index()); + }; + + return ( + <> + + +
+ +

Event

+

+ {shortHash(event.id)} +

+
+ +
+

+ {event.title}{' '} + + #{shortHash(event.id)} + +

+
+ + {host && ( + + + @{host.username} + {' '} + {state === 'cancelled' + ? 'cancelled this event' + : 'is hosting'}{' '} + ·{' '} + {' '} + · {event.going_count} going + + )} +
+ +
+ + {isHost && isOpen && } +
+
+ +
+ + + {formatEventWhen(event, timeZone)} + + + {event.location !== null && ( + + + {event.location} + + + )} + {event.url !== null && ( + + + {event.url.replace(/^https?:\/\//u, '')} + + + )} + + + + {eventFormat(event) === 'Online' ? ( + + ) : ( + + )} + {eventFormat(event)} + + {event.agenda_items.length > 0 && ( + + {event.agenda_items.length}{' '} + {event.agenda_items.length === 1 + ? 'talk' + : 'talks'} + + )} + + + + {attendees.length === 0 && host === null ? ( + + No one yet + + ) : ( +
    + {host && ( + + )} + {attendees.map((attendee) => ( + + ))} + {event.going_count > attendees.length && ( +
  • + +{event.going_count - attendees.length} +
  • + )} +
+ )} +
+
+ + {event.agenda_items.length > 0 && ( + + )} + +

+ Discussion +

+ + + + + ); +} + +function Detail({ + icon: Icon, + term, + id, + children, +}: { + icon: typeof CalendarDays; + term: string; + id?: string; + children: ReactNode; +}) { + return ( +
+
+ + {term} +
+
{children}
+
+ ); +} + +function AttendeeAvatar({ user, label }: { user: User; label?: string }) { + return ( +
  • + + + +
  • + ); +} + +/** + * The agenda reads like a milestone: a progress bar over its talks, ticked off as the next one starts. + */ +function Agenda({ + event, + items, + now, +}: { + event: Event; + items: EventAgendaItem[]; + now: Date; +}) { + const { auth } = usePage().props; + const timeZone = auth.user?.timezone ?? undefined; + const isCancelled = event.cancelled_at !== null; + const { done, current } = isCancelled + ? { done: 0, current: null } + : agendaProgress(items, event, now); + const percent = Math.round((done / items.length) * 100); + + return ( +
    +
    +

    Agenda

    +

    + {done} of {items.length} done · {percent}% +

    +
    +
    +
    +
    +
      + {items.map((item, index) => { + const isDone = !isCancelled && index < done; + const isCurrent = item.id === current; + const Icon = isDone + ? CircleCheck + : isCurrent + ? CircleDot + : Circle; + + return ( +
    1. + + + {formatEventTime(item.starts_at, timeZone)} + + + {item.label} + {isCurrent && ( + + Now + + )} + +
    2. + ); + })} +
    +
    + ); +} + +/** + * Cancelling is final, like closing an issue as not planned, so it asks first. + */ +function CancelEventButton({ event }: { event: Event }) { + const [isOpen, setIsOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const cancel = () => { + router.post( + EventCancellationController.store(event.id), + {}, + { + preserveScroll: true, + onStart: () => setIsSaving(true), + onFinish: () => { + setIsSaving(false); + setIsOpen(false); + }, + }, + ); + }; + + return ( + <> + + + + Cancel “{event.title}”? + + The event stays here, marked as cancelled, with its + discussion. It can't be reopened. + + + + + + + + + + + ); +} + +Show.layout = AppLayout; diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx index b2450aa29a..c6a694544a 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -10,6 +10,7 @@ import { import { ArrowUp, AtSign, + CalendarX2, Check, ChevronDown, ChevronsUp, @@ -31,6 +32,7 @@ import { useState, } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; +import EventController from '@/actions/App/Http/Controllers/EventController'; import { index } from '@/actions/App/Http/Controllers/NotificationController'; import { show as groupShow } from '@/actions/App/Http/Controllers/NotificationGroupController'; import NotificationReadAtController from '@/actions/App/Http/Controllers/NotificationReadAtController'; @@ -49,6 +51,7 @@ import { useNow } from '@/hooks/use-now'; import { useUser } from '@/hooks/use-user'; import AppLayout from '@/layouts/app-layout'; import { backgroundPollOptions } from '@/lib/background-poll'; +import { formatEventWhen } from '@/lib/event-dates'; import { exactCountTitle, formatCount } from '@/lib/numbers'; import { cn, @@ -765,6 +768,7 @@ export function NotificationContent({ }) { const now = useNow(); const { user } = useUser(); + const timeZone = user?.timezone ?? undefined; const actorRef = useRef(null); const { actor } = notification; const actors = @@ -800,8 +804,13 @@ export function NotificationContent({ notification.type === 'mentioned' || notification.type === 'first_commit'; const level = notification.type === 'leveled_up' ? notification : null; + const cancelledEvent = + notification.type === 'event_cancelled' + ? notification.commit.event + : null; const Icon = { followed: UserPlus, + event_cancelled: CalendarX2, leveled_up: ChevronsUp, replied: MessageSquareReply, quoted: Quote, @@ -826,12 +835,14 @@ export function NotificationContent({ notification.type === 'leveled_up' || (!summary && notification.type === 'starred') ? show(actor) - : CommitController.show({ - profile: isOwnCommit - ? (notification.organization ?? user) - : actor, - commit: notification.commit, - }); + : cancelledEvent + ? EventController.show(cancelledEvent.id) + : CommitController.show({ + profile: isOwnCommit + ? (notification.organization ?? user) + : actor, + commit: notification.commit, + }); const time = (