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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,7 @@ VITE_APP_NAME="${APP_NAME}"

REMOTES_REQUESTS=false
REMOTES_EVERYONE=false

WEB_PUSH_SUBJECT=https://dev-null.sh
WEB_PUSH_PUBLIC_KEY=
WEB_PUSH_PRIVATE_KEY=
12 changes: 9 additions & 3 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,9 +330,15 @@ 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. Only the sound has a 10-second cooldown shared across remotes and tabs; notification banners continue updating during a burst. The browser tab title shows the combined unread notification and remote message counts from the navigation, such as **(2)**, caps the display at **(99+)** above 99, and removes the prefix when nothing remains unread. The count does not depend on sound or notification permission. Alerts skip only the remote you are actively reading in a visible, focused tab, whether on the Remotes page or in the dock. Messages in other remotes can alert while you browse any page, including a different remote or the Remotes inbox. Switching away lets new messages alert again, without replaying messages suppressed while you were reading that remote. Existing unread messages do not trigger alerts when you open the site.
Remotes alerts are controlled under **Settings → Profile → Remotes**. **On-site notifications** and **Notification sound** are on by default. On-site alerts stay inside /dev/null and never ask for browser permission. Sound does not require notification permission and plays only while the site is running; the browser may require interaction before it can play.

With **Notifications** enabled, a new remote alert alternates the browser tab title between its normal title and **Alice in dev/null core sent you a message**, or **Alice sent you a message** for a direct remote. It switches every second for three normal/message cycles, then keeps the message title until you view the receiving remote. All already-open tabs for your account share the alert through the browser's native BroadcastChannel API; opening that remote in a visible, focused tab restores its alert titles to the normal titles and unread counts everywhere. Focusing an unrelated page or a different remote does not dismiss it. A newer alert updates the sender without restarting the blinking, and the sound cooldown does not delay it. This uses the browser's native page title and works even without permission for notification banners. Message text is never included, and muted or actively read remotes do not trigger it.
**Off-site notifications** are off by default. Turn that separate switch on to allow system notifications from this browser or installed PWA, including when closed. This is an explicit choice for each account and browser; an existing browser permission or an earlier on-site preference does not turn it on. On iPhone and iPad, add /dev/null to your Home Screen and turn the switch on from the installed app. The browser asks for permission only after you choose to enable off-site notifications. Declining permission or a rejected enrollment leaves them off. If /dev/null cannot confirm that a failed enrollment was removed, the switch stays on with an error so you can try turning it off again. Turning it off revokes that account's delivery registration; the browser permission and shared browser subscription can remain for reuse. On-site alerts remain independent. Up to 20 browsers or installed apps per account can have off-site notifications enabled. At the limit, existing enrollments stay active; turn off off-site notifications in one before enabling another.

Off-site alerts are silent, contain no message data, and open the Remotes inbox. Delivery depends on browser and operating-system permissions, background delivery, and Focus settings. A fully quit browser may prevent delivery. Delivery registrations are removed on sign-out, account deletion, turning Remotes off, or when the push service reports that they expired. Delivery briefly waits and rechecks unread messages, membership, mutes, and blocks before sending.

Choices stay saved for the signed-in account on that browser. Earlier combined alert opt-outs continue to apply to on-site alerts and sound until each is changed. Alerts never show message text. Only sound has a 10-second cooldown shared across remotes and tabs. The browser tab title shows the combined unread notification and remote message counts from navigation, such as **(2)**, caps the display at **(99+)** above 99, and removes the prefix when nothing remains unread. Counts do not depend on sound or off-site permission. While the site is open, alerts skip the remote you are actively reading in a visible, focused tab, whether on the Remotes page or in the dock. Messages in other remotes can alert while you browse any page. Existing unread messages do not trigger on-site alerts when you open the site.

With **On-site notifications** enabled, a new remote alert alternates the browser tab title between its normal title and **Alice in dev/null core sent you a message**, or **Alice sent you a message** for a direct remote. It switches every second for three normal/message cycles, then keeps the message title until you view the receiving remote. All already-open tabs for your account share the alert through the browser's native BroadcastChannel API; opening that remote in a visible, focused tab restores its alert titles to the normal titles and unread counts everywhere. Focusing an unrelated page or a different remote does not dismiss it. A newer alert updates the sender without restarting the blinking, and the sound cooldown does not delay it. This uses the browser's native page title and works even without permission for notification banners. Message text is never included, and muted or actively read remotes do not trigger it.

Pin a direct remote or org from its **…** menu to keep it above unpinned conversations in your inbox. Pinned conversations stay ordered by their latest message. Pins belong to your account and follow you across devices; other participants do not see them. Choose **Unpin** to return a remote to its usual place. Stashing keeps its pin, and it returns pinned when a new message pops it back into Open.

