${avatarTemplate({
author: actor,
- clickAction: clickAction === "link" ? "link" : "none",
+ clickAction: clickAction === "link" ? "default" : "none",
})}
{} } = {}) {
let shown = false;
let dismissed = false;
let timeoutId = null;
+ let removeTimeoutId = null;
+
+ function remove() {
+ if (removeTimeoutId != null) clearTimeout(removeTimeoutId);
+ removeTimeoutId = null;
+ mountedToasts.delete(handle);
+ toast.remove();
+ }
function dismiss() {
if (dismissed) return;
@@ -46,7 +55,7 @@ function mountToast(toast, { timeout = 3000, onDismiss = () => {} } = {}) {
}
}
if (shown) toast.hidePopover();
- setTimeout(() => toast.remove(), 1000);
+ removeTimeoutId = setTimeout(remove, 1000);
onDismiss();
}
@@ -97,7 +106,16 @@ function mountToast(toast, { timeout = 3000, onDismiss = () => {} } = {}) {
show();
- return { dismiss, element: toast };
+ const handle = { dismiss, remove, element: toast };
+ mountedToasts.add(handle);
+ return handle;
+}
+
+export function cleanupToasts() {
+ for (const handle of [...mountedToasts]) {
+ handle.dismiss();
+ handle.remove();
+ }
}
export function showToast(
diff --git a/src/js/utils.js b/src/js/utils.js
index 83e5c147..61537d45 100644
--- a/src/js/utils.js
+++ b/src/js/utils.js
@@ -562,6 +562,39 @@ export class Poller {
}
}
+export class KeyedScheduler {
+ constructor() {
+ this.timers = new Map();
+ }
+
+ get size() {
+ return this.timers.size;
+ }
+
+ schedule(key, delayMs, fn) {
+ this.cancel(key);
+ const timer = setTimeout(() => {
+ this.timers.delete(key);
+ fn();
+ }, delayMs);
+ this.timers.set(key, timer);
+ }
+
+ cancel(key) {
+ const timer = this.timers.get(key);
+ if (timer === undefined) return;
+ clearTimeout(timer);
+ this.timers.delete(key);
+ }
+
+ dispose() {
+ for (const timer of this.timers.values()) {
+ clearTimeout(timer);
+ }
+ this.timers.clear();
+ }
+}
+
export function raf() {
return new Promise((resolve) => requestAnimationFrame(resolve));
}
diff --git a/src/js/views/profile.view.js b/src/js/views/profile.view.js
index 31e07b78..c2f22576 100644
--- a/src/js/views/profile.view.js
+++ b/src/js/views/profile.view.js
@@ -237,6 +237,7 @@ export default async function profileView({
profile.did,
);
const isCurrentUser = currentUser?.did === profile.did;
+ const liveStatus = dataLayer.derived.$actorLiveStatus.get(profile.did);
let authorFeedsToShow = getAuthorFeeds({ isCurrentUser, isLabeler });
const feedGenCount = profile.associated?.feedgens || 0;
if (feedGenCount > 0) {
@@ -317,6 +318,7 @@ export default async function profileView({
profileInteractionHandler.handleReport(profile),
onClickAddToLists: (profile) => handleAddToLists(profile),
onClickEditProfile: () => handleEditProfile(profile),
+ liveStatus,
pluginService,
isFollowPending: dataLayer.derived.$isFollowPending.get(
profile.did,
diff --git a/tests/e2e/specs/views/home.view.test.js b/tests/e2e/specs/views/home.view.test.js
index ba5da6e9..4c73b025 100644
--- a/tests/e2e/specs/views/home.view.test.js
+++ b/tests/e2e/specs/views/home.view.test.js
@@ -4,6 +4,7 @@ import { MockServer } from "../../mockServer.js";
import {
createFeedGenerator,
createList,
+ createLiveStatusView,
createPost,
} from "../../../shared/factories.js";
import { userProfile } from "../../testData.js";
@@ -1458,4 +1459,49 @@ test.describe("Home view", () => {
});
});
});
+
+ test.describe("Live avatars on touch devices", () => {
+ test.use({
+ viewport: { width: 375, height: 667 },
+ hasTouch: true,
+ isMobile: true,
+ });
+
+ test("tapping a live avatar opens the live status dialog instead of navigating", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ const post = createPost({
+ uri: "at://did:plc:liveauthor1/app.bsky.feed.post/post1",
+ text: "Streaming now",
+ authorHandle: "liveauthor.bsky.social",
+ authorDisplayName: "Live Author",
+ authorStatus: createLiveStatusView({
+ did: "did:plc:liveauthor1",
+ url: "https://www.twitch.tv/liveauthor",
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
+ }),
+ });
+ mockServer.addTimelinePosts([post]);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/");
+
+ const feedItem = page.locator('[data-testid="feed-item"]');
+ await expect(feedItem.locator('[data-testid="live-badge"]')).toBeVisible({
+ timeout: 10000,
+ });
+
+ await feedItem.locator("a.avatar-link").click();
+ const dialog = page.locator('[data-testid="live-status-dialog"]');
+ await expect(dialog).toBeVisible();
+ await expect(
+ dialog.locator('[data-testid="live-status-watch"]'),
+ ).toHaveAttribute("href", "https://www.twitch.tv/liveauthor");
+
+ // Still on the home view — the tap did not navigate to the profile
+ await expect(page.locator("#home-view")).toBeVisible();
+ });
+ });
});
diff --git a/tests/e2e/specs/views/profile.view.test.js b/tests/e2e/specs/views/profile.view.test.js
index 8cb8f991..6365d122 100644
--- a/tests/e2e/specs/views/profile.view.test.js
+++ b/tests/e2e/specs/views/profile.view.test.js
@@ -8,6 +8,7 @@ import {
createFeedGenerator,
createLabelerView,
createList,
+ createLiveStatusView,
} from "../../../shared/factories.js";
const otherUser = createProfile({
@@ -2555,4 +2556,90 @@ test.describe("Profile view", () => {
});
});
});
+
+ test.describe("Live status", () => {
+ const liveStatusUrl = "https://www.twitch.tv/otheruser";
+
+ function createLiveUser(status) {
+ return createProfile({
+ did: "did:plc:liveuser1",
+ handle: "liveuser.bsky.social",
+ displayName: "Live User",
+ status,
+ });
+ }
+
+ test("shows the live badge and opens the live status dialog from the header avatar", async ({
+ page,
+ }) => {
+ const liveUser = createLiveUser(
+ createLiveStatusView({
+ did: "did:plc:liveuser1",
+ url: liveStatusUrl,
+ title: "Playing games",
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
+ }),
+ );
+ const mockServer = new MockServer();
+ mockServer.addProfile(liveUser);
+ await mockServer.setup(page);
+ await login(page);
+ await page.goto(`/profile/${liveUser.did}`);
+
+ const view = page.locator("#profile-view");
+ await expect(view.locator('[data-testid="profile-name"]')).toContainText(
+ "Live User",
+ { timeout: 10000 },
+ );
+ await expect(view.locator('[data-testid="live-badge"]')).toBeVisible();
+
+ await view.locator('[data-testid="avatar-live-button"]').click();
+ const dialog = page.locator('[data-testid="live-status-dialog"]');
+ await expect(dialog).toBeVisible();
+ await expect(
+ dialog.locator('[data-testid="live-status-watch"]'),
+ ).toHaveAttribute("href", liveStatusUrl);
+
+ await dialog.locator('[data-testid="live-status-open-profile"]').click();
+ await expect(dialog).not.toBeVisible();
+ });
+
+ test("shows no live badge for expired, disallowed-host, or disabled statuses", async ({
+ page,
+ }) => {
+ for (const status of [
+ createLiveStatusView({
+ did: "did:plc:liveuser1",
+ url: liveStatusUrl,
+ expiresAt: "2025-01-15T13:00:00.000Z",
+ }),
+ createLiveStatusView({
+ did: "did:plc:liveuser1",
+ url: "https://example.com/stream",
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
+ }),
+ createLiveStatusView({
+ did: "did:plc:liveuser1",
+ url: liveStatusUrl,
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
+ isDisabled: true,
+ }),
+ ]) {
+ const mockServer = new MockServer();
+ mockServer.addProfile(createLiveUser(status));
+ await mockServer.setup(page);
+ await login(page);
+ await page.goto("/profile/did:plc:liveuser1");
+
+ const view = page.locator("#profile-view");
+ await expect(
+ view.locator('[data-testid="profile-name"]'),
+ ).toContainText("Live User", { timeout: 10000 });
+ await expect(view.locator('[data-testid="live-badge"]')).toHaveCount(0);
+ await expect(
+ view.locator('[data-testid="avatar-live-button"]'),
+ ).toHaveCount(0);
+ }
+ });
+ });
});
diff --git a/tests/shared/factories.js b/tests/shared/factories.js
index 3c9ca7e1..29e2ee9e 100644
--- a/tests/shared/factories.js
+++ b/tests/shared/factories.js
@@ -173,6 +173,7 @@ export function createProfile({
associated,
viewer,
labels,
+ status,
}) {
return {
did,
@@ -188,6 +189,51 @@ export function createProfile({
...(followersCount !== undefined ? { followersCount } : {}),
...(followsCount !== undefined ? { followsCount } : {}),
...(postsCount !== undefined ? { postsCount } : {}),
+ ...(status ? { status } : {}),
+ };
+}
+
+// A hydrated app.bsky.actor.defs#statusView, as it arrives on profile views
+export function createLiveStatusView({
+ did,
+ url = "https://www.twitch.tv/testuser",
+ title = "Test Stream",
+ createdAt = "2025-01-15T12:00:00.000Z",
+ durationMinutes = 60,
+ expiresAt,
+ isDisabled = false,
+ embed,
+ thumb,
+}) {
+ // Pass expiresAt: null to omit the field entirely
+ const computedExpiresAt =
+ expiresAt ??
+ new Date(Date.parse(createdAt) + durationMinutes * 60 * 1000).toISOString();
+ return {
+ status: "app.bsky.actor.status#live",
+ uri: `at://${did}/app.bsky.actor.status/self`,
+ cid: "bafyreilivestatus",
+ record: {
+ $type: "app.bsky.actor.status",
+ status: "app.bsky.actor.status#live",
+ createdAt,
+ durationMinutes,
+ },
+ embed:
+ embed !== undefined
+ ? embed
+ : {
+ $type: "app.bsky.embed.external#view",
+ external: {
+ uri: url,
+ title,
+ description: "",
+ ...(thumb ? { thumb } : {}),
+ },
+ },
+ isActive: true,
+ ...(expiresAt === null ? {} : { expiresAt: computedExpiresAt }),
+ isDisabled,
};
}
@@ -365,6 +411,7 @@ export function createPost({
labels,
viewer,
authorViewer,
+ authorStatus,
loggedOut = false,
threadgate,
}) {
@@ -382,6 +429,7 @@ export function createPost({
: { viewer: { muted: false, blockedBy: false, ...authorViewer } }),
labels: [],
createdAt: "2025-01-01T00:00:00.000Z",
+ ...(authorStatus ? { status: authorStatus } : {}),
},
record: {
$type: "app.bsky.feed.post",
diff --git a/tests/unit/env.js b/tests/unit/env.js
index 9854c7af..819b5a24 100644
--- a/tests/unit/env.js
+++ b/tests/unit/env.js
@@ -160,3 +160,13 @@ class EmojiPickerStub extends globalThis.window.HTMLElement {}
if (!globalThis.customElements.get("emoji-picker")) {
globalThis.customElements.define("emoji-picker", EmojiPickerStub);
}
+
+// Global per-test cleanup: emptying the body disconnects custom elements
+// (whose disconnectedCallback clears their timers) and cleanupToasts
+// cancels toast timers, so no test leaves timers pending on the event loop.
+const { afterEach } = await import("node:test");
+const { cleanupToasts } = await import("/js/toasts.js");
+afterEach(() => {
+ document.body.replaceChildren();
+ cleanupToasts();
+});
diff --git a/tests/unit/specs/components/profile-hover-card.test.js b/tests/unit/specs/components/profile-hover-card.test.js
index 5aed28ff..6076ef6b 100644
--- a/tests/unit/specs/components/profile-hover-card.test.js
+++ b/tests/unit/specs/components/profile-hover-card.test.js
@@ -1,7 +1,10 @@
import { describe, it, beforeEach, after } from "node:test";
import assert from "node:assert/strict";
import "/js/components/profile-hover-card.js";
-import { createProfile } from "../../../shared/factories.js";
+import {
+ createLiveStatusView,
+ createProfile,
+} from "../../../shared/factories.js";
import { makeTestDataLayer } from "../../testHelpers.js";
// The element renders reactively via an effect(), which flushes on rAF.
@@ -202,4 +205,44 @@ describe("
", () => {
await flushRender();
assert.match(card.textContent, /New Name/);
});
+
+ it("renders the live status card instead of the profile body when live", async () => {
+ const profile = makeDetailedProfile({
+ status: createLiveStatusView({
+ did: "did:plc:target",
+ url: "https://www.twitch.tv/target",
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
+ }),
+ });
+ const { dataLayer, interactionHandlers } = makeSetup({
+ detailedProfile: profile,
+ });
+ const card = mountCard({ dataLayer, interactionHandlers });
+ await flushRender();
+ assert(card.querySelector('[data-testid="live-status-card"]') !== null);
+ assert.equal(
+ card
+ .querySelector('[data-testid="live-status-watch"]')
+ .getAttribute("href"),
+ "https://www.twitch.tv/target",
+ );
+ assert(card.querySelector('[data-testid="hover-card-name"]') === null);
+ });
+
+ it("renders the normal profile body when the status is expired", async () => {
+ const profile = makeDetailedProfile({
+ status: createLiveStatusView({
+ did: "did:plc:target",
+ url: "https://www.twitch.tv/target",
+ expiresAt: "2025-01-15T13:00:00.000Z",
+ }),
+ });
+ const { dataLayer, interactionHandlers } = makeSetup({
+ detailedProfile: profile,
+ });
+ const card = mountCard({ dataLayer, interactionHandlers });
+ await flushRender();
+ assert(card.querySelector('[data-testid="live-status-card"]') === null);
+ assert(card.querySelector('[data-testid="hover-card-name"]') !== null);
+ });
});
diff --git a/tests/unit/specs/dataLayer/dataLayer.test.js b/tests/unit/specs/dataLayer/dataLayer.test.js
index 14ee1435..c5f30a2d 100644
--- a/tests/unit/specs/dataLayer/dataLayer.test.js
+++ b/tests/unit/specs/dataLayer/dataLayer.test.js
@@ -5,6 +5,7 @@ import { DraftMediaStore } from "/js/drafts.js";
import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js";
import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js";
import { Constellation } from "/js/constellation.js";
+import { trackDisposable } from "../../testHelpers.js";
function createMockApi(options = {}) {
const isAuthenticated = options.isAuthenticated ?? false;
@@ -19,7 +20,7 @@ function createMockApi(options = {}) {
}
function createDataLayer(api) {
- return new DataLayer(
+ const dataLayer = new DataLayer(
api,
new PreferencesProvider(api),
{ resolveHandle: async () => null },
@@ -27,6 +28,8 @@ function createDataLayer(api) {
new HiddenFeedItemsStore(),
new Constellation(),
);
+ trackDisposable(dataLayer.derived.liveStatusScheduler);
+ return dataLayer;
}
describe("constructor", () => {
diff --git a/tests/unit/specs/dataLayer/derived.test.js b/tests/unit/specs/dataLayer/derived.test.js
index 7259266e..dcca15d1 100644
--- a/tests/unit/specs/dataLayer/derived.test.js
+++ b/tests/unit/specs/dataLayer/derived.test.js
@@ -16,6 +16,7 @@ import {
createPost,
createProfile,
} from "../../../shared/factories.js";
+import { trackDisposable } from "../../testHelpers.js";
function makeDerived(dataStore, { preferences, draftMediaStore } = {}) {
const patchStore = new PatchStore();
@@ -33,6 +34,7 @@ function makeDerived(dataStore, { preferences, draftMediaStore } = {}) {
false,
draftMediaStore ?? new DraftMediaStore("test-media"),
);
+ trackDisposable(derived.liveStatusScheduler);
return { derived, patchStore, hiddenFeedItemsStore };
}
diff --git a/tests/unit/specs/dataLayer/liveStatus.test.js b/tests/unit/specs/dataLayer/liveStatus.test.js
new file mode 100644
index 00000000..bbae85c7
--- /dev/null
+++ b/tests/unit/specs/dataLayer/liveStatus.test.js
@@ -0,0 +1,331 @@
+import { describe, it, beforeEach, afterEach, mock } from "node:test";
+import assert from "node:assert/strict";
+import { Derived } from "/js/dataLayer/derived.js";
+import { DataStore } from "/js/dataLayer/dataStore.js";
+import { createSessionState } from "/js/dataLayer/sessionState.js";
+import { PatchStore } from "/js/dataLayer/patchStore.js";
+import { Preferences } from "/js/preferences.js";
+import { Signal } from "/js/signals.js";
+import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js";
+import { DraftMediaStore } from "/js/drafts.js";
+import { isAllowedLiveHost, isStatusValid } from "/js/dataHelpers.js";
+import {
+ createLiveStatusView,
+ createPost,
+ createProfile,
+} from "../../../shared/factories.js";
+import { trackDisposable } from "../../testHelpers.js";
+
+const did = "did:plc:liveuser1";
+
+function makeDerived({ preferences } = {}) {
+ const dataStore = new DataStore(createSessionState(null));
+ const patchStore = new PatchStore();
+ const prefs = preferences ?? Preferences.createLoggedOutPreferences();
+ const preferencesProvider = {
+ requirePreferences: () => prefs,
+ $preferences: new Signal.State(prefs),
+ };
+ const derived = new Derived(
+ dataStore,
+ patchStore,
+ preferencesProvider,
+ new HiddenFeedItemsStore(),
+ false,
+ new DraftMediaStore("test-media"),
+ );
+ trackDisposable(derived.liveStatusScheduler);
+ return { derived, dataStore };
+}
+
+function addLiveProfile(dataStore, status, profileOverrides = {}) {
+ const profile = createProfile({
+ did,
+ handle: "liveuser.bsky.social",
+ displayName: "Live User",
+ status,
+ ...profileOverrides,
+ });
+ dataStore.setProfiles([profile]);
+ return profile;
+}
+
+function futureExpiry(minutes = 60) {
+ return new Date(Date.now() + minutes * 60 * 1000).toISOString();
+}
+
+describe("live status", () => {
+ describe("isAllowedLiveHost", () => {
+ it("allows exact hosts and subdomains of allowed domains", () => {
+ assert(isAllowedLiveHost("https://twitch.tv/somebody"));
+ assert(isAllowedLiveHost("https://www.twitch.tv/somebody"));
+ assert(isAllowedLiveHost("https://m.twitch.tv/somebody"));
+ assert(isAllowedLiveHost("https://www.youtube.com/watch?v=abc"));
+ });
+
+ it("rejects lookalike hosts, disallowed hosts, and non-URLs", () => {
+ assert(!isAllowedLiveHost("https://twitch.tv.evil.com/somebody"));
+ assert(!isAllowedLiveHost("https://eviltwitch.tv/somebody"));
+ assert(!isAllowedLiveHost("https://example.com/stream"));
+ assert(!isAllowedLiveHost("not a url"));
+ assert(!isAllowedLiveHost(""));
+ });
+ });
+
+ describe("isStatusValid", () => {
+ it("accepts a structurally valid statusView", () => {
+ assert.equal(isStatusValid(createLiveStatusView({ did })), true);
+ });
+
+ it("rejects wrong status values, missing uri, and missing expiresAt", () => {
+ assert.equal(isStatusValid(null), false);
+ assert.equal(
+ isStatusValid(createLiveStatusView({ did, expiresAt: null })),
+ false,
+ );
+ assert.equal(
+ isStatusValid({
+ ...createLiveStatusView({ did }),
+ status: "app.bsky.actor.status#other",
+ }),
+ false,
+ );
+ assert.equal(
+ isStatusValid({
+ ...createLiveStatusView({ did }),
+ uri: undefined,
+ }),
+ false,
+ );
+ });
+
+ it("rejects missing, non-external, and disallowed-host embeds", () => {
+ assert.equal(
+ isStatusValid(createLiveStatusView({ did, embed: null })),
+ false,
+ );
+ assert.equal(
+ isStatusValid(
+ createLiveStatusView({
+ did,
+ embed: { $type: "app.bsky.embed.images#view", images: [] },
+ }),
+ ),
+ false,
+ );
+ assert.equal(
+ isStatusValid(
+ createLiveStatusView({ did, url: "https://example.com/stream" }),
+ ),
+ false,
+ );
+ });
+ });
+
+ describe("$actorLiveStatus", () => {
+ it("returns none when the profile has no status", () => {
+ const { derived, dataStore } = makeDerived();
+ addLiveProfile(dataStore, undefined);
+ assert.deepEqual(derived.$actorLiveStatus.get(did), { state: "none" });
+ assert.equal(derived.$isActorLive.get(did), false);
+ });
+
+ it("returns active for a valid unexpired status", () => {
+ const { derived, dataStore } = makeDerived();
+ const expiresAt = futureExpiry();
+ const status = createLiveStatusView({ did, expiresAt });
+ addLiveProfile(dataStore, status);
+ const liveStatus = derived.$actorLiveStatus.get(did);
+ assert.equal(liveStatus.state, "active");
+ assert.equal(liveStatus.uri, `at://${did}/app.bsky.actor.status/self`);
+ assert.equal(liveStatus.expiresAt, expiresAt);
+ assert.equal(
+ liveStatus.embed.external.uri,
+ "https://www.twitch.tv/testuser",
+ );
+ assert.equal(derived.$isActorLive.get(did), true);
+ });
+
+ it("returns inactive for an expired status", () => {
+ const { derived, dataStore } = makeDerived();
+ const status = createLiveStatusView({
+ did,
+ expiresAt: "2025-01-15T13:00:00.000Z",
+ });
+ addLiveProfile(dataStore, status);
+ const liveStatus = derived.$actorLiveStatus.get(did);
+ assert.equal(liveStatus.state, "inactive");
+ assert.equal(liveStatus.uri, `at://${did}/app.bsky.actor.status/self`);
+ assert.equal(derived.$isActorLive.get(did), false);
+ });
+
+ it("returns inactive when expiresAt is missing", () => {
+ const { derived, dataStore } = makeDerived();
+ addLiveProfile(dataStore, createLiveStatusView({ did, expiresAt: null }));
+ assert.equal(derived.$actorLiveStatus.get(did).state, "inactive");
+ });
+
+ it("returns inactive for a bad embed or disallowed host", () => {
+ const { derived, dataStore } = makeDerived();
+ addLiveProfile(
+ dataStore,
+ createLiveStatusView({
+ did,
+ expiresAt: futureExpiry(),
+ url: "https://example.com/stream",
+ }),
+ );
+ assert.equal(derived.$actorLiveStatus.get(did).state, "inactive");
+ });
+
+ it("returns inactive with isDisabled for a disabled status", () => {
+ const { derived, dataStore } = makeDerived();
+ addLiveProfile(
+ dataStore,
+ createLiveStatusView({
+ did,
+ expiresAt: futureExpiry(),
+ isDisabled: true,
+ }),
+ );
+ const liveStatus = derived.$actorLiveStatus.get(did);
+ assert.equal(liveStatus.state, "inactive");
+ assert.equal(liveStatus.isDisabled, true);
+ });
+
+ it("returns none when the viewer blocks or mutes the author", () => {
+ for (const viewer of [
+ { blocking: "at://block" },
+ { blockedBy: true },
+ { muted: true },
+ ]) {
+ const { derived, dataStore } = makeDerived();
+ addLiveProfile(
+ dataStore,
+ createLiveStatusView({ did, expiresAt: futureExpiry() }),
+ { viewer },
+ );
+ assert.deepEqual(derived.$actorLiveStatus.get(did), { state: "none" });
+ }
+ });
+
+ it("returns none when the profile has a blur label", () => {
+ const preferences = {
+ getProfileBlurLabel: () => "porn",
+ clone() {
+ return this;
+ },
+ };
+ const { derived, dataStore } = makeDerived({ preferences });
+ addLiveProfile(
+ dataStore,
+ createLiveStatusView({ did, expiresAt: futureExpiry() }),
+ );
+ assert.deepEqual(derived.$actorLiveStatus.get(did), { state: "none" });
+ });
+
+ it("attaches isLive to hydrated profiles and post authors", () => {
+ const { derived, dataStore } = makeDerived();
+ addLiveProfile(
+ dataStore,
+ createLiveStatusView({ did, expiresAt: futureExpiry() }),
+ );
+ const post = createPost({
+ uri: `at://${did}/app.bsky.feed.post/live1`,
+ text: "hello",
+ authorHandle: "liveuser.bsky.social",
+ authorDisplayName: "Live User",
+ });
+ dataStore.setPosts([post]);
+ assert.equal(derived.$hydratedProfiles.get(did).isLive, true);
+ assert.equal(derived.$hydratedPosts.get(post.uri).author.isLive, true);
+ });
+
+ it("does not attach isLive when the status is inactive", () => {
+ const { derived, dataStore } = makeDerived();
+ addLiveProfile(dataStore, createLiveStatusView({ did }));
+ assert.equal(derived.$hydratedProfiles.get(did).isLive, undefined);
+ });
+ });
+
+ describe("expiry flip via the scheduled recheck", () => {
+ beforeEach(() => {
+ mock.timers.enable({
+ apis: ["setTimeout", "Date"],
+ now: Date.parse("2025-01-15T12:30:00.000Z"),
+ });
+ });
+
+ afterEach(() => {
+ mock.timers.reset();
+ });
+
+ it("judging an unexpired status arms a recheck that flips it at expiry", () => {
+ const { derived, dataStore } = makeDerived();
+ // Expires at 13:00; the mocked clock starts at 12:30
+ addLiveProfile(dataStore, createLiveStatusView({ did }));
+ // No timer until something reads the judgment
+ assert.equal(derived.liveStatusScheduler.size, 0);
+ assert.equal(derived.$actorLiveStatus.get(did).state, "active");
+ assert.equal(derived.liveStatusScheduler.size, 1);
+
+ mock.timers.tick(29 * 60 * 1000);
+ assert.equal(derived.$actorLiveStatus.get(did).state, "active");
+
+ mock.timers.tick(2 * 60 * 1000);
+ assert.equal(derived.$actorLiveStatus.get(did).state, "inactive");
+ assert.equal(derived.liveStatusScheduler.size, 0);
+ });
+
+ it("arms no timer for absent or already-expired statuses", () => {
+ const { derived, dataStore } = makeDerived();
+ addLiveProfile(dataStore, undefined);
+ assert.equal(derived.$actorLiveStatus.get(did).state, "none");
+ assert.equal(derived.liveStatusScheduler.size, 0);
+ addLiveProfile(
+ dataStore,
+ createLiveStatusView({ did, expiresAt: "2025-01-15T12:00:00.000Z" }),
+ );
+ assert.equal(derived.$actorLiveStatus.get(did).state, "inactive");
+ assert.equal(derived.liveStatusScheduler.size, 0);
+ });
+
+ it("re-arms for the new deadline when a rewrite extends the expiry", () => {
+ const { derived, dataStore } = makeDerived();
+ addLiveProfile(dataStore, createLiveStatusView({ did }));
+ assert.equal(derived.$actorLiveStatus.get(did).state, "active");
+
+ // A later write extends the expiry to 14:00; the next judgment re-arms
+ addLiveProfile(
+ dataStore,
+ createLiveStatusView({ did, expiresAt: "2025-01-15T14:00:00.000Z" }),
+ );
+ assert.equal(derived.$actorLiveStatus.get(did).state, "active");
+ assert.equal(derived.liveStatusScheduler.size, 1);
+
+ mock.timers.tick(35 * 60 * 1000);
+ assert.equal(derived.$actorLiveStatus.get(did).state, "active");
+ mock.timers.tick(60 * 60 * 1000);
+ assert.equal(derived.$actorLiveStatus.get(did).state, "inactive");
+ });
+
+ it("flips liveness for profiles written via setDetailedProfile", () => {
+ const { derived, dataStore } = makeDerived();
+ const profile = createProfile({
+ did,
+ handle: "liveuser.bsky.social",
+ displayName: "Live User",
+ status: createLiveStatusView({ did }),
+ });
+ dataStore.setDetailedProfile(profile);
+ assert.equal(derived.$actorLiveStatus.get(did).state, "active");
+
+ mock.timers.tick(31 * 60 * 1000);
+ assert.equal(derived.$actorLiveStatus.get(did).state, "inactive");
+ assert.equal(
+ derived.$hydratedDetailedProfiles.get(did).isLive,
+ undefined,
+ );
+ });
+ });
+});
diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js
index 7c986121..e2dffdf6 100644
--- a/tests/unit/specs/dataLayer/mutations.test.js
+++ b/tests/unit/specs/dataLayer/mutations.test.js
@@ -11,6 +11,7 @@ import { Signal } from "/js/signals.js";
import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js";
import { CDN_URL } from "/js/config.js";
import { ApiError } from "/js/api.js";
+import { trackDisposable } from "../../testHelpers.js";
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -66,7 +67,7 @@ function makeDerived(
: null,
),
};
- return new Derived(
+ const derived = new Derived(
dataStore,
patchStore,
provider,
@@ -74,6 +75,8 @@ function makeDerived(
isAuthenticated,
new DraftMediaStore("test-media"),
);
+ trackDisposable(derived.liveStatusScheduler);
+ return derived;
}
describe("addLike", () => {
diff --git a/tests/unit/specs/templates/avatar.template.test.js b/tests/unit/specs/templates/avatar.template.test.js
index 2b8df71e..607c1a30 100644
--- a/tests/unit/specs/templates/avatar.template.test.js
+++ b/tests/unit/specs/templates/avatar.template.test.js
@@ -125,6 +125,158 @@ describe("avatarTemplate", () => {
});
});
+describe("avatarTemplate - live status", () => {
+ const liveAuthor = { ...post.author, isLive: true };
+
+ it("should render live ring class and badge for a live author", () => {
+ const result = avatarTemplate({ author: liveAuthor });
+ const container = document.createElement("div");
+ render(result, container);
+ const frame = container.querySelector(".avatar-image-frame");
+ assert(frame.classList.contains("avatar-live"));
+ assert(container.querySelector("[data-testid='live-badge']") !== null);
+ });
+
+ it("should render no ring class or badge for a non-live author", () => {
+ const result = avatarTemplate({ author: post.author });
+ const container = document.createElement("div");
+ render(result, container);
+ const frame = container.querySelector(".avatar-image-frame");
+ assert(!frame.classList.contains("avatar-live"));
+ assert.deepEqual(
+ container.querySelector("[data-testid='live-badge']"),
+ null,
+ );
+ });
+
+ it("should hide the badge but keep the ring when showLiveBadge is false", () => {
+ const result = avatarTemplate({ author: liveAuthor, showLiveBadge: false });
+ const container = document.createElement("div");
+ render(result, container);
+ const frame = container.querySelector(".avatar-image-frame");
+ assert(frame.classList.contains("avatar-live"));
+ assert.deepEqual(
+ container.querySelector("[data-testid='live-badge']"),
+ null,
+ );
+ });
+
+ it("should render no ring or badge when showLiveStatus is false", () => {
+ const result = avatarTemplate({
+ author: liveAuthor,
+ showLiveStatus: false,
+ });
+ const container = document.createElement("div");
+ render(result, container);
+ const frame = container.querySelector(".avatar-image-frame");
+ assert(!frame.classList.contains("avatar-live"));
+ assert.deepEqual(
+ container.querySelector("[data-testid='live-badge']"),
+ null,
+ );
+ });
+
+ it("should dispatch live-avatar:click instead of navigating on touch devices", () => {
+ const originalMatchMedia = window.matchMedia;
+ window.matchMedia = () => ({
+ matches: true,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ });
+ try {
+ const container = document.createElement("div");
+ render(avatarTemplate({ author: liveAuthor }), container);
+ let detail = null;
+ container.addEventListener("live-avatar:click", (event) => {
+ detail = event.detail;
+ });
+ const clickEvent = new window.MouseEvent("click", {
+ bubbles: true,
+ cancelable: true,
+ });
+ container.querySelector("a.avatar-link").dispatchEvent(clickEvent);
+ assert(clickEvent.defaultPrevented);
+ assert.equal(detail.did, liveAuthor.did);
+ } finally {
+ window.matchMedia = originalMatchMedia;
+ }
+ });
+
+ it("should keep link navigation on touch devices when clickAction is link", () => {
+ const originalMatchMedia = window.matchMedia;
+ window.matchMedia = () => ({
+ matches: true,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ });
+ try {
+ const container = document.createElement("div");
+ render(
+ avatarTemplate({ author: liveAuthor, clickAction: "link" }),
+ container,
+ );
+ let dispatched = false;
+ let templatePrevented = null;
+ container.addEventListener("live-avatar:click", () => {
+ dispatched = true;
+ });
+ container.addEventListener("click", (event) => {
+ templatePrevented = event.defaultPrevented;
+ event.preventDefault();
+ });
+ const clickEvent = new window.MouseEvent("click", {
+ bubbles: true,
+ cancelable: true,
+ });
+ container.querySelector("a.avatar-link").dispatchEvent(clickEvent);
+ assert.equal(templatePrevented, false);
+ assert.equal(dispatched, false);
+ } finally {
+ window.matchMedia = originalMatchMedia;
+ }
+ });
+
+ it("should keep link navigation for live avatars on non-touch devices", () => {
+ const container = document.createElement("div");
+ render(avatarTemplate({ author: liveAuthor }), container);
+ let dispatched = false;
+ let templatePrevented = null;
+ container.addEventListener("live-avatar:click", () => {
+ dispatched = true;
+ });
+ container.addEventListener("click", (event) => {
+ templatePrevented = event.defaultPrevented;
+ event.preventDefault();
+ });
+ const clickEvent = new window.MouseEvent("click", {
+ bubbles: true,
+ cancelable: true,
+ });
+ container.querySelector("a.avatar-link").dispatchEvent(clickEvent);
+ assert.equal(templatePrevented, false);
+ assert.equal(dispatched, false);
+ });
+
+ it("should render a button dispatching live-avatar:click when clickAction is live", () => {
+ const result = avatarTemplate({
+ author: liveAuthor,
+ clickAction: "live",
+ });
+ const container = document.createElement("div");
+ render(result, container);
+ let detail = null;
+ container.addEventListener("live-avatar:click", (event) => {
+ detail = event.detail;
+ });
+ const button = container.querySelector(
+ "[data-testid='avatar-live-button']",
+ );
+ assert(button !== null);
+ button.click();
+ assert.equal(detail.did, liveAuthor.did);
+ });
+});
+
describe("avatarTemplate - labeler profiles", () => {
it("should render avatar for labeler profile with labeler class", () => {
const labelerAuthor = {
diff --git a/tests/unit/specs/toasts.test.js b/tests/unit/specs/toasts.test.js
index 2b0478d0..4c16f400 100644
--- a/tests/unit/specs/toasts.test.js
+++ b/tests/unit/specs/toasts.test.js
@@ -1,6 +1,11 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
-import { showToast, showPluginToast, hidePluginToast } from "/js/toasts.js";
+import {
+ showToast,
+ showPluginToast,
+ hidePluginToast,
+ cleanupToasts,
+} from "/js/toasts.js";
import { html } from "/js/lib/lit-html.js";
function clearDOM() {
@@ -241,6 +246,31 @@ describe("showPluginToast", () => {
});
});
+describe("cleanupToasts", () => {
+ it("should dismiss and remove every mounted toast immediately", async () => {
+ clearDOM();
+ await showToast("First", { timeout: 0 });
+ await showToast("Second", { timeout: 0 });
+ assert.deepEqual(document.querySelectorAll(".toast").length, 2);
+ cleanupToasts();
+ assert.deepEqual(document.querySelectorAll(".toast").length, 0);
+ });
+
+ it("should be a no-op when no toasts are mounted", () => {
+ clearDOM();
+ cleanupToasts();
+ });
+
+ it("should remove a toast that was already dismissed but not yet cleaned up", async () => {
+ clearDOM();
+ const handle = await showToast("Going", { timeout: 0 });
+ handle.dismiss();
+ assert(document.querySelector(".toast") !== null);
+ cleanupToasts();
+ assert.deepEqual(document.querySelectorAll(".toast").length, 0);
+ });
+});
+
describe("hidePluginToast", () => {
it("should dismiss the matching toast", () => {
clearDOM();
diff --git a/tests/unit/testHelpers.js b/tests/unit/testHelpers.js
index 2a96a0f3..9a8f5a7c 100644
--- a/tests/unit/testHelpers.js
+++ b/tests/unit/testHelpers.js
@@ -1,4 +1,4 @@
-import { mock } from "node:test";
+import { afterEach, mock } from "node:test";
import { DataLayer } from "/js/dataLayer/dataLayer.js";
import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js";
import { DraftMediaStore } from "/js/drafts.js";
@@ -6,6 +6,17 @@ import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js";
import { Constellation } from "/js/constellation.js";
import { Signal, SignalMap } from "/js/signals.js";
+const disposables = new Set();
+afterEach(() => {
+ for (const disposable of disposables) disposable.dispose();
+ disposables.clear();
+});
+
+export function trackDisposable(disposable) {
+ disposables.add(disposable);
+ return disposable;
+}
+
export function makeTestDataLayer({
api: apiOverrides = {},
identityResolver,
@@ -21,7 +32,7 @@ export function makeTestDataLayer({
updatePreferences: async () => {},
...apiOverrides,
};
- return new DataLayer(
+ const dataLayer = new DataLayer(
api,
new PreferencesProvider(api),
identityResolver ?? { resolveHandle: async () => null },
@@ -29,6 +40,8 @@ export function makeTestDataLayer({
hiddenFeedItemsStore ?? new HiddenFeedItemsStore(),
constellation ?? new Constellation(),
);
+ trackDisposable(dataLayer.derived.liveStatusScheduler);
+ return dataLayer;
}
export function makeTestPluginService(overrides = {}) {