mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 23:43:35 +00:00
Fix Recordly 2.0 part 5 follow-up review findings
Preserve existing PR history and integrate validated current-cycle fixes.
This commit is contained in:
commit
ee287a687f
6 files changed
+145
-62
No files matched your search
@@ -102,7 +102,7 @@ export function AccountSettings({
|
||||
submittedForm.current = event.currentTarget;
|
||||
void run("email", async () => {
|
||||
const updated = await requestAccountEmailChange(email);
|
||||
return updated.email === email.trim()
|
||||
return updated.email?.toLowerCase() === email.trim().toLowerCase()
|
||||
? t("settings.account.emailUpdated", "Email address updated.")
|
||||
: t(
|
||||
"settings.account.confirmInboxes",
|
||||
|
||||
@@ -17,71 +17,85 @@ export function RecordingPermissionsSettings({ onReview }: { onReview?: () => vo
|
||||
const { t } = useI18n();
|
||||
const mounted = useRef(false);
|
||||
const pending = useRef(false);
|
||||
const refreshQueued = useRef(false);
|
||||
const [snapshot, setSnapshot] = useState<PermissionSnapshot>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [opening, setOpening] = useState<Permission>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [actionError, setActionError] = useState<{ permission: Permission; message: string }>();
|
||||
const refresh = useCallback(async () => {
|
||||
if (pending.current) return;
|
||||
if (!mounted.current) return;
|
||||
if (pending.current) {
|
||||
refreshQueued.current = true;
|
||||
return;
|
||||
}
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const mac = (await window.electronAPI.getPlatform()) === "darwin";
|
||||
const values: PermissionSnapshot["values"] = {};
|
||||
let failed = false;
|
||||
const results = await Promise.allSettled([
|
||||
mac
|
||||
? window.electronAPI.getScreenRecordingPermissionStatus()
|
||||
: Promise.resolve(null),
|
||||
mac ? window.electronAPI.getAccessibilityPermissionStatus() : Promise.resolve(null),
|
||||
typeof window.electronAPI.getMediaPermissionStatus === "function"
|
||||
? window.electronAPI.getMediaPermissionStatus()
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const [screen, accessibility, media] = results;
|
||||
if (mac) {
|
||||
if (screen.status === "fulfilled" && screen.value?.success)
|
||||
values.screen = normalize(screen.value.status);
|
||||
else {
|
||||
values.screen = "unknown";
|
||||
failed = true;
|
||||
do {
|
||||
refreshQueued.current = false;
|
||||
try {
|
||||
const mac = (await window.electronAPI.getPlatform()) === "darwin";
|
||||
const values: PermissionSnapshot["values"] = {};
|
||||
let failed = false;
|
||||
const results = await Promise.allSettled([
|
||||
mac
|
||||
? window.electronAPI.getScreenRecordingPermissionStatus()
|
||||
: Promise.resolve(null),
|
||||
mac
|
||||
? window.electronAPI.getAccessibilityPermissionStatus()
|
||||
: Promise.resolve(null),
|
||||
typeof window.electronAPI.getMediaPermissionStatus === "function"
|
||||
? window.electronAPI.getMediaPermissionStatus()
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const [screen, accessibility, media] = results;
|
||||
if (mac) {
|
||||
if (screen.status === "fulfilled" && screen.value?.success)
|
||||
values.screen = normalize(screen.value.status);
|
||||
else {
|
||||
values.screen = "unknown";
|
||||
failed = true;
|
||||
}
|
||||
if (accessibility.status === "fulfilled" && accessibility.value?.success)
|
||||
values.accessibility = accessibility.value.trusted
|
||||
? "granted"
|
||||
: "denied";
|
||||
else {
|
||||
values.accessibility = "unknown";
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (media.status === "fulfilled" && media.value?.success) {
|
||||
values.microphone = normalize(media.value.microphone);
|
||||
values.camera = normalize(media.value.camera);
|
||||
} else {
|
||||
values.microphone = "unknown";
|
||||
values.camera = "unknown";
|
||||
failed = true;
|
||||
}
|
||||
if (!mounted.current) return;
|
||||
setSnapshot({ mac, values });
|
||||
setError(
|
||||
failed
|
||||
? t(
|
||||
"settings.permissions.checkFailed",
|
||||
"Some permissions could not be checked. Try again.",
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
setSnapshot(undefined);
|
||||
setError(
|
||||
t(
|
||||
"settings.permissions.checkFailed",
|
||||
"Some permissions could not be checked. Try again.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (accessibility.status === "fulfilled" && accessibility.value?.success)
|
||||
values.accessibility = accessibility.value.trusted ? "granted" : "denied";
|
||||
else {
|
||||
values.accessibility = "unknown";
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (media.status === "fulfilled" && media.value?.success) {
|
||||
values.microphone = normalize(media.value.microphone);
|
||||
values.camera = normalize(media.value.camera);
|
||||
} else {
|
||||
values.microphone = "unknown";
|
||||
values.camera = "unknown";
|
||||
failed = true;
|
||||
}
|
||||
if (!mounted.current) return;
|
||||
setSnapshot({ mac, values });
|
||||
setError(
|
||||
failed
|
||||
? t(
|
||||
"settings.permissions.checkFailed",
|
||||
"Some permissions could not be checked. Try again.",
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
setSnapshot(undefined);
|
||||
setError(
|
||||
t(
|
||||
"settings.permissions.checkFailed",
|
||||
"Some permissions could not be checked. Try again.",
|
||||
),
|
||||
);
|
||||
}
|
||||
} while (mounted.current && refreshQueued.current);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
|
||||
@@ -144,8 +144,7 @@ export function GradientPicker({
|
||||
{draft.colors.map((color, index) => (
|
||||
<div key={index} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted">
|
||||
{te("color", "Color")}
|
||||
{index + 1}
|
||||
{te("colorNumber", "Color {{number}}", { number: index + 1 })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<ColorControl
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ColorControl } from "@/components/ui/color-picker";
|
||||
import { type AppLocale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import { translateForLocale } from "@/i18n/messages";
|
||||
import { GradientPicker } from "./GradientPicker";
|
||||
import {
|
||||
createCustomGradient,
|
||||
normalizeCustomGradients,
|
||||
@@ -45,3 +50,57 @@ describe("custom gradient backgrounds", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const gradientLocale = vi.hoisted(() => ({ value: "en" as AppLocale }));
|
||||
vi.mock("react", async (original) => ({
|
||||
...(await original<typeof import("react")>()),
|
||||
useEffect: () => undefined,
|
||||
useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}],
|
||||
}));
|
||||
vi.mock("@/contexts/I18nContext", () => ({
|
||||
useScopedT:
|
||||
(namespace: string) =>
|
||||
(key: string, fallback?: string, values?: Record<string, string | number>) =>
|
||||
translateForLocale(gradientLocale.value, `${namespace}.${key}`, fallback, values),
|
||||
}));
|
||||
|
||||
function gradientNodes(node: ReactNode): ReactElement<Record<string, unknown>>[] {
|
||||
if (Array.isArray(node)) return node.flatMap(gradientNodes);
|
||||
if (!node || typeof node !== "object") return [];
|
||||
const element = node as ReactElement<{ children?: ReactNode }>;
|
||||
return [element, ...gradientNodes(element.props?.children)];
|
||||
}
|
||||
function gradientText(node: ReactNode): string {
|
||||
if (Array.isArray(node)) return node.map(gradientText).join("");
|
||||
if (node == null || typeof node === "boolean") return "";
|
||||
if (typeof node !== "object") return String(node);
|
||||
return gradientText((node as ReactElement<{ children?: ReactNode }>).props.children);
|
||||
}
|
||||
|
||||
it.each(
|
||||
SUPPORTED_LOCALES,
|
||||
)("uses the localized numbered color label consistently in %s", (locale) => {
|
||||
gradientLocale.value = locale;
|
||||
const tree = GradientPicker({
|
||||
value: "#ffffff",
|
||||
presets: [],
|
||||
onChange: vi.fn(),
|
||||
tileClassName: () => "",
|
||||
open: true,
|
||||
});
|
||||
const elements = gradientNodes(tree);
|
||||
const rowLabels = elements.filter(
|
||||
(node) => node.type === "span" && node.props.className === "text-sm text-muted",
|
||||
);
|
||||
const controls = elements.filter((node) => node.type === ColorControl);
|
||||
expect(rowLabels).toHaveLength(2);
|
||||
expect(controls).toHaveLength(2);
|
||||
for (const [index, row] of rowLabels.entries()) {
|
||||
const expected = translateForLocale(locale, "editing.colorNumber", undefined, {
|
||||
number: index + 1,
|
||||
});
|
||||
expect(expected).not.toBe("editing.colorNumber");
|
||||
expect(gradientText(row.props.children as ReactNode)).toBe(expected);
|
||||
expect(controls[index].props.label).toBe(expected);
|
||||
}
|
||||
});
|
||||
@@ -16,8 +16,13 @@ export function usePreviewMediaState(
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
const previewVideoSourceRef = useRef(new PreviewVideoSource());
|
||||
const previewRenderLoopRef = useRef<ReturnType<typeof createPreviewRenderLoop> | null>(null);
|
||||
|
||||
const attachVideo = useCallback((video: HTMLVideoElement | null) => {
|
||||
if (video !== videoRef.current) {
|
||||
// Ref detachment precedes passive cleanup. Stop sampling before the source is destroyed.
|
||||
previewRenderLoopRef.current?.dispose();
|
||||
}
|
||||
// VideoSource.destroy() clears the media URL, so only destroy it when
|
||||
// React detaches the element, never during a layout effect cleanup.
|
||||
previewVideoSourceRef.current.setVideo(video);
|
||||
@@ -64,7 +69,6 @@ export function usePreviewMediaState(
|
||||
|
||||
const lastRenderedContentTimeRef = useRef<number | null>(null);
|
||||
|
||||
const previewRenderLoopRef = useRef<ReturnType<typeof createPreviewRenderLoop> | null>(null);
|
||||
const requestPreviewRender = useCallback(() => {
|
||||
previewRenderLoopRef.current?.requestRender();
|
||||
}, []);
|
||||
|
||||
@@ -12,6 +12,7 @@ export class RecordingFileWriter {
|
||||
private queue = Promise.resolve();
|
||||
private streamId: string | undefined;
|
||||
private pendingBytes = 0;
|
||||
private oversizedChunkBytes = 0;
|
||||
private failure: string | undefined;
|
||||
private cancelled = false;
|
||||
private path: string | undefined;
|
||||
@@ -28,8 +29,12 @@ export class RecordingFileWriter {
|
||||
}
|
||||
write(chunk: Blob) {
|
||||
if (this.cancelled || this.failure || !chunk.size) return;
|
||||
// A single delayed recorder Blob drains in slices; bound additional queued data.
|
||||
if (this.pendingBytes > 0 && this.pendingBytes + chunk.size > 32 * 1024 * 1024) {
|
||||
const isOversizedChunk = this.pendingBytes === 0 && chunk.size > 32 * 1024 * 1024;
|
||||
// One delayed Blob may drain in slices alongside up to 32 MiB of later data.
|
||||
if (
|
||||
!isOversizedChunk &&
|
||||
this.pendingBytes - this.oversizedChunkBytes + chunk.size > 32 * 1024 * 1024
|
||||
) {
|
||||
this.fail(
|
||||
new Error(
|
||||
"Recording storage cannot keep up. The partial recording will be preserved.",
|
||||
@@ -37,6 +42,7 @@ export class RecordingFileWriter {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isOversizedChunk) this.oversizedChunkBytes = chunk.size;
|
||||
this.pendingBytes += chunk.size;
|
||||
this.queue = this.queue
|
||||
.then(async () => {
|
||||
@@ -68,6 +74,7 @@ export class RecordingFileWriter {
|
||||
.catch((error) => this.fail(error))
|
||||
.finally(() => {
|
||||
this.pendingBytes -= chunk.size;
|
||||
if (isOversizedChunk) this.oversizedChunkBytes = 0;
|
||||
});
|
||||
}
|
||||
cancel() {
|
||||
|
||||
Reference in new issue
Block a user