diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index be110dcc1..4b5d845b2 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -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) @@ -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. diff --git a/PRODUCT.md b/PRODUCT.md index f12b19345..f220174c2 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. +- **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 000000000..ae9c9b3b2 --- /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 000000000..bb14086f9 --- /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 000000000..afee3dcf8 --- /dev/null +++ b/app/Actions/CreateEventCancellationNotifications.php @@ -0,0 +1,41 @@ +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())); + } +} diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 41d90a5e3..2ed2b0065 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -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; @@ -31,6 +33,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{ @@ -41,6 +45,8 @@ * commits: Generator>, * polls: Generator, * poll_votes: Generator, + * events: Generator, + * event_responses: Generator, * stars: Generator, * following: Generator, * followers: Generator, @@ -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()), @@ -262,6 +270,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 0a8760e9a..7dc06d1a6 100644 --- a/app/Actions/DeleteCommitContent.php +++ b/app/Actions/DeleteCommitContent.php @@ -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 @@ -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(); diff --git a/app/Actions/DeleteEventAttendance.php b/app/Actions/DeleteEventAttendance.php new file mode 100644 index 000000000..f7d4b5074 --- /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 195817be9..6a755d48c 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 000000000..25090e13d --- /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 000000000..e53bc9c1b --- /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 000000000..526fa6677 --- /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 000000000..23c1a6708 --- /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 000000000..fb4998236 --- /dev/null +++ b/app/Http/Controllers/EventController.php @@ -0,0 +1,88 @@ + $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, + 'body' => Commit::MAX_BODY_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), + 'attendeesLimit' => EventAttendeesQuery::LIMIT, + 'commit' => fn (): Commit => $page->get($commit, $currentUser), + 'replies' => fn (): CursorPaginator => $replies->paginate($commit, $currentUser, limit: $request->repliesPerPage()), + ]); + } +} diff --git a/app/Http/Controllers/ModerationController.php b/app/Http/Controllers/ModerationController.php index e9327401c..761fdeb17 100644 --- a/app/Http/Controllers/ModerationController.php +++ b/app/Http/Controllers/ModerationController.php @@ -30,7 +30,9 @@ public function show(ShowModerationRequest $request, #[CurrentUser] User $user): 'parent.user', 'linkPreview', 'images', - 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->with(['user', 'linkPreview', 'images'])->withPollFor($user), + // An announcement's event is part of what gets approved: title, place, link and agenda. + 'event.agendaItems', + 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->with(['user', 'linkPreview', 'images', 'event'])->withPollFor($user), ]) ->withStarsFor($user) ->withPollFor($user) diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php index deef67fe7..aede511a3 100644 --- a/app/Http/Controllers/NotificationController.php +++ b/app/Http/Controllers/NotificationController.php @@ -21,7 +21,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' => $readAt?->toJSON(), ]); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index f54a2dd86..1eca42b58 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 000000000..464788ae1 --- /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 000000000..ba77f4f4c --- /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 000000000..100b54ffd --- /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 000000000..a1049b9b3 --- /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 000000000..e76acf7c8 --- /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 a6cef64f6..aac3e476e 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 000000000..060533c07 --- /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 fd9d1dff2..4e0ab2b2b 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -53,6 +53,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 @@ -253,6 +254,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 000000000..d1cdbb788 --- /dev/null +++ b/app/Models/Event.php @@ -0,0 +1,153 @@ + $agendaItems + * @property-read Collection $attendees + */ +#[Visible(['id', 'commit_id', 'title', 'starts_at', 'ends_at', 'location', 'url', 'cancelled_at', 'going_count', 'agendaItems', 'commit', 'host'])] +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); + } + + /** + * Whoever posted the announcement, for lists that show only who hosts rather than the whole commit. + * + * @return HasOneThrough + */ + public function host(): HasOneThrough + { + return $this->hasOneThrough(User::class, Commit::class, 'id', 'id', 'commit_id', 'user_id'); + } + + /** + * @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 000000000..eb5355c3c --- /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 000000000..569d986d9 --- /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 ae1553e45..1e85439d8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -80,6 +80,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 @@ -269,6 +270,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 f2dda3958..e810005a3 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -204,7 +204,7 @@ public function withQuotedCommitFor(?User $viewer): self 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query ->withTrashed() ->where(fn (self $query): self => $query->visibleTo($viewer)) - ->with(['user', 'linkPreview', 'images']) + ->with(['user', 'linkPreview', 'images', 'event']) ->withPollFor($viewer), ]); } @@ -292,7 +292,7 @@ public function withPollFor(?User $viewer): self public function withCardDataFor(?User $viewer): self { return $this - ->with(['user', 'linkPreview', 'images']) + ->with(['user', '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 000000000..c212274df --- /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 000000000..eaa355796 --- /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('host') + ->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 38c841ebc..34df2b3ef 100644 --- a/app/Queries/NotificationBuilder.php +++ b/app/Queries/NotificationBuilder.php @@ -41,6 +41,12 @@ public function relevant(): self ->whereColumn('commits.user_id', 'notifications.recipient_id') ->whereColumn('commits.status', 'notifications.type') ->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 927458fb8..1d07fbc96 100644 --- a/app/Queries/NotificationGroupsQuery.php +++ b/app/Queries/NotificationGroupsQuery.php @@ -80,6 +80,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 000000000..158d5a0ad --- /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 000000000..7a758a643 --- /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 000000000..a6e3ca16e --- /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 000000000..c0ba98813 --- /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 000000000..2ce44988e --- /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 000000000..8df1a5120 --- /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 000000000..6c0cd9ddc --- /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 000000000..f8f84fc33 --- /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 7b92f146c..8afa40f59 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 37d44786b..4438b77e8 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'; @@ -97,6 +99,11 @@ function useNavItems(): (NavItem & { isActive: boolean })[] { }, ] : []), + { + title: 'Events', + href: EventController.index(), + icon: CalendarDays, + }, { title: 'Profile', href: hrefFor((user) => show(user)), @@ -143,7 +150,9 @@ function useNavItems(): (NavItem & { isActive: boolean })[] { ? isCurrentOrParentUrl('/remotes') : item.title === 'Operations' ? isCurrentOrParentUrl('/operations') - : isCurrentUrl(item.href)), + : item.title === 'Events' + ? isCurrentOrParentUrl('/events') + : isCurrentUrl(item.href)), })); } @@ -539,7 +548,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 9634b1df0..836778eb8 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'; @@ -480,6 +481,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 format = eventFormat(event); + const where = { + 'In person': event.location ?? '', + Online: 'Online', + Hybrid: `${eventPlace(event)} · Online`, + }[format]; + const isPending = commit.status !== 'approved'; + + return ( +
+ + + + + + {event.title} + + + + + {formatEventWhen(event, timeZone)} + + + {format === 'Online' ? ( + + ) : ( + + )} + + {where} + + + + + {isPending && event.url !== null && ( +

+ {event.url} +

+ )} + {isPending && + 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 000000000..2513f1739 --- /dev/null +++ b/resources/js/components/event-list.tsx @@ -0,0 +1,122 @@ +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, + eventPlace, + eventStateAt, + eventStateLabels, +} from '@/lib/event'; +import { formatEventWhen } from '@/lib/event-dates'; +import { formatCount, formatExactCount } 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)} +

    +

    + {eventPlace(event) !== null && + `${eventPlace(event)} · `} + hosted by @{event.host.handle} +

    +
    + + + {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 000000000..e4e7b5b65 --- /dev/null +++ b/resources/js/components/event-response-button.tsx @@ -0,0 +1,178 @@ +import { Link, router, usePage } from '@inertiajs/react'; +import { CalendarCheck, 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, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + 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 X; +}[] = [ + { + 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} + + {/* Radio items, so the current choice is announced as checked rather than only drawn. */} + + choose( + value === 'none' + ? null + : (value as EventResponse), + ) + } + > + {choices.map((choice) => ( + + + + {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 000000000..30a1b56dc --- /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 000000000..55e802fae --- /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/components/quoted-commit.tsx b/resources/js/components/quoted-commit.tsx index e59fe21eb..253eb26dd 100644 --- a/resources/js/components/quoted-commit.tsx +++ b/resources/js/components/quoted-commit.tsx @@ -1,4 +1,5 @@ -import { Link } from '@inertiajs/react'; +import { Link, usePage } from '@inertiajs/react'; +import { CalendarDays } from 'lucide-react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import CommitLinkPreviewController from '@/actions/App/Http/Controllers/CommitLinkPreviewController'; import { CommitBodyExcerpt } from '@/components/commit-body'; @@ -8,6 +9,7 @@ import { CommitStatusBadge } from '@/components/commit-status-badge'; import { LinkPreview } from '@/components/link-preview'; import { UserAvatar } from '@/components/user-avatar'; import { useNow } from '@/hooks/use-now'; +import { formatEventWhen } from '@/lib/event-dates'; import { cn, formatFullDateTime, @@ -26,6 +28,7 @@ export function QuotedCommit({ label?: string; }) { const now = useNow(); + const { auth } = usePage().props; if (commit === null || isDeleted(commit)) { return (

+ {commit.event && ( + // The quote links to the commit already, so the event it announces is named rather than linked. + + + + {commit.event.title} + + + {formatEventWhen( + commit.event, + auth.user?.timezone ?? undefined, + )} + + + )} {commit.images && } {commit.poll && ( diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx index 5b9e046d8..aacb6b6ea 100644 --- a/resources/js/layouts/app-layout.tsx +++ b/resources/js/layouts/app-layout.tsx @@ -145,7 +145,8 @@ export default function AppLayout({ children }: AppLayoutProps) { )} - {!isMessages && ( + {/* The new event form is a composer of its own, so the button would only cover its fields. */} + {!isMessages && component !== 'event/create' && ( + 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 000000000..a2d41803d --- /dev/null +++ b/resources/js/lib/event.ts @@ -0,0 +1,136 @@ +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 }; +} + +/** + * The event's place, unless it only says it is online. + */ +export function eventPlace(event: Pick): string | null { + return event.location === null || + event.location.trim().toLowerCase() === 'online' + ? null + : event.location; +} + +/** + * A place, a link, or both, as GitHub would label it. + */ +export function eventFormat( + event: Pick, +): 'In person' | 'Online' | 'Hybrid' { + if (eventPlace(event) !== null && event.url !== null) { + return 'Hybrid'; + } + + return event.url !== null ? 'Online' : 'In person'; +} + +/** + * The zone's short name at that moment, like "GMT+1" or "CET", so times say which clock they follow. + */ +export function timeZoneLabel(date: Date, timeZone?: string): string { + return ( + new Intl.DateTimeFormat(undefined, { + timeZone, + timeZoneName: 'short', + }) + .formatToParts(date) + .find((part) => part.type === 'timeZoneName')?.value ?? '' + ); +} + +/** + * How far the zone's clock is ahead of UTC at that moment, in milliseconds. + */ +function zoneOffset(date: Date, timeZone?: string): number { + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-US', { + timeZone, + hourCycle: 'h23', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }) + .formatToParts(date) + .map((part) => [part.type, Number(part.value)]), + ); + + return ( + Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + ) - + Math.floor(date.getTime() / 1000) * 1000 + ); +} + +/** + * Reads a datetime-local value on the zone's clock rather than the browser's, so an event is entered in the same zone + * its pages show it in. + */ +export function wallTimeToInstant(value: string, timeZone?: string): string { + if (value === '') { + return ''; + } + + const [date, time] = value.split('T'); + const [year, month, day] = date.split('-').map(Number); + const [hour, minute] = time.split(':').map(Number); + const wall = Date.UTC(year, month - 1, day, hour, minute); + const guess = wall - zoneOffset(new Date(wall), timeZone); + + // Around a clock change the offset at the guess can differ from the one at the result, so it is taken again. + return new Date(wall - zoneOffset(new Date(guess), timeZone)).toISOString(); +} diff --git a/resources/js/pages/event/create.tsx b/resources/js/pages/event/create.tsx new file mode 100644 index 000000000..1184e2769 --- /dev/null +++ b/resources/js/pages/event/create.tsx @@ -0,0 +1,326 @@ +import { Head, Link, useForm, usePage } 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 { useNow } from '@/hooks/use-now'; +import AppLayout from '@/layouts/app-layout'; +import { timeZoneLabel, wallTimeToInstant } from '@/lib/event'; +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'; + +type AgendaDraft = { label: string; starts_at: string }; + +export default function Create({ + limits, +}: { + limits: { + title: number; + body: number; + location: number; + url: number; + agendaItems: number; + agendaLabel: number; + durationDays: number; + }; +}) { + const { auth } = usePage().props; + const now = useNow(); + // Times are entered on the clock the event pages show them on: the profile's time zone, else the browser's. + const timeZone = auth.user?.timezone ?? undefined; + const zoneName = + timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + const zoneLabel = timeZoneLabel(now, timeZone); + const toInstant = (value: string) => wallTimeToInstant(value, timeZone); + 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. + + + {limits.body - 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" + /> + +
+

+ Times are in {zoneName} + {zoneLabel === zoneName ? '' : ` (${zoneLabel})`}. +

+
+ +
+
+ + + 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 000000000..3c20d3347 --- /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 000000000..a5549b4a8 --- /dev/null +++ b/resources/js/pages/event/show.tsx @@ -0,0 +1,437 @@ +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, + eventPlace, + eventStateAt, + timeZoneLabel, +} 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, + attendeesLimit, + commit, + replies, +}: { + event: Event & { going_count: number; agenda_items: EventAgendaItem[] }; + host: User | null; + response: EventResponse | null; + attendees: User[]; + attendeesLimit: number; + commit: Commit; + replies: CursorPaginated; +}) { + const { auth } = usePage().props; + const now = useNow(); + const timeZone = auth.user?.timezone ?? undefined; + const place = eventPlace(event); + 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.handle} + {' '} + {state === 'cancelled' + ? 'cancelled this event' + : 'is hosting'}{' '} + ·{' '} + {' '} + · {event.going_count} going + + )} +
+ +
+ + {isHost && isOpen && } +
+
+ +
+ + + {formatEventWhen(event, timeZone)} ·{' '} + {timeZoneLabel(new Date(event.starts_at), timeZone)} + + + {place !== null && ( + + + {place} + + + )} + {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 ? ( + + No one yet + + ) : ( +
    + {attendees.map((attendee) => ( + + ))} + {/* Only once the avatars are full, so it never stands for someone left out across a block. */} + {attendees.length >= attendeesLimit && + 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 }: { user: User }) { + 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 fcb7111b6..77199a26d 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -3,6 +3,7 @@ import { Head, InfiniteScroll, Link, router, usePoll } from '@inertiajs/react'; import { ArrowUp, AtSign, + CalendarX2, Check, ChevronDown, ChevronsUp, @@ -23,6 +24,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 { CommitBodyExcerpt } from '@/components/commit-body'; @@ -40,6 +42,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, @@ -728,6 +731,7 @@ export function NotificationContent({ }) { const now = useNow(); const { user } = useUser(); + const timeZone = user?.timezone ?? undefined; const actorRef = useRef(null); const { actor } = notification; const actors = @@ -763,8 +767,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, @@ -789,10 +798,12 @@ export function NotificationContent({ notification.type === 'leveled_up' || (!summary && notification.type === 'starred') ? show(actor) - : CommitController.show({ - user: isOwnCommit ? user : actor, - commit: notification.commit, - }); + : cancelledEvent + ? EventController.show(cancelledEvent.id) + : CommitController.show({ + user: isOwnCommit ? user : actor, + commit: notification.commit, + }); const time = (