mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent
4d58deafaa
commit
144b4f6c8d
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,/);
|
||||
});
|
||||
Reference in New Issue
Block a user