diff --git a/DESIGN.md b/DESIGN.md
index 50e649f9c..3721e3831 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -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.
diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md
index 84c9cbbeb..d67da6d15 100644
--- a/KNOWLEDGE_BASE.md
+++ b/KNOWLEDGE_BASE.md
@@ -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.
diff --git a/app/Http/Controllers/NotificationGroupController.php b/app/Http/Controllers/NotificationGroupController.php
index 6f55d5a3b..d6bc4d923 100644
--- a/app/Http/Controllers/NotificationGroupController.php
+++ b/app/Http/Controllers/NotificationGroupController.php
@@ -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());
}
}
diff --git a/app/Queries/NotificationGroupsQuery.php b/app/Queries/NotificationGroupsQuery.php
index 882bdefa3..fcb60631a 100644
--- a/app/Queries/NotificationGroupsQuery.php
+++ b/app/Queries/NotificationGroupsQuery.php
@@ -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
@@ -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;
@@ -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
diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx
index eb2a90e3f..3485bd591 100644
--- a/resources/js/pages/notification/index.tsx
+++ b/resources/js/pages/notification/index.tsx
@@ -5,6 +5,7 @@ import {
AtSign,
Check,
ChevronDown,
+ ImageIcon,
MessageSquareReply,
Quote,
Star,
@@ -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;
@@ -730,10 +736,10 @@ function FollowBackButton({ actor }: { actor: Notification['actor'] }) {
);
}
-function OwnCommitExcerpt({ segments }: { segments: BodySegment[] }) {
+function OwnCommitExcerpt({ commit }: { commit: ExcerptedCommit }) {
return (
-
+
);
}
@@ -745,13 +751,16 @@ function OwnCommitExcerpt({ segments }: { segments: BodySegment[] }) {
function QuotedOwnCommit({
commit,
}: {
- commit: Pick | DeletedCommit | null;
+ commit:
+ | (ExcerptedCommit & Pick)
+ | DeletedCommit
+ | null;
}) {
if (commit === null || isDeleted(commit)) {
return ;
}
- return ;
+ return ;
}
function NotificationContent({
@@ -834,10 +843,10 @@ function NotificationContent({
}[notification.type];
const excerpt =
notification.type === 'followed' ? null : isOwnCommit ? (
-
+
) : (
-
);
@@ -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 (
+
+ );
+ }
+
+ return (
+