feat: add Nostr channel plugin and onboarding install defaults
Co-authored-by: joelklabo <joelklabo@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
312
ui/src/ui/views/channels.nostr-profile-form.ts
Normal file
312
ui/src/ui/views/channels.nostr-profile-form.ts
Normal 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
|
||||
),
|
||||
};
|
||||
}
|
||||
217
ui/src/ui/views/channels.nostr.ts
Normal file
217
ui/src/ui/views/channels.nostr.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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 ?? {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user