Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,10 @@ Pending replies, quotes, and mentions wait for approval before notifying others.

Sources: [notifications page](resources/js/pages/notification/index.tsx), [notification filtering](app/Queries/NotificationBuilder.php), [grouping](app/Queries/NotificationGroupsQuery.php), [moderation notifications](app/Actions/UpdateCommitStatuses.php).

Remotes can also show **browser notifications** and play a **notification sound**, controlled independently under **Remotes** in your account's **Settings → Profile**. Both are on by default. Choose **Allow notifications** there if your browser still needs permission for banners; the site never requests permission automatically on page load. Sound does not require notification permission and can stay on when banners are off. Each choice stays saved for the signed-in account on that browser, and an earlier opt-out from combined alerts keeps both off until you change them. Alerts work only while the site is open, including background tabs where the browser allows polling; they are not push notifications after closing the site. Browsers may suspend background tabs or block sound until you interact with the page. Alerts never show message text and share a 10-second cooldown across remotes and tabs so a burst stays quiet. While the Remotes page is visible and focused, all remote alerts are silenced across tabs; other conversations keep their unread counts. Outside that page, alerts skip the remote you are actively reading in the dock. Switching away lets new messages alert again, without replaying messages suppressed while you were on Remotes. Existing unread messages do not trigger alerts when you open the site.

To silence one direct remote or org, open its **…** menu and choose **Mute**, below **Stash**, then **For one hour**, **Until tomorrow**, **For one week**, or **Forever**. **Until tomorrow** ends at the next midnight in your account's timezone, or UTC if no timezone is set. The mute follows your account across devices, does not tell other members, and suppresses browser alerts and sound without hiding messages or changing unread counts. New messages do not cancel a mute. Timed mutes expire automatically; choose **Unmute** in the same menu to end any mute early.

