diff --git a/package.json b/package.json index 35df9844..c66411cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.30", + "version": "0.20.31", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 34dc9b27..c5a59f52 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -123,6 +123,7 @@ html { transparent ); --warning-color: var(--yellow); + --live-color: light-dark(#e91646, #eb2452); /* border colors */ --header-border-color: var(--generic-border-color); @@ -1142,6 +1143,57 @@ input[type="color"]::-moz-color-swatch { width: fit-content; } +.avatar-live-frame { + position: relative; + display: inline-flex; +} + +.avatar-image-frame.avatar-live { + border: 2px solid var(--live-color); +} + +.avatar-live-badge { + position: absolute; + bottom: -6px; + left: 50%; + transform: translateX(-50%); + background-color: var(--live-color); + color: var(--white); + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.03em; + line-height: 1; + padding: 2px 4px; + border-radius: 4px; + pointer-events: none; + z-index: 1; +} + +.avatar-live-button { + background: none; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: fit-content; + color: inherit; + font: inherit; +} + +.profile-top-row .avatar-image-frame.avatar-live { + border-width: 3px; + border-color: var(--live-color); +} + +.profile-top-row .avatar-live-badge { + font-size: 12px; + padding: 3px 6px; + bottom: -8px; + border-radius: 5px; +} + .avatar-placeholder { width: var(--avatar-size-post); height: var(--avatar-size-post); @@ -5252,6 +5304,118 @@ chat-input .rich-text-input-placeholder { padding: 2px 2px 2px 0; } +@media (min-width: 800px) { + .bottom-sheet.live-status-dialog { + max-width: 400px; + } +} + +.live-status-card { + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; +} + +.live-status-dialog-content { + display: flex; + flex-direction: column; + padding-top: 10px; + overflow-y: auto; +} + +.live-status-thumb { + position: relative; + border-radius: 8px; + overflow: hidden; + aspect-ratio: 1.91 / 1; + background-color: var(--skeleton-color); +} + +.live-status-thumb-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.live-status-thumb-image--blurred { + filter: blur(20px); + transform: translateZ(0); +} + +.live-status-thumb-badge { + position: absolute; + top: 10px; + left: 10px; + background-color: var(--live-color); + color: var(--white); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.03em; + line-height: 1; + padding: 4px 7px; + border-radius: 5px; +} + +.live-status-title { + font-size: 17px; + font-weight: 600; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.live-status-domain { + margin-top: 4px; + font-size: 14px; + color: var(--text-color-muted); +} + +.live-status-watch-button { + text-align: center; + text-decoration: none; +} + +.live-status-divider { + border-top: var(--hair) solid var(--generic-border-color); +} + +.live-status-profile-row { + display: flex; + align-items: center; + gap: 10px; +} + +.live-status-profile-info { + flex: 1; + min-width: 0; +} + +.live-status-profile-name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.live-status-profile-handle { + font-size: 14px; + color: var(--text-color-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.live-status-footer { + text-align: center; + font-size: 13px; + color: var(--text-color-muted); + padding: 0 16px 16px; +} + .bottom-sheet.reactions-dialog { height: 28em; } diff --git a/src/js/app.js b/src/js/app.js index 10b865f7..8f431630 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -49,6 +49,7 @@ import { NewChatService } from "/js/newChatService.js"; import { AccountSwitcherService } from "/js/accountSwitcherService.js"; import { ReportService } from "/js/reportService.js"; import { GroupChatLinkService } from "/js/groupChatLinkService.js"; +import { LiveStatusService } from "/js/liveStatusService.js"; import { ProfileHoverCardService } from "/js/profileHoverCardService.js"; import { InteractionHandlers } from "/js/interactionHandlers.js"; import { hapticsImpactLight } from "/js/haptics.js"; @@ -167,6 +168,7 @@ export async function main() { : null; const reportService = session ? new ReportService(dataLayer) : null; const groupChatLinkService = new GroupChatLinkService(dataLayer, router); + const liveStatusService = new LiveStatusService(dataLayer); const interactionHandlers = new InteractionHandlers({ session, dataLayer, @@ -221,6 +223,7 @@ export async function main() { accountSwitcherService, reportService, groupChatLinkService, + liveStatusService, pluginService, interactionHandlers, profileHoverCardService, diff --git a/src/js/components/live-status-dialog.js b/src/js/components/live-status-dialog.js new file mode 100644 index 00000000..4a5f30ae --- /dev/null +++ b/src/js/components/live-status-dialog.js @@ -0,0 +1,147 @@ +import { Component } from "/js/components/component.js"; +import { html, render } from "/js/lib/lit-html.js"; +import { scrollLocks } from "/js/scrollLocks.js"; +import { closeWithAnimation } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; +import { avatarTemplate } from "/js/templates/avatar.template.js"; +import { + cdnImageUrl, + getDisplayName, + getDisplayDomain, +} from "/js/dataHelpers.js"; +import { linkToProfile } from "/js/navigation.js"; +import { classnames } from "/js/utils.js"; + +class LiveStatusDialog extends Component { + connectedCallback() { + if (this._initialized) return; + this._initialized = true; + this.setAttribute("data-dialog-wrapper", ""); + this.scrollLock = null; + this.render(); + } + + open() { + const dialog = this.querySelector(".live-status-dialog"); + if (!dialog || dialog.open) return; + dialog.showModal(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); + enableDragToDismiss(dialog, { + onDismiss: () => this._close(), + scrollContainer: this.querySelector(".live-status-dialog-content"), + ignoreTouchTarget: (element) => element.closest("button, a") !== null, + }); + } + + disconnectedCallback() { + this.scrollLock?.release(); + this.scrollLock = null; + } + + _close() { + const dialog = this.querySelector(".live-status-dialog"); + if (!dialog?.open) { + this.scrollLock?.release(); + this.scrollLock = null; + this.dispatchEvent(new CustomEvent("close")); + return Promise.resolve(); + } + return closeWithAnimation(dialog); + } + + async _openProfile() { + await this._close(); + window.router.go(linkToProfile(this.profile)); + } + + render() { + const profile = this.profile; + const external = this.liveStatus?.embed?.external; + if (!profile || !external) return; + const title = external.title || external.uri; + const thumb = external.thumb ? cdnImageUrl(external.thumb) : null; + render( + html` + { + if (event.target === event.currentTarget) this._close(); + }} + @cancel=${(event) => { + event.preventDefault(); + this._close(); + }} + @close=${() => { + this.scrollLock?.release(); + this.scrollLock = null; + this.dispatchEvent(new CustomEvent("close")); + }} + > +
+
+ ${thumb + ? html`
+ +
LIVE
+
` + : null} +
+
${title}
+
+ ${getDisplayDomain(external.uri)} +
+
+ this._close()} + > + Watch now + +
+
+ ${avatarTemplate({ + author: profile, + clickAction: "none", + showLiveBadge: false, + })} +
+
+ ${getDisplayName(profile)} +
+
+ @${profile.handle} +
+
+ +
+
+ +
+
+ `, + this, + ); + } +} + +LiveStatusDialog.register(); diff --git a/src/js/components/profile-hover-card.js b/src/js/components/profile-hover-card.js index 043add8b..a5ece01b 100644 --- a/src/js/components/profile-hover-card.js +++ b/src/js/components/profile-hover-card.js @@ -2,6 +2,8 @@ import { html, render, keyed } from "/js/lib/lit-html.js"; import { Signal, effect } from "/js/signals.js"; import { classnames, formatLargeNumber } from "/js/utils.js"; import { + cdnImageUrl, + getDisplayDomain, getDisplayName, isLabelerProfile, hasValidHandle, @@ -49,16 +51,29 @@ class ProfileHoverCard extends Component { null; const currentUser = derived.$currentUser.get(); const isDetailed = !!derived.$hydratedDetailedProfiles.get(did); + const liveStatus = derived.$actorLiveStatus.get(did); + const isLive = liveStatus.state === "active" && !!profile; + // Match social-app: the live card is wider than the profile card + this.#floatingCard.style.width = isLive ? "350px" : "300px"; render( keyed( did, - hoverCardTemplate({ - profile, - isDetailed, - currentUser, - interactionHandlers: this.#interactionHandlers, - dataLayer: this.#dataLayer, - }), + isLive + ? liveHoverCardTemplate({ + profile, + liveStatus, + onOpenProfile: () => { + this.close(); + window.router.go(linkToProfile(profile)); + }, + }) + : hoverCardTemplate({ + profile, + isDetailed, + currentUser, + interactionHandlers: this.#interactionHandlers, + dataLayer: this.#dataLayer, + }), ), this.#floatingCard, ); @@ -100,6 +115,61 @@ class ProfileHoverCard extends Component { ProfileHoverCard.register(); +function liveHoverCardTemplate({ profile, liveStatus, onOpenProfile }) { + const external = liveStatus?.embed?.external; + if (!external) return null; + const title = external.title || external.uri; + const thumb = external.thumb ? cdnImageUrl(external.thumb) : null; + return html`
+
+ ${thumb + ? html`
+ +
LIVE
+
` + : null} +
+
${title}
+
${getDisplayDomain(external.uri)}
+
+ + Watch now + +
+
+ ${avatarTemplate({ + author: profile, + clickAction: "none", + showLiveBadge: false, + })} +
+
${getDisplayName(profile)}
+
@${profile.handle}
+
+ +
+
+
`; +} + function loadingBodyTemplate() { return html`
diff --git a/src/js/config.js b/src/js/config.js index 8930a497..d1cebc79 100644 --- a/src/js/config.js +++ b/src/js/config.js @@ -36,6 +36,15 @@ export const VIDEO_SERVICE_DID = "did:web:video.bsky.app"; export const BSKY_LABELER_DID = "did:plc:ar7c4by46qjdydhdevvrndac"; +export const LIVE_ALLOWED_DOMAINS = [ + "twitch.tv", + "stream.place", + "bluecast.app", + "youtube.com", + "substack.com", + "beehiiv.com", +]; + export const IN_APP_LINK_DOMAINS = [ "bsky.app", "impro.social", diff --git a/src/js/dataHelpers.js b/src/js/dataHelpers.js index 81b5151e..eec37bde 100644 --- a/src/js/dataHelpers.js +++ b/src/js/dataHelpers.js @@ -3,6 +3,7 @@ import { CDN_URL, FOLLOWING_FEED_URI, IN_APP_LINK_DOMAINS, + LIVE_ALLOWED_DOMAINS, } from "/js/config.js"; export const INVALID_HANDLE = "handle.invalid"; @@ -1039,6 +1040,36 @@ export function isInAppLinkHostname(hostname) { ); } +// Display form of a link's host, e.g. "https://www.twitch.tv/x" -> "twitch.tv" +export function getDisplayDomain(url) { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +export function isAllowedLiveHost(url) { + let hostname; + try { + hostname = new URL(url).hostname.toLowerCase(); + } catch { + return false; + } + return LIVE_ALLOWED_DOMAINS.some( + (domain) => hostname === domain || hostname.endsWith("." + domain), + ); +} + +export function isStatusValid(statusView) { + if (!statusView || typeof statusView !== "object") return false; + if (statusView.status !== "app.bsky.actor.status#live") return false; + if (!statusView.uri || !statusView.expiresAt) return false; + if (statusView.embed?.$type !== "app.bsky.embed.external#view") return false; + const externalUri = statusView.embed.external?.uri; + return !!externalUri && isAllowedLiveHost(externalUri); +} + const CHAT_INVITE_PATH_REGEX = /^\/chat\/([a-zA-Z0-9]{7,10})$/; export function getInviteCodeFromUrl(url) { diff --git a/src/js/dataLayer/dataStore.js b/src/js/dataLayer/dataStore.js index 104d7b5c..9aeebe39 100644 --- a/src/js/dataLayer/dataStore.js +++ b/src/js/dataLayer/dataStore.js @@ -114,6 +114,12 @@ export class DataStore extends ReactiveStore { this.$profiles.set(profile.did, merged); } + setDetailedProfile(profile) { + if (!profile?.did) return; + this.$profiles.set(profile.did, profile); + this.$detailedProfiles.set(profile.did, profile); + } + setProfiles(profiles) { for (const profile of profiles) { this.mergeProfile(profile); diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index d90e10ca..8b210c4c 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -25,8 +25,9 @@ import { attachJoinLinkPreviewToEmbed, getJoinLinkCodeFromEmbed, isFollowingFeedUri, + isStatusValid, } from "/js/dataHelpers.js"; -import { sortBy } from "/js/utils.js"; +import { sortBy, KeyedScheduler } from "/js/utils.js"; import { FOLLOWING_FEED_URI } from "/js/config.js"; import { effect, @@ -123,6 +124,7 @@ export class Derived extends ReactiveStore { hiddenFeedItemsStore, isAuthenticated, draftMediaStore, + liveStatusScheduler = new KeyedScheduler(), ) { super("derived"); this.dataStore = dataStore; @@ -131,6 +133,9 @@ export class Derived extends ReactiveStore { this.hiddenFeedItemsStore = hiddenFeedItemsStore; this.isAuthenticated = isAuthenticated; this.draftMediaStore = draftMediaStore; + this.liveStatusScheduler = liveStatusScheduler; + // did → increasing id, bumped when an actor's live status expires + this.$liveStatusRecheckIds = new SignalMap(); this.$showLessInteractions = new ComputedMap( (feedUri) => this.dataStore.$showLessInteractions.get(feedUri) ?? [], ); @@ -173,6 +178,61 @@ export class Derived extends ReactiveStore { if (!patches?.length) return profile; return this.patchStore.applyProfilePatches(profile, patches); }); + // { state: "none" | "active" | "inactive" } + this.$actorLiveStatus = new ComputedMap((did) => { + const profile = + this.$patchedDetailedProfiles.get(did) ?? + this.$patchedProfiles.get(did); + const statusView = profile?.status; + if (!statusView || typeof statusView !== "object") { + return { state: "none" }; + } + const viewer = profile.viewer; + const preferences = this.$preferences.get(); + if ( + viewer?.blocking || + viewer?.blockedBy || + viewer?.muted || + (preferences && preferences.getProfileBlurLabel(profile)) + ) { + return { state: "none" }; + } + if (isStatusValid(statusView)) { + // Subscribe to expiry rechecks + this.$liveStatusRecheckIds.get(did); + const delayMs = Date.parse(statusView.expiresAt) - Date.now(); + if (Number.isFinite(delayMs) && delayMs > 0) { + // Schedule expiry recheck - note this is a side effect, so be careful with this logic! + this.liveStatusScheduler.schedule(did, delayMs, () => { + this.$liveStatusRecheckIds.set( + did, + (this.$liveStatusRecheckIds.get(did) ?? 0) + 1, + ); + }); + if (!statusView.isDisabled) { + return { + state: "active", + uri: statusView.uri, + cid: statusView.cid ?? null, + embed: statusView.embed, + expiresAt: statusView.expiresAt, + record: statusView.record ?? null, + }; + } + } + } + return { + state: "inactive", + isDisabled: !!statusView.isDisabled, + uri: statusView.uri ?? null, + cid: statusView.cid ?? null, + record: statusView.record ?? null, + expiresAt: statusView.expiresAt ?? null, + }; + }); + this.$isActorLive = new ComputedMap( + (did) => this.$actorLiveStatus.get(did).state === "active", + ); this.$patchedMessages = new ComputedMap((messageId) => { const message = this.dataStore.$messages.get(messageId); if (!message) return null; @@ -454,16 +514,12 @@ export class Derived extends ReactiveStore { this.$hydratedProfiles = new ComputedMap((did) => { const profile = this.$patchedProfiles.get(did); if (!profile) return profile; - const preferences = this.$preferences.get(); - if (!preferences) return profile; - return this.hydrateProfileLabels(profile, preferences); + return this.hydrateProfile(profile); }); this.$hydratedDetailedProfiles = new ComputedMap((did) => { const profile = this.$patchedDetailedProfiles.get(did); if (!profile) return null; - const preferences = this.$preferences.get(); - if (!preferences) return profile; - return this.hydrateProfileLabels(profile, preferences); + return this.hydrateProfile(profile); }); this.$hydratedAuthorFeeds = new ComputedMap((feedURI) => { const rawFeed = this.dataStore.$authorFeeds.get(feedURI); @@ -567,9 +623,14 @@ export class Derived extends ReactiveStore { if (!preferences) return null; return preferences.getLabelerSettings(labelerDid); }); - this.$convos = new ComputedMap((convoId) => - this.$patchedConvos.get(convoId), - ); + this.$convos = new ComputedMap((convoId) => { + const convo = this.$patchedConvos.get(convoId); + if (!convo) return convo; + return { + ...convo, + members: convo.members.map((member) => this.hydrateProfile(member)), + }; + }); this.$convoList = new Signal.Computed(() => { const data = this.dataStore.$convoList.get(); if (!data) return null; @@ -618,12 +679,9 @@ export class Derived extends ReactiveStore { .filter((did) => !convo.members.some((member) => member.did === did)) .map((did) => this.$hydratedProfiles.get(did)) .filter(Boolean); - const preferences = this.$preferences.get(); - const members = preferences - ? convo.members.map((member) => - this.hydrateProfileLabels(member, preferences), - ) - : convo.members; + const members = convo.members.map((member) => + this.hydrateProfile(member), + ); return [...members, ...referencedProfiles]; }); this.$convoForProfile = new ComputedMap((profileDid) => { @@ -797,15 +855,22 @@ export class Derived extends ReactiveStore { return { ...item, embed: updated }; } - hydrateProfileLabels(profile, preferences) { + hydrateProfile(profile) { + const preferences = this.$preferences.get(); let result = profile; - const blurLabel = preferences.getProfileBlurLabel(profile); - if (blurLabel) { - result = { ...result, blurLabel }; + if (preferences) { + const blurLabel = preferences.getProfileBlurLabel(result); + if (blurLabel) { + result = { ...result, blurLabel }; + } + const badgeLabels = preferences.getBadgeLabelsForProfile(result); + if (badgeLabels.length > 0) { + result = { ...result, badgeLabels }; + } } - const badgeLabels = preferences.getBadgeLabelsForProfile(profile); - if (badgeLabels.length > 0) { - result = { ...result, badgeLabels }; + if (result?.did && this.$isActorLive.get(result.did)) { + // NOTE: LEXICON DEVIATION + result = { ...result, isLive: true }; } return result; } @@ -815,7 +880,7 @@ export class Derived extends ReactiveStore { return null; } const storedAuthor = post.author?.did - ? this.$patchedProfiles.get(post.author.did) + ? this.$hydratedProfiles.get(post.author.did) : null; if (storedAuthor) { post = { ...post, author: storedAuthor }; diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index aa18b974..904211f7 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -578,16 +578,14 @@ export class Requests { async loadDetailedProfile(did) { const profile = await this.api.getProfile(did); - this.dataStore.$profiles.set(did, profile); - this.dataStore.$detailedProfiles.set(did, profile); + this.dataStore.setDetailedProfile(profile); } async loadDetailedProfiles(dids) { if (dids.length === 0) return; const profiles = await this.api.getProfiles(dids); for (const profile of profiles) { - this.dataStore.$profiles.set(profile.did, profile); - this.dataStore.$detailedProfiles.set(profile.did, profile); + this.dataStore.setDetailedProfile(profile); } } diff --git a/src/js/liveStatusService.js b/src/js/liveStatusService.js new file mode 100644 index 00000000..0f20d15c --- /dev/null +++ b/src/js/liveStatusService.js @@ -0,0 +1,24 @@ +import "/js/components/live-status-dialog.js"; + +export class LiveStatusService { + constructor(dataLayer) { + this.dataLayer = dataLayer; + } + + handleAvatarClick(did) { + const liveStatus = this.dataLayer.derived.$actorLiveStatus.get(did); + if (liveStatus?.state !== "active") return; + const profile = + this.dataLayer.derived.$hydratedProfiles.get(did) ?? + this.dataLayer.derived.$hydratedDetailedProfiles.get(did); + if (!profile) return; + const dialog = document.createElement("live-status-dialog"); + dialog.profile = profile; + dialog.liveStatus = liveStatus; + dialog.addEventListener("close", () => { + dialog.remove(); + }); + document.body.appendChild(dialog); + dialog.open(); + } +} diff --git a/src/js/mainLayout.js b/src/js/mainLayout.js index 81c0b786..696cb7b2 100644 --- a/src/js/mainLayout.js +++ b/src/js/mainLayout.js @@ -69,6 +69,7 @@ export function mainLayoutTemplate({ previewingPlugins = [], onLongPressProfile = null, groupChatLinkService, + liveStatusService, dataLayer, }) { return html` @@ -79,6 +80,8 @@ export function mainLayoutTemplate({ e.detail.actionType, e.detail.preview, )} + @live-avatar:click=${(e) => + liveStatusService.handleAvatarClick(e.detail.did)} >
@@ -156,6 +159,7 @@ export class MainLayout extends Layout { accountSwitcherService, pluginService, groupChatLinkService, + liveStatusService, profileHoverCardService, auth, } = this.context; @@ -213,6 +217,7 @@ export class MainLayout extends Layout { previewingPlugins, onLongPressProfile, groupChatLinkService, + liveStatusService, dataLayer, }), container, diff --git a/src/js/templates/avatar.template.js b/src/js/templates/avatar.template.js index 9872115b..6864efec 100644 --- a/src/js/templates/avatar.template.js +++ b/src/js/templates/avatar.template.js @@ -5,14 +5,46 @@ import { getDisplayName, isLabelerProfile, } from "/js/dataHelpers.js"; -import { classnames } from "/js/utils.js"; +import { classnames, isNative, isTouchOnlyDevice } from "/js/utils.js"; import { linkToProfile } from "/js/navigation.js"; import "/js/components/lightbox-image-group.js"; -// CLick actions: "link", "lightbox", "none" +// Click actions: "default", "link", "lightbox", "live", "none". +// "default" links to the profile, except live avatars on touch devices open +// the live status dialog instead; "link" always navigates. -function avatarWrapperTemplate({ author, clickAction, children }) { - if (clickAction === "link") { +// Bubbles up to the main layout, which opens the live status dialog +function dispatchLiveAvatarClick(event, author) { + event.currentTarget.dispatchEvent( + new CustomEvent("live-avatar:click", { + detail: { did: author.did }, + bubbles: true, + }), + ); +} + +// Wraps a live avatar's frame so the LIVE badge can hang below it +function liveWrapperTemplate({ isLive, showLiveBadge, children }) { + if (!isLive) return children; + return html`
+ ${children} + ${showLiveBadge + ? html`
LIVE
` + : null} +
`; +} + +function avatarWrapperTemplate({ author, clickAction, isLive, children }) { + if (clickAction === "live") { + return html``; + } else if (clickAction === "link") { return html`${children}`; - } else { + } else if (clickAction === "none") { return children; + } else { + // "default" has "link" behavior unless user is live, + // in which case it opens the live status dialog on touch devices + return html` { + if (isLive && (isNative() || isTouchOnlyDevice())) { + event.preventDefault(); + dispatchLiveAvatarClick(event, author); + } + }} + >${children}`; } } @@ -54,38 +101,47 @@ function getAvatarFullSizeUrl(author, isLabeler) { export function avatarTemplate({ author, - clickAction = "link", + clickAction = "default", lazyLoad = false, // lazyLoad = true, + showLiveStatus = true, + showLiveBadge = true, }) { const isLabeler = isLabelerProfile(author); const isBlurred = !!author.blurLabel; + const isLive = showLiveStatus && !!author.isLive; const avatarThumbnailUrl = getAvataThumbnailUrl(author, isLabeler); const avatarFullSizeUrl = getAvatarFullSizeUrl(author, isLabeler); return html`
${avatarWrapperTemplate({ author, clickAction, + isLive, children: keyed( author.handle, - html`
- x - ${getDisplayName(author)} profile picture -
`, + > + x + ${getDisplayName(author)} profile picture +
`, + }), ), })}
`; diff --git a/src/js/templates/avatarGroup.template.js b/src/js/templates/avatarGroup.template.js index 30eb883b..41025d11 100644 --- a/src/js/templates/avatarGroup.template.js +++ b/src/js/templates/avatarGroup.template.js @@ -8,7 +8,11 @@ export function avatarGroupTemplate({ authors }) {
`; } if (authors.length === 1) { - return avatarTemplate({ author: authors[0], clickAction: "none" }); + return avatarTemplate({ + author: authors[0], + clickAction: "none", + showLiveStatus: false, + }); } const shownAuthors = authors.slice(0, 4); return html`
html`
- ${avatarTemplate({ author, clickAction: "none" })} + ${avatarTemplate({ + author, + clickAction: "none", + showLiveStatus: false, + })}
`, )}
`; diff --git a/src/js/templates/profileCard.template.js b/src/js/templates/profileCard.template.js index 6807d3aa..bea52b86 100644 --- a/src/js/templates/profileCard.template.js +++ b/src/js/templates/profileCard.template.js @@ -267,6 +267,7 @@ export function profileCardTemplate({ onClickAddToLists = noop, onClickReport = noop, onClickEditProfile = noop, + liveStatus = null, pluginService = null, isFollowPending = false, isBlockPending = false, @@ -301,7 +302,10 @@ export function profileCardTemplate({
${avatarTemplate({ author: profile, - clickAction: "lightbox", + clickAction: + liveStatus?.state === "active" && !isCurrentUser + ? "live" + : "lightbox", })} ${!isCurrentUser && !isLabeler && isAuthenticated && !isBlockedBy ? html` ${isFollowing diff --git a/src/js/templates/profileFeed.template.js b/src/js/templates/profileFeed.template.js index 3b680b01..8c43641e 100644 --- a/src/js/templates/profileFeed.template.js +++ b/src/js/templates/profileFeed.template.js @@ -137,7 +137,7 @@ export function profileListItemTemplate({
${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 = {}) {