Refactor: split overgrown files into focused modules

Split client.ts (1036 lines) into 11 domain modules under lib/nostr/ —
core, notes, social, articles, engagement, dms, bookmarks, muting,
search, relays, trending. Barrel index.ts re-exports all; zero consumer
import changes.

Extract ProfileView sub-components (ImageField, Nip05Field,
EditProfileForm, ProfileMediaGallery), NoteContent renderers
(TextSegments, MediaCards), and NoteCard actions (NoteActions,
InlineReplyBox). All component files now ≤270 lines, all lib files ≤300.
This commit is contained in:
Jure
2026-03-20 16:32:50 +01:00
parent 1c3e58cdb0
commit 80838fb204
24 changed files with 1973 additions and 1989 deletions
@@ -0,0 +1,96 @@
import { useState } from "react";
import { useUserStore } from "../../stores/user";
import { invalidateProfileCache } from "../../hooks/useProfile";
import { publishProfile } from "../../lib/nostr";
import { ImageField } from "./ImageField";
import { Nip05Field } from "./Nip05Field";
export function EditProfileForm({ pubkey, onSaved }: { pubkey: string; onSaved: () => void }) {
const { profile, fetchOwnProfile } = useUserStore();
const [name, setName] = useState(profile?.name || "");
const [displayName, setDisplayName] = useState(profile?.displayName || "");
const [about, setAbout] = useState(profile?.about || "");
const [picture, setPicture] = useState(profile?.picture || "");
const [banner, setBanner] = useState(profile?.banner || "");
const [website, setWebsite] = useState(profile?.website || "");
const [nip05, setNip05] = useState(profile?.nip05 || "");
const [lud16, setLud16] = useState(profile?.lud16 || "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
invalidateProfileCache(pubkey);
await publishProfile({
name: name.trim() || undefined,
display_name: displayName.trim() || undefined,
about: about.trim() || undefined,
picture: picture.trim() || undefined,
banner: banner.trim() || undefined,
website: website.trim() || undefined,
nip05: nip05.trim() || undefined,
lud16: lud16.trim() || undefined,
});
await fetchOwnProfile();
setSaved(true);
setTimeout(onSaved, 1000);
} catch (err) {
setError(`Failed to save: ${err}`);
} finally {
setSaving(false);
}
};
const field = (label: string, value: string, onChange: (v: string) => void, placeholder = "") => (
<div>
<label className="text-text-dim text-[10px] block mb-1">{label}</label>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
/>
</div>
);
return (
<div className="px-4 py-4 border-b border-border">
<div className="grid grid-cols-2 gap-3 mb-3">
{field("Display name", displayName, setDisplayName, "Square that Circle")}
{field("Username", name, setName, "squarethecircle")}
<Nip05Field value={nip05} onChange={setNip05} pubkey={pubkey} />
{field("Lightning address (lud16)", lud16, setLud16, "you@walletofsatoshi.com")}
{field("Website", website, setWebsite, "https://…")}
<ImageField label="Profile picture" value={picture} onChange={setPicture} />
</div>
<div className="mb-3">
<label className="text-text-dim text-[10px] block mb-1">Bio</label>
<textarea
value={about}
onChange={(e) => setAbout(e.target.value)}
placeholder="Tell people about yourself…"
rows={3}
className="w-full bg-bg border border-border px-3 py-1.5 text-text text-[12px] resize-none focus:outline-none focus:border-accent/50"
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
/>
</div>
<div className="mb-3">
<ImageField label="Banner image" value={banner} onChange={setBanner} />
</div>
{error && <p className="text-danger text-[11px] mb-2">{error}</p>}
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={saving || saved}
className="px-4 py-1.5 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{saved ? "saved ✓" : saving ? "saving…" : "save profile"}
</button>
</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useState, useRef } from "react";
import { uploadImage } from "../../lib/upload";
export function ImageField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setUploadError(null);
try {
const url = await uploadImage(file);
onChange(url);
} catch (err) {
setUploadError(String(err));
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
return (
<div>
<label className="text-text-dim text-[10px] block mb-1">{label}</label>
<div className="flex gap-1.5">
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="https://… or click upload →"
className="flex-1 bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="px-2 py-1.5 text-[10px] border border-border text-text-dim hover:text-accent hover:border-accent/40 transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
title="Upload from your computer"
>
{uploading ? "uploading…" : "upload"}
</button>
</div>
{uploadError && <p className="text-danger text-[10px] mt-1">{uploadError}</p>}
<input ref={fileRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
type Nip05Status = "idle" | "checking" | "valid" | "mismatch" | "notfound";
export function Nip05Field({ value, onChange, pubkey }: { value: string; onChange: (v: string) => void; pubkey: string }) {
const [status, setStatus] = useState<Nip05Status>("idle");
useEffect(() => {
if (!value.includes("@")) { setStatus("idle"); return; }
setStatus("checking");
const t = setTimeout(async () => {
const [name, domain] = value.trim().split("@");
if (!name || !domain) { setStatus("notfound"); return; }
try {
const resp = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`);
const data = await resp.json();
const resolved = data.names?.[name];
if (!resolved) setStatus("notfound");
else if (resolved === pubkey) setStatus("valid");
else setStatus("mismatch");
} catch {
setStatus("notfound");
}
}, 900);
return () => clearTimeout(t);
}, [value, pubkey]);
const badge = {
idle: null,
checking: <span className="text-text-dim text-[10px]">checking</span>,
valid: <span className="text-success text-[10px]"> verified</span>,
mismatch: <span className="text-danger text-[10px]"> pubkey mismatch</span>,
notfound: <span className="text-danger text-[10px]"> not found</span>,
}[status];
return (
<div>
<div className="flex items-baseline gap-2 mb-1">
<label className="text-text-dim text-[10px]">NIP-05 verified name</label>
{badge}
</div>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="you@domain.com"
className="w-full bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
/>
<p className="text-text-dim text-[10px] mt-1">
Proves your identity via a domain you control.{" "}
<a
href="https://nostr.how/en/guides/get-verified"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-accent-hover transition-colors"
>
How to get verified
</a>
</p>
</div>
);
}
@@ -0,0 +1,130 @@
import { useState } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useUIStore } from "../../stores/ui";
import { parseContent } from "../../lib/parsing";
import { ImageLightbox } from "../shared/ImageLightbox";
const MEDIA_SEGMENT_TYPES = new Set(["image", "video", "audio", "youtube", "vimeo"]);
interface MediaItem {
type: "image" | "video" | "audio";
url: string;
thumbnailId?: string;
noteId: string;
}
function extractMediaItems(notes: NDKEvent[]): MediaItem[] {
const items: MediaItem[] = [];
const seen = new Set<string>();
for (const note of notes) {
const segments = parseContent(note.content);
for (const seg of segments) {
if (!MEDIA_SEGMENT_TYPES.has(seg.type)) continue;
if (seen.has(seg.value)) continue;
seen.add(seg.value);
if (seg.type === "image") {
items.push({ type: "image", url: seg.value, noteId: note.id! });
} else if (seg.type === "video" || seg.type === "youtube" || seg.type === "vimeo") {
items.push({ type: "video", url: seg.value, thumbnailId: seg.mediaId, noteId: note.id! });
} else if (seg.type === "audio") {
items.push({ type: "audio", url: seg.value, noteId: note.id! });
}
}
}
return items;
}
export function ProfileMediaGallery({ notes, loading }: { notes: NDKEvent[]; loading: boolean }) {
const { openThread } = useUIStore();
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null);
if (loading) {
return <div className="px-4 py-8 text-text-dim text-[12px] text-center">Loading media</div>;
}
const items = extractMediaItems(notes);
const imageUrls = items.filter((i) => i.type === "image").map((i) => i.url);
if (items.length === 0) {
return <div className="px-4 py-8 text-text-dim text-[12px] text-center">No media found.</div>;
}
const openNote = (noteId: string) => {
const note = notes.find((n) => n.id === noteId);
if (note) openThread(note, "profile");
};
let imageIndex = 0;
return (
<>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-1 p-2">
{items.map((item, idx) => {
if (item.type === "image") {
const currentImageIdx = imageIndex++;
return (
<div
key={idx}
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setLightboxIdx(currentImageIdx)}
>
<img
src={item.url}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
);
}
if (item.type === "video") {
return (
<div
key={idx}
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity relative flex items-center justify-center"
onClick={() => openNote(item.noteId)}
>
{item.thumbnailId ? (
<img
src={`https://img.youtube.com/vi/${item.thumbnailId}/mqdefault.jpg`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full bg-bg-raised" />
)}
<div className="absolute inset-0 flex items-center justify-center">
<span className="w-10 h-10 rounded-full bg-black/60 flex items-center justify-center text-white text-lg"></span>
</div>
</div>
);
}
// audio
return (
<div
key={idx}
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity flex items-center justify-center"
onClick={() => openNote(item.noteId)}
>
<div className="text-center">
<span className="text-3xl text-text-dim"></span>
<p className="text-text-dim text-[10px] mt-1 px-2 truncate">{item.url.split("/").pop()}</p>
</div>
</div>
);
})}
</div>
{lightboxIdx !== null && (
<ImageLightbox
images={imageUrls}
index={lightboxIdx}
onClose={() => setLightboxIdx(null)}
onNavigate={(i) => setLightboxIdx(i)}
/>
)}
</>
);
}
+5 -333
View File
@@ -1,218 +1,17 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useUIStore } from "../../stores/ui";
import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { useProfile, invalidateProfileCache } from "../../hooks/useProfile";
import { fetchUserNotesNIP65, fetchAuthorArticles, publishProfile, getNDK } from "../../lib/nostr";
import { parseContent } from "../../lib/parsing";
import { useProfile } from "../../hooks/useProfile";
import { fetchUserNotesNIP65, fetchAuthorArticles, getNDK } from "../../lib/nostr";
import { shortenPubkey } from "../../lib/utils";
import { uploadImage } from "../../lib/upload";
import { NoteCard } from "../feed/NoteCard";
import { ArticleCard } from "../article/ArticleCard";
import { ZapModal } from "../zap/ZapModal";
import { ImageLightbox } from "../shared/ImageLightbox";
// ── Profile helper sub-components ────────────────────────────────────────────
function ImageField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setUploadError(null);
try {
const url = await uploadImage(file);
onChange(url);
} catch (err) {
setUploadError(String(err));
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
return (
<div>
<label className="text-text-dim text-[10px] block mb-1">{label}</label>
<div className="flex gap-1.5">
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="https://… or click upload →"
className="flex-1 bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="px-2 py-1.5 text-[10px] border border-border text-text-dim hover:text-accent hover:border-accent/40 transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
title="Upload from your computer"
>
{uploading ? "uploading…" : "upload"}
</button>
</div>
{uploadError && <p className="text-danger text-[10px] mt-1">{uploadError}</p>}
<input ref={fileRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
</div>
);
}
type Nip05Status = "idle" | "checking" | "valid" | "mismatch" | "notfound";
function Nip05Field({ value, onChange, pubkey }: { value: string; onChange: (v: string) => void; pubkey: string }) {
const [status, setStatus] = useState<Nip05Status>("idle");
useEffect(() => {
if (!value.includes("@")) { setStatus("idle"); return; }
setStatus("checking");
const t = setTimeout(async () => {
const [name, domain] = value.trim().split("@");
if (!name || !domain) { setStatus("notfound"); return; }
try {
const resp = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`);
const data = await resp.json();
const resolved = data.names?.[name];
if (!resolved) setStatus("notfound");
else if (resolved === pubkey) setStatus("valid");
else setStatus("mismatch");
} catch {
setStatus("notfound");
}
}, 900);
return () => clearTimeout(t);
}, [value, pubkey]);
const badge = {
idle: null,
checking: <span className="text-text-dim text-[10px]">checking</span>,
valid: <span className="text-success text-[10px]"> verified</span>,
mismatch: <span className="text-danger text-[10px]"> pubkey mismatch</span>,
notfound: <span className="text-danger text-[10px]"> not found</span>,
}[status];
return (
<div>
<div className="flex items-baseline gap-2 mb-1">
<label className="text-text-dim text-[10px]">NIP-05 verified name</label>
{badge}
</div>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="you@domain.com"
className="w-full bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
/>
<p className="text-text-dim text-[10px] mt-1">
Proves your identity via a domain you control.{" "}
<a
href="https://nostr.how/en/guides/get-verified"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-accent-hover transition-colors"
>
How to get verified
</a>
</p>
</div>
);
}
function EditProfileForm({ pubkey, onSaved }: { pubkey: string; onSaved: () => void }) {
const { profile, fetchOwnProfile } = useUserStore();
const [name, setName] = useState(profile?.name || "");
const [displayName, setDisplayName] = useState(profile?.displayName || "");
const [about, setAbout] = useState(profile?.about || "");
const [picture, setPicture] = useState(profile?.picture || "");
const [banner, setBanner] = useState(profile?.banner || "");
const [website, setWebsite] = useState(profile?.website || "");
const [nip05, setNip05] = useState(profile?.nip05 || "");
const [lud16, setLud16] = useState(profile?.lud16 || "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
invalidateProfileCache(pubkey);
await publishProfile({
name: name.trim() || undefined,
display_name: displayName.trim() || undefined,
about: about.trim() || undefined,
picture: picture.trim() || undefined,
banner: banner.trim() || undefined,
website: website.trim() || undefined,
nip05: nip05.trim() || undefined,
lud16: lud16.trim() || undefined,
});
await fetchOwnProfile();
setSaved(true);
setTimeout(onSaved, 1000);
} catch (err) {
setError(`Failed to save: ${err}`);
} finally {
setSaving(false);
}
};
const field = (label: string, value: string, onChange: (v: string) => void, placeholder = "") => (
<div>
<label className="text-text-dim text-[10px] block mb-1">{label}</label>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
/>
</div>
);
return (
<div className="px-4 py-4 border-b border-border">
<div className="grid grid-cols-2 gap-3 mb-3">
{field("Display name", displayName, setDisplayName, "Square that Circle")}
{field("Username", name, setName, "squarethecircle")}
<Nip05Field value={nip05} onChange={setNip05} pubkey={pubkey} />
{field("Lightning address (lud16)", lud16, setLud16, "you@walletofsatoshi.com")}
{field("Website", website, setWebsite, "https://…")}
<ImageField label="Profile picture" value={picture} onChange={setPicture} />
</div>
<div className="mb-3">
<label className="text-text-dim text-[10px] block mb-1">Bio</label>
<textarea
value={about}
onChange={(e) => setAbout(e.target.value)}
placeholder="Tell people about yourself…"
rows={3}
className="w-full bg-bg border border-border px-3 py-1.5 text-text text-[12px] resize-none focus:outline-none focus:border-accent/50"
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
/>
</div>
<div className="mb-3">
<ImageField label="Banner image" value={banner} onChange={setBanner} />
</div>
{error && <p className="text-danger text-[11px] mb-2">{error}</p>}
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={saving || saved}
className="px-4 py-1.5 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{saved ? "saved ✓" : saving ? "saving…" : "save profile"}
</button>
</div>
</div>
);
}
import { EditProfileForm } from "./EditProfileForm";
import { ProfileMediaGallery } from "./ProfileMediaGallery";
export function ProfileView() {
const { selectedPubkey, goBack, openDM } = useUIStore();
@@ -467,130 +266,3 @@ export function ProfileView() {
</div>
);
}
// ── Media gallery sub-component ──────────────────────────────────────────────
const MEDIA_SEGMENT_TYPES = new Set(["image", "video", "audio", "youtube", "vimeo"]);
interface MediaItem {
type: "image" | "video" | "audio";
url: string;
thumbnailId?: string;
noteId: string;
}
function extractMediaItems(notes: NDKEvent[]): MediaItem[] {
const items: MediaItem[] = [];
const seen = new Set<string>();
for (const note of notes) {
const segments = parseContent(note.content);
for (const seg of segments) {
if (!MEDIA_SEGMENT_TYPES.has(seg.type)) continue;
if (seen.has(seg.value)) continue;
seen.add(seg.value);
if (seg.type === "image") {
items.push({ type: "image", url: seg.value, noteId: note.id! });
} else if (seg.type === "video" || seg.type === "youtube" || seg.type === "vimeo") {
items.push({ type: "video", url: seg.value, thumbnailId: seg.mediaId, noteId: note.id! });
} else if (seg.type === "audio") {
items.push({ type: "audio", url: seg.value, noteId: note.id! });
}
}
}
return items;
}
function ProfileMediaGallery({ notes, loading }: { notes: NDKEvent[]; loading: boolean }) {
const { openThread } = useUIStore();
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null);
if (loading) {
return <div className="px-4 py-8 text-text-dim text-[12px] text-center">Loading media</div>;
}
const items = extractMediaItems(notes);
const imageUrls = items.filter((i) => i.type === "image").map((i) => i.url);
if (items.length === 0) {
return <div className="px-4 py-8 text-text-dim text-[12px] text-center">No media found.</div>;
}
const openNote = (noteId: string) => {
const note = notes.find((n) => n.id === noteId);
if (note) openThread(note, "profile");
};
let imageIndex = 0;
return (
<>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-1 p-2">
{items.map((item, idx) => {
if (item.type === "image") {
const currentImageIdx = imageIndex++;
return (
<div
key={idx}
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setLightboxIdx(currentImageIdx)}
>
<img
src={item.url}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
);
}
if (item.type === "video") {
return (
<div
key={idx}
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity relative flex items-center justify-center"
onClick={() => openNote(item.noteId)}
>
{item.thumbnailId ? (
<img
src={`https://img.youtube.com/vi/${item.thumbnailId}/mqdefault.jpg`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full bg-bg-raised" />
)}
<div className="absolute inset-0 flex items-center justify-center">
<span className="w-10 h-10 rounded-full bg-black/60 flex items-center justify-center text-white text-lg"></span>
</div>
</div>
);
}
// audio
return (
<div
key={idx}
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity flex items-center justify-center"
onClick={() => openNote(item.noteId)}
>
<div className="text-center">
<span className="text-3xl text-text-dim"></span>
<p className="text-text-dim text-[10px] mt-1 px-2 truncate">{item.url.split("/").pop()}</p>
</div>
</div>
);
})}
</div>
{lightboxIdx !== null && (
<ImageLightbox
images={imageUrls}
index={lightboxIdx}
onClose={() => setLightboxIdx(null)}
onNavigate={(i) => setLightboxIdx(i)}
/>
)}
</>
);
}