fix(desktop): make invite QR downloadable (#2168)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-20 15:29:22 +00:00
committed by GitHub
co-authored by npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent 4d58deafaa
commit 144b4f6c8d
7 changed files with 154 additions and 2 deletions
+1
View File
@@ -101,6 +101,7 @@ export default defineConfig({
"**/channel-sort.spec.ts",
"**/identity-lost.spec.ts",
"**/deep-link-invite.spec.ts",
"**/invite-qr-download.spec.ts",
"**/global-agent-config-screenshots.spec.ts",
"**/doctor-states.spec.ts",
"**/onboarding-avatar-skip.spec.ts",
+2
View File
@@ -40,6 +40,7 @@ mod project_git_exec;
mod project_git_workflow;
mod project_repo_paths;
mod project_terminal;
mod qr_download;
mod relay_members;
mod relay_reconnect;
mod social;
@@ -88,6 +89,7 @@ pub use project_git::*;
pub use project_git_diff::*;
pub use project_git_workflow::*;
pub use project_terminal::*;
pub use qr_download::*;
pub use relay_members::*;
pub use relay_reconnect::*;
pub use social::*;
@@ -0,0 +1,52 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
use crate::commands::export_util::save_bytes_with_dialog;
use crate::commands::media::sanitize_filename;
use crate::commands::personas::PNG_MAGIC;
fn decode_png_data_url(data_url: &str) -> Result<Vec<u8>, String> {
let encoded = data_url
.strip_prefix("data:image/png;base64,")
.ok_or_else(|| "invalid PNG data URL".to_string())?;
let bytes = STANDARD
.decode(encoded.trim())
.map_err(|_| "invalid PNG data URL".to_string())?;
if !bytes.starts_with(&PNG_MAGIC) {
return Err("invalid PNG data URL".to_string());
}
Ok(bytes)
}
/// Save a generated PNG data URL through the native save-file dialog.
#[tauri::command]
pub async fn save_png_data_url(
data_url: String,
filename: String,
app: tauri::AppHandle,
) -> Result<bool, String> {
let bytes = decode_png_data_url(&data_url)?;
let filename = sanitize_filename(&filename);
save_bytes_with_dialog(&app, &filename, "PNG image", &["png"], &bytes).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_png_data_url_accepts_png_payload() {
let encoded = STANDARD.encode([PNG_MAGIC.as_slice(), &[0, 1, 2]].concat());
let bytes = decode_png_data_url(&format!("data:image/png;base64,{encoded}")).unwrap();
assert!(bytes.starts_with(&PNG_MAGIC));
}
#[test]
fn decode_png_data_url_rejects_wrong_mime_or_bytes() {
let png = STANDARD.encode(PNG_MAGIC);
assert!(decode_png_data_url(&format!("data:image/jpeg;base64,{png}")).is_err());
let not_png = STANDARD.encode(b"not a png");
assert!(decode_png_data_url(&format!("data:image/png;base64,{not_png}")).is_err());
assert!(decode_png_data_url("data:image/png;base64,not-base64!").is_err());
}
}
+1
View File
@@ -745,6 +745,7 @@ pub fn run() {
pick_and_upload_image,
upload_media_bytes,
download_image,
save_png_data_url,
download_file,
fetch_media_bytes,
copy_image_to_clipboard,
@@ -1,9 +1,10 @@
import { Check, Copy, Link2 } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import { QRCodeCanvas } from "qrcode.react";
import * as React from "react";
import { toast } from "sonner";
import { mintInvite } from "@/shared/api/invites";
import { invokeTauri } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -15,6 +16,11 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
import {
MediaContextMenu,
type MediaContextMenuPosition,
useDismissMediaContextMenu,
} from "@/shared/ui/markdown/MediaContextMenu";
const TTL_OPTIONS: { label: string; value: number }[] = [
{ label: "1 day", value: 24 * 60 * 60 },
@@ -48,6 +54,12 @@ export function InviteLinkSection() {
expiresAt: number;
} | null>(null);
const [copied, setCopied] = React.useState(false);
const [qrMenu, setQrMenu] = React.useState<MediaContextMenuPosition | null>(
null,
);
const qrRef = React.useRef<HTMLCanvasElement | null>(null);
const closeQrMenu = React.useCallback(() => setQrMenu(null), []);
useDismissMediaContextMenu(Boolean(qrMenu), closeQrMenu);
const ttlLabel =
TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days";
@@ -82,6 +94,20 @@ export function InviteLinkSection() {
}
}
async function handleDownloadQr() {
closeQrMenu();
const dataUrl = qrRef.current?.toDataURL("image/png");
if (!dataUrl) return;
try {
await invokeTauri("save_png_data_url", {
dataUrl,
filename: "buzz-community-invite.png",
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Download failed");
}
}
return (
<div className="space-y-1.5">
<span className="text-sm font-medium">Invite link</span>
@@ -160,10 +186,17 @@ export function InviteLinkSection() {
{invite ? (
<div className="flex flex-col gap-3 rounded-md border border-border/70 bg-background/70 p-3 sm:flex-row sm:items-center">
<div className="shrink-0 self-center rounded-md bg-white p-2 text-black">
<QRCodeSVG
<QRCodeCanvas
ref={qrRef}
aria-label="Invite QR code"
data-testid="invite-link-qr-code"
level="M"
onContextMenuCapture={(event) => {
event.preventDefault();
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation();
setQrMenu({ x: event.clientX, y: event.clientY });
}}
size={128}
value={invite.url}
/>
@@ -185,6 +218,18 @@ export function InviteLinkSection() {
until it expires.
</p>
)}
{qrMenu ? (
<MediaContextMenu
dataAttributes={["data-invite-qr-context-menu"]}
items={[
{
label: "Download image",
onSelect: () => void handleDownloadQr(),
},
]}
position={qrMenu}
/>
) : null}
</div>
);
}
+1
View File
@@ -10183,6 +10183,7 @@ export function maybeInstallE2eTauriMocks() {
return buf;
}
case "download_image":
case "save_png_data_url":
case "download_file":
// The save dialog can't run headlessly; report a successful save so the
// FileCard / image-menu click handlers resolve. Specs assert the
@@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
await page.route("**/api/invites", async (route) => {
await route.fulfill({
contentType: "application/json",
json: {
code: "qr-download-test",
expires_at: Math.floor(Date.now() / 1000) + 86_400,
url: "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=qr-download-test",
},
status: 200,
});
});
});
test("invite QR reuses the media menu and saves its PNG", async ({ page }) => {
await page.goto("/");
await openSettings(page, "community-members");
await expect(page.getByTestId("settings-community-members")).toBeVisible();
await page.getByTestId("create-invite-link").click();
const qrCode = page.getByTestId("invite-link-qr-code");
await expect(qrCode).toBeVisible();
await qrCode.click({ button: "right", position: { x: 32, y: 32 } });
const menu = page.locator("[data-invite-qr-context-menu]");
await expect(menu).toBeVisible();
await menu.getByRole("button", { name: "Download image" }).click();
await expect(menu).not.toBeVisible();
const payload = await page.evaluate(() => {
const log = (
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: Record<string, unknown> | null;
}>;
}
).__BUZZ_E2E_COMMAND_LOG__;
return log?.find(({ command }) => command === "save_png_data_url")?.payload;
});
expect(payload?.filename).toBe("buzz-community-invite.png");
expect(payload?.dataUrl).toMatch(/^data:image\/png;base64,/);
});