mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): canonical <PubKey> component — hover to view/copy full keys, owner "you" labels (#1589)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -18,6 +18,7 @@ dashmap = { workspace = true }
|
||||
moka = { workspace = true }
|
||||
evalexpr = "11"
|
||||
cron = "0.16"
|
||||
nostr = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
@@ -25,8 +26,5 @@ tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
reqwest = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
nostr = { workspace = true }
|
||||
|
||||
[features]
|
||||
reqwest = ["dep:reqwest"]
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::collections::HashMap;
|
||||
|
||||
use buzz_core::tenant::CommunityId;
|
||||
use evalexpr::HashMapContext;
|
||||
use nostr::ToBech32;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
@@ -62,7 +63,8 @@ impl TriggerContext {
|
||||
///
|
||||
/// Supports filters:
|
||||
/// - `| truncate(N)` — truncate to N characters
|
||||
/// - `| truncate_pubkey` — shorten pubkey to `abc...xyz` (first 6 + last 6 chars)
|
||||
/// - `| npub` — encode a hex pubkey as its full bech32 `npub` (non-pubkey
|
||||
/// values pass through unchanged); `truncate_pubkey` is a legacy alias
|
||||
///
|
||||
/// Unknown `{{keys}}` are left as literal text (no error, no substitution).
|
||||
pub fn resolve_template(
|
||||
@@ -185,28 +187,12 @@ fn apply_filter(value: String, filter: &str) -> Result<String, WorkflowError> {
|
||||
return Ok(truncated);
|
||||
}
|
||||
|
||||
// `truncate_pubkey` — shorten to `abc...xyz` (first 6 + last 6 chars).
|
||||
// Only skip truncation if the string is shorter than the truncated form would be.
|
||||
if filter == "truncate_pubkey" {
|
||||
let char_count = value.chars().count();
|
||||
if char_count <= 12 {
|
||||
// Already short enough that truncating would be longer than the original.
|
||||
// But we still apply the format for consistency if exactly 12.
|
||||
// For strings < 12 chars, return as-is.
|
||||
if char_count < 12 {
|
||||
return Ok(value);
|
||||
}
|
||||
// `npub` (alias `truncate_pubkey`): full bech32 npub — truncated prefixes are grindable.
|
||||
if filter == "npub" || filter == "truncate_pubkey" {
|
||||
if let Ok(pk) = nostr::PublicKey::from_hex(&value) {
|
||||
return Ok(pk.to_bech32().unwrap_or(value));
|
||||
}
|
||||
let head: String = value.chars().take(6).collect();
|
||||
let tail: String = value
|
||||
.chars()
|
||||
.rev()
|
||||
.take(6)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect();
|
||||
return Ok(format!("{head}...{tail}"));
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
Err(WorkflowError::TemplateError(format!(
|
||||
@@ -1284,15 +1270,30 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_truncate_pubkey_filter() {
|
||||
let ctx = make_trigger();
|
||||
fn resolve_npub_filter_encodes_hex_pubkey() {
|
||||
let mut ctx = make_trigger();
|
||||
ctx.author = "e17e5abf7b1dbd363f0ed6fbda2455609727b2555428dea251388c542cd2f03f".to_owned();
|
||||
let out = resolve_template("{{trigger.author | npub}}", &ctx, &HashMap::new()).unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
"npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_truncate_pubkey_is_alias_for_npub() {
|
||||
let mut ctx = make_trigger();
|
||||
ctx.author = "e17e5abf7b1dbd363f0ed6fbda2455609727b2555428dea251388c542cd2f03f".to_owned();
|
||||
let out = resolve_template(
|
||||
"{{trigger.author | truncate_pubkey}}",
|
||||
&ctx,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, "abc123...def456");
|
||||
assert_eq!(
|
||||
out,
|
||||
"npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1543,10 +1544,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_truncate_pubkey_short_string_returned_as_is() {
|
||||
// Strings shorter than 12 chars are returned as-is (no truncation).
|
||||
fn resolve_pubkey_filter_non_pubkey_passes_through() {
|
||||
// Values that are not valid hex pubkeys are returned unchanged.
|
||||
let mut ctx = make_trigger();
|
||||
ctx.author = "short".to_owned(); // 5 chars < 12
|
||||
ctx.author = "short".to_owned();
|
||||
let out = resolve_template(
|
||||
"{{trigger.author | truncate_pubkey}}",
|
||||
&ctx,
|
||||
@@ -1557,17 +1558,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_truncate_pubkey_exactly_12_chars() {
|
||||
// Exactly 12 chars → format as head...tail (6+6).
|
||||
fn resolve_npub_filter_passes_npub_through() {
|
||||
// Already-encoded npubs are not valid hex, so they pass through intact.
|
||||
let mut ctx = make_trigger();
|
||||
ctx.author = "abcdef123456".to_owned(); // exactly 12 chars
|
||||
let out = resolve_template(
|
||||
"{{trigger.author | truncate_pubkey}}",
|
||||
&ctx,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, "abcdef...123456");
|
||||
ctx.author = "npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux".to_owned();
|
||||
let out = resolve_template("{{trigger.author | npub}}", &ctx, &HashMap::new()).unwrap();
|
||||
assert_eq!(out, ctx.author);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:file-sizes": "node ./scripts/check-file-sizes.mjs",
|
||||
"check:px-text": "node ./scripts/check-px-text.mjs",
|
||||
"check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs",
|
||||
"lint": "biome lint .",
|
||||
"check": "biome check . && pnpm check:file-sizes && pnpm check:px-text",
|
||||
"check": "biome check . && pnpm check:file-sizes && pnpm check:px-text && pnpm check:pubkey-truncation",
|
||||
"format": "biome format --write .",
|
||||
"test": "node --import ./test-loader.mjs --experimental-strip-types --test 'src/**/*.test.mjs'",
|
||||
"preview": "vite preview",
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
||||
"**/activity-scope-label-screenshots.spec.ts",
|
||||
"**/local-archive-screenshots.spec.ts",
|
||||
"**/agent-readiness-screenshots.spec.ts",
|
||||
"**/pubkey-display-screenshots.spec.ts",
|
||||
"**/file-attachment.spec.ts",
|
||||
"**/image-attachment-gallery.spec.ts",
|
||||
"**/composer-image-draw.spec.ts",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runPubkeyTruncationCheck } from "../../scripts/check-pubkey-truncation-core.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
|
||||
// Truncated pubkey prefixes are forgeable (vanity grinding), so all display
|
||||
// truncation goes through the canonical `truncatePubkey` / `<PubKey>` — this
|
||||
// guard keeps ad-hoc `pubkey.slice(0, N)` forms from fragmenting again.
|
||||
const rules = [
|
||||
{
|
||||
root: "src",
|
||||
extensions: new Set([".ts", ".tsx"]),
|
||||
},
|
||||
];
|
||||
|
||||
// Non-display uses: array windows over pubkey lists, color/initials
|
||||
// derivation where the value is never presented as an identity.
|
||||
const overrides = new Set([
|
||||
// ProfileAvatar fallback label — decorative glyphs inside an avatar disc.
|
||||
"src/features/huddle/components/ParticipantList.tsx:92",
|
||||
// HexAvatar: 6-char badge + hue derivation inside a color-coded disc,
|
||||
// clearly decorative (paired with a full truncatePubkey aria-label).
|
||||
"src/features/huddle/components/ParticipantList.tsx:143",
|
||||
"src/features/huddle/components/ParticipantList.tsx:144",
|
||||
// clientId (not a pubkey) sliced in a debug log next to the real thing.
|
||||
"src/features/channels/readState/readStateManager.ts:338",
|
||||
// Array windows (first N pubkeys), not string truncation.
|
||||
"src/features/messages/lib/threadPanel.ts:395",
|
||||
"src/features/projects/ui/ProjectsView.tsx:166",
|
||||
"src/features/projects/ui/ProjectsOverviewPanel.tsx:209",
|
||||
]);
|
||||
|
||||
await runPubkeyTruncationCheck({
|
||||
projectRoot,
|
||||
rules,
|
||||
overrides,
|
||||
allowedFiles: new Set([
|
||||
// The canonical helper itself.
|
||||
"src/shared/lib/pubkey.ts",
|
||||
// E2E mock bridge fabricates ids/nsecs from pubkeys; nothing here is a
|
||||
// user-facing identity display.
|
||||
"src/testing/e2eBridge.ts",
|
||||
]),
|
||||
label: "Desktop",
|
||||
scriptPath: "desktop/scripts/check-pubkey-truncation.mjs",
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use nostr::Event;
|
||||
use nostr::{Event, ToBech32};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::models::*;
|
||||
@@ -447,7 +447,8 @@ pub fn agents_from_events(events: &[Event]) -> Value {
|
||||
.map(|ev| {
|
||||
let mut v: Value = serde_json::from_str(&ev.content).unwrap_or_else(|_| json!({}));
|
||||
let pubkey = ev.pubkey.to_hex();
|
||||
let short_pubkey = pubkey[..8].to_string();
|
||||
// Full npub fallback — truncated prefixes are grindable (see pubkey-display).
|
||||
let npub = ev.pubkey.to_bech32().unwrap_or_else(|_| pubkey.clone());
|
||||
// Always overwrite the pubkey with the event author — it's the
|
||||
// authoritative source even if the content claims otherwise.
|
||||
if let Some(obj) = v.as_object_mut() {
|
||||
@@ -457,7 +458,7 @@ pub fn agents_from_events(events: &[Event]) -> Value {
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| short_pubkey.clone());
|
||||
.unwrap_or_else(|| npub.clone());
|
||||
if !obj.get("name").is_some_and(Value::is_string) {
|
||||
obj.insert("name".to_string(), json!(fallback_name));
|
||||
}
|
||||
@@ -479,7 +480,7 @@ pub fn agents_from_events(events: &[Event]) -> Value {
|
||||
} else {
|
||||
v = json!({
|
||||
"pubkey": pubkey,
|
||||
"name": short_pubkey,
|
||||
"name": npub,
|
||||
"agent_type": "agent",
|
||||
"channels": [],
|
||||
"channel_ids": [],
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Button } from "@/shared/ui/button";
|
||||
import { AgentConfigPanel } from "./AgentConfigPanel";
|
||||
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
|
||||
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
|
||||
import { truncatePubkey } from "./agentUi";
|
||||
import { PubKey } from "@/shared/ui/PubKey";
|
||||
|
||||
export function ManagedAgentRow({
|
||||
agent,
|
||||
@@ -237,7 +237,7 @@ function AgentSummary({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{truncatePubkey(agent.pubkey)}</span>
|
||||
<PubKey pubkey={agent.pubkey} />
|
||||
{agent.backend.type === "local" ? (
|
||||
<span>
|
||||
{agent.startOnAppLaunch ? "Auto-start" : "Manual start"}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { RelayAgent } from "@/shared/api/types";
|
||||
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { truncatePubkey } from "./agentUi";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export function RelayDirectorySection({
|
||||
error,
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
mergeAllowlist,
|
||||
parsePubkeyInput,
|
||||
} from "@/features/agents/lib/respondToAllowlist";
|
||||
import { formatPubkey } from "@/features/channels/lib/memberUtils";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { PubKey } from "@/shared/ui/PubKey";
|
||||
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
|
||||
import { useUserSearchQuery } from "@/features/profile/hooks";
|
||||
import type { RespondToMode, UserSearchResult } from "@/shared/api/types";
|
||||
@@ -36,7 +37,7 @@ function formatSearchUserName(user: UserSearchResult) {
|
||||
return (
|
||||
user.displayName?.trim() ||
|
||||
user.nip05Handle?.trim() ||
|
||||
formatPubkey(user.pubkey)
|
||||
truncatePubkey(user.pubkey)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +47,7 @@ function formatSearchUserSecondary(user: UserSearchResult) {
|
||||
if (displayName && nip05Handle) {
|
||||
return nip05Handle;
|
||||
}
|
||||
return formatPubkey(user.pubkey);
|
||||
return truncatePubkey(user.pubkey);
|
||||
}
|
||||
|
||||
const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [
|
||||
@@ -276,8 +277,9 @@ function AllowlistPicker({
|
||||
) : null}
|
||||
{!isPersona && ownerPubkey ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Owner (<span className="font-mono">{formatPubkey(ownerPubkey)}</span>)
|
||||
is always implicitly allowed by the harness — no need to add it here.
|
||||
Owner (
|
||||
<PubKey pubkey={ownerPubkey} />) is always implicitly allowed by the
|
||||
harness — no need to add it here.
|
||||
</p>
|
||||
) : !isPersona ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -308,12 +310,12 @@ function AllowlistPicker({
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={null}
|
||||
displayName={formatPubkey(pubkey)}
|
||||
displayName={truncatePubkey(pubkey)}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="font-mono">{formatPubkey(pubkey)}</span>
|
||||
<PubKey pubkey={pubkey} />
|
||||
<button
|
||||
aria-label={`Remove ${formatPubkey(pubkey)}`}
|
||||
aria-label={`Remove ${truncatePubkey(pubkey)}`}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
disabled={disabled}
|
||||
onClick={() => onRemove(pubkey)}
|
||||
@@ -370,12 +372,12 @@ function AllowlistPicker({
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<UserAvatar
|
||||
avatarUrl={null}
|
||||
displayName={formatPubkey(deferredQuery)}
|
||||
displayName={truncatePubkey(deferredQuery)}
|
||||
size="xs"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium leading-5">
|
||||
{formatPubkey(deferredQuery)}
|
||||
{truncatePubkey(deferredQuery)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
Add pubkey directly
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export function truncatePubkey(pubkey: string) {
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-6)}`;
|
||||
}
|
||||
|
||||
function commandLooksLikePath(command: string) {
|
||||
const trimmed = command.trim();
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChannelMember } from "@/shared/api/types";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export const roleOrder: Record<ChannelMember["role"], number> = {
|
||||
owner: 0,
|
||||
@@ -8,10 +9,6 @@ export const roleOrder: Record<ChannelMember["role"], number> = {
|
||||
bot: 4,
|
||||
};
|
||||
|
||||
export function formatPubkey(pubkey: string) {
|
||||
return `${pubkey.slice(0, 8)}\u2026${pubkey.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function formatMemberName(
|
||||
member: ChannelMember,
|
||||
currentPubkey?: string,
|
||||
@@ -20,7 +17,7 @@ export function formatMemberName(
|
||||
return "You";
|
||||
}
|
||||
|
||||
return member.displayName ?? formatPubkey(member.pubkey);
|
||||
return member.displayName ?? truncatePubkey(member.pubkey);
|
||||
}
|
||||
|
||||
export function compareMembersByRole(
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
writeStoredReadState,
|
||||
} from "@/features/channels/readState/readStateStorage";
|
||||
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id";
|
||||
const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id";
|
||||
@@ -335,7 +336,7 @@ export class ReadStateManager {
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized || this.destroyed) return;
|
||||
console.debug(
|
||||
`[ReadStateManager] initialize pubkey=${this.pubkey.substring(0, 8)}… clientId=${this.clientId.substring(0, 8)}… slotId=${this.slotId}`,
|
||||
`[ReadStateManager] initialize pubkey=${truncatePubkey(this.pubkey)} clientId=${this.clientId.substring(0, 8)}… slotId=${this.slotId}`,
|
||||
);
|
||||
|
||||
this.hydrateFromLocalStorage();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Search, UserPlus, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { formatPubkey } from "@/features/channels/lib/memberUtils";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { PubKey } from "@/shared/ui/PubKey";
|
||||
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
|
||||
import { useUserSearchQuery } from "@/features/profile/hooks";
|
||||
import type {
|
||||
@@ -17,7 +18,7 @@ function formatSearchUserName(user: UserSearchResult) {
|
||||
return (
|
||||
user.displayName?.trim() ||
|
||||
user.nip05Handle?.trim() ||
|
||||
formatPubkey(user.pubkey)
|
||||
truncatePubkey(user.pubkey)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,6 +199,22 @@ export function ChannelMemberInviteCard({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedInvitees.length > 0 ? (
|
||||
<div className="space-y-1 border-t border-border/70 px-2.5 py-2">
|
||||
{selectedInvitees.map((invitee) => (
|
||||
<div
|
||||
className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-2xs text-muted-foreground"
|
||||
data-testid={`invitee-pubkey-${invitee.pubkey}`}
|
||||
key={invitee.pubkey}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{formatSearchUserName(invitee)}
|
||||
</span>
|
||||
<PubKey pubkey={invitee.pubkey} variant="full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{deferredInviteQuery.length > 0 ? (
|
||||
<div className="border-t border-border/70 px-2 py-2">
|
||||
{userSearchQuery.isLoading ? (
|
||||
@@ -292,7 +309,7 @@ export function ChannelMemberInviteCard({
|
||||
<div className="space-y-1 text-sm text-destructive">
|
||||
{submissionErrors.map((error) => (
|
||||
<p key={`${error.pubkey}-${error.error}`}>
|
||||
{formatPubkey(error.pubkey)}: {error.error}
|
||||
{truncatePubkey(error.pubkey)}: {error.error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -15,16 +15,14 @@ import {
|
||||
import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField";
|
||||
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
|
||||
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
|
||||
import {
|
||||
formatMemberName,
|
||||
formatPubkey,
|
||||
} from "@/features/channels/lib/memberUtils";
|
||||
import { formatMemberName } from "@/features/channels/lib/memberUtils";
|
||||
import {
|
||||
useFlattenedUserSearchResults,
|
||||
useInfiniteUserSearchQuery,
|
||||
useUserSearchFetchMoreOnScroll,
|
||||
useUsersBatchQuery,
|
||||
} from "@/features/profile/hooks";
|
||||
import { formatOwnerLabel } from "@/features/profile/lib/identity";
|
||||
import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch";
|
||||
import { usePresenceQuery } from "@/features/presence/hooks";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
@@ -49,7 +47,7 @@ import {
|
||||
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
|
||||
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import {
|
||||
MODAL_SEARCH_INPUT_CLASS,
|
||||
@@ -64,24 +62,7 @@ function formatAddCandidateName(user: UserSearchResult) {
|
||||
return (
|
||||
user.displayName?.trim() ||
|
||||
user.nip05Handle?.trim() ||
|
||||
formatPubkey(user.pubkey)
|
||||
);
|
||||
}
|
||||
function formatOwnerName(
|
||||
user: UserSearchResult,
|
||||
ownerProfiles?: Record<
|
||||
string,
|
||||
{ displayName: string | null; nip05Handle: string | null }
|
||||
>,
|
||||
) {
|
||||
if (!user.ownerPubkey) {
|
||||
return null;
|
||||
}
|
||||
const owner = ownerProfiles?.[normalizePubkey(user.ownerPubkey)];
|
||||
return (
|
||||
owner?.displayName?.trim() ||
|
||||
owner?.nip05Handle?.trim() ||
|
||||
formatPubkey(user.ownerPubkey)
|
||||
truncatePubkey(user.pubkey)
|
||||
);
|
||||
}
|
||||
type AddMemberSearchCandidate = UserSearchResult & {
|
||||
@@ -592,7 +573,9 @@ export function MembersSidebar({
|
||||
}
|
||||
member={member}
|
||||
memberIsBot={memberIsBot}
|
||||
memberAvatarLabel={member.displayName ?? formatPubkey(member.pubkey)}
|
||||
memberAvatarLabel={
|
||||
member.displayName ?? truncatePubkey(member.pubkey)
|
||||
}
|
||||
memberLabel={formatMemberName(member, currentPubkey)}
|
||||
onChangeRole={(m, role) => {
|
||||
void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
|
||||
@@ -711,8 +694,9 @@ export function MembersSidebar({
|
||||
onSelect={(selectedUser) => {
|
||||
void handleAddSearchResult(selectedUser);
|
||||
}}
|
||||
ownerLabel={formatOwnerName(
|
||||
user,
|
||||
ownerLabel={formatOwnerLabel(
|
||||
user.ownerPubkey,
|
||||
identityQuery.data?.pubkey,
|
||||
addSearchOwnerProfilesQuery.data?.profiles,
|
||||
)}
|
||||
user={user}
|
||||
@@ -807,7 +791,7 @@ export function MembersSidebar({
|
||||
<div className="mt-4 space-y-1 text-sm text-destructive">
|
||||
{inviteSubmissionErrors.map((error) => (
|
||||
<p key={`${error.pubkey}-${error.error}`}>
|
||||
{formatPubkey(error.pubkey)}: {error.error}
|
||||
{truncatePubkey(error.pubkey)}: {error.error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@/features/agents/lib/managedAgentControlActions";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
import { truncatePubkey } from "@/features/profile/lib/identity";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import type {
|
||||
ChannelMember,
|
||||
ManagedAgent,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
RelayAgent,
|
||||
} from "@/shared/api/types";
|
||||
import { usePanelReturnTarget } from "@/shared/hooks/usePanelReturnTarget";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import {
|
||||
type AgentSessionReturnTarget,
|
||||
resolveAgentSessionReturnTarget,
|
||||
@@ -96,7 +96,7 @@ export function buildChannelAgentSessionCandidates({
|
||||
|
||||
byPubkey.set(key, {
|
||||
pubkey: member.pubkey,
|
||||
name: member.displayName ?? member.pubkey.slice(0, 8),
|
||||
name: member.displayName ?? truncatePubkey(member.pubkey),
|
||||
status: "deployed",
|
||||
agentSource: "member-bot",
|
||||
canInterruptTurn: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { UserNote } from "@/shared/api/socialTypes";
|
||||
import type { UserProfileSummary } from "@/shared/api/types";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
type RecentNotesSectionProps = {
|
||||
notes: UserNote[];
|
||||
@@ -52,7 +53,7 @@ export function RecentNotesSection({
|
||||
{notes.slice(0, 5).map((note) => {
|
||||
const profile = profiles[note.pubkey.toLowerCase()];
|
||||
const displayName =
|
||||
profile?.displayName ?? `${note.pubkey.slice(0, 8)}...`;
|
||||
profile?.displayName ?? truncatePubkey(note.pubkey);
|
||||
const isAgent = agentPubkeys.has(note.pubkey);
|
||||
|
||||
return (
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useHuddle } from "../HuddleContext";
|
||||
import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog";
|
||||
import { MicControls, SpeakerControls } from "./MicControls";
|
||||
import { HuddleParticipantsControl } from "./ParticipantList";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
// Mirrors HuddleState in src-tauri/src/huddle/mod.rs.
|
||||
type HuddleState = {
|
||||
@@ -93,7 +94,7 @@ function clampReactionName(name: string): string {
|
||||
}
|
||||
|
||||
function fallbackNameForPubkey(pubkey?: string | null): string {
|
||||
return pubkey ? `Participant ${pubkey.slice(0, 8)}` : "Someone";
|
||||
return pubkey ? `Participant ${truncatePubkey(pubkey)}` : "Someone";
|
||||
}
|
||||
|
||||
function parseHuddleReactionEvent(event: RelayEvent) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
type ParticipantListProps = {
|
||||
/** Pubkey hex strings from the Rust huddle state */
|
||||
@@ -76,7 +77,7 @@ export function HuddleParticipantsControl({
|
||||
{participants.map((pubkey) => {
|
||||
const profile = profiles[pubkey.toLowerCase()];
|
||||
const displayName =
|
||||
profile?.displayName || `Participant ${pubkey.slice(0, 8)}`;
|
||||
profile?.displayName || `Participant ${truncatePubkey(pubkey)}`;
|
||||
const isActive = activeSpeakers?.includes(pubkey);
|
||||
const isAgent = agentSet.has(pubkey);
|
||||
|
||||
@@ -146,7 +147,7 @@ function HexAvatar({
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={`Participant ${pubkey.slice(0, 8)}`}
|
||||
aria-label={`Participant ${truncatePubkey(pubkey)}`}
|
||||
role="img"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full font-semibold shadow-xs",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Channel, ChannelMember } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
type BuildHuddleChannelNameInput = {
|
||||
channel: Channel;
|
||||
@@ -7,10 +7,6 @@ type BuildHuddleChannelNameInput = {
|
||||
members?: readonly ChannelMember[];
|
||||
};
|
||||
|
||||
function fallbackPubkeyLabel(pubkey: string): string {
|
||||
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
|
||||
}
|
||||
|
||||
function firstName(label: string): string {
|
||||
return label.trim().split(/\s+/)[0] ?? "";
|
||||
}
|
||||
@@ -37,7 +33,7 @@ function channelParticipantLabel(
|
||||
return firstName(fallbackName);
|
||||
}
|
||||
|
||||
return fallbackPubkeyLabel(pubkey);
|
||||
return truncatePubkey(pubkey);
|
||||
}
|
||||
|
||||
export function buildHuddleChannelName({
|
||||
|
||||
@@ -41,6 +41,7 @@ import { formatTime } from "@/features/messages/lib/dateFormatters";
|
||||
// Pure overlay helper lives in a sibling .mjs so node:test (no TS loader)
|
||||
// can exercise the exact same source the renderer uses.
|
||||
import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
const HEX_RE = /^[0-9a-f]+$/i;
|
||||
|
||||
@@ -334,7 +335,7 @@ export function formatTimelineMessages(
|
||||
? "You"
|
||||
: profile?.displayName?.trim() ||
|
||||
profile?.nip05Handle?.trim() ||
|
||||
`${actorPubkey.slice(0, 8)}…`;
|
||||
truncatePubkey(actorPubkey);
|
||||
existing.users.push({
|
||||
pubkey: actorPubkey,
|
||||
displayName,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export type MentionCandidateForRanking = {
|
||||
displayName: string | null;
|
||||
@@ -63,7 +63,8 @@ export function rankMentionCandidates<T extends MentionCandidateForRanking>(
|
||||
? normalizePubkey(candidate.pubkey)
|
||||
: "";
|
||||
const label =
|
||||
candidate.displayName ?? candidate.pubkey?.slice(0, 8) ?? "persona";
|
||||
candidate.displayName ??
|
||||
(candidate.pubkey ? truncatePubkey(candidate.pubkey) : "persona");
|
||||
const groupRank = getMentionCandidateGroupRank(
|
||||
candidate,
|
||||
activePersonaIds,
|
||||
|
||||
@@ -31,8 +31,9 @@ import type {
|
||||
UserSearchResult,
|
||||
} from "@/shared/api/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { formatOwnerLabel } from "@/features/profile/lib/identity";
|
||||
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
|
||||
import { hasMention } from "./hasMention";
|
||||
import { rankMentionCandidates } from "./mentionRanking";
|
||||
@@ -57,7 +58,10 @@ type MentionCandidate = {
|
||||
};
|
||||
|
||||
function mentionCandidateLabel(candidate: MentionCandidate) {
|
||||
return candidate.displayName ?? candidate.pubkey?.slice(0, 8) ?? "persona";
|
||||
return (
|
||||
candidate.displayName ??
|
||||
(candidate.pubkey ? truncatePubkey(candidate.pubkey) : "persona")
|
||||
);
|
||||
}
|
||||
|
||||
function globalSearchIdentityKey(candidate: MentionCandidate) {
|
||||
@@ -102,31 +106,6 @@ function formatSearchUserSecondaryLabel(user: UserSearchResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatOwnerLabel(
|
||||
ownerPubkey: string | null | undefined,
|
||||
currentPubkey: string | null | undefined,
|
||||
ownerProfiles?: UserProfileLookup,
|
||||
) {
|
||||
if (!ownerPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedOwnerPubkey = normalizePubkey(ownerPubkey);
|
||||
if (
|
||||
currentPubkey &&
|
||||
normalizedOwnerPubkey === normalizePubkey(currentPubkey)
|
||||
) {
|
||||
return "you";
|
||||
}
|
||||
|
||||
const owner = ownerProfiles?.[normalizedOwnerPubkey];
|
||||
return (
|
||||
owner?.displayName?.trim() ||
|
||||
owner?.nip05Handle?.trim() ||
|
||||
`${ownerPubkey.slice(0, 8)}…`
|
||||
);
|
||||
}
|
||||
|
||||
export function useMentions(
|
||||
channelId: string | null,
|
||||
externalMembers?: ChannelMember[],
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
POPOVER_SURFACE_CLASS,
|
||||
} from "@/shared/ui/popoverSurface";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { safeNpub } from "@/shared/lib/nostrUtils";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export type MentionSuggestion = {
|
||||
pubkey?: string;
|
||||
@@ -59,6 +61,15 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Name collisions are the impersonation vector: a vanity-ground key can
|
||||
// wear any display name. When two suggestions share a name, surface each
|
||||
// one's npub (truncated; full key in the hover tooltip) to tell them apart.
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const suggestion of suggestions) {
|
||||
const name = suggestion.displayName.toLowerCase();
|
||||
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -86,6 +97,12 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
(suggestion.personaId ? `persona-${suggestion.personaId}` : null) ??
|
||||
suggestion.displayName;
|
||||
const agentLabel = "agent";
|
||||
const hasNameCollision =
|
||||
(nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1;
|
||||
const collisionNpub =
|
||||
hasNameCollision && suggestion.pubkey
|
||||
? safeNpub(suggestion.pubkey)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -166,6 +183,20 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{collisionNpub ? (
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate font-mono text-2xs leading-snug",
|
||||
index === selectedIndex
|
||||
? "text-accent-foreground/60"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
data-testid="mention-collision-npub"
|
||||
title={collisionNpub}
|
||||
>
|
||||
{truncatePubkey(collisionNpub)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Shimmer } from "@/shared/ui/Shimmer";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
type TypingIndicatorRowProps = {
|
||||
channel: Channel | null;
|
||||
@@ -97,7 +98,7 @@ export function TypingIndicatorRow({
|
||||
<div className="flex shrink-0 items-center">
|
||||
{typingPubkeys.map((pubkey, index) => {
|
||||
const profile = profiles?.[pubkey.toLowerCase()];
|
||||
const label = labels[index] ?? pubkey.slice(0, 8);
|
||||
const label = labels[index] ?? truncatePubkey(pubkey);
|
||||
return (
|
||||
<div
|
||||
key={pubkey}
|
||||
|
||||
@@ -22,7 +22,7 @@ import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTex
|
||||
import type { UseDraftsResult } from "@/features/messages/lib/useDrafts";
|
||||
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
|
||||
import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames";
|
||||
import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags";
|
||||
|
||||
@@ -562,7 +562,8 @@ export function useMentionSendFlow({
|
||||
if (!pendingNonMemberSend) return [];
|
||||
|
||||
return pendingNonMemberSend.nonMemberPubkeys.map(
|
||||
(pubkey) => mentions.getMentionDisplayName(pubkey) ?? pubkey.slice(0, 8),
|
||||
(pubkey) =>
|
||||
mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey),
|
||||
);
|
||||
}, [mentions.getMentionDisplayName, pendingNonMemberSend]);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import {
|
||||
resolveUserLabel,
|
||||
truncatePubkey,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { Check, Copy, KeyRound, ShieldX } from "lucide-react";
|
||||
|
||||
import { nsecToNpub, pubkeyToNpub, shortenNpub } from "@/shared/lib/nostrUtils";
|
||||
import { nsecToNpub, pubkeyToNpub } from "@/shared/lib/nostrUtils";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
@@ -178,7 +178,7 @@ export function MembershipDenied({
|
||||
This will use this Nostr identity:
|
||||
</p>
|
||||
<p className="break-all font-mono text-2xs text-muted-foreground">
|
||||
{shortenNpub(previewNpub)}
|
||||
{previewNpub}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import { Check, KeyRound } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { nsecToNpub, shortenNpub } from "@/shared/lib/nostrUtils";
|
||||
import { nsecToNpub } from "@/shared/lib/nostrUtils";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
@@ -228,7 +228,7 @@ export function NostrKeyImportForm({
|
||||
This will use this Nostr identity:
|
||||
</p>
|
||||
<p className="break-all font-mono text-2xs text-muted-foreground">
|
||||
{shortenNpub(previewNpub)}
|
||||
{previewNpub}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { Profile, UserProfileSummary } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export type UserProfileLookup = Record<string, UserProfileSummary>;
|
||||
|
||||
export function truncatePubkey(pubkey: string) {
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
|
||||
}
|
||||
export { truncatePubkey };
|
||||
|
||||
function getResolvedProfile(
|
||||
pubkey: string,
|
||||
@@ -116,3 +114,32 @@ export function resolveUserSecondaryLabel(input: {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for an agent's owner: "you" when the current user owns it, otherwise
|
||||
* the owner's display name, NIP-05 handle, or truncated pubkey.
|
||||
*/
|
||||
export function formatOwnerLabel(
|
||||
ownerPubkey: string | null | undefined,
|
||||
currentPubkey: string | null | undefined,
|
||||
ownerProfiles?: UserProfileLookup,
|
||||
) {
|
||||
if (!ownerPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedOwnerPubkey = normalizePubkey(ownerPubkey);
|
||||
if (
|
||||
currentPubkey &&
|
||||
normalizedOwnerPubkey === normalizePubkey(currentPubkey)
|
||||
) {
|
||||
return "you";
|
||||
}
|
||||
|
||||
const owner = ownerProfiles?.[normalizedOwnerPubkey];
|
||||
return (
|
||||
owner?.displayName?.trim() ||
|
||||
owner?.nip05Handle?.trim() ||
|
||||
truncatePubkey(ownerPubkey)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
|
||||
import { truncatePubkey as truncatePubkeyShort } from "@/features/profile/lib/identity";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { copyTextToClipboard } from "@/shared/lib/clipboard";
|
||||
import { PubKey } from "@/shared/ui/PubKey";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import type {
|
||||
AgentPersona,
|
||||
@@ -166,11 +167,10 @@ export function buildPublicFields({
|
||||
|
||||
if (pubkey) {
|
||||
fields.push({
|
||||
copyValue: pubkey,
|
||||
displayValue: truncatePubkeyShort(pubkey),
|
||||
displayValue: truncatePubkey(pubkey),
|
||||
displayNode: <PubKey pubkey={pubkey} testId="user-profile-copy-pubkey" />,
|
||||
icon: Fingerprint,
|
||||
label: "Public key",
|
||||
testId: "user-profile-copy-pubkey",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ import type {
|
||||
RelayAgent,
|
||||
UpdateManagedAgentInput,
|
||||
} from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { truncatePubkey } from "@/features/profile/lib/identity";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export { truncatePubkey };
|
||||
|
||||
|
||||
@@ -23,10 +23,7 @@ import {
|
||||
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
|
||||
import {
|
||||
ownsAuthorAgent,
|
||||
truncatePubkey,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
|
||||
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
|
||||
import { usePresenceQuery } from "@/features/presence/hooks";
|
||||
import { useUserStatusQuery } from "@/features/user-status/hooks";
|
||||
@@ -43,7 +40,7 @@ import { sendChannelMessage } from "@/shared/api/tauri";
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
|
||||
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@/features/projects/hooks";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelMember } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
|
||||
|
||||
@@ -36,7 +36,7 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) {
|
||||
return (
|
||||
profile?.displayName?.trim() ||
|
||||
profile?.nip05Handle?.trim() ||
|
||||
`${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`
|
||||
truncatePubkey(pubkey)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import type { UserProfileSummary } from "@/shared/api/types";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
type AgentActivityCardProps = {
|
||||
group: AgentNoteGroup;
|
||||
@@ -44,7 +45,7 @@ export function AgentActivityCard({
|
||||
agentStatus,
|
||||
}: AgentActivityCardProps) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const displayName = profile?.displayName ?? `${group.pubkey.slice(0, 8)}...`;
|
||||
const displayName = profile?.displayName ?? truncatePubkey(group.pubkey);
|
||||
const avatarUrl = profile?.avatarUrl ?? null;
|
||||
const isSingleNote = group.notes.length === 1;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AnimatedCount } from "@/shared/ui/AnimatedCount";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export type NoteCardActions = {
|
||||
reply?: (
|
||||
@@ -66,7 +67,7 @@ function ReplyParentContext({
|
||||
const parentDisplayName = parentNote
|
||||
? (cachedProfile?.displayName ??
|
||||
fetchedProfile?.displayName ??
|
||||
`${parentNote.pubkey.slice(0, 8)}...`)
|
||||
truncatePubkey(parentNote.pubkey))
|
||||
: null;
|
||||
const parentAvatarUrl =
|
||||
cachedProfile?.avatarUrl ?? fetchedProfile?.avatarUrl ?? null;
|
||||
@@ -142,7 +143,7 @@ export function NoteCard({
|
||||
members = [],
|
||||
actions,
|
||||
}: NoteCardProps) {
|
||||
const displayName = profile?.displayName ?? `${note.pubkey.slice(0, 8)}...`;
|
||||
const displayName = profile?.displayName ?? truncatePubkey(note.pubkey);
|
||||
const avatarUrl = profile?.avatarUrl ?? null;
|
||||
const [isReplyComposerOpen, setIsReplyComposerOpen] = React.useState(false);
|
||||
const actionButtonClass =
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Input } from "@/shared/ui/input";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export type PulseTab =
|
||||
| "search"
|
||||
@@ -223,7 +224,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
||||
: null;
|
||||
const currentDisplayName =
|
||||
currentProfile?.displayName ??
|
||||
(currentPubkey ? `${currentPubkey.slice(0, 8)}...` : "You");
|
||||
(currentPubkey ? truncatePubkey(currentPubkey) : "You");
|
||||
|
||||
const pulseMentionMembers = React.useMemo<ChannelMember[]>(() => {
|
||||
const members: ChannelMember[] = [];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { truncatePubkey } from "@/features/profile/lib/identity";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { PubKey } from "@/shared/ui/PubKey";
|
||||
import { useRemoveRelayMemberMutation } from "@/features/relay-members/hooks";
|
||||
import type { RelayMember } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -44,6 +45,13 @@ export function ConfirmRemoveDialog({
|
||||
<DialogDescription>
|
||||
This will immediately revoke their access to the relay.
|
||||
</DialogDescription>
|
||||
{member ? (
|
||||
<PubKey
|
||||
pubkey={member.pubkey}
|
||||
testId="confirm-remove-member-pubkey"
|
||||
variant="full"
|
||||
/>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
|
||||
@@ -3,7 +3,8 @@ import { MoreHorizontal, Plus, Shield, ShieldCheck, User } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { truncatePubkey } from "@/features/profile/lib/identity";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { PubKey } from "@/shared/ui/PubKey";
|
||||
import {
|
||||
useChangeRelayMemberRoleMutation,
|
||||
useMyRelayMembershipQuery,
|
||||
@@ -107,8 +108,9 @@ function MemberRow({
|
||||
<span className="text-xs text-muted-foreground">(you)</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Joined {formatRelativeDate(member.createdAt)}
|
||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<PubKey className="text-xs" pubkey={member.pubkey} />
|
||||
<span>Joined {formatRelativeDate(member.createdAt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import type { RelayMember, RelayMemberRole } from "@/shared/api/types";
|
||||
import type { UserProfileSummary } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -66,10 +66,7 @@ function isValidHexPubkey(value: string): boolean {
|
||||
}
|
||||
|
||||
function formatDisplayName(member: RelayMember, displayName?: string | null) {
|
||||
return (
|
||||
displayName?.trim() ||
|
||||
`${member.pubkey.slice(0, 10)}…${member.pubkey.slice(-6)}`
|
||||
);
|
||||
return displayName?.trim() || truncatePubkey(member.pubkey);
|
||||
}
|
||||
|
||||
function npubFromPubkey(pubkey: string): string | null {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { SearchPromptPlaceholder } from "@/features/search/ui/SearchPromptPlaceholder";
|
||||
import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
|
||||
import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen";
|
||||
import {
|
||||
@@ -145,7 +145,7 @@ function getUserDisplayName(user: UserSearchResult) {
|
||||
return (
|
||||
user.displayName?.trim() ||
|
||||
user.nip05Handle?.trim() ||
|
||||
`${normalizePubkey(user.pubkey).slice(0, 8)}...`
|
||||
truncatePubkey(user.pubkey)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
useUserSearchFetchMoreOnScroll,
|
||||
useUsersBatchQuery,
|
||||
} from "@/features/profile/hooks";
|
||||
import { truncatePubkey } from "@/features/profile/lib/identity";
|
||||
import { formatOwnerLabel } from "@/features/profile/lib/identity";
|
||||
import {
|
||||
getKeyboardSearchSelection,
|
||||
rankUserCandidatesBySearch,
|
||||
@@ -25,13 +25,11 @@ import {
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import type {
|
||||
ManagedAgent,
|
||||
UserSearchResult,
|
||||
UserProfileSummary,
|
||||
} from "@/shared/api/types";
|
||||
import type { ManagedAgent, UserSearchResult } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { safeNpub } from "@/shared/lib/nostrUtils";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { PubKey } from "@/shared/ui/PubKey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -65,22 +63,6 @@ function formatUserName(user: UserSearchResult) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatOwnerName(
|
||||
user: UserSearchResult,
|
||||
ownerProfiles?: Record<string, UserProfileSummary>,
|
||||
) {
|
||||
if (!user.ownerPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const owner = ownerProfiles?.[normalizePubkey(user.ownerPubkey)];
|
||||
return (
|
||||
owner?.displayName?.trim() ||
|
||||
owner?.nip05Handle?.trim() ||
|
||||
truncatePubkey(user.ownerPubkey)
|
||||
);
|
||||
}
|
||||
|
||||
type DirectMessageSearchCandidate = UserSearchResult & {
|
||||
isManagedAgent?: boolean;
|
||||
isMember?: boolean;
|
||||
@@ -699,6 +681,22 @@ export function NewDirectMessageDialog({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedUsers.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{selectedUsers.map((user) => (
|
||||
<div
|
||||
className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-2xs text-muted-foreground"
|
||||
data-testid={`new-dm-pubkey-${user.pubkey}`}
|
||||
key={user.pubkey}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{formatUserName(user)}
|
||||
</span>
|
||||
<PubKey pubkey={user.pubkey} variant="full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -717,8 +715,9 @@ export function NewDirectMessageDialog({
|
||||
{searchResults.length > 0 ? (
|
||||
<div>
|
||||
{searchResults.map((user) => {
|
||||
const ownerLabel = formatOwnerName(
|
||||
user,
|
||||
const ownerLabel = formatOwnerLabel(
|
||||
user.ownerPubkey,
|
||||
currentPubkey ?? identityQuery.data?.pubkey,
|
||||
ownerProfilesQuery.data?.profiles,
|
||||
);
|
||||
|
||||
@@ -748,8 +747,8 @@ export function NewDirectMessageDialog({
|
||||
/>
|
||||
<div className="pointer-events-none relative z-10 min-w-0 flex-1">
|
||||
{user.isAgent ? (
|
||||
<div className="relative min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2 transition-opacity duration-150 ease-out group-hover/dm-result:opacity-0 group-focus-within/dm-result:opacity-0">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium tracking-tight">
|
||||
{formatUserName(user)}
|
||||
</span>
|
||||
@@ -763,14 +762,16 @@ export function NewDirectMessageDialog({
|
||||
</span>
|
||||
</div>
|
||||
{ownerLabel ? (
|
||||
<span className="block truncate text-xs text-muted-foreground transition-opacity duration-150 ease-out group-hover/dm-result:opacity-0 group-focus-within/dm-result:opacity-0">
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
owned by {ownerLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="absolute inset-0 flex items-center opacity-0 transition-opacity duration-150 ease-out group-hover/dm-result:opacity-100 group-focus-within/dm-result:opacity-100">
|
||||
<span className="truncate font-mono text-sm text-muted-foreground">
|
||||
{truncatePubkey(user.pubkey)}
|
||||
</span>
|
||||
<span
|
||||
className="hidden min-w-0 break-all font-mono text-2xs leading-snug text-muted-foreground group-hover/dm-result:block group-focus-within/dm-result:block"
|
||||
data-testid={`new-dm-npub-${user.pubkey}`}
|
||||
>
|
||||
{safeNpub(user.pubkey) ??
|
||||
truncatePubkey(user.pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -11,6 +11,18 @@ export function pubkeyToNpub(hexPubkey: string): string {
|
||||
return npubEncode(hexPubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `pubkeyToNpub`, but returns null instead of throwing on malformed
|
||||
* input. For display surfaces that must degrade gracefully.
|
||||
*/
|
||||
export function safeNpub(pubkey: string): string | null {
|
||||
try {
|
||||
return npubEncode(pubkey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a bech32 nsec string and derive the matching npub. Returns null if
|
||||
* the input is not a syntactically valid `nsec1…` (does NOT throw — this is
|
||||
@@ -35,14 +47,3 @@ export function nsecToNpub(nsec: string): string | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an npub for compact display: `npub1abcd…wxyz`. Falls back to the
|
||||
* original string if it's shorter than the truncation thresholds.
|
||||
*/
|
||||
export function shortenNpub(npub: string): string {
|
||||
if (npub.length <= 16) {
|
||||
return npub;
|
||||
}
|
||||
return `${npub.slice(0, 12)}…${npub.slice(-6)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { normalizePubkey, truncatePubkey } from "./pubkey.ts";
|
||||
|
||||
const PUBKEY =
|
||||
"44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435";
|
||||
|
||||
test("truncates to the canonical 8+4 form with unicode ellipsis", () => {
|
||||
assert.equal(truncatePubkey(PUBKEY), "44b8e82b…0435");
|
||||
});
|
||||
|
||||
test("returns short strings unchanged", () => {
|
||||
assert.equal(truncatePubkey("abcd1234"), "abcd1234");
|
||||
assert.equal(truncatePubkey(""), "");
|
||||
});
|
||||
|
||||
test("normalizePubkey trims and lowercases", () => {
|
||||
assert.equal(normalizePubkey(" ABCDEF "), "abcdef");
|
||||
});
|
||||
@@ -7,3 +7,19 @@
|
||||
export function normalizePubkey(pubkey: string): string {
|
||||
return pubkey.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE canonical compact display form for a pubkey: `abcd1234…wxyz`.
|
||||
*
|
||||
* A truncated pubkey is a recognition aid, never an identity proof — vanity
|
||||
* grinders forge short prefixes cheaply. Surfaces where the user makes a
|
||||
* trust decision must show the full npub (see `<PubKey variant="full">`).
|
||||
* Do not hand-roll `pubkey.slice(…)` display forms; `check-pubkey-truncation`
|
||||
* fails the build if one sneaks in outside this module.
|
||||
*/
|
||||
export function truncatePubkey(pubkey: string): string {
|
||||
if (pubkey.length <= 12) {
|
||||
return pubkey;
|
||||
}
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { copyTextToClipboard } from "@/shared/lib/clipboard";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { safeNpub } from "@/shared/lib/nostrUtils";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
|
||||
const HOVER_OPEN_DELAY_MS = 500;
|
||||
const HOVER_CLOSE_DELAY_MS = 200;
|
||||
|
||||
type PubKeyProps = {
|
||||
/** 64-char hex pubkey. */
|
||||
pubkey: string;
|
||||
/**
|
||||
* `compact` — truncated hex, click/tap opens a popover with the full npub,
|
||||
* full hex, and copy buttons. The default for identity display in lists,
|
||||
* cards, and metadata rows.
|
||||
*
|
||||
* `full` — the complete npub rendered inline with copy buttons. Required on
|
||||
* security-decision surfaces (invite/approve, removal, trust/pairing, new
|
||||
* DM, key import): a truncated key is forgeable by vanity grinding, so
|
||||
* decisions must be made against the whole key.
|
||||
*/
|
||||
variant?: "compact" | "full";
|
||||
className?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
function CopyRow({ label, value }: { label: string; value: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const resetTimer = React.useRef<number | undefined>(undefined);
|
||||
React.useEffect(() => () => window.clearTimeout(resetTimer.current), []);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-start gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-2xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="break-all font-mono text-xs">{value}</div>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`Copy ${label}`}
|
||||
onClick={() => {
|
||||
copyTextToClipboard(value, `${label} copied`);
|
||||
setCopied(true);
|
||||
window.clearTimeout(resetTimer.current);
|
||||
resetTimer.current = window.setTimeout(() => setCopied(false), 1500);
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PubKeyDetails({ pubkey }: { pubkey: string }) {
|
||||
const npub = safeNpub(pubkey);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{npub ? <CopyRow label="npub" value={npub} /> : null}
|
||||
<CopyRow label="hex" value={pubkey} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical pubkey display. See the `variant` prop for when each form is
|
||||
* appropriate; never render a hand-truncated pubkey outside this component.
|
||||
*/
|
||||
export function PubKey({
|
||||
pubkey,
|
||||
variant = "compact",
|
||||
className,
|
||||
testId,
|
||||
}: PubKeyProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const clearHoverTimer = React.useCallback(() => {
|
||||
if (hoverTimerRef.current !== null) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTriggerMouseEnter = React.useCallback(() => {
|
||||
clearHoverTimer();
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setOpen(true);
|
||||
}, HOVER_OPEN_DELAY_MS);
|
||||
}, [clearHoverTimer]);
|
||||
|
||||
const handleMouseLeave = React.useCallback(() => {
|
||||
clearHoverTimer();
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, HOVER_CLOSE_DELAY_MS);
|
||||
}, [clearHoverTimer]);
|
||||
|
||||
const handleContentMouseEnter = React.useCallback(() => {
|
||||
clearHoverTimer();
|
||||
}, [clearHoverTimer]);
|
||||
|
||||
React.useEffect(() => clearHoverTimer, [clearHoverTimer]);
|
||||
|
||||
if (variant === "full") {
|
||||
const npub = safeNpub(pubkey);
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex min-w-0 items-center gap-1", className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className="break-all font-mono text-xs">{npub ?? pubkey}</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Copy public key"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-96 max-w-[90vw]">
|
||||
<PubKeyDetails pubkey={pubkey} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-label="Show full public key"
|
||||
className={cn(
|
||||
"cursor-pointer rounded font-mono hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
onMouseEnter={handleTriggerMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
type="button"
|
||||
>
|
||||
{truncatePubkey(pubkey)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-96 max-w-[90vw]"
|
||||
onMouseEnter={handleContentMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<PubKeyDetails pubkey={pubkey} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import {
|
||||
installMockBridge,
|
||||
openNewDirectMessageDialog,
|
||||
TEST_IDENTITIES,
|
||||
} from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/pubkey-display";
|
||||
|
||||
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
|
||||
const AGENT_PUBKEY = "cafef00d".repeat(8);
|
||||
|
||||
// Screenshot evidence for the pubkey-display work: the canonical <PubKey>
|
||||
// compact popover (full npub + hex, copy-either) and the inline-full-npub
|
||||
// decision surfaces. Not a regression suite — assertions are the minimum
|
||||
// needed to know each shot captured the right state.
|
||||
|
||||
test("profile panel Public key row opens the PubKey popover on hover", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const messageRow = page.getByTestId("message-row").first();
|
||||
await expect(messageRow).toBeVisible();
|
||||
await messageRow.locator("button").first().click();
|
||||
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
||||
|
||||
const pubkeyTrigger = page.getByTestId("user-profile-copy-pubkey");
|
||||
await expect(pubkeyTrigger).toBeVisible();
|
||||
await pubkeyTrigger.hover();
|
||||
|
||||
// Hover-open fires after a 500ms intent delay.
|
||||
await expect(page.getByText("hex", { exact: true })).toBeVisible({
|
||||
timeout: 3_000,
|
||||
});
|
||||
await expect(page.getByText("npub", { exact: true })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/profile-panel-pubkey-hover-popover.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("new-DM agent row keeps the name on hover and shows 'owned by you'", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Agent rows only surface when the agent is mentionable (managed or in a
|
||||
// shared channel), so seed managedAgents alongside the search profile.
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [
|
||||
{
|
||||
name: "Pinky",
|
||||
pubkey: AGENT_PUBKEY,
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
searchProfiles: [
|
||||
{
|
||||
displayName: "Pinky",
|
||||
isAgent: true,
|
||||
ownerPubkey: MOCK_IDENTITY_PUBKEY,
|
||||
pubkey: AGENT_PUBKEY,
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
await openNewDirectMessageDialog(page);
|
||||
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
|
||||
await page.getByTestId("new-dm-search").fill("pinky");
|
||||
|
||||
// The result testid sits on an empty inset overlay button; the visible
|
||||
// text lives on the parent row.
|
||||
const agentRow = page
|
||||
.getByTestId(`new-dm-result-${AGENT_PUBKEY}`)
|
||||
.locator("..");
|
||||
await expect(agentRow).toBeVisible();
|
||||
await expect(agentRow).toContainText("owned by you");
|
||||
|
||||
await agentRow.hover();
|
||||
// Hover must ADD the full npub, not swap the name away.
|
||||
await expect(agentRow).toContainText("Pinky");
|
||||
await expect(page.getByTestId(`new-dm-npub-${AGENT_PUBKEY}`)).toContainText(
|
||||
"npub1",
|
||||
);
|
||||
await page.getByTestId("new-dm-dialog").screenshot({
|
||||
path: `${SHOTS}/new-dm-agent-row-hover-owned-by-you.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("compact PubKey popover reveals full npub + hex with copy actions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
// The new-DM dialog renders agent rows and, on selection, the
|
||||
// full-npub verification list — capture both there.
|
||||
await openNewDirectMessageDialog(page);
|
||||
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
|
||||
|
||||
await page.getByTestId("new-dm-search").fill("charlie");
|
||||
await expect(
|
||||
page.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const verifyRow = page.getByTestId(
|
||||
`new-dm-pubkey-${TEST_IDENTITIES.charlie.pubkey}`,
|
||||
);
|
||||
await expect(verifyRow).toBeVisible();
|
||||
await expect(verifyRow).toContainText("npub1");
|
||||
|
||||
await page.getByTestId("new-dm-dialog").screenshot({
|
||||
path: `${SHOTS}/new-dm-full-npub.png`,
|
||||
});
|
||||
|
||||
// Open the copy popover from the full-variant copy button.
|
||||
await verifyRow.getByRole("button", { name: "Copy public key" }).click();
|
||||
await expect(page.getByText("hex", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: `${SHOTS}/pubkey-copy-popover.png` });
|
||||
});
|
||||
|
||||
test("member removal confirm shows the full npub inline", async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await page.getByTestId("channel-members-trigger").click();
|
||||
await expect(page.getByTestId("members-sidebar")).toBeVisible();
|
||||
await page.getByTestId("members-sidebar").screenshot({
|
||||
path: `${SHOTS}/members-sidebar.png`,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Shared "no hand-rolled pubkey truncation" guard.
|
||||
*
|
||||
* A truncated pubkey prefix is forgeable by vanity-grinding, so display
|
||||
* truncation must be consistent and centralized: the canonical
|
||||
* `truncatePubkey` in `shared/lib/pubkey.ts` (or the `<PubKey>` component,
|
||||
* which also offers full-key reveal + copy). Ad-hoc `pubkey.slice(0, N)`
|
||||
* display forms fragmented into five formats before this guard existed.
|
||||
*
|
||||
* It flags `.slice(` / `.substring(` / `.slice(0` template-truncations applied
|
||||
* to identifiers that look like a pubkey/npub, outside the canonical module.
|
||||
* Non-display uses (array windows, color derivation from a key, avatar
|
||||
* initials) live in each app's `overrides` allowlist.
|
||||
*/
|
||||
|
||||
const PUBKEY_SLICE_RE =
|
||||
/\b[A-Za-z_$][\w$]*(?:[Pp]ubkey|[Pp]ub_key|[Nn]pub)[\w$]*\??\.(?:slice|substring)\(|\b(?:pubkey|npub)\??\.(?:slice|substring)\(/g;
|
||||
|
||||
async function walkFiles(directory) {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return walkFiles(fullPath);
|
||||
}
|
||||
return [fullPath];
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.projectRoot Absolute path the rule roots resolve against.
|
||||
* @param {Array<{root: string, extensions: Set<string>}>} options.rules Where to scan.
|
||||
* @param {string} options.label Human label for the failure header.
|
||||
* @param {Set<string>} [options.overrides] Allowlisted "relativePath:lineNumber" entries.
|
||||
* @param {Set<string>} [options.allowedFiles] Relative paths allowed to truncate (the canonical module).
|
||||
* @param {string} options.scriptPath Path mentioned in the failure hint.
|
||||
*/
|
||||
export async function runPubkeyTruncationCheck({
|
||||
projectRoot,
|
||||
rules,
|
||||
label,
|
||||
overrides = new Set(),
|
||||
allowedFiles = new Set(),
|
||||
scriptPath,
|
||||
}) {
|
||||
const candidateFiles = (
|
||||
await Promise.all(
|
||||
rules.map((rule) => {
|
||||
const dir = path.join(projectRoot, rule.root);
|
||||
return fs
|
||||
.access(dir)
|
||||
.then(() => walkFiles(dir))
|
||||
.catch(() => []);
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of candidateFiles) {
|
||||
const relativePath = path.relative(projectRoot, filePath);
|
||||
const rule = rules.find((r) =>
|
||||
relativePath.startsWith(`${r.root}${path.sep}`),
|
||||
);
|
||||
if (!rule || !rule.extensions.has(path.extname(filePath))) {
|
||||
continue;
|
||||
}
|
||||
if (allowedFiles.has(relativePath.split(path.sep).join("/"))) {
|
||||
continue;
|
||||
}
|
||||
if (relativePath.includes(".test.")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
lines.forEach((line, index) => {
|
||||
PUBKEY_SLICE_RE.lastIndex = 0;
|
||||
if (!PUBKEY_SLICE_RE.test(line)) {
|
||||
return;
|
||||
}
|
||||
const key = `${relativePath.split(path.sep).join("/")}:${index + 1}`;
|
||||
if (overrides.has(key)) {
|
||||
return;
|
||||
}
|
||||
violations.push({ key, line: line.trim() });
|
||||
});
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(
|
||||
`${label}: found ${violations.length} hand-rolled pubkey truncation(s).\n` +
|
||||
`Use \`truncatePubkey\` from shared/lib/pubkey (or the <PubKey> component) instead.\n` +
|
||||
`Genuine non-display uses can be allowlisted in ${scriptPath}.\n`,
|
||||
);
|
||||
for (const violation of violations) {
|
||||
console.error(` ${violation.key}: ${violation.line}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -8,8 +8,9 @@
|
||||
"build": "tsc && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:file-sizes": "node ./scripts/check-file-sizes.mjs",
|
||||
"check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs",
|
||||
"lint": "biome lint .",
|
||||
"check": "biome check . && pnpm check:file-sizes",
|
||||
"check": "biome check . && pnpm check:file-sizes && pnpm check:pubkey-truncation",
|
||||
"format": "biome format --write .",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "pnpm build && playwright test",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runPubkeyTruncationCheck } from "../../scripts/check-pubkey-truncation-core.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
|
||||
const rules = [
|
||||
{
|
||||
root: "src",
|
||||
extensions: new Set([".ts", ".tsx"]),
|
||||
},
|
||||
];
|
||||
|
||||
const overrides = new Set([
|
||||
// Avatar fallback initials — two glyphs inside an avatar disc.
|
||||
"src/features/repos/ui/PubkeyAvatar.tsx:29",
|
||||
// Array window (first N pubkeys), not string truncation.
|
||||
"src/features/repos/ui/OrgSidebar.tsx:22",
|
||||
]);
|
||||
|
||||
await runPubkeyTruncationCheck({
|
||||
projectRoot,
|
||||
rules,
|
||||
overrides,
|
||||
allowedFiles: new Set(["src/shared/lib/pubkey.ts"]),
|
||||
label: "Web",
|
||||
scriptPath: "web/scripts/check-pubkey-truncation.mjs",
|
||||
});
|
||||
@@ -4,13 +4,9 @@ import { Link } from "@tanstack/react-router";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { relativeTime } from "@/shared/lib/relative-time";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import type { Repo } from "../use-repos";
|
||||
|
||||
function truncateHex(hex: string): string {
|
||||
if (hex.length <= 12) return hex;
|
||||
return `${hex.slice(0, 8)}...${hex.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function RepoListItem({ repo }: { repo: Repo }) {
|
||||
return (
|
||||
<div className="py-6">
|
||||
@@ -41,7 +37,7 @@ export function RepoListItem({ repo }: { repo: Repo }) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default font-mono">
|
||||
{truncateHex(repo.owner)}
|
||||
{truncatePubkey(repo.owner)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{repo.owner}</TooltipContent>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The ONE canonical compact display form for a pubkey: `abcd1234…wxyz`.
|
||||
* Mirrors desktop's `@/shared/lib/pubkey`. A truncated pubkey is a
|
||||
* recognition aid, never an identity proof — security decisions need the
|
||||
* full npub.
|
||||
*/
|
||||
export function truncatePubkey(pubkey: string): string {
|
||||
if (pubkey.length <= 12) {
|
||||
return pubkey;
|
||||
}
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user