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`
- ${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);
+ });
+ });
});