diff --git a/packages/shell/src/components/FavoriteButton.ts b/packages/shell/src/components/FavoriteButton.ts new file mode 100644 index 000000000..dc705626e --- /dev/null +++ b/packages/shell/src/components/FavoriteButton.ts @@ -0,0 +1,127 @@ +import { css, html, LitElement, PropertyValues } from "lit"; +import { property, state } from "lit/decorators.js"; +import { RuntimeInternals } from "../lib/runtime.ts"; +import { Task } from "@lit/task"; + +export class XFavoriteButtonElement extends LitElement { + static override styles = css` + x-button.emoji-button { + opacity: 0.7; + transition: opacity 0.2s; + font-size: 1rem; + } + + x-button.emoji-button:hover { + opacity: 1; + } + + x-button.auth-button { + font-size: 1rem; + } + `; + + @property() + rt?: RuntimeInternals; + + @property({ attribute: false }) + charmId?: string; + + // Local state for favoriting, used when + // modifying state inbetween server syncs. + @state() + isFavorite: boolean | undefined = undefined; + + private async handleFavoriteClick(e: Event) { + e.preventDefault(); + e.stopPropagation(); + if (!this.rt || !this.charmId) return; + const manager = this.rt.cc().manager(); + + const isFavorite = this.deriveIsFavorite(); + + // Update local state, and use until overridden by + // syncing state, or another click. + this.isFavorite = !isFavorite; + + const charmCell = (await this.rt.cc().get(this.charmId, true)).getCell(); + if (isFavorite) { + await manager.removeFavorite(charmCell); + } else { + await manager.addFavorite(charmCell); + } + + this.isFavoriteSync.run(); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("charmId")) { + this.isFavorite = undefined; + } + } + + private deriveIsFavorite(): boolean { + // If `isFavorite` is defined, we have local state that is not + // yet synced. Prefer local state if defined, otherwise use server state. + return this.isFavorite ?? this.isFavoriteSync.value ?? false; + } + + isFavoriteSync = new Task(this, { + task: async ( + [charmId, rt], + { signal }, + ): Promise => { + const isFavorite = await isFavoriteSync(rt, charmId); + + // If another favorite request was initiated, store + // the sync status, but don't overwrite the local state. + if (signal.aborted) return isFavorite; + + // We update `this.isFavorite` here to `undefined`, + // indicating that the synced state should be preferred + // now that it's fresh. + this.isFavorite = undefined; + return isFavorite; + }, + args: () => [this.charmId, this.rt], + }); + + override render() { + const isFavorite = this.deriveIsFavorite(); + + return html` + + ${isFavorite ? "⭐" : "☆"} + + `; + } +} + +globalThis.customElements.define("x-favorite-button", XFavoriteButtonElement); + +async function isFavoriteSync( + rt?: RuntimeInternals, + charmId?: string, +): Promise { + if (!charmId || !rt) { + return false; + } + const manager = rt.cc().manager(); + try { + const charm = await manager.get(charmId, true); + if (charm) { + const favorites = manager.getFavorites(); + await favorites.sync(); + return manager.isFavorite(charm); + } else { + return false; + } + } catch (_) { + // + } + return false; +} diff --git a/packages/shell/src/components/index.ts b/packages/shell/src/components/index.ts index 78e4fcaf2..d657fc3f4 100644 --- a/packages/shell/src/components/index.ts +++ b/packages/shell/src/components/index.ts @@ -1,5 +1,6 @@ export * from "./Button.ts"; export * from "./CharmLink.ts"; export * from "./CTLogo.ts"; +export * from "./FavoriteButton.ts"; export * from "./Flex.ts"; export * from "./Spinner.ts"; diff --git a/packages/shell/src/lib/app/commands.ts b/packages/shell/src/lib/app/commands.ts index bb14e44bf..7ea57a064 100644 --- a/packages/shell/src/lib/app/commands.ts +++ b/packages/shell/src/lib/app/commands.ts @@ -5,8 +5,7 @@ import { AppStateConfigKey } from "./state.ts"; export type Command = | { type: "set-view"; view: AppView } | { type: "set-identity"; identity: Identity | undefined } - | { type: "set-config"; key: AppStateConfigKey; value: boolean } - | { type: "toggle-favorite"; charmId: string }; + | { type: "set-config"; key: AppStateConfigKey; value: boolean }; export function isCommand(value: unknown): value is Command { if ( @@ -27,9 +26,6 @@ export function isCommand(value: unknown): value is Command { return "key" in value && typeof value.key === "string" && "value" in value && typeof value.value === "boolean"; } - case "toggle-favorite": { - return "charmId" in value && typeof value.charmId === "string"; - } } return false; } diff --git a/packages/shell/src/views/HeaderView.ts b/packages/shell/src/views/HeaderView.ts index 5dccdac89..ab1e3b954 100644 --- a/packages/shell/src/views/HeaderView.ts +++ b/packages/shell/src/views/HeaderView.ts @@ -44,7 +44,7 @@ export class XHeaderView extends BaseView { gap: 3px; } - .button-group x-button { + .button-group > * { flex: none; } @@ -111,9 +111,6 @@ export class XHeaderView extends BaseView { @property({ type: Boolean }) hasSidebarContent = false; - @property({ attribute: false }) - isFavorite = false; - private handleAuthClick(e: Event) { e.preventDefault(); e.stopPropagation(); @@ -155,58 +152,6 @@ export class XHeaderView extends BaseView { }); } - private handleFavoriteClick(e: Event) { - e.preventDefault(); - e.stopPropagation(); - if (!this.charmId) return; - this.command({ - type: "toggle-favorite", - charmId: this.charmId, - }); - } - - private handleFavoriteChanged = (e: Event) => { - const event = e as CustomEvent<{ charmId: string; isFavorite: boolean }>; - if (event.detail.charmId === this.charmId) { - this.isFavorite = event.detail.isFavorite; - } - }; - - override connectedCallback() { - super.connectedCallback(); - globalThis.addEventListener("favorite-changed", this.handleFavoriteChanged); - } - - override disconnectedCallback() { - super.disconnectedCallback(); - globalThis.removeEventListener( - "favorite-changed", - this.handleFavoriteChanged, - ); - } - - override async updated(changedProperties: Map) { - super.updated(changedProperties); - // Check favorite state when charm changes - if (changedProperties.has("charmId") && this.charmId && this.rt) { - const manager = this.rt.cc().manager(); - // Ensure favorites are synced before checking - try { - const charm = await manager.get(this.charmId, true); - if (charm) { - const favorites = manager.getFavorites(); - await favorites.sync(); - this.isFavorite = manager.isFavorite(charm); - } else { - this.isFavorite = false; - } - } catch (_error) { - // If sync fails (e.g., authorization error), assume not favorited - this.isFavorite = false; - } - } - } - private getConnectionStatus(): ConnectionStatus { return this.rt ? "connected" : "disconnected"; } @@ -259,16 +204,10 @@ export class XHeaderView extends BaseView { ` : null} ${this.charmId ? html` - - ${this.isFavorite ? "⭐" : "☆"} - + ` : null} { - await this.processCommand(command); + apply(command: Command): Promise { + this.processCommand(command); this.requestUpdate(); return this.updateComplete.then((_) => undefined); } @@ -165,34 +165,8 @@ export class XRootView extends BaseView { return clone(this.app); } - private async processCommand(command: Command) { + private processCommand(command: Command) { try { - // Handle async commands that don't affect state - if (command.type === "toggle-favorite") { - const rt = this._rt.value; - if (!rt) return; - - const manager = rt.cc().manager(); - const charm = await rt.cc().get(command.charmId, true); - const isFavorite = manager.isFavorite(charm.getCell()); - - if (isFavorite) { - await manager.removeFavorite(charm.getCell()); - } else { - await manager.addFavorite(charm.getCell()); - } - - // Trigger HeaderView to update its favorite state - this.dispatchEvent( - new CustomEvent("favorite-changed", { - detail: { charmId: command.charmId, isFavorite: !isFavorite }, - bubbles: true, - composed: true, - }), - ); - return; - } - // Apply command synchronously for state changes const state = applyCommand(this.app, command); this.app = state;