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 ``;
- },
-};
+ 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 @@
/>
- ${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`
+
+ `,
+ 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`
+
+ `,
+ 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`