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:
Wes
2026-07-07 13:35:15 -07:00
committed by GitHub
co-authored by Pinky Brain
parent cdf982bdb3
commit 777babf393
52 changed files with 812 additions and 232 deletions
+1 -3
View File
@@ -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"]
+34 -38
View File
@@ -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]
+2 -1
View File
@@ -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",
+1
View File
@@ -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",
});
+5 -4
View File
@@ -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>
+31 -4
View File
@@ -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;
+3 -2
View File
@@ -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 =
+2 -1
View File
@@ -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>
) : (
+12 -11
View File
@@ -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)}`;
}
+20
View File
@@ -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");
});
+16
View File
@@ -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)}`;
}
+170
View File
@@ -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`,
});
});
+108
View File
@@ -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
View File
@@ -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",
+29
View File
@@ -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",
});
+2 -6
View File
@@ -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>
+12
View File
@@ -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)}`;
}