From 516a8d19053b010f6e06c5b2ed645dd31dafd909 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Wed, 2 Sep 2026 17:44:43 -0500 Subject: [PATCH 1/4] Add go live setting from profile page --- .../buildIconSprite.js | 58 ++-- eleventy.config.js | 3 + package.json | 2 +- src/_includes/head.html | 1 - src/css/style.css | 160 ++++++++++- src/img/icons/custom/broadcast-line.svg | 1 + src/index.html | 1 + src/js/api.js | 44 +++ src/js/components/app-icon.js | 2 +- src/js/components/drafts-dialog.js | 4 +- src/js/components/edit-live-dialog.js | 211 ++++++++++++++ src/js/components/go-live-dialog.js | 191 +++++++++++++ src/js/components/live-link-form.js | 213 ++++++++++++++ src/js/components/live-status-dialog.js | 2 +- src/js/components/post-composer.js | 4 +- src/js/components/profile-hover-card.js | 2 +- src/js/config.js | 14 +- src/js/dataHelpers.js | 39 ++- src/js/dataLayer/dataStore.js | 18 +- src/js/dataLayer/derived.js | 14 +- src/js/dataLayer/mutations.js | 91 +++++- src/js/dataLayer/requests.js | 2 +- src/js/embedHelpers.js | 16 +- src/js/liveStatusService.js | 25 +- src/js/mainLayout.js | 5 +- src/js/postComposerService.js | 2 +- src/js/postCreator.js | 16 +- src/js/postInteractionHandler.js | 2 +- src/js/templates/avatar.template.js | 2 +- src/js/templates/postHeaderText.template.js | 4 +- src/js/templates/profileCard.template.js | 81 ++++-- src/js/utils.js | 42 ++- src/js/views/chat.view.js | 4 +- src/js/views/chatDetail.view.js | 13 +- src/js/views/chatRequests.view.js | 4 +- src/js/views/notifications.view.js | 20 +- src/js/views/profile.view.js | 40 ++- tests/e2e/mockServer.js | 92 ++++++ tests/e2e/specs/flows/goLive.test.js | 159 +++++++++++ tests/e2e/specs/views/profile.view.test.js | 8 +- tests/unit/specs/components/app-icon.test.js | 12 +- .../specs/components/live-link-form.test.js | 30 ++ .../components/profile-hover-card.test.js | 15 +- tests/unit/specs/dataLayer/liveStatus.test.js | 265 +++++++++++++++++- 44 files changed, 1778 insertions(+), 156 deletions(-) rename src/img/icons.11ty.js => build-support/buildIconSprite.js (54%) create mode 100644 src/img/icons/custom/broadcast-line.svg create mode 100644 src/js/components/edit-live-dialog.js create mode 100644 src/js/components/go-live-dialog.js create mode 100644 src/js/components/live-link-form.js create mode 100644 tests/e2e/specs/flows/goLive.test.js create mode 100644 tests/unit/specs/components/live-link-form.test.js diff --git a/src/img/icons.11ty.js b/build-support/buildIconSprite.js similarity index 54% rename from src/img/icons.11ty.js rename to build-support/buildIconSprite.js index b394a31f..03ac22cb 100644 --- a/src/img/icons.11ty.js +++ b/build-support/buildIconSprite.js @@ -2,7 +2,13 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { join, basename, dirname, relative } from "node:path"; import { fileURLToPath } from "node:url"; -const ICONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "icons"); +const ICONS_DIR = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "src", + "img", + "icons", +); const PRESERVED_ATTRS = [ "fill", @@ -41,35 +47,23 @@ function extractInner(svg) { return { viewBox, attrs: attrs.join(" "), inner }; } -export default { - data() { - return { - permalink: "/img/icons.svg", - eleventyExcludeFromCollections: true, - }; - }, - - render() { - // Combine every SVG under src/img/icons/** into a sprite file - const files = walk(ICONS_DIR).sort(); - const symbols = []; - const seen = new Set(); - for (const file of files) { - const id = basename(file, ".svg"); - if (seen.has(id)) { - console.warn( - `duplicate icon id "${id}" (${relative(ICONS_DIR, file)})`, - ); - continue; - } - seen.add(id); - const svg = readFileSync(file, "utf8"); - const { viewBox, attrs, inner } = extractInner(svg); - const attrStr = attrs ? ` ${attrs}` : ""; - symbols.push( - `${inner}`, - ); +export function buildIconSprite() { + const files = walk(ICONS_DIR).sort(); + const symbols = []; + const seen = new Set(); + for (const file of files) { + const id = basename(file, ".svg"); + if (seen.has(id)) { + console.warn(`duplicate icon id "${id}" (${relative(ICONS_DIR, file)})`); + continue; } - return `${symbols.join("")}`; - }, -}; + seen.add(id); + const svg = readFileSync(file, "utf8"); + const { viewBox, attrs, inner } = extractInner(svg); + const attrStr = attrs ? ` ${attrs}` : ""; + symbols.push( + `${inner}`, + ); + } + return ``; +} diff --git a/eleventy.config.js b/eleventy.config.js index 335b81c0..4e884c2e 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -3,6 +3,7 @@ import path from "node:path"; import { isAssetPath } from "./build-support/assetPaths.js"; import { applyContentHashing } from "./build-support/contentHash.js"; import { watchLocalPlugins } from "./build-support/localPlugins.js"; +import { buildIconSprite } from "./build-support/buildIconSprite.js"; const BUILD_DIR = process.env.BUILD_DIR || "build"; @@ -19,6 +20,8 @@ export default async function (eleventyConfig) { ); eleventyConfig.addPassthroughCopy("src/img/shortcuts"); eleventyConfig.addWatchTarget("src/img/icons"); + // Inlined icons for index html + eleventyConfig.addShortcode("iconSprite", () => buildIconSprite()); eleventyConfig.addPassthroughCopy("src/manifest.json"); eleventyConfig.addPassthroughCopy("src/sw.js"); eleventyConfig.addPassthroughCopy("src/_headers"); diff --git a/package.json b/package.json index 1941560e..45219af3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.41", + "version": "0.20.42", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/_includes/head.html b/src/_includes/head.html index 19889459..0452f0a7 100644 --- a/src/_includes/head.html +++ b/src/_includes/head.html @@ -8,7 +8,6 @@ /> Impro - context-menu-item > button { font-weight: 500; } +.bottom-sheet .checkbox-row, +.bottom-sheet .pill-radio-group label, +.bottom-sheet .interaction-settings-quote-row { + background: var(--secondary-button-color); +} + .checkbox-row:has(input:checked) { background: color-mix(in srgb, var(--highlight-color) 15%, transparent); } @@ -10222,6 +10375,7 @@ label:has(toggle-switch[disabled]) { color: var(--text-color); padding: 4px 8px; opacity: 0.6; + font-size: 24px; } @media (min-width: 800px) { diff --git a/src/img/icons/custom/broadcast-line.svg b/src/img/icons/custom/broadcast-line.svg new file mode 100644 index 00000000..2d3c23f9 --- /dev/null +++ b/src/img/icons/custom/broadcast-line.svg @@ -0,0 +1 @@ + diff --git a/src/index.html b/src/index.html index 6d690380..09230c82 100644 --- a/src/index.html +++ b/src/index.html @@ -5,6 +5,7 @@ + {% iconSprite %}
diff --git a/src/js/api.js b/src/js/api.js index 39bfb943..775dd66a 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -34,6 +34,10 @@ export function isRecordNotFoundError(error) { return error instanceof ApiError && error.data?.error === "RecordNotFound"; } +export function isInvalidSwapError(error) { + return error instanceof ApiError && error.data?.error === "InvalidSwap"; +} + class PublicSession { constructor() { this.serviceEndpoint = PUBLIC_SERVICE_ENDPOINT_URL; @@ -1330,6 +1334,46 @@ export class Api { return res.data; } + async getStatusRecord() { + const res = await this.request("com.atproto.repo.getRecord", { + query: { + repo: this.session.did, + collection: "app.bsky.actor.status", + rkey: "self", + }, + }); + return res.data; + } + + async putStatusRecord(record, swapRecord) { + const res = await this.request("com.atproto.repo.putRecord", { + method: "POST", + body: { + repo: this.session.did, + collection: "app.bsky.actor.status", + rkey: "self", + record: { + $type: "app.bsky.actor.status", + ...record, + }, + swapRecord: swapRecord ?? null, + }, + }); + return res.data; + } + + async deleteStatusRecord() { + const res = await this.request("com.atproto.repo.deleteRecord", { + method: "POST", + body: { + repo: this.session.did, + collection: "app.bsky.actor.status", + rkey: "self", + }, + }); + return res.data; + } + async createListRecord(record) { const res = await this.request("com.atproto.repo.createRecord", { method: "POST", diff --git a/src/js/components/app-icon.js b/src/js/components/app-icon.js index b577bad8..b9c31425 100644 --- a/src/js/components/app-icon.js +++ b/src/js/components/app-icon.js @@ -22,7 +22,7 @@ export class AppIcon extends Component { render(html``, this); return; } - render(html``, this); + render(html``, this); } } diff --git a/src/js/components/drafts-dialog.js b/src/js/components/drafts-dialog.js index 4cd6b005..74964280 100644 --- a/src/js/components/drafts-dialog.js +++ b/src/js/components/drafts-dialog.js @@ -1,7 +1,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { displayRelativeTime } from "/js/utils.js"; +import { formatRelativeTime } from "/js/utils.js"; import { closeWithAnimation } from "/js/dialogHelpers.js"; import { enableDragToDismiss } from "/js/dragHelpers.js"; import { Signal, ReactiveStore, effect, untrack } from "/js/signals.js"; @@ -90,7 +90,7 @@ function draftItemTemplate({ draftView, onSelect, onDelete }) { >
- ${displayRelativeTime(draftView.updatedAt)} + ${formatRelativeTime(draftView.updatedAt)}
${firstPost.text ? html`
${firstPost.text}
` diff --git a/src/js/components/edit-live-dialog.js b/src/js/components/edit-live-dialog.js new file mode 100644 index 00000000..da63d6f4 --- /dev/null +++ b/src/js/components/edit-live-dialog.js @@ -0,0 +1,211 @@ +import { html, render } from "/js/lib/lit-html.js"; +import { Component } from "/js/components/component.js"; +import { scrollLocks } from "/js/scrollLocks.js"; +import { closeWithAnimation } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; +import { showToast } from "/js/toasts.js"; +import { classnames, formatDuration, formatShortTime } from "/js/utils.js"; +import { confirmModal } from "/js/modals/confirm.modal.js"; +import "/js/components/live-link-form.js"; + +class EditLiveDialog extends Component { + connectedCallback() { + if (this._initialized) return; + this._initialized = true; + this.setAttribute("data-dialog-wrapper", ""); + this.scrollLock = null; + const external = this.liveStatus?.embed?.external; + this._originalUrl = external?.uri ?? ""; + this._initialLinkMeta = external + ? { + url: external.uri, + title: external.title || external.uri, + description: external.description || "", + image: external.thumb || null, + } + : null; + this._linkMeta = this._initialLinkMeta; + this._linkFormLoading = false; + this._submitError = null; + this._submitting = false; + this.render(); + // Re-render every minute so the "expires in ... at ..." row stays current. + this._tickInterval = setInterval(() => this.render(), 60_000); + } + + disconnectedCallback() { + clearInterval(this._tickInterval); + this._tickInterval = null; + this.scrollLock?.release(); + this.scrollLock = null; + } + + open() { + const dialog = this.querySelector(".edit-live-dialog"); + if (!dialog || dialog.open) return; + dialog.showModal(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); + this.querySelector("live-link-form")?.focus({ preventScroll: true }); + enableDragToDismiss(dialog, { + onDismiss: () => this._close(), + scrollContainer: this.querySelector(".live-dialog-content"), + ignoreTouchTarget: (element) => + element.closest("button, a, input, select") !== null, + disableWhenKeyboardOpen: true, + }); + } + + _close() { + const dialog = this.querySelector(".edit-live-dialog"); + if (!dialog?.open) { + this.scrollLock?.release(); + this.scrollLock = null; + this.dispatchEvent(new CustomEvent("close")); + return Promise.resolve(); + } + return closeWithAnimation(dialog); + } + + _isDirty() { + const url = this._linkMeta?.url; + return !!url && url !== this._originalUrl; + } + + _onLinkFormChange(event) { + this._linkMeta = event.detail.linkMeta; + this._linkFormLoading = event.detail.isLoading; + this.render(); + } + + async _submit() { + const record = this.liveStatus?.record; + this._submitting = true; + this._submitError = null; + this.render(); + try { + await this.dataLayer.mutations.setLiveStatus({ + durationMinutes: record.durationMinutes, + linkMeta: this._linkMeta, + createdAt: record.createdAt, + }); + await this._close(); + showToast("Live status updated", { style: "success" }); + } catch (error) { + console.warn("Failed to update live status", error); + this._submitError = + error?.data?.message ?? + "Failed to update your live status. Please try again."; + this._submitting = false; + this.render(); + } + } + + async _remove() { + const confirmed = await confirmModal("Remove your live status?", { + title: "Remove live status", + confirmButtonText: "Remove", + confirmButtonStyle: "danger", + pendingText: "Removing", + onConfirm: async () => { + await this.dataLayer.mutations.clearLiveStatus(); + }, + }); + if (confirmed) { + await this._close(); + showToast("You are no longer live", { style: "success" }); + } + } + + render() { + const record = this.liveStatus?.record; + const durationMinutes = record?.durationMinutes; + const expiresAtMs = this.liveStatus?.expiresAt + ? Date.parse(this.liveStatus.expiresAt) + : null; + const remainingMinutes = expiresAtMs + ? Math.max(0, Math.round((expiresAtMs - Date.now()) / 60000)) + : null; + const expiryLabel = + remainingMinutes !== null + ? `Expires in ${formatDuration(remainingMinutes)} at ${formatShortTime(expiresAtMs)}` + : "No expiration set"; + const isDirty = this._isDirty(); + const canSave = + isDirty && + !this._submitting && + !!this._linkMeta && + !this._linkFormLoading; + + 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")); + }} + > +
+

You are Live

+
+ ${expiryLabel} +
+ this._onLinkFormChange(event)} + > + ${this._submitError + ? html`
+ ${this._submitError} +
` + : null} +
+ + +
+
+
+ `, + this, + ); + } +} + +EditLiveDialog.register(); diff --git a/src/js/components/go-live-dialog.js b/src/js/components/go-live-dialog.js new file mode 100644 index 00000000..a9e28177 --- /dev/null +++ b/src/js/components/go-live-dialog.js @@ -0,0 +1,191 @@ +import { html, render } from "/js/lib/lit-html.js"; +import { Component } from "/js/components/component.js"; +import { scrollLocks } from "/js/scrollLocks.js"; +import { closeWithAnimation } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; +import { showToast } from "/js/toasts.js"; +import { classnames, formatDuration, formatShortTime } from "/js/utils.js"; +import "/js/components/live-link-form.js"; + +const DURATIONS = Array.from({ length: 48 }, (_, i) => (i + 1) * 5); // 5..240 +const DEFAULT_DURATION = 60; + +class GoLiveDialog extends Component { + connectedCallback() { + if (this._initialized) return; + this._initialized = true; + this.setAttribute("data-dialog-wrapper", ""); + this.scrollLock = null; + this._submitError = null; + this._submitting = false; + this._duration = DEFAULT_DURATION; + this._linkMeta = null; + this._linkFormLoading = false; + this.render(); + // Re-render every minute so end times in the duration select stay current. + this._tickInterval = setInterval(() => this.render(), 60_000); + } + + disconnectedCallback() { + clearInterval(this._tickInterval); + this._tickInterval = null; + this.scrollLock?.release(); + this.scrollLock = null; + } + + open() { + const dialog = this.querySelector(".go-live-dialog"); + if (!dialog || dialog.open) return; + dialog.showModal(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); + this.querySelector("live-link-form")?.focus({ preventScroll: true }); + enableDragToDismiss(dialog, { + onDismiss: () => this._close(), + scrollContainer: this.querySelector(".live-dialog-content"), + ignoreTouchTarget: (element) => + element.closest("button, a, input, select") !== null, + disableWhenKeyboardOpen: true, + }); + } + + _close() { + const dialog = this.querySelector(".go-live-dialog"); + if (!dialog?.open) { + this.scrollLock?.release(); + this.scrollLock = null; + this.dispatchEvent(new CustomEvent("close")); + return Promise.resolve(); + } + return closeWithAnimation(dialog); + } + + _onLinkFormChange(event) { + this._linkMeta = event.detail.linkMeta; + this._linkFormLoading = event.detail.isLoading; + this.render(); + } + + _handleDurationChange(event) { + this._duration = Number(event.target.value); + this.render(); + } + + async _submit() { + this._submitting = true; + this._submitError = null; + this.render(); + try { + await this.dataLayer.mutations.setLiveStatus({ + durationMinutes: this._duration, + linkMeta: this._linkMeta, + }); + await this._close(); + showToast("You are now live!", { style: "success" }); + } catch (error) { + console.warn("Failed to publish live status", error); + this._submitError = + error?.data?.message ?? + "Failed to publish your live status. Please try again."; + this._submitting = false; + this.render(); + } + } + + render() { + const canSubmit = + !this._submitting && !!this._linkMeta && !this._linkFormLoading; + 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")); + }} + > +
+

Go Live

+

+ Add a temporary live status to your profile. +

+ this._onLinkFormChange(event)} + > + ${this._linkMeta + ? html`` + : null} + ${this._submitError + ? html`
+ ${this._submitError} +
` + : null} +
+ + +
+
+
+ `, + this, + ); + } +} + +GoLiveDialog.register(); diff --git a/src/js/components/live-link-form.js b/src/js/components/live-link-form.js new file mode 100644 index 00000000..501fa522 --- /dev/null +++ b/src/js/components/live-link-form.js @@ -0,0 +1,213 @@ +import { html, render } from "/js/lib/lit-html.js"; +import { Component } from "/js/components/component.js"; +import { isAllowedLiveHost, getDisplayDomain } from "/js/dataHelpers.js"; +import { getLinkCardMeta } from "/js/embedHelpers.js"; +import { LIVE_ALLOWED_SERVICES } from "/js/config.js"; +import { normalizeUrl } from "/js/utils.js"; + +const ALLOWED_SERVICES_LABEL = + "Allowed services: " + + LIVE_ALLOWED_SERVICES.map(({ displayName }) => displayName).join(", "); + +function errorTemplate({ error }) { + return html`
+ ${error} +
`; +} + +function infoTemplate() { + return html`
+ ${ALLOWED_SERVICES_LABEL} +
`; +} + +function previewTemplate({ linkMeta, linkMetaFailed }) { + return html` + + ${linkMetaFailed + ? html`
+ We couldn't load a preview for this link. +
` + : null} + `; +} + +class LiveLinkForm extends Component { + connectedCallback() { + if (this._initialized) return; + this._initialized = true; + const initialUrl = this.initialUrl ?? ""; + const initialMeta = this.initialLinkMeta ?? null; + this._urlInput = initialUrl; + this._normalizedUrl = initialUrl ? normalizeUrl(initialUrl) : null; + this._debouncedUrl = this._normalizedUrl; + this._debounceTimer = null; + this._linkMeta = initialMeta; + this._linkMetaLoading = false; + this._linkMetaFailed = false; + this._urlError = null; + this.render(); + if (this._normalizedUrl && !initialMeta) { + this._loadLinkMeta(this._normalizedUrl); + } + } + + disconnectedCallback() { + clearTimeout(this._debounceTimer); + } + + focus(options) { + this.querySelector("input")?.focus(options); + } + + get isLoading() { + return this._debouncedUrl !== this._normalizedUrl || this._linkMetaLoading; + } + + get linkMeta() { + return this._linkMeta; + } + + _notifyChange() { + this.dispatchEvent( + new CustomEvent("change", { + detail: { linkMeta: this._linkMeta, isLoading: this.isLoading }, + }), + ); + } + + _handleInput(event) { + const value = event.target.value; + this._urlInput = value; + this._urlError = null; + this._linkMetaFailed = false; + clearTimeout(this._debounceTimer); + const normalized = normalizeUrl(value); + this._normalizedUrl = normalized; + if (!value.trim()) { + this._linkMeta = null; + this._debouncedUrl = null; + this._linkMetaLoading = false; + this.render(); + this._notifyChange(); + return; + } + this._debounceTimer = setTimeout(() => { + this._debouncedUrl = normalized; + this._loadLinkMeta(normalized); + }, 500); + this.render(); + this._notifyChange(); + } + + _handleBlur() { + if (this._urlInput.trim() && !this._normalizedUrl) { + this._urlError = "This is not a valid link"; + this.render(); + this._notifyChange(); + } + } + + _handleFocus() { + if (this._urlError) { + this._urlError = null; + this.render(); + this._notifyChange(); + } + } + + async _loadLinkMeta(url) { + if (!url) { + this._linkMeta = null; + this._linkMetaLoading = false; + this._linkMetaFailed = false; + this.render(); + this._notifyChange(); + return; + } + if (!isAllowedLiveHost(url)) { + this._linkMeta = null; + this._linkMetaLoading = false; + this._linkMetaFailed = false; + this._urlError = + "This service is not supported while the Live feature is in beta."; + this.render(); + this._notifyChange(); + return; + } + this._linkMeta = { url, title: url, description: "", image: null }; + this._linkMetaLoading = true; + this._linkMetaFailed = false; + this.render(); + this._notifyChange(); + const data = await getLinkCardMeta(url); + if (this._debouncedUrl !== url) return; + if (data) { + this._linkMeta = { + url, + title: data.title || url, + description: data.description || "", + image: data.image || null, + }; + } else { + this._linkMetaFailed = true; + } + this._linkMetaLoading = false; + this.render(); + this._notifyChange(); + } + + render() { + render( + html` + + ${this._urlError + ? errorTemplate({ error: this._urlError }) + : this._linkMeta + ? previewTemplate({ + linkMeta: this._linkMeta, + linkMetaFailed: this._linkMetaFailed, + }) + : infoTemplate()} + `, + this, + ); + } +} + +LiveLinkForm.register(); diff --git a/src/js/components/live-status-dialog.js b/src/js/components/live-status-dialog.js index 4a5f30ae..2bf2b9f7 100644 --- a/src/js/components/live-status-dialog.js +++ b/src/js/components/live-status-dialog.js @@ -89,7 +89,7 @@ class LiveStatusDialog extends Component { src="${thumb}" alt="" class=${classnames("live-status-thumb-image", { - "live-status-thumb-image--blurred": !!profile.blurLabel, + "is-blurred": !!profile.blurLabel, })} />
LIVE
diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js index ec659073..8e7081eb 100644 --- a/src/js/components/post-composer.js +++ b/src/js/components/post-composer.js @@ -1733,9 +1733,7 @@ class PostComposer extends Component { ); const currentPosts = this._getPosts(); // Replace posts with the latest versions - return posts.map((stale) => - currentPosts.find((p) => p.id === stale.id), - ); + return posts.map((stale) => currentPosts.find((p) => p.id === stale.id)); } async send() { diff --git a/src/js/components/profile-hover-card.js b/src/js/components/profile-hover-card.js index 65893049..68ab25f7 100644 --- a/src/js/components/profile-hover-card.js +++ b/src/js/components/profile-hover-card.js @@ -128,7 +128,7 @@ function liveHoverCardTemplate({ profile, liveStatus, onOpenProfile }) { src="${thumb}" alt="" class=${classnames("live-status-thumb-image", { - "live-status-thumb-image--blurred": !!profile.blurLabel, + "is-blurred": !!profile.blurLabel, })} />
LIVE
diff --git a/src/js/config.js b/src/js/config.js index d1cebc79..a890bd04 100644 --- a/src/js/config.js +++ b/src/js/config.js @@ -36,13 +36,13 @@ 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 LIVE_ALLOWED_SERVICES = [ + { domain: "twitch.tv", displayName: "Twitch" }, + { domain: "stream.place", displayName: "Streamplace" }, + { domain: "bluecast.app", displayName: "Bluecast" }, + { domain: "youtube.com", displayName: "YouTube" }, + { domain: "substack.com", displayName: "Substack" }, + { domain: "beehiiv.com", displayName: "Beehiiv" }, ]; export const IN_APP_LINK_DOMAINS = [ diff --git a/src/js/dataHelpers.js b/src/js/dataHelpers.js index eec37bde..23772146 100644 --- a/src/js/dataHelpers.js +++ b/src/js/dataHelpers.js @@ -3,7 +3,7 @@ import { CDN_URL, FOLLOWING_FEED_URI, IN_APP_LINK_DOMAINS, - LIVE_ALLOWED_DOMAINS, + LIVE_ALLOWED_SERVICES, } from "/js/config.js"; export const INVALID_HANDLE = "handle.invalid"; @@ -60,6 +60,39 @@ function blobCdnUrl(prefix, did, blob) { return buildCdnUrl(prefix, did, cid); } +export function createStatusView({ did, cid, record }) { + const external = record.embed.external; + const expiresAt = new Date( + Date.parse(record.createdAt) + record.durationMinutes * 60 * 1000, + ).toISOString(); + return { + $type: "app.bsky.actor.defs#statusView", + status: record.status, + record, + uri: `at://${did}/app.bsky.actor.status/self`, + cid, + embed: { + $type: "app.bsky.embed.external#view", + external: { + uri: external.uri, + title: external.title, + description: external.description, + ...(external.thumb + ? { + thumb: buildCdnUrl( + "feed_thumbnail", + did, + external.thumb.ref.$link, + ), + } + : {}), + }, + }, + isActive: true, + expiresAt, + }; +} + // A profileViewDetailed-shaped object assembled from the raw // app.bsky.actor.profile record, for when the appview is unreachable. // Appview-computed fields (counts, viewer, labels, associated) are absent. @@ -1056,8 +1089,8 @@ export function isAllowedLiveHost(url) { } catch { return false; } - return LIVE_ALLOWED_DOMAINS.some( - (domain) => hostname === domain || hostname.endsWith("." + domain), + return LIVE_ALLOWED_SERVICES.some( + ({ domain }) => hostname === domain || hostname.endsWith("." + domain), ); } diff --git a/src/js/dataLayer/dataStore.js b/src/js/dataLayer/dataStore.js index 9aeebe39..4c5884e0 100644 --- a/src/js/dataLayer/dataStore.js +++ b/src/js/dataLayer/dataStore.js @@ -47,6 +47,8 @@ export class DataStore extends ReactiveStore { this.$postThreadOthers = new SignalMap(); this.$profiles = new SignalMap(); this.$detailedProfiles = new SignalMap(); + // did -> statusView, normalized from profiles + this.$profileStatuses = new SignalMap(); this.$authorFeeds = new SignalMap(); this.$unavailablePosts = new SignalMap(); this.$reposts = new SignalMap(); @@ -103,6 +105,7 @@ export class DataStore extends ReactiveStore { // Merge into existing profile object if present mergeProfile(profile) { + // Check for handle to prevent tombstones from overriding records if (!profile?.did || !profile.handle) { return; } @@ -114,18 +117,31 @@ export class DataStore extends ReactiveStore { this.$profiles.set(profile.did, merged); } + _saveStatusForProfile(profile) { + if (!profile?.did) { + return; + } + this.$profileStatuses.set(profile.did, profile.status ?? null); + } + setDetailedProfile(profile) { - if (!profile?.did) return; this.$profiles.set(profile.did, profile); this.$detailedProfiles.set(profile.did, profile); + this._saveStatusForProfile(profile); } setProfiles(profiles) { for (const profile of profiles) { this.mergeProfile(profile); + this._saveStatusForProfile(profile); } } + setCurrentUser(profile) { + this.$currentUser.set(profile); + this._saveStatusForProfile(profile); + } + // Save the convo and sync convo lists if necessary setConvo(convo) { this.$convos.set(convo.id, convo); diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index 2021684e..6d82fb70 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -183,21 +183,23 @@ export class Derived extends ReactiveStore { }); // { state: "none" | "active" | "inactive" } this.$actorLiveStatus = new ComputedMap((did) => { - const profile = - this.$patchedDetailedProfiles.get(did) ?? - this.$patchedProfiles.get(did); - const statusView = profile?.status; + const statusView = this.dataStore.$profileStatuses.get(did); if (!statusView || typeof statusView !== "object") { + this.liveStatusScheduler.cancel(did); return { state: "none" }; } - const viewer = profile.viewer; + const profile = + this.$patchedDetailedProfiles.get(did) ?? + this.$patchedProfiles.get(did); + const viewer = profile?.viewer; const preferences = this.$preferences.get(); if ( viewer?.blocking || viewer?.blockedBy || viewer?.muted || - (preferences && preferences.getProfileBlurLabel(profile)) + (profile && preferences && preferences.getProfileBlurLabel(profile)) ) { + this.liveStatusScheduler.cancel(did); return { state: "none" }; } if (isStatusValid(statusView)) { diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 96673906..2d4a0181 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -1,9 +1,10 @@ -import { isRecordNotFoundError } from "/js/api.js"; +import { isInvalidSwapError, isRecordNotFoundError } from "/js/api.js"; import { parseUri, buildUri, hasDisableEmbeddingRule, createNotFoundPost, + createStatusView, addFeedItemToFeed, pinPostInFeed, unpinPostInFeed, @@ -11,6 +12,7 @@ import { buildCdnUrl, } from "/js/dataHelpers.js"; import { batch, getCurrentTimestamp } from "/js/utils.js"; +import { fetchAndCompressImage } from "/js/embedHelpers.js"; import { PostCreator } from "/js/postCreator.js"; import { untrack } from "/js/signals.js"; @@ -1032,6 +1034,93 @@ export class Mutations { } } + async setLiveStatus({ + linkMeta, + durationMinutes, + createdAt: passedCreatedAt = null, + }) { + const currentUser = untrack(() => this.dataStore.$currentUser.get()); + if (!currentUser) throw new Error("No current user"); + const createdAt = passedCreatedAt ?? getCurrentTimestamp(); + + // Upload thumbnail (best-effort — failure here doesn't block the publish) + let thumbBlob = null; + if (linkMeta.image) { + try { + const compressed = await fetchAndCompressImage(linkMeta.image); + const uploaded = await this.api.uploadBlob(compressed.blob); + thumbBlob = { + $type: "blob", + mimeType: uploaded.mimeType, + ref: { $link: uploaded.ref.$link }, + size: uploaded.size, + }; + } catch (error) { + if (error?.name === "AbortError") throw error; + console.warn("Error uploading live status thumb", error); + } + } + + const record = { + $type: "app.bsky.actor.status", + status: "app.bsky.actor.status#live", + createdAt, + durationMinutes, + embed: { + $type: "app.bsky.embed.external", + external: { + $type: "app.bsky.embed.external#external", + uri: linkMeta.url, + title: linkMeta.title, + description: linkMeta.description, + ...(thumbBlob ? { thumb: thumbBlob } : {}), + }, + }, + }; + + let attempts = 0; + let putResult; + while (true) { + let priorCid = null; + try { + const prior = await this.api.getStatusRecord(); + priorCid = prior.cid ?? null; + } catch (error) { + if (!isRecordNotFoundError(error)) throw error; + } + try { + putResult = await this.api.putStatusRecord(record, priorCid); + break; + } catch (error) { + if ( + isInvalidSwapError(error) && + attempts < 5 // retry up to 5 times + ) { + attempts += 1; + continue; + } + throw error; + } + } + const statusView = createStatusView({ + did: currentUser.did, + cid: putResult?.cid ?? null, + record, + }); + this.dataStore.$profileStatuses.set(currentUser.did, statusView); + } + + async clearLiveStatus() { + const currentUser = untrack(() => this.dataStore.$currentUser.get()); + if (!currentUser) throw new Error("No current user"); + try { + await this.api.deleteStatusRecord(); + } catch (error) { + if (!isRecordNotFoundError(error)) throw error; + } + this.dataStore.$profileStatuses.set(currentUser.did, null); + } + async createList({ purpose, name, description, avatarBlob }) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) throw new Error("No current user"); diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index 904211f7..7e878617 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -279,7 +279,7 @@ export class Requests { ); profile = await this.loadCurrentUserFromRecord(session); } - this.dataStore.$currentUser.set(profile); + this.dataStore.setCurrentUser(profile); } async loadCurrentUserFromRecord(session) { diff --git a/src/js/embedHelpers.js b/src/js/embedHelpers.js index 1663c019..9b6a5fb6 100644 --- a/src/js/embedHelpers.js +++ b/src/js/embedHelpers.js @@ -5,7 +5,21 @@ import { } from "/js/config.js"; import { createEmbedFromPost, isInAppLinkHostname } from "/js/dataHelpers.js"; import { resolveDidFromHandleOrDid } from "/js/atproto.js"; -import { fetchWithTimeout } from "/js/utils.js"; +import { fetchWithTimeout, readFileAsDataUrl } from "/js/utils.js"; +import { ImageCompressor } from "/js/imageCompressor.js"; + +// Fetch a remote image and return a compressed { blob, ... } ready for +// api.uploadBlob. +export async function fetchAndCompressImage( + url, + { signal, imageCompressor } = {}, +) { + const imageRes = await fetch(url, { signal }); + const imageBlob = await imageRes.blob(); + const dataUrl = await readFileAsDataUrl(imageBlob); + const compressor = imageCompressor ?? new ImageCompressor(); + return compressor.compressImage(dataUrl); +} export async function getLinkCardMeta(url, { timeoutMs = 15000 } = {}) { let res; diff --git a/src/js/liveStatusService.js b/src/js/liveStatusService.js index 0f20d15c..7ff6497e 100644 --- a/src/js/liveStatusService.js +++ b/src/js/liveStatusService.js @@ -1,5 +1,8 @@ import "/js/components/live-status-dialog.js"; +import "/js/components/edit-live-dialog.js"; +import "/js/components/go-live-dialog.js"; +// Handles clicks on avatars with live badge export class LiveStatusService { constructor(dataLayer) { this.dataLayer = dataLayer; @@ -8,16 +11,32 @@ export class LiveStatusService { handleAvatarClick(did) { const liveStatus = this.dataLayer.derived.$actorLiveStatus.get(did); if (liveStatus?.state !== "active") return; + const currentUser = this.dataLayer.derived.$currentUser.get(); + if (currentUser?.did === did) { + this._openEditLiveDialog(liveStatus); + return; + } const profile = this.dataLayer.derived.$hydratedProfiles.get(did) ?? this.dataLayer.derived.$hydratedDetailedProfiles.get(did); if (!profile) return; + this._openLiveStatusDialog(profile, liveStatus); + } + + _openLiveStatusDialog(profile, liveStatus) { const dialog = document.createElement("live-status-dialog"); dialog.profile = profile; dialog.liveStatus = liveStatus; - dialog.addEventListener("close", () => { - dialog.remove(); - }); + dialog.addEventListener("close", () => dialog.remove()); + document.body.appendChild(dialog); + dialog.open(); + } + + _openEditLiveDialog(liveStatus) { + const dialog = document.createElement("edit-live-dialog"); + dialog.dataLayer = this.dataLayer; + 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 0d3bc08e..2f1ce715 100644 --- a/src/js/mainLayout.js +++ b/src/js/mainLayout.js @@ -213,8 +213,9 @@ export class MainLayout extends Layout { chatNotificationService?.$numNotifications.get() ?? null, onClickActiveNavItem, children: slot, - onClickComposeButton: () => - postComposerService.composePost({ currentUser }), + onClickComposeButton: currentUser + ? () => postComposerService.composePost({ currentUser }) + : null, pluginService, previewingPlugins, onLongPressProfile, diff --git a/src/js/postComposerService.js b/src/js/postComposerService.js index 4919e772..e0e00ad9 100644 --- a/src/js/postComposerService.js +++ b/src/js/postComposerService.js @@ -21,7 +21,7 @@ export class PostComposerService { quotedPost = null, }) { if (!currentUser) { - console.warn("No current user"); + console.warn("composePost: No current user"); return; } if (this.currentPostComposer !== null) { diff --git a/src/js/postCreator.js b/src/js/postCreator.js index 414e8658..c2d447b0 100644 --- a/src/js/postCreator.js +++ b/src/js/postCreator.js @@ -1,6 +1,7 @@ -import { getPostLangs, isNil, readFileAsDataUrl, wait } from "/js/utils.js"; +import { getPostLangs, isNil, wait } from "/js/utils.js"; import { computeRecordCid, generateTid } from "/js/atproto.js"; import { ImageCompressor } from "/js/imageCompressor.js"; +import { fetchAndCompressImage } from "/js/embedHelpers.js"; import { getUnresolvedFacetsFromText, resolveFacets, @@ -271,20 +272,15 @@ export class PostCreator { // If there's an external link, upload the preview image if (externalImage) { try { - const imageRes = await fetch(externalImage, { signal }); - const imageBlob = await imageRes.blob(); - const dataUrl = await readFileAsDataUrl(imageBlob); - const compressedImage = - await this.imageCompressor.compressImage(dataUrl); - const blob = await this.api.uploadBlob(compressedImage.blob, { + const compressed = await fetchAndCompressImage(externalImage, { signal, + imageCompressor: this.imageCompressor, }); + const blob = await this.api.uploadBlob(compressed.blob, { signal }); externalEmbed.external.thumb = { $type: "blob", mimeType: blob.mimeType, - ref: { - $link: blob.ref.$link, - }, + ref: { $link: blob.ref.$link }, size: blob.size, }; } catch (error) { diff --git a/src/js/postInteractionHandler.js b/src/js/postInteractionHandler.js index 301624f3..e26e2a72 100644 --- a/src/js/postInteractionHandler.js +++ b/src/js/postInteractionHandler.js @@ -250,7 +250,7 @@ export class PostInteractionHandler { async handleQuotePost(post) { const currentUser = this.dataLayer.derived.$currentUser.get(); if (!currentUser) { - console.warn("No current user"); + console.warn("handleQuotePost: No current user"); return; } try { diff --git a/src/js/templates/avatar.template.js b/src/js/templates/avatar.template.js index 6864efec..42afe085 100644 --- a/src/js/templates/avatar.template.js +++ b/src/js/templates/avatar.template.js @@ -134,7 +134,7 @@ export function avatarTemplate({ alt="${getDisplayName(author)} profile picture" class=${classnames("avatar-image", { "labeler-avatar": isLabeler, - "avatar-image--blurred": isBlurred, + "is-blurred": isBlurred, })} data-testid="avatar-image" data-lightbox-src="${avatarFullSizeUrl}" diff --git a/src/js/templates/postHeaderText.template.js b/src/js/templates/postHeaderText.template.js index 6fa92349..54fdbc91 100644 --- a/src/js/templates/postHeaderText.template.js +++ b/src/js/templates/postHeaderText.template.js @@ -1,5 +1,5 @@ import { html } from "/js/lib/lit-html.js"; -import { displayRelativeTime } from "/js/utils.js"; +import { formatRelativeTime } from "/js/utils.js"; import { linkToProfile } from "/js/navigation.js"; import { verificationBadgeTemplate } from "/js/templates/verificationBadge.template.js"; import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBadge.template.js"; @@ -42,7 +42,7 @@ export function postHeaderTextTemplate({ ${includeTime ? html`·${displayRelativeTime(timestamp)}${formatRelativeTime(timestamp)}` : ""}
`; diff --git a/src/js/templates/profileCard.template.js b/src/js/templates/profileCard.template.js index 2165da10..92147587 100644 --- a/src/js/templates/profileCard.template.js +++ b/src/js/templates/profileCard.template.js @@ -98,6 +98,29 @@ function profileDescriptionTemplate({ // Match the default banner color in social-app const LABELER_BANNER_FALLBACK_COLOR = "rgb(105, 0, 255)"; +function goLiveMenuItem({ liveStatus, onClickGoLive, onClickEditLive }) { + const state = liveStatus?.state ?? "none"; + const isActive = state === "active"; + const isDisabled = state === "inactive" && liveStatus?.isDisabled; + const teststate = isActive ? "live" : isDisabled ? "disabled" : "off"; + const label = isActive ? "Edit live status" : "Go live"; + return html` { + if (isActive) { + onClickEditLive(); + } else { + onClickGoLive(); + } + }} + > + ${label} + `; +} + function profileContextMenuTemplate({ profile, isAuthenticated, @@ -109,6 +132,9 @@ function profileContextMenuTemplate({ onClickBlock, onClickAddToLists, onClickReport, + onClickGoLive, + onClickEditLive, + liveStatus, }) { const isFollowing = profile.viewer?.following; const pluginGroups = [...groupBy(pluginItems, "pluginId").values()]; @@ -136,15 +162,26 @@ function profileContextMenuTemplate({ ${isAuthenticated ? html` - { - router.go(linkToSearchPostsByProfile(profile)); - }} - > - Search posts - + + { + router.go(linkToSearchPostsByProfile(profile)); + }} + > + Search posts + + { + onClickAddToLists(profile); + }} + > + Add to Lists + + ` : null} ${isAuthenticated && !isCurrentUser @@ -164,15 +201,6 @@ function profileContextMenuTemplate({ ` : null} - { - onClickAddToLists(profile); - }} - > - Add to Lists - ` : null} + ${isCurrentUser && isAuthenticated + ? html` + ${goLiveMenuItem({ liveStatus, onClickGoLive, onClickEditLive })} + ` + : null} ${pluginGroups.map( (group) => html` @@ -266,6 +299,8 @@ export function profileCardTemplate({ onClickAddToLists = noop, onClickReport = noop, onClickEditProfile = noop, + onClickGoLive = noop, + onClickEditLive = noop, liveStatus = null, pluginService = null, isFollowPending = false, @@ -290,7 +325,7 @@ export function profileCardTemplate({ src="${cdnImageUrl(profile.banner)}" alt="${getDisplayName(profile)} banner" class=${classnames("profile-banner", { - "profile-banner--blurred": !!profile.blurLabel, + "is-blurred": !!profile.blurLabel, })} /> @@ -301,10 +336,7 @@ export function profileCardTemplate({
${avatarTemplate({ author: profile, - clickAction: - liveStatus?.state === "active" && !isCurrentUser - ? "live" - : "lightbox", + clickAction: liveStatus?.state === "active" ? "live" : "lightbox", })} ${!isCurrentUser && !isLabeler && isAuthenticated && !isBlockedBy ? html` ${isFollowing @@ -421,6 +453,9 @@ export function profileCardTemplate({ onClickBlock, onClickAddToLists, onClickReport, + onClickGoLive, + onClickEditLive, + liveStatus, }); }} > diff --git a/src/js/utils.js b/src/js/utils.js index 61537d45..a16f88aa 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -115,7 +115,7 @@ window.addEventListener("visibilitychange", () => { } }); -export function displayRelativeTime(timestamp) { +export function formatRelativeTime(timestamp) { // e.g. "2025-09-11T15:08:11.414Z" -> "7h" const now = relativeTimeBase; const then = new Date(timestamp); @@ -231,6 +231,14 @@ export function formatFullDate(timestamp) { }); } +// E.g. 3:44 PM +export function formatShortTime(timestamp) { + return new Date(timestamp).toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); +} + // E.g. September 29, 2025 at 3:44 PM export function formatFullTimestamp(timestamp) { const date = new Date(timestamp); @@ -1200,3 +1208,35 @@ export function requireArg(method, name, value) { throw new Error(`${method} requires a ${name}`); } } + +// E.g. "twitch.tv/foo" -> "https://twitch.tv/foo". +// Returns null if the input clearly isn't an url +export function normalizeUrl(input) { + const trimmed = (input ?? "").trim(); + if (!trimmed) return null; + const withScheme = /^https?:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; + let parsed; + try { + parsed = new URL(withScheme); + } catch { + return null; + } + const labels = parsed.hostname.split(".").filter(Boolean); + if (labels.length < 2) return null; + const tld = labels[labels.length - 1]; + if (!/^[a-z]{2,}$/i.test(tld)) return null; + return parsed.href; +} + +// e.g. "1 hour", "30 minutes", "1 hour 5 minutes" +export function formatDuration(minutes) { + const total = Math.max(0, Math.round(minutes)); + const hours = Math.floor(total / 60); + const mins = total % 60; + const parts = []; + if (hours > 0) parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`); + if (mins > 0) parts.push(`${mins} ${mins === 1 ? "minute" : "minutes"}`); + return parts.join(" ") || "0 minutes"; +} diff --git a/src/js/views/chat.view.js b/src/js/views/chat.view.js index 6b5645f4..7ab7cc4a 100644 --- a/src/js/views/chat.view.js +++ b/src/js/views/chat.view.js @@ -6,7 +6,7 @@ import { } from "/js/router.js"; import { html, render } from "/js/lib/lit-html.js"; import { headerTemplate } from "/js/templates/header.template.js"; -import { displayRelativeTime } from "/js/utils.js"; +import { formatRelativeTime } from "/js/utils.js"; import { getConvoPreviewText, getDisplayName, @@ -59,7 +59,7 @@ export default async function chatView({ ); const otherUser = groupDetails ? null : otherMembers[0]; const timeAgo = lastInteraction - ? displayRelativeTime(getInteractionTimestamp(lastInteraction)) + ? formatRelativeTime(getInteractionTimestamp(lastInteraction)) : ""; const isUnread = convo.unreadCount > 0; return html` diff --git a/src/js/views/chatDetail.view.js b/src/js/views/chatDetail.view.js index 74ed6000..eec52014 100644 --- a/src/js/views/chatDetail.view.js +++ b/src/js/views/chatDetail.view.js @@ -38,6 +38,7 @@ import { wait, raf, differenceInMinutes, + formatShortTime, isMobileViewport, canHover, pinScrollPosition, @@ -1032,14 +1033,6 @@ export default async function chatDetailView({ `; } - function formatTime(timestamp) { - return new Date(timestamp).toLocaleTimeString([], { - hour: "numeric", - minute: "2-digit", - hour12: true, - }); - } - function systemMessageTemplate({ message, convo }) { const memberDid = message.data?.member?.did; const memberProfile = memberDid ? getMemberProfile(convo, memberDid) : null; @@ -1116,7 +1109,7 @@ export default async function chatDetailView({ ? "message-group-time-sent" : "message-group-time-received"}" > - ${formatTime(group.lastSentAt)} + ${formatShortTime(group.lastSentAt)}
`; @@ -1126,7 +1119,7 @@ export default async function chatDetailView({ const isToday = isSameDate(date, new Date()); return html`
${isToday ? "Today" : getDayOfWeek(date)} at - ${formatTime(startTime)} + ${formatShortTime(startTime)}
`; } diff --git a/src/js/views/chatRequests.view.js b/src/js/views/chatRequests.view.js index c0863fef..69290e7e 100644 --- a/src/js/views/chatRequests.view.js +++ b/src/js/views/chatRequests.view.js @@ -1,7 +1,7 @@ import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js"; import { html, render } from "/js/lib/lit-html.js"; import { headerTemplate } from "/js/templates/header.template.js"; -import { displayRelativeTime } from "/js/utils.js"; +import { formatRelativeTime } from "/js/utils.js"; import { getConvoPreviewText, getDisplayName, @@ -53,7 +53,7 @@ export default async function chatRequestsView({ (member) => member.did !== currentUser?.did, ); const otherMember = groupDetails ? null : otherMembers[0]; - const timeAgo = lastMessage ? displayRelativeTime(lastMessage.sentAt) : ""; + const timeAgo = lastMessage ? formatRelativeTime(lastMessage.sentAt) : ""; const messagePreview = lastMessage ? getConvoPreviewText(lastMessage, { currentUser, diff --git a/src/js/views/notifications.view.js b/src/js/views/notifications.view.js index 761e67cd..ee8df8c9 100644 --- a/src/js/views/notifications.view.js +++ b/src/js/views/notifications.view.js @@ -4,7 +4,7 @@ import { headerTemplate } from "/js/templates/header.template.js"; import { floatingComposeButtonTemplate } from "/js/templates/floatingComposeButton.template.js"; import { smallPostTemplate } from "/js/templates/smallPost.template.js"; import { postSkeletonTemplate } from "/js/templates/postSkeleton.template.js"; -import { displayRelativeTime, batch } from "/js/utils.js"; +import { formatRelativeTime, batch } from "/js/utils.js"; import { Signal, ReactiveStore } from "/js/signals.js"; import { bindToPage, @@ -308,7 +308,7 @@ export default async function notificationsView({ function followNotificationTemplate({ notificationGroup }) { const { notifications } = notificationGroup; const firstNotif = notifications[0]; - const timeAgo = displayRelativeTime(firstNotif.indexedAt); + const timeAgo = formatRelativeTime(firstNotif.indexedAt); const isUnread = !firstNotif.isRead; return html`
@@ -336,7 +336,7 @@ export default async function notificationsView({ const { notifications } = notificationGroup; const firstNotif = notifications[0]; const post = notificationGroup.subject; - const timeAgo = displayRelativeTime(firstNotif.indexedAt); + const timeAgo = formatRelativeTime(firstNotif.indexedAt); const isUnread = !firstNotif.isRead; const profileLink = linkToProfile(post.author); return notificationItemTemplate({ @@ -366,7 +366,7 @@ export default async function notificationsView({ function likeNotificationTemplate({ notificationGroup, isRepost = false }) { const { notifications } = notificationGroup; const firstNotif = notifications[0]; - const timeAgo = displayRelativeTime(firstNotif.indexedAt); + const timeAgo = formatRelativeTime(firstNotif.indexedAt); const isUnread = !firstNotif.isRead; // Get the liked post for preview @@ -398,7 +398,7 @@ export default async function notificationsView({ function repostNotificationTemplate({ notificationGroup, isRepost = false }) { const { notifications } = notificationGroup; const firstNotif = notifications[0]; - const timeAgo = displayRelativeTime(firstNotif.indexedAt); + const timeAgo = formatRelativeTime(firstNotif.indexedAt); const isUnread = !firstNotif.isRead; // Get the reposted post for preview @@ -451,7 +451,7 @@ export default async function notificationsView({ function feedgenLikeNotificationTemplate({ notificationGroup }) { const { notifications } = notificationGroup; const firstNotif = notifications[0]; - const timeAgo = displayRelativeTime(firstNotif.indexedAt); + const timeAgo = formatRelativeTime(firstNotif.indexedAt); const isUnread = !firstNotif.isRead; const subjectUri = notificationGroup.subject; const { repo, rkey } = subjectUri ? parseUri(subjectUri) : {}; @@ -482,7 +482,7 @@ export default async function notificationsView({ function starterpackJoinedNotificationTemplate({ notificationGroup }) { const { notifications } = notificationGroup; const firstNotif = notifications[0]; - const timeAgo = displayRelativeTime(firstNotif.indexedAt); + const timeAgo = formatRelativeTime(firstNotif.indexedAt); const isUnread = !firstNotif.isRead; const subjectUri = notificationGroup.subject; const { repo, rkey } = subjectUri ? parseUri(subjectUri) : {}; @@ -514,7 +514,7 @@ export default async function notificationsView({ function verifiedNotificationTemplate({ notificationGroup }) { const { notifications } = notificationGroup; const firstNotif = notifications[0]; - const timeAgo = displayRelativeTime(firstNotif.indexedAt); + const timeAgo = formatRelativeTime(firstNotif.indexedAt); const isUnread = !firstNotif.isRead; return html` @@ -540,7 +540,7 @@ export default async function notificationsView({ function unverifiedNotificationTemplate({ notificationGroup }) { const { notifications } = notificationGroup; const firstNotif = notifications[0]; - const timeAgo = displayRelativeTime(firstNotif.indexedAt); + const timeAgo = formatRelativeTime(firstNotif.indexedAt); const isUnread = !firstNotif.isRead; const otherCount = notifications.length - 1; @@ -568,7 +568,7 @@ export default async function notificationsView({ function contactMatchNotificationTemplate({ notificationGroup }) { const { notifications } = notificationGroup; const firstNotif = notifications[0]; - const timeAgo = displayRelativeTime(firstNotif.indexedAt); + const timeAgo = formatRelativeTime(firstNotif.indexedAt); const isUnread = !firstNotif.isRead; const displayName = getDisplayName(firstNotif.author); const profileLink = linkToProfile(firstNotif.author); diff --git a/src/js/views/profile.view.js b/src/js/views/profile.view.js index f7db9f38..52172651 100644 --- a/src/js/views/profile.view.js +++ b/src/js/views/profile.view.js @@ -21,6 +21,8 @@ import { feedsFeedTemplate } from "/js/templates/feedsFeed.template.js"; import { listFeedTemplate } from "/js/templates/listFeed.template.js"; import "/js/components/edit-profile-dialog.js"; import "/js/components/add-to-lists-dialog.js"; +import "/js/components/go-live-dialog.js"; +import "/js/components/edit-live-dialog.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; export default async function profileView({ @@ -70,6 +72,29 @@ export default async function profileView({ dialog.open(); } + function handleGoLive(profile) { + const liveStatus = dataLayer.derived.$actorLiveStatus.get(profile.did); + const dialog = document.createElement("go-live-dialog"); + dialog.dataLayer = dataLayer; + // Prefill url from old status if present + if (liveStatus?.state === "inactive" && !liveStatus.isDisabled) { + dialog.initialUrl = liveStatus?.record?.embed?.external?.uri ?? ""; + } + dialog.addEventListener("close", () => dialog.remove()); + document.body.appendChild(dialog); + dialog.open(); + } + + function handleEditLive(profile) { + const liveStatus = dataLayer.derived.$actorLiveStatus.get(profile.did); + const dialog = document.createElement("edit-live-dialog"); + dialog.dataLayer = dataLayer; + dialog.liveStatus = liveStatus; + dialog.addEventListener("close", () => dialog.remove()); + document.body.appendChild(dialog); + dialog.open(); + } + async function handleEditProfile(profile) { const dialog = document.createElement("edit-profile-dialog"); dialog.addEventListener("profile-save", (event) => @@ -313,6 +338,8 @@ export default async function profileView({ profileInteractionHandler.handleReport(profile), onClickAddToLists: (profile) => handleAddToLists(profile), onClickEditProfile: () => handleEditProfile(profile), + onClickGoLive: () => handleGoLive(profile), + onClickEditLive: () => handleEditLive(profile), liveStatus, pluginService, isFollowPending: dataLayer.derived.$isFollowPending.get( @@ -444,7 +471,9 @@ export default async function profileView({ ? dataLayer.derived.$labelerInfo.get(profile.did) : null; // If labeler, require labeler info to be loaded - const isLoaded = profile && (isLabeler ? !!labelerInfo : true); + const isCurrentUserLoaded = !isAuthenticated ? true : !!currentUser; + const isLoaded = + profile && isCurrentUserLoaded && (isLabeler ? !!labelerInfo : true); const activeTab = state.$activeTab.get(); render( html`
@@ -549,17 +578,14 @@ export default async function profileView({ state.$activeTab.set("labeler-settings"); dataLayer.requests.loadLabelerInfo(profile.did); } + const currentUserDid = isAuthenticated ? dataLayer.api.session.did : null; + const isCurrentUser = profile.did === currentUserDid; if (!profile.viewer?.blocking && !profile.viewer?.blockedBy) { - const isCurrentUser = - profile.did === dataLayer.derived.$currentUser.get()?.did; loadAuthorFeed({ reload: true }); preloadHiddenFeeds({ isCurrentUser, isLabeler }); } // Load chat status - if ( - isAuthenticated && - profile.did !== dataLayer.derived.$currentUser.get()?.did - ) { + if (!isCurrentUser) { dataLayer.requests.loadProfileChatStatus(profile.did); } } diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index a214e60b..0f2352a9 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -126,6 +126,13 @@ export class MockServer { this.registerPushStatus = 200; this.slingshotUnreachable = false; this.pdsEndpoint = "http://localhost:8081"; + // did -> { cid, value } for app.bsky.actor.status/self + this.statusRecords = new Map(); + this.putStatusCalls = []; + this.deleteStatusCalls = []; + // Remaining InvalidSwap failures the next putStatusRecord calls will emit + this.invalidSwapRemaining = 0; + this.statusCounter = 0; } // Make slingshot fail to resolve identities, so the app falls back to @@ -333,6 +340,20 @@ export class MockServer { this.externalLinkCards.set(url, meta); } + // Seeds a status record for the given did (default: current user). + setStatusRecord( + record, + { did = userProfile.did, cid = "bafyreiseedstatus" } = {}, + ) { + this.statusRecords.set(did, { cid, value: record }); + } + + // Makes the next N putStatusRecord calls fail with InvalidSwap before + // succeeding. Consumed one-per-call. + simulateInvalidSwap({ times = 1 } = {}) { + this.invalidSwapRemaining = times; + } + addNotifications(notifications, { cursor } = {}) { this.notifications.push(...notifications); this.notificationCursor = cursor; @@ -2426,6 +2447,27 @@ export class MockServer { const collection = body?.collection; const rkey = body?.rkey; + if (collection === "app.bsky.actor.status") { + const repo = body?.repo; + this.deleteStatusCalls.push(body); + if (!this.statusRecords.has(repo)) { + return route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: "RecordNotFound", + message: "Could not locate record", + }), + }); + } + this.statusRecords.delete(repo); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: "{}", + }); + } + if (collection === "app.bsky.feed.like") { const feedKey = `${userProfile.did}-likes`; const likes = this.authorFeeds.get(feedKey) || []; @@ -2826,6 +2868,29 @@ export class MockServer { }), }); } + if (collection === "app.bsky.actor.status" && rkey === "self") { + const repo = url.searchParams.get("repo"); + const seeded = this.statusRecords.get(repo); + if (seeded) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + uri: `at://${repo}/${collection}/${rkey}`, + cid: seeded.cid, + value: seeded.value, + }), + }); + } + return route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: "RecordNotFound", + message: `Could not locate record: at://${repo}/${collection}/${rkey}`, + }), + }); + } if (collection === "app.bsky.graph.list") { const repo = url.searchParams.get("repo"); const listUri = `at://${repo}/${collection}/${rkey}`; @@ -2935,6 +3000,33 @@ export class MockServer { }), }); } + if (collection === "app.bsky.actor.status") { + const repo = body?.repo; + const rkey = body?.rkey ?? "self"; + this.putStatusCalls.push(body); + if (this.invalidSwapRemaining > 0) { + this.invalidSwapRemaining -= 1; + return route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: "InvalidSwap", + message: "Record swap CID does not match current CID", + }), + }); + } + this.statusCounter += 1; + const cid = `bafyreistatus${this.statusCounter}`; + this.statusRecords.set(repo, { cid, value: body?.record }); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + uri: `at://${repo}/${collection}/${rkey}`, + cid, + }), + }); + } if (collection === "app.bsky.graph.list") { const repo = body?.repo; const rkey = body?.rkey; diff --git a/tests/e2e/specs/flows/goLive.test.js b/tests/e2e/specs/flows/goLive.test.js new file mode 100644 index 00000000..cec2938b --- /dev/null +++ b/tests/e2e/specs/flows/goLive.test.js @@ -0,0 +1,159 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { userProfile } from "../../testData.js"; +import { MockServer } from "../../mockServer.js"; + +const STREAM_URL = "https://www.twitch.tv/streamer"; + +async function openProfileMenu(page) { + const view = page.locator("#profile-view"); + await expect(view.locator('[data-testid="profile-name"]')).toBeVisible({ + timeout: 10000, + }); + await view.locator(".ellipsis-button").click(); + return page.locator(".profile-context-menu"); +} + +test.describe("Go Live flow", () => { + test("create → badge appears → edit → remove", async ({ page }) => { + const mockServer = new MockServer(); + mockServer.addProfile({ + ...userProfile, + followersCount: 5, + followsCount: 5, + postsCount: 3, + }); + mockServer.setExternalLinkCard(STREAM_URL, { + title: "Cool Stream", + description: "Streaming live", + }); + await mockServer.setup(page); + await login(page); + await page.goto(`/profile/${userProfile.did}`); + + // 1. Menu shows "Go live" (off state) for the current user. + let menu = await openProfileMenu(page); + let goLiveItem = menu.locator( + '[data-testid="menu-action-profile-go-live"]', + ); + await expect(goLiveItem).toHaveAttribute("data-teststate", "off"); + await expect(goLiveItem).toContainText("Go live"); + await goLiveItem.click(); + + // 2. Dialog opens; type URL; preview lands after debounce. + const dialog = page.locator('[data-testid="go-live-dialog"]'); + await expect(dialog).toBeVisible(); + const urlInput = dialog.locator('[data-testid="link-input"]'); + await urlInput.fill(STREAM_URL); + await expect(dialog.locator('[data-testid="link-preview"]')).toBeVisible({ + timeout: 5000, + }); + await expect(dialog.locator('[data-testid="link-preview"]')).toContainText( + "Cool Stream", + ); + + // 3. Submit → toast → dialog closes → badge on the profile avatar. + await dialog.locator('[data-testid="go-live-submit"]').click(); + await expect(dialog).not.toBeVisible(); + await expect(page.locator('[data-testid="live-badge"]')).toBeVisible(); + expect(mockServer.putStatusCalls).toHaveLength(1); + expect(mockServer.putStatusCalls[0].record.embed.external.uri).toBe( + STREAM_URL, + ); + const originalCreatedAt = mockServer.putStatusCalls[0].record.createdAt; + + // 4. Reopen menu → label flips to "Edit live status". + menu = await openProfileMenu(page); + goLiveItem = menu.locator('[data-testid="menu-action-profile-go-live"]'); + await expect(goLiveItem).toHaveAttribute("data-teststate", "live"); + await expect(goLiveItem).toContainText("Edit live status"); + await goLiveItem.click(); + + // 5. Edit dialog opens with URL prefilled; change URL and Save. + const editDialog = page.locator('[data-testid="edit-live-dialog"]'); + await expect(editDialog).toBeVisible(); + const editInput = editDialog.locator('[data-testid="link-input"]'); + await expect(editInput).toHaveValue(STREAM_URL); + const NEW_STREAM_URL = "https://www.twitch.tv/streamer-updated"; + mockServer.setExternalLinkCard(NEW_STREAM_URL, { + title: "Updated Stream", + description: "", + }); + await editInput.fill(NEW_STREAM_URL); + // Wait for debounce + preview + await expect( + editDialog.locator('[data-testid="link-preview"]'), + ).toContainText("Updated Stream", { timeout: 5000 }); + await editDialog.locator('[data-testid="edit-live-save"]').click(); + await expect(editDialog).not.toBeVisible(); + expect(mockServer.putStatusCalls).toHaveLength(2); + // createdAt is threaded through unchanged + expect(mockServer.putStatusCalls[1].record.createdAt).toBe( + originalCreatedAt, + ); + expect(mockServer.putStatusCalls[1].record.embed.external.uri).toBe( + NEW_STREAM_URL, + ); + + // 6. Reopen menu → still live → remove. + menu = await openProfileMenu(page); + await menu.locator('[data-testid="menu-action-profile-go-live"]').click(); + await expect( + page.locator('[data-testid="edit-live-dialog"]'), + ).toBeVisible(); + await page.locator('[data-testid="edit-live-remove"]').click(); + + // Confirm modal + await page.locator('[data-testid="modal-confirm-button"]').click(); + await expect( + page.locator('[data-testid="edit-live-dialog"]'), + ).not.toBeVisible(); + expect(mockServer.deleteStatusCalls).toHaveLength(1); + + // 7. Badge gone, menu back to "Go live". + await expect(page.locator('[data-testid="live-badge"]')).toHaveCount(0); + menu = await openProfileMenu(page); + await expect( + menu.locator('[data-testid="menu-action-profile-go-live"]'), + ).toHaveAttribute("data-teststate", "off"); + }); + + test("retries on InvalidSwap and still succeeds on a single submit", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.addProfile({ ...userProfile }); + mockServer.setExternalLinkCard(STREAM_URL, { + title: "Cool Stream", + description: "", + }); + // Prior status exists on the PDS, first putStatusRecord will race and + // fail once with InvalidSwap; the CAS loop then re-reads and succeeds. + mockServer.setStatusRecord( + { + $type: "app.bsky.actor.status", + status: "app.bsky.actor.status#live", + createdAt: "2025-01-01T00:00:00.000Z", + durationMinutes: 60, + }, + { cid: "bafyreiseedstatus" }, + ); + mockServer.simulateInvalidSwap({ times: 1 }); + await mockServer.setup(page); + await login(page); + await page.goto(`/profile/${userProfile.did}`); + + const menu = await openProfileMenu(page); + await menu.locator('[data-testid="menu-action-profile-go-live"]').click(); + const dialog = page.locator('[data-testid="go-live-dialog"]'); + await expect(dialog).toBeVisible(); + await dialog.locator('[data-testid="link-input"]').fill(STREAM_URL); + await expect(dialog.locator('[data-testid="link-preview"]')).toBeVisible({ + timeout: 5000, + }); + await dialog.locator('[data-testid="go-live-submit"]').click(); + await expect(dialog).not.toBeVisible(); + // Two put attempts: the InvalidSwap failure and the successful retry. + expect(mockServer.putStatusCalls).toHaveLength(2); + }); +}); diff --git a/tests/e2e/specs/views/profile.view.test.js b/tests/e2e/specs/views/profile.view.test.js index 0fd7d223..bd37c256 100644 --- a/tests/e2e/specs/views/profile.view.test.js +++ b/tests/e2e/specs/views/profile.view.test.js @@ -887,9 +887,15 @@ test.describe("Profile view", () => { await view.locator(".ellipsis-button").click(); const menu = page.locator(".profile-context-menu"); - await expect(menu.locator("context-menu-item")).toHaveCount(3, { + await expect(menu.locator("context-menu-item")).toHaveCount(5, { timeout: 5000, }); + await expect( + menu.locator('[data-testid="menu-action-profile-go-live"]'), + ).toBeVisible(); + await expect( + menu.locator('[data-testid="menu-action-profile-add-to-lists"]'), + ).toBeVisible(); await expect( menu.locator('[data-testid="menu-action-profile-search-posts"]'), ).toBeVisible(); diff --git a/tests/unit/specs/components/app-icon.test.js b/tests/unit/specs/components/app-icon.test.js index d62604ca..09f507bf 100644 --- a/tests/unit/specs/components/app-icon.test.js +++ b/tests/unit/specs/components/app-icon.test.js @@ -14,7 +14,7 @@ describe("app-icon", () => { const use = element.querySelector("use"); assert(use !== null); - assert.deepEqual(use.getAttribute("href"), "/img/icons.svg#bell"); + assert.deepEqual(use.getAttribute("href"), "#bell"); }); it("renders nothing when icon is absent", () => { @@ -27,15 +27,9 @@ describe("app-icon", () => { const element = document.createElement("app-icon"); element.setAttribute("icon", "bus"); document.body.appendChild(element); - assert.deepEqual( - element.querySelector("use").getAttribute("href"), - "/img/icons.svg#bus", - ); + assert.deepEqual(element.querySelector("use").getAttribute("href"), "#bus"); element.setAttribute("icon", "car"); - assert.deepEqual( - element.querySelector("use").getAttribute("href"), - "/img/icons.svg#car", - ); + assert.deepEqual(element.querySelector("use").getAttribute("href"), "#car"); }); }); diff --git a/tests/unit/specs/components/live-link-form.test.js b/tests/unit/specs/components/live-link-form.test.js new file mode 100644 index 00000000..a34aaf3d --- /dev/null +++ b/tests/unit/specs/components/live-link-form.test.js @@ -0,0 +1,30 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import "/js/components/live-link-form.js"; + +describe("live-link-form", () => { + beforeEach(() => { + document.body.innerHTML = ""; + mock.timers.enable({ apis: ["setTimeout"] }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + mock.timers.reset(); + }); + + it("clears the debounce timer on disconnect so _loadLinkMeta never runs", () => { + const element = document.createElement("live-link-form"); + document.body.appendChild(element); + const loadSpy = mock.method(element, "_loadLinkMeta", async () => {}); + // Type a URL — arms the 500ms debounce + element._handleInput({ + target: { value: "https://www.twitch.tv/streamer" }, + }); + assert.notEqual(element._debounceTimer, null); + // Remove from document before debounce fires + element.remove(); + mock.timers.tick(1000); + assert.equal(loadSpy.mock.calls.length, 0); + }); +}); diff --git a/tests/unit/specs/components/profile-hover-card.test.js b/tests/unit/specs/components/profile-hover-card.test.js index 6076ef6b..01a270d0 100644 --- a/tests/unit/specs/components/profile-hover-card.test.js +++ b/tests/unit/specs/components/profile-hover-card.test.js @@ -31,16 +31,21 @@ function makeSetup({ } = {}) { const dataLayer = makeTestDataLayer(); if (detailedProfile) { - dataLayer.dataStore.$detailedProfiles.set( - detailedProfile.did, - detailedProfile, - ); + // Route through setDetailedProfile so `status` is extracted into + // $profileStatuses like production ingestion does. + dataLayer.dataStore.setDetailedProfile(detailedProfile); } if (basicProfile) { - dataLayer.dataStore.$profiles.set(basicProfile.did, basicProfile); + dataLayer.dataStore.setProfiles([basicProfile]); } if (currentUser) { dataLayer.dataStore.$currentUser.set(currentUser); + if (currentUser.status !== undefined) { + dataLayer.dataStore.$profileStatuses.set( + currentUser.did, + currentUser.status, + ); + } } const followCalls = []; const interactionHandlers = { diff --git a/tests/unit/specs/dataLayer/liveStatus.test.js b/tests/unit/specs/dataLayer/liveStatus.test.js index bbae85c7..b79299fc 100644 --- a/tests/unit/specs/dataLayer/liveStatus.test.js +++ b/tests/unit/specs/dataLayer/liveStatus.test.js @@ -9,6 +9,9 @@ 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 { normalizeUrl, formatDuration } from "/js/utils.js"; +import { ApiError } from "/js/api.js"; +import { Mutations } from "/js/dataLayer/mutations.js"; import { createLiveStatusView, createPost, @@ -292,7 +295,10 @@ describe("live status", () => { it("re-arms for the new deadline when a rewrite extends the expiry", () => { const { derived, dataStore } = makeDerived(); - addLiveProfile(dataStore, createLiveStatusView({ did })); + addLiveProfile( + dataStore, + createLiveStatusView({ did, expiresAt: futureExpiry() }), + ); assert.equal(derived.$actorLiveStatus.get(did).state, "active"); // A later write extends the expiry to 14:00; the next judgment re-arms @@ -328,4 +334,261 @@ describe("live status", () => { ); }); }); + + describe("normalizeUrl", () => { + it("prepends https:// to bare hosts and normalizes", () => { + assert.equal(normalizeUrl("twitch.tv/foo"), "https://twitch.tv/foo"); + assert.equal( + normalizeUrl(" https://www.youtube.com/watch "), + "https://www.youtube.com/watch", + ); + }); + it("rejects strings without a valid TLD", () => { + assert.equal(normalizeUrl("nope"), null); + assert.equal(normalizeUrl("localhost/foo"), null); + assert.equal(normalizeUrl(""), null); + assert.equal(normalizeUrl(null), null); + }); + }); + + describe("formatDuration", () => { + it("formats hours and minutes", () => { + assert.equal(formatDuration(5), "5 minutes"); + assert.equal(formatDuration(60), "1 hour"); + assert.equal(formatDuration(65), "1 hour 5 minutes"); + assert.equal(formatDuration(120), "2 hours"); + assert.equal(formatDuration(0), "0 minutes"); + }); + }); + + describe("mutations.setLiveStatus / clearLiveStatus", () => { + // Matches the placeholder linkMeta shape the dialog always passes, + // even on cardyb failure (title falls back to the URL). + function placeholderLinkMeta(url) { + return { url, title: url, description: "", image: null }; + } + + function makeApi(overrides = {}) { + const api = { + getStatusRecord: mock.fn(async () => { + const err = new ApiError({ + status: 400, + statusText: "Bad Request", + data: { error: "RecordNotFound" }, + headers: {}, + url: "", + }); + throw err; + }), + putStatusRecord: mock.fn(async (record) => ({ + uri: "at://did:plc:live1/app.bsky.actor.status/self", + cid: "bafyputcid", + value: record, + })), + deleteStatusRecord: mock.fn(async () => ({})), + uploadBlob: mock.fn(async () => ({ + mimeType: "image/jpeg", + ref: { $link: "bafthumbcid" }, + size: 123, + })), + ...overrides, + }; + return api; + } + + function makeSetup(api = makeApi()) { + const dataStore = new DataStore(createSessionState(null)); + dataStore.$currentUser.set({ + did: "did:plc:live1", + handle: "live1.test", + }); + const patchStore = new PatchStore(); + const preferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + $preferences: new Signal.State( + Preferences.createLoggedOutPreferences(), + ), + }; + const mutations = new Mutations( + api, + dataStore, + patchStore, + preferencesProvider, + { resolveHandle: async () => null }, + new DraftMediaStore("test-media"), + ); + return { mutations, dataStore, patchStore, api }; + } + + it("publishes a status, writes it to the current user, and clears patches", async () => { + const { mutations, dataStore, patchStore, api } = makeSetup(); + await mutations.setLiveStatus({ + durationMinutes: 60, + linkMeta: { + url: "https://www.twitch.tv/streamer", + title: "Cool Stream", + description: "", + image: null, + }, + }); + assert.equal(api.putStatusRecord.mock.calls.length, 1); + const [record, swapCid] = api.putStatusRecord.mock.calls[0].arguments; + assert.equal(record.status, "app.bsky.actor.status#live"); + assert.equal(record.durationMinutes, 60); + assert.equal(record.embed.external.uri, "https://www.twitch.tv/streamer"); + assert.equal(swapCid, null); + const status = dataStore.$profileStatuses.get("did:plc:live1"); + assert.equal(status.status, "app.bsky.actor.status#live"); + assert.equal(status.embed.external.uri, "https://www.twitch.tv/streamer"); + assert.equal(status.cid, "bafyputcid"); + // No profile-patch dance any more — $profileStatuses is written directly. + assert.equal( + patchStore.$profilePatches.get("did:plc:live1") ?? null, + null, + ); + }); + + it("preserves createdAt on edit and threads durationMinutes through", async () => { + const { mutations, api } = makeSetup(); + const createdAt = "2025-05-01T12:00:00.000Z"; + await mutations.setLiveStatus({ + durationMinutes: 30, + linkMeta: placeholderLinkMeta("https://www.twitch.tv/updated"), + createdAt, + }); + const record = api.putStatusRecord.mock.calls[0].arguments[0]; + assert.equal(record.createdAt, createdAt); + assert.equal(record.durationMinutes, 30); + }); + + it("retries on InvalidSwap and eventually succeeds", async () => { + let attempts = 0; + const api = makeApi({ + putStatusRecord: mock.fn(async (record) => { + attempts += 1; + if (attempts < 3) { + throw new ApiError({ + status: 400, + statusText: "Bad Request", + data: { error: "InvalidSwap" }, + headers: {}, + url: "", + }); + } + return { uri: "at://x", cid: "cid3", value: record }; + }), + }); + const { mutations } = makeSetup(api); + await mutations.setLiveStatus({ + durationMinutes: 60, + linkMeta: placeholderLinkMeta("https://www.twitch.tv/a"), + }); + assert.equal(attempts, 3); + assert.equal(api.getStatusRecord.mock.calls.length, 3); + }); + + it("swallows thumb upload failures but still publishes", async () => { + const api = makeApi({ + uploadBlob: mock.fn(async () => { + throw new Error("upload failed"); + }), + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + blob: async () => new Blob(["x"], { type: "image/png" }), + }); + try { + const { mutations } = makeSetup(api); + await mutations.setLiveStatus({ + durationMinutes: 60, + linkMeta: { + url: "https://www.twitch.tv/a", + title: "t", + description: "", + image: "https://example.com/img.jpg", + }, + }); + assert.equal(api.putStatusRecord.mock.calls.length, 1); + const record = api.putStatusRecord.mock.calls[0].arguments[0]; + assert.equal(record.embed.external.thumb, undefined); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("clearLiveStatus writes explicit null and tolerates RecordNotFound", async () => { + const { mutations, dataStore, api } = makeSetup(); + // Seed with a status first + dataStore.$profileStatuses.set( + "did:plc:live1", + createLiveStatusView({ did: "did:plc:live1" }), + ); + api.deleteStatusRecord = mock.fn(async () => { + throw new ApiError({ + status: 400, + statusText: "Bad Request", + data: { error: "RecordNotFound" }, + headers: {}, + url: "", + }); + }); + await mutations.clearLiveStatus(); + assert.equal(dataStore.$profileStatuses.get("did:plc:live1"), null); + }); + }); + + describe("cross-device convergence", () => { + it("setProfiles clears a stale status when the fresh payload omits it", () => { + const { derived, dataStore } = makeDerived(); + addLiveProfile( + dataStore, + createLiveStatusView({ did, expiresAt: futureExpiry() }), + ); + assert.equal(derived.$actorLiveStatus.get(did).state, "active"); + // Fresh appview payload with no `status` field + const refreshed = createProfile({ + did, + handle: "liveuser.bsky.social", + displayName: "Live User", + }); + dataStore.setProfiles([refreshed]); + assert.equal(derived.$actorLiveStatus.get(did).state, "none"); + }); + + it("mergeProfile from a post author path preserves an existing status", () => { + const { derived, dataStore } = makeDerived(); + addLiveProfile( + dataStore, + createLiveStatusView({ did, expiresAt: futureExpiry() }), + ); + assert.equal(derived.$actorLiveStatus.get(did).state, "active"); + dataStore.mergeProfile({ + did, + handle: "liveuser.bsky.social", + displayName: "Live User (from post)", + }); + assert.equal(derived.$actorLiveStatus.get(did).state, "active"); + }); + + it("cancels the per-DID expiry timer when the status transitions to none", () => { + const { derived, dataStore } = makeDerived(); + addLiveProfile( + dataStore, + createLiveStatusView({ did, expiresAt: futureExpiry() }), + ); + // Reading the computed schedules the timer + assert.equal(derived.$actorLiveStatus.get(did).state, "active"); + assert.equal(derived.liveStatusScheduler.size, 1); + // Status vanishes + dataStore.setProfiles([ + createProfile({ + did, + handle: "liveuser.bsky.social", + displayName: "Live User", + }), + ]); + assert.equal(derived.$actorLiveStatus.get(did).state, "none"); + assert.equal(derived.liveStatusScheduler.size, 0); + }); + }); }); -- 2.51.2 From c36616cbeee326516593e055aa39e5549919afbd Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Wed, 2 Sep 2026 18:20:57 -0500 Subject: [PATCH 2/4] Save status for all profiles --- src/js/dataLayer/dataStore.js | 2 ++ tests/unit/specs/dataLayer/liveStatus.test.js | 7 +++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/js/dataLayer/dataStore.js b/src/js/dataLayer/dataStore.js index 4c5884e0..0eaefe45 100644 --- a/src/js/dataLayer/dataStore.js +++ b/src/js/dataLayer/dataStore.js @@ -88,6 +88,7 @@ export class DataStore extends ReactiveStore { const normalizedQuotedPost = embedViewRecordToPostView(quotedPost); this.mergeProfile(normalizedQuotedPost.author); + this._saveStatusForProfile(normalizedQuotedPost.author); if (!this.$posts.has(quotedPost.uri)) { this.$embeddedPosts.set(quotedPost.uri, normalizedQuotedPost); } @@ -96,6 +97,7 @@ export class DataStore extends ReactiveStore { for (const post of posts) { this.mergeProfile(post.author); + this._saveStatusForProfile(post.author); this.$posts.set(post.uri, post); // Delete matching embedded post, since they're only used as previews this.$embeddedPosts.delete(post.uri); diff --git a/tests/unit/specs/dataLayer/liveStatus.test.js b/tests/unit/specs/dataLayer/liveStatus.test.js index b79299fc..5154b222 100644 --- a/tests/unit/specs/dataLayer/liveStatus.test.js +++ b/tests/unit/specs/dataLayer/liveStatus.test.js @@ -229,15 +229,14 @@ describe("live status", () => { it("attaches isLive to hydrated profiles and post authors", () => { const { derived, dataStore } = makeDerived(); - addLiveProfile( - dataStore, - createLiveStatusView({ did, expiresAt: futureExpiry() }), - ); + const status = createLiveStatusView({ did, expiresAt: futureExpiry() }); + addLiveProfile(dataStore, status); const post = createPost({ uri: `at://${did}/app.bsky.feed.post/live1`, text: "hello", authorHandle: "liveuser.bsky.social", authorDisplayName: "Live User", + authorStatus: status, }); dataStore.setPosts([post]); assert.equal(derived.$hydratedProfiles.get(did).isLive, true); -- 2.51.2 From f89374f667ebe29f925c580a469af5fb3c93f81f Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Wed, 2 Sep 2026 18:25:55 -0500 Subject: [PATCH 3/4] Use shared component for account switcher --- src/css/style.css | 5 + src/js/components/account-switcher-dialog.js | 128 ++---------- .../templates/accountSwitcherList.template.js | 157 +++++++++++++++ src/js/views/login.view.js | 126 ++---------- tests/e2e/specs/flows/sessionExpired.test.js | 2 +- tests/e2e/specs/views/login.view.test.js | 28 +-- .../accountSwitcherList.template.test.js | 185 ++++++++++++++++++ 7 files changed, 386 insertions(+), 245 deletions(-) create mode 100644 src/js/templates/accountSwitcherList.template.js create mode 100644 tests/unit/specs/templates/accountSwitcherList.template.test.js diff --git a/src/css/style.css b/src/css/style.css index 438fed1d..f1ffadd3 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -9093,6 +9093,11 @@ context-menu-item-group > context-menu-item > button { white-space: nowrap; } +.account-switcher-display-name .verification-badge, +.account-switcher-display-name .automated-account-badge { + margin-left: 4px; +} + .account-switcher-handle { font-size: 14px; color: var(--text-color-muted); diff --git a/src/js/components/account-switcher-dialog.js b/src/js/components/account-switcher-dialog.js index 06264552..abcdbc3e 100644 --- a/src/js/components/account-switcher-dialog.js +++ b/src/js/components/account-switcher-dialog.js @@ -7,8 +7,7 @@ import { getLoginErrorMessage } from "/js/auth.js"; import { Signal, ReactiveStore, effect } from "/js/signals.js"; import { showToast } from "/js/toasts.js"; import { linkToLogin } from "/js/navigation.js"; -import { getDisplayName } from "/js/dataHelpers.js"; -import { avatarTemplate } from "/js/templates/avatar.template.js"; +import { accountSwitcherListTemplate } from "/js/templates/accountSwitcherList.template.js"; import "/js/components/app-icon.js"; class AccountSwitcherDialog extends Component { @@ -122,119 +121,18 @@ class AccountSwitcherDialog extends Component {
- + ${accountSwitcherListTemplate({ + accounts: orderedAccounts, + profilesByDid: this.state.$profilesByDid.get(), + currentDid, + pendingDid: + pendingAction?.type === "switch" ? pendingAction.did : null, + profilesLoading, + onSelect: (account) => this._onSelect(account), + onAdd: () => this._onAdd(), + addLabel: "Add account", + addPending: pendingAction?.type === "add", + })}
`, diff --git a/src/js/templates/accountSwitcherList.template.js b/src/js/templates/accountSwitcherList.template.js new file mode 100644 index 00000000..85128c3f --- /dev/null +++ b/src/js/templates/accountSwitcherList.template.js @@ -0,0 +1,157 @@ +import { html } from "/js/lib/lit-html.js"; +import { getDisplayName } from "/js/dataHelpers.js"; +import { avatarTemplate } from "/js/templates/avatar.template.js"; +import { verificationBadgeTemplate } from "/js/templates/verificationBadge.template.js"; +import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBadge.template.js"; +import "/js/components/app-icon.js"; + +export function accountSwitcherListTemplate({ + accounts, + profilesByDid, + currentDid, + pendingDid, + profilesLoading, + onSelect, + onAdd, + addLabel, + addPending = false, +}) { + const anyPending = pendingDid !== null || addPending; + return html` + + `; +} + +function rowTemplate({ + account, + profile, + isCurrent, + isPending, + anyPending, + showSkeleton, + onClick, +}) { + const teststate = isPending + ? "pending" + : isCurrent + ? "current" + : account.needsReauth + ? "reauth" + : "other"; + return html` + + `; +} + +function addRowTemplate({ label, onClick, pending, anyPending }) { + return html` + + `; +} + +function identityTemplate({ profile, account }) { + const handle = profile?.handle ?? account.handle; + return html` + + + `; +} + +function identitySkeletonTemplate() { + return html` + + + `; +} + +function spinnerTemplate() { + return html``; +} diff --git a/src/js/views/login.view.js b/src/js/views/login.view.js index 666a0e4e..8d13c06e 100644 --- a/src/js/views/login.view.js +++ b/src/js/views/login.view.js @@ -8,11 +8,8 @@ import { CUSTOM_APP_VIEW_CONFIG_ID, } from "/js/appViewConfig.js"; import { validateReturnToParam } from "/js/navigation.js"; -import { getDisplayName } from "/js/dataHelpers.js"; -import { avatarTemplate } from "/js/templates/avatar.template.js"; +import { accountSwitcherListTemplate } from "/js/templates/accountSwitcherList.template.js"; import "/js/components/app-icon.js"; -import { verificationBadgeTemplate } from "/js/templates/verificationBadge.template.js"; -import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBadge.template.js"; import { pageEffect, bindToPage, @@ -208,117 +205,16 @@ export default async function loginView({

Sign in as...

- + ${accountSwitcherListTemplate({ + accounts: savedAccounts, + profilesByDid, + currentDid, + pendingDid, + profilesLoading, + onSelect: handleSelectAccount, + onAdd: handleUseAnotherAccount, + addLabel: "Other account", + })}