mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-31 01:00:47 +08:00
Add an author hover card
This commit is contained in:
+405
-15
@@ -1,8 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { truncateId } from "@app/lib/utils";
|
||||
import type { Config } from "@bindings/config/Config";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
import { cachedAlias, cachedConfig, writeToClipboard } from "@app/lib/invoke";
|
||||
import { getPatchActivitySource } from "@app/lib/patchActivityContext";
|
||||
import {
|
||||
didFromPublicKey,
|
||||
explorerUrl,
|
||||
pluralize,
|
||||
truncateId,
|
||||
} from "@app/lib/utils";
|
||||
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import Popover from "@app/components/Popover.svelte";
|
||||
import UserAvatar from "@app/components/UserAvatar.svelte";
|
||||
|
||||
type IconName = ComponentProps<typeof Icon>["name"];
|
||||
|
||||
interface Props {
|
||||
publicKey: string;
|
||||
alias?: string;
|
||||
@@ -16,6 +30,81 @@
|
||||
inline = false,
|
||||
styleFont = undefined,
|
||||
}: Props = $props();
|
||||
|
||||
let cardExpanded = $state(false);
|
||||
let openTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let copyIcon: "copy" | "checkmark" = $state("copy");
|
||||
let fetchedAlias: string | undefined = $state(undefined);
|
||||
let fetchedConfig: Config | undefined = $state(undefined);
|
||||
const did = $derived(didFromPublicKey(publicKey));
|
||||
const effectiveAlias = $derived(alias ?? fetchedAlias);
|
||||
const activitySource = getPatchActivitySource();
|
||||
// Only while the card is open. The figures are card-only, and resolving them
|
||||
// eagerly would have every `NodeId` on the page — of which there are many —
|
||||
// ask for repo-wide patch and issue lists it never renders.
|
||||
const activity = $derived(
|
||||
cardExpanded ? activitySource?.resolve(publicKey) : undefined,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (cardExpanded) activitySource?.prefetch();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (alias) return;
|
||||
let cancelled = false;
|
||||
void cachedAlias(publicKey)
|
||||
.then(result => {
|
||||
if (cancelled) return;
|
||||
if (result) fetchedAlias = result;
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch the node config lazily when the card opens; it drives the explorer
|
||||
// profile link, which needs the configured `publicExplorer`.
|
||||
$effect(() => {
|
||||
if (!cardExpanded || fetchedConfig) return;
|
||||
let cancelled = false;
|
||||
void cachedConfig()
|
||||
.then(result => {
|
||||
if (!cancelled) fetchedConfig = result;
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
function openCard() {
|
||||
if (closeTimer) clearTimeout(closeTimer);
|
||||
if (cardExpanded) return;
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
openTimer = setTimeout(() => {
|
||||
cardExpanded = true;
|
||||
}, 200);
|
||||
}
|
||||
function scheduleClose() {
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
if (closeTimer) clearTimeout(closeTimer);
|
||||
closeTimer = setTimeout(() => {
|
||||
cardExpanded = false;
|
||||
}, 150);
|
||||
}
|
||||
function cancelClose() {
|
||||
if (closeTimer) clearTimeout(closeTimer);
|
||||
}
|
||||
async function copyDid() {
|
||||
await writeToClipboard(did);
|
||||
copyIcon = "checkmark";
|
||||
setTimeout(() => (copyIcon = "copy"), 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -25,6 +114,9 @@
|
||||
gap: 0.375rem;
|
||||
font: var(--txt-body-m-regular);
|
||||
}
|
||||
.node-id-trigger {
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
.avatar-container {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
@@ -48,19 +140,317 @@
|
||||
.inline .alias {
|
||||
align-self: baseline;
|
||||
}
|
||||
.node-id-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
padding: 0.875rem;
|
||||
min-width: 16rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-canvas);
|
||||
box-shadow: var(--elevation-low);
|
||||
}
|
||||
.node-id-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.node-id-card-avatar {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.node-id-card-avatar :global(img) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.node-id-card-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.node-id-card-activity-divider {
|
||||
height: 1px;
|
||||
background-color: var(--color-border-subtle);
|
||||
margin: 0.125rem 0;
|
||||
}
|
||||
.node-id-card-alias {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.node-id-card-did-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
margin-left: -0.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
}
|
||||
.node-id-card-did-row:hover,
|
||||
.node-id-card-did-row:focus-visible {
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
.node-id-card-did-row:hover .node-id-card-did,
|
||||
.node-id-card-did-row:focus-visible .node-id-card-did,
|
||||
.node-id-card-did-row:hover .node-id-card-copy,
|
||||
.node-id-card-did-row:focus-visible .node-id-card-copy {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.node-id-card-did {
|
||||
color: var(--color-text-tertiary);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
.node-id-card-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.node-id-card-activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-top: 0.875rem;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.node-id-card-activity-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.node-id-card-activity-heading {
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.node-id-card-activity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.node-id-card-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.node-id-card-author-chip {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.125rem 0.375rem 0.125rem 0.125rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-brand-subtle);
|
||||
color: var(--color-text-brand);
|
||||
}
|
||||
.node-id-card-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.node-id-card-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-canvas);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.node-id-card-action:hover,
|
||||
.node-id-card-action:focus-visible {
|
||||
background-color: var(--color-surface-subtle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="avatar-alias" class:inline style:font={styleFont}>
|
||||
<div class="avatar-container">
|
||||
<UserAvatar nodeId={publicKey} styleWidth="1rem" />
|
||||
</div>
|
||||
{#if alias}
|
||||
<span class="txt-overflow alias">
|
||||
{alias}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="no-alias">
|
||||
{truncateId(publicKey)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Popover
|
||||
popoverPadding="0"
|
||||
placement="bottom-start"
|
||||
bind:expanded={cardExpanded}>
|
||||
{#snippet toggle(_onclick)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="avatar-alias node-id-trigger"
|
||||
class:inline
|
||||
style:font={styleFont}
|
||||
onmouseenter={openCard}
|
||||
onmouseleave={scheduleClose}
|
||||
onfocusin={openCard}
|
||||
onfocusout={scheduleClose}>
|
||||
<div class="avatar-container">
|
||||
<UserAvatar nodeId={publicKey} styleWidth="1rem" />
|
||||
</div>
|
||||
{#if effectiveAlias}
|
||||
<span class="txt-overflow alias">
|
||||
{effectiveAlias}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="no-alias">
|
||||
{truncateId(publicKey)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet popover()}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="node-id-card"
|
||||
onmouseenter={cancelClose}
|
||||
onmouseleave={scheduleClose}>
|
||||
<div class="node-id-card-header">
|
||||
<div class="node-id-card-avatar">
|
||||
<UserAvatar nodeId={publicKey} styleWidth="4rem" />
|
||||
</div>
|
||||
<div class="node-id-card-text">
|
||||
{#if effectiveAlias}
|
||||
<div class="node-id-card-alias txt-body-m-medium">
|
||||
{effectiveAlias}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="node-id-card-alias txt-body-m-medium">
|
||||
{truncateId(publicKey)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if activity?.isAuthor || activity?.isDelegate}
|
||||
<div class="node-id-card-chips">
|
||||
{#if activity?.isAuthor}
|
||||
<div class="node-id-card-author-chip txt-body-s-medium">
|
||||
<Icon name="patch" />
|
||||
<span>Patch author</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if activity?.isDelegate}
|
||||
<div class="node-id-card-author-chip txt-body-s-medium">
|
||||
<Icon name="badge" />
|
||||
<span>Delegate</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="node-id-card-did-row"
|
||||
title="Copy DID"
|
||||
onclick={event => {
|
||||
event.stopPropagation();
|
||||
void copyDid();
|
||||
}}>
|
||||
<span class="node-id-card-did txt-body-s-regular">
|
||||
{truncateId(publicKey)}
|
||||
</span>
|
||||
<span class="node-id-card-copy">
|
||||
<Icon name={copyIcon} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if activity}
|
||||
{@const patchFacts = (
|
||||
[
|
||||
activity.revisionCount > 0
|
||||
? {
|
||||
icon: "revision" as IconName,
|
||||
label: `${activity.revisionCount} ${pluralize("revision", activity.revisionCount)}`,
|
||||
}
|
||||
: undefined,
|
||||
activity.commitCount > 0
|
||||
? {
|
||||
icon: "commit" as IconName,
|
||||
label: `${activity.commitCount} ${pluralize("commit", activity.commitCount)}`,
|
||||
}
|
||||
: undefined,
|
||||
activity.reviewCount > 0
|
||||
? {
|
||||
icon: "comment" as IconName,
|
||||
label: `${activity.reviewCount} ${pluralize("review", activity.reviewCount)}`,
|
||||
}
|
||||
: undefined,
|
||||
] as ({ icon: IconName; label: string } | undefined)[]
|
||||
).filter(
|
||||
(x): x is { icon: IconName; label: string } => x !== undefined,
|
||||
)}
|
||||
{@const repoFacts = (
|
||||
[
|
||||
activity.patchesAuthored > 0
|
||||
? {
|
||||
icon: "patch" as IconName,
|
||||
label: `${activity.patchesAuthored} ${pluralize("patch", activity.patchesAuthored)}`,
|
||||
}
|
||||
: undefined,
|
||||
activity.issuesAuthored > 0
|
||||
? {
|
||||
icon: "issue" as IconName,
|
||||
label: `${activity.issuesAuthored} ${pluralize("issue", activity.issuesAuthored)}`,
|
||||
}
|
||||
: undefined,
|
||||
] as ({ icon: IconName; label: string } | undefined)[]
|
||||
).filter(
|
||||
(x): x is { icon: IconName; label: string } => x !== undefined,
|
||||
)}
|
||||
{#if patchFacts.length > 0 || repoFacts.length > 0}
|
||||
<div class="node-id-card-activity">
|
||||
{#if patchFacts.length > 0}
|
||||
<div class="node-id-card-activity-group">
|
||||
<div class="node-id-card-activity-heading txt-body-s-medium">
|
||||
On this patch
|
||||
</div>
|
||||
{#each patchFacts as item (item.label)}
|
||||
<div class="node-id-card-activity-row txt-body-m-regular">
|
||||
<Icon name={item.icon} />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if patchFacts.length > 0 && repoFacts.length > 0}
|
||||
<div class="node-id-card-activity-divider"></div>
|
||||
{/if}
|
||||
{#if repoFacts.length > 0}
|
||||
<div class="node-id-card-activity-group">
|
||||
<div class="node-id-card-activity-heading txt-body-s-medium">
|
||||
In this repo
|
||||
</div>
|
||||
{#each repoFacts as item (item.label)}
|
||||
<div class="node-id-card-activity-row txt-body-m-regular">
|
||||
<Icon name={item.icon} />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="node-id-card-actions">
|
||||
<a
|
||||
class="node-id-card-action txt-body-s-medium"
|
||||
href={fetchedConfig
|
||||
? explorerUrl(`users/${did}`, fetchedConfig)
|
||||
: undefined}
|
||||
title="View profile on radicle.network"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onclick={event => event.stopPropagation()}>
|
||||
View profile
|
||||
<Icon name="open-external" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DiffOptions } from "@bindings/cob/DiffOptions";
|
||||
import type { Config } from "@bindings/config/Config";
|
||||
import type { Diff } from "@bindings/diff/Diff";
|
||||
import type { Stats } from "@bindings/diff/Stats";
|
||||
import type { Commit } from "@bindings/repo/Commit";
|
||||
@@ -139,6 +140,22 @@ export const cachedListJobs = cached(
|
||||
{ max: 200, ttl: 30_000 },
|
||||
);
|
||||
|
||||
async function alias(nid: string): Promise<string | null> {
|
||||
return withTestBackend(tauri.invoke, "alias", { nid });
|
||||
}
|
||||
|
||||
export const cachedAlias = cached(alias, (...[nid]) => `alias:${nid}`, {
|
||||
max: 5_000,
|
||||
});
|
||||
|
||||
async function config(): Promise<Config> {
|
||||
return withTestBackend(tauri.invoke, "config", {});
|
||||
}
|
||||
|
||||
// The node config is static for the session, so a single-entry cache collapses
|
||||
// the many callers (e.g. every NodeId hover card) into one in-flight request.
|
||||
export const cachedConfig = cached(config, () => "config", { max: 1 });
|
||||
|
||||
async function diffStats(
|
||||
rid: string,
|
||||
base: string,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getContext, setContext } from "svelte";
|
||||
|
||||
export interface PatchUserActivity {
|
||||
isAuthor: boolean;
|
||||
isDelegate: boolean;
|
||||
revisionCount: number;
|
||||
commitCount: number;
|
||||
reviewCount: number;
|
||||
patchesAuthored: number;
|
||||
issuesAuthored: number;
|
||||
}
|
||||
|
||||
export interface PatchActivitySource {
|
||||
/// Pure lookup: derives the counts from data already in hand. Safe to call
|
||||
/// from a `$derived`.
|
||||
resolve: (publicKey: string) => PatchUserActivity | undefined;
|
||||
/// Loads the repo-wide figures the lookup needs for `patchesAuthored` and
|
||||
/// `issuesAuthored`. Separate from `resolve` because it fetches, and a
|
||||
/// `$derived` is no place to start one; call it from an effect when a card
|
||||
/// is actually opened.
|
||||
prefetch: () => void;
|
||||
}
|
||||
|
||||
const KEY = Symbol("patch-activity-context");
|
||||
|
||||
export function setPatchActivitySource(source: PatchActivitySource) {
|
||||
setContext(KEY, source);
|
||||
}
|
||||
|
||||
export function getPatchActivitySource(): PatchActivitySource | undefined {
|
||||
return getContext(KEY);
|
||||
}
|
||||
Reference in New Issue
Block a user