diff --git a/src/js/components/account-switcher-dialog.js b/src/js/components/account-switcher-dialog.js index 06264552..9eb5d676 100644 --- a/src/js/components/account-switcher-dialog.js +++ b/src/js/components/account-switcher-dialog.js @@ -21,7 +21,6 @@ class AccountSwitcherDialog extends Component { this.state = new ReactiveStore("account-switcher-dialog"); this.state.$currentDid = new Signal.State(null); this.state.$accounts = new Signal.State(null); - this.state.$profilesByDid = new Signal.State({}); this.state.$profilesLoading = new Signal.State(true); this.state.$pendingAction = new Signal.State(null); // { type: "switch"|"add", did? } this.innerHTML = ""; @@ -55,16 +54,9 @@ class AccountSwitcherDialog extends Component { const accounts = await this.auth.listAccounts(); this.state.$accounts.set(accounts); try { - const profiles = await this.dataLayer.declarative.ensureDetailedProfiles( + await this.dataLayer.declarative.ensureDetailedProfiles( accounts.map((account) => account.did), ); - const profilesByDid = {}; - for (const profile of profiles) { - if (profile) { - profilesByDid[profile.did] = profile; - } - } - this.state.$profilesByDid.set(profilesByDid); } catch { // pass } finally { @@ -128,7 +120,9 @@ class AccountSwitcherDialog extends Component { > ${orderedAccounts.map((account) => { const profile = - this.state.$profilesByDid.get()[account.did] ?? null; + this.dataLayer.derived.$hydratedDetailedProfiles.get( + account.did, + ) ?? null; const isCurrent = account.did === currentDid; const isPendingRow = pendingAction?.type === "switch" && @@ -177,6 +171,7 @@ class AccountSwitcherDialog extends Component { ? avatarTemplate({ author: profile, clickAction: "none", + showLiveBadge: false, }) : html`
`} diff --git a/src/js/templates/avatarGroup.template.js b/src/js/templates/avatarGroup.template.js index 41025d11..d268f0b7 100644 --- a/src/js/templates/avatarGroup.template.js +++ b/src/js/templates/avatarGroup.template.js @@ -11,7 +11,6 @@ export function avatarGroupTemplate({ authors }) { return avatarTemplate({ author: authors[0], clickAction: "none", - showLiveStatus: false, }); } const shownAuthors = authors.slice(0, 4); @@ -25,7 +24,7 @@ export function avatarGroupTemplate({ authors }) { ${avatarTemplate({ author, clickAction: "none", - showLiveStatus: false, + showLiveBadge: false, })} `, )} diff --git a/src/js/templates/postEmbed.template.js b/src/js/templates/postEmbed.template.js index e65cc728..a112817a 100644 --- a/src/js/templates/postEmbed.template.js +++ b/src/js/templates/postEmbed.template.js @@ -217,6 +217,7 @@ export function quotedPostTemplate({ ${avatarTemplate({ author: quotedPost.author, lazyLoad: lazyLoadImages, + showLiveBadge: false, })} ${postHeaderTextTemplate({ author: quotedPost.author, diff --git a/src/js/views/chatDetail.view.js b/src/js/views/chatDetail.view.js index 74ed6000..dff82624 100644 --- a/src/js/views/chatDetail.view.js +++ b/src/js/views/chatDetail.view.js @@ -1136,7 +1136,7 @@ export default async function chatDetailView({ !profile.viewer?.blocking && !profile.viewer?.blockedBy; return html`
- ${avatarTemplate({ author: profile })} + ${avatarTemplate({ author: profile, showLiveStatus: false })}
${getDisplayName(profile)}${verificationBadgeTemplate({ profile, diff --git a/tests/e2e/specs/views/chat.view.test.js b/tests/e2e/specs/views/chat.view.test.js index 6f22c989..d5ab8927 100644 --- a/tests/e2e/specs/views/chat.view.test.js +++ b/tests/e2e/specs/views/chat.view.test.js @@ -4,6 +4,7 @@ import { MockServer } from "../../mockServer.js"; import { createConvo, createGroupConvo, + createLiveStatusView, createMessage, createProfile, createSystemMessage, @@ -588,6 +589,140 @@ test.describe("Chat view", () => { ); }); + test.describe("Live status", () => { + function createLiveMember({ status } = {}) { + return createProfile({ + did: "did:plc:liveuser1", + handle: "liveuser.bsky.social", + displayName: "Live User", + ...(status ? { status } : {}), + }); + } + + async function seedProfileAndGoto(page, did, path) { + await page.goto(`/profile/${did}`); + await expect(page.locator('[data-testid="profile-name"]')).toBeVisible({ + timeout: 10000, + }); + await page.evaluate((target) => window.router.load(target), path); + } + + test("shows the live ring and badge on the convo row for a live participant", async ({ + page, + }) => { + const liveStatus = createLiveStatusView({ + did: "did:plc:liveuser1", + url: "https://www.twitch.tv/liveuser", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + const liveMember = createLiveMember({ status: liveStatus }); + const mockServer = new MockServer(); + mockServer.addProfile(liveMember); + mockServer.addConvos([ + createConvo({ + id: "convo-live-1", + otherMember: liveMember, + lastMessage: createMessage({ + id: "msg-1", + text: "Hi", + senderDid: liveMember.did, + }), + }), + ]); + await mockServer.setup(page); + + await login(page); + await seedProfileAndGoto(page, liveMember.did, "/messages"); + + const chatView = page.locator("#chat-view"); + const row = chatView.locator('[data-testid="convo-item-direct"]'); + await expect(row).toHaveCount(1, { timeout: 10000 }); + await expect( + row.locator('[data-testid="avatar"] .avatar-live'), + ).toBeVisible(); + await expect(row.locator('[data-testid="live-badge"]')).toBeVisible(); + }); + + test("shows no live ring or badge on the convo row for an expired status", async ({ + page, + }) => { + const expiredStatus = createLiveStatusView({ + did: "did:plc:liveuser1", + url: "https://www.twitch.tv/liveuser", + expiresAt: "2025-01-15T13:00:00.000Z", + }); + const expiredMember = createLiveMember({ status: expiredStatus }); + const mockServer = new MockServer(); + mockServer.addProfile(expiredMember); + mockServer.addConvos([ + createConvo({ + id: "convo-expired-1", + otherMember: expiredMember, + lastMessage: createMessage({ + id: "msg-1", + text: "Hi", + senderDid: expiredMember.did, + }), + }), + ]); + await mockServer.setup(page); + + await login(page); + await seedProfileAndGoto(page, expiredMember.did, "/messages"); + + const chatView = page.locator("#chat-view"); + const row = chatView.locator('[data-testid="convo-item-direct"]'); + await expect(row).toHaveCount(1, { timeout: 10000 }); + await expect( + row.locator('[data-testid="avatar"] .avatar-live'), + ).toHaveCount(0); + await expect(row.locator('[data-testid="live-badge"]')).toHaveCount(0); + }); + + test("shows the live ring and badge on the chat detail header for a live participant, and not on the pre-chat info panel", async ({ + page, + }) => { + const liveStatus = createLiveStatusView({ + did: "did:plc:liveuser1", + url: "https://www.twitch.tv/liveuser", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + const liveMember = createLiveMember({ status: liveStatus }); + const mockServer = new MockServer(); + mockServer.addProfile(liveMember); + // No lastMessage so the empty state renders the info panel + mockServer.addConvos([ + createConvo({ + id: "convo-live-1", + otherMember: liveMember, + }), + ]); + await mockServer.setup(page); + + await login(page); + await seedProfileAndGoto(page, liveMember.did, "/messages/convo-live-1"); + + const chatDetail = page.locator("#chat-detail-view"); + const header = chatDetail.locator('[data-testid="header"]'); + await expect( + header.locator('[data-testid="header-title"]'), + ).toContainText("Live User", { timeout: 10000 }); + await expect( + header.locator('[data-testid="avatar"] .avatar-live'), + ).toBeVisible(); + await expect(header.locator('[data-testid="live-badge"]')).toBeVisible(); + + const infoPanel = chatDetail.locator('[data-testid="chat-info-panel"]'); + await expect(infoPanel).toBeVisible({ timeout: 10000 }); + await expect( + infoPanel.locator('[data-testid="avatar"] .avatar-live'), + ).toHaveCount(0); + await expect(infoPanel.locator('[data-testid="live-badge"]')).toHaveCount( + 0, + ); + }); + }); + test.describe("Logged-out behavior", () => { test("should redirect to /login when not authenticated", async ({ page, diff --git a/tests/e2e/specs/views/notifications.view.test.js b/tests/e2e/specs/views/notifications.view.test.js index 23c7cff7..01ab9085 100644 --- a/tests/e2e/specs/views/notifications.view.test.js +++ b/tests/e2e/specs/views/notifications.view.test.js @@ -2,6 +2,7 @@ import { test, expect } from "../../base.js"; import { login } from "../../helpers.js"; import { MockServer } from "../../mockServer.js"; import { + createLiveStatusView, createNotification, createPost, createProfile, @@ -1748,4 +1749,65 @@ test.describe("Notifications view", () => { await expect(items.nth(3)).toContainText("removed their verification"); await expect(items.nth(4)).toContainText("is on Bluesky"); }); + + test("shows live ring and pill on a stacked notification avatar for a live actor", async ({ + page, + }) => { + const liveAlice = createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + status: createLiveStatusView({ + did: "did:plc:alice1", + url: "https://www.twitch.tv/alice", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + }); + + const likedPost = createPost({ + uri: "at://did:plc:testuser123/app.bsky.feed.post/popular2", + text: "Popular post", + authorHandle: "testuser.bsky.social", + authorDisplayName: "Test User", + }); + + const mockServer = new MockServer(); + mockServer.addPosts([likedPost]); + mockServer.addNotifications([ + createNotification({ + reason: "like", + author: liveAlice, + reasonSubject: likedPost.uri, + indexedAt: new Date().toISOString(), + }), + createNotification({ + reason: "like", + author: bob, + reasonSubject: likedPost.uri, + indexedAt: new Date().toISOString(), + }), + createNotification({ + reason: "like", + author: charlie, + reasonSubject: likedPost.uri, + indexedAt: new Date().toISOString(), + }), + ]); + 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 liveAvatar = item + .locator(".notification-avatar") + .filter({ has: page.locator('[alt="Alice profile picture"]') }); + await expect(liveAvatar.locator(".avatar-live")).toBeVisible(); + await expect( + liveAvatar.locator('[data-testid="live-badge"]'), + ).toBeVisible(); + }); }); diff --git a/tests/e2e/specs/views/profileFollowers.view.test.js b/tests/e2e/specs/views/profileFollowers.view.test.js index d4149f2d..12292bb3 100644 --- a/tests/e2e/specs/views/profileFollowers.view.test.js +++ b/tests/e2e/specs/views/profileFollowers.view.test.js @@ -1,7 +1,10 @@ import { test, expect } from "../../base.js"; import { login } from "../../helpers.js"; import { MockServer } from "../../mockServer.js"; -import { createProfile } from "../../../shared/factories.js"; +import { + createProfile, + createLiveStatusView, +} from "../../../shared/factories.js"; const profileUser = createProfile({ did: "did:plc:profileuser1", @@ -318,6 +321,50 @@ test.describe("Profile followers view", () => { }); }); + test.describe("Live status", () => { + test("shows live ring and pill for a follower with an active status", async ({ + page, + }) => { + const liveFollower = createProfile({ + did: "did:plc:livefollower1", + handle: "livefollower.bsky.social", + displayName: "Live Follower", + status: createLiveStatusView({ + did: "did:plc:livefollower1", + url: "https://www.twitch.tv/otheruser", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + }); + const mockServer = new MockServer(); + mockServer.addProfile(profileUser); + mockServer.addProfileFollowers(profileUser.did, [liveFollower, alice]); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${profileUser.did}/followers`); + + const view = page.locator("#profile-followers-view"); + const liveRow = view + .locator(".profile-list-item") + .filter({ hasText: "Live Follower" }); + const nonLiveRow = view + .locator(".profile-list-item") + .filter({ hasText: "Alice" }); + + await expect( + liveRow.locator('[data-testid="avatar"] .avatar-live'), + ).toBeVisible({ timeout: 10000 }); + await expect(liveRow.locator('[data-testid="live-badge"]')).toBeVisible(); + + await expect( + nonLiveRow.locator('[data-testid="avatar"] .avatar-live'), + ).toHaveCount(0); + await expect( + nonLiveRow.locator('[data-testid="live-badge"]'), + ).toHaveCount(0); + }); + }); + test.describe("Logged-out behavior", () => { test("should redirect to /login when not authenticated", async ({ page, diff --git a/tests/e2e/specs/views/profileFollowing.view.test.js b/tests/e2e/specs/views/profileFollowing.view.test.js index c6d1161a..d628462e 100644 --- a/tests/e2e/specs/views/profileFollowing.view.test.js +++ b/tests/e2e/specs/views/profileFollowing.view.test.js @@ -1,7 +1,10 @@ import { test, expect } from "../../base.js"; import { login } from "../../helpers.js"; import { MockServer } from "../../mockServer.js"; -import { createProfile } from "../../../shared/factories.js"; +import { + createProfile, + createLiveStatusView, +} from "../../../shared/factories.js"; const profileUser = createProfile({ did: "did:plc:profileuser1", @@ -236,6 +239,50 @@ test.describe("Profile following view", () => { }); }); + test.describe("Live status", () => { + test("shows live ring and pill for a followed profile with an active status", async ({ + page, + }) => { + const liveFollow = createProfile({ + did: "did:plc:livefollow1", + handle: "livefollow.bsky.social", + displayName: "Live Follow", + status: createLiveStatusView({ + did: "did:plc:livefollow1", + url: "https://www.twitch.tv/otheruser", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + }); + const mockServer = new MockServer(); + mockServer.addProfile(profileUser); + mockServer.addProfileFollows(profileUser.did, [liveFollow, alice]); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${profileUser.did}/following`); + + const view = page.locator("#profile-following-view"); + const liveRow = view + .locator(".profile-list-item") + .filter({ hasText: "Live Follow" }); + const nonLiveRow = view + .locator(".profile-list-item") + .filter({ hasText: "Alice" }); + + await expect( + liveRow.locator('[data-testid="avatar"] .avatar-live'), + ).toBeVisible({ timeout: 10000 }); + await expect(liveRow.locator('[data-testid="live-badge"]')).toBeVisible(); + + await expect( + nonLiveRow.locator('[data-testid="avatar"] .avatar-live'), + ).toHaveCount(0); + await expect( + nonLiveRow.locator('[data-testid="live-badge"]'), + ).toHaveCount(0); + }); + }); + test.describe("Logged-out behavior", () => { test("should redirect to /login when not authenticated", async ({ page, diff --git a/tests/e2e/specs/views/search.view.test.js b/tests/e2e/specs/views/search.view.test.js index ac1dee24..f4a25c94 100644 --- a/tests/e2e/specs/views/search.view.test.js +++ b/tests/e2e/specs/views/search.view.test.js @@ -5,6 +5,7 @@ import { createPost, createProfile, createFeedGenerator, + createLiveStatusView, } from "../../../shared/factories.js"; test.describe("Search view", () => { @@ -1627,4 +1628,95 @@ test.describe("Search view", () => { .toEqual(["did:plc:recent1"]); }); }); + + test.describe("Live status", () => { + test("profile search results show ring and pill for a live actor", async ({ + page, + }) => { + const mockServer = new MockServer(); + const liveProfile = createProfile({ + did: "did:plc:liveperson1", + handle: "liveperson.bsky.social", + displayName: "Live Person", + status: createLiveStatusView({ + did: "did:plc:liveperson1", + url: "https://www.twitch.tv/liveperson", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + }); + mockServer.addSearchProfiles([liveProfile]); + await mockServer.setup(page); + + await login(page); + await page.goto("/search?q=live&tab=profiles"); + + const view = page.locator("#search-view"); + const row = view.locator(".profile-list-item"); + await expect(row).toHaveCount(1, { timeout: 10000 }); + await expect( + row.locator('[data-testid="avatar"] .avatar-live'), + ).toBeVisible(); + await expect(row.locator('[data-testid="live-badge"]')).toBeVisible(); + }); + + test("search typeahead rows show ring and pill for a live actor", async ({ + page, + }) => { + const mockServer = new MockServer(); + const liveProfile = createProfile({ + did: "did:plc:liveperson1", + handle: "liveperson.bsky.social", + displayName: "Live Person", + status: createLiveStatusView({ + did: "did:plc:liveperson1", + url: "https://www.twitch.tv/liveperson", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + }); + mockServer.addTypeaheadProfiles([liveProfile]); + await mockServer.setup(page); + + await login(page); + await page.goto("/search"); + + const view = page.locator("#search-view"); + await view.locator(".search-input").fill("live"); + + const row = view.locator('[data-testid="search-typeahead-result"]'); + await expect(row).toHaveCount(1, { timeout: 10000 }); + await expect( + row.locator('[data-testid="avatar"] .avatar-live'), + ).toBeVisible(); + await expect(row.locator('[data-testid="live-badge"]')).toBeVisible(); + }); + + test("expired-status actor shows neither ring nor pill in search results", async ({ + page, + }) => { + const mockServer = new MockServer(); + const expiredProfile = createProfile({ + did: "did:plc:expiredperson1", + handle: "expiredperson.bsky.social", + displayName: "Expired Person", + status: createLiveStatusView({ + did: "did:plc:expiredperson1", + url: "https://www.twitch.tv/expiredperson", + expiresAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + }), + }); + mockServer.addSearchProfiles([expiredProfile]); + await mockServer.setup(page); + + await login(page); + await page.goto("/search?q=expired&tab=profiles"); + + const view = page.locator("#search-view"); + const row = view.locator(".profile-list-item"); + await expect(row).toHaveCount(1, { timeout: 10000 }); + await expect( + row.locator('[data-testid="avatar"] .avatar-live'), + ).toHaveCount(0); + await expect(row.locator('[data-testid="live-badge"]')).toHaveCount(0); + }); + }); });