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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ Under the graph, a mono 13px strip prints the streaks, `longest streak 12 days
Hovering an avatar, name, or handle (mouse only, 450ms delay, 200ms grace to move into it) opens a 304px Popover preview below the trigger, flipping above near the viewport bottom: a 56px avatar ringed in the user's lane, Follow / Following (Unfollow on hover) top right, name, mono handle with lane dot and a "Follows you" chip, a 3-line bio, and mono counts linking to following and followers. Follow toggles optimistically and rolls back on error. Closes on scroll, Escape, or leaving.

### Notifications
The inbox is a log too: every row hangs on a Rail Gray rail whose node is a 36px hollow circle holding the type icon (person-plus, reply, quote, star, at, check, cross). A sticky header carries the "Notifications" title over a row of page tabs (All, Replies, Quotes, Stars, Mentions, Follows, Moderation) with the sliding Commit Blue underline; on phones the row scrolls sideways with the current filter kept in view. Day markers ("Today", "Yesterday", "Wed, Oct 14") split the rows the way they split the log. A row opens with a line of stacked 32px avatars (up to three, then a mono `+N` disc) and a mono relative time pushed right, then the sentence: the actor's name (600), their mono handle when they act alone, otherwise "and N others", and the action in plain prose. Words someone else wrote (a reply, quote, or mention) follow at 15px like a commit body; your own commit (a star, an approval, a rejection) sits underneath as a hairline-bordered, muted block clamped to three lines. Moderation rows replace the avatars with a mono `approved` or `rejected` chip, and a rejection prints a mono `reason:` key before the moderator's words. Groups expand in place from a Commit Blue toggle ("5 stars" / "Hide stars"), listing one compact row per event behind a 1px Rail Gray guide line, each follower with their own Follow button. Rows the inbox marked read on this visit ring their node in Commit Blue and sit above a `HEAD@{last visit}` marker. While you read, polling adds new rows above without moving the ones on screen, and a Commit Blue pill ("↑ 1 new notification") takes you to them. Empty filters speak in git's voice (`$ cat /dev/notifications | grep stars`) with a link back to all notifications; the end of the list is a hollow root node: "That's the oldest notification. There's nothing before it."
The inbox is a log too: every row hangs on a Rail Gray rail whose node is a 36px hollow circle holding the type icon (person-plus, reply, quote, star, at, check, cross). A sticky header carries the "Notifications" title over a row of page tabs (All, Replies, Quotes, Stars, Mentions, Follows, Moderation) with the sliding Commit Blue underline; on phones the row scrolls sideways with the current filter kept in view. Day markers ("Today", "Yesterday", "Wed, Oct 14") split the rows the way they split the log. A row opens with a line of stacked 32px avatars (up to three, then a mono `+N` disc) and a mono relative time pushed right, then the sentence: the actor's name (600), their mono handle when they act alone, otherwise "and N others", and the action in plain prose. Words someone else wrote (a reply, quote, or mention) follow at 15px like a commit body; your own commit (a star, an approval, a rejection) sits underneath as a hairline-bordered, muted block clamped to three lines. A commit of images alone has no words to show, so an image icon and "Image" or "N images" stand in for them. Moderation rows replace the avatars with a mono `approved` or `rejected` chip, and a rejection prints a mono `reason:` key before the moderator's words. Groups expand in place from a Commit Blue toggle ("5 stars" / "Hide stars"), listing one compact row per event behind a 1px Rail Gray guide line, each follower with their own Follow button. Rows the inbox marked read on this visit ring their node in Commit Blue and sit above a `HEAD@{last visit}` marker. While you read, polling adds new rows above without moving the ones on screen, and a Commit Blue pill ("↑ 1 new notification") takes you to them. Empty filters speak in git's voice (`$ cat /dev/notifications | grep stars`) with a link back to all notifications; the end of the list is a hollow root node: "That's the oldest notification. There's nothing before it."

### Reply Threads
Under a commit's body, a quiet action row: "Reply" (muted, opens an inline bordered composer with its own counter, Cancel, and Reply; ⌘/Ctrl+Enter submits, Escape cancels) "N replies" in Commit Blue, which expands the thread in place, and a muted copy-link icon. Replies are compact rows: 24px (phone) / 28px avatar, name, mono handle (hidden on phones), time, status badge, then the body. Children render below their parent at the parent's left edge, not inside its text column, behind a 1px Rail Gray guide line; each level costs 19px (phone) / 27px. Indentation stops at three levels: deeper replies stay at that depth and carry a mono "↳ replying to @handle" label. Bodies wrap anywhere (`overflow-wrap: anywhere`) so long URLs and tokens never overflow.
Expand Down
2 changes: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ Sources: [Pastéis setting](resources/js/components/pasteis-mode-setting.tsx), [

### Notifications

Open **Notifications** to read in-app activity: follows, replies, quotes, mentions, stars, and moderation messages. The navigation shows unread activity. Related activity is grouped, with expandable details, and filters let you choose categories or all/none. Opening the inbox marks relevant notifications read. You can follow back or unfollow from follower notifications.
Open **Notifications** to read in-app activity: follows, replies, quotes, mentions, stars, and moderation messages. The navigation shows unread activity. Related activity is grouped, with expandable details, and filters let you choose categories or all/none. Opening the inbox marks relevant notifications read. You can follow back or unfollow from follower notifications. A commit posted with only images shows an image icon and "Image" or "N images" where its text would be.

Pending replies, quotes, and mentions wait for approval before notifying others. Self-actions do not notify you; mentioning the parent or quoted author does not duplicate their reply or quote notification. A new follower notifies only once even if they unfollow and follow again. Deleted or unavailable sources, mutes, and blocks affect which notifications remain visible. Rejected commits can produce a moderation message with a reason, without revealing a moderator's identity.

Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/NotificationGroupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@
public function show(ShowNotificationGroupRequest $request, #[CurrentUser] User $user, Notification $notification, NotificationGroupsQuery $query): JsonResponse
{
return response()->json($query->members($user, $notification, $request->type())
->with('commit')->latest()->latest('id')->cursorPaginate(20)->withQueryString());
->latest()->latest('id')->cursorPaginate(20)->withQueryString());
}
}
11 changes: 7 additions & 4 deletions app/Queries/NotificationGroupsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function paginate(User $user, string $type = 'all', ?CarbonImmutable $thr
->with([
'actor' => fn (BelongsTo $query): BelongsTo => $query
->withExists(['followers as is_followed' => fn (Builder $query): Builder => $query->whereKey($user->id)]),
'commit',
'commit' => fn (BelongsTo $query): BelongsTo => $query->withCount('images'),
])
->latest()->latest('id');
$perPage = $through instanceof CarbonImmutable
Expand All @@ -59,7 +59,7 @@ public function paginate(User $user, string $type = 'all', ?CarbonImmutable $thr
public function withQuotedCommits(User $user, CursorPaginator $groups): CursorPaginator
{
new Collection($groups->items())->loadMissing([
'commit.quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->whereBelongsTo($user),
'commit.quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->whereBelongsTo($user)->withCount('images'),
]);

return $groups;
Expand All @@ -85,8 +85,11 @@ public function unreadCount(User $user, string $type = 'all'): int
public function members(User $user, Notification $notification, string $type = 'all'): NotificationBuilder
{
return $this->group($this->visible($user), $type, $notification)
->with(['actor' => fn (BelongsTo $query): BelongsTo => $query
->withExists(['followers as is_followed' => fn (Builder $query): Builder => $query->whereKey($user->id)])]);
->with([
'actor' => fn (BelongsTo $query): BelongsTo => $query
->withExists(['followers as is_followed' => fn (Builder $query): Builder => $query->whereKey($user->id)]),
'commit' => fn (BelongsTo $query): BelongsTo => $query->withCount('images'),
]);
}

public function events(User $user, string $type = 'all'): NotificationBuilder
Expand Down
59 changes: 51 additions & 8 deletions resources/js/pages/notification/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AtSign,
Check,
ChevronDown,
ImageIcon,
MessageSquareReply,
Quote,
Star,
Expand Down Expand Up @@ -54,16 +55,21 @@ import {
} from '@/lib/utils';
import { show } from '@/routes/user';
import type {
BodySegment,
Commit,
CursorPaginated,
DeletedCommit,
Notification,
NotificationCommit,
NotificationGroup,
} from '@/types';

type Filters = { type: string };

type ExcerptedCommit = Pick<
NotificationCommit,
'body_segments' | 'images_count'
>;

const filterOptions: {
type: Filters['type'];
label: string;
Expand Down Expand Up @@ -730,10 +736,10 @@ function FollowBackButton({ actor }: { actor: Notification['actor'] }) {
);
}

function OwnCommitExcerpt({ segments }: { segments: BodySegment[] }) {
function OwnCommitExcerpt({ commit }: { commit: ExcerptedCommit }) {
return (
<div className="mt-2 rounded-lg border px-3 py-2 text-muted-foreground">
<CommitBodyExcerpt segments={segments} className="max-h-[73px]" />
<NotificationCommitBody commit={commit} className="max-h-[73px]" />
</div>
);
}
Expand All @@ -745,13 +751,16 @@ function OwnCommitExcerpt({ segments }: { segments: BodySegment[] }) {
function QuotedOwnCommit({
commit,
}: {
commit: Pick<Commit, 'body_segments' | 'deleted_at'> | DeletedCommit | null;
commit:
| (ExcerptedCommit & Pick<Commit, 'deleted_at'>)
| DeletedCommit
| null;
}) {
if (commit === null || isDeleted(commit)) {
return <QuotedCommit commit={commit} />;
}

return <OwnCommitExcerpt segments={commit.body_segments} />;
return <OwnCommitExcerpt commit={commit} />;
}

function NotificationContent({
Expand Down Expand Up @@ -834,10 +843,10 @@ function NotificationContent({
}[notification.type];
const excerpt =
notification.type === 'followed' ? null : isOwnCommit ? (
<OwnCommitExcerpt segments={notification.commit.body_segments} />
<OwnCommitExcerpt commit={notification.commit} />
) : (
<CommitBodyExcerpt
segments={notification.commit.body_segments}
<NotificationCommitBody
commit={notification.commit}
className="mt-1 max-h-40"
/>
);
Expand Down Expand Up @@ -1053,4 +1062,38 @@ function NotificationContent({
);
}

/**
* A commit of images alone has no body to excerpt, so it says how many images it holds instead.
*/
function NotificationCommitBody({
commit,
className,
}: {
commit: ExcerptedCommit;
className: string;
}) {
if (commit.body_segments.length > 0) {
return (
<CommitBodyExcerpt
segments={commit.body_segments}
className={className}
/>
);
}

return (
<p
className={cn(
'flex items-center gap-1.5 text-[15px] text-muted-foreground',
className,
)}
>
<ImageIcon className="size-4 shrink-0" aria-hidden />
{commit.images_count === 1
? 'Image'
: `${commit.images_count} images`}
</p>
);
}

Index.layout = AppLayout;
15 changes: 11 additions & 4 deletions resources/js/types/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ type BaseNotification = {
reason: string | null;
};

export type NotificationCommit = Omit<Commit, 'user'> & {
images_count: number;
};

export type NotificationGroup = Notification & {
group_id: string;
event_count: number;
Expand All @@ -31,15 +35,18 @@ export type Notification =
type: 'replied' | 'starred';
commit_id: string;
subject_id: string;
commit: Omit<Commit, 'user'>;
commit: NotificationCommit;
})
| (BaseNotification & {
type: 'quoted';
commit_id: string;
subject_id: null;
commit: Omit<Commit, 'user' | 'quoted_commit'> & {
commit: Omit<NotificationCommit, 'quoted_commit'> & {
quoted_commit?:
| Pick<Commit, 'id' | 'body_segments' | 'deleted_at'>
| Pick<
NotificationCommit,
'id' | 'body_segments' | 'deleted_at' | 'images_count'
>
| DeletedCommit
| null;
};
Expand All @@ -48,5 +55,5 @@ export type Notification =
type: 'mentioned' | 'approved' | 'rejected';
commit_id: string;
subject_id: null;
commit: Omit<Commit, 'user'>;
commit: NotificationCommit;
});
25 changes: 25 additions & 0 deletions tests/Feature/Controllers/NotificationControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Actions\CreateCommitStar;
use App\Actions\DeleteUserFollow;
use App\Models\Commit;
use App\Models\CommitImage;
use App\Models\Notification;
use App\Models\User;

Expand Down Expand Up @@ -128,6 +129,30 @@
expect($user->notifications()->unread()->exists())->toBeFalse();
});

it('counts the images of each commit so one without a body still has something to show', function (): void {
$user = User::factory()->create();
$commit = Commit::factory()->for($user)->create(['body' => '']);
CommitImage::factory()->count(2)->for($commit)->create();
$starred = Notification::factory()->for($user, 'recipient')->starred($commit)->create();
$quote = Notification::factory()->for($user, 'recipient')->quoted()->create(['created_at' => now()->subMinute()]);
$quote->commit->quotedCommit->update(['body' => '']);
CommitImage::factory()->for($quote->commit->quotedCommit)->create();

$this->actingAs($user)->get(route('notification.index'))
->assertOk()
->assertInertia(fn ($page) => $page
->where('notifications.data.0.commit.id', $commit->id)
->where('notifications.data.0.commit.body_segments', [])
->where('notifications.data.0.commit.images_count', 2)
->where('notifications.data.1.commit.quoted_commit.body_segments', [])
->where('notifications.data.1.commit.quoted_commit.images_count', 1));

$this->getJson(route('notification-group.show', $starred))
->assertOk()
->assertJsonPath('data.0.commit.id', $commit->id)
->assertJsonPath('data.0.commit.images_count', 2);
});

it('says whether the user follows each new follower back', function (): void {
$user = User::factory()->create();
$followedBack = Notification::factory()->for($user, 'recipient')->create(['created_at' => now()->subMinute()]);
Expand Down
Loading