diff --git a/src/css/style.css b/src/css/style.css
index 284e90fe..61f8d4e3 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -3775,6 +3775,31 @@ image-carousel {
font-weight: normal;
}
+.notification-via-starter-pack {
+ font-size: 14px;
+ color: var(--text-color-muted);
+}
+
+.notification-starter-pack-link {
+ font-weight: 600;
+ color: var(--text-color);
+ text-decoration: none;
+}
+
+.notification-starter-pack-icon {
+ width: 12px;
+ height: 12px;
+ border-radius: 3px;
+ vertical-align: -1px;
+ margin-right: 4px;
+}
+
+@media (hover: hover) {
+ .notification-starter-pack-link:hover {
+ text-decoration: underline;
+ }
+}
+
.notification-preview {
margin-top: 5px;
font-size: 14px;
diff --git a/src/js/views/notifications.view.js b/src/js/views/notifications.view.js
index fb79f18c..4c86f39f 100644
--- a/src/js/views/notifications.view.js
+++ b/src/js/views/notifications.view.js
@@ -12,7 +12,11 @@ import {
bindPageTitle,
onPageShow,
} from "/js/router.js";
-import { linkToPost, linkToProfile } from "/js/navigation.js";
+import {
+ linkToPost,
+ linkToProfile,
+ linkToStarterPack,
+} from "/js/navigation.js";
import { avatarTemplate } from "/js/templates/avatar.template.js";
import {
cdnImageUrl,
@@ -188,9 +192,13 @@ export default async function notificationsView({
const isFollowBackNotif = isFollowBackNotification(notification);
const type = isFollowBackNotif ? "follow-back" : reason;
+ const starterPack = notification.starterPack ?? null;
const existingGroup = notificationGroups.find(
- (group) => group.type === type && group.subject === subject,
+ (group) =>
+ group.type === type &&
+ group.subject === subject &&
+ group.starterPack?.uri === starterPack?.uri,
);
if (existingGroup && GROUPED_NOTIFICATION_TYPES.includes(type)) {
@@ -199,6 +207,7 @@ export default async function notificationsView({
notificationGroups.push({
type,
subject,
+ starterPack,
notifications: [notification],
});
}
@@ -330,11 +339,35 @@ export default async function notificationsView({
: "followed you"}
ยท ${timeAgo}
+ ${followedViaStarterPackTemplate({
+ starterPack: notificationGroup.starterPack,
+ })}
`;
}
+ function followedViaStarterPackTemplate({ starterPack }) {
+ if (!starterPack) return "";
+ return html`
+
+ `;
+ }
+
function subscribedPostNotificationTemplate({ notificationGroup }) {
const { notifications } = notificationGroup;
const firstNotif = notifications[0];
diff --git a/tests/e2e/specs/views/notifications.view.test.js b/tests/e2e/specs/views/notifications.view.test.js
index d3fbc8de..68a48d6d 100644
--- a/tests/e2e/specs/views/notifications.view.test.js
+++ b/tests/e2e/specs/views/notifications.view.test.js
@@ -6,6 +6,7 @@ import {
createNotification,
createPost,
createProfile,
+ createStarterPack,
createTid,
} from "../../../shared/factories.js";
@@ -240,6 +241,130 @@ test.describe("Notifications view", () => {
await expect(item.locator(".notification-avatar")).toHaveCount(2);
});
+ test("should show the starter pack a follow came from", async ({ page }) => {
+ const mockServer = new MockServer();
+ const starterPack = createStarterPack({
+ uri: "at://did:plc:testuser123/app.bsky.graph.starterpack/pack1",
+ name: "Cool People",
+ creatorHandle: "testuser.bsky.social",
+ });
+ mockServer.addNotifications([
+ createNotification({
+ reason: "follow",
+ author: alice,
+ indexedAt: new Date().toISOString(),
+ starterPack,
+ }),
+ ]);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/notifications");
+
+ const view = page.locator("#notifications-view");
+ const item = view.locator(".notification-item");
+ await expect(item).toHaveCount(1, { timeout: 10000 });
+ const via = item.locator('[data-testid="notification-via-starter-pack"]');
+ await expect(via).toBeVisible();
+ const link = via.locator("a");
+ await expect(link).toHaveText("Cool People");
+ await expect(link).toHaveAttribute(
+ "href",
+ "/profile/testuser.bsky.social/starter-pack/pack1",
+ );
+ });
+
+ test("should group follows from the same starter pack but not different ones", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ const packOne = createStarterPack({
+ uri: "at://did:plc:testuser123/app.bsky.graph.starterpack/pack1",
+ name: "Pack One",
+ creatorHandle: "testuser.bsky.social",
+ });
+ const packTwo = createStarterPack({
+ uri: "at://did:plc:testuser123/app.bsky.graph.starterpack/pack2",
+ name: "Pack Two",
+ creatorHandle: "testuser.bsky.social",
+ });
+ mockServer.addNotifications([
+ createNotification({
+ reason: "follow",
+ author: alice,
+ indexedAt: new Date().toISOString(),
+ starterPack: packOne,
+ }),
+ createNotification({
+ reason: "follow",
+ author: bob,
+ indexedAt: new Date().toISOString(),
+ starterPack: packOne,
+ }),
+ createNotification({
+ reason: "follow",
+ author: charlie,
+ indexedAt: new Date().toISOString(),
+ starterPack: packTwo,
+ }),
+ ]);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/notifications");
+
+ const view = page.locator("#notifications-view");
+ const items = view.locator(".notification-item");
+ await expect(items).toHaveCount(2, { timeout: 10000 });
+ await expect(items.nth(0)).toContainText("Alice");
+ await expect(items.nth(0)).toContainText("1 other");
+ await expect(
+ items.nth(0).locator('[data-testid="notification-via-starter-pack"] a'),
+ ).toHaveText("Pack One");
+ await expect(items.nth(1)).toContainText("Charlie");
+ await expect(
+ items.nth(1).locator('[data-testid="notification-via-starter-pack"] a'),
+ ).toHaveText("Pack Two");
+ });
+
+ test("should not group a starter pack follow with a plain follow", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ const starterPack = createStarterPack({
+ uri: "at://did:plc:testuser123/app.bsky.graph.starterpack/pack1",
+ name: "Pack One",
+ creatorHandle: "testuser.bsky.social",
+ });
+ mockServer.addNotifications([
+ createNotification({
+ reason: "follow",
+ author: alice,
+ indexedAt: new Date().toISOString(),
+ starterPack,
+ }),
+ createNotification({
+ reason: "follow",
+ author: bob,
+ indexedAt: new Date().toISOString(),
+ }),
+ ]);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/notifications");
+
+ const view = page.locator("#notifications-view");
+ const items = view.locator(".notification-item");
+ await expect(items).toHaveCount(2, { timeout: 10000 });
+ await expect(
+ items.nth(0).locator('[data-testid="notification-via-starter-pack"]'),
+ ).toBeVisible();
+ await expect(
+ items.nth(1).locator('[data-testid="notification-via-starter-pack"]'),
+ ).toHaveCount(0);
+ });
+
test("should display 'followed you back' for a follow-back notification", async ({
page,
}) => {
diff --git a/tests/shared/factories.js b/tests/shared/factories.js
index 8474fe9b..d3cb1f62 100644
--- a/tests/shared/factories.js
+++ b/tests/shared/factories.js
@@ -379,6 +379,7 @@ export function createNotification({
isRead = false,
indexedAt = "2025-01-15T12:00:00.000Z",
record,
+ starterPack,
}) {
return {
uri: uri || `at://${author.did}/app.bsky.feed.like/notif-${Date.now()}`,
@@ -390,6 +391,7 @@ export function createNotification({
isRead,
indexedAt,
labels: [],
+ ...(starterPack ? { starterPack } : {}),
};
}