Add Contact beside Socials in the Home sidebar

This commit is contained in:
webadderall committed 2026-11-06 21:00:07 +11:00
1 parent 4d00503727
commit ee4876bd18
4 files changed
+138 -41

No files matched your search

+25
View File
@@ -42,6 +42,31 @@ it("does not prompt or open settings merely by registering permission handlers",
expect(mocks.open).not.toHaveBeenCalled();
});
it.each([
"mailto:support@recordly.dev",
"mailto:youngchen3442@gmail.com",
])("opens the fixed contact address %s in the default mail client", async (url) => {
expect(await mocks.handlers.get("open-external-url")!(undefined, url)).toEqual({
success: true,
});
expect(mocks.open).toHaveBeenCalledExactlyOnceWith(url);
});
it.each([
"mailto:other@example.com",
"mailto:support@recordly.dev?bcc=other@example.com",
"mailto:support@recordly.dev.evil.test",
"mailto:youngchen3442@gmail.com?bcc=other@example.com",
"mailto:youngchen3442@gmail.com.evil.test",
"file:///tmp/example",
"custom:example",
])("blocks unsupported external URL %s", async (url) => {
expect(await mocks.handlers.get("open-external-url")!(undefined, url)).toMatchObject({
success: false,
});
expect(mocks.open).not.toHaveBeenCalled();
});
it("reports microphone and camera status without requesting access", async () => {
mocks.status.mockImplementation((device: string) =>
device === "microphone" ? "denied" : "not-determined",
+7 -2
View File
@@ -20,9 +20,14 @@ export function registerPermissionHandlers() {
ipcMain.handle("close-permission-accessory", ({ sender }) => dismissPermissionAccessory(sender));
ipcMain.handle("open-external-url", async (_, url: string) => {
try {
// Security: only allow http/https URLs to prevent file:// or custom protocol abuse
// Only the two displayed contact addresses may launch a mail client; other protocols stay blocked.
const parsed = new URL(url);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
if (
parsed.protocol !== "https:" &&
parsed.protocol !== "http:" &&
url !== "mailto:support@recordly.dev" &&
url !== "mailto:youngchen3442@gmail.com"
) {
return { success: false, error: `Blocked non-HTTP URL: ${parsed.protocol}` };
}
await shell.openExternal(url);
+81 -38
View File
@@ -1,4 +1,4 @@
import { GithubLogo, XLogo, YoutubeLogo } from "@phosphor-icons/react";
import { EnvelopeSimple, GithubLogo, XLogo, YoutubeLogo } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { UpgradeButton } from "@/components/billing/UpgradeButton";
import { Button } from "@/components/ui/button";
@@ -111,43 +111,86 @@ export function CommunityLinks() {
truncateLabel
className="h-8 w-full min-w-0 px-1.5 text-xs"
/>
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full min-w-0 rounded-lg text-xs text-muted-foreground hover:text-foreground"
>
{t("socials", "Socials")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("socials", "Socials")}</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-4 gap-2">
{links.map(({ label, accessibleLabel, url, Icon }) => (
<Button
key={label}
type="button"
variant="ghost"
className="h-20 w-full min-w-0 flex-col gap-2 rounded-xl px-1 text-xs text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
aria-label={accessibleLabel}
onClick={() =>
void openExternalLink(
url,
t("feedback.openFailed", "Failed to open link."),
)
}
>
<Icon className="size-7 shrink-0" aria-hidden="true" />
<span>{label}</span>
</Button>
))}
</div>
</DialogContent>
</Dialog>
<div className="flex items-center gap-1">
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 min-w-0 flex-1 rounded-lg px-1 text-xs text-muted-foreground hover:text-foreground"
>
{t("socials", "Socials")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("socials", "Socials")}</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-4 gap-2">
{links.map(({ label, accessibleLabel, url, Icon }) => (
<Button
key={label}
type="button"
variant="ghost"
className="h-20 w-full min-w-0 flex-col gap-2 rounded-xl px-1 text-xs text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
aria-label={accessibleLabel}
onClick={() =>
void openExternalLink(
url,
t("feedback.openFailed", "Failed to open link."),
)
}
>
<Icon className="size-7 shrink-0" aria-hidden="true" />
<span>{label}</span>
</Button>
))}
</div>
</DialogContent>
</Dialog>
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 min-w-0 flex-1 rounded-lg px-1 text-xs text-muted-foreground hover:text-foreground"
>
{tBilling("contact", "Contact")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{tBilling("contact", "Contact")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
{["support@recordly.dev", "youngchen3442@gmail.com"].map((email) => (
<Button
key={email}
type="button"
variant="ghost"
className="h-auto min-h-12 w-full justify-start gap-3 rounded-xl px-3 py-3 text-sm hover:bg-foreground/5"
onClick={() =>
void openExternalLink(
`mailto:${email}`,
t("feedback.openFailed", "Failed to open link."),
)
}
>
<EnvelopeSimple
className="size-5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<span className="min-w-0 break-all whitespace-normal text-left">
{email}
</span>
</Button>
))}
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}
+25 -1
View File
@@ -167,6 +167,13 @@ for (const { width, height, theme } of [
const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
const socials = home.getByRole("button", { name: "Socials", exact: true });
await expect(socials).toBeInViewport({ ratio: 1 });
const contact = home.getByRole("button", { name: "Contact", exact: true });
await expect(contact).toBeInViewport({ ratio: 1 });
const socialsBox = (await socials.boundingBox())!;
const contactBox = (await contact.boundingBox())!;
expect(contactBox.y).toBe(socialsBox.y);
expect(contactBox.x).toBeGreaterThanOrEqual(socialsBox.x + socialsBox.width);
await expect(home.getByRole("button", { name: "YouTube", exact: true })).toHaveCount(0);
await socials.click();
const links = page.getByRole("dialog", { name: "Socials", exact: true });
@@ -196,6 +203,23 @@ for (const { width, height, theme } of [
await expect(socials).toBeFocused();
await expect(home).toBeVisible();
await home.locator("aside").screenshot({ path: test.info().outputPath(`footer-${theme}.png`) });
await contact.click();
const contactDialog = page.getByRole("dialog", { name: "Contact", exact: true });
await expect(contactDialog).toBeVisible();
for (const email of ["support@recordly.dev", "youngchen3442@gmail.com"]) {
const emailButton = contactDialog.getByRole("button", { name: email, exact: true });
await expect(emailButton).toBeInViewport({ ratio: 1 });
await emailButton.focus();
await page.keyboard.press("Enter");
await expect(page.locator("html")).toHaveAttribute("data-opened-community-url", `mailto:${email}`);
await expect(page).toHaveURL(url);
}
await contactDialog.screenshot({ path: test.info().outputPath(`contact-${theme}.png`) });
await page.keyboard.press("Escape");
await expect(contactDialog).toBeHidden();
await expect(contact).toBeFocused();
await expect(home).toBeVisible();
});
}
@@ -240,7 +264,7 @@ test("Japanese community footer fits Upgrade and Socials without overlap", async
}
boxes.push(box);
}
await expect(footer.getByRole("button")).toHaveCount(2);
await expect(footer.getByRole("button")).toHaveCount(3);
const socials = footer.getByRole("button", { name: "SNS", exact: true });
await expect(socials).toBeVisible();
await socials.click();