mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
Merge remote-tracking branch 'origin/main' into duncan/agent-usage-archive
* origin/main: (22 commits) feat(catalog): resolve publisher display name in catalog detail pane (#3640) feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) (#3741) docs(nips): specify kind:30621 multi-repo projects (NIP-MP) (#3163) Refine agent sharing dialog (#3699) desktop: enable getUserMedia in the Linux WebKitGTK webview (#3607) fix: align responsive agent views (#3688) Add macOS agent menu-bar menu (#3565) Fix pending message feedback (#3543) fix(desktop): remove remaining Projects panel fills (#3742) feat(mobile): desktop-parity emoji and thread experience (#3485) desktop: restore direct community member adds (#3634) fix(desktop): explain open agent access (#2561) fix(cli): resolve agents from owner records (#3178) fix(desktop): remove Projects overview card fills (#3416) feat(replica): portable heartbeat-token fence with snapshot-local reader routing (#3268) fix(git): channel binding tooling + author remediation for unbound repos (#3626) feat: configure S3 URL addressing style (#3400) feat: add first-class OpenRouter provider support (#1975) feat(agent,acp): wire provider total_tokens through NIP-AM publish chain (#3593) chore(release): release Buzz Desktop version 0.5.2 (#3624) ... Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
6
desktop/src/shared/api/trayMenu.ts
Normal file
6
desktop/src/shared/api/trayMenu.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
|
||||
/** Clears community-scoped agent activity from the native tray menu. */
|
||||
export function clearTrayAgentActivity(): Promise<void> {
|
||||
return invokeTauri("clear_tray_agent_activity");
|
||||
}
|
||||
@@ -27,6 +27,42 @@ test("mediaProxyUrl: uses the IPv4 loopback literal for the localhost proxy", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("media-proxy port store: resolved port publishes and reset notifies subscribers", async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
let notifications = 0;
|
||||
|
||||
globalThis.window = {
|
||||
__TAURI_INTERNALS__: {
|
||||
invoke(command) {
|
||||
if (command === "get_media_proxy_port") return Promise.resolve(54321);
|
||||
if (command === "get_relay_http_url") {
|
||||
return Promise.resolve("https://relay.example");
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected command: ${command}`));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const mediaUrl = await import(`./mediaUrl.ts?portStore=${Date.now()}`);
|
||||
const unsubscribe = mediaUrl.subscribeMediaProxyPort(() => notifications++);
|
||||
|
||||
mediaUrl.rewriteRelayUrl(`https://relay.example/media/${HASH}.png`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(mediaUrl.getCachedMediaProxyPort(), 54321);
|
||||
assert.equal(notifications, 1);
|
||||
|
||||
mediaUrl.resetMediaCaches();
|
||||
assert.equal(mediaUrl.getCachedMediaProxyPort(), null);
|
||||
assert.equal(notifications, 2);
|
||||
|
||||
unsubscribe();
|
||||
} finally {
|
||||
globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test("relay-origin store: publishes are canonicalized at the store boundary", () => {
|
||||
// The store must hold the invariant that `cachedRelayOrigin` is always a
|
||||
// canonical URL origin — consumers compare `new URL(src).origin` against it
|
||||
|
||||
@@ -63,10 +63,17 @@ let cacheGeneration = 0;
|
||||
/** `useSyncExternalStore` listeners for relay-origin changes. */
|
||||
const relayOriginListeners = new Set<() => void>();
|
||||
|
||||
/** `useSyncExternalStore` listeners for media-proxy port changes. */
|
||||
const mediaProxyPortListeners = new Set<() => void>();
|
||||
|
||||
function notifyRelayOriginListeners(): void {
|
||||
for (const listener of relayOriginListeners) listener();
|
||||
}
|
||||
|
||||
function notifyMediaProxyPortListeners(): void {
|
||||
for (const listener of mediaProxyPortListeners) listener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a resolved relay origin, but only if `generation` is still current
|
||||
* (the fetch wasn't superseded by a workspace switch). The stored snapshot is
|
||||
@@ -108,6 +115,18 @@ export function subscribeRelayOrigin(listener: () => void): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to media-proxy port changes. Returns a stable unsubscribe function
|
||||
* for `useSyncExternalStore` consumers that need to re-run `rewriteRelayUrl`
|
||||
* once the async Tauri proxy lookup has resolved.
|
||||
*/
|
||||
export function subscribeMediaProxyPort(listener: () => void): () => void {
|
||||
mediaProxyPortListeners.add(listener);
|
||||
return () => {
|
||||
mediaProxyPortListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 100;
|
||||
const POLL_TIMEOUT_MS = 5000;
|
||||
|
||||
@@ -188,7 +207,10 @@ async function fetchProxyPort(): Promise<number | null> {
|
||||
deadline,
|
||||
);
|
||||
if (port !== null && port > 0 && generation === cacheGeneration) {
|
||||
cachedPort = port;
|
||||
if (cachedPort !== port) {
|
||||
cachedPort = port;
|
||||
notifyMediaProxyPortListeners();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// invoke failed (e.g. Tauri IPC not ready yet) — keep retrying
|
||||
@@ -227,8 +249,12 @@ if (typeof window !== "undefined") {
|
||||
*/
|
||||
export function resetMediaCaches(): void {
|
||||
cacheGeneration += 1;
|
||||
const hadCachedPort = cachedPort !== null;
|
||||
cachedPort = null;
|
||||
portPromise = null;
|
||||
if (hadCachedPort) {
|
||||
notifyMediaProxyPortListeners();
|
||||
}
|
||||
if (cachedRelayOrigin !== null) {
|
||||
cachedRelayOrigin = null;
|
||||
notifyRelayOriginListeners();
|
||||
@@ -246,6 +272,14 @@ export function getCachedRelayOrigin(): string | null {
|
||||
return cachedRelayOrigin;
|
||||
}
|
||||
|
||||
/**
|
||||
* The localhost proxy port if it has been resolved, else `null`. Synchronous
|
||||
* best-effort read of the same cache `rewriteRelayUrl` uses.
|
||||
*/
|
||||
export function getCachedMediaProxyPort(): number | null {
|
||||
return cachedPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the local proxy URL with an IPv4 literal. The Rust proxy binds
|
||||
* `127.0.0.1:0`, not `::1`, and some WebViews resolve `localhost` to IPv6
|
||||
|
||||
@@ -23,6 +23,34 @@ export function safeNpub(pubkey: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/;
|
||||
|
||||
/**
|
||||
* Parse user-entered public key input — either a 64-character hex pubkey or
|
||||
* a bech32 `npub1…` string — into a lowercase hex pubkey. Returns null for
|
||||
* anything else (does NOT throw — intended for live form validation).
|
||||
*
|
||||
* The input is trimmed first; surrounding whitespace from copy-paste is
|
||||
* tolerated.
|
||||
*/
|
||||
export function parsePubkeyInput(input: string): string | null {
|
||||
const trimmed = input.trim().toLowerCase();
|
||||
if (HEX_PUBKEY_REGEX.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
try {
|
||||
const decoded = decode(trimmed);
|
||||
if (decoded.type === "npub") {
|
||||
return decoded.data;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a bech32 nsec string and derive the matching npub. Returns null if
|
||||
* the input is not a syntactically valid `nsec1…` (does NOT throw — this is
|
||||
|
||||
50
desktop/src/shared/lib/parsePubkeyInput.test.mjs
Normal file
50
desktop/src/shared/lib/parsePubkeyInput.test.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { parsePubkeyInput } from "./nostrUtils.ts";
|
||||
|
||||
const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328";
|
||||
const NPUB = "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60";
|
||||
|
||||
describe("parsePubkeyInput", () => {
|
||||
it("accepts a lowercase 64-char hex pubkey", () => {
|
||||
assert.equal(parsePubkeyInput(HEX), HEX);
|
||||
});
|
||||
|
||||
it("lowercases an uppercase hex pubkey", () => {
|
||||
assert.equal(parsePubkeyInput(HEX.toUpperCase()), HEX);
|
||||
});
|
||||
|
||||
it("decodes an npub to its hex pubkey", () => {
|
||||
assert.equal(parsePubkeyInput(NPUB), HEX);
|
||||
});
|
||||
|
||||
it("tolerates surrounding whitespace from copy-paste", () => {
|
||||
assert.equal(parsePubkeyInput(` ${NPUB}\n`), HEX);
|
||||
assert.equal(parsePubkeyInput(` ${HEX} `), HEX);
|
||||
});
|
||||
|
||||
it("rejects an npub with a corrupted checksum", () => {
|
||||
assert.equal(parsePubkeyInput(`${NPUB.slice(0, -1)}q`), null);
|
||||
});
|
||||
|
||||
it("rejects other bech32 entities such as nsec", () => {
|
||||
assert.equal(
|
||||
parsePubkeyInput(
|
||||
"nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects hex of the wrong length", () => {
|
||||
assert.equal(parsePubkeyInput(HEX.slice(0, 63)), null);
|
||||
assert.equal(parsePubkeyInput(`${HEX}0`), null);
|
||||
});
|
||||
|
||||
it("rejects non-hex non-npub input", () => {
|
||||
assert.equal(parsePubkeyInput(""), null);
|
||||
assert.equal(parsePubkeyInput("alice"), null);
|
||||
assert.equal(parsePubkeyInput(`z${HEX.slice(1)}`), null);
|
||||
});
|
||||
});
|
||||
17
desktop/src/shared/lib/useMediaProxyPort.ts
Normal file
17
desktop/src/shared/lib/useMediaProxyPort.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { getCachedMediaProxyPort, subscribeMediaProxyPort } from "./mediaUrl";
|
||||
|
||||
/**
|
||||
* The resolved localhost media-proxy port, re-rendering when it resolves or
|
||||
* changes. Components that call `rewriteRelayUrl` during render can subscribe
|
||||
* to this so an initial `buzz-media://` fallback is replaced by the loopback
|
||||
* proxy URL as soon as the Tauri backend reports the port.
|
||||
*/
|
||||
export function useMediaProxyPort(): number | null {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeMediaProxyPort,
|
||||
getCachedMediaProxyPort,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
@@ -481,6 +481,13 @@
|
||||
color: var(--buzz-muted-foreground);
|
||||
}
|
||||
|
||||
/* Match the hover treatment used by inactive channel rows. */
|
||||
:root[data-buzz-sidebar]
|
||||
[data-testid="app-sidebar"]
|
||||
[data-testid="sidebar-profile-card"]:hover {
|
||||
background-color: var(--buzz-hover-surface);
|
||||
}
|
||||
|
||||
/*
|
||||
* App and settings sidebar section headings plus secondary footer text use the
|
||||
* same tint. Navigation rows keep their normal foreground in both sidebars.
|
||||
|
||||
Reference in New Issue
Block a user