Livestream history: the notification requirements on [day one at 5:28:53](https://www.youtube.com/watch?v=6zCNQa3hNm0&t=19733s) explicitly included removing stale activity after an unfollow or deleted reply. The stated goal was to avoid both spam and notifications that “no longer make sense.”

### Remotes: direct messages
Expand Down
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +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.
- **Remotes:** private one-to-one and group messages (orgs).
- **Notifications:** in-app only.
- **Notifications:** in-app activity, plus independently controlled browser and sound alerts for Remotes while the site is open, enabled by default; only browser banners require notification permission.
- **Search:** developers by name, commits by meaning.
- **Safety:** mute, block, reports, and moderation.
- **Bots:** Tom answers questions about /dev/null from the knowledge base, the public repository, and aggregate community statistics; World answers "hello world". They use @tom and @world until those GitHub owners sign in, then keep working as @devnull-tom and @devnull-world.
Expand Down
30 changes: 26 additions & 4 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
* blocking: Generator<int, ExportUser>,
* dismissed_suggestions: Generator<int, ExportUser>,
* messages: Generator<int, ExportMessage>,
* remote_mutes: array<int, array{to: string|null, muted_until_at: string|null}>,
* notifications: Generator<int, array<string, int|string|null>>,
* ssh_keys: array<mixed>,
* devices: array<int, array{name: string, fingerprint: string, approved_at: string, last_used_at: string|null}>,
Expand Down Expand Up @@ -105,6 +106,20 @@ public function handle(User $user): array
'blocking' => $this->usernames($user->blocking()),
'dismissed_suggestions' => $this->usernames($user->dismissedSuggestions()),
'messages' => $this->messages($user),
'remote_mutes' => Conversation::query()
->join('conversation_participants as mine', 'mine.conversation_id', '=', 'conversations.id')
->where('mine.user_id', $user->id)
->whereNotNull('mine.muted_until_at')
->select('conversations.*')
->addSelect('mine.muted_until_at as muted_until_at')
->with('participants.user')
->oldest('mine.id')
->get()
->map(fn (Conversation $conversation): array => [
'to' => $this->recipient($user, $conversation),
'muted_until_at' => $conversation->muted_until_at?->toIso8601String(),
])
->all(),
'notifications' => $this->notifications($user),
'ssh_keys' => $user->sshKeys()->pluck('fingerprint')->all(),
'devices' => $user->devices()->oldest('approved_at')->get()->map(fn (Device $device): array => [
Expand Down Expand Up @@ -217,10 +232,7 @@ private function messages(User $user): Generator
->whereIn('id', ConversationParticipant::query()->select('conversation_id')->where('user_id', $user->id))
->with('participants.user')
->get()
->mapWithKeys(fn (Conversation $conversation): array => [$conversation->id => $conversation->isOrg()
? 'org: '.($conversation->name ?? $conversation->id)
: $conversation->participants->firstWhere('user_id', '!=', $user->id)?->user->username,
]);
->mapWithKeys(fn (Conversation $conversation): array => [$conversation->id => $this->recipient($user, $conversation)]);

foreach (Message::query()->where('user_id', $user->id)->whereNull('event')->with('voice')->lazyById(500) as $message) {
yield [
Expand All @@ -232,6 +244,16 @@ private function messages(User $user): Generator
}
}

/**
* The other person in a conversation, or the org it is.
*/
private function recipient(User $user, Conversation $conversation): ?string
{
return $conversation->isOrg()
? 'org: '.($conversation->name ?? $conversation->id)
: $conversation->participants->firstWhere('user_id', '!=', $user->id)?->user->username;
}

/**
* @param BelongsToMany<User, User> $users
* @return Generator<int, ExportUser>
Expand Down
29 changes: 29 additions & 0 deletions app/Actions/UpdateConversationMute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\ConversationMuteDuration;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\User;
use Carbon\CarbonImmutable;

final readonly class UpdateConversationMute
{
/**
* Muting is the user's own: everyone else keeps hearing the conversation.
*/
public function handle(User $user, Conversation $conversation, ConversationMuteDuration $duration): ?CarbonImmutable
{
$until = $duration->until($user->timezone);

ConversationParticipant::query()
->where('user_id', $user->id)
->where('conversation_id', $conversation->id)
->update(['muted_until_at' => $until]);

return $until;
}
}
34 changes: 34 additions & 0 deletions app/Enums/ConversationMuteDuration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Enums;

use Carbon\CarbonImmutable;

/**
* How long a remote stays muted for the user who muted it.
*/
enum ConversationMuteDuration: string
{
case Hour = 'hour';
case Tomorrow = 'tomorrow';
case Week = 'week';
case Forever = 'forever';
case Off = 'off';

/**
* When the mute ends, to the second the column keeps: forever is the far end of the calendar, and off is no
* mute at all.
*/
public function until(?string $timezone = null): ?CarbonImmutable
{
return match ($this) {
self::Hour => CarbonImmutable::now()->startOfSecond()->addHour(),
self::Tomorrow => CarbonImmutable::now($timezone ?? 'UTC')->addDay()->startOfDay()->utc(),
self::Week => CarbonImmutable::now()->startOfSecond()->addWeek(),
self::Forever => CarbonImmutable::parse('9999-12-31T00:00:00Z'),
self::Off => null,
};
}
}
25 changes: 25 additions & 0 deletions app/Http/Controllers/Api/ConversationMuteController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\UpdateConversationMute;
use App\Http\Requests\UpdateApiConversationMuteRequest;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;

final readonly class ConversationMuteController
{
/**
* Mutes the conversation for the user only, until the chosen duration ends or forever, or unmutes it.
*/
public function update(UpdateApiConversationMuteRequest $request, #[CurrentUser] User $user, User $partner, UpdateConversationMute $action): JsonResponse
{
$until = $action->handle($user, Conversation::betweenOrFail($user, $partner), $request->duration());

return response()->json(['muted_until_at' => $until?->toJSON()]);
}
}
25 changes: 25 additions & 0 deletions app/Http/Controllers/Api/OrgMuteController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\UpdateConversationMute;
use App\Http\Requests\UpdateApiOrgMuteRequest;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;

final readonly class OrgMuteController
{
/**
* Mutes the org for the user only, until the chosen duration ends or forever, or unmutes it.
*/
public function update(UpdateApiOrgMuteRequest $request, #[CurrentUser] User $user, Conversation $org, UpdateConversationMute $action): JsonResponse
{
$until = $action->handle($user, $org, $request->duration());

return response()->json(['muted_until_at' => $until?->toJSON()]);
}
}
25 changes: 25 additions & 0 deletions app/Http/Controllers/ConversationMuteController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\UpdateConversationMute;
use App\Http\Requests\UpdateConversationMuteRequest;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;

final readonly class ConversationMuteController
{
/**
* Mutes the conversation for the user only, until the chosen duration ends or forever, or unmutes it.
*/
public function update(UpdateConversationMuteRequest $request, #[CurrentUser] User $user, User $partner, UpdateConversationMute $action): JsonResponse
{
$until = $action->handle($user, Conversation::betweenOrFail($user, $partner), $request->duration());

return response()->json(['muted_until_at' => $until?->toJSON()]);
}
}
1 change: 1 addition & 0 deletions app/Http/Controllers/InboxController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
public function index(ListInboxRequest $request, #[CurrentUser] User $user, ConversationsQuery $conversations): JsonResponse
{
return response()->json([
...($request->notificationBaseline() ? ['notification_baseline' => $conversations->notificationBaseline($user)] : []),
...$conversations->get($user, $request->tab(), $request->term(), $request->beforeId()),
'unread' => $conversations->unreadCount($user),
'requests' => $conversations->requestCount($user),
Expand Down
25 changes: 25 additions & 0 deletions app/Http/Controllers/OrgMuteController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\UpdateConversationMute;
use App\Http\Requests\UpdateOrgMuteRequest;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;

final readonly class OrgMuteController
{
/**
* Mutes the org for the user only, until the chosen duration ends or forever, or unmutes it.
*/
public function update(UpdateOrgMuteRequest $request, #[CurrentUser] User $user, Conversation $org, UpdateConversationMute $action): JsonResponse
{
$until = $action->handle($user, $org, $request->duration());

return response()->json(['muted_until_at' => $until?->toJSON()]);
}
}
6 changes: 6 additions & 0 deletions app/Http/Requests/ListInboxRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,15 @@ public function rules(): array
'tab' => ['nullable', config('remotes.requests') ? Rule::enum(InboxTab::class) : Rule::enum(InboxTab::class)->except(InboxTab::Fetched)],
'q' => ['nullable', 'string', 'max:100'],
'before' => ['nullable', 'uuid'],
'notification_baseline' => ['sometimes', 'boolean'],
];
}

public function notificationBaseline(): bool
{
return $this->boolean('notification_baseline');
}

public function tab(): InboxTab
{
return $this->enum('tab', InboxTab::class) ?? InboxTab::Open;
Expand Down
47 changes: 47 additions & 0 deletions app/Http/Requests/UpdateApiConversationMuteRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Enums\ConversationMuteDuration;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;

final class UpdateApiConversationMuteRequest extends FormRequest
{
/**
* There is nothing to mute before the conversation starts, and muting never starts one.
*/
public function authorize(): Response
{
$user = $this->user();
assert($user instanceof User);

$partner = $this->route('partner');
assert($partner instanceof User);

return $user->canMessage($partner) && Conversation::between($user, $partner) instanceof Conversation
? Response::allow()
: Response::denyAsNotFound();
}

/**
* @return array<string, array<int, string|Enum>>
*/
public function rules(): array
{
return [
'duration' => ['required', 'string', Rule::enum(ConversationMuteDuration::class)],
];
}

public function duration(): ConversationMuteDuration
{
return ConversationMuteDuration::from($this->string('duration')->value());
}
}
43 changes: 43 additions & 0 deletions app/Http/Requests/UpdateApiOrgMuteRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Enums\ConversationMuteDuration;
use App\Enums\OrgRole;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;

final class UpdateApiOrgMuteRequest extends FormRequest
{
public function authorize(): Response
{
$user = $this->user();
assert($user instanceof User);

$org = $this->route('org');
assert($org instanceof Conversation);

return $user->remotes_enabled && $org->roleOf($user) instanceof OrgRole ? Response::allow() : Response::denyAsNotFound();
}

/**
* @return array<string, array<int, string|Enum>>
*/
public function rules(): array
{
return [
'duration' => ['required', 'string', Rule::enum(ConversationMuteDuration::class)],
];
}

public function duration(): ConversationMuteDuration
{
return ConversationMuteDuration::from($this->string('duration')->value());
}
}
Loading
Loading