diff --git a/apps/example/microblog-ui/app/Post/page.tsx b/apps/example/microblog-ui/app/Post/page.tsx new file mode 100644 index 0000000..91aa12d --- /dev/null +++ b/apps/example/microblog-ui/app/Post/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from 'react'; + +import MicroblogPostRouteClient from '../../src/components/MicroblogPostRouteClient'; + +export default function PostPage() { + return ( + + + + ); +} diff --git a/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx b/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx new file mode 100644 index 0000000..3676518 --- /dev/null +++ b/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx @@ -0,0 +1,363 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +import ImageFieldInput from './ImageFieldInput'; +import TxStatus, { type TxPhase } from './TxStatus'; +import { fnCreate } from '../lib/app'; +import { chainWithRpcOverride, requestWalletAddress } from '../lib/clients'; +import { getReadRpcUrl } from '../lib/manifest'; +import { submitWriteTx } from '../lib/tx'; +import { listOwnedProfiles, loadMicroblogRuntime, profileHandle, profileLabel, type ProfileRecord } from '../lib/microblog'; + +type ComposeState = { + loading: boolean; + runtimeError: string | null; + connectError: string | null; + submitError: string | null; +}; + +const PROFILE_STORAGE_PREFIX = 'TH_MICROBLOG_PROFILE_ID:'; + +export default function MicroblogComposeClient() { + const router = useRouter(); + const [state, setState] = useState({ + loading: true, + runtimeError: null, + connectError: null, + submitError: null + }); + const [runtime, setRuntime] = useState(null); + const [account, setAccount] = useState(null); + const [profiles, setProfiles] = useState([]); + const [selectedProfileId, setSelectedProfileId] = useState(''); + const [body, setBody] = useState(''); + const [image, setImage] = useState(''); + const [imageUploadBusy, setImageUploadBusy] = useState(false); + const [txStatus, setTxStatus] = useState(null); + const [txPhase, setTxPhase] = useState('idle'); + const [txHash, setTxHash] = useState(null); + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const loadedRuntime = await loadMicroblogRuntime(); + if (cancelled) return; + setRuntime(loadedRuntime); + + try { + const cached = localStorage.getItem('TH_ACCOUNT'); + if (cached && !cancelled) setAccount(cached); + } catch { + // ignore + } + } catch (error: any) { + if (cancelled) return; + setState((prev) => ({ ...prev, runtimeError: String(error?.message ?? error), loading: false })); + return; + } + + if (!cancelled) setState((prev) => ({ ...prev, loading: false })); + })(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; + if (!runtime || !account) { + setProfiles([]); + setSelectedProfileId(''); + return; + } + + setState((prev) => ({ ...prev, loading: true, connectError: null })); + void (async () => { + try { + const ownedProfiles = await listOwnedProfiles(runtime, account); + if (cancelled) return; + setProfiles(ownedProfiles); + + let preferred = ''; + try { + const stored = localStorage.getItem(`${PROFILE_STORAGE_PREFIX}${account.toLowerCase()}`) ?? ''; + if (stored && ownedProfiles.some((entry) => String(entry.id) === stored)) preferred = stored; + } catch { + // ignore + } + if (!preferred && ownedProfiles[0]) preferred = String(ownedProfiles[0].id); + setSelectedProfileId(preferred); + } catch (error: any) { + if (cancelled) return; + setState((prev) => ({ ...prev, connectError: String(error?.message ?? error) })); + } finally { + if (!cancelled) setState((prev) => ({ ...prev, loading: false })); + } + })(); + + return () => { + cancelled = true; + }; + }, [runtime, account]); + + useEffect(() => { + if (!account || !selectedProfileId) return; + try { + localStorage.setItem(`${PROFILE_STORAGE_PREFIX}${account.toLowerCase()}`, selectedProfileId); + } catch { + // ignore + } + }, [account, selectedProfileId]); + + const selectedProfile = useMemo( + () => profiles.find((entry) => String(entry.id) === selectedProfileId) ?? null, + [profiles, selectedProfileId] + ); + const walletChain = useMemo( + () => (runtime ? chainWithRpcOverride(runtime.chain, getReadRpcUrl(runtime.manifest) || undefined) : null), + [runtime] + ); + + async function connectWallet() { + if (!walletChain) return; + setState((prev) => ({ ...prev, connectError: null })); + try { + const nextAccount = await requestWalletAddress(walletChain); + setAccount(nextAccount); + try { + localStorage.setItem('TH_ACCOUNT', nextAccount); + } catch { + // ignore + } + } catch (error: any) { + setState((prev) => ({ ...prev, connectError: String(error?.message ?? error) })); + } + } + + async function submit() { + if (!runtime || !walletChain || !selectedProfile || !body.trim() || imageUploadBusy) return; + + setState((prev) => ({ ...prev, submitError: null })); + setTxStatus(null); + setTxPhase('idle'); + setTxHash(null); + + try { + const result = await submitWriteTx({ + manifest: runtime.manifest, + deployment: runtime.deployment, + chain: walletChain, + publicClient: runtime.publicClient, + address: runtime.appAddress, + abi: runtime.abi, + functionName: fnCreate('Post'), + contractArgs: [ + { + authorProfile: selectedProfile.id, + body: body.trim(), + image: image.trim() + } + ], + setStatus: setTxStatus, + onPhase: setTxPhase, + onHash: setTxHash + }); + + setTxStatus(`Posted (${result.hash.slice(0, 10)}…).`); + router.push('/'); + router.refresh(); + } catch (error: any) { + setState((prev) => ({ ...prev, submitError: String(error?.message ?? error) })); + setTxStatus(null); + setTxPhase('failed'); + } + } + + if (state.loading && !runtime) { + return ( +
+

Loading composer…

+

Resolving the active deployment and wallet state.

+
+ ); + } + + if (state.runtimeError) { + return ( +
+
/compose/error
+

Unable to load composer

+

{state.runtimeError}

+
+ ); + } + + return ( +
+
+
+
+
+ /post/compose +
+ normalized author identity + profile-linked posts +
+
+

+ Compose as a profile +
+ not as a copied handle string. +

+

+ Posts now store authorProfile as an on-chain reference to Profile, + so handle and avatar changes flow through existing posts automatically. +

+
+ Back to feed + Browse profiles +
+
+ +
+
/identity
+
+
+
{account ? 1 : 0}
+
Wallet linked
+
+
+
{profiles.length}
+
Owned profiles
+
+
+
+ posts reference profiles + profile changes propagate +
+
+
+
+ + {!account ? ( +
+
/wallet
+

Connect a wallet to compose

+

Posting now requires selecting one of your on-chain profiles. Connect the wallet that owns the profile first.

+
+ +
+ {state.connectError ?

{state.connectError}

: null} +
+ ) : null} + + {account && !profiles.length ? ( +
+
/profiles/empty
+

No owned profiles found

+

Create a profile first. Once it exists on-chain under this wallet, you can compose posts as that profile.

+
+ Create profile +
+ {state.connectError ?

{state.connectError}

: null} +
+ ) : null} + + {account && profiles.length ? ( +
+
+

Compose Post

+

Choose the on-chain profile identity for this post, then write the post body and optional image.

+
+ +
+
+ + +
+ +
+ +
+ {selectedProfile ? ( +
+
+ profile #{String(selectedProfile.id)} + {profileHandle(selectedProfile.record) ? @{profileHandle(selectedProfile.record)} : null} +
+ {profileLabel(selectedProfile.record)} + {String(selectedProfile.record?.bio ?? '').trim() ? ( +

{String(selectedProfile.record.bio)}

+ ) : null} +
+ ) : ( + Select a profile. + )} +
+
+ +
+ +