feat: add Nostr channel plugin and onboarding install defaults

Co-authored-by: joelklabo <joelklabo@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-20 20:14:44 +00:00
parent 8686b3b951
commit 7b6cbf5869
46 changed files with 7789 additions and 9 deletions

View File

@@ -6,6 +6,8 @@ import {
} from "./controllers/channels";
import { loadConfig, saveConfig } from "./controllers/config";
import type { ClawdbotApp } from "./app";
import type { NostrProfile } from "./types";
import { createNostrProfileFormState } from "./views/channels.nostr-profile-form";
export async function handleWhatsAppStart(host: ClawdbotApp, force: boolean) {
await startWhatsAppLogin(host, force);
@@ -32,3 +34,200 @@ export async function handleChannelConfigReload(host: ClawdbotApp) {
await loadConfig(host);
await loadChannels(host, true);
}
function parseValidationErrors(details: unknown): Record<string, string> {
if (!Array.isArray(details)) return {};
const errors: Record<string, string> = {};
for (const entry of details) {
if (typeof entry !== "string") continue;
const [rawField, ...rest] = entry.split(":");
if (!rawField || rest.length === 0) continue;
const field = rawField.trim();
const message = rest.join(":").trim();
if (field && message) errors[field] = message;
}
return errors;
}
function resolveNostrAccountId(host: ClawdbotApp): string {
const accounts = host.channelsSnapshot?.channelAccounts?.nostr ?? [];
return accounts[0]?.accountId ?? host.nostrProfileAccountId ?? "default";
}
function buildNostrProfileUrl(accountId: string, suffix = ""): string {
return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`;
}
export function handleNostrProfileEdit(
host: ClawdbotApp,
accountId: string,
profile: NostrProfile | null,
) {
host.nostrProfileAccountId = accountId;
host.nostrProfileFormState = createNostrProfileFormState(profile ?? undefined);
}
export function handleNostrProfileCancel(host: ClawdbotApp) {
host.nostrProfileFormState = null;
host.nostrProfileAccountId = null;
}
export function handleNostrProfileFieldChange(
host: ClawdbotApp,
field: keyof NostrProfile,
value: string,
) {
const state = host.nostrProfileFormState;
if (!state) return;
host.nostrProfileFormState = {
...state,
values: {
...state.values,
[field]: value,
},
fieldErrors: {
...state.fieldErrors,
[field]: "",
},
};
}
export function handleNostrProfileToggleAdvanced(host: ClawdbotApp) {
const state = host.nostrProfileFormState;
if (!state) return;
host.nostrProfileFormState = {
...state,
showAdvanced: !state.showAdvanced,
};
}
export async function handleNostrProfileSave(host: ClawdbotApp) {
const state = host.nostrProfileFormState;
if (!state || state.saving) return;
const accountId = resolveNostrAccountId(host);
host.nostrProfileFormState = {
...state,
saving: true,
error: null,
success: null,
fieldErrors: {},
};
try {
const response = await fetch(buildNostrProfileUrl(accountId), {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(state.values),
});
const data = (await response.json().catch(() => null)) as
| { ok?: boolean; error?: string; details?: unknown; persisted?: boolean }
| null;
if (!response.ok || data?.ok === false || !data) {
const errorMessage = data?.error ?? `Profile update failed (${response.status})`;
host.nostrProfileFormState = {
...state,
saving: false,
error: errorMessage,
success: null,
fieldErrors: parseValidationErrors(data?.details),
};
return;
}
if (!data.persisted) {
host.nostrProfileFormState = {
...state,
saving: false,
error: "Profile publish failed on all relays.",
success: null,
};
return;
}
host.nostrProfileFormState = {
...state,
saving: false,
error: null,
success: "Profile published to relays.",
fieldErrors: {},
original: { ...state.values },
};
await loadChannels(host, true);
} catch (err) {
host.nostrProfileFormState = {
...state,
saving: false,
error: `Profile update failed: ${String(err)}`,
success: null,
};
}
}
export async function handleNostrProfileImport(host: ClawdbotApp) {
const state = host.nostrProfileFormState;
if (!state || state.importing) return;
const accountId = resolveNostrAccountId(host);
host.nostrProfileFormState = {
...state,
importing: true,
error: null,
success: null,
};
try {
const response = await fetch(buildNostrProfileUrl(accountId, "/import"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ autoMerge: true }),
});
const data = (await response.json().catch(() => null)) as
| { ok?: boolean; error?: string; imported?: NostrProfile; merged?: NostrProfile; saved?: boolean }
| null;
if (!response.ok || data?.ok === false || !data) {
const errorMessage = data?.error ?? `Profile import failed (${response.status})`;
host.nostrProfileFormState = {
...state,
importing: false,
error: errorMessage,
success: null,
};
return;
}
const merged = data.merged ?? data.imported ?? null;
const nextValues = merged ? { ...state.values, ...merged } : state.values;
const showAdvanced = Boolean(
nextValues.banner || nextValues.website || nextValues.nip05 || nextValues.lud16,
);
host.nostrProfileFormState = {
...state,
importing: false,
values: nextValues,
error: null,
success: data.saved
? "Profile imported from relays. Review and publish."
: "Profile imported. Review and publish.",
showAdvanced,
};
if (data.saved) {
await loadChannels(host, true);
}
} catch (err) {
host.nostrProfileFormState = {
...state,
importing: false,
error: `Profile import failed: ${String(err)}`,
success: null,
};
}
}

View File

@@ -222,6 +222,8 @@ export function renderApp(state: AppViewState) {
configUiHints: state.configUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
@@ -229,6 +231,14 @@ export function renderApp(state: AppViewState) {
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
})
: nothing}

View File

@@ -12,6 +12,7 @@ import type {
HealthSnapshot,
LogEntry,
LogLevel,
NostrProfile,
PresenceEntry,
SessionsListResult,
SkillStatusReport,
@@ -26,6 +27,7 @@ import type {
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
export type AppViewState = {
settings: UiSettings;
@@ -85,6 +87,8 @@ export type AppViewState = {
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
whatsappBusy: boolean;
nostrProfileFormState: NostrProfileFormState | null;
nostrProfileAccountId: string | null;
configFormDirty: boolean;
presenceLoading: boolean;
presenceEntries: PresenceEntry[];
@@ -141,6 +145,12 @@ export type AppViewState = {
handleWhatsAppLogout: () => Promise<void>;
handleChannelConfigSave: () => Promise<void>;
handleChannelConfigReload: () => Promise<void>;
handleNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void;
handleNostrProfileCancel: () => void;
handleNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void;
handleNostrProfileSave: () => Promise<void>;
handleNostrProfileImport: () => Promise<void>;
handleNostrProfileToggleAdvanced: () => void;
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
handleConfigLoad: () => Promise<void>;
handleConfigSave: () => Promise<void>;

View File

@@ -20,6 +20,7 @@ import type {
SessionsListResult,
SkillStatusReport,
StatusSummary,
NostrProfile,
} from "./types";
import { type ChatQueueItem, type CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
@@ -64,10 +65,17 @@ import {
import {
handleChannelConfigReload as handleChannelConfigReloadInternal,
handleChannelConfigSave as handleChannelConfigSaveInternal,
handleNostrProfileCancel as handleNostrProfileCancelInternal,
handleNostrProfileEdit as handleNostrProfileEditInternal,
handleNostrProfileFieldChange as handleNostrProfileFieldChangeInternal,
handleNostrProfileImport as handleNostrProfileImportInternal,
handleNostrProfileSave as handleNostrProfileSaveInternal,
handleNostrProfileToggleAdvanced as handleNostrProfileToggleAdvancedInternal,
handleWhatsAppLogout as handleWhatsAppLogoutInternal,
handleWhatsAppStart as handleWhatsAppStartInternal,
handleWhatsAppWait as handleWhatsAppWaitInternal,
} from "./app-channels";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
declare global {
interface Window {
@@ -153,6 +161,8 @@ export class ClawdbotApp extends LitElement {
@state() whatsappLoginQrDataUrl: string | null = null;
@state() whatsappLoginConnected: boolean | null = null;
@state() whatsappBusy = false;
@state() nostrProfileFormState: NostrProfileFormState | null = null;
@state() nostrProfileAccountId: string | null = null;
@state() presenceLoading = false;
@state() presenceEntries: PresenceEntry[] = [];
@@ -372,6 +382,30 @@ export class ClawdbotApp extends LitElement {
await handleChannelConfigReloadInternal(this);
}
handleNostrProfileEdit(accountId: string, profile: NostrProfile | null) {
handleNostrProfileEditInternal(this, accountId, profile);
}
handleNostrProfileCancel() {
handleNostrProfileCancelInternal(this);
}
handleNostrProfileFieldChange(field: keyof NostrProfile, value: string) {
handleNostrProfileFieldChangeInternal(this, field, value);
}
async handleNostrProfileSave() {
await handleNostrProfileSaveInternal(this);
}
async handleNostrProfileImport() {
await handleNostrProfileImportInternal(this);
}
handleNostrProfileToggleAdvanced() {
handleNostrProfileToggleAdvancedInternal(this);
}
async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") {
const active = this.execApprovalQueue[0];
if (!active || !this.client || this.execApprovalBusy) return;

View File

@@ -200,6 +200,27 @@ export type IMessageStatus = {
lastProbeAt?: number | null;
};
export type NostrProfile = {
name?: string | null;
displayName?: string | null;
about?: string | null;
picture?: string | null;
banner?: string | null;
website?: string | null;
nip05?: string | null;
lud16?: string | null;
};
export type NostrStatus = {
configured: boolean;
publicKey?: string | null;
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
profile?: NostrProfile | null;
};
export type MSTeamsProbe = {
ok: boolean;
error?: string | null;
@@ -254,7 +275,6 @@ export type ConfigSchemaResponse = {
};
export type PresenceEntry = {
deviceId?: string | null;
instanceId?: string | null;
host?: string | null;
ip?: string | null;
@@ -265,8 +285,6 @@ export type PresenceEntry = {
mode?: string | null;
lastInputSeconds?: number | null;
reason?: string | null;
roles?: string[] | null;
scopes?: string[] | null;
text?: string | null;
ts?: number | null;
};
@@ -339,8 +357,15 @@ export type CronPayload =
thinking?: string;
timeoutSeconds?: number;
deliver?: boolean;
channel?: string;
provider?: string;
provider?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string;
bestEffortDeliver?: boolean;
};

View File

@@ -0,0 +1,312 @@
/**
* Nostr Profile Edit Form
*
* Provides UI for editing and publishing Nostr profile (kind:0).
*/
import { html, nothing, type TemplateResult } from "lit";
import type { NostrProfile as NostrProfileType } from "../types";
// ============================================================================
// Types
// ============================================================================
export interface NostrProfileFormState {
/** Current form values */
values: NostrProfileType;
/** Original values for dirty detection */
original: NostrProfileType;
/** Whether the form is currently submitting */
saving: boolean;
/** Whether import is in progress */
importing: boolean;
/** Last error message */
error: string | null;
/** Last success message */
success: string | null;
/** Validation errors per field */
fieldErrors: Record<string, string>;
/** Whether to show advanced fields */
showAdvanced: boolean;
}
export interface NostrProfileFormCallbacks {
/** Called when a field value changes */
onFieldChange: (field: keyof NostrProfileType, value: string) => void;
/** Called when save is clicked */
onSave: () => void;
/** Called when import is clicked */
onImport: () => void;
/** Called when cancel is clicked */
onCancel: () => void;
/** Called when toggle advanced is clicked */
onToggleAdvanced: () => void;
}
// ============================================================================
// Helpers
// ============================================================================
function isFormDirty(state: NostrProfileFormState): boolean {
const { values, original } = state;
return (
values.name !== original.name ||
values.displayName !== original.displayName ||
values.about !== original.about ||
values.picture !== original.picture ||
values.banner !== original.banner ||
values.website !== original.website ||
values.nip05 !== original.nip05 ||
values.lud16 !== original.lud16
);
}
// ============================================================================
// Form Rendering
// ============================================================================
export function renderNostrProfileForm(params: {
state: NostrProfileFormState;
callbacks: NostrProfileFormCallbacks;
accountId: string;
}): TemplateResult {
const { state, callbacks, accountId } = params;
const isDirty = isFormDirty(state);
const renderField = (
field: keyof NostrProfileType,
label: string,
opts: {
type?: "text" | "url" | "textarea";
placeholder?: string;
maxLength?: number;
help?: string;
} = {}
) => {
const { type = "text", placeholder, maxLength, help } = opts;
const value = state.values[field] ?? "";
const error = state.fieldErrors[field];
const inputId = `nostr-profile-${field}`;
if (type === "textarea") {
return html`
<div class="form-field" style="margin-bottom: 12px;">
<label for="${inputId}" style="display: block; margin-bottom: 4px; font-weight: 500;">
${label}
</label>
<textarea
id="${inputId}"
.value=${value}
placeholder=${placeholder ?? ""}
maxlength=${maxLength ?? 2000}
rows="3"
style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; resize: vertical; font-family: inherit;"
@input=${(e: InputEvent) => {
const target = e.target as HTMLTextAreaElement;
callbacks.onFieldChange(field, target.value);
}}
?disabled=${state.saving}
></textarea>
${help ? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">${help}</div>` : nothing}
${error ? html`<div style="font-size: 12px; color: var(--danger-color); margin-top: 2px;">${error}</div>` : nothing}
</div>
`;
}
return html`
<div class="form-field" style="margin-bottom: 12px;">
<label for="${inputId}" style="display: block; margin-bottom: 4px; font-weight: 500;">
${label}
</label>
<input
id="${inputId}"
type=${type}
.value=${value}
placeholder=${placeholder ?? ""}
maxlength=${maxLength ?? 256}
style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px;"
@input=${(e: InputEvent) => {
const target = e.target as HTMLInputElement;
callbacks.onFieldChange(field, target.value);
}}
?disabled=${state.saving}
/>
${help ? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">${help}</div>` : nothing}
${error ? html`<div style="font-size: 12px; color: var(--danger-color); margin-top: 2px;">${error}</div>` : nothing}
</div>
`;
};
const renderPicturePreview = () => {
const picture = state.values.picture;
if (!picture) return nothing;
return html`
<div style="margin-bottom: 12px;">
<img
src=${picture}
alt="Profile picture preview"
style="max-width: 80px; max-height: 80px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
@error=${(e: Event) => {
const img = e.target as HTMLImageElement;
img.style.display = "none";
}}
@load=${(e: Event) => {
const img = e.target as HTMLImageElement;
img.style.display = "block";
}}
/>
</div>
`;
};
return html`
<div class="nostr-profile-form" style="padding: 16px; background: var(--bg-secondary); border-radius: 8px; margin-top: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<div style="font-weight: 600; font-size: 16px;">Edit Profile</div>
<div style="font-size: 12px; color: var(--text-muted);">Account: ${accountId}</div>
</div>
${state.error
? html`<div class="callout danger" style="margin-bottom: 12px;">${state.error}</div>`
: nothing}
${state.success
? html`<div class="callout success" style="margin-bottom: 12px;">${state.success}</div>`
: nothing}
${renderPicturePreview()}
${renderField("name", "Username", {
placeholder: "satoshi",
maxLength: 256,
help: "Short username (e.g., satoshi)",
})}
${renderField("displayName", "Display Name", {
placeholder: "Satoshi Nakamoto",
maxLength: 256,
help: "Your full display name",
})}
${renderField("about", "Bio", {
type: "textarea",
placeholder: "Tell people about yourself...",
maxLength: 2000,
help: "A brief bio or description",
})}
${renderField("picture", "Avatar URL", {
type: "url",
placeholder: "https://example.com/avatar.jpg",
help: "HTTPS URL to your profile picture",
})}
${state.showAdvanced
? html`
<div style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;">
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">Advanced</div>
${renderField("banner", "Banner URL", {
type: "url",
placeholder: "https://example.com/banner.jpg",
help: "HTTPS URL to a banner image",
})}
${renderField("website", "Website", {
type: "url",
placeholder: "https://example.com",
help: "Your personal website",
})}
${renderField("nip05", "NIP-05 Identifier", {
placeholder: "you@example.com",
help: "Verifiable identifier (e.g., you@domain.com)",
})}
${renderField("lud16", "Lightning Address", {
placeholder: "you@getalby.com",
help: "Lightning address for tips (LUD-16)",
})}
</div>
`
: nothing}
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
<button
class="btn primary"
@click=${callbacks.onSave}
?disabled=${state.saving || !isDirty}
>
${state.saving ? "Saving..." : "Save & Publish"}
</button>
<button
class="btn"
@click=${callbacks.onImport}
?disabled=${state.importing || state.saving}
>
${state.importing ? "Importing..." : "Import from Relays"}
</button>
<button
class="btn"
@click=${callbacks.onToggleAdvanced}
>
${state.showAdvanced ? "Hide Advanced" : "Show Advanced"}
</button>
<button
class="btn"
@click=${callbacks.onCancel}
?disabled=${state.saving}
>
Cancel
</button>
</div>
${isDirty
? html`<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px;">
You have unsaved changes
</div>`
: nothing}
</div>
`;
}
// ============================================================================
// Factory
// ============================================================================
/**
* Create initial form state from existing profile
*/
export function createNostrProfileFormState(
profile: NostrProfileType | undefined
): NostrProfileFormState {
const values: NostrProfileType = {
name: profile?.name ?? "",
displayName: profile?.displayName ?? "",
about: profile?.about ?? "",
picture: profile?.picture ?? "",
banner: profile?.banner ?? "",
website: profile?.website ?? "",
nip05: profile?.nip05 ?? "",
lud16: profile?.lud16 ?? "",
};
return {
values,
original: { ...values },
saving: false,
importing: false,
error: null,
success: null,
fieldErrors: {},
showAdvanced: Boolean(
profile?.banner || profile?.website || profile?.nip05 || profile?.lud16
),
};
}

View File

@@ -0,0 +1,217 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, NostrStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
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>
`;
}

View File

@@ -7,6 +7,8 @@ import type {
ChannelsStatusSnapshot,
DiscordStatus,
IMessageStatus,
NostrProfile,
NostrStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
@@ -21,6 +23,7 @@ import { channelEnabled, renderChannelAccountCount } from "./channels.shared";
import { renderChannelConfigSection } from "./channels.config";
import { renderDiscordCard } from "./channels.discord";
import { renderIMessageCard } from "./channels.imessage";
import { renderNostrCard } from "./channels.nostr";
import { renderSignalCard } from "./channels.signal";
import { renderSlackCard } from "./channels.slack";
import { renderTelegramCard } from "./channels.telegram";
@@ -38,6 +41,7 @@ export function renderChannels(props: ChannelsProps) {
const slack = (channels?.slack ?? null) as SlackStatus | null;
const signal = (channels?.signal ?? null) as SignalStatus | null;
const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
const nostr = (channels?.nostr ?? null) as NostrStatus | null;
const channelOrder = resolveChannelOrder(props.snapshot);
const orderedChannels = channelOrder
.map((key, index) => ({
@@ -60,6 +64,7 @@ export function renderChannels(props: ChannelsProps) {
slack,
signal,
imessage,
nostr,
channelAccounts: props.snapshot?.channelAccounts ?? null,
}),
)}
@@ -92,7 +97,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe
if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder;
}
return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"];
return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "nostr"];
}
function renderChannel(
@@ -142,6 +147,33 @@ function renderChannel(
imessage: data.imessage,
accountCountLabel,
});
case "nostr": {
const nostrAccounts = data.channelAccounts?.nostr ?? [];
const primaryAccount = nostrAccounts[0];
const accountId = primaryAccount?.accountId ?? "default";
const profile =
(primaryAccount as { profile?: NostrProfile | null } | undefined)?.profile ?? null;
const showForm =
props.nostrProfileAccountId === accountId ? props.nostrProfileFormState : null;
const profileFormCallbacks = showForm
? {
onFieldChange: props.onNostrProfileFieldChange,
onSave: props.onNostrProfileSave,
onImport: props.onNostrProfileImport,
onCancel: props.onNostrProfileCancel,
onToggleAdvanced: props.onNostrProfileToggleAdvanced,
}
: null;
return renderNostrCard({
props,
nostr: data.nostr,
nostrAccounts,
accountCountLabel,
profileFormState: showForm,
profileFormCallbacks,
onEditProfile: () => props.onNostrProfileEdit(accountId, profile),
});
}
default:
return renderGenericChannelCard(key, props, data.channelAccounts ?? {});
}

View File

@@ -4,11 +4,14 @@ import type {
ConfigUiHints,
DiscordStatus,
IMessageStatus,
NostrProfile,
NostrStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type { NostrProfileFormState } from "./channels.nostr-profile-form";
export type ChannelKey = string;
@@ -28,6 +31,8 @@ export type ChannelsProps = {
configUiHints: ConfigUiHints;
configSaving: boolean;
configFormDirty: boolean;
nostrProfileFormState: NostrProfileFormState | null;
nostrProfileAccountId: string | null;
onRefresh: (probe: boolean) => void;
onWhatsAppStart: (force: boolean) => void;
onWhatsAppWait: () => void;
@@ -35,6 +40,12 @@ export type ChannelsProps = {
onConfigPatch: (path: Array<string | number>, value: unknown) => void;
onConfigSave: () => void;
onConfigReload: () => void;
onNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void;
onNostrProfileCancel: () => void;
onNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void;
onNostrProfileSave: () => void;
onNostrProfileImport: () => void;
onNostrProfileToggleAdvanced: () => void;
};
export type ChannelsChannelData = {
@@ -44,5 +55,6 @@ export type ChannelsChannelData = {
slack?: SlackStatus | null;
signal?: SignalStatus | null;
imessage?: IMessageStatus | null;
nostr?: NostrStatus | null;
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null;
};