index.tsx raw

   1  import Uploader from '@/components/PostEditor/Uploader'
   2  import ProfileBanner from '@/components/ProfileBanner'
   3  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
   4  import { Button } from '@/components/ui/button'
   5  import { Input } from '@/components/ui/input'
   6  import { Label } from '@/components/ui/label'
   7  import { Textarea } from '@/components/ui/textarea'
   8  import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
   9  import { createProfileDraftEvent } from '@/lib/draft-event'
  10  import { generateImageByPubkey } from '@/lib/pubkey'
  11  import { isEmail } from '@/lib/utils'
  12  import { useSecondaryPage } from '@/PageManager'
  13  import { useNostr } from '@/providers/NostrProvider'
  14  import { Loader, Upload } from 'lucide-react'
  15  import { forwardRef, useEffect, useMemo, useState } from 'react'
  16  import { useTranslation } from 'react-i18next'
  17  
  18  const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
  19    const { t } = useTranslation()
  20    const { pop } = useSecondaryPage()
  21    const { account, profile, profileEvent, publish, updateProfileEvent } = useNostr()
  22    const [banner, setBanner] = useState<string>('')
  23    const [avatar, setAvatar] = useState<string>('')
  24    const [username, setUsername] = useState<string>('')
  25    const [about, setAbout] = useState<string>('')
  26    const [website, setWebsite] = useState<string>('')
  27    const [nip05, setNip05] = useState<string>('')
  28    const [nip05Error, setNip05Error] = useState<string>('')
  29    const [lightningAddress, setLightningAddress] = useState<string>('')
  30    const [lightningAddressError, setLightningAddressError] = useState<string>('')
  31    const [hasChanged, setHasChanged] = useState(false)
  32    const [saving, setSaving] = useState(false)
  33    const [uploadingBanner, setUploadingBanner] = useState(false)
  34    const [uploadingAvatar, setUploadingAvatar] = useState(false)
  35    const defaultImage = useMemo(
  36      () => (account ? generateImageByPubkey(account.pubkey) : undefined),
  37      [account]
  38    )
  39  
  40    useEffect(() => {
  41      if (profile) {
  42        setBanner(profile.banner ?? '')
  43        setAvatar(profile.avatar ?? '')
  44        setUsername(profile.original_username ?? '')
  45        setAbout(profile.about ?? '')
  46        setWebsite(profile.website ?? '')
  47        setNip05(profile.nip05 ?? '')
  48        setLightningAddress(profile.lightningAddress || '')
  49      } else {
  50        setBanner('')
  51        setAvatar('')
  52        setUsername('')
  53        setAbout('')
  54        setWebsite('')
  55        setNip05('')
  56        setLightningAddress('')
  57      }
  58    }, [profile])
  59  
  60    if (!account || !profile) return null
  61  
  62    const save = async () => {
  63      if (nip05 && !isEmail(nip05)) {
  64        setNip05Error(t('Invalid NIP-05 address'))
  65        return
  66      }
  67  
  68      const oldProfileContent = profileEvent ? JSON.parse(profileEvent.content) : {}
  69      const newProfileContent = {
  70        ...oldProfileContent,
  71        display_name: username,
  72        displayName: username,
  73        name: oldProfileContent.name ?? username,
  74        about,
  75        website,
  76        nip05,
  77        banner,
  78        picture: avatar
  79      }
  80  
  81      if (lightningAddress) {
  82        if (isEmail(lightningAddress)) {
  83          newProfileContent.lud16 = lightningAddress
  84        } else if (lightningAddress.startsWith('lnurl')) {
  85          newProfileContent.lud06 = lightningAddress
  86        } else {
  87          setLightningAddressError(t('Invalid Lightning Address'))
  88          return
  89        }
  90      } else {
  91        delete newProfileContent.lud16
  92      }
  93  
  94      setSaving(true)
  95      setHasChanged(false)
  96      const profileDraftEvent = createProfileDraftEvent(
  97        JSON.stringify(newProfileContent),
  98        profileEvent?.tags
  99      )
 100      const newProfileEvent = await publish(profileDraftEvent)
 101      await updateProfileEvent(newProfileEvent)
 102      setSaving(false)
 103      pop()
 104    }
 105  
 106    const onBannerUploadSuccess = ({ url }: { url: string }) => {
 107      setBanner(url)
 108      setHasChanged(true)
 109    }
 110  
 111    const onAvatarUploadSuccess = ({ url }: { url: string }) => {
 112      setAvatar(url)
 113      setHasChanged(true)
 114    }
 115  
 116    const controls = (
 117      <div className="pr-3">
 118        <Button className="w-16 rounded-full" onClick={save} disabled={saving || !hasChanged}>
 119          {saving ? <Loader className="animate-spin" /> : t('Save')}
 120        </Button>
 121      </div>
 122    )
 123  
 124    return (
 125      <SecondaryPageLayout ref={ref} index={index} title={profile.username} controls={controls}>
 126        <div className="relative bg-cover bg-center mb-2">
 127          <Uploader
 128            onUploadSuccess={onBannerUploadSuccess}
 129            onUploadStart={() => setUploadingBanner(true)}
 130            onUploadEnd={() => setUploadingBanner(false)}
 131            className="w-full relative cursor-pointer"
 132          >
 133            <ProfileBanner banner={banner} pubkey={account.pubkey} className="w-full aspect-[3/1]" />
 134            <div className="absolute top-0 bg-muted/30 w-full h-full flex flex-col justify-center items-center">
 135              {uploadingBanner ? <Loader size={36} className="animate-spin" /> : <Upload size={36} />}
 136            </div>
 137          </Uploader>
 138          <Uploader
 139            onUploadSuccess={onAvatarUploadSuccess}
 140            onUploadStart={() => setUploadingAvatar(true)}
 141            onUploadEnd={() => setUploadingAvatar(false)}
 142            className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full"
 143          >
 144            <Avatar className="w-full h-full">
 145              <AvatarImage src={avatar} className="object-cover object-center" />
 146              <AvatarFallback>
 147                <img src={defaultImage} />
 148              </AvatarFallback>
 149            </Avatar>
 150            <div className="absolute top-0 bg-muted/30 w-full h-full rounded-full flex flex-col justify-center items-center">
 151              {uploadingAvatar ? <Loader className="animate-spin" /> : <Upload />}
 152            </div>
 153          </Uploader>
 154        </div>
 155        <div className="pt-14 px-4 flex flex-col gap-4">
 156          <Item>
 157            <Label htmlFor="profile-username-input">{t('Display Name')}</Label>
 158            <Input
 159              id="profile-username-input"
 160              value={username}
 161              onChange={(e) => {
 162                setUsername(e.target.value)
 163                setHasChanged(true)
 164              }}
 165            />
 166          </Item>
 167          <Item>
 168            <Label htmlFor="profile-about-textarea">{t('Bio')}</Label>
 169            <Textarea
 170              id="profile-about-textarea"
 171              className="h-44"
 172              value={about}
 173              onChange={(e) => {
 174                setAbout(e.target.value)
 175                setHasChanged(true)
 176              }}
 177            />
 178          </Item>
 179          <Item>
 180            <Label htmlFor="profile-website-input">{t('Website')}</Label>
 181            <Input
 182              id="profile-website-input"
 183              value={website}
 184              onChange={(e) => {
 185                setWebsite(e.target.value)
 186                setHasChanged(true)
 187              }}
 188            />
 189          </Item>
 190          <Item>
 191            <Label htmlFor="profile-nip05-input">{t('Nostr Address (NIP-05)')}</Label>
 192            <Input
 193              id="profile-nip05-input"
 194              value={nip05}
 195              onChange={(e) => {
 196                setNip05Error('')
 197                setNip05(e.target.value)
 198                setHasChanged(true)
 199              }}
 200              className={nip05Error ? 'border-destructive' : ''}
 201            />
 202            {nip05Error && <div className="text-xs text-destructive pl-3">{nip05Error}</div>}
 203          </Item>
 204          <Item>
 205            <Label htmlFor="profile-lightning-address-input">
 206              {t('Lightning Address (or LNURL)')}
 207            </Label>
 208            <Input
 209              id="profile-lightning-address-input"
 210              value={lightningAddress}
 211              onChange={(e) => {
 212                setLightningAddressError('')
 213                setLightningAddress(e.target.value)
 214                setHasChanged(true)
 215              }}
 216              className={lightningAddressError ? 'border-destructive' : ''}
 217            />
 218            {lightningAddressError && (
 219              <div className="text-xs text-destructive pl-3">{lightningAddressError}</div>
 220            )}
 221          </Item>
 222        </div>
 223      </SecondaryPageLayout>
 224    )
 225  })
 226  ProfileEditorPage.displayName = 'ProfileEditorPage'
 227  export default ProfileEditorPage
 228  
 229  function Item({ children }: { children: React.ReactNode }) {
 230    return <div className="grid gap-2">{children}</div>
 231  }
 232