Files
Moltbot/ui/src/ui/views/channels.nostr.ts
2026-02-02 15:23:36 +09:00

238 lines
8.2 KiB
TypeScript

import { html, nothing } from "lit";
import type { ChannelAccountSnapshot, NostrStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { formatAgo } from "../format";
import { renderChannelConfigSection } from "./channels.config";
import {
renderNostrProfileForm,
type NostrProfileFormState,
type NostrProfileFormCallbacks,
} from "./channels.nostr-profile-form";
/**
* Truncate a pubkey for display (shows first and last 8 chars)
*/
function truncatePubkey(pubkey: string | null | undefined): string {
if (!pubkey) {
return "n/a";
}
if (pubkey.length <= 20) {
return pubkey;
}
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
}
export function renderNostrCard(params: {
props: ChannelsProps;
nostr?: NostrStatus | null;
nostrAccounts: ChannelAccountSnapshot[];
accountCountLabel: unknown;
/** Profile form state (optional - if provided, shows form) */
profileFormState?: NostrProfileFormState | null;
/** Profile form callbacks */
profileFormCallbacks?: NostrProfileFormCallbacks | null;
/** Called when Edit Profile is clicked */
onEditProfile?: () => void;
}) {
const {
props,
nostr,
nostrAccounts,
accountCountLabel,
profileFormState,
profileFormCallbacks,
onEditProfile,
} = params;
const primaryAccount = nostrAccounts[0];
const summaryConfigured = nostr?.configured ?? primaryAccount?.configured ?? false;
const summaryRunning = nostr?.running ?? primaryAccount?.running ?? false;
const summaryPublicKey =
nostr?.publicKey ?? (primaryAccount as { publicKey?: string } | undefined)?.publicKey;
const summaryLastStartAt = nostr?.lastStartAt ?? primaryAccount?.lastStartAt ?? null;
const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null;
const hasMultipleAccounts = nostrAccounts.length > 1;
const showingForm = profileFormState !== null && profileFormState !== undefined;
const renderAccountCard = (account: ChannelAccountSnapshot) => {
const publicKey = (account as { publicKey?: string }).publicKey;
const profile = (account as { profile?: { name?: string; displayName?: string } }).profile;
const displayName = profile?.displayName ?? profile?.name ?? account.name ?? account.accountId;
return html`
<div class="account-card">
<div class="account-card-header">
<div class="account-card-title">${displayName}</div>
<div class="account-card-id">${account.accountId}</div>
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="monospace" title="${publicKey ?? ""}">${truncatePubkey(publicKey)}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${
account.lastError
? html`
<div class="account-card-error">${account.lastError}</div>
`
: nothing
}
</div>
</div>
`;
};
const renderProfileSection = () => {
// If showing form, render the form instead of the read-only view
if (showingForm && profileFormCallbacks) {
return renderNostrProfileForm({
state: profileFormState,
callbacks: profileFormCallbacks,
accountId: nostrAccounts[0]?.accountId ?? "default",
});
}
const profile =
(
primaryAccount as
| {
profile?: {
name?: string;
displayName?: string;
about?: string;
picture?: string;
nip05?: string;
};
}
| undefined
)?.profile ?? nostr?.profile;
const { name, displayName, about, picture, nip05 } = profile ?? {};
const hasAnyProfileData = name || displayName || about || picture || nip05;
return html`
<div style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-weight: 500;">Profile</div>
${
summaryConfigured
? html`
<button
class="btn btn-sm"
@click=${onEditProfile}
style="font-size: 12px; padding: 4px 8px;"
>
Edit Profile
</button>
`
: nothing
}
</div>
${
hasAnyProfileData
? html`
<div class="status-list">
${
picture
? html`
<div style="margin-bottom: 8px;">
<img
src=${picture}
alt="Profile picture"
style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
@error=${(e: Event) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
`
: nothing
}
${name ? html`<div><span class="label">Name</span><span>${name}</span></div>` : nothing}
${
displayName
? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>`
: nothing
}
${
about
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
: nothing
}
${nip05 ? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>` : nothing}
</div>
`
: html`
<div style="color: var(--text-muted); font-size: 13px">
No profile set. Click "Edit Profile" to add your name, bio, and avatar.
</div>
`
}
</div>
`;
};
return html`
<div class="card">
<div class="card-title">Nostr</div>
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
${accountCountLabel}
${
hasMultipleAccounts
? html`
<div class="account-card-list">
${nostrAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${summaryConfigured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${summaryRunning ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="monospace" title="${summaryPublicKey ?? ""}"
>${truncatePubkey(summaryPublicKey)}</span
>
</div>
<div>
<span class="label">Last start</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
</div>
</div>
`
}
${
summaryLastError
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing
}
${renderProfileSection()}
${renderChannelConfigSection({ channelId: "nostr", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(false)}>Refresh</button>
</div>
</div>
`;
}