mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): route text copies through native clipboard (#2054)
Signed-off-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n
parent
738d456367
commit
9f82850959
@@ -0,0 +1,36 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
/// App-lifetime clipboard ownership keeps copied data available on Linux and
|
||||
/// serializes access on Windows. All operations still run on Tauri's main
|
||||
/// thread for macOS/AppKit safety.
|
||||
pub struct ClipboardState(Mutex<Option<arboard::Clipboard>>);
|
||||
|
||||
impl ClipboardState {
|
||||
pub fn new() -> Self {
|
||||
Self(Mutex::new(None))
|
||||
}
|
||||
|
||||
pub fn release(&self) {
|
||||
if let Ok(mut clipboard) = self.0.lock() {
|
||||
clipboard.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_clipboard<T>(
|
||||
app: &tauri::AppHandle,
|
||||
operation: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
|
||||
) -> Result<T, String> {
|
||||
let state = app.state::<ClipboardState>();
|
||||
let mut stored = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "clipboard state lock poisoned".to_string())?;
|
||||
if stored.is_none() {
|
||||
*stored = Some(arboard::Clipboard::new().map_err(|e| format!("clipboard error: {e}"))?);
|
||||
}
|
||||
operation(stored.as_mut().expect("clipboard initialized"))
|
||||
.map_err(|e| format!("clipboard error: {e}"))
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use sha2::{Digest, Sha256};
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::commands::clipboard::with_clipboard;
|
||||
use crate::commands::export_util::save_bytes_with_dialog;
|
||||
use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth, sanitize_filename};
|
||||
use crate::commands::{
|
||||
@@ -201,18 +202,15 @@ pub async fn copy_image_to_clipboard(
|
||||
// arboard requires main-thread access on macOS. Use a sync channel so the
|
||||
// async command can await the result.
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||
let clipboard_app = app.clone();
|
||||
app.run_on_main_thread(move || {
|
||||
let result = arboard::Clipboard::new()
|
||||
.map_err(|e| format!("clipboard error: {e}"))
|
||||
.and_then(|mut clipboard| {
|
||||
clipboard
|
||||
.set_image(arboard::ImageData {
|
||||
width,
|
||||
height,
|
||||
bytes: std::borrow::Cow::Owned(raw),
|
||||
})
|
||||
.map_err(|e| format!("clipboard error: {e}"))
|
||||
});
|
||||
let result = with_clipboard(&clipboard_app, |clipboard| {
|
||||
clipboard.set_image(arboard::ImageData {
|
||||
width,
|
||||
height,
|
||||
bytes: std::borrow::Cow::Owned(raw),
|
||||
})
|
||||
});
|
||||
// Ignore send errors — the receiver dropped only if the command was
|
||||
// cancelled, in which case nobody is waiting for the result.
|
||||
let _ = tx.send(result);
|
||||
@@ -235,20 +233,15 @@ pub async fn copy_text_to_clipboard(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||
let clipboard_app = app.clone();
|
||||
app.run_on_main_thread(move || {
|
||||
let result = arboard::Clipboard::new()
|
||||
.map_err(|e| format!("clipboard error: {e}"))
|
||||
.and_then(|mut clipboard| {
|
||||
if let Some(html) = html {
|
||||
clipboard
|
||||
.set_html(html, Some(text))
|
||||
.map_err(|e| format!("clipboard error: {e}"))
|
||||
} else {
|
||||
clipboard
|
||||
.set_text(text)
|
||||
.map_err(|e| format!("clipboard error: {e}"))
|
||||
}
|
||||
});
|
||||
let result = with_clipboard(&clipboard_app, |clipboard| {
|
||||
if let Some(html) = html {
|
||||
clipboard.set_html(html, Some(text))
|
||||
} else {
|
||||
clipboard.set_text(text)
|
||||
}
|
||||
});
|
||||
let _ = tx.send(result);
|
||||
})
|
||||
.map_err(|e| format!("main thread dispatch failed: {e}"))?;
|
||||
|
||||
@@ -12,6 +12,7 @@ mod canvas;
|
||||
mod channel_templates;
|
||||
mod channel_window;
|
||||
mod channels;
|
||||
mod clipboard;
|
||||
mod dms;
|
||||
mod engrams;
|
||||
mod export_util;
|
||||
@@ -61,6 +62,7 @@ pub use canvas::*;
|
||||
pub use channel_templates::*;
|
||||
pub use channel_window::*;
|
||||
pub use channels::*;
|
||||
pub use clipboard::*;
|
||||
pub use dms::*;
|
||||
pub use engrams::*;
|
||||
pub use global_agent_config::*;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Deep async call chains (mesh ensure→download→start under Tauri command
|
||||
// futures) exceed the default query depth when computing layouts.
|
||||
// Deep async call chains under Tauri command futures exceed the default query depth when computing layouts.
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
mod app_state;
|
||||
mod archive;
|
||||
mod commands;
|
||||
@@ -450,6 +448,7 @@ pub fn run() {
|
||||
});
|
||||
})
|
||||
.manage(build_app_state())
|
||||
.manage(ClipboardState::new())
|
||||
.manage(PendingCommunityDeepLinks::default())
|
||||
.manage(commands::pairing::PairingHandle::new())
|
||||
.setup(move |app| {
|
||||
@@ -980,6 +979,7 @@ pub fn run() {
|
||||
}
|
||||
RunEvent::Exit => {
|
||||
shut_down_app(app_handle, &run_shutdown_done);
|
||||
app_handle.state::<ClipboardState>().release();
|
||||
|
||||
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
|
||||
if restart_requested.load(Ordering::SeqCst) {
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
ToggleRow,
|
||||
} from "./ChannelManagementSheetRows";
|
||||
import { ChannelManagementModerationActions } from "./ChannelManagementModerationActions";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
type ChannelManagementSheetProps = {
|
||||
channel: Channel | null;
|
||||
@@ -788,9 +789,9 @@ function ChannelManagementPanelContent({
|
||||
icon={Copy}
|
||||
label="Copy ID"
|
||||
onClick={() => {
|
||||
void navigator.clipboard
|
||||
.writeText(resolvedChannel.id)
|
||||
.then(() => toast.success("Copied channel ID"));
|
||||
void writeTextToClipboard(resolvedChannel.id).then(() =>
|
||||
toast.success("Copied channel ID"),
|
||||
);
|
||||
}}
|
||||
testId="channel-management-copy-id-action"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { toast } from "sonner";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
function getChannelIcon(channelType: Channel["channelType"]): LucideIcon {
|
||||
if (channelType === "forum") {
|
||||
@@ -126,7 +127,7 @@ export function CopyFieldRow({
|
||||
testId?: string;
|
||||
}) {
|
||||
async function handleCopy() {
|
||||
await navigator.clipboard.writeText(value);
|
||||
await writeTextToClipboard(value);
|
||||
toast.success(`Copied ${label.toLowerCase()}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
type ChatHeaderProps = {
|
||||
actions?: React.ReactNode;
|
||||
@@ -104,7 +105,7 @@ export function ChatHeader({
|
||||
if (!value) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
await writeTextToClipboard(value);
|
||||
toast.success("Channel name copied");
|
||||
} catch {
|
||||
toast.error("Failed to copy channel name");
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Button } from "@/shared/ui/button";
|
||||
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
|
||||
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
|
||||
import { OnboardingChrome } from "@/features/onboarding/ui/OnboardingChrome";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
type WelcomeSetupPage = "welcome" | "join" | "invite";
|
||||
type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
|
||||
@@ -190,7 +191,7 @@ export function WelcomeSetup({
|
||||
className="h-10 w-10 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
disabled={!npub}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(npub).then(() => {
|
||||
void writeTextToClipboard(npub).then(() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
const TTL_OPTIONS: { label: string; value: number }[] = [
|
||||
{ label: "1 day", value: 24 * 60 * 60 },
|
||||
@@ -72,7 +73,7 @@ export function InviteLinkSection() {
|
||||
async function handleCopy() {
|
||||
if (!invite) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(invite.url);
|
||||
await writeTextToClipboard(invite.url);
|
||||
setCopied(true);
|
||||
toast.success("Invite link copied");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Input } from "@/shared/ui/input";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
|
||||
import { InviteRedeemForm } from "./InviteRedeemForm";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
type MembershipDeniedProps = {
|
||||
/** The relay that denied membership — used as the target for bare-code invites. */
|
||||
@@ -53,7 +54,7 @@ export function MembershipDenied({
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(npub);
|
||||
await writeTextToClipboard(npub);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Check, Copy, Eye, EyeOff } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
type NsecMaskedDisplayProps = {
|
||||
nsec: string;
|
||||
@@ -43,7 +44,7 @@ export function NsecMaskedDisplay({
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
await navigator.clipboard.writeText(nsec);
|
||||
await writeTextToClipboard(nsec);
|
||||
setIsCopied(true);
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => setIsCopied(false), 2000);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
const COPY_SUCCESS_MESSAGE =
|
||||
"Signed response copied. Paste it into the Buzz admin console.";
|
||||
@@ -103,7 +104,7 @@ function formatError(error: unknown): string {
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
await writeTextToClipboard(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("copy signed nostr binding response failed:", error);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Check, Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
/** Icon button that copies arbitrary text with a brief check feedback. */
|
||||
export function CopyTextButton({
|
||||
@@ -15,7 +16,7 @@ export function CopyTextButton({
|
||||
}) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const handleCopy = React.useCallback(() => {
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
void writeTextToClipboard(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2_000);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
toggleNoteIdInSet,
|
||||
} from "@/features/pulse/lib/noteActions";
|
||||
import type { UserNote } from "@/shared/api/socialTypes";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
export type PulseNoteActions = {
|
||||
isReplySending: boolean;
|
||||
@@ -144,7 +145,7 @@ export function usePulseNoteActions({
|
||||
|
||||
const share = React.useCallback(async (note: UserNote) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildNoteShareUri(note));
|
||||
await writeTextToClipboard(buildNoteShareUri(note));
|
||||
toast.success("Copied note link");
|
||||
} catch {
|
||||
toast.error("Failed to copy note link");
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/shared/ui/dialog";
|
||||
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
type PairingStep =
|
||||
| "generating"
|
||||
@@ -168,7 +169,7 @@ function PairingDialog({
|
||||
|
||||
async function handleCopy() {
|
||||
if (!qrUri) return;
|
||||
await navigator.clipboard.writeText(qrUri);
|
||||
await writeTextToClipboard(qrUri);
|
||||
toast.success("Copied to clipboard");
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Input } from "@/shared/ui/input";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
type ProfileSettingsCardProps = {
|
||||
currentPubkey?: string;
|
||||
@@ -85,7 +86,7 @@ function IdentityRow({
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-muted px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
data-testid={`copy-${testId}`}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(copyValue);
|
||||
await writeTextToClipboard(copyValue);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
title={`Copy ${label}`}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { getInitials } from "@/shared/lib/initials";
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
import { useIsFullscreen } from "@/shared/lib/useIsFullscreen";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
|
||||
type CommunityRailProps = {
|
||||
communities: Community[];
|
||||
@@ -223,7 +224,7 @@ export function CommunityRail({
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(community.relayUrl);
|
||||
void writeTextToClipboard(community.relayUrl);
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Copy plain text to the clipboard with a success toast, surfacing
|
||||
* `writeText` rejections (permissions, unfocused document) as an error
|
||||
* toast instead of an unhandled rejection.
|
||||
*/
|
||||
import { copyTextToSystemClipboard } from "@/shared/api/tauriMedia";
|
||||
|
||||
/** Write plain text through the native clipboard integration. */
|
||||
export async function writeTextToClipboard(text: string): Promise<void> {
|
||||
await copyTextToSystemClipboard(text);
|
||||
}
|
||||
|
||||
/** Copy plain text and show standard success/error feedback. */
|
||||
export function copyTextToClipboard(
|
||||
text: string,
|
||||
successMessage = "Copied to clipboard",
|
||||
) {
|
||||
void navigator.clipboard
|
||||
.writeText(text)
|
||||
void writeTextToClipboard(text)
|
||||
.then(() => {
|
||||
toast.success(successMessage);
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { copyTextToSystemClipboard } from "@/shared/api/tauriMedia";
|
||||
|
||||
const BUZZ_CODE_BLOCK_ATTRIBUTE = "data-buzz-code-block";
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
@@ -15,13 +17,10 @@ function createBuzzCodeBlockHtml(code: string) {
|
||||
|
||||
export async function copyCodeBlockToClipboard(code: string) {
|
||||
const clipboard = navigator.clipboard;
|
||||
if (!clipboard) {
|
||||
throw new Error("Clipboard API is unavailable");
|
||||
}
|
||||
|
||||
if (
|
||||
typeof ClipboardItem !== "undefined" &&
|
||||
typeof clipboard.write === "function"
|
||||
typeof clipboard?.write === "function"
|
||||
) {
|
||||
try {
|
||||
await clipboard.write([
|
||||
@@ -38,7 +37,7 @@ export async function copyCodeBlockToClipboard(code: string) {
|
||||
}
|
||||
}
|
||||
|
||||
await clipboard.writeText(code);
|
||||
await copyTextToSystemClipboard(code);
|
||||
}
|
||||
|
||||
export function getBuzzCodeBlockClipboardText(
|
||||
|
||||
@@ -1388,7 +1388,7 @@ test.describe("inbox stable-conversation regressions", () => {
|
||||
// be truly centered rather than clamped at the scroll container floor.
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const reply = emit({
|
||||
emit({
|
||||
channelName: "general",
|
||||
content: `Reaction-drift later reply ${i} — provides content below the selected message for a non-clamped center.`,
|
||||
parentEventId: fetchRoot.id,
|
||||
|
||||
@@ -46,15 +46,6 @@ type LatencyReport = {
|
||||
longtaskTotal: number;
|
||||
};
|
||||
|
||||
function quantile(sorted: number[], q: number): number {
|
||||
if (sorted.length === 0) return 0;
|
||||
const index = Math.min(
|
||||
sorted.length - 1,
|
||||
Math.floor(q * (sorted.length - 1)),
|
||||
);
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
async function resetWindowMetrics(page: import("@playwright/test").Page) {
|
||||
await page.evaluate(() => {
|
||||
const store = window as unknown as {
|
||||
|
||||
Reference in New Issue
Block a user