mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
chore(desktop): merge origin/main into workspace-scoped-agent-store
Adopt spawn_hash → spawn_snapshot rename from main; migrate spawn_config_hash: 0 test fixtures to prospective_spawn_config_snapshot() calls in global_agent_config tests, epoch tests, and runtime_commands tests. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
commit
4634fe1b39
93
desktop/src/shared/api/relayAuthPolicy.test.mjs
Normal file
93
desktop/src/shared/api/relayAuthPolicy.test.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
AuthOkTracker,
|
||||
MAX_CONSECUTIVE_AUTH_REJECTIONS,
|
||||
} from "./relayAuthPolicy.ts";
|
||||
|
||||
test("success resolves authenticated and resets the streak", () => {
|
||||
const tracker = new AuthOkTracker();
|
||||
tracker.record(false, "auth-required: verification failed");
|
||||
tracker.record(false, "auth-required: verification failed");
|
||||
assert.equal(tracker.record(true, ""), "authenticated");
|
||||
// Streak reset: the next rejection starts a fresh count.
|
||||
assert.equal(
|
||||
tracker.record(false, "auth-required: verification failed"),
|
||||
"retry",
|
||||
);
|
||||
});
|
||||
|
||||
test("already-authenticated rejection is treated as authenticated (duplicate-AUTH race)", () => {
|
||||
const tracker = new AuthOkTracker();
|
||||
assert.equal(
|
||||
tracker.record(false, "auth-required: already authenticated"),
|
||||
"authenticated",
|
||||
);
|
||||
});
|
||||
|
||||
test("restricted rejections latch terminal immediately", () => {
|
||||
for (const message of [
|
||||
"restricted: not a relay member",
|
||||
"restricted: you are banned",
|
||||
]) {
|
||||
assert.equal(new AuthOkTracker().record(false, message), "terminal");
|
||||
}
|
||||
});
|
||||
|
||||
test("the relay's actual ban string latches terminal immediately", () => {
|
||||
// Exact string emitted by crates/buzz-relay/src/handlers/auth.rs at the
|
||||
// ban seam. A known-permanent ban must never enter the retry loop.
|
||||
assert.equal(
|
||||
new AuthOkTracker().record(
|
||||
false,
|
||||
"blocked: you are banned from this community",
|
||||
),
|
||||
"terminal",
|
||||
);
|
||||
});
|
||||
|
||||
test("verification failures retry with backoff (clock skew, DB fail-closed)", () => {
|
||||
const tracker = new AuthOkTracker();
|
||||
assert.equal(
|
||||
tracker.record(false, "auth-required: verification failed"),
|
||||
"retry",
|
||||
);
|
||||
});
|
||||
|
||||
test("unknown rejection reasons retry rather than latch", () => {
|
||||
assert.equal(new AuthOkTracker().record(false, ""), "retry");
|
||||
assert.equal(new AuthOkTracker().record(false, "error: internal"), "retry");
|
||||
});
|
||||
|
||||
test("consecutive rejections latch terminal at the cap", () => {
|
||||
const tracker = new AuthOkTracker();
|
||||
let decision = "retry";
|
||||
for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) {
|
||||
decision = tracker.record(false, "auth-required: verification failed");
|
||||
}
|
||||
assert.equal(decision, "terminal");
|
||||
});
|
||||
|
||||
test("reset grants a fresh retry streak after explicit re-engagement", () => {
|
||||
const tracker = new AuthOkTracker();
|
||||
for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) {
|
||||
tracker.record(false, "auth-required: verification failed");
|
||||
}
|
||||
tracker.reset();
|
||||
assert.equal(
|
||||
tracker.record(false, "auth-required: verification failed"),
|
||||
"retry",
|
||||
);
|
||||
});
|
||||
|
||||
test("already-authenticated wins even past the cap (session is usable)", () => {
|
||||
const tracker = new AuthOkTracker();
|
||||
for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS + 1; i++) {
|
||||
tracker.record(false, "auth-required: verification failed");
|
||||
}
|
||||
assert.equal(
|
||||
tracker.record(false, "auth-required: already authenticated"),
|
||||
"authenticated",
|
||||
);
|
||||
});
|
||||
65
desktop/src/shared/api/relayAuthPolicy.ts
Normal file
65
desktop/src/shared/api/relayAuthPolicy.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Policy for NIP-42 AUTH `OK` responses (G2 of the CMD+R gap audit).
|
||||
*
|
||||
* Historically ANY auth `OK false` latched the session terminal — no
|
||||
* reconnect until explicit user re-engagement. But the relay sends
|
||||
* `OK false` for conditions that are transient from the client's side:
|
||||
*
|
||||
* - `auth-required: already authenticated` — a duplicate/late AUTH event on
|
||||
* a connection that is in fact authenticated. The session is usable;
|
||||
* treat it as authenticated.
|
||||
* - `auth-required: verification failed` — covers ±60s clock-skew rejects
|
||||
* and the relay's fail-closed allowlist DB lookup errors, both of which
|
||||
* can clear on retry.
|
||||
*
|
||||
* Only `restricted:` and `blocked:` rejections (not a relay member / banned)
|
||||
* are known permanent. Everything else retries with normal backoff, but
|
||||
* latches terminal after `MAX_CONSECUTIVE_AUTH_REJECTIONS` consecutive
|
||||
* rejections so a genuinely broken identity (e.g. persistently wrong system
|
||||
* clock) still surfaces the terminal error card instead of flapping forever.
|
||||
*
|
||||
* The rejection streak is preserved across environment-driven resume
|
||||
* attempts (focus/online/visibility); only explicit user re-engagement —
|
||||
* the reconnect card or a community switch — may reset it.
|
||||
*/
|
||||
export type AuthOkDecision = "authenticated" | "retry" | "terminal";
|
||||
|
||||
export const MAX_CONSECUTIVE_AUTH_REJECTIONS = 3;
|
||||
|
||||
/** Tracks consecutive AUTH rejections across reconnect attempts. */
|
||||
export class AuthOkTracker {
|
||||
private consecutiveRejections = 0;
|
||||
|
||||
/**
|
||||
* Record an AUTH `OK` and decide the session's next move.
|
||||
* A success — real or "already authenticated" — resets the streak.
|
||||
*/
|
||||
record(success: boolean, message: string): AuthOkDecision {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
if (
|
||||
success ||
|
||||
normalized.startsWith("auth-required: already authenticated")
|
||||
) {
|
||||
this.consecutiveRejections = 0;
|
||||
return "authenticated";
|
||||
}
|
||||
|
||||
this.consecutiveRejections++;
|
||||
|
||||
if (
|
||||
normalized.startsWith("restricted:") ||
|
||||
normalized.startsWith("blocked:")
|
||||
) {
|
||||
return "terminal";
|
||||
}
|
||||
if (this.consecutiveRejections >= MAX_CONSECUTIVE_AUTH_REJECTIONS) {
|
||||
return "terminal";
|
||||
}
|
||||
return "retry";
|
||||
}
|
||||
|
||||
/** Called on explicit re-engagement (disconnect / manual preconnect). */
|
||||
reset(): void {
|
||||
this.consecutiveRejections = 0;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEm
|
||||
import {
|
||||
isServiceRestartClose,
|
||||
isWebSocketClose,
|
||||
isWebSocketError,
|
||||
shouldRefuseConnect,
|
||||
shouldScheduleReconnect,
|
||||
shouldWaitForScheduledReconnect,
|
||||
@@ -65,6 +66,7 @@ import {
|
||||
STALL_IDLE_TIMEOUT_MS,
|
||||
} from "@/shared/api/relayClientTimings";
|
||||
import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
|
||||
import { AuthOkTracker } from "@/shared/api/relayAuthPolicy";
|
||||
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
|
||||
|
||||
export class RelayClient {
|
||||
@@ -92,6 +94,7 @@ export class RelayClient {
|
||||
private connectionGeneration = 0;
|
||||
private stabilityTimer: number | null = null;
|
||||
private visibleChannelId: string | null = null;
|
||||
private authOkTracker = new AuthOkTracker();
|
||||
|
||||
private terminal = false;
|
||||
|
||||
@@ -128,6 +131,7 @@ export class RelayClient {
|
||||
this.notifyReconnectListeners = false;
|
||||
this.terminal = false;
|
||||
this.visibleChannelId = null;
|
||||
this.authOkTracker.reset();
|
||||
this.connectionStateEmitter.set("idle");
|
||||
|
||||
if (this.wsId !== null) {
|
||||
@@ -295,7 +299,7 @@ export class RelayClient {
|
||||
parentEventId?: string | null,
|
||||
rootEventId?: string | null,
|
||||
) {
|
||||
// Bail when disconnected — not worth triggering a reconnect for ephemeral typing events.
|
||||
// Disconnected: not worth triggering a reconnect for ephemeral typing.
|
||||
if (this.wsId === null) {
|
||||
return;
|
||||
}
|
||||
@@ -325,11 +329,11 @@ export class RelayClient {
|
||||
channelId: string,
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
) {
|
||||
// 39005 rides only this window-store subscription — CHANNEL_EVENT_KINDS'
|
||||
// other consumers (unread tracking, cache merges) must never see
|
||||
// summary overlays.
|
||||
return this.subscribe(
|
||||
{
|
||||
// 39005 rides only this window-store subscription — not
|
||||
// CHANNEL_EVENT_KINDS, whose other consumers (unread tracking,
|
||||
// timeline-cache merges) must never see summary overlays.
|
||||
kinds: [...CHANNEL_EVENT_KINDS, KIND_CHANNEL_THREAD_SUMMARY],
|
||||
"#h": [channelId],
|
||||
limit: 1000,
|
||||
@@ -340,10 +344,9 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel.
|
||||
* Used by HuddleIndicator to detect active huddles without being drowned
|
||||
* out by regular channel messages in the generic subscription window.
|
||||
* Includes both historical (last 10) and live events.
|
||||
* Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel,
|
||||
* so HuddleIndicator detects active huddles without being drowned out by
|
||||
* regular channel messages. Includes the last 10 historical events.
|
||||
*/
|
||||
async subscribeToHuddleEvents(
|
||||
channelId: string,
|
||||
@@ -425,12 +428,26 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
async preconnect() {
|
||||
// Explicit re-engagement. If the session went terminal (auth rejection)
|
||||
// the caller is asking us to try again, so clear the latch. A manual
|
||||
// reconnect also bypasses the current delay once; ordinary operations do
|
||||
// not, so background traffic cannot continuously defeat backoff.
|
||||
// Explicit re-engagement (reconnect card / community switch): clears the
|
||||
// terminal latch and AUTH rejection streak, and bypasses backoff once.
|
||||
this.terminal = false;
|
||||
this.authOkTracker.reset();
|
||||
this.keepAliveRequested = true;
|
||||
await this.connectBypassingBackoff();
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment-driven resume (online/focus/visibility): bypasses a pending
|
||||
* backoff timer but preserves the terminal latch and AUTH rejection streak
|
||||
* — only `preconnect()` clears those, so resume events during repeated
|
||||
* AUTH rejection cannot defeat the consecutive-rejection cap.
|
||||
*/
|
||||
async resumeReconnect() {
|
||||
if (this.terminal) return;
|
||||
await this.connectBypassingBackoff();
|
||||
}
|
||||
|
||||
private async connectBypassingBackoff() {
|
||||
if (this.reconnectTimeout !== null) {
|
||||
window.clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
@@ -448,7 +465,6 @@ export class RelayClient {
|
||||
|
||||
subscribeToReconnects(listener: () => void) {
|
||||
this.reconnectListeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this.reconnectListeners.delete(listener);
|
||||
};
|
||||
@@ -460,8 +476,8 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to connection-state transitions. The listener is invoked
|
||||
* immediately with the current state so callers don't need a separate
|
||||
* Subscribe to connection-state transitions. The listener fires
|
||||
* immediately with the current state, so callers need no separate
|
||||
* `getConnectionState()` call to seed their UI.
|
||||
*/
|
||||
subscribeToConnectionState(listener: (state: ConnectionState) => void) {
|
||||
@@ -470,11 +486,10 @@ export class RelayClient {
|
||||
|
||||
private async ensureConnected() {
|
||||
if (shouldRefuseConnect({ terminal: this.terminal })) {
|
||||
// Session is terminal (e.g. relay rejected auth). Refuse to connect
|
||||
// until an explicit re-engagement (disconnect()/preconnect()) clears
|
||||
// the flag. Without this, the reconnect timer's catch handler — and
|
||||
// the retry wrappers in publishEvent / sendRawWithReconnectRetry —
|
||||
// would race the terminal "disconnected" state back to "reconnecting".
|
||||
// Terminal (e.g. relay rejected auth): refuse until disconnect() or
|
||||
// preconnect() clears the latch, else the reconnect-timer catch and
|
||||
// the publish/subscribe retry wrappers would race the terminal
|
||||
// "disconnected" state back to "reconnecting".
|
||||
throw new Error("Relay session is terminal; cannot reconnect.");
|
||||
}
|
||||
|
||||
@@ -494,7 +509,7 @@ export class RelayClient {
|
||||
// The reconnect coordinator owns outage pacing. Query, publish, and
|
||||
// subscription callers must wait for its scheduled attempt instead of
|
||||
// clearing the timer and creating an immediate reconnect storm.
|
||||
return this.waitForScheduledReconnect();
|
||||
return this.reconnectWaiters.wait();
|
||||
}
|
||||
|
||||
const connectPromise = this.connect();
|
||||
@@ -670,7 +685,6 @@ export class RelayClient {
|
||||
error,
|
||||
fallbackMessage,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
await this.sendRaw(payload);
|
||||
@@ -749,12 +763,7 @@ export class RelayClient {
|
||||
this.resetConnection(new Error("Relay connection closed."));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"type" in message &&
|
||||
message.type === "Error"
|
||||
) {
|
||||
if (isWebSocketError(message)) {
|
||||
this.resetConnection(new Error("Relay connection errored."));
|
||||
return;
|
||||
}
|
||||
@@ -819,8 +828,7 @@ export class RelayClient {
|
||||
|
||||
if (type === "NOTICE" && typeof rest[0] === "string") {
|
||||
const notice: string = rest[0];
|
||||
// Relay back-pressure signal — activate the gate so pending operations
|
||||
// back off until the window expires.
|
||||
// Relay back-pressure — arm the gate until the window expires.
|
||||
if (notice.startsWith("rate-limited:")) {
|
||||
activateRateLimit(parseRateLimitHint(notice));
|
||||
}
|
||||
@@ -892,12 +900,14 @@ export class RelayClient {
|
||||
const authRequest = this.authRequest;
|
||||
this.authRequest = null;
|
||||
|
||||
if (success) {
|
||||
// Decision table lives in relayAuthPolicy.ts.
|
||||
const decision = this.authOkTracker.record(success, message);
|
||||
if (decision === "authenticated") {
|
||||
authRequest.resolve();
|
||||
} else {
|
||||
const error = new Error(message || "Relay authentication rejected.");
|
||||
authRequest.reject(error);
|
||||
this.resetConnection(error, { reconnect: false });
|
||||
this.resetConnection(error, { reconnect: decision === "retry" });
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -919,13 +929,7 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
private hasLiveSubscriptions() {
|
||||
for (const subscription of this.subscriptions.values()) {
|
||||
if (subscription.mode === "live") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return [...this.subscriptions.values()].some((s) => s.mode === "live");
|
||||
}
|
||||
|
||||
private async replayLiveSubscriptions() {
|
||||
@@ -948,13 +952,6 @@ export class RelayClient {
|
||||
}
|
||||
}
|
||||
|
||||
private waitForScheduledReconnect(): Promise<void> {
|
||||
if (this.reconnectTimeout === null) {
|
||||
return this.ensureConnected();
|
||||
}
|
||||
return this.reconnectWaiters.wait();
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (
|
||||
!shouldScheduleReconnect({
|
||||
@@ -968,9 +965,8 @@ export class RelayClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply ±25% jitter so a fleet of clients reconnecting simultaneously
|
||||
// spreads their AUTH storms across a 50% window instead of all hitting
|
||||
// the relay at the same instant.
|
||||
// ±25% jitter spreads a fleet's AUTH storms across a 50% window instead
|
||||
// of hitting the relay at the same instant.
|
||||
const jitter = this.reconnectDelayMs * (0.75 + Math.random() * 0.5);
|
||||
const delay = Math.min(jitter, RECONNECT_MAX_DELAY_MS);
|
||||
this.reconnectDelayMs = Math.min(
|
||||
@@ -1031,9 +1027,13 @@ export class RelayClient {
|
||||
if (options?.reconnect === false) {
|
||||
this.terminal = true;
|
||||
this.connectionStateEmitter.set("disconnected");
|
||||
} else if (this.connectionStateEmitter.get() !== "stalled") {
|
||||
// Stall is a stronger signal than a generic drop; keep it until the
|
||||
// reconnect timer transitions us back to "reconnecting" in connect().
|
||||
} else if (
|
||||
// A late retry failure racing a terminal latch must not paint
|
||||
// "reconnecting" over the terminal "disconnected" state; stall is a
|
||||
// stronger signal than a generic drop and is kept until reconnect.
|
||||
!this.terminal &&
|
||||
this.connectionStateEmitter.get() !== "stalled"
|
||||
) {
|
||||
this.connectionStateEmitter.set("reconnecting");
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ test("classifyRelayClosed: terminal messages return terminal", () => {
|
||||
for (const message of [
|
||||
"restricted: not a channel member",
|
||||
"restricted: channel access revoked",
|
||||
"auth-required: not authenticated",
|
||||
"blocked: banned",
|
||||
"invalid: malformed filter",
|
||||
"pow: difficulty too low",
|
||||
@@ -38,6 +37,21 @@ test("classifyRelayClosed: transient errors return retryable", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("classifyRelayClosed: auth-required is retryable (REQ/AUTH reconnect race)", () => {
|
||||
// A REQ that lands before the AUTH handshake completes gets CLOSED with
|
||||
// auth-required. Deleting the subscription would silently freeze the
|
||||
// channel while the connection state still reads "connected" — the sub
|
||||
// must survive and be retried after backoff.
|
||||
assert.equal(
|
||||
classifyRelayClosed("auth-required: not authenticated"),
|
||||
"retryable",
|
||||
);
|
||||
assert.equal(
|
||||
classifyRelayClosed("auth-required: authenticate before subscribing"),
|
||||
"retryable",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Subscription-survival semantics ──────────────────────────────────────────
|
||||
// These replace the removed isRetryableRelayClosed wrapper tests.
|
||||
// rate-limited must not delete the subscription; terminal must.
|
||||
@@ -59,7 +73,6 @@ test("classifyRelayClosed: retryable class survives (subscription must not be de
|
||||
test("classifyRelayClosed: terminal class triggers deletion (no retry)", () => {
|
||||
for (const message of [
|
||||
"restricted: not a channel member",
|
||||
"auth-required: not authenticated",
|
||||
"blocked: banned",
|
||||
"invalid: malformed filter",
|
||||
"pow: difficulty too low",
|
||||
|
||||
@@ -19,9 +19,13 @@ export function classifyRelayClosed(message: string): RelayClosedClass {
|
||||
if (normalized.startsWith("rate-limited:")) {
|
||||
return "rate-limited";
|
||||
}
|
||||
// `auth-required:` is deliberately retryable, NOT terminal: it occurs
|
||||
// transiently when a REQ races the AUTH handshake after a reconnect. The
|
||||
// backoff retry re-sends the REQ once the session is authenticated. A
|
||||
// session that is genuinely unauthenticated latches `terminal` at the
|
||||
// connection level (AUTH OK=false), so this cannot loop forever.
|
||||
if (
|
||||
normalized.startsWith("restricted:") ||
|
||||
normalized.startsWith("auth-required:") ||
|
||||
normalized.startsWith("blocked:") ||
|
||||
normalized.startsWith("invalid:") ||
|
||||
normalized.startsWith("pow:") ||
|
||||
|
||||
@@ -70,3 +70,13 @@ export function isServiceRestartClose(message: unknown): boolean {
|
||||
data.code === 1012
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a WS-layer message is the plugin's `Error` frame. */
|
||||
export function isWebSocketError(message: unknown): boolean {
|
||||
return (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"type" in message &&
|
||||
message.type === "Error"
|
||||
);
|
||||
}
|
||||
|
||||
70
desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs
Normal file
70
desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
RESUME_TRIGGER_MIN_INTERVAL_MS,
|
||||
shouldTriggerResumeReconnect,
|
||||
} from "./relayResumeTriggerPolicy.ts";
|
||||
|
||||
const base = Object.freeze({
|
||||
connectionState: "reconnecting",
|
||||
lastAttemptAt: 0,
|
||||
now: RESUME_TRIGGER_MIN_INTERVAL_MS + 1,
|
||||
});
|
||||
|
||||
test("reconnecting + interval elapsed triggers", () => {
|
||||
assert.equal(shouldTriggerResumeReconnect({ ...base }), true);
|
||||
});
|
||||
|
||||
test("stalled + interval elapsed triggers", () => {
|
||||
assert.equal(
|
||||
shouldTriggerResumeReconnect({ ...base, connectionState: "stalled" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("healthy and terminal states never trigger", () => {
|
||||
for (const connectionState of [
|
||||
"idle",
|
||||
"connecting",
|
||||
"connected",
|
||||
"disconnected",
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldTriggerResumeReconnect({ ...base, connectionState }),
|
||||
false,
|
||||
connectionState,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("burst of triggers within the rate window fires once", () => {
|
||||
assert.equal(
|
||||
shouldTriggerResumeReconnect({
|
||||
...base,
|
||||
lastAttemptAt: 1_000,
|
||||
now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS - 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldTriggerResumeReconnect({
|
||||
...base,
|
||||
lastAttemptAt: 1_000,
|
||||
now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("custom interval override is honoured", () => {
|
||||
assert.equal(
|
||||
shouldTriggerResumeReconnect({
|
||||
...base,
|
||||
lastAttemptAt: 0,
|
||||
now: 10,
|
||||
minIntervalMs: 5,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
39
desktop/src/shared/api/relayResumeTriggerPolicy.ts
Normal file
39
desktop/src/shared/api/relayResumeTriggerPolicy.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Policy for event-driven reconnect triggers (G1 of the CMD+R gap audit).
|
||||
*
|
||||
* The exponential-backoff timer is the only thing driving recovery after an
|
||||
* outage — and WKWebView throttles JS timers in occluded/background windows,
|
||||
* so at max backoff (30s) a scheduled attempt may not fire until the user
|
||||
* focuses the window. These triggers short-circuit the wait the moment the
|
||||
* environment signals recovery: network `online`, window focus, and
|
||||
* visibility becoming visible. `preconnect()` already clears the pending
|
||||
* backoff timer, so a trigger converts "wait up to 30s (or forever, if
|
||||
* throttled)" into "reconnect now".
|
||||
*/
|
||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||
|
||||
/** Min ms between trigger-driven preconnect attempts. */
|
||||
export const RESUME_TRIGGER_MIN_INTERVAL_MS = 5_000;
|
||||
|
||||
export function shouldTriggerResumeReconnect(inputs: {
|
||||
connectionState: ConnectionState;
|
||||
lastAttemptAt: number;
|
||||
now: number;
|
||||
minIntervalMs?: number;
|
||||
}): boolean {
|
||||
const minInterval = inputs.minIntervalMs ?? RESUME_TRIGGER_MIN_INTERVAL_MS;
|
||||
|
||||
// Only degraded-but-recoverable states. `disconnected` is the terminal
|
||||
// latch — explicit user re-engagement owns that path, and `idle` /
|
||||
// `connecting` / `connected` need no help.
|
||||
if (
|
||||
inputs.connectionState !== "reconnecting" &&
|
||||
inputs.connectionState !== "stalled"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rate-limit: focus/online events arrive in bursts (e.g. wake fires all
|
||||
// three); one attempt per window is enough.
|
||||
return inputs.now - inputs.lastAttemptAt >= minInterval;
|
||||
}
|
||||
34
desktop/src/shared/api/restartDiff.ts
Normal file
34
desktop/src/shared/api/restartDiff.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Wire types for the restart-required config diff.
|
||||
*
|
||||
* These are camelCase / TS-idiomatic mirrors of the Rust `RestartDiffEntry`
|
||||
* serialized shape. The `field` is a dotted path derived from serde field
|
||||
* names (e.g. `"model"`, `"env.FOO"`); display labels are produced by
|
||||
* humanising the path — there is no per-field label map.
|
||||
*/
|
||||
|
||||
/** A JSON-compatible value. Arrays are atomic leaves in the diff. */
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* One change in a restart diff. The `kind` discriminant is a closed set of
|
||||
* five values; unknown `field` paths must render gracefully in the UI.
|
||||
*/
|
||||
export type RestartChange =
|
||||
| { kind: "value"; before: JsonValue; after: JsonValue }
|
||||
| { kind: "text"; before_chars: number | null; after_chars: number | null }
|
||||
| { kind: "masked"; before: string | null; after: string | null }
|
||||
| { kind: "added" }
|
||||
| { kind: "removed" };
|
||||
|
||||
export type RestartDiffEntry = {
|
||||
/** Dotted path derived from serde field names, e.g. "model", "env.FOO". */
|
||||
field: string;
|
||||
change: RestartChange;
|
||||
};
|
||||
@@ -118,6 +118,8 @@ type RawRelayAgent = {
|
||||
respond_to?: RelayAgent["respondTo"];
|
||||
respond_to_allowlist?: string[];
|
||||
};
|
||||
|
||||
import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff";
|
||||
export type RawManagedAgent = {
|
||||
pubkey: string;
|
||||
name: string;
|
||||
@@ -143,6 +145,7 @@ export type RawManagedAgent = {
|
||||
persona_out_of_date: boolean;
|
||||
persona_orphaned: boolean;
|
||||
needs_restart: boolean;
|
||||
restart_diff?: RawRestartDiffEntry[];
|
||||
env_vars?: Record<string, string>;
|
||||
status: ManagedAgent["status"];
|
||||
pid: number | null;
|
||||
@@ -158,8 +161,7 @@ export type RawManagedAgent = {
|
||||
auto_restart_on_config_change?: boolean;
|
||||
backend: ManagedAgentBackend;
|
||||
backend_agent_id: string | null;
|
||||
// Optional: pre-feature mock fixtures may omit these. Mapped to
|
||||
// `"owner-only"` / `[]` in `fromRawManagedAgent`.
|
||||
// Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent.
|
||||
respond_to?: ManagedAgent["respondTo"];
|
||||
respond_to_allowlist?: string[];
|
||||
};
|
||||
@@ -596,11 +598,11 @@ export async function uploadMedia(
|
||||
isTemp,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pickAndUploadMedia(): Promise<BlobDescriptor[]> {
|
||||
return invokeTauri<BlobDescriptor[]>("pick_and_upload_media", {});
|
||||
export async function pickAndUploadMedia(
|
||||
progressId?: string,
|
||||
): Promise<BlobDescriptor[]> {
|
||||
return invokeTauri<BlobDescriptor[]>("pick_and_upload_media", { progressId });
|
||||
}
|
||||
|
||||
export async function uploadMediaBytes(
|
||||
data: number[],
|
||||
filename?: string,
|
||||
@@ -707,11 +709,11 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
|
||||
avatarUrl: agent.avatar_url ?? null,
|
||||
model: agent.model,
|
||||
modelSource: agent.model_source ?? null,
|
||||
// Fallbacks for pre-feature mocks/fixtures. Real records always carry them.
|
||||
provider: agent.provider ?? null,
|
||||
personaOutOfDate: agent.persona_out_of_date ?? false,
|
||||
personaOrphaned: agent.persona_orphaned ?? false,
|
||||
needsRestart: agent.needs_restart ?? false,
|
||||
restartDiff: agent.restart_diff ?? [],
|
||||
envVars: agent.env_vars ?? {},
|
||||
status: agent.status,
|
||||
pid: agent.pid,
|
||||
@@ -727,8 +729,6 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
|
||||
autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true,
|
||||
backend: agent.backend,
|
||||
backendAgentId: agent.backend_agent_id,
|
||||
// Fallbacks for pre-feature mocks/fixtures that don't carry these fields.
|
||||
// Real agent records always include them (defaulted server-side).
|
||||
respondTo: agent.respond_to ?? "owner-only",
|
||||
respondToAllowlist: agent.respond_to_allowlist ?? [],
|
||||
};
|
||||
|
||||
@@ -88,23 +88,22 @@ function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription {
|
||||
// ── API wrappers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns `true` when observer-feed archive policy is enforced.
|
||||
* Returns `true` when observer-feed archive is enabled by default.
|
||||
*
|
||||
* Internal builds set `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` at build time;
|
||||
* OSS builds never set it, so this returns `false`. The frontend calls this
|
||||
* every startup to decide whether to reconcile the `owner_p` subscription.
|
||||
* Always returns `true` — archive defaults to enabled for all builds.
|
||||
* The frontend calls this every startup to decide whether to reconcile
|
||||
* the `owner_p` subscription for kind 24200 (observer frames).
|
||||
*/
|
||||
export async function observerArchiveDefaultEnabled(): Promise<boolean> {
|
||||
return invokeTauri<boolean>("observer_archive_default_enabled");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when the build has agent-turn-metric archive default-on.
|
||||
* Returns `true` when agent-turn-metric archive is enabled by default.
|
||||
*
|
||||
* Internal builds set `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` at build time;
|
||||
* OSS builds never set it, so this returns `false`. The frontend calls this
|
||||
* once at startup to decide whether to auto-seed an `owner_p` [44200]
|
||||
* subscription.
|
||||
* Always returns `true` — archive defaults to enabled for all builds.
|
||||
* The frontend calls this once at startup to decide whether to auto-seed
|
||||
* an `owner_p` [44200] subscription for new identities.
|
||||
*/
|
||||
export async function agentMetricArchiveDefaultEnabled(): Promise<boolean> {
|
||||
return invokeTauri<boolean>("agent_metric_archive_default_enabled");
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
import { invoke as invokeTauriRaw } from "@tauri-apps/api/core";
|
||||
import { type BlobDescriptor, invokeTauri } from "./tauri";
|
||||
|
||||
function encodeRawIpcHeader(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return window
|
||||
.btoa(binary)
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/** Transfer a browser File to Rust as a raw IPC body, avoiding JSON expansion. */
|
||||
export async function uploadMediaFile(
|
||||
file: File,
|
||||
progressId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BlobDescriptor> {
|
||||
const headers: Record<string, string> = {
|
||||
"x-buzz-filename": encodeRawIpcHeader(file.name),
|
||||
};
|
||||
if (progressId) {
|
||||
headers["x-buzz-progress-id"] = encodeRawIpcHeader(progressId);
|
||||
}
|
||||
|
||||
if (signal?.aborted) throw new Error("upload cancelled");
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (signal?.aborted) throw new Error("upload cancelled");
|
||||
|
||||
return invokeTauriRaw<BlobDescriptor>("upload_media_bytes_raw", bytes, {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop the native HTTP request associated with a background media upload. */
|
||||
export async function cancelMediaUpload(progressId: string): Promise<void> {
|
||||
await invokeTauri("cancel_media_upload", { progressId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a native single-file picker constrained to images and upload the
|
||||
* chosen file. Non-image files are rejected in Rust (via MIME sniffing)
|
||||
|
||||
@@ -304,6 +304,8 @@ export type ManagedAgentBackend =
|
||||
| { type: "local" }
|
||||
| { type: "provider"; id: string; config: Record<string, unknown> };
|
||||
|
||||
import type { RestartDiffEntry } from "./restartDiff";
|
||||
export type { JsonValue, RestartChange, RestartDiffEntry } from "./restartDiff";
|
||||
export type ManagedAgent = {
|
||||
pubkey: string;
|
||||
name: string;
|
||||
@@ -357,6 +359,8 @@ export type ManagedAgent = {
|
||||
* Always `false` for stopped agents.
|
||||
*/
|
||||
needsRestart: boolean;
|
||||
/** Non-empty iff `needsRestart` is true. Empty when Rust omits the field. */
|
||||
restartDiff: RestartDiffEntry[];
|
||||
/** Per-agent env vars. Layered on top of persona envVars. */
|
||||
envVars: Record<string, string>;
|
||||
status: "running" | "stopped" | "deployed" | "not_deployed";
|
||||
@@ -382,11 +386,7 @@ export type ManagedAgent = {
|
||||
respondToAllowlist: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Inbound author gate mode. Mirrors `buzz-acp`'s `--respond-to` CLI flag.
|
||||
* `"nobody"` is supported by the harness but not surfaced through this API —
|
||||
* it's a heartbeat-only mode without a meaningful GUI use case.
|
||||
*/
|
||||
/** Inbound author gate mode. Mirrors buzz-acp's --respond-to CLI flag. */
|
||||
export type RespondToMode = "owner-only" | "allowlist" | "anyone";
|
||||
|
||||
export type BackendProviderCandidate = {
|
||||
|
||||
@@ -2,12 +2,12 @@ import * as React from "react";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
isRelayConnectionDegraded,
|
||||
useRelayConnection,
|
||||
} from "@/shared/api/useRelayConnection";
|
||||
type ConnectionState,
|
||||
} from "@/shared/api/relayClientShared";
|
||||
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
|
||||
import {
|
||||
isRateLimited,
|
||||
waitForRateLimit,
|
||||
@@ -100,8 +100,6 @@ export class RelayAutoHealScheduler {
|
||||
*/
|
||||
export function useRelayAutoHeal(): void {
|
||||
const queryClient = useQueryClient();
|
||||
const connectionState = useRelayConnection();
|
||||
const prevConnectionStateRef = React.useRef(connectionState);
|
||||
const schedulerRef = React.useRef<RelayAutoHealScheduler | null>(null);
|
||||
|
||||
if (schedulerRef.current === null) {
|
||||
@@ -129,14 +127,22 @@ export function useRelayAutoHeal(): void {
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
// Observe the RAW connection-state emitter, not the 2s-debounced
|
||||
// useRelayConnection() hook. A sub-2s flap never surfaces through the
|
||||
// debounced hook (its degraded report is cancelled by the recovery), yet
|
||||
// resetConnection() already rejected every in-flight query — the heal
|
||||
// must still fire or errored panes persist until a manual reconnect.
|
||||
let prev: ConnectionState | null = null;
|
||||
const unsubscribe = relayClient.subscribeToConnectionState((next) => {
|
||||
if (prev !== null) {
|
||||
schedulerRef.current?.onTransition(prev, next);
|
||||
}
|
||||
prev = next;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
schedulerRef.current?.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const prev = prevConnectionStateRef.current;
|
||||
prevConnectionStateRef.current = connectionState;
|
||||
schedulerRef.current?.onTransition(prev, connectionState);
|
||||
}, [connectionState]);
|
||||
}
|
||||
|
||||
60
desktop/src/shared/api/useRelayResumeTriggers.ts
Normal file
60
desktop/src/shared/api/useRelayResumeTriggers.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { shouldTriggerResumeReconnect } from "@/shared/api/relayResumeTriggerPolicy";
|
||||
|
||||
/**
|
||||
* Event-driven reconnect triggers: network `online`, window `focus`, and
|
||||
* visibility→visible each attempt an immediate `resumeReconnect()` when the
|
||||
* relay session is degraded (reconnecting/stalled), rate-limited by
|
||||
* `RESUME_TRIGGER_MIN_INTERVAL_MS`.
|
||||
*
|
||||
* Rationale (CMD+R gap audit G1): without these, recovery rides solely on
|
||||
* the backoff timer, which WKWebView throttles while the window is occluded
|
||||
* or the system was asleep. The moment a user focuses the window to hit
|
||||
* CMD+R *is* a focus event — this fires the reconnect first.
|
||||
*
|
||||
* Uses `resumeReconnect()`, NOT `preconnect()`: resume events bypass the
|
||||
* pending backoff timer but must preserve the terminal latch and the AUTH
|
||||
* rejection streak. Otherwise a focus/online event arriving during repeated
|
||||
* AUTH rejection would reset the consecutive-rejection cap and the session
|
||||
* could retry indefinitely instead of surfacing `disconnected`. The
|
||||
* terminal state stays user-owned via the reconnect card.
|
||||
*/
|
||||
export function useRelayResumeTriggers(): void {
|
||||
React.useEffect(() => {
|
||||
let lastAttemptAt = -Infinity;
|
||||
|
||||
const attempt = () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!shouldTriggerResumeReconnect({
|
||||
connectionState: relayClient.getConnectionState(),
|
||||
lastAttemptAt,
|
||||
now,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastAttemptAt = now;
|
||||
// resumeReconnect() clears any pending backoff timer and connects now,
|
||||
// preserving terminal/AUTH-streak state. Failures re-arm the normal
|
||||
// backoff loop; nothing to handle here.
|
||||
void relayClient.resumeReconnect().catch(() => {});
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") attempt();
|
||||
};
|
||||
|
||||
window.addEventListener("online", attempt);
|
||||
window.addEventListener("focus", attempt);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", attempt);
|
||||
window.removeEventListener("focus", attempt);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -58,6 +58,8 @@ export const KIND_AGENT_TURN_METRIC = 44200;
|
||||
export const KIND_EVENT_REMINDER = 30300;
|
||||
export const KIND_REPO_ANNOUNCEMENT = 30617;
|
||||
export const KIND_REPO_STATE = 30618;
|
||||
// NIP-MP: project grouping above NIP-34 repositories.
|
||||
export const KIND_PROJECT_ANNOUNCEMENT = 30621;
|
||||
export const KIND_GIT_PATCH = 1617;
|
||||
export const KIND_GIT_PULL_REQUEST = 1618;
|
||||
export const KIND_GIT_PR_UPDATE = 1619;
|
||||
|
||||
@@ -53,6 +53,9 @@ export const channelChrome = {
|
||||
contentPadding: "pt-(--buzz-channel-content-top-padding,5.75rem)",
|
||||
/** Absolute/fixed top offset below the measured channel header chrome. */
|
||||
top: "top-(--buzz-channel-content-top-padding,5.75rem)",
|
||||
/** Sticky timeline controls sit slightly below the channel navigation. */
|
||||
stickyTimelineTop:
|
||||
"top-[calc(var(--buzz-channel-content-top-padding,5.75rem)+0.5rem)]",
|
||||
/** Height matching the measured channel header chrome. */
|
||||
headerHeight: "h-(--buzz-channel-content-top-padding,5.75rem)",
|
||||
/** Negative margin for overlaid channel chrome that should not affect flow. */
|
||||
|
||||
135
desktop/src/shared/lib/entityLink.test.mjs
Normal file
135
desktop/src/shared/lib/entityLink.test.mjs
Normal file
@@ -0,0 +1,135 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildIssueLink,
|
||||
buildPullRequestLink,
|
||||
buildRepoLink,
|
||||
entityLinkProjectRouteId,
|
||||
isEntityLink,
|
||||
parseEntityLink,
|
||||
} from "./entityLink.ts";
|
||||
|
||||
const OWNER =
|
||||
"71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
|
||||
const EVENT_ID =
|
||||
"c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";
|
||||
|
||||
// Golden format strings — must match the Rust builder in
|
||||
// crates/buzz-cli/src/links.rs (`golden_format_matches_desktop` test).
|
||||
test("builders emit the canonical cross-language link format", () => {
|
||||
assert.equal(
|
||||
buildPullRequestLink({ id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }),
|
||||
`buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`,
|
||||
);
|
||||
assert.equal(
|
||||
buildIssueLink({ id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }),
|
||||
`buzz://issue?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`,
|
||||
);
|
||||
assert.equal(
|
||||
buildRepoLink({ owner: OWNER, dtag: "buzz-world" }),
|
||||
`buzz://repo?owner=${OWNER}&d=buzz-world`,
|
||||
);
|
||||
});
|
||||
|
||||
test("builders reject invalid identifiers", () => {
|
||||
assert.throws(() =>
|
||||
buildRepoLink({ owner: "not-a-pubkey", dtag: "buzz-world" }),
|
||||
);
|
||||
assert.throws(() => buildRepoLink({ owner: OWNER, dtag: ".hidden" }));
|
||||
assert.throws(() => buildRepoLink({ owner: OWNER, dtag: "a..b" }));
|
||||
assert.throws(() =>
|
||||
buildPullRequestLink({ id: "short", owner: OWNER, dtag: "buzz-world" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("parseEntityLink round-trips built links", () => {
|
||||
const link = buildPullRequestLink({
|
||||
id: EVENT_ID,
|
||||
owner: OWNER,
|
||||
dtag: "buzz-world",
|
||||
});
|
||||
assert.deepEqual(parseEntityLink(link), {
|
||||
ok: true,
|
||||
value: { type: "pr", id: EVENT_ID, owner: OWNER, dtag: "buzz-world" },
|
||||
});
|
||||
|
||||
const repoLink = buildRepoLink({ owner: OWNER, dtag: "buzz-world" });
|
||||
assert.deepEqual(parseEntityLink(repoLink), {
|
||||
ok: true,
|
||||
value: { type: "repo", owner: OWNER, dtag: "buzz-world" },
|
||||
});
|
||||
});
|
||||
|
||||
test("parseEntityLink lowercase-normalizes hex identifiers", () => {
|
||||
const parsed = parseEntityLink(
|
||||
`buzz://issue?id=${EVENT_ID.toUpperCase()}&owner=${OWNER.toUpperCase()}&d=buzz-world`,
|
||||
);
|
||||
assert.deepEqual(parsed, {
|
||||
ok: true,
|
||||
value: { type: "issue", id: EVENT_ID, owner: OWNER, dtag: "buzz-world" },
|
||||
});
|
||||
});
|
||||
|
||||
test("parseEntityLink rejects malformed links", () => {
|
||||
const cases = [
|
||||
["not a url at all", "invalid-url"],
|
||||
[`https://pr?id=${EVENT_ID}&owner=${OWNER}&d=repo`, "wrong-scheme"],
|
||||
[`buzz://message?channel=x&id=${EVENT_ID}`, "wrong-host"],
|
||||
[`buzz://pr?id=${EVENT_ID}&owner=nope&d=repo`, "invalid-owner"],
|
||||
[`buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=.hidden`, "invalid-dtag"],
|
||||
[`buzz://pr?id=${EVENT_ID}&owner=${OWNER}`, "invalid-dtag"],
|
||||
[`buzz://pr?owner=${OWNER}&d=repo`, "invalid-id"],
|
||||
[`buzz://issue?id=short&owner=${OWNER}&d=repo`, "invalid-id"],
|
||||
];
|
||||
for (const [href, reason] of cases) {
|
||||
assert.deepEqual(parseEntityLink(href), { ok: false, reason }, href);
|
||||
}
|
||||
});
|
||||
|
||||
test("isEntityLink matches entity hosts and excludes message links", () => {
|
||||
assert.equal(isEntityLink(`buzz://pr?id=${EVENT_ID}`), true);
|
||||
assert.equal(isEntityLink(`buzz://issue?id=${EVENT_ID}`), true);
|
||||
assert.equal(isEntityLink(`buzz://repo?owner=${OWNER}`), true);
|
||||
assert.equal(isEntityLink("buzz://message?channel=x&id=y"), false);
|
||||
assert.equal(isEntityLink("https://github.com/block/buzz"), false);
|
||||
assert.equal(isEntityLink(null), false);
|
||||
});
|
||||
|
||||
test("entityLinkProjectRouteId emits the canonical 30617 coordinate route id", () => {
|
||||
const parsed = parseEntityLink(
|
||||
buildRepoLink({ owner: OWNER, dtag: "buzz-world" }),
|
||||
);
|
||||
assert.ok(parsed.ok);
|
||||
assert.equal(
|
||||
entityLinkProjectRouteId(parsed.value),
|
||||
`30617:${OWNER}:buzz-world`,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseEntityLink rejects noncanonical extras", () => {
|
||||
// Unexpected path segments — reserved for future versioning.
|
||||
assert.deepEqual(
|
||||
parseEntityLink(
|
||||
`buzz://pr/ignored?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`,
|
||||
),
|
||||
{ ok: false, reason: "unexpected-path" },
|
||||
);
|
||||
// Fragment — not part of the canonical format.
|
||||
assert.deepEqual(
|
||||
parseEntityLink(`buzz://repo?owner=${OWNER}&d=buzz-world#section`),
|
||||
{ ok: false, reason: "unexpected-fragment" },
|
||||
);
|
||||
// Unknown query parameter — reject to preserve forward-compat posture.
|
||||
assert.deepEqual(
|
||||
parseEntityLink(
|
||||
`buzz://repo?owner=${OWNER}&d=buzz-world&relay=wss%3A%2F%2Frelay.example`,
|
||||
),
|
||||
{ ok: false, reason: "unknown-param" },
|
||||
);
|
||||
// Duplicate required parameter — reject.
|
||||
assert.deepEqual(
|
||||
parseEntityLink(`buzz://repo?owner=${OWNER}&d=buzz-world&owner=${OWNER}`),
|
||||
{ ok: false, reason: "duplicate-param" },
|
||||
);
|
||||
});
|
||||
194
desktop/src/shared/lib/entityLink.ts
Normal file
194
desktop/src/shared/lib/entityLink.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* `buzz://` deep links for Buzz-hosted git entities, mirroring
|
||||
* `features/messages/lib/messageLink.ts` for `buzz://message`.
|
||||
*
|
||||
* Formats:
|
||||
* buzz://repo?owner=<owner-pubkey>&d=<repo-dtag>
|
||||
* buzz://pr?id=<event-id>&owner=<owner-pubkey>&d=<repo-dtag>
|
||||
* buzz://issue?id=<event-id>&owner=<owner-pubkey>&d=<repo-dtag>
|
||||
*
|
||||
* `owner` + `d` identify the NIP-34 repository coordinate
|
||||
* (`30617:<owner>:<d>`); `id` is the kind 1618 / 1621 event id. The CLI
|
||||
* builder in `crates/buzz-cli/src/links.rs` emits the same format — the two
|
||||
* must stay compatible (see the golden-format tests on both sides).
|
||||
*/
|
||||
|
||||
const ENTITY_LINK_SCHEME = "buzz:";
|
||||
|
||||
export type ParsedEntityLink =
|
||||
| { type: "pr"; id: string; owner: string; dtag: string }
|
||||
| { type: "issue"; id: string; owner: string; dtag: string }
|
||||
| { type: "repo"; owner: string; dtag: string };
|
||||
|
||||
export type EntityLinkParseResult =
|
||||
| { ok: true; value: ParsedEntityLink }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
const HEX64_RE = /^[a-fA-F0-9]{64}$/;
|
||||
const DTAG_RE = /^[a-zA-Z0-9._-]{1,64}$/;
|
||||
|
||||
function isValidDtag(dtag: string): boolean {
|
||||
return DTAG_RE.test(dtag) && !dtag.startsWith(".") && !dtag.includes("..");
|
||||
}
|
||||
|
||||
function checkCoordinate(owner: string, dtag: string): void {
|
||||
if (!HEX64_RE.test(owner)) {
|
||||
throw new Error("entityLink: owner must be a 64-char hex pubkey");
|
||||
}
|
||||
if (!isValidDtag(dtag)) {
|
||||
throw new Error("entityLink: invalid repository d-tag");
|
||||
}
|
||||
}
|
||||
|
||||
function checkEventId(id: string): void {
|
||||
if (!HEX64_RE.test(id)) {
|
||||
throw new Error("entityLink: id must be a 64-char hex event id");
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a `buzz://repo` link for a repository announcement (kind 30617). */
|
||||
export function buildRepoLink(input: { owner: string; dtag: string }): string {
|
||||
checkCoordinate(input.owner, input.dtag);
|
||||
return `buzz://repo?owner=${input.owner.toLowerCase()}&d=${input.dtag}`;
|
||||
}
|
||||
|
||||
/** Build a `buzz://pr` link for a pull request event (kind 1618). */
|
||||
export function buildPullRequestLink(input: {
|
||||
id: string;
|
||||
owner: string;
|
||||
dtag: string;
|
||||
}): string {
|
||||
checkEventId(input.id);
|
||||
checkCoordinate(input.owner, input.dtag);
|
||||
return `buzz://pr?id=${input.id.toLowerCase()}&owner=${input.owner.toLowerCase()}&d=${input.dtag}`;
|
||||
}
|
||||
|
||||
/** Build a `buzz://issue` link for an issue event (kind 1621). */
|
||||
export function buildIssueLink(input: {
|
||||
id: string;
|
||||
owner: string;
|
||||
dtag: string;
|
||||
}): string {
|
||||
checkEventId(input.id);
|
||||
checkCoordinate(input.owner, input.dtag);
|
||||
return `buzz://issue?id=${input.id.toLowerCase()}&owner=${input.owner.toLowerCase()}&d=${input.dtag}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap pre-check used by the markdown renderer and preview extraction
|
||||
* before parsing. `buzz://message` is intentionally excluded — it has its
|
||||
* own pill rendering path.
|
||||
*/
|
||||
export function isEntityLink(href: string | undefined | null): boolean {
|
||||
if (!href) return false;
|
||||
return (
|
||||
href.startsWith("buzz://pr?") ||
|
||||
href.startsWith("buzz://issue?") ||
|
||||
href.startsWith("buzz://repo?")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `buzz://pr|issue|repo?…` URL. Returns a discriminated result so
|
||||
* callers can fall back to plain-link rendering without throwing. All
|
||||
* identifiers are validated; hex values are lowercase-normalized.
|
||||
*
|
||||
* Strict canonical form to preserve forward-compatibility:
|
||||
* - Empty or root path only (no `/extra/segments`)
|
||||
* - No fragment
|
||||
* - Each required parameter must appear exactly once
|
||||
* - Unknown query parameters are rejected (callers that need to add
|
||||
* parameters must version the format or add them to the known-params set)
|
||||
*
|
||||
* This ensures old clients decline rather than silently misinterpret future
|
||||
* extensions (e.g. the reserved `relay=` cross-community field).
|
||||
*/
|
||||
export function parseEntityLink(url: string): EntityLinkParseResult {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return { ok: false, reason: "invalid-url" };
|
||||
}
|
||||
|
||||
if (parsed.protocol !== ENTITY_LINK_SCHEME) {
|
||||
return { ok: false, reason: "wrong-scheme" };
|
||||
}
|
||||
|
||||
const host = parsed.hostname;
|
||||
if (host !== "pr" && host !== "issue" && host !== "repo") {
|
||||
return { ok: false, reason: "wrong-host" };
|
||||
}
|
||||
|
||||
// Require empty/root path — path segments are reserved for future versioning.
|
||||
if (parsed.pathname !== "" && parsed.pathname !== "/") {
|
||||
return { ok: false, reason: "unexpected-path" };
|
||||
}
|
||||
|
||||
// Reject fragments — not part of the canonical format.
|
||||
if (parsed.hash) {
|
||||
return { ok: false, reason: "unexpected-fragment" };
|
||||
}
|
||||
|
||||
// Validate known params and reject unknown ones, and enforce single-instance.
|
||||
const KNOWN_REPO_PARAMS = new Set(["owner", "d"]);
|
||||
const KNOWN_EVENT_PARAMS = new Set(["id", "owner", "d"]);
|
||||
const knownParams = host === "repo" ? KNOWN_REPO_PARAMS : KNOWN_EVENT_PARAMS;
|
||||
|
||||
for (const key of parsed.searchParams.keys()) {
|
||||
if (!knownParams.has(key)) {
|
||||
return { ok: false, reason: "unknown-param" };
|
||||
}
|
||||
}
|
||||
for (const key of knownParams) {
|
||||
const values = parsed.searchParams.getAll(key);
|
||||
if (values.length > 1) {
|
||||
return { ok: false, reason: "duplicate-param" };
|
||||
}
|
||||
}
|
||||
|
||||
const owner = parsed.searchParams.get("owner");
|
||||
const dtag = parsed.searchParams.get("d");
|
||||
if (!owner || !HEX64_RE.test(owner)) {
|
||||
return { ok: false, reason: "invalid-owner" };
|
||||
}
|
||||
if (!dtag || !isValidDtag(dtag)) {
|
||||
return { ok: false, reason: "invalid-dtag" };
|
||||
}
|
||||
|
||||
if (host === "repo") {
|
||||
return {
|
||||
ok: true,
|
||||
value: { type: "repo", owner: owner.toLowerCase(), dtag },
|
||||
};
|
||||
}
|
||||
|
||||
const id = parsed.searchParams.get("id");
|
||||
if (!id || !HEX64_RE.test(id)) {
|
||||
return { ok: false, reason: "invalid-id" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
type: host,
|
||||
id: id.toLowerCase(),
|
||||
owner: owner.toLowerCase(),
|
||||
dtag,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical NIP-34 repository coordinate (`30617:<owner>:<dtag>`) used as the
|
||||
* route id for `/projects/$projectId`. Duncan's #4671 branch resolves 30617
|
||||
* coordinates regardless of which explicit project contains the repository, so
|
||||
* entity links remain stable when a repo's container project changes.
|
||||
*
|
||||
* Do NOT use the legacy `<owner>:<dtag>` form — it only matched implicit
|
||||
* project cards and breaks for repos claimed by an explicit project with a
|
||||
* different d-tag.
|
||||
*/
|
||||
export function entityLinkProjectRouteId(link: ParsedEntityLink): string {
|
||||
return `30617:${link.owner}:${link.dtag}`;
|
||||
}
|
||||
@@ -53,6 +53,160 @@ test("parseSupportedLinkPreview ignores unsupported GitHub URLs", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const BUZZ_OWNER =
|
||||
"71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
|
||||
|
||||
test("parseSupportedLinkPreview parses Buzz relay git clone URLs", () => {
|
||||
// Must pass the active relay origin for host validation.
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview(
|
||||
`https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`,
|
||||
"https://buzz.block.builderlab.xyz",
|
||||
),
|
||||
{
|
||||
kind: "buzz-repository",
|
||||
href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`,
|
||||
provider: "Buzz",
|
||||
title: "buzz-world-galaxy",
|
||||
typeLabel: "repo",
|
||||
},
|
||||
);
|
||||
// Same URL without a matching origin stays external.
|
||||
assert.equal(
|
||||
parseSupportedLinkPreview(
|
||||
`https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview strips .git suffix from clone URLs", () => {
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview(
|
||||
`http://localhost:3000/git/${BUZZ_OWNER}/buzz-world.git`,
|
||||
"http://localhost:3000",
|
||||
),
|
||||
{
|
||||
kind: "buzz-repository",
|
||||
href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`,
|
||||
provider: "Buzz",
|
||||
title: "buzz-world",
|
||||
typeLabel: "repo",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview rejects malformed Buzz git URLs", () => {
|
||||
for (const href of [
|
||||
// Owner segment must be a 64-char lowercase hex pubkey.
|
||||
"https://relay.example/git/not-a-pubkey/repo",
|
||||
`https://relay.example/git/${BUZZ_OWNER.toUpperCase()}/repo`,
|
||||
`https://relay.example/git/${BUZZ_OWNER.slice(0, 32)}/repo`,
|
||||
// Missing or invalid repo segment.
|
||||
`https://relay.example/git/${BUZZ_OWNER}`,
|
||||
`https://relay.example/git/${BUZZ_OWNER}/.hidden`,
|
||||
// Deeper transport paths are not repo links.
|
||||
`https://relay.example/git/${BUZZ_OWNER}/repo/info/refs`,
|
||||
]) {
|
||||
// Even with a matching origin, structural issues return null.
|
||||
assert.equal(
|
||||
parseSupportedLinkPreview(href, "https://relay.example"),
|
||||
null,
|
||||
href,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview rejects clone URLs from non-relay hosts", () => {
|
||||
// Correct path shape but origin does not match the active relay.
|
||||
assert.equal(
|
||||
parseSupportedLinkPreview(
|
||||
`https://evil.example/git/${BUZZ_OWNER}/my-repo`,
|
||||
"https://buzz.block.builderlab.xyz",
|
||||
),
|
||||
null,
|
||||
);
|
||||
// github.com sharing the path shape must never become a Buzz repo card.
|
||||
assert.equal(
|
||||
parseSupportedLinkPreview(
|
||||
`https://github.com/git/${BUZZ_OWNER}/my-repo`,
|
||||
"https://buzz.block.builderlab.xyz",
|
||||
),
|
||||
null,
|
||||
);
|
||||
// No relay origin provided — stays external.
|
||||
assert.equal(
|
||||
parseSupportedLinkPreview(
|
||||
`https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world`,
|
||||
null,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
const BUZZ_EVENT_ID =
|
||||
"c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";
|
||||
|
||||
test("parseSupportedLinkPreview parses buzz:// PR and issue deep links", () => {
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview(
|
||||
`buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`,
|
||||
),
|
||||
{
|
||||
kind: "buzz-pull-request",
|
||||
href: `buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`,
|
||||
provider: "Buzz",
|
||||
title: "buzz-world #c3b589fa",
|
||||
typeLabel: "PR",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview(
|
||||
`buzz://issue?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`,
|
||||
)?.typeLabel,
|
||||
"issue",
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview(`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`),
|
||||
{
|
||||
kind: "buzz-repository",
|
||||
href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`,
|
||||
provider: "Buzz",
|
||||
title: "buzz-world",
|
||||
typeLabel: "repo",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview rejects malformed buzz:// entity links", () => {
|
||||
for (const href of [
|
||||
`buzz://pr?owner=${BUZZ_OWNER}&d=buzz-world`,
|
||||
`buzz://pr?id=short&owner=${BUZZ_OWNER}&d=buzz-world`,
|
||||
`buzz://issue?id=${BUZZ_EVENT_ID}&owner=nope&d=buzz-world`,
|
||||
`buzz://repo?owner=${BUZZ_OWNER}&d=.hidden`,
|
||||
]) {
|
||||
assert.equal(parseSupportedLinkPreview(href), null, href);
|
||||
}
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews picks up buzz:// links in prose", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
`PR is up: buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world — review please.`,
|
||||
).map((preview) => [preview.kind, preview.title]),
|
||||
[["buzz-pull-request", "buzz-world #c3b589fa"]],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews uses markdown labels for buzz:// links", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
`[Add header links](buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world)`,
|
||||
).map((preview) => preview.title),
|
||||
["Add header links"],
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview parses Linear issue URLs", () => {
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview(
|
||||
@@ -114,6 +268,66 @@ test("extractSupportedLinkPreviews returns unique supported links in order", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews picks up bare Buzz clone URLs in prose", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
`master pushed; clone: https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy and review please.`,
|
||||
"https://buzz.block.builderlab.xyz",
|
||||
),
|
||||
[
|
||||
{
|
||||
kind: "buzz-repository",
|
||||
href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`,
|
||||
provider: "Buzz",
|
||||
title: "buzz-world-galaxy",
|
||||
typeLabel: "repo",
|
||||
},
|
||||
],
|
||||
);
|
||||
// Without a relay origin the URL is treated as an ordinary external link.
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
`clone: https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`,
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews uses markdown labels for Buzz repo links", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
`[Buzz World](https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy)`,
|
||||
"https://relay.example",
|
||||
).map((preview) => preview.title),
|
||||
["Buzz World"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews dedupes clone URL variants of one repo", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
`https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`,
|
||||
`https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy.git`,
|
||||
].join(" "),
|
||||
"https://relay.example",
|
||||
).map((preview) => preview.href),
|
||||
[`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`],
|
||||
);
|
||||
});
|
||||
|
||||
test("clone URLs and buzz://repo links for the same repo dedupe to one card", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
`https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`,
|
||||
`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`,
|
||||
].join(" "),
|
||||
).map((preview) => preview.href),
|
||||
[`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews handles markdown link serialization", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
@@ -252,3 +466,106 @@ test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => {
|
||||
);
|
||||
assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false);
|
||||
});
|
||||
|
||||
// ── useResolvedLinkPreviews: behavioral regression pins ──────────────────────
|
||||
//
|
||||
// These tests pin the three behaviors that were implemented without tests in
|
||||
// the initial fix round. They use the exported pure helpers directly so no
|
||||
// React hook environment is required.
|
||||
|
||||
import {
|
||||
getLinkPreviewCacheGeneration,
|
||||
resetLinkPreviewTitleCache,
|
||||
shouldResolveTitle,
|
||||
} from "./useResolvedLinkPreviews.ts";
|
||||
import { buzzEntityFallbackTitle } from "./linkPreview.ts";
|
||||
|
||||
const OWNER_HEX =
|
||||
"71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
|
||||
const EVENT_HEX =
|
||||
"c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";
|
||||
|
||||
function makePrPreview(title) {
|
||||
return {
|
||||
kind: "buzz-pull-request",
|
||||
href: `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`,
|
||||
title,
|
||||
provider: "Buzz",
|
||||
typeLabel: "pr",
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Cache epoch: stale promise cannot seed the new generation.
|
||||
// `resetLinkPreviewTitleCache` must increment the generation counter so that
|
||||
// a promise captured before the reset sees a different generation and skips
|
||||
// writing back.
|
||||
test("resetLinkPreviewTitleCache_incrementsGenerationCounter", () => {
|
||||
const before = getLinkPreviewCacheGeneration();
|
||||
resetLinkPreviewTitleCache();
|
||||
const after = getLinkPreviewCacheGeneration();
|
||||
assert.equal(after, before + 1, "each reset must bump the generation by 1");
|
||||
resetLinkPreviewTitleCache();
|
||||
assert.equal(
|
||||
getLinkPreviewCacheGeneration(),
|
||||
before + 2,
|
||||
"second reset must increment again",
|
||||
);
|
||||
});
|
||||
|
||||
// 2. Mismatched a-tag: shouldResolveTitle uses buzzEntityFallbackTitle to
|
||||
// decide whether to attempt a relay lookup. When the link's href parses to a
|
||||
// PR/issue with the expected fallback title, resolution should proceed. When
|
||||
// the title has already been set to something else (explicit label or earlier
|
||||
// relay result), shouldResolveTitle must return false so the label wins.
|
||||
test("shouldResolveTitle_fallbackTitle_returnsTrue", () => {
|
||||
const parsed = {
|
||||
ok: true,
|
||||
value: { type: "pr", id: EVENT_HEX, owner: OWNER_HEX, dtag: "buzz-world" },
|
||||
};
|
||||
// Construct the expected fallback title and verify shouldResolveTitle allows lookup.
|
||||
const fallback = buzzEntityFallbackTitle(parsed.value);
|
||||
const preview = makePrPreview(fallback);
|
||||
assert.equal(
|
||||
shouldResolveTitle(preview),
|
||||
true,
|
||||
"fallback title should trigger relay lookup",
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldResolveTitle_customLabel_returnsFalse_labelMustWin", () => {
|
||||
// User has written `[My custom PR title](buzz://pr?...)` — the label must
|
||||
// win; shouldResolveTitle must return false to skip writing the relay title.
|
||||
const preview = makePrPreview("My custom PR title");
|
||||
assert.equal(
|
||||
shouldResolveTitle(preview),
|
||||
false,
|
||||
"custom label must suppress relay title lookup (label-must-win invariant)",
|
||||
);
|
||||
});
|
||||
|
||||
// 3. Label-rerender: converting a bare link to `[label](link)` changes the
|
||||
// preview title away from the fallback — shouldResolveTitle transitions
|
||||
// from true to false, so a cached relay title is not applied.
|
||||
test("shouldResolveTitle_transitionsFromTrueToFalseWhenLabelApplied", () => {
|
||||
const parsed = {
|
||||
ok: true,
|
||||
value: { type: "pr", id: EVENT_HEX, owner: OWNER_HEX, dtag: "buzz-world" },
|
||||
};
|
||||
const fallback = buzzEntityFallbackTitle(parsed.value);
|
||||
|
||||
// Before the label: bare link with fallback title — should resolve.
|
||||
const barePreview = makePrPreview(fallback);
|
||||
assert.equal(
|
||||
shouldResolveTitle(barePreview),
|
||||
true,
|
||||
"bare link should resolve",
|
||||
);
|
||||
|
||||
// After the label: same href but title is now the user's label — must NOT resolve.
|
||||
const labeledPreview = makePrPreview("My labeled PR");
|
||||
assert.equal(
|
||||
shouldResolveTitle(labeledPreview),
|
||||
false,
|
||||
"labeled link must not overwrite label with cached relay title",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import {
|
||||
buildIssueLink,
|
||||
buildPullRequestLink,
|
||||
buildRepoLink,
|
||||
isEntityLink,
|
||||
parseEntityLink,
|
||||
type ParsedEntityLink,
|
||||
} from "./entityLink";
|
||||
|
||||
export type SupportedLinkPreviewKind =
|
||||
| "buzz-pull-request"
|
||||
| "buzz-issue"
|
||||
| "buzz-repository"
|
||||
| "github-pull-request"
|
||||
| "github-issue"
|
||||
| "github-repository"
|
||||
@@ -13,6 +25,7 @@ export type SupportedLinkPreview = {
|
||||
kind: SupportedLinkPreviewKind;
|
||||
href: string;
|
||||
provider:
|
||||
| "Buzz"
|
||||
| "GitHub"
|
||||
| "Linear"
|
||||
| "Google Drive"
|
||||
@@ -31,10 +44,13 @@ export type SupportedLinkPreview = {
|
||||
| "presentation";
|
||||
};
|
||||
|
||||
// Buzz relay hosts differ per community, so relay git URLs are recognized by
|
||||
// their distinctive path shape (`/git/<64-hex-pubkey>/<repo>`) rather than by
|
||||
// hostname, and require an explicit scheme.
|
||||
const SUPPORTED_URL_RE =
|
||||
/(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi;
|
||||
/(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo)\?[^\s<>"'\]]+)/gi;
|
||||
const MARKDOWN_SUPPORTED_LINK_RE =
|
||||
/!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi;
|
||||
/!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo)\?[^)\s<>"']+)\)/gi;
|
||||
const MAX_PREVIEWS = 8;
|
||||
|
||||
type HiddenRange = {
|
||||
@@ -266,6 +282,97 @@ function createPreview(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder title shown before (or instead of) the relay event lookup in
|
||||
* `useResolvedLinkPreviews` resolves the real `subject` / repo name.
|
||||
* Exported so the resolver can tell "still the fallback" apart from a
|
||||
* markdown-label override it must not overwrite.
|
||||
*/
|
||||
export function buzzEntityFallbackTitle(link: ParsedEntityLink): string {
|
||||
if (link.type === "repo") return link.dtag;
|
||||
return `${link.dtag} #${link.id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a `buzz://pr|issue|repo` deep link onto a preview card. The href is
|
||||
* rebuilt through the canonical builders so equivalent links (case or query
|
||||
* order variants) dedupe to a single card.
|
||||
*/
|
||||
function parseBuzzEntityPreview(href: string): SupportedLinkPreview | null {
|
||||
const parsed = parseEntityLink(href);
|
||||
if (!parsed.ok) return null;
|
||||
|
||||
const link = parsed.value;
|
||||
const title = buzzEntityFallbackTitle(link);
|
||||
if (link.type === "pr") {
|
||||
return {
|
||||
kind: "buzz-pull-request",
|
||||
href: buildPullRequestLink(link),
|
||||
provider: "Buzz",
|
||||
title,
|
||||
typeLabel: "PR",
|
||||
};
|
||||
}
|
||||
if (link.type === "issue") {
|
||||
return {
|
||||
kind: "buzz-issue",
|
||||
href: buildIssueLink(link),
|
||||
provider: "Buzz",
|
||||
title,
|
||||
typeLabel: "issue",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "buzz-repository",
|
||||
href: buildRepoLink(link),
|
||||
provider: "Buzz",
|
||||
title,
|
||||
typeLabel: "repo",
|
||||
};
|
||||
}
|
||||
|
||||
const BUZZ_GIT_PATH_RE =
|
||||
/^\/git\/([a-f0-9]{64})\/([a-zA-Z0-9._-]+?)(?:\.git)?\/?$/;
|
||||
|
||||
/**
|
||||
* Recognize a Buzz relay git URL (`{relay-origin}/git/<owner-pubkey>/<repo>`,
|
||||
* the clone URL shape agents paste when announcing work). The preview href
|
||||
* is normalized to the canonical `buzz://repo` deep link: the raw git
|
||||
* transport endpoint is not a browsable page, and the buzz:// href gives the
|
||||
* card the same in-app click navigation as explicit entity links (and
|
||||
* dedupes the two spellings of the same repository).
|
||||
*
|
||||
* Security: the URL origin must equal `activeRelayOrigin` (the currently
|
||||
* connected relay). Path shape alone is not proof that a host belongs to the
|
||||
* active Buzz relay — an arbitrary external URL sharing the path shape must
|
||||
* remain an ordinary external link. Pass `null` when the relay origin is not
|
||||
* yet resolved; the link stays external until it can be verified.
|
||||
*/
|
||||
function parseBuzzGitLink(
|
||||
parsed: URL,
|
||||
activeRelayOrigin: string | null,
|
||||
): SupportedLinkPreview | null {
|
||||
if (!activeRelayOrigin || parsed.origin !== activeRelayOrigin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = BUZZ_GIT_PATH_RE.exec(parsed.pathname);
|
||||
if (!match) return null;
|
||||
|
||||
const [, owner, repo] = match;
|
||||
if (repo.startsWith(".") || repo.includes("..") || repo.length > 64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "buzz-repository",
|
||||
href: buildRepoLink({ owner, dtag: repo }),
|
||||
provider: "Buzz",
|
||||
title: repo,
|
||||
typeLabel: "repo",
|
||||
};
|
||||
}
|
||||
|
||||
function parseGithubLink(parsed: URL): SupportedLinkPreview | null {
|
||||
if (normalizeHostname(parsed) !== "github.com") {
|
||||
return null;
|
||||
@@ -416,10 +523,15 @@ function parseGoogleDocsLink(parsed: URL): SupportedLinkPreview | null {
|
||||
/** Parse a supported external URL into a compact preview. */
|
||||
export function parseSupportedLinkPreview(
|
||||
href: string,
|
||||
activeRelayOrigin?: string | null,
|
||||
): SupportedLinkPreview | null {
|
||||
const candidate = trimUrlCandidate(href);
|
||||
if (isEntityLink(candidate)) {
|
||||
return parseBuzzEntityPreview(candidate);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
const candidate = trimUrlCandidate(href);
|
||||
parsed = new URL(
|
||||
/^https?:\/\//i.test(candidate) ? candidate : `https://${candidate}`,
|
||||
);
|
||||
@@ -432,6 +544,7 @@ export function parseSupportedLinkPreview(
|
||||
}
|
||||
|
||||
return (
|
||||
parseBuzzGitLink(parsed, activeRelayOrigin ?? null) ??
|
||||
parseGithubLink(parsed) ??
|
||||
parseLinearIssue(parsed) ??
|
||||
parseGoogleDriveLink(parsed) ??
|
||||
@@ -442,16 +555,23 @@ export function parseSupportedLinkPreview(
|
||||
export function isSupportedLinkAutolinkLabel(
|
||||
label: string,
|
||||
preview: SupportedLinkPreview,
|
||||
activeRelayOrigin?: string | null,
|
||||
): boolean {
|
||||
return parseSupportedLinkPreview(label)?.href === preview.href;
|
||||
return (
|
||||
parseSupportedLinkPreview(label, activeRelayOrigin)?.href === preview.href
|
||||
);
|
||||
}
|
||||
|
||||
function titleFromMarkdownLabel(
|
||||
label: string,
|
||||
preview: SupportedLinkPreview,
|
||||
activeRelayOrigin: string | null,
|
||||
): string | null {
|
||||
const title = label.replace(/\s+/g, " ").trim();
|
||||
if (!title || isSupportedLinkAutolinkLabel(title, preview)) {
|
||||
if (
|
||||
!title ||
|
||||
isSupportedLinkAutolinkLabel(title, preview, activeRelayOrigin)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return title;
|
||||
@@ -474,6 +594,7 @@ type LinkPreviewCandidate = {
|
||||
/** Extract supported link previews from message text, preserving first-seen order. */
|
||||
export function extractSupportedLinkPreviews(
|
||||
content: string,
|
||||
activeRelayOrigin?: string | null,
|
||||
): SupportedLinkPreview[] {
|
||||
const previews: SupportedLinkPreview[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -506,8 +627,9 @@ export function extractSupportedLinkPreviews(
|
||||
|
||||
candidates.sort((a, b) => a.index - b.index || a.order - b.order);
|
||||
|
||||
const relayOrigin = activeRelayOrigin ?? null;
|
||||
for (const candidate of candidates) {
|
||||
const preview = parseSupportedLinkPreview(candidate.href);
|
||||
const preview = parseSupportedLinkPreview(candidate.href, relayOrigin);
|
||||
if (!preview || seen.has(preview.href)) continue;
|
||||
|
||||
seen.add(preview.href);
|
||||
@@ -515,7 +637,7 @@ export function extractSupportedLinkPreviews(
|
||||
withTitle(
|
||||
preview,
|
||||
candidate.label
|
||||
? titleFromMarkdownLabel(candidate.label, preview)
|
||||
? titleFromMarkdownLabel(candidate.label, preview, relayOrigin)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -34,12 +34,13 @@ function install(ls) {
|
||||
}
|
||||
|
||||
test("startup recovery removes disposable caches but preserves user state", () => {
|
||||
const ls = makeQuotaLocalStorage({ maxEntries: 5 });
|
||||
const ls = makeQuotaLocalStorage({ maxEntries: 6 });
|
||||
install(ls);
|
||||
ls.store.set("buzz-channel-messages.v1:relay:chan", "big");
|
||||
ls.store.set("buzz-channels.v1:relay", "big");
|
||||
ls.store.set("buzz-timeline-skeleton-shape.v1:chan", "small");
|
||||
ls.store.set("buzz-sidebar-skeleton-shape.v1:community:user", "small");
|
||||
ls.store.set("buzz-user-labels.v1:relay", "small");
|
||||
ls.store.set("buzz-communities", "keep");
|
||||
|
||||
recoverLocalStorageQuotaOnStartup();
|
||||
@@ -51,6 +52,7 @@ test("startup recovery removes disposable caches but preserves user state", () =
|
||||
ls.getItem("buzz-sidebar-skeleton-shape.v1:community:user"),
|
||||
null,
|
||||
);
|
||||
assert.equal(ls.getItem("buzz-user-labels.v1:relay"), null);
|
||||
assert.equal(ls.getItem("buzz-communities"), "keep");
|
||||
assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), "1");
|
||||
});
|
||||
@@ -200,3 +202,40 @@ test("returns false when eviction frees nothing", () => {
|
||||
assert.equal(ls.getItem("k"), null);
|
||||
assert.equal(ls.getItem("buzz-workspaces"), "keep");
|
||||
});
|
||||
|
||||
test("buzz-observed-unread.v1: prefix participates in LRU eviction and durable state survives", () => {
|
||||
// Sentinel: fails if buzz-observed-unread.v1: is removed from PURE_CACHE_KEY_PREFIXES —
|
||||
// the bucket becomes invisible to LRU and the wrong entry is evicted instead.
|
||||
const ls = makeQuotaLocalStorage({ maxEntries: 20 });
|
||||
install(ls);
|
||||
ls.store.set("buzz-communities", "keep");
|
||||
|
||||
const snapshot = (updatedAt) =>
|
||||
JSON.stringify({ updatedAt, payload: "x".repeat(400_000) });
|
||||
const observedKey = "buzz-observed-unread.v1:wss://relay.example.com:pk1";
|
||||
const olderKey = "buzz-channel-messages.v1:relay:older";
|
||||
const newestKey = "buzz-channel-messages.v1:relay:newest";
|
||||
|
||||
// Seed observed-unread (oldest updatedAt=1) and a sibling channel-messages entry (updatedAt=2).
|
||||
assert.equal(setLocalStorageItemWithRecovery(observedKey, snapshot(1)), true);
|
||||
assert.equal(setLocalStorageItemWithRecovery(olderKey, snapshot(2)), true);
|
||||
|
||||
// Writing a third pure-cache entry (updatedAt=3) pushes the total above the 2 MiB budget.
|
||||
// The observed-unread entry must be evicted first (oldest LRU); the sibling survives.
|
||||
assert.equal(setLocalStorageItemWithRecovery(newestKey, snapshot(3)), true);
|
||||
assert.equal(
|
||||
ls.getItem(observedKey),
|
||||
null,
|
||||
"observed-unread bucket must be evicted as the oldest LRU pure-cache entry",
|
||||
);
|
||||
assert.notEqual(
|
||||
ls.getItem(olderKey),
|
||||
null,
|
||||
"channel-messages bucket with newer updatedAt must survive",
|
||||
);
|
||||
assert.equal(
|
||||
ls.getItem("buzz-communities"),
|
||||
"keep",
|
||||
"durable state must survive",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
const PURE_CACHE_KEY_PREFIXES = [
|
||||
"buzz-channel-messages.v1:",
|
||||
"buzz-channels.v1:",
|
||||
"buzz-observed-unread.v1:",
|
||||
"buzz-sidebar-skeleton-shape.v1:",
|
||||
"buzz-timeline-skeleton-shape.v1:",
|
||||
"buzz-user-labels.v1:",
|
||||
];
|
||||
|
||||
const QUOTA_RECOVERY_MARKER_KEY = "buzz-local-storage-quota-recovery.v1";
|
||||
|
||||
@@ -231,6 +231,19 @@ async function fetchProxyPort(): Promise<number | null> {
|
||||
return cachedPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the relay-origin lookup is active after a community cache reset.
|
||||
*
|
||||
* Consumers that classify relay-owned URLs may render before any media URL
|
||||
* asks for the proxy port. Starting the shared fetch here keeps host
|
||||
* classification independent from incidental image/video rendering.
|
||||
*/
|
||||
export function ensureRelayOriginFetch(): void {
|
||||
if (!portPromise && typeof window !== "undefined") {
|
||||
portPromise = fetchProxyPort();
|
||||
}
|
||||
}
|
||||
|
||||
/** Eagerly fetch the port at module load so it's ready by first render. */
|
||||
// The try/catch inside fetchProxyPort handles non-Tauri environments gracefully
|
||||
// (invoke will throw, we retry until timeout, then give up — no side effects).
|
||||
@@ -319,7 +332,7 @@ export function rewriteRelayUrl(url: string): string {
|
||||
}
|
||||
|
||||
if (!portPromise && typeof window !== "undefined") {
|
||||
portPromise = fetchProxyPort();
|
||||
ensureRelayOriginFetch();
|
||||
}
|
||||
|
||||
return `buzz-media://localhost/media/${m[1]}`;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { getCachedRelayOrigin, subscribeRelayOrigin } from "./mediaUrl";
|
||||
import {
|
||||
ensureRelayOriginFetch,
|
||||
getCachedRelayOrigin,
|
||||
subscribeRelayOrigin,
|
||||
} from "./mediaUrl";
|
||||
|
||||
/**
|
||||
* The resolved relay origin, re-rendering when it resolves or changes.
|
||||
@@ -16,9 +20,11 @@ import { getCachedRelayOrigin, subscribeRelayOrigin } from "./mediaUrl";
|
||||
* "not yet downloadable" (fail closed).
|
||||
*/
|
||||
export function useRelayOrigin(): string | null {
|
||||
return React.useSyncExternalStore(
|
||||
const origin = React.useSyncExternalStore(
|
||||
subscribeRelayOrigin,
|
||||
getCachedRelayOrigin,
|
||||
() => null,
|
||||
);
|
||||
React.useEffect(ensureRelayOriginFetch, []);
|
||||
return origin;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
KIND_GIT_ISSUE,
|
||||
KIND_GIT_PULL_REQUEST,
|
||||
} from "@/shared/constants/kinds";
|
||||
|
||||
import type { SupportedLinkPreview } from "./linkPreview";
|
||||
import { parseEntityLink } from "./entityLink";
|
||||
import {
|
||||
buzzEntityFallbackTitle,
|
||||
type SupportedLinkPreview,
|
||||
} from "./linkPreview";
|
||||
|
||||
const GOOGLE_FALLBACK_TITLES = new Set([
|
||||
"Drive file",
|
||||
@@ -13,33 +22,126 @@ const GOOGLE_FALLBACK_TITLES = new Set([
|
||||
]);
|
||||
|
||||
const titleCache = new Map<string, Promise<string | null> | string | null>();
|
||||
/**
|
||||
* Generation counter incremented on every `resetLinkPreviewTitleCache` call.
|
||||
* Each in-flight promise captures the generation at creation time and only
|
||||
* writes back if no reset has happened since — preventing a resolved promise
|
||||
* from a previous community from repopulating stale entries into the fresh
|
||||
* cache of a new community.
|
||||
*/
|
||||
let cacheGeneration = 0;
|
||||
|
||||
/**
|
||||
* Buzz entity titles come from relay events, so they are community-scoped —
|
||||
* wired into `resetCommunityState()` (see useCommunityInit.ts) to avoid
|
||||
* leaking titles across community switches.
|
||||
*/
|
||||
export function resetLinkPreviewTitleCache(): void {
|
||||
cacheGeneration += 1;
|
||||
titleCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current cache generation counter. Used in tests to verify that
|
||||
* `resetLinkPreviewTitleCache` increments the generation so stale in-flight
|
||||
* promises cannot seed the new cache.
|
||||
*
|
||||
* @internal test-only export
|
||||
*/
|
||||
export function getLinkPreviewCacheGeneration(): number {
|
||||
return cacheGeneration;
|
||||
}
|
||||
|
||||
function fetchLinkPreviewTitle(href: string): Promise<string | null> {
|
||||
return invokeTauri<string | null>("fetch_link_preview_title", { href });
|
||||
}
|
||||
|
||||
function shouldResolveTitle(preview: SupportedLinkPreview): boolean {
|
||||
/**
|
||||
* Resolve a Buzz PR/issue card title from the relay event's `subject` tag
|
||||
* (first content line as fallback — the same precedence the projects views
|
||||
* use).
|
||||
*
|
||||
* Security: the fetched event's canonical `a` tag must equal
|
||||
* `30617:<owner>:<d>` from the link before we adopt its title. Without this
|
||||
* check, a crafted link could pair the real title of a legitimate PR with an
|
||||
* unrelated repository destination.
|
||||
*/
|
||||
async function fetchBuzzEntityTitle(href: string): Promise<string | null> {
|
||||
const parsed = parseEntityLink(href);
|
||||
if (!parsed.ok || parsed.value.type === "repo") return null;
|
||||
|
||||
const { id, owner, dtag } = parsed.value;
|
||||
const expectedCoordinate = `30617:${owner}:${dtag}`;
|
||||
|
||||
const events = await relayClient.fetchEvents({
|
||||
kinds: [
|
||||
parsed.value.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE,
|
||||
],
|
||||
ids: [id],
|
||||
limit: 1,
|
||||
});
|
||||
const event = events[0];
|
||||
if (!event) return null;
|
||||
|
||||
// Verify the event belongs to the claimed repository coordinate.
|
||||
const aTag = event.tags.find(
|
||||
(tag) => tag[0] === "a" && tag[1] === expectedCoordinate,
|
||||
);
|
||||
if (!aTag) return null;
|
||||
|
||||
const subject = event.tags.find((tag) => tag[0] === "subject")?.[1];
|
||||
return subject || event.content.split("\n")[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the preview's current title is still the auto-generated
|
||||
* fallback and a relay lookup should be attempted to replace it. Returns false
|
||||
* once the user has applied a markdown label (`[My label](link)`) so that the
|
||||
* label wins over any cached relay title.
|
||||
*
|
||||
* Exported for unit testing of the label-must-win invariant.
|
||||
*/
|
||||
export function shouldResolveTitle(preview: SupportedLinkPreview): boolean {
|
||||
if (preview.kind === "buzz-pull-request" || preview.kind === "buzz-issue") {
|
||||
// A markdown-label override replaces the fallback title and must win
|
||||
// over the relay lookup.
|
||||
const parsed = parseEntityLink(preview.href);
|
||||
return parsed.ok && preview.title === buzzEntityFallbackTitle(parsed.value);
|
||||
}
|
||||
|
||||
return (
|
||||
preview.kind.startsWith("google-") &&
|
||||
GOOGLE_FALLBACK_TITLES.has(preview.title)
|
||||
);
|
||||
}
|
||||
|
||||
function cacheTitle(href: string): Promise<string | null> {
|
||||
const cached = titleCache.get(href);
|
||||
function resolveTitle(preview: SupportedLinkPreview): Promise<string | null> {
|
||||
return preview.href.startsWith("buzz://")
|
||||
? fetchBuzzEntityTitle(preview.href)
|
||||
: fetchLinkPreviewTitle(preview.href);
|
||||
}
|
||||
|
||||
function cacheTitle(preview: SupportedLinkPreview): Promise<string | null> {
|
||||
const cached = titleCache.get(preview.href);
|
||||
if (cached instanceof Promise) return cached;
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
|
||||
const promise = fetchLinkPreviewTitle(href)
|
||||
const generation = cacheGeneration;
|
||||
const promise = resolveTitle(preview)
|
||||
.then((title) => {
|
||||
titleCache.set(href, title);
|
||||
// Only write back if no community switch has happened since we started.
|
||||
if (cacheGeneration === generation) {
|
||||
titleCache.set(preview.href, title);
|
||||
}
|
||||
return title;
|
||||
})
|
||||
.catch(() => {
|
||||
titleCache.set(href, null);
|
||||
if (cacheGeneration === generation) {
|
||||
titleCache.set(preview.href, null);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
titleCache.set(href, promise);
|
||||
titleCache.set(preview.href, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
@@ -66,7 +168,7 @@ export function useResolvedLinkPreviews(
|
||||
continue;
|
||||
}
|
||||
|
||||
void cacheTitle(preview.href).then((title) => {
|
||||
void cacheTitle(preview).then((title) => {
|
||||
if (cancelled || !title) return;
|
||||
setResolvedTitles((current) =>
|
||||
current[preview.href] === title
|
||||
@@ -85,7 +187,14 @@ export function useResolvedLinkPreviews(
|
||||
() =>
|
||||
previews.map((preview) => {
|
||||
const title = resolvedTitles[preview.href];
|
||||
return title ? { ...preview, title } : preview;
|
||||
// Only apply a relay-resolved title while the preview still has the
|
||||
// fallback title (i.e. no markdown-label override). If the user edits
|
||||
// a bare link into `[My label](same-link)`, `shouldResolveTitle`
|
||||
// returns false and the label wins — the cached relay title is not
|
||||
// applied to prevent it from silently overriding the explicit label.
|
||||
return title && shouldResolveTitle(preview)
|
||||
? { ...preview, title }
|
||||
: preview;
|
||||
}),
|
||||
[previews, resolvedTitles],
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@import "./globals/skeleton.css";
|
||||
@import "./globals/spoilers.css";
|
||||
@import "./globals/components.css";
|
||||
@import "./globals/terminal.css";
|
||||
@import "./globals/utilities.css";
|
||||
@import "./globals/media-controls.css";
|
||||
@import "./globals/avatar-framing.css";
|
||||
@@ -30,3 +31,6 @@
|
||||
Must stay below every `@import`: CSS requires `@import` to precede other
|
||||
at-rules, so placing this above them silently drops the rest of the sheet. */
|
||||
@custom-variant hover (&:hover);
|
||||
/* The app persists its selected theme on the root `.dark` class. Use that
|
||||
class, rather than the system preference, for every `dark:` utility. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@@ -10,10 +10,9 @@
|
||||
--buzz-huddle-drawer-radius: 24px;
|
||||
--buzz-huddle-drawer-ease: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--buzz-huddle-drawer-surface: var(--huddle-drawer-surface, 0 0% 0%);
|
||||
/* The lifted app surface reveals this shell at its rounded corners. The
|
||||
* Buzz theme paints its gradient here; the drawer remains responsible for
|
||||
* its own dark surface below. */
|
||||
background: transparent;
|
||||
/* Keep the drawer surface behind the lifted app so the dark background
|
||||
* remains visible through its rounded lower corners. */
|
||||
background: hsl(var(--buzz-huddle-drawer-surface));
|
||||
}
|
||||
|
||||
.buzz-huddle-app-surface {
|
||||
@@ -61,6 +60,26 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.buzz-huddle-drawer-backdrop {
|
||||
background: hsl(var(--buzz-huddle-drawer-surface));
|
||||
bottom: 0;
|
||||
height: calc(
|
||||
var(--buzz-huddle-drawer-height) +
|
||||
var(--buzz-huddle-drawer-radius)
|
||||
);
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
transition: opacity 260ms var(--buzz-huddle-drawer-ease);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.buzz-huddle-drawer-backdrop-open {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The dedicated companion keeps room for the controls without making the
|
||||
* transcript look like a raised card. */
|
||||
.buzz-huddle-app-surface-room-open {
|
||||
@@ -199,6 +218,10 @@
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.buzz-huddle-drawer-backdrop {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.buzz-huddle-mic-meter-bar {
|
||||
transition: none;
|
||||
}
|
||||
@@ -751,119 +774,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.buzz-terminal-substrate {
|
||||
background: var(--buzz-terminal-background, #101014);
|
||||
color: var(--buzz-terminal-foreground, #e8e8ec);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-variant-ligatures: none;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-contract-bar {
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
display: flex;
|
||||
flex: 0 0 32px;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-tabs,
|
||||
.buzz-terminal-readout {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab,
|
||||
.buzz-terminal-tab-select,
|
||||
.buzz-terminal-close,
|
||||
.buzz-terminal-new-tab {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
font: inherit;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
opacity: 0.62;
|
||||
padding: 0 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab-active::after {
|
||||
background: hsl(var(--buzz-selected-accent));
|
||||
bottom: 0;
|
||||
content: "";
|
||||
height: 1px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-designator,
|
||||
.buzz-terminal-readout {
|
||||
font-size: 0.5625rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.buzz-terminal-close {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab:hover .buzz-terminal-close,
|
||||
.buzz-terminal-close:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.buzz-terminal-readout {
|
||||
gap: 14px;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.buzz-terminal-viewport,
|
||||
.buzz-terminal-viewport canvas {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buzz-terminal-viewport {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buzz-terminal-viewport canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buzz-terminal-welcome {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.buzz-terminal-input {
|
||||
height: 1px;
|
||||
left: -10000px;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
302
desktop/src/shared/styles/globals/terminal.css
Normal file
302
desktop/src/shared/styles/globals/terminal.css
Normal file
@@ -0,0 +1,302 @@
|
||||
@layer components {
|
||||
.buzz-terminal-substrate {
|
||||
background: var(--buzz-terminal-background, #101014);
|
||||
color: var(--buzz-terminal-foreground, #e8e8ec);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-variant-ligatures: none;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-contract-bar {
|
||||
align-items: center;
|
||||
background: hsl(var(--secondary));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--secondary-foreground));
|
||||
display: flex;
|
||||
flex: 0 0 40px;
|
||||
font-family: inherit;
|
||||
font-variant-ligatures: normal;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
padding: 4px 1.25rem;
|
||||
}
|
||||
|
||||
.buzz-terminal-tabs,
|
||||
.buzz-terminal-readout {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab,
|
||||
.buzz-terminal-tab-select,
|
||||
.buzz-terminal-close,
|
||||
.buzz-terminal-new-tab {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
color: hsl(var(--muted-foreground));
|
||||
display: flex;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buzz-terminal-tabs {
|
||||
flex: 1 1 auto;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.buzz-terminal-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.buzz-terminal-readout {
|
||||
background: hsl(var(--secondary));
|
||||
flex: 0 0 auto;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab {
|
||||
background: hsl(var(--background));
|
||||
border-radius: 4px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab-active {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.buzz-terminal-tab:hover,
|
||||
.buzz-terminal-tab:focus-within {
|
||||
background: hsl(var(--foreground) / 0.06);
|
||||
}
|
||||
|
||||
.buzz-terminal-tab-select {
|
||||
border-radius: inherit;
|
||||
max-width: 12rem;
|
||||
min-width: 0;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab-active .buzz-terminal-tab-select {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.buzz-terminal-new-tab:hover {
|
||||
background: hsl(var(--foreground) / 0.06);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.buzz-terminal-new-tab {
|
||||
align-items: center;
|
||||
background: hsl(var(--background));
|
||||
border-radius: 4px;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
padding: 7px;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.buzz-terminal-new-tab svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.buzz-terminal-designator {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
font-size: 0.75rem;
|
||||
gap: 2px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-designator svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.buzz-terminal-close {
|
||||
height: 16px;
|
||||
left: 8px;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab:hover .buzz-terminal-close,
|
||||
.buzz-terminal-tab:focus-within .buzz-terminal-close {
|
||||
color: hsl(var(--foreground));
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.buzz-terminal-close svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.buzz-terminal-readout {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.buzz-terminal-viewport,
|
||||
.buzz-terminal-viewport canvas {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buzz-terminal-viewport {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buzz-terminal-viewport canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buzz-terminal-welcome {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.buzz-terminal-input {
|
||||
height: 1px;
|
||||
left: -10000px;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.buzz-terminal-dock-host:has(.buzz-terminal-substrate) {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
flex-grow 180ms ease,
|
||||
flex-basis 180ms ease;
|
||||
}
|
||||
|
||||
.buzz-content-primary {
|
||||
transition:
|
||||
flex-grow 180ms ease,
|
||||
flex-basis 180ms ease;
|
||||
}
|
||||
|
||||
.buzz-terminal-dock-host:has([data-terminal-mode="maximized"]) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.buzz-content-primary:has(
|
||||
+ .buzz-terminal-dock-host [data-terminal-mode="maximized"]
|
||||
) {
|
||||
flex: 0 1 0%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-substrate {
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
inset: auto;
|
||||
min-height: 180px;
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
height 180ms ease,
|
||||
transform 180ms ease;
|
||||
width: 100%;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.buzz-terminal-substrate[data-terminal-resizing="true"] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.buzz-terminal-substrate[data-terminal-visible="false"] {
|
||||
height: 0 !important;
|
||||
min-height: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
.buzz-terminal-dock-host [data-terminal-mode="maximized"] {
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.buzz-terminal-resize-handle {
|
||||
cursor: ns-resize;
|
||||
height: 5px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -3px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.buzz-terminal-window-action {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
color: hsl(var(--muted-foreground));
|
||||
display: inline-flex;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.buzz-terminal-window-action:hover,
|
||||
.buzz-terminal-window-action:focus-visible {
|
||||
background: hsl(var(--foreground) / 0.06);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.buzz-terminal-window-action svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.buzz-content-primary,
|
||||
.buzz-terminal-dock-host:has(.buzz-terminal-substrate),
|
||||
.buzz-terminal-substrate {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
const UNREAD_PILL_CLASS =
|
||||
"pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/70 bg-background/95 px-2 py-1 text-2xs font-medium tracking-[0.02em] text-muted-foreground/70 shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-4";
|
||||
const PRIMARY_UNREAD_PILL_CLASS =
|
||||
"pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full px-2 py-1 text-xs font-medium shadow-sm [&_svg]:size-4";
|
||||
|
||||
export function unreadCountLabel(count: number) {
|
||||
return `${count} new message${count === 1 ? "" : "s"}`;
|
||||
@@ -11,11 +14,13 @@ export function unreadCountLabel(count: number) {
|
||||
|
||||
export function UnreadPill({
|
||||
direction,
|
||||
emphasis = "default",
|
||||
label,
|
||||
onClick,
|
||||
testId,
|
||||
}: {
|
||||
direction: "up" | "down";
|
||||
emphasis?: "default" | "primary";
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
testId: string;
|
||||
@@ -23,12 +28,14 @@ export function UnreadPill({
|
||||
const Arrow = direction === "up" ? ArrowUp : ArrowDown;
|
||||
return (
|
||||
<Button
|
||||
className={UNREAD_PILL_CLASS}
|
||||
className={cn(
|
||||
emphasis === "primary" ? PRIMARY_UNREAD_PILL_CLASS : UNREAD_PILL_CLASS,
|
||||
)}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant={emphasis === "primary" ? "default" : "outline"}
|
||||
>
|
||||
<Arrow aria-hidden />
|
||||
{label}
|
||||
|
||||
@@ -2,7 +2,12 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
import { DialogContent, DialogHeader, DialogTitle } from "./dialog";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./dialog";
|
||||
|
||||
type ChooserDialogContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DialogContent
|
||||
@@ -13,6 +18,7 @@ type ChooserDialogContentProps = React.ComponentPropsWithoutRef<
|
||||
footerTestId?: string;
|
||||
contentClassName?: string;
|
||||
headerClassName?: string;
|
||||
headerSubtitle?: React.ReactNode;
|
||||
headerTestId?: string;
|
||||
scrollAreaClassName?: string;
|
||||
scrollAreaTestId?: string;
|
||||
@@ -33,6 +39,7 @@ export const ChooserDialogContent = React.forwardRef<
|
||||
footerClassName,
|
||||
footerTestId,
|
||||
headerClassName,
|
||||
headerSubtitle,
|
||||
headerTestId,
|
||||
scrollAreaClassName,
|
||||
scrollAreaTestId,
|
||||
@@ -56,6 +63,9 @@ export const ChooserDialogContent = React.forwardRef<
|
||||
data-testid={headerTestId}
|
||||
>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{headerSubtitle ? (
|
||||
<DialogDescription>{headerSubtitle}</DialogDescription>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ExternalLink } from "lucide-react";
|
||||
|
||||
import type { SupportedLinkPreview } from "@/shared/lib/linkPreview";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentActions,
|
||||
@@ -91,6 +92,10 @@ function GoogleSlidesLogo({ className }: { className?: string }) {
|
||||
|
||||
function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) {
|
||||
switch (preview.kind) {
|
||||
case "buzz-issue":
|
||||
case "buzz-pull-request":
|
||||
case "buzz-repository":
|
||||
return <BuzzMark className="h-4 w-4" />;
|
||||
case "github-issue":
|
||||
case "github-pull-request":
|
||||
case "github-repository":
|
||||
@@ -111,9 +116,16 @@ function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) {
|
||||
|
||||
export function LinkPreviewAttachment({
|
||||
className,
|
||||
onOpen,
|
||||
preview,
|
||||
}: {
|
||||
className?: string;
|
||||
/**
|
||||
* In-app navigation handler for links the OS cannot open (e.g. `buzz://`
|
||||
* entity deep links). When set, the card renders a button trigger instead
|
||||
* of an external anchor.
|
||||
*/
|
||||
onOpen?: () => void;
|
||||
preview: SupportedLinkPreview;
|
||||
}) {
|
||||
return (
|
||||
@@ -141,18 +153,29 @@ export function LinkPreviewAttachment({
|
||||
className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100"
|
||||
/>
|
||||
</AttachmentActions>
|
||||
<AttachmentTrigger asChild>
|
||||
<a
|
||||
{onOpen ? (
|
||||
<AttachmentTrigger
|
||||
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open {preview.provider} {preview.typeLabel}: {preview.title}
|
||||
</span>
|
||||
</a>
|
||||
</AttachmentTrigger>
|
||||
</AttachmentTrigger>
|
||||
) : (
|
||||
<AttachmentTrigger asChild>
|
||||
<a
|
||||
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open {preview.provider} {preview.typeLabel}: {preview.title}
|
||||
</span>
|
||||
</a>
|
||||
</AttachmentTrigger>
|
||||
)}
|
||||
</Attachment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -521,9 +521,10 @@ test("rehypeImageGallery: leaves a single trailing image in the text flow", () =
|
||||
|
||||
// Regression test: react-markdown's `defaultUrlTransform` strips unknown
|
||||
// schemes (returns `""`) before our `a` component override can see them,
|
||||
// which would break copy → paste → click for `buzz://message?…` links
|
||||
// end-to-end. We pass a custom `urlTransform` that delegates to the
|
||||
// default for `buzz://message` and legacy `buzz://message` hrefs.
|
||||
// which would break copy → paste → click for `buzz://message?…` links and
|
||||
// `buzz://pr|issue|repo?…` entity links end-to-end. We pass a custom
|
||||
// `urlTransform` (`buzzDeepLinkUrlTransform`) that preserves valid Buzz
|
||||
// deep links and delegates everything else to `defaultUrlTransform`.
|
||||
//
|
||||
// This test renders real `<ReactMarkdown>` with the production transform
|
||||
// and asserts the link href survives to the rendered DOM. Mirrors the
|
||||
@@ -534,12 +535,18 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
||||
|
||||
import { isMessageLink } from "../../features/messages/lib/messageLink.ts";
|
||||
import { parseEntityLink } from "../lib/entityLink.ts";
|
||||
import remarkSpoilers from "../lib/remarkSpoilers.ts";
|
||||
|
||||
function messageLinkUrlTransform(value, key) {
|
||||
if (key === "href" && isMessageLink(value)) {
|
||||
return value;
|
||||
}
|
||||
const OWNER_HEX =
|
||||
"71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
|
||||
const EVENT_HEX =
|
||||
"c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";
|
||||
|
||||
function buzzDeepLinkUrlTransform(value, key) {
|
||||
if (key !== "href") return defaultUrlTransform(value);
|
||||
if (isMessageLink(value)) return value;
|
||||
if (parseEntityLink(value).ok) return value;
|
||||
return defaultUrlTransform(value);
|
||||
}
|
||||
|
||||
@@ -547,7 +554,7 @@ function renderMarkdown(content) {
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
ReactMarkdown,
|
||||
{ urlTransform: messageLinkUrlTransform },
|
||||
{ urlTransform: buzzDeepLinkUrlTransform },
|
||||
content,
|
||||
),
|
||||
);
|
||||
@@ -592,7 +599,7 @@ test("messageLinkUrlTransform: preserves legacy buzz://message href", () => {
|
||||
assert.match(html, /href="buzz:\/\/message\?channel=abc&(?:amp;)?id=xyz"/);
|
||||
});
|
||||
|
||||
test("messageLinkUrlTransform: leaves non-message buzz:// schemes to default", () => {
|
||||
test("messageLinkUrlTransform: leaves non-entity buzz:// schemes to default", () => {
|
||||
// `buzz://connect?relay=…` is handled by a different code path (Tauri
|
||||
// single-instance). The markdown renderer should let it pass through
|
||||
// defaultUrlTransform (which strips it) since it's not clickable in-app.
|
||||
@@ -602,6 +609,127 @@ test("messageLinkUrlTransform: leaves non-message buzz:// schemes to default", (
|
||||
assert.match(html, /href=""/);
|
||||
});
|
||||
|
||||
test("buzzDeepLinkUrlTransform: preserves buzz://pr entity link href", () => {
|
||||
const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`;
|
||||
const html = renderMarkdown(`[My PR](${prLink})`);
|
||||
// The href must survive — our transform preserves valid entity links.
|
||||
assert.match(html, /href="buzz:\/\/pr\?/);
|
||||
assert.doesNotMatch(html, /href=""/);
|
||||
});
|
||||
|
||||
test("buzzDeepLinkUrlTransform: preserves buzz://pr autolink href", () => {
|
||||
const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`;
|
||||
const html = renderMarkdown(`<${prLink}>`);
|
||||
assert.match(html, /href="buzz:\/\/pr\?/);
|
||||
assert.doesNotMatch(html, /href=""/);
|
||||
});
|
||||
|
||||
test("buzzDeepLinkUrlTransform: preserves buzz://issue entity link href", () => {
|
||||
const issueLink = `buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`;
|
||||
const html = renderMarkdown(`[Issue title](${issueLink})`);
|
||||
assert.match(html, /href="buzz:\/\/issue\?/);
|
||||
assert.doesNotMatch(html, /href=""/);
|
||||
});
|
||||
|
||||
test("buzzDeepLinkUrlTransform: preserves buzz://repo entity link href", () => {
|
||||
const repoLink = `buzz://repo?owner=${OWNER_HEX}&d=buzz-world`;
|
||||
const html = renderMarkdown(`[My repo](${repoLink})`);
|
||||
assert.match(html, /href="buzz:\/\/repo\?/);
|
||||
assert.doesNotMatch(html, /href=""/);
|
||||
});
|
||||
|
||||
test("buzzDeepLinkUrlTransform: strips malformed buzz://pr (unknown param)", () => {
|
||||
// Strict parser rejects unknown params — transform falls back to default sanitizer.
|
||||
const html = renderMarkdown(
|
||||
`[link](buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world&extra=ignored)`,
|
||||
);
|
||||
assert.match(html, /href=""/);
|
||||
});
|
||||
|
||||
// ── renderEntityLinkAnchor: clone-URL anchor origin gating ───────────────────
|
||||
//
|
||||
// `renderEntityLinkAnchor` now accepts `relayOrigin` and passes it to
|
||||
// `parseSupportedLinkPreview`. A clone URL is only treated as an in-app entity
|
||||
// link when the URL origin exactly matches the active relay origin. This covers
|
||||
// the inline anchor click path (not just card extraction).
|
||||
|
||||
import { renderEntityLinkAnchor } from "../ui/markdown/entityLinks.tsx";
|
||||
|
||||
const CLONE_URL = `https://relay.example/git/${OWNER_HEX}/my-repo`;
|
||||
|
||||
test("renderEntityLinkAnchor_matchingOriginCloneUrl_returnsEntityAnchor", () => {
|
||||
// Origin matches active relay — anchor should navigate in-app (non-null).
|
||||
const el = renderEntityLinkAnchor({
|
||||
anchorProps: {},
|
||||
children: React.createElement("span", null, "my-repo"),
|
||||
href: CLONE_URL,
|
||||
onOpenEntityLink: () => {},
|
||||
relayOrigin: "https://relay.example",
|
||||
});
|
||||
assert.notEqual(
|
||||
el,
|
||||
null,
|
||||
"matching-origin clone URL must produce an entity anchor",
|
||||
);
|
||||
const html = renderToStaticMarkup(el);
|
||||
// The entity anchor must be rendered — it carries the original href (for display)
|
||||
// and navigates in-app via onClick. Verify the anchor is present with the cursor style.
|
||||
assert.match(
|
||||
html,
|
||||
/cursor-pointer/,
|
||||
"entity anchor must have the in-app navigation cursor style",
|
||||
);
|
||||
});
|
||||
|
||||
test("renderEntityLinkAnchor_lookalikeDomainCloneUrl_returnsNull", () => {
|
||||
// Origin does NOT match active relay — must fall through to ExternalLinkAnchor.
|
||||
const el = renderEntityLinkAnchor({
|
||||
anchorProps: {},
|
||||
children: React.createElement("span", null, "my-repo"),
|
||||
href: CLONE_URL,
|
||||
onOpenEntityLink: () => {},
|
||||
relayOrigin: "https://evil.example",
|
||||
});
|
||||
assert.equal(
|
||||
el,
|
||||
null,
|
||||
"lookalike-origin clone URL must return null so the anchor falls through to external",
|
||||
);
|
||||
});
|
||||
|
||||
test("renderEntityLinkAnchor_noRelayOrigin_cloneUrlReturnsNull", () => {
|
||||
// No known relay origin — must fail closed, not guess.
|
||||
const el = renderEntityLinkAnchor({
|
||||
anchorProps: {},
|
||||
children: React.createElement("span", null, "my-repo"),
|
||||
href: CLONE_URL,
|
||||
onOpenEntityLink: () => {},
|
||||
relayOrigin: null,
|
||||
});
|
||||
assert.equal(
|
||||
el,
|
||||
null,
|
||||
"clone URL without a relay origin must return null (fail closed)",
|
||||
);
|
||||
});
|
||||
|
||||
test("renderEntityLinkAnchor_directEntityLink_returnsAnchorRegardlessOfOrigin", () => {
|
||||
// A direct buzz://pr link always resolves in-app — it does not require origin.
|
||||
const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`;
|
||||
const el = renderEntityLinkAnchor({
|
||||
anchorProps: {},
|
||||
children: React.createElement("span", null, "My PR"),
|
||||
href: prLink,
|
||||
onOpenEntityLink: () => {},
|
||||
relayOrigin: null,
|
||||
});
|
||||
assert.notEqual(
|
||||
el,
|
||||
null,
|
||||
"direct buzz://pr link must produce an entity anchor regardless of origin",
|
||||
);
|
||||
});
|
||||
|
||||
test("remarkSpoilers: block delimiter spoilers expose a block prop to React", () => {
|
||||
let spoilerProps;
|
||||
renderToStaticMarkup(
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
@@ -23,13 +22,13 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { copyTextToClipboard } from "@/shared/lib/clipboard";
|
||||
import {
|
||||
extractSupportedLinkPreviews,
|
||||
parseSupportedLinkPreview,
|
||||
} from "@/shared/lib/linkPreview";
|
||||
import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
|
||||
import { AttachmentGroup } from "@/shared/ui/attachment";
|
||||
import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment";
|
||||
import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment";
|
||||
@@ -59,6 +58,12 @@ import {
|
||||
MarkdownCodeBlock,
|
||||
SyntaxHighlightedCode,
|
||||
} from "./markdown/CodeBlock";
|
||||
import {
|
||||
renderEntityLinkAnchor,
|
||||
useEntityCardOpenHandlers,
|
||||
useOpenEntityLink,
|
||||
} from "./markdown/entityLinks";
|
||||
import { ExternalLinkAnchor } from "./markdown/ExternalLinkAnchor";
|
||||
import { FileCard } from "./markdown/FileCard";
|
||||
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
|
||||
import { MarkdownInput } from "./markdown/MarkdownInput";
|
||||
@@ -108,7 +113,6 @@ import {
|
||||
visibleImageGalleryForTrigger,
|
||||
} from "./markdown/imageLightbox";
|
||||
import { MarkdownTable } from "./markdown/MarkdownTable";
|
||||
import { MaskedLinkTooltip } from "./markdown/MaskedLinkTooltip";
|
||||
import { ProgressiveImage } from "./markdown/ProgressiveImage";
|
||||
import { MessageLinkPill } from "./markdown/MessageLinkPill";
|
||||
import { renderCachedMarkdown } from "./markdown/nodeCache";
|
||||
@@ -1272,85 +1276,6 @@ function ImageMosaic({ children }: { children: React.ReactNode[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An external `[text](href)` link with a custom right-click menu.
|
||||
*
|
||||
* Buzz renders inside a native webview whose default context menu has no
|
||||
* useful link actions, so a plain right-click on a link is a no-op. This adds
|
||||
* an in-app menu with "Open link" (via the OS opener, matching the anchor's
|
||||
* left-click `target="_blank"` behavior) and "Copy link" (the real href, not
|
||||
* the masked display text).
|
||||
*/
|
||||
function ExternalLinkAnchor({
|
||||
anchorProps,
|
||||
children,
|
||||
href,
|
||||
isLinearLink,
|
||||
label,
|
||||
}: {
|
||||
anchorProps: React.ComponentPropsWithoutRef<"a">;
|
||||
children: React.ReactNode;
|
||||
href: string | undefined;
|
||||
isLinearLink: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
const [menu, setMenu] = React.useState<MediaContextMenuPosition | null>(null);
|
||||
const closeMenu = React.useCallback(() => setMenu(null), []);
|
||||
useDismissMediaContextMenu(Boolean(menu), closeMenu);
|
||||
|
||||
const anchor = (
|
||||
<a
|
||||
{...anchorProps}
|
||||
className={cn(
|
||||
"font-medium underline underline-offset-4 transition-colors",
|
||||
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
|
||||
)}
|
||||
href={href}
|
||||
onContextMenuCapture={(event) => {
|
||||
if (!href) return;
|
||||
event.preventDefault();
|
||||
setMenu({ x: event.clientX, y: event.clientY });
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
|
||||
{anchor}
|
||||
</MaskedLinkTooltip>
|
||||
{menu && href ? (
|
||||
<MediaContextMenu
|
||||
dataAttributes={["data-link-context-menu"]}
|
||||
items={[
|
||||
{
|
||||
label: "Open link",
|
||||
onSelect: () => {
|
||||
closeMenu();
|
||||
void openUrl(href).catch(() => {
|
||||
toast.error("Failed to open link");
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Copy link",
|
||||
onSelect: () => {
|
||||
closeMenu();
|
||||
copyTextToClipboard(href, "Link copied to clipboard");
|
||||
},
|
||||
},
|
||||
]}
|
||||
position={menu}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function createMarkdownComponents(
|
||||
interactive = true,
|
||||
mediaInset = false,
|
||||
@@ -1366,8 +1291,10 @@ function createMarkdownComponents(
|
||||
const {
|
||||
channels,
|
||||
imetaByUrl,
|
||||
onOpenEntityLink,
|
||||
onOpenMessageLink,
|
||||
onImportSnapshotFromUrl,
|
||||
relayOrigin,
|
||||
snapshotSharedBy,
|
||||
} = useMarkdownRuntime();
|
||||
if (!interactive) {
|
||||
@@ -1465,7 +1392,20 @@ function createMarkdownComponents(
|
||||
// anchor (renders as a normal external link).
|
||||
}
|
||||
|
||||
const supportedLinkPreview = href ? parseSupportedLinkPreview(href) : null;
|
||||
// `buzz://pr|issue|repo?…` entity links navigate in-app; malformed ones
|
||||
// fall through to the default anchor.
|
||||
const entityAnchor = renderEntityLinkAnchor({
|
||||
anchorProps: props,
|
||||
children,
|
||||
href,
|
||||
onOpenEntityLink,
|
||||
relayOrigin,
|
||||
});
|
||||
if (entityAnchor) return entityAnchor;
|
||||
|
||||
const supportedLinkPreview = href
|
||||
? parseSupportedLinkPreview(href, relayOrigin)
|
||||
: null;
|
||||
const isLinearLink = supportedLinkPreview?.kind === "linear-issue";
|
||||
|
||||
return (
|
||||
@@ -1799,7 +1739,7 @@ function createMarkdownComponents(
|
||||
* four instances ever exist. Module-stable maps mean cached markdown element
|
||||
* trees (see ./markdown/nodeCache.ts) never embed per-mount closures.
|
||||
*/
|
||||
const MARKDOWN_COMPONENT_SCHEMA_VERSION = "4";
|
||||
const MARKDOWN_COMPONENT_SCHEMA_VERSION = "5";
|
||||
const markdownComponentsByVariant = new Map<string, MarkdownComponentSet>();
|
||||
|
||||
type MarkdownComponentSet = { components: Components; variant: string };
|
||||
@@ -1852,6 +1792,7 @@ function MarkdownInner({
|
||||
},
|
||||
[goChannel],
|
||||
);
|
||||
const onOpenEntityLink = useOpenEntityLink();
|
||||
const onOpenMessageLink = React.useCallback(
|
||||
(link: ParsedMessageLink) => {
|
||||
// Always route through `goChannel` with `messageId` set: the channel
|
||||
@@ -1868,9 +1809,11 @@ function MarkdownInner({
|
||||
},
|
||||
[goChannel],
|
||||
);
|
||||
const relayOrigin = useRelayOrigin();
|
||||
const linkPreviews = React.useMemo(
|
||||
() => (interactive ? extractSupportedLinkPreviews(content) : []),
|
||||
[content, interactive],
|
||||
() =>
|
||||
interactive ? extractSupportedLinkPreviews(content, relayOrigin) : [],
|
||||
[content, interactive, relayOrigin],
|
||||
);
|
||||
const configNudge = React.useMemo(
|
||||
() => computeConfigNudge(content, interactive, configNudgeAuthorPubkey),
|
||||
@@ -1883,7 +1826,9 @@ function MarkdownInner({
|
||||
imetaByUrl,
|
||||
mentionPubkeysByName,
|
||||
onOpenChannel,
|
||||
onOpenEntityLink,
|
||||
onOpenMessageLink,
|
||||
relayOrigin,
|
||||
snapshotSharedBy,
|
||||
onImportSnapshotFromUrl: (
|
||||
fileBytes: number[],
|
||||
@@ -1900,7 +1845,9 @@ function MarkdownInner({
|
||||
imetaByUrl,
|
||||
mentionPubkeysByName,
|
||||
onOpenChannel,
|
||||
onOpenEntityLink,
|
||||
onOpenMessageLink,
|
||||
relayOrigin,
|
||||
snapshotSharedBy,
|
||||
goAgents,
|
||||
],
|
||||
@@ -1922,6 +1869,10 @@ function MarkdownInner({
|
||||
}
|
||||
|
||||
const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews);
|
||||
const entityCardOpenHandlers = useEntityCardOpenHandlers(
|
||||
resolvedLinkPreviews,
|
||||
onOpenEntityLink,
|
||||
);
|
||||
|
||||
// When a config-nudge suppresses the prose (selectProseOrNudge returns
|
||||
// null), skip the parse entirely — it would be thrown away unrendered.
|
||||
@@ -1977,7 +1928,11 @@ function MarkdownInner({
|
||||
data-link-preview-list=""
|
||||
>
|
||||
{resolvedLinkPreviews.map((preview) => (
|
||||
<LinkPreviewAttachment key={preview.href} preview={preview} />
|
||||
<LinkPreviewAttachment
|
||||
key={preview.href}
|
||||
onOpen={entityCardOpenHandlers.get(preview.href)}
|
||||
preview={preview}
|
||||
/>
|
||||
))}
|
||||
</AttachmentGroup>
|
||||
) : null}
|
||||
|
||||
92
desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx
Normal file
92
desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { copyTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
import { MaskedLinkTooltip } from "./MaskedLinkTooltip";
|
||||
import {
|
||||
MediaContextMenu,
|
||||
type MediaContextMenuPosition,
|
||||
useDismissMediaContextMenu,
|
||||
} from "./MediaContextMenu";
|
||||
|
||||
/**
|
||||
* An external `[text](href)` link with a custom right-click menu.
|
||||
*
|
||||
* Buzz renders inside a native webview whose default context menu has no
|
||||
* useful link actions, so a plain right-click on a link is a no-op. This adds
|
||||
* an in-app menu with "Open link" (via the OS opener, matching the anchor's
|
||||
* left-click `target="_blank"` behavior) and "Copy link" (the real href, not
|
||||
* the masked display text).
|
||||
*/
|
||||
export function ExternalLinkAnchor({
|
||||
anchorProps,
|
||||
children,
|
||||
href,
|
||||
isLinearLink,
|
||||
label,
|
||||
}: {
|
||||
anchorProps: React.ComponentPropsWithoutRef<"a">;
|
||||
children: React.ReactNode;
|
||||
href: string | undefined;
|
||||
isLinearLink: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
const [menu, setMenu] = React.useState<MediaContextMenuPosition | null>(null);
|
||||
const closeMenu = React.useCallback(() => setMenu(null), []);
|
||||
useDismissMediaContextMenu(Boolean(menu), closeMenu);
|
||||
|
||||
const anchor = (
|
||||
<a
|
||||
{...anchorProps}
|
||||
className={cn(
|
||||
"font-medium underline underline-offset-4 transition-colors",
|
||||
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
|
||||
)}
|
||||
href={href}
|
||||
onContextMenuCapture={(event) => {
|
||||
if (!href) return;
|
||||
event.preventDefault();
|
||||
setMenu({ x: event.clientX, y: event.clientY });
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
|
||||
{anchor}
|
||||
</MaskedLinkTooltip>
|
||||
{menu && href ? (
|
||||
<MediaContextMenu
|
||||
dataAttributes={["data-link-context-menu"]}
|
||||
items={[
|
||||
{
|
||||
label: "Open link",
|
||||
onSelect: () => {
|
||||
closeMenu();
|
||||
void openUrl(href).catch(() => {
|
||||
toast.error("Failed to open link");
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Copy link",
|
||||
onSelect: () => {
|
||||
closeMenu();
|
||||
copyTextToClipboard(href, "Link copied to clipboard");
|
||||
},
|
||||
},
|
||||
]}
|
||||
position={menu}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
112
desktop/src/shared/ui/markdown/entityLinks.tsx
Normal file
112
desktop/src/shared/ui/markdown/entityLinks.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import {
|
||||
entityLinkProjectRouteId,
|
||||
isEntityLink,
|
||||
parseEntityLink,
|
||||
type ParsedEntityLink,
|
||||
} from "@/shared/lib/entityLink";
|
||||
import {
|
||||
parseSupportedLinkPreview,
|
||||
type SupportedLinkPreview,
|
||||
} from "@/shared/lib/linkPreview";
|
||||
|
||||
/**
|
||||
* Navigate to the project detail view for a `buzz://pr|issue|repo` link.
|
||||
* The link's (owner, d) coordinate is exactly the `/projects/$projectId`
|
||||
* route id, so no read-model resolution is needed.
|
||||
*/
|
||||
export function useOpenEntityLink(): (link: ParsedEntityLink) => void {
|
||||
const { goProject } = useAppNavigation();
|
||||
return React.useCallback(
|
||||
(link: ParsedEntityLink) => {
|
||||
void goProject(entityLinkProjectRouteId(link), {
|
||||
...(link.type === "pr" ? { pullRequestId: link.id } : {}),
|
||||
...(link.type === "issue" ? { issueId: link.id } : {}),
|
||||
});
|
||||
},
|
||||
[goProject],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app open handlers for `buzz://` entity preview cards, keyed by href.
|
||||
* External cards get no handler and keep their OS-opened anchor.
|
||||
*/
|
||||
export function useEntityCardOpenHandlers(
|
||||
previews: SupportedLinkPreview[],
|
||||
onOpenEntityLink: (link: ParsedEntityLink) => void,
|
||||
): Map<string, () => void> {
|
||||
return React.useMemo(() => {
|
||||
const handlers = new Map<string, () => void>();
|
||||
for (const preview of previews) {
|
||||
if (!isEntityLink(preview.href)) continue;
|
||||
const parsed = parseEntityLink(preview.href);
|
||||
if (parsed.ok) {
|
||||
handlers.set(preview.href, () => onOpenEntityLink(parsed.value));
|
||||
}
|
||||
}
|
||||
return handlers;
|
||||
}, [onOpenEntityLink, previews]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an anchor href to a canonical `buzz://` entity link, accepting
|
||||
* both the deep-link scheme directly and HTTPS relay clone URLs (which the
|
||||
* preview parser normalizes onto `buzz://repo` only when the URL origin
|
||||
* matches the active relay origin).
|
||||
*/
|
||||
function resolveEntityHref(
|
||||
href: string,
|
||||
relayOrigin: string | null,
|
||||
): string | null {
|
||||
if (isEntityLink(href)) return href;
|
||||
if (!/^https?:\/\//i.test(href)) return null;
|
||||
|
||||
const preview = parseSupportedLinkPreview(href, relayOrigin);
|
||||
return preview && isEntityLink(preview.href) ? preview.href : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an inline anchor for a Buzz entity link (`buzz://pr|issue|repo` or
|
||||
* an HTTPS relay clone URL whose origin matches the active relay) that
|
||||
* navigates in-app instead of handing the URL to the OS. Returns null when
|
||||
* the href is not a valid entity link so the caller can fall through to its
|
||||
* default anchor.
|
||||
*/
|
||||
export function renderEntityLinkAnchor({
|
||||
anchorProps,
|
||||
children,
|
||||
href,
|
||||
onOpenEntityLink,
|
||||
relayOrigin,
|
||||
}: {
|
||||
anchorProps: React.ComponentPropsWithoutRef<"a">;
|
||||
children: React.ReactNode;
|
||||
href: string | undefined;
|
||||
onOpenEntityLink: (link: ParsedEntityLink) => void;
|
||||
relayOrigin: string | null;
|
||||
}): React.ReactElement | null {
|
||||
if (!href) return null;
|
||||
|
||||
const canonicalHref = resolveEntityHref(href, relayOrigin);
|
||||
if (!canonicalHref) return null;
|
||||
|
||||
const parsed = parseEntityLink(canonicalHref);
|
||||
if (!parsed.ok) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
{...anchorProps}
|
||||
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"
|
||||
href={href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onOpenEntityLink(parsed.value);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import remarkCustomEmoji, {
|
||||
import remarkMentions from "@/shared/lib/remarkMentions";
|
||||
import remarkSpoilers from "@/shared/lib/remarkSpoilers";
|
||||
|
||||
import { messageLinkUrlTransform } from "./utils";
|
||||
import { buzzDeepLinkUrlTransform } from "./utils";
|
||||
|
||||
/**
|
||||
* Parsed-markdown element cache.
|
||||
@@ -105,7 +105,7 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
|
||||
] as any[],
|
||||
rehypePlugins,
|
||||
urlTransform: messageLinkUrlTransform,
|
||||
urlTransform: buzzDeepLinkUrlTransform,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ import type { MarkdownRuntime } from "./types";
|
||||
const INERT_MARKDOWN_RUNTIME: MarkdownRuntime = {
|
||||
channels: [],
|
||||
onOpenChannel: () => {},
|
||||
onOpenEntityLink: () => {},
|
||||
onOpenMessageLink: () => {},
|
||||
relayOrigin: null,
|
||||
};
|
||||
|
||||
export const MarkdownRuntimeContext = React.createContext<MarkdownRuntime>(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ParsedMessageLink } from "@/features/messages/lib/messageLink";
|
||||
import type { ParsedEntityLink } from "@/shared/lib/entityLink";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
|
||||
import type { VideoReviewContext } from "../VideoPlayer";
|
||||
@@ -31,7 +32,15 @@ export type MarkdownRuntime = {
|
||||
imetaByUrl?: ImetaLookup;
|
||||
mentionPubkeysByName?: Record<string, string>;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
/** Navigate to a Buzz git entity (`buzz://pr|issue|repo` deep link). */
|
||||
onOpenEntityLink: (link: ParsedEntityLink) => void;
|
||||
onOpenMessageLink: (link: ParsedMessageLink) => void;
|
||||
/**
|
||||
* The resolved relay origin (e.g. `https://buzz.block.builderlab.xyz`),
|
||||
* or `null` when not yet resolved. Used by the anchor component to
|
||||
* validate that clone-URL rewrites point to the active relay only.
|
||||
*/
|
||||
relayOrigin: string | null;
|
||||
/** Display name of the message author sharing an agent snapshot. */
|
||||
snapshotSharedBy?: string;
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { defaultUrlTransform } from "react-markdown";
|
||||
|
||||
import { isMessageLink } from "@/features/messages/lib/messageLink";
|
||||
import { parseEntityLink } from "@/shared/lib/entityLink";
|
||||
|
||||
export function useStableArray<T>(arr: T[]): T[] {
|
||||
const ref = React.useRef(arr);
|
||||
@@ -166,16 +167,32 @@ export function isInsideHiddenSpoiler(element: Element): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* `urlTransform` for `<ReactMarkdown>` that preserves `buzz://message?…`
|
||||
* links. The default transform strips unknown schemes (returns `""`) before
|
||||
* the `a` component override can see them, which would break copy → paste →
|
||||
* click end-to-end. Everything else delegates to `defaultUrlTransform`.
|
||||
* `urlTransform` for `<ReactMarkdown>` that preserves `buzz://` deep links
|
||||
* used by Buzz — both `buzz://message?…` links and `buzz://pr|issue|repo?…`
|
||||
* entity links. The default transform strips unknown schemes (returns `""`)
|
||||
* before the `a` component override can see them, which would break copy →
|
||||
* paste → click end-to-end.
|
||||
*
|
||||
* Policy:
|
||||
* - `buzz://message` hrefs — preserved unconditionally (handled by the
|
||||
* message-link pill renderer).
|
||||
* - `buzz://pr|issue|repo` hrefs — preserved only when `parseEntityLink`
|
||||
* succeeds, keeping the sanitizer active against arbitrary `buzz://` URIs.
|
||||
* - Everything else delegates to `defaultUrlTransform`.
|
||||
*/
|
||||
export function buzzDeepLinkUrlTransform(value: string, key: string): string {
|
||||
if (key !== "href") return defaultUrlTransform(value);
|
||||
if (isMessageLink(value)) return value;
|
||||
if (parseEntityLink(value).ok) return value;
|
||||
return defaultUrlTransform(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Preserved for external callers; use `buzzDeepLinkUrlTransform`
|
||||
* which also handles `buzz://pr|issue|repo` entity links.
|
||||
*/
|
||||
export function messageLinkUrlTransform(value: string, key: string): string {
|
||||
if (key === "href" && isMessageLink(value)) {
|
||||
return value;
|
||||
}
|
||||
return defaultUrlTransform(value);
|
||||
return buzzDeepLinkUrlTransform(value, key);
|
||||
}
|
||||
|
||||
export function getReactNodeText(node: React.ReactNode): string {
|
||||
|
||||
Reference in New Issue
Block a user