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` +
+ via Starter Pack + ${starterPack.record.name} +
+ `; + } + 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 } : {}), }; }