wip: new import account

This commit is contained in:
2023-10-15 16:10:16 +07:00
parent 620e763380
commit cd3b9ada5a
13 changed files with 430 additions and 418 deletions

View File

@@ -3,8 +3,7 @@ import { fetch } from '@tauri-apps/plugin-http';
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
import { ReactFlowProvider } from 'reactflow';
import { AuthCreateScreen } from '@app/auth/create';
import { AuthImportScreen } from '@app/auth/import';
import { CreateAccountScreen } from '@app/auth/create';
import { OnboardingScreen } from '@app/auth/onboarding';
import { ChatsScreen } from '@app/chats';
import { ErrorScreen } from '@app/error';
@@ -28,7 +27,7 @@ export default function App() {
const totalAccount = await db.checkAccount();
const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null;
const step = onboarding ? JSON.parse(onboarding).state.step : null;
// redirect to welcome screen if none user exist
if (totalAccount === 0) return redirect('/auth/welcome');
@@ -169,46 +168,16 @@ export default function App() {
},
},
{
path: 'import',
element: <AuthImportScreen />,
path: 'create',
element: <CreateAccountScreen />,
errorElement: <ErrorScreen />,
children: [
{
path: '',
async lazy() {
const { ImportStep1Screen } = await import('@app/auth/import/step-1');
return { Component: ImportStep1Screen };
},
},
{
path: 'step-2',
async lazy() {
const { ImportStep2Screen } = await import('@app/auth/import/step-2');
return { Component: ImportStep2Screen };
},
},
],
},
{
path: 'create',
element: <AuthCreateScreen />,
errorElement: <ErrorScreen />,
children: [
{
path: '',
async lazy() {
const { CreateStep1Screen } = await import('@app/auth/create/step-1');
return { Component: CreateStep1Screen };
},
},
{
path: 'step-2',
async lazy() {
const { CreateStep2Screen } = await import('@app/auth/create/step-2');
return { Component: CreateStep2Screen };
},
},
],
path: 'import',
async lazy() {
const { ImportAccountScreen } = await import('@app/auth/import');
return { Component: ImportAccountScreen };
},
},
{
path: 'onboarding',

View File

@@ -1,9 +1,5 @@
import { Outlet } from 'react-router-dom';
export function AuthCreateScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
export function CreateAccountScreen() {
return <Outlet />;
}

View File

@@ -16,7 +16,6 @@ export function CreateStep1Screen() {
const { db } = useStorage();
const navigate = useNavigate();
const setTempPrivkey = useOnboarding((state) => state.setTempPrivkey);
const setPubkey = useOnboarding((state) => state.setPubkey);
const setStep = useOnboarding((state) => state.setStep);
@@ -29,6 +28,17 @@ export function CreateStep1Screen() {
const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey);
const copyPrivkey = async () => {
try {
await writeText(nsec);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
await message(e, { title: 'Cannot copy private key', type: 'error' });
}
};
const download = async () => {
try {
const downloadPath = await downloadDir();
@@ -50,29 +60,22 @@ export function CreateStep1Screen() {
}
};
const copyPrivkey = async () => {
try {
await writeText(nsec);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
} catch (e) {
await message(e, { title: 'Cannot copy private key', type: 'error' });
}
};
const submit = async () => {
setLoading(true);
try {
setLoading(true);
setPubkey(pubkey);
// update state
setTempPrivkey(privkey); // only use if user close app and reopen it
setPubkey(pubkey);
// save privkey
await db.secureSave(privkey, pubkey);
// save to database
await db.createAccount(npub, pubkey);
// save to database
await db.createAccount(npub, pubkey);
// redirect to next step
navigate('/auth/create/step-2', { replace: true });
// redirect to next step
navigate('/auth/create/step-2', { replace: true });
} catch (e) {
await message(e, { title: 'Something went wrong!', type: 'error' });
}
};
useEffect(() => {
@@ -81,70 +84,86 @@ export function CreateStep1Screen() {
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
This is your new Nostr account
</h1>
<p className="mb-2 text-white/70">
Your private key is your password. If you lose this key, you will lose access to
your account! Copy it and keep it in a safe place. There is no way to reset your
private key.
</p>
<p className="text-white/70">
Public key is used for sharing with other people so that they can find you using
the public key.
</p>
</div>
<div className="flex flex-col gap-8">
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div>
<h1 className="mb-2 text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
This is your new Nostr account
</h1>
<p className="mb-2 select-text text-neutral-600 dark:text-neutral-300">
Your private key is your password. If you lose this key, you will lose access
to your account! Copy it and keep it in a safe place.{' '}
<span className="text-red-500">
There is no way to reset your private key.
</span>
</p>
<p className="select-text text-neutral-600 dark:text-neutral-300">
Public key is used for sharing with other people so that they can find you
using the public key.
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="font-medium text-white">Private Key</span>
<div className="relative">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="nsec"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Private Key
</label>
<div className="relative">
<input
readOnly
name="nsec"
value={nsec.substring(0, 5) + '**************************************'}
className="relative h-12 w-full rounded-lg bg-neutral-200 py-1 pl-3.5 pr-11 text-neutral-900 !outline-none dark:bg-neutral-800 dark:text-neutral-100"
/>
<button
type="button"
onClick={() => copyPrivkey()}
className="group absolute right-2 top-1/2 inline-flex h-7 -translate-y-1/2 transform items-center gap-1.5 rounded-md bg-neutral-300 px-2.5 text-sm text-neutral-800 hover:bg-neutral-400 hover:text-neutral-900 dark:bg-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-600 dark:hover:text-neutral-100"
>
<CopyIcon className="h-4 w-4" />
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="npub"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Public Key
</label>
<input
readOnly
value={nsec.substring(0, 5) + '**************************************'}
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 py-1 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
name="npub"
value={npub}
className="relative h-12 w-full rounded-lg bg-neutral-200 px-3.5 py-1 text-neutral-900 !outline-none dark:bg-neutral-800 dark:text-neutral-100"
/>
<button
type="button"
onClick={() => copyPrivkey()}
className="group absolute right-2 top-1/2 inline-flex h-7 -translate-y-1/2 transform items-center gap-1.5 rounded-md bg-white/20 px-2.5 text-sm hover:bg-white/30"
>
<CopyIcon className="h-4 w-4 text-white/70 group-hover:text-white" />
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium text-white">Public Key</span>
<input
readOnly
value={npub}
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
<button
type="button"
onClick={() => download()}
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none"
>
{downloaded ? 'Downloaded' : 'Download account keys'}
</button>
<button
type="button"
onClick={() => submit()}
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-neutral-200 px-6 font-medium leading-none text-neutral-900 hover:bg-neutral-300 focus:outline-none dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
{loading ? 'Creating...' : 'Continue'}
</button>
<span className="select-text text-center text-sm text-neutral-400 dark:text-neutral-600">
By clicking &apos;Continue&apos;, you are ensuring that your keys are saved
in a safe place. You cannot recover these keys if they are lost.
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => download()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none"
>
{downloaded ? 'Downloaded' : 'Download account keys'}
</button>
<button
type="button"
onClick={() => submit()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 px-6 font-medium leading-none text-white hover:bg-white/30 focus:outline-none"
>
{loading ? 'Creating...' : 'Continue'}
</button>
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, you are ensuring that your keys are saved in
a safe place. You cannot recover these keys if they are lost.
</span>
</div>
</div>
</div>
);

225
src/app/auth/import.tsx Normal file
View File

@@ -0,0 +1,225 @@
import { motion } from 'framer-motion';
import { nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge';
import { useStorage } from '@libs/storage/provider';
import { User } from '@shared/user';
export function ImportAccountScreen() {
const { db } = useStorage();
const navigate = useNavigate();
const [npub, setNpub] = useState<string>('');
const [nsec, setNsec] = useState<string>('');
const [pubkey, setPubkey] = useState<undefined | string>(undefined);
const [created, setCreated] = useState(false);
const [savedPrivkey, setSavedPrivkey] = useState(false);
const submitNpub = async () => {
if (npub.length < 6) return toast('You must enter valid npub');
if (!npub.startsWith('npub1')) return toast('npub must be starts with npub1');
try {
const pubkey = nip19.decode(npub).data as string;
setPubkey(pubkey);
} catch (e) {
return toast(`npub invalid: ${e}`);
}
};
const changeAccount = async () => {
setNpub('');
setPubkey('');
};
const createAccount = async () => {
try {
await db.createAccount(npub, pubkey);
setCreated(true);
} catch (e) {
return toast(`Create account failed: ${e}`);
}
};
const submitNsec = async () => {
if (savedPrivkey) return;
if (nsec.length > 50 && nsec.startsWith('nsec1')) {
try {
const privkey = nip19.decode(nsec).data as string;
await db.secureSave(pubkey, privkey);
setSavedPrivkey(true);
} catch (e) {
return toast(`nsec invalid: ${e}`);
}
}
};
const finish = async () => {
navigate('/auth/onboarding');
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Import your Nostr account
</h1>
<div className="flex flex-col gap-3">
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex flex-col gap-1.5">
<label htmlFor="npub" className="font-semibold">
Enter your nostr npub:
</label>
<div className="inline-flex w-full items-center gap-2">
<input
type="text"
value={npub}
onChange={(e) => setNpub(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="npub1"
className="h-11 flex-1 rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
/>
{!pubkey ? (
<button
type="button"
onClick={submitNpub}
className="h-11 w-24 shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Continue
</button>
) : null}
</div>
</div>
</div>
{pubkey ? (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
}}
transition={{ y: { velocity: -100 } }}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
>
<h5 className="mb-1.5 font-semibold">Account found</h5>
<div className="flex w-full flex-col gap-2">
<div className="inline-flex h-full flex-1 items-center rounded-lg bg-neutral-200 p-2">
<User pubkey={pubkey} variant="simple" />
</div>
{!created ? (
<div className="flex gap-2">
<button
type="button"
onClick={changeAccount}
className="h-9 flex-1 shrink-0 rounded-lg bg-neutral-200 font-semibold text-neutral-800 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700"
>
Change account
</button>
<button
type="button"
onClick={createAccount}
className="h-9 flex-1 shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Continue
</button>
</div>
) : null}
</div>
</motion.div>
) : null}
{created ? (
<>
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
}}
transition={{ y: { velocity: -100 } }}
className="rounded-lg bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="npub" className="font-semibold">
Enter your nostr nsec (optional):
</label>
<div className="inline-flex w-full items-center gap-2">
<input
type="text"
value={nsec}
onChange={(e) => setNsec(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="nsec1"
className="h-11 flex-1 rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={submitNsec}
className={twMerge(
'h-11 w-24 shrink-0 rounded-lg font-semibold text-white',
!savedPrivkey
? 'bg-blue-500 hover:bg-blue-600'
: 'bg-teal-500 hover:bg-teal-600'
)}
>
{savedPrivkey ? 'Saved' : 'Save'}
</button>
</div>
</div>
<div className="mt-3 select-text">
<p className="text-sm">
<b>nsec</b> is used to sign your event. For example, if you want to
make a new post or send a message to your contact, you need to use
nsec to sign this event.
</p>
<h5 className="mt-2 font-semibold">
1. In case you store nsec in Lume
</h5>
<p className="text-sm">
Lume will put your nsec to{' '}
{db.platform === 'macos'
? 'Apple Keychain (macOS)'
: db.platform === 'windows'
? 'Credential Manager (Windows)'
: 'Secret Service (Linux)'}
, it will be secured by your OS
</p>
<h5 className="mt-2 font-semibold">
2. In case you do not store nsec in Lume
</h5>
<p className="text-sm">
When you make an event that requires a sign by your nsec, Lume will
show a prompt popup for you to enter nsec. It will be cleared after
signing and not stored anywhere.
</p>
</div>
</motion.div>
<motion.button
type="button"
onClick={finish}
initial={{ opacity: 0, y: 80 }}
animate={{
opacity: 1,
y: 0,
}}
transition={{ y: { velocity: -130 } }}
className="h-9 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Finish
</motion.button>
</>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -1,9 +0,0 @@
import { Outlet } from 'react-router-dom';
export function AuthImportScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
}

View File

@@ -1,156 +0,0 @@
import { getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
type FormValues = {
privkey: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.privkey ? values : {},
errors: !values.privkey
? {
privkey: {
type: 'required',
message: 'This is required.',
},
}
: {},
};
};
export function ImportStep1Screen() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [passwordInput, setPasswordInput] = useState('password');
const [setStep, setPubkey, setTempPrivkey] = useOnboarding((state) => [
state.setStep,
state.setPubkey,
state.setTempPrivkey,
]);
const { db } = useStorage();
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: { [x: string]: string }) => {
try {
setLoading(true);
let privkey = data['privkey'];
if (privkey.substring(0, 4) === 'nsec') {
privkey = nip19.decode(privkey).data as string;
}
if (typeof getPublicKey(privkey) === 'string') {
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
setTempPrivkey(privkey);
setPubkey(pubkey);
// add account to local database
await db.createAccount(npub, pubkey);
// redirect to step 2 with delay 1.2s
setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
}
} catch (error) {
setLoading(false);
setError('privkey', {
type: 'custom',
message: 'Private key is invalid, please check again',
});
}
};
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/import');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-4 pb-4">
<h1 className="text-center text-2xl font-semibold text-white">
Import your Nostr key
</h1>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="privkey" className="font-medium text-white">
Insert your nostr private key, in nsec or hex format
</label>
<div className="relative">
<input
{...register('privkey', { required: true, minLength: 32 })}
type={passwordInput}
placeholder="nsec1..."
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3 py-1 text-white backdrop-blur-xl placeholder:text-white/70 focus:outline-none"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<span className="text-sm text-red-500">
{errors.privkey && <p>{errors.privkey.message}</p>}
</span>
</div>
<div className="flex items-center justify-center">
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Importing...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,90 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr';
export function ImportStep2Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const { db } = useStorage();
const { fetchUserData } = useNostr();
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
// show loading indicator
setLoading(true);
// prefetch data
const user = await fetchUserData();
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
// redirect to next step
if (user.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
} else {
setLoading(false);
}
} catch (e) {
console.log('error: ', e);
setLoading(false);
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/import/step-3');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-4 pb-4">
<h1 className="text-center text-2xl font-semibold text-white">
{loading ? 'Downloading...' : 'Your Nostr profile'}
</h1>
</div>
<div className="flex flex-col gap-3">
<div className="rounded-lg border-t border-white/10 bg-white/20 px-3 py-3">
<User pubkey={db.account.pubkey} variant="simple" />
</div>
<div className="flex flex-col gap-2">
<button
type="button"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none"
onClick={() => submit()}
>
{loading ? (
<>
<span className="w-5" />
<span>It might take a bit, please patient...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, Lume will download your old relay list and
metadata. It may take a bit
</span>
</div>
</div>
</div>
);
}

View File

@@ -19,13 +19,11 @@ export class LumeStorage {
}
public async secureSave(value: string, key?: string) {
return await invoke('secure_save', { key: this.account.pubkey ?? key, value });
return await invoke('secure_save', { key, value });
}
public async secureLoad(key?: string) {
const value: string = await invoke('secure_load', {
key: this.account.pubkey ?? key,
});
const value: string = await invoke('secure_load', { key });
return value;
}

View File

@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner';
import { NDKProvider } from '@libs/ndk/provider';
import { StorageProvider } from '@libs/storage/provider';
@@ -15,6 +16,7 @@ root.render(
<QueryClientProvider client={queryClient}>
<StorageProvider>
<NDKProvider>
<Toaster />
<App />
</NDKProvider>
</StorageProvider>

View File

@@ -162,18 +162,18 @@ export const User = memo(function User({
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-10 w-10 rounded-lg"
className="h-11 w-11 rounded-lg"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
className="h-11 w-11 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex w-full flex-col items-start">
<h3 className="max-w-[15rem] truncate font-medium text-neutral-900 dark:text-neutral-100">
<h3 className="max-w-[15rem] truncate text-base font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName}
</h3>
<p className="max-w-[10rem] truncate text-sm text-neutral-900 dark:text-neutral-100/70">