mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Refine conversation visual affordances (#615)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,7 @@ export type TimelineThreadSummaryParticipant = {
|
||||
export type TimelineThreadSummary = {
|
||||
threadHeadId: string;
|
||||
replyCount: number;
|
||||
lastReplyAt: number | null;
|
||||
participants: TimelineThreadSummaryParticipant[];
|
||||
};
|
||||
|
||||
@@ -26,6 +27,7 @@ export type MainTimelineEntry = {
|
||||
|
||||
type ThreadDescendantStats = {
|
||||
descendantCount: number;
|
||||
lastReplyAt: number | null;
|
||||
recentParticipantsNewestFirst: TimelineThreadSummaryParticipant[];
|
||||
};
|
||||
|
||||
@@ -80,6 +82,7 @@ function buildDescendantStatsByMessageId(
|
||||
message.id,
|
||||
{
|
||||
descendantCount: 0,
|
||||
lastReplyAt: null,
|
||||
recentParticipantsNewestFirst: [],
|
||||
},
|
||||
]),
|
||||
@@ -115,6 +118,10 @@ function buildDescendantStatsByMessageId(
|
||||
}
|
||||
|
||||
ancestorStats.descendantCount += 1;
|
||||
ancestorStats.lastReplyAt = Math.max(
|
||||
ancestorStats.lastReplyAt ?? 0,
|
||||
message.createdAt,
|
||||
);
|
||||
|
||||
if (
|
||||
ancestorStats.recentParticipantsNewestFirst.length <
|
||||
@@ -146,6 +153,7 @@ function buildSummaryForDirectReplies(
|
||||
return {
|
||||
threadHeadId: messageId,
|
||||
replyCount: descendantStats.descendantCount,
|
||||
lastReplyAt: descendantStats.lastReplyAt,
|
||||
participants: [...descendantStats.recentParticipantsNewestFirst].reverse(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
|
||||
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
|
||||
import {
|
||||
resolveMentionNames,
|
||||
resolveMentionPubkeysByName,
|
||||
} from "@/shared/lib/resolveMentionNames";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { MessageActionBar } from "./MessageActionBar";
|
||||
import { MessageTimestamp } from "./MessageTimestamp";
|
||||
@@ -64,6 +67,10 @@ export const MessageRow = React.memo(
|
||||
() => resolveMentionNames(message.tags, profiles),
|
||||
[profiles, message.tags],
|
||||
);
|
||||
const mentionPubkeysByName = React.useMemo(
|
||||
() => resolveMentionPubkeysByName(message.tags, profiles),
|
||||
[profiles, message.tags],
|
||||
);
|
||||
|
||||
const imetaByUrl = React.useMemo(
|
||||
() => (message.tags ? parseImetaTags(message.tags) : undefined),
|
||||
@@ -119,6 +126,7 @@ export const MessageRow = React.memo(
|
||||
content={message.body}
|
||||
imetaByUrl={imetaByUrl}
|
||||
mentionNames={mentionNames}
|
||||
mentionPubkeysByName={mentionPubkeysByName}
|
||||
searchQuery={searchQuery}
|
||||
tight
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,21 @@ import type {
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
function formatRelativeTime(unixSeconds: number): string {
|
||||
const now = Date.now() / 1_000;
|
||||
const diff = now - unixSeconds;
|
||||
|
||||
if (diff < 60) return "just now";
|
||||
if (diff < 3_600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86_400) return `${Math.floor(diff / 3_600)}h`;
|
||||
if (diff < 604_800) return `${Math.floor(diff / 86_400)}d`;
|
||||
|
||||
return new Date(unixSeconds * 1_000).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function ParticipantAvatar({
|
||||
participant,
|
||||
index,
|
||||
@@ -71,7 +86,7 @@ export function MessageThreadSummaryRow({
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="inline-flex w-fit max-w-full items-center gap-1 rounded-full border border-border/70 bg-muted/70 py-0.5 pl-0.5 pr-2 text-left text-xs font-medium text-foreground/90 transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
className="group inline-flex w-fit max-w-full items-center gap-1 text-left text-xs font-medium text-muted-foreground"
|
||||
data-thread-head-id={message.id}
|
||||
data-testid="message-thread-summary"
|
||||
onClick={() => onOpenThread(message)}
|
||||
@@ -89,10 +104,15 @@ export function MessageThreadSummaryRow({
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
<span>
|
||||
<span className="transition-colors group-hover:text-foreground">
|
||||
{summary.replyCount}{" "}
|
||||
{summary.replyCount === 1 ? "reply" : "replies"}
|
||||
</span>
|
||||
{summary.lastReplyAt ? (
|
||||
<span className="ml-1 text-muted-foreground/70">
|
||||
last {formatRelativeTime(summary.lastReplyAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -135,7 +135,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div
|
||||
className="flex w-full flex-col gap-2 pb-10 pt-16"
|
||||
className="flex w-full flex-col gap-2 pb-10 pt-12"
|
||||
ref={contentRef}
|
||||
>
|
||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||
@@ -152,7 +152,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
data-testid="message-timeline-beginning"
|
||||
>
|
||||
<Separator className="flex-1" />
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground/75">
|
||||
Beginning of conversation
|
||||
</p>
|
||||
<Separator className="flex-1" />
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
@@ -26,7 +27,7 @@ type SystemMessagePayload = {
|
||||
|
||||
type SystemMessageDescription = {
|
||||
action: React.ReactNode;
|
||||
title: string;
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
function resolveLabel(
|
||||
@@ -57,7 +58,7 @@ function resolveAvatarUrl(
|
||||
return profiles[pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
}
|
||||
|
||||
function labelWithSuffix(
|
||||
function resolveLabelWithSuffix(
|
||||
pubkey: string | undefined,
|
||||
currentPubkey: string | undefined,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
@@ -66,6 +67,97 @@ function labelWithSuffix(
|
||||
return `${resolveLabel(pubkey, currentPubkey, profiles)}${suffix}`;
|
||||
}
|
||||
|
||||
function ProfileName({
|
||||
children,
|
||||
highlight = false,
|
||||
pubkey,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
highlight?: boolean;
|
||||
pubkey: string | undefined;
|
||||
}) {
|
||||
const node = (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-sm transition-colors hover:text-foreground",
|
||||
pubkey && "cursor-pointer",
|
||||
highlight &&
|
||||
"rounded-md bg-primary/15 px-1 py-0.5 font-medium text-primary hover:bg-primary/25 hover:text-primary/90",
|
||||
)}
|
||||
>
|
||||
{highlight ? "@" : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
return pubkey ? (
|
||||
<UserProfilePopover pubkey={pubkey} triggerElement="span">
|
||||
{node}
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
node
|
||||
);
|
||||
}
|
||||
|
||||
function SystemMessageAvatar({
|
||||
actorPubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
targetPubkey,
|
||||
}: {
|
||||
actorPubkey: string | undefined;
|
||||
currentPubkey: string | undefined;
|
||||
profiles: UserProfileLookup | undefined;
|
||||
targetPubkey: string | undefined;
|
||||
}) {
|
||||
const hasActorAndTarget =
|
||||
actorPubkey && targetPubkey && actorPubkey !== targetPubkey;
|
||||
const actorLabel = actorPubkey
|
||||
? resolveUserLabel({
|
||||
pubkey: actorPubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
})
|
||||
: "Someone";
|
||||
|
||||
if (!hasActorAndTarget) {
|
||||
return (
|
||||
<UserAvatar
|
||||
avatarUrl={resolveAvatarUrl(actorPubkey ?? targetPubkey, profiles)}
|
||||
className="!h-9 !w-9 shrink-0 text-[10px]"
|
||||
displayName={actorLabel}
|
||||
testId="system-message-avatar"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const targetLabel = resolveUserLabel({
|
||||
pubkey: targetPubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-9 w-9 shrink-0"
|
||||
data-testid="system-message-avatar"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={resolveAvatarUrl(actorPubkey, profiles)}
|
||||
className="!h-7 !w-7 border-2 border-background text-[9px]"
|
||||
displayName={actorLabel}
|
||||
/>
|
||||
<UserAvatar
|
||||
avatarUrl={resolveAvatarUrl(targetPubkey, profiles)}
|
||||
className="!absolute !bottom-0 !right-0 !h-7 !w-7 border-2 border-background text-[9px]"
|
||||
displayName={targetLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function describeSystemEvent(
|
||||
payload: SystemMessagePayload,
|
||||
currentPubkey: string | undefined,
|
||||
@@ -73,27 +165,39 @@ function describeSystemEvent(
|
||||
personaLookup?: Map<string, string>,
|
||||
): SystemMessageDescription | null {
|
||||
const personaSuffix = resolvePersonaSuffix(payload.target, personaLookup);
|
||||
const actorLabel = labelWithSuffix(payload.actor, currentPubkey, profiles);
|
||||
const targetLabel = labelWithSuffix(
|
||||
const actorLabel = resolveLabelWithSuffix(
|
||||
payload.actor,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
);
|
||||
const targetLabel = resolveLabelWithSuffix(
|
||||
payload.target,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
personaSuffix,
|
||||
);
|
||||
const actorName = (
|
||||
<ProfileName pubkey={payload.actor}>{actorLabel}</ProfileName>
|
||||
);
|
||||
const targetName = (
|
||||
<ProfileName highlight pubkey={payload.target}>
|
||||
{targetLabel}
|
||||
</ProfileName>
|
||||
);
|
||||
|
||||
switch (payload.type) {
|
||||
case "member_joined": {
|
||||
if (payload.actor === payload.target) {
|
||||
return {
|
||||
title: targetLabel,
|
||||
title: targetName,
|
||||
action: "joined the channel",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: actorLabel,
|
||||
title: actorName,
|
||||
action: (
|
||||
<>
|
||||
added <span className="font-medium">{targetLabel}</span> to the
|
||||
added <span className="font-medium">{targetName}</span> to the
|
||||
channel
|
||||
</>
|
||||
),
|
||||
@@ -101,42 +205,42 @@ function describeSystemEvent(
|
||||
}
|
||||
case "member_left":
|
||||
return {
|
||||
title: actorLabel,
|
||||
title: actorName,
|
||||
action: "left the channel",
|
||||
};
|
||||
case "member_removed":
|
||||
return {
|
||||
title: actorLabel,
|
||||
title: actorName,
|
||||
action: (
|
||||
<>
|
||||
removed <span className="font-medium">{targetLabel}</span> from the
|
||||
removed <span className="font-medium">{targetName}</span> from the
|
||||
channel
|
||||
</>
|
||||
),
|
||||
};
|
||||
case "topic_changed":
|
||||
return {
|
||||
title: actorLabel,
|
||||
title: actorName,
|
||||
action: <>changed the topic to “{payload.topic}”</>,
|
||||
};
|
||||
case "purpose_changed":
|
||||
return {
|
||||
title: actorLabel,
|
||||
title: actorName,
|
||||
action: <>changed the purpose to “{payload.purpose}”</>,
|
||||
};
|
||||
case "channel_created":
|
||||
return {
|
||||
title: actorLabel,
|
||||
title: actorName,
|
||||
action: "created this channel",
|
||||
};
|
||||
case "channel_archived":
|
||||
return {
|
||||
title: actorLabel,
|
||||
title: actorName,
|
||||
action: "archived this channel",
|
||||
};
|
||||
case "channel_unarchived":
|
||||
return {
|
||||
title: actorLabel,
|
||||
title: actorName,
|
||||
action: "unarchived this channel",
|
||||
};
|
||||
default:
|
||||
@@ -188,33 +292,23 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
||||
return null;
|
||||
}
|
||||
|
||||
const avatarPubkey = payload.actor ?? payload.target;
|
||||
const avatarLabel = avatarPubkey
|
||||
? resolveUserLabel({
|
||||
pubkey: avatarPubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
})
|
||||
: "Someone";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/message relative rounded-2xl px-2 py-1 transition-colors"
|
||||
data-testid="system-message-row"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<UserAvatar
|
||||
avatarUrl={resolveAvatarUrl(avatarPubkey, profiles)}
|
||||
className="!h-9 !w-9 shrink-0 text-[10px]"
|
||||
displayName={avatarLabel}
|
||||
testId="system-message-avatar"
|
||||
<SystemMessageAvatar
|
||||
actorPubkey={payload.actor}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
targetPubkey={payload.target}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<p className="truncate text-sm font-semibold leading-none tracking-tight text-foreground/90">
|
||||
<div className="truncate text-sm font-semibold leading-none tracking-tight text-foreground/90">
|
||||
{description.title}
|
||||
</p>
|
||||
</div>
|
||||
<MessageTimestamp
|
||||
createdAt={message.createdAt}
|
||||
time={message.time}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
|
||||
type UserProfilePopoverProps = {
|
||||
children: React.ReactNode;
|
||||
pubkey: string;
|
||||
triggerElement?: "div" | "span";
|
||||
/** When set to "bot", a BotIdenticon badge renders next to the display name. */
|
||||
role?: string;
|
||||
/** Value used to generate the BotIdenticon glyph (typically the author name). */
|
||||
@@ -58,6 +59,7 @@ function truncatePubkey(pubkey: string) {
|
||||
export function UserProfilePopover({
|
||||
children,
|
||||
pubkey,
|
||||
triggerElement = "div",
|
||||
role,
|
||||
botIdenticonValue,
|
||||
}: UserProfilePopoverProps) {
|
||||
@@ -130,11 +132,12 @@ export function UserProfilePopover({
|
||||
return () => clearHoverTimer();
|
||||
}, [clearHoverTimer]);
|
||||
|
||||
const TriggerElement = triggerElement;
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverAnchor asChild>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: wrapper div for hover/click behavior */}
|
||||
<div
|
||||
<TriggerElement
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleTriggerClick}
|
||||
@@ -152,7 +155,7 @@ export function UserProfilePopover({
|
||||
className="inline-flex"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TriggerElement>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
|
||||
@@ -33,3 +33,28 @@ export function resolveMentionNames(
|
||||
|
||||
return names.size > 0 ? [...names] : undefined;
|
||||
}
|
||||
|
||||
export function resolveMentionPubkeysByName(
|
||||
tags: string[][] | undefined,
|
||||
profiles: Record<string, UserProfileSummary> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
if (!profiles || !tags) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pubkeysByName: Record<string, string> = {};
|
||||
|
||||
for (const tag of tags) {
|
||||
if (tag[0] !== "p" || !tag[1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pubkey = tag[1].toLowerCase();
|
||||
const displayName = profiles[pubkey]?.displayName?.trim();
|
||||
if (displayName) {
|
||||
pubkeysByName[displayName.toLowerCase()] = pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(pubkeysByName).length > 0 ? pubkeysByName : undefined;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import remarkGfm from "remark-gfm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
@@ -34,6 +35,7 @@ type MarkdownProps = {
|
||||
content: string;
|
||||
imetaByUrl?: ImetaLookup;
|
||||
mentionNames?: string[];
|
||||
mentionPubkeysByName?: Record<string, string>;
|
||||
searchQuery?: string;
|
||||
tight?: boolean;
|
||||
};
|
||||
@@ -106,6 +108,7 @@ function createMarkdownComponents(
|
||||
channels: Channel[],
|
||||
onOpenChannel: (channelId: string) => void,
|
||||
imetaByUrl?: ImetaLookup,
|
||||
mentionPubkeysByName?: Record<string, string>,
|
||||
): Components {
|
||||
const paragraphClassName =
|
||||
variant === "tight"
|
||||
@@ -322,14 +325,27 @@ function createMarkdownComponents(
|
||||
ul: ({ children }) => (
|
||||
<ul className={cn("list-disc", listClassName)}>{children}</ul>
|
||||
),
|
||||
mention: ({ children }: { children?: React.ReactNode }) => (
|
||||
<span
|
||||
data-mention=""
|
||||
className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-semibold text-primary"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
mention: ({ children }: { children?: React.ReactNode }) => {
|
||||
const mentionText = String(children ?? "");
|
||||
const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase();
|
||||
const pubkey = mentionPubkeysByName?.[mentionName];
|
||||
const mentionNode = (
|
||||
<span
|
||||
data-mention=""
|
||||
className="cursor-pointer rounded-md bg-primary/15 px-1 py-0.5 text-sm font-semibold text-primary transition-colors hover:bg-primary/25 hover:text-primary/90"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
return pubkey ? (
|
||||
<UserProfilePopover pubkey={pubkey} triggerElement="span">
|
||||
{mentionNode}
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
mentionNode
|
||||
);
|
||||
},
|
||||
"channel-link": ({ children }: { children?: React.ReactNode }) => {
|
||||
const text = String(children ?? "");
|
||||
const channelName = text.startsWith("#") ? text.slice(1) : text;
|
||||
@@ -374,6 +390,7 @@ function MarkdownInner({
|
||||
content,
|
||||
imetaByUrl,
|
||||
mentionNames,
|
||||
mentionPubkeysByName,
|
||||
searchQuery,
|
||||
tight = false,
|
||||
}: MarkdownProps) {
|
||||
@@ -394,8 +411,9 @@ function MarkdownInner({
|
||||
void goChannel(channelId);
|
||||
},
|
||||
imetaByUrl,
|
||||
mentionPubkeysByName,
|
||||
),
|
||||
[goChannel, variant, channels, imetaByUrl],
|
||||
[goChannel, variant, channels, imetaByUrl, mentionPubkeysByName],
|
||||
);
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
|
||||
@@ -462,6 +480,7 @@ export const Markdown = React.memo(
|
||||
prev.className === next.className &&
|
||||
prev.compact === next.compact &&
|
||||
prev.tight === next.tight &&
|
||||
prev.mentionPubkeysByName === next.mentionPubkeysByName &&
|
||||
shallowArrayEqual(prev.mentionNames, next.mentionNames) &&
|
||||
shallowArrayEqual(prev.channelNames, next.channelNames) &&
|
||||
prev.imetaByUrl === next.imetaByUrl &&
|
||||
|
||||
@@ -20,9 +20,25 @@ test("keyboard shortcut opens the channel browser dialog", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
|
||||
await page.keyboard.press(
|
||||
process.platform === "darwin" ? "Meta+Shift+O" : "Control+Shift+O",
|
||||
const isMacBrowser = await page.evaluate(() =>
|
||||
/mac|iphone|ipad|ipod/i.test(navigator.platform),
|
||||
);
|
||||
|
||||
if (isMacBrowser) {
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: "O",
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
await page.keyboard.press("Control+Shift+O");
|
||||
}
|
||||
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -660,10 +660,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: lucide_icons_flutter
|
||||
sha256: df29c1cf4f19f9309f5204d40e0ba95338d9946a8ffdb4d75cf9aaccf449ce41
|
||||
sha256: f9fd5d49b93bf14b89e0ff4818658a74ab16899bdaf7aa745358ee1a34f54eed
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.12"
|
||||
version: "3.1.14+1"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user