Expand Down Expand Up @@ -408,7 +414,7 @@ Sources: [appearance settings](resources/js/pages/appearance/update.tsx), [lane

/dev/null is a progressive web app. Browsers that support installation (Chrome, Edge, and Safari's **Add to Home Screen** / **Add to Dock**) offer to install it from the web app manifest; the installed app opens in its own window without browser controls, uses the app icon, and follows the Light, Dark, or System appearance in its title and status bars. The app's icon menu offers shortcuts to the Following log, the Latest log, and Notifications. Where the browser supports it, links launched into the installed app reuse its existing window; installing the app does not guarantee that every browser will open links in it.

A service worker caches the built assets of the current release and shows an offline page when a page cannot be reached; the page reloads on its own once the connection returns. The service worker does not cache pages or retain signed-in page content for offline access, and there is no offline reading of logs or offline posting. Browser-local preferences and [stashed drafts](#stashed-drafts) are separate from this asset cache. There are no push notifications; the installed app polls for new notifications and messages the same way the browser does.
A service worker caches the built assets of the current release and shows an offline page when a page cannot be reached; the page reloads on its own once the connection returns. The service worker does not cache pages or retain signed-in page content for offline access, and there is no offline reading of logs or offline posting. Browser-local preferences and [stashed drafts](#stashed-drafts) are separate from this asset cache. While open, the installed app polls for new notifications and messages the same way the browser does. Remotes can also send silent, generic push notifications while the app is backgrounded or closed when you explicitly enable **Off-site notifications** under **Settings → Profile → Remotes**. This switch is off by default; activity notifications remain on-site.

Sources: [web app manifest](public/site.webmanifest), [service worker](public/sw.js), [offline page](public/offline.html), [service worker registration](resources/js/lib/service-worker.ts), [document head](resources/views/app.blade.php), [manifest tests](tests/Feature/WebAppManifestTest.php).

Expand Down
4 changes: 2 additions & 2 deletions 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 activity, plus independently controlled browser and sound alerts for Remotes while the site is open, enabled by default; only browser banners require notification permission.
- **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.
- **Emails:** an occasional update the admins write to everyone, or to a filtered audience, with one-click unsubscribe. Never per-activity notifications.
- **Safety:** mute, block, reports, and moderation.
Expand All @@ -51,7 +51,7 @@ Rules every feature must keep:
- **Store as little as possible.** Assume the database has leaked: no data without a feature that needs it, sensitive content encrypted, no secrets stored, and everything about a person erased with their account and included in their export (see `.ai/guidelines/data.blade.php`). The one deliberate exception is moderation: bans and timeouts are kept by GitHub account after deletion, holding only the GitHub id, reason, and dates, so deleting an account and signing up again can't escape or reset them.
- **Same rules on every surface.** Web, mobile API, terminal, and Action share validation, moderation, visibility, and rate limits.

Deliberately excluded: sign-up or identity outside GitHub, editing commits, and email or push notifications.
Deliberately excluded: sign-up or identity outside GitHub, editing commits, and email activity notifications.

## Brand Commitments

Expand Down
5 changes: 5 additions & 0 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use App\Models\Message;
use App\Models\Poll;
use App\Models\PollOption;
use App\Models\PushSubscription;
use App\Models\User;
use App\Models\UserTimeout;
use App\Queries\CommitBuilder;
Expand Down Expand Up @@ -48,6 +49,7 @@
* 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>,
* push_subscriptions: array<int, array{id: string}>,
* devices: array<int, array{name: string, fingerprint: string, approved_at: string, last_used_at: string|null}>,
* timeouts: array<int, array{reason: string, ends_at: string, created_at: string}>,
* sessions: Collection<int, stdClass>
Expand Down Expand Up @@ -139,6 +141,9 @@ public function handle(User $user): array
->all(),
'notifications' => $this->notifications($user),
'ssh_keys' => $user->sshKeys()->pluck('fingerprint')->all(),
'push_subscriptions' => $user->pushSubscriptions()->get()->map(fn (PushSubscription $subscription): array => [
'id' => $subscription->id,
])->all(),
'devices' => $user->devices()->oldest('approved_at')->get()->map(fn (Device $device): array => [
'name' => $device->name,
'fingerprint' => $device->fingerprint,
Expand Down
8 changes: 8 additions & 0 deletions app/Actions/CreateMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Actions;

use App\Enums\RemoteStatus;
use App\Jobs\SendRemotePush;
use App\Jobs\UnfurlLink;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
Expand Down Expand Up @@ -89,6 +90,13 @@ public function handle(User $sender, Conversation $conversation, string $body, s

self::advance($conversation, $message, unstash: true);

if (config('services.web_push.public_key') && config('services.web_push.private_key')) {
foreach (ConversationParticipant::query()->where('conversation_id', $conversation->id)
->where('user_id', '!=', $sender->id)->whereHas('user.pushSubscriptions')->get(['user_id']) as $participant) {
dispatch(new SendRemotePush($message->id, $participant->user_id))->delay(now()->addSeconds(10))->afterCommit();
}
}

ConversationParticipant::query()
->where('conversation_id', $conversation->id)
->where('user_id', $sender->id)
Expand Down
20 changes: 20 additions & 0 deletions app/Actions/CreatePushSubscription.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\PushSubscription;
use App\Models\User;

final readonly class CreatePushSubscription
{
public function handle(User $user, string $endpoint): PushSubscription
{
// One browser subscription belongs to only the account currently using it.
return PushSubscription::query()->updateOrCreate(
['endpoint_hash' => hash('sha256', $endpoint)],
['user_id' => $user->id, 'endpoint' => $endpoint],
);
Comment thread
xHeaven marked this conversation as resolved.
}
}
16 changes: 16 additions & 0 deletions app/Actions/DeletePushSubscription.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\PushSubscription;
use App\Models\User;

final readonly class DeletePushSubscription
{
public function handle(User $user, string $endpointHash): void
{
PushSubscription::query()->where('user_id', $user->id)->where('endpoint_hash', $endpointHash)->delete();
}
}
4 changes: 4 additions & 0 deletions app/Actions/UpdateUserRemotes.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,9 @@
public function handle(User $user, bool $enabled): void
{
$user->update(['remotes_enabled' => $enabled]);

if (! $enabled) {
$user->pushSubscriptions()->delete();
}
}
}
40 changes: 40 additions & 0 deletions app/Http/Controllers/PushSubscriptionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\CreatePushSubscription;
use App\Actions\DeletePushSubscription;
use App\Http\Requests\CreatePushSubscriptionRequest;
use App\Http\Requests\DeletePushSubscriptionRequest;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;

final readonly class PushSubscriptionController
{
public function index(): JsonResponse
{
return response()->json([
'public_key' => config('services.web_push.private_key') ? config('services.web_push.public_key') : null,
])->header('Cache-Control', 'private, no-store');
}

public function store(CreatePushSubscriptionRequest $request, #[CurrentUser] User $user, CreatePushSubscription $action): Response
{
$subscription = $action->handle($user, $request->string('endpoint')->toString());
$request->session()->put('push_endpoint_hash', $subscription->endpoint_hash);

return response()->noContent();
}

public function destroy(DeletePushSubscriptionRequest $request, #[CurrentUser] User $user, DeletePushSubscription $action): Response
{
$action->handle($user, hash('sha256', $request->string('endpoint')->toString()));
$request->session()->forget('push_endpoint_hash');

return response()->noContent();
}
}
9 changes: 8 additions & 1 deletion app/Http/Controllers/SessionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
namespace App\Http\Controllers;

use App\Actions\CreateTomFriendship;
use App\Actions\DeletePushSubscription;
use App\Actions\UpdateOrCreateUser;
use App\Jobs\ImportGitHubFollowsJob;
use App\Jobs\SyncGitHubSshKeysJob;
use App\Models\User;
use App\Services\GitHubAccountScreening;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -68,8 +70,13 @@ public function store(Request $request, UpdateOrCreateUser $action, CreateTomFri
return redirect()->intended(route('log.show', $user->log, absolute: false));
}

public function destroy(Request $request): RedirectResponse
public function destroy(Request $request, DeletePushSubscription $deletePushSubscription): RedirectResponse
{
$user = $request->user();
assert($user instanceof User);
$endpointHash = $request->session()->get('push_endpoint_hash');
$deletePushSubscription->handle($user, is_string($endpointHash) ? $endpointHash : '');

Auth::guard('web')->logout();

$request->session()->invalidate();
Expand Down
33 changes: 33 additions & 0 deletions app/Http/Middleware/SerializePushSessions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;

final readonly class SerializePushSessions
{
/**
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$sessionId = $request->cookie(Config::string('session.cookie'));

if (! is_string($sessionId)) {
return $next($request);
}

// Run before StartSession and hold through its save; a cache lease can expire mid-enrollment.
return DB::transaction(function () use ($sessionId, $request, $next): Response {
DB::select('select pg_advisory_xact_lock(hashtextextended(?, 0))', [hash('sha256', 'remote-push-session:'.$sessionId)]);

return $next($request);
});
}
}
31 changes: 31 additions & 0 deletions app/Http/Middleware/SerializePushSubscriptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;

final readonly class SerializePushSubscriptions
{
/**
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
assert($user instanceof User);

// Hold the account lock across validation and creation, including concurrent browser enrollments.
return DB::transaction(function () use ($user, $request, $next): Response {
User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail();
$user->refresh();

return $next($request);
});
}
}
Loading
Loading