diff --git a/resources/lang/en.json b/resources/lang/en.json
index 35a2bd36df3..5d55d86f365 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -1732,11 +1732,19 @@
"milestones": "Milestones",
"no_clans": "This player isn't in any clans.",
"no_prestige": "Hasn't prestiged yet",
- "not_found": "Player profile not found",
+ "not_found_body": "We couldn't find a player at this link. It may be mistyped, or the account may have been deleted.",
+ "not_found_title": "Player not found",
+ "play": "Play OpenFront",
"prestige_history": "Prestige history",
"share": "Share Profile",
+ "share_copy_link": "Copy link",
+ "share_discord": "Copy for Discord",
+ "share_native": "Share",
+ "share_text": "Check out {name}'s profile on OpenFront",
+ "share_x": "Share on X",
"title": "Player Profile",
- "view": "View Profile"
+ "view": "View Profile",
+ "view_leaderboard": "View leaderboard"
},
"player_stats_table": {
"arrived": "Arrived",
@@ -1838,6 +1846,7 @@
"keep_holding": "Keep holding…",
"load_failed": "Couldn't open Prestige. Check your connection and try again.",
"ready": "Level 100! Until you prestige, XP only adds to your lifetime total.",
+ "share_text": "I just reached Prestige {rank} on OpenFront!",
"stale": "Your level changed since this page loaded, so your account has been refreshed.",
"stale_failed": "Your level changed since this page loaded. Reload the page to see where you are.",
"submitting": "Prestiging…",
@@ -1912,6 +1921,10 @@
"penalty_line": "−{percent}% XP ({source})",
"prestige": "Prestige {prestige}",
"prestige_short": "P{prestige}",
+ "share_moment_legend": "Share Legend",
+ "share_moment_level": "Share Level {level}",
+ "share_text_legend": "I just became a Legend on OpenFront!",
+ "share_text_level": "I just reached Level {level} on OpenFront!",
"sign_in_to_earn": "Sign in to earn XP and level up.",
"skip_reveal": "Skip",
"tap_to_skip": "Tap to skip",
diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts
index 2a940d80d8b..f826c007c9e 100644
--- a/src/client/AccountModal.ts
+++ b/src/client/AccountModal.ts
@@ -215,6 +215,7 @@ export class AccountModal extends BaseModal {
${this.renderTab(tab)}
diff --git a/src/client/Main.ts b/src/client/Main.ts
index 82ab3103482..fabb7a0fe08 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -157,6 +157,7 @@ import {
installDoubleTapZoomBlocker,
installSafariPinchZoomBlocker,
} from "./utilities/DisableSafariPinchZoom";
+import { playerProfileRouteArgs } from "./utilities/PlayerProfileUrl";
import "./components/DesktopNavBar";
import "./components/Footer";
@@ -475,6 +476,8 @@ class Client {
tag: "player-profile-modal",
pageId: "page-profile",
});
+ // The canonical profile link, `/player/` (see playerProfileUrl).
+ modalRouter.registerPath("profile", playerProfileRouteArgs);
modalRouter.register("help", { tag: "help-modal", pageId: "page-help" });
modalRouter.register("news", { tag: "news-modal", pageId: "page-news" });
modalRouter.register("language", {
@@ -1398,6 +1401,10 @@ class Client {
console.log(`${spectate ? "spectating" : "joining"} lobby ${lobbyId}`);
return;
}
+ // A shared profile link (`/player/`) opens that profile.
+ if (modalRouter.routeFromPath()) {
+ return;
+ }
if (modalRouter.routeFromHash()) {
return;
}
diff --git a/src/client/ModalRouter.ts b/src/client/ModalRouter.ts
index c7a71dfb98b..63a87286bf7 100644
--- a/src/client/ModalRouter.ts
+++ b/src/client/ModalRouter.ts
@@ -7,9 +7,22 @@
*
* Lobby modals (join/host) and matchmaking are intentionally not registered:
* they have their own URL state (path-based) or none at all.
+ *
+ * A modal can also be opened by a path link (`/player/`, see
+ * registerPath). While it stays open the URL keeps that path, with tab
+ * changes written to `?tab=`; closing it returns the URL to the home page, so
+ * a reload doesn't reopen it. Opening another modal, in-app or from the hash,
+ * hands the URL to the hash and leaves the path the same way.
*/
import { loadModal } from "./LazyModals";
+import { pagePin } from "./PagePin";
+
+/** Parses a page location into a modal's open() args, or null if it isn't one. */
+export type PathRouteParser = (location: {
+ pathname: string;
+ search: string;
+}) => Record | null;
interface RegistryEntry {
/** Custom element tag, e.g. "store-modal". */
@@ -34,11 +47,57 @@ class ModalRouter {
private currentName: string | null = null;
/** True while we're routing from the URL (suppress modal→URL sync). */
private routingFromUrl = false;
+ private pathRoutes = new Map();
+ /** Name of the modal the URL's path (not its hash) reflects, if any. */
+ private pathRoutedName: string | null = null;
register(name: string, entry: RegistryEntry): void {
this.registry.set(name, entry);
}
+ /** Let a registered modal be opened by a path link as well as the hash. */
+ registerPath(name: string, parse: PathRouteParser): void {
+ this.pathRoutes.set(name, parse);
+ }
+
+ /**
+ * Open the modal the current path names, if one does. Returns true when it
+ * did (the open itself is asynchronous, as for routeFromHash).
+ */
+ routeFromPath(): boolean {
+ for (const [name, parse] of this.pathRoutes) {
+ const entry = this.registry.get(name);
+ if (entry === undefined) continue;
+ const args = parse(window.location);
+ if (args === null) continue;
+ this.pathRoutedName = name;
+ void this.openRegistered(name, entry, args);
+ return true;
+ }
+ return false;
+ }
+
+ /** True while the named modal is open from a path link. */
+ isPathRouted(name: string): boolean {
+ return this.pathRoutedName === name;
+ }
+
+ // The path link has had its say: put the URL back to the home page. The
+ // home page keeps the page's version pin when it has one (see
+ // currentPagePath in Utils, which this mirrors without its imports). A
+ // `#modal=` hash stays: it's another modal's, and that modal is still open.
+ private leavePath(): void {
+ if (this.pathRoutedName === null) return;
+ this.pathRoutedName = null;
+ const commit = pagePin();
+ history.replaceState(
+ history.state,
+ "",
+ (commit === null ? "/" : `/v/${commit}/`) +
+ (this.isHashRouted() ? window.location.hash : ""),
+ );
+ }
+
/**
* Parse `window.location.hash` for `#modal=&...`. If present and
* registered, open the modal with the remaining keys as args. Returns true
@@ -66,6 +125,11 @@ class ModalRouter {
args[key] = value;
});
+ // The hash names the modal now, as when one opens in-app (syncOpened).
+ // Left on, the path would outlive the modal it names: the router stops
+ // tracking that modal, so its close wouldn't clear the path, and the next
+ // reload would open it again.
+ this.leavePath();
void this.openRegistered(name, entry, args);
return true;
}
@@ -85,8 +149,14 @@ class ModalRouter {
return;
}
await customElements.whenDefined(entry.tag);
- // A slow load can land after the URL has moved on to another modal.
- if (this.currentHashParams().get("modal") !== name) return;
+ // A slow load can land after the URL has moved on to another modal. The
+ // URL names this one by its hash, or by its path while path-routed.
+ if (
+ this.currentHashParams().get("modal") !== name &&
+ this.pathRoutedName !== name
+ ) {
+ return;
+ }
this.routingFromUrl = true;
try {
@@ -108,6 +178,8 @@ class ModalRouter {
syncOpened(name: string, args?: Record): void {
if (this.routingFromUrl) return; // we're driving the modal from the URL; don't loop
if (!this.registry.has(name)) return;
+ // Another modal (or this one again) opened in-app: the hash takes over.
+ this.leavePath();
this.currentName = name;
this.writeHash(name, args);
}
@@ -115,6 +187,13 @@ class ModalRouter {
/** Called by BaseModal.close() when a router-managed modal closes. */
syncClosed(name: string): void {
if (this.routingFromUrl) return;
+ // The path names this modal whichever modal the router last recorded, so
+ // its close always leaves the path.
+ if (this.pathRoutedName === name) {
+ if (this.currentName === name) this.currentName = null;
+ this.leavePath();
+ return;
+ }
if (this.currentName !== name) return; // not the active routed modal
this.currentName = null;
this.replaceHash("");
@@ -124,6 +203,24 @@ class ModalRouter {
syncTab(name: string, tab: string): void {
if (this.routingFromUrl) return;
if (this.currentName !== name) return;
+ if (this.pathRoutedName === name) {
+ // Opened from a path: the path stays, and the tab rides as `?tab=`.
+ const search = new URLSearchParams(window.location.search);
+ if (tab) {
+ search.set("tab", tab);
+ } else {
+ search.delete("tab");
+ }
+ const query = search.toString();
+ history.replaceState(
+ history.state,
+ "",
+ window.location.pathname +
+ (query ? `?${query}` : "") +
+ window.location.hash,
+ );
+ return;
+ }
const params = this.currentHashParams();
params.set("modal", name);
if (tab) {
@@ -138,6 +235,8 @@ class ModalRouter {
syncArgs(name: string, args: Record): void {
if (this.routingFromUrl) return;
if (this.currentName !== name) return;
+ // The path is the state of a path-routed modal; don't add a hash to it.
+ if (this.pathRoutedName === name) return;
const params = this.currentHashParams();
params.set("modal", name);
for (const [key, value] of Object.entries(args)) {
diff --git a/src/client/MomentShare.ts b/src/client/MomentShare.ts
new file mode 100644
index 00000000000..3ffb1797d15
--- /dev/null
+++ b/src/client/MomentShare.ts
@@ -0,0 +1,99 @@
+import type { GameXpPanelState } from "./components/GameXpPanel";
+import {
+ isMilestoneLevel,
+ levelsReachedInGame,
+ MAX_LEVEL,
+ MAX_PRESTIGE,
+ reachedLegendThisGame,
+} from "./Progression";
+import { playerProfileUrl } from "./utilities/PlayerProfileUrl";
+import { translateText } from "./Utils";
+
+// A progression moment worth sharing: a milestone level, becoming a Legend,
+// or a new prestige rank. Shared as the player's profile link with the moment
+// named in `?moment=`, so the link unfurls into a card for it.
+export type ShareMoment =
+ | { kind: "level"; level: number }
+ | { kind: "legend" }
+ | { kind: "prestige"; rank: number };
+
+/**
+ * The moment a finished game is worth sharing for, or null. Only the server's
+ * result counts (never a provisional figure), and only a milestone level or
+ * becoming a Legend: an ordinary level-up isn't shared. A Legend beats any
+ * level, and of several milestones reached in one game the highest is shared.
+ */
+export function gameShareMoment(view: GameXpPanelState): ShareMoment | null {
+ if (view.kind !== "result" || !view.data.eligible) return null;
+ const data = view.data;
+ if (reachedLegendThisGame(data)) return { kind: "legend" };
+ const milestones = levelsReachedInGame(data)
+ .map((l) => l.level)
+ .filter(isMilestoneLevel);
+ if (milestones.length === 0) return null;
+ return { kind: "level", level: Math.max(...milestones) };
+}
+
+// The moments the site's card route draws: a level from 2 (the first one
+// reached) to the last, and a prestige rank from 1 to the last. It answers
+// anything else with a 400, which unfurls as no image at all.
+function inRange(n: number, min: number, max: number): boolean {
+ return Number.isInteger(n) && n >= min && n <= max;
+}
+
+/**
+ * The `?moment=` value: `level50`, `legend`, `prestige3`. Null for a level or
+ * rank the card route wouldn't draw (a surprising server value), so the
+ * share is dropped rather than sending a link that unfurls without a card.
+ */
+export function momentSlug(moment: ShareMoment): string | null {
+ switch (moment.kind) {
+ case "level":
+ return inRange(moment.level, 2, MAX_LEVEL)
+ ? `level${moment.level}`
+ : null;
+ case "legend":
+ return "legend";
+ case "prestige":
+ return inRange(moment.rank, 1, MAX_PRESTIGE)
+ ? `prestige${moment.rank}`
+ : null;
+ }
+}
+
+/**
+ * The player's profile link with the moment: `/player/?moment=level50`.
+ * Null when the moment has no slug (see momentSlug): nothing to share.
+ */
+export function momentShareUrl(
+ publicId: string,
+ moment: ShareMoment,
+): string | null {
+ const slug = momentSlug(moment);
+ return slug === null ? null : `${playerProfileUrl(publicId)}?moment=${slug}`;
+}
+
+/** The line that goes with the link ("I just reached Level 50 on OpenFront!"). */
+export function momentShareText(moment: ShareMoment): string {
+ switch (moment.kind) {
+ case "level":
+ return translateText("progression.share_text_level", {
+ level: moment.level,
+ });
+ case "legend":
+ return translateText("progression.share_text_legend");
+ case "prestige":
+ return translateText("prestige.share_text", { rank: moment.rank });
+ }
+}
+
+/** The share button's label for a game's moment ("Share Level 50"). */
+export function momentShareLabel(
+ moment: Exclude,
+): string {
+ return moment.kind === "legend"
+ ? translateText("progression.share_moment_legend")
+ : translateText("progression.share_moment_level", {
+ level: moment.level,
+ });
+}
diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts
index 86b21d198d0..72fe83ce94d 100644
--- a/src/client/PlayerProfileModal.ts
+++ b/src/client/PlayerProfileModal.ts
@@ -9,6 +9,7 @@ import { customElement, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { fetchPublicPlayerProfile } from "./Api";
import "./ClanModal";
+import "./components/baseComponents/Button";
import "./components/baseComponents/stats/PlayerGameHistoryView";
import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView";
import "./components/baseComponents/stats/PlayerStatsTree";
@@ -17,13 +18,18 @@ import "./components/clan/ClanCard";
import "./components/PlayerName";
import "./components/ProfileCard";
import "./components/ProfileProgression";
+import "./components/ProfileShare";
import { modalHeader } from "./components/ui/ModalHeader";
import { usernameText } from "./components/ui/UsernameText";
import { verifiedBadge } from "./components/ui/VerifiedBadge";
import "./GameStatsModal";
import "./LeaderboardModal";
+import { modalRouter } from "./ModalRouter";
import { fetchPublicPlayerProgress } from "./ProgressionApi";
-import { playerProfileUrl } from "./utilities/PlayerProfileUrl";
+import {
+ parsePlayerProfilePath,
+ playerProfileUrl,
+} from "./utilities/PlayerProfileUrl";
import { currentPagePath, translateText } from "./Utils";
export { playerProfileUrl };
@@ -45,6 +51,9 @@ export class PlayerProfileModal extends BaseModal {
// Level / XP, when progression is on and the player has any.
@state() private progress: PublicProgress | null = null;
@state() private loading = false;
+ // The link this profile was opened from (`openfront.io/player/`), when
+ // it was opened from one: the not-found state shows it.
+ @state() private openedLink: string | null = null;
private openedFrom: ProfileOrigin | null = null;
// Mirrors the account modal's Games tab: keep the accumulated history list +
// cursor across tab switches (and the game-stats detour) so re-entering Games
@@ -238,10 +247,16 @@ export class PlayerProfileModal extends BaseModal {
if (this.loading) {
return this.renderLoadingSpinner(translateText("player_profile.loading"));
}
- if (!this.profileLoaded()) {
+ const publicId = this.publicId;
+ if (!this.profileLoaded() || publicId === null) {
return this.renderNotFound();
}
- // The card heads the stats; games and wins stay in the stats below it.
+ // The card heads the stats; games and wins stay in the stats below it,
+ // and the ways to share the profile follow it.
+ //
+ // A "Show my profile in search engines" setting will join the account
+ // settings' Privacy card once the API carries the flag and the site
+ // Worker honours it with noindex; profile links are indexable until then.
return html`
${this.progress === null
? nothing
@@ -252,6 +267,11 @@ export class PlayerProfileModal extends BaseModal {
.progress=${this.progress}
.openKey=${`profile-${this.openCount}`}
>`}
+
@@ -290,15 +310,95 @@ export class PlayerProfileModal extends BaseModal {
private renderNotFound() {
return html`
-
`;
}
+ // Close the profile onto the home page (the URL follows: see ModalRouter).
+ private goHome(): void {
+ this.close();
+ }
+
+ private openLeaderboard(): void {
+ this.close();
+ document
+ .querySelector("leaderboard-modal")
+ ?.open();
+ }
+
+ // Opening another player's profile over this one: BaseModal syncs the URL
+ // only when a modal first opens, which would leave it naming the last
+ // player (its `/player/` path, or its `#modal=` hash), with tab changes
+ // written onto that and a reload opening the wrong profile. Hand the URL to
+ // the new player as an in-app open does. The router ignores this while it
+ // is the one opening the modal (from a path or the hash).
+ public override open(args?: Record): void {
+ const wasOpen = this.isOpen();
+ const before = this.publicId;
+ super.open(args);
+ if (wasOpen && this.isOpen() && this.publicId !== before) {
+ modalRouter.syncOpened(this.routerName, args);
+ }
+ }
+
protected onOpen(args?: Record): void {
const publicId =
typeof args?.publicID === "string" && args.publicID.length > 0
@@ -348,6 +448,14 @@ export class PlayerProfileModal extends BaseModal {
this.gameHistoryCache = null;
this.gamesScrollTop = 0;
this.restoreGamesScrollAfterOpen = false;
+ // Only while the path is this player's: the URL can still name the last
+ // one for the moment (see open()).
+ this.openedLink =
+ publicId !== null &&
+ modalRouter.isPathRouted("profile") &&
+ parsePlayerProfilePath(window.location.pathname) === publicId
+ ? `${window.location.host}${window.location.pathname}`
+ : null;
this.loading = publicId !== null;
if (publicId !== null) {
void this.loadProfile(publicId);
diff --git a/src/client/components/FriendsList.ts b/src/client/components/FriendsList.ts
index 54a60ea9d6a..4efc5df89d5 100644
--- a/src/client/components/FriendsList.ts
+++ b/src/client/components/FriendsList.ts
@@ -10,14 +10,16 @@ import {
sendFriendRequest,
} from "../FriendsApi";
import { showInGameConfirm } from "../InGameModal";
+import { parsePlayerProfilePath } from "../utilities/PlayerProfileUrl";
import { showToast, translateText } from "../Utils";
import { playerNameLink } from "./ui/PlayerNameLink";
const PAGE_LIMIT = 20;
/**
- * Accept a pasted profile share link (`…#modal=profile&publicID=abc12345`) and
- * keep only the id, mirroring the private-lobby join box. Only strings that are
+ * Accept a pasted profile share link (`…/player/abc12345`, or the older
+ * `…#modal=profile&publicID=abc12345`) and keep only the id, mirroring the
+ * private-lobby join box. Only strings that are
* actually URLs are touched: usernames render as `wonder #5005`, so a bare `#`
* must be left alone.
*/
@@ -32,6 +34,9 @@ export function extractPublicIdFromUrl(input: string): string {
// so don't log; just hand the raw text back for the server to reject.
return input;
}
+ // The canonical link names the player in its path.
+ const fromPath = parsePlayerProfilePath(url.pathname);
+ if (fromPath !== null) return fromPath;
// Modals are hash-routed (`…/#modal=profile&publicID=abc12345`), so the id is
// in the fragment, not the query string — `url.searchParams` is always empty
// here. Parse the fragment's own `key=value&…` pairs instead.
diff --git a/src/client/components/GameXpPanel.ts b/src/client/components/GameXpPanel.ts
index 62613304c85..043b20a8a9a 100644
--- a/src/client/components/GameXpPanel.ts
+++ b/src/client/components/GameXpPanel.ts
@@ -149,6 +149,8 @@ export class GameXpPanel extends LitElement {
// The Legend result already announced (see announceLegend).
private announcedLegend: GameXpEligible | null = null;
private timers: ReturnType[] = [];
+ // The result last announced as settled (see updated()).
+ private settledView: GameXpPanelState | null = null;
createRenderRoot() {
return this;
@@ -487,6 +489,7 @@ export class GameXpPanel extends LitElement {
protected updated(changed: PropertyValues): void {
super.updated(changed);
+ this.announceSettled();
// A Legend result shown without its reveal reaching level 100 (reduced
// motion, a skip, or it arrived while the popup was hidden) still gets
// its moment, once it's on screen.
@@ -502,6 +505,21 @@ export class GameXpPanel extends LitElement {
}
}
+ // Tells the host once a result is showing in full: its reveal has ended or
+ // been skipped, or there was none. Once per result (`detail` is the view).
+ private announceSettled(): void {
+ const view = this.view;
+ if (view.kind !== "result" || this.reveal !== null) return;
+ if (this.settledView === view) return;
+ this.settledView = view;
+ this.dispatchEvent(
+ new CustomEvent("xp-reveal-settled", {
+ detail: view,
+ bubbles: true,
+ }),
+ );
+ }
+
// Ends any reveal on its final state (a no-op when none is playing).
private skipReveal(): void {
this.clearTimers();
diff --git a/src/client/components/LegendCeremony.ts b/src/client/components/LegendCeremony.ts
index 8d0eacc29a4..131a6296e8d 100644
--- a/src/client/components/LegendCeremony.ts
+++ b/src/client/components/LegendCeremony.ts
@@ -7,6 +7,7 @@ import {
TemplateResult,
} from "lit";
import { customElement, state } from "lit/decorators.js";
+import { momentShareText, momentShareUrl } from "../MomentShare";
import { MAX_PRESTIGE } from "../Progression";
import { translateText } from "../Utils";
import {
@@ -15,6 +16,7 @@ import {
renderHoneycomb,
} from "./Ceremony";
import "./LevelBadge";
+import "./ProfileShare";
import { formatXp } from "./XpBar";
// Becoming a Legend (Prestige 10, level 99 to 100): the whole screen, once
@@ -22,8 +24,9 @@ import { formatXp } from "./XpBar";
// it and the honeycomb pulsing in toward it, implodes to a point of light, and
// the Legend crown drops in and lands hard: a gold flash, two shockwaves, the
// screen shaking and two gold waves running out across the honeycomb. LEGEND
-// slams in and a gold sweep runs through it, then a line on what it took, and
-// Continue. At rest the crown breathes and the rays turn slowly.
+// slams in and a gold sweep runs through it, then a line on what it took, a
+// row to share it, and Continue. At rest the crown breathes and the rays turn
+// slowly.
//
// Any click or key skips to the end; Escape (or Continue) then closes it.
// Emits `legend-ceremony-closed` when it closes.
@@ -59,6 +62,8 @@ export interface LegendMoment {
lifetimeXp: number;
// When it happened.
at: Date;
+ // Whose it is, for the share link. Empty: no share row.
+ publicId: string;
}
const SEEN_PREFIX = "legendCeremonySeen:";
@@ -215,7 +220,8 @@ export class LegendCeremony extends LitElement {
}
// Every key while it's up stays with it. Before the end any key skips;
- // at the end Escape closes and Tab stays on Continue.
+ // at the end Escape closes and Tab moves between the share buttons and
+ // Continue without leaving the ceremony.
private onKeyDown = (e: KeyboardEvent): void => {
if (this.moment === null) return;
e.stopPropagation();
@@ -229,7 +235,19 @@ export class LegendCeremony extends LitElement {
this.close();
} else if (e.key === "Tab") {
e.preventDefault();
- this.continueButton()?.focus();
+ const buttons = [
+ ...(this.root()?.querySelectorAll(
+ "button:not([disabled])",
+ ) ?? []),
+ ];
+ if (buttons.length === 0) return;
+ // From anywhere else, back to Continue (the last).
+ const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
+ const next =
+ at === -1
+ ? buttons.length - 1
+ : (at + (e.shiftKey ? -1 : 1) + buttons.length) % buttons.length;
+ buttons[next].focus();
}
};
@@ -371,6 +389,29 @@ export class LegendCeremony extends LitElement {
`;
}
+ // Sharing it, between the line and Continue, the same as the prestige
+ // ceremony's row. The ceremony skips (or, done, does nothing) on any
+ // click; the row keeps its clicks. It is simply there, without the rise
+ // the Continue button has.
+ private renderShare(moment: LegendMoment): TemplateResult | typeof nothing {
+ if (moment.publicId === "") return nothing;
+ const share = { kind: "legend" } as const;
+ const url = momentShareUrl(moment.publicId, share);
+ if (url === null) return nothing;
+ return html`