feat: generate new account & profile editor
This commit is contained in:
59
src/components/AccountManager/GenerateNewAccount.tsx
Normal file
59
src/components/AccountManager/GenerateNewAccount.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Check, Copy, RefreshCcw } from 'lucide-react'
|
||||
import { generateSecretKey } from 'nostr-tools'
|
||||
import { nsecEncode } from 'nostr-tools/nip19'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function GenerateNewAccount({
|
||||
back,
|
||||
onLoginSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onLoginSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { nsecLogin } = useNostr()
|
||||
const [nsec, setNsec] = useState(generateNsec())
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleLogin = () => {
|
||||
nsecLogin(nsec).then(() => onLoginSuccess())
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-orange-400">
|
||||
{t(
|
||||
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={nsec} />
|
||||
<Button variant="secondary" onClick={() => setNsec(generateNsec())}>
|
||||
<RefreshCcw />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(nsec)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleLogin}>{t('Login')}</Button>
|
||||
<Button variant="secondary" onClick={back}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function generateNsec() {
|
||||
const sk = generateSecretKey()
|
||||
return nsecEncode(sk)
|
||||
}
|
||||
@@ -1,34 +1,38 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { TSignerType } from '@/types'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AccountList from '../AccountList'
|
||||
import BunkerLogin from './BunkerLogin'
|
||||
import PrivateKeyLogin from './NsecLogin'
|
||||
import GenerateNewAccount from './GenerateNewAccount'
|
||||
|
||||
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | null
|
||||
|
||||
export default function AccountManager({ close }: { close?: () => void }) {
|
||||
const [loginMethod, setLoginMethod] = useState<TSignerType | null>(null)
|
||||
const [page, setPage] = useState<TAccountManagerPage>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
{loginMethod === 'nsec' ? (
|
||||
<PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} />
|
||||
) : loginMethod === 'bunker' ? (
|
||||
<BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} />
|
||||
{page === 'nsec' ? (
|
||||
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'bunker' ? (
|
||||
<BunkerLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'generate' ? (
|
||||
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : (
|
||||
<AccountManagerNav setLoginMethod={setLoginMethod} close={close} />
|
||||
<AccountManagerNav setPage={setPage} close={close} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountManagerNav({
|
||||
setLoginMethod,
|
||||
setPage,
|
||||
close
|
||||
}: {
|
||||
setLoginMethod: (method: TSignerType) => void
|
||||
setPage: (page: TAccountManagerPage) => void
|
||||
close?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -44,12 +48,19 @@ function AccountManagerNav({
|
||||
{t('Login with Browser Extension')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setLoginMethod('bunker')} className="w-full">
|
||||
<Button variant="secondary" onClick={() => setPage('bunker')} className="w-full">
|
||||
{t('Login with Bunker')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full">
|
||||
<Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
|
||||
{t('Login with Private Key')}
|
||||
</Button>
|
||||
<Separator />
|
||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||
{t("Don't have an account yet?")}
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => setPage('generate')} className="w-full">
|
||||
{t('Generate New Account')}
|
||||
</Button>
|
||||
{accounts.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function LoginDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-96 max-h-[90vh] overflow-auto">
|
||||
<DialogContent className="w-[520px] max-h-[90vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="hidden" />
|
||||
<DialogDescription className="hidden" />
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { TMailboxRelay, TMailboxRelayScope } from '@/types'
|
||||
import { CircleX, Server } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TMailboxRelay, TMailboxRelayScope } from './types'
|
||||
|
||||
export default function MailboxRelay({
|
||||
mailboxRelay,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useToast } from '@/hooks'
|
||||
import { createRelayListDraftEvent } from '@/lib/draft-event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import dayjs from 'dayjs'
|
||||
import { TMailboxRelay } from '@/types'
|
||||
import { CloudUpload, Loader } from 'lucide-react'
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../ui/button'
|
||||
import { TMailboxRelay } from './types'
|
||||
|
||||
export default function SaveButton({
|
||||
mailboxRelays,
|
||||
@@ -24,14 +23,7 @@ export default function SaveButton({
|
||||
if (!pubkey) return
|
||||
|
||||
setPushing(true)
|
||||
const event = {
|
||||
kind: kinds.RelayList,
|
||||
content: '',
|
||||
tags: mailboxRelays.map(({ url, scope }) =>
|
||||
scope === 'both' ? ['r', url] : ['r', url, scope]
|
||||
),
|
||||
created_at: dayjs().unix()
|
||||
}
|
||||
const event = createRelayListDraftEvent(mailboxRelays)
|
||||
const relayListEvent = await publish(event)
|
||||
updateRelayListEvent(relayListEvent)
|
||||
toast({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { TMailboxRelay, TMailboxRelayScope } from '@/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MailboxRelay from './MailboxRelay'
|
||||
import NewMailboxRelayInput from './NewMailboxRelayInput'
|
||||
import SaveButton from './SaveButton'
|
||||
import { TMailboxRelay, TMailboxRelayScope } from './types'
|
||||
|
||||
export default function MailboxSetting() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export type TMailboxRelayScope = 'read' | 'write' | 'both'
|
||||
export type TMailboxRelay = {
|
||||
url: string
|
||||
scope: TMailboxRelayScope
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useToast } from '@/hooks/use-toast'
|
||||
import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { ChevronDown, LoaderCircle } from 'lucide-react'
|
||||
import { ChevronDown, ImageUp, LoaderCircle } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -32,6 +32,7 @@ export default function NormalPostContent({
|
||||
const [posting, setPosting] = useState(false)
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||
const [addClientTag, setAddClientTag] = useState(false)
|
||||
const [uploadingPicture, setUploadingPicture] = useState(false)
|
||||
const canPost = !!content && !posting
|
||||
|
||||
useEffect(() => {
|
||||
@@ -116,7 +117,13 @@ export default function NormalPostContent({
|
||||
setPictureInfos((prev) => [...prev, { url, tags }])
|
||||
setContent((prev) => `${prev}\n${url}`)
|
||||
}}
|
||||
/>
|
||||
onUploadingChange={setUploadingPicture}
|
||||
accept="image/*,video/*,audio/*"
|
||||
>
|
||||
<Button variant="secondary" disabled={uploadingPicture}>
|
||||
{uploadingPicture ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
|
||||
</Button>
|
||||
</Uploader>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-foreground gap-0 px-0"
|
||||
|
||||
@@ -5,8 +5,9 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { StorageKey } from '@/constants'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { createPictureNoteDraftEvent } from '@/lib/draft-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { ChevronDown, LoaderCircle, X } from 'lucide-react'
|
||||
import { ChevronDown, Loader, LoaderCircle, Plus, X } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -177,6 +178,7 @@ function PictureUploader({
|
||||
>
|
||||
}) {
|
||||
const [index, setIndex] = useState(-1)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -203,11 +205,20 @@ function PictureUploader({
|
||||
</div>
|
||||
))}
|
||||
<Uploader
|
||||
variant="big"
|
||||
onUploadSuccess={({ url, tags }) => {
|
||||
setPictureInfos((prev) => [...prev, { url, tags }])
|
||||
}}
|
||||
/>
|
||||
onUploadingChange={setUploading}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 items-center justify-center aspect-square w-full rounded-lg border border-dashed',
|
||||
uploading ? 'cursor-not-allowed text-muted-foreground' : 'clickable'
|
||||
)}
|
||||
>
|
||||
{uploading ? <Loader size={36} className="animate-spin" /> : <Plus size={36} />}
|
||||
</div>
|
||||
</Uploader>
|
||||
</div>
|
||||
{index >= 0 &&
|
||||
createPortal(
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { ImageUp, Loader, LoaderCircle, Plus } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { z } from 'zod'
|
||||
|
||||
export default function Uploader({
|
||||
children,
|
||||
onUploadSuccess,
|
||||
variant = 'button'
|
||||
onUploadingChange,
|
||||
className,
|
||||
accept = 'image/*'
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void
|
||||
variant?: 'button' | 'big'
|
||||
onUploadingChange?: (uploading: boolean) => void
|
||||
className?: string
|
||||
accept?: string
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const { signHttpAuth } = useNostr()
|
||||
const { toast } = useToast()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -26,7 +28,7 @@ export default function Uploader({
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
setUploading(true)
|
||||
onUploadingChange?.(true)
|
||||
const url = 'https://nostr.build/api/v2/nip96/upload'
|
||||
const auth = await signHttpAuth(url, 'POST')
|
||||
const response = await fetch(url, {
|
||||
@@ -60,7 +62,7 @@ export default function Uploader({
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
} finally {
|
||||
setUploading(false)
|
||||
onUploadingChange?.(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,41 +73,16 @@ export default function Uploader({
|
||||
}
|
||||
}
|
||||
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleUploadClick} disabled={uploading}>
|
||||
{uploading ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,video/*,audio/*"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 items-center justify-center aspect-square w-full rounded-lg border border-dashed',
|
||||
uploading ? 'cursor-not-allowed text-muted-foreground' : 'clickable'
|
||||
)}
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
{uploading ? <Loader size={36} className="animate-spin" /> : <Plus size={36} />}
|
||||
</div>
|
||||
<div onClick={handleUploadClick} className={className}>
|
||||
{children}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
accept={accept}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,3 +24,4 @@ export const PICTURE_EVENT_KIND = 20
|
||||
export const COMMENT_EVENT_KIND = 1111
|
||||
|
||||
export const URL_REGEX = /(https?:\/\/[^\s"']+)/g
|
||||
export const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { userIdToPubkey } from '@/lib/pubkey'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { TProfile } from '@/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export function useFetchProfile(id?: string) {
|
||||
const { profile: currentAccountProfile } = useNostr()
|
||||
const [isFetching, setIsFetching] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||
const pubkey = useMemo(() => (id ? userIdToPubkey(id) : undefined), [id])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
@@ -31,5 +35,11 @@ export function useFetchProfile(id?: string) {
|
||||
fetchProfile()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentAccountProfile && pubkey === currentAccountProfile.pubkey) {
|
||||
setProfile(currentAccountProfile)
|
||||
}
|
||||
}, [currentAccountProfile])
|
||||
|
||||
return { isFetching, error, profile }
|
||||
}
|
||||
|
||||
@@ -127,6 +127,17 @@ export default {
|
||||
'write relays description':
|
||||
'Write relays are used to publish your events. Other users will seek your events from your write relays.',
|
||||
'read & write relays notice':
|
||||
'The number of read and write servers should ideally be kept between 2 and 4.'
|
||||
'The number of read and write servers should ideally be kept between 2 and 4.',
|
||||
"Don't have an account yet?": "Don't have an account yet?",
|
||||
'Generate New Account': 'Generate New Account',
|
||||
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.':
|
||||
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.',
|
||||
Edit: 'Edit',
|
||||
Save: 'Save',
|
||||
'Display Name': 'Display Name',
|
||||
Bio: 'Bio',
|
||||
'Nostr Address (NIP-05)': 'Nostr Address (NIP-05)',
|
||||
'Invalid NIP-05 address': 'Invalid NIP-05 address',
|
||||
'Copy private key (nsec)': 'Copy private key (nsec)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,17 @@ export default {
|
||||
'读服务器用于寻找与您有关的事件。其他用户会将想要你看到的事件发布到您的读服务器。',
|
||||
'write relays description':
|
||||
'写服务器用于发布您的事件。其他用户会从您的写服务器寻找您发布的事件。',
|
||||
'read & write relays notice': '读服务器和写服务器的数量都应尽量保持在 2 到 4 个之间。'
|
||||
'read & write relays notice': '读服务器和写服务器的数量都应尽量保持在 2 到 4 个之间。',
|
||||
"Don't have an account yet?": '还没有账户?',
|
||||
'Generate New Account': '生成新账户',
|
||||
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.':
|
||||
'这是私钥,请勿与他人分享。请妥善保管,否则将无法找回。',
|
||||
Edit: '编辑',
|
||||
Save: '保存',
|
||||
'Display Name': '昵称',
|
||||
Bio: '简介',
|
||||
'Nostr Address (NIP-05)': 'Nostr 地址 (NIP-05)',
|
||||
'Invalid NIP-05 address': '无效的 NIP-05 地址',
|
||||
'Copy private key (nsec)': '复制私钥 (nsec)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import BackButton from '@/components/BackButton'
|
||||
import BottomNavigationBar from '@/components/BottomNavigationBar'
|
||||
import ScrollToTopButton from '@/components/ScrollToTopButton'
|
||||
import ThemeToggle from '@/components/ThemeToggle'
|
||||
import { Titlebar } from '@/components/Titlebar'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
@@ -11,13 +10,15 @@ import { useEffect, useRef, useState } from 'react'
|
||||
export default function SecondaryPageLayout({
|
||||
children,
|
||||
index,
|
||||
titlebarContent,
|
||||
title,
|
||||
controls,
|
||||
hideBackButton = false,
|
||||
displayScrollToTopButton = false
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
index?: number
|
||||
titlebarContent?: React.ReactNode
|
||||
title?: React.ReactNode
|
||||
controls?: React.ReactNode
|
||||
hideBackButton?: boolean
|
||||
displayScrollToTopButton?: boolean
|
||||
}): JSX.Element {
|
||||
@@ -90,7 +91,8 @@ export default function SecondaryPageLayout({
|
||||
}}
|
||||
>
|
||||
<SecondaryPageTitlebar
|
||||
content={titlebarContent}
|
||||
title={title}
|
||||
controls={controls}
|
||||
hideBackButton={hideBackButton}
|
||||
visible={visible}
|
||||
/>
|
||||
@@ -104,11 +106,13 @@ export default function SecondaryPageLayout({
|
||||
}
|
||||
|
||||
export function SecondaryPageTitlebar({
|
||||
content,
|
||||
title,
|
||||
controls,
|
||||
hideBackButton = false,
|
||||
visible = true
|
||||
}: {
|
||||
content?: React.ReactNode
|
||||
title?: React.ReactNode
|
||||
controls?: React.ReactNode
|
||||
hideBackButton?: boolean
|
||||
visible?: boolean
|
||||
}): JSX.Element {
|
||||
@@ -116,8 +120,12 @@ export function SecondaryPageTitlebar({
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Titlebar className="h-12 flex gap-1 p-1 items-center font-semibold" visible={visible}>
|
||||
<BackButton hide={hideBackButton}>{content}</BackButton>
|
||||
<Titlebar
|
||||
className="h-12 flex gap-1 p-1 items-center justify-between font-semibold"
|
||||
visible={visible}
|
||||
>
|
||||
<BackButton hide={hideBackButton}>{title}</BackButton>
|
||||
<div className="flex-shrink-0">{controls}</div>
|
||||
</Titlebar>
|
||||
)
|
||||
}
|
||||
@@ -125,11 +133,9 @@ export function SecondaryPageTitlebar({
|
||||
return (
|
||||
<Titlebar className="h-12 flex gap-1 p-1 justify-between items-center font-semibold">
|
||||
<div className="flex items-center gap-1 flex-1 w-0">
|
||||
<BackButton hide={hideBackButton}>{content}</BackButton>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<ThemeToggle />
|
||||
<BackButton hide={hideBackButton}>{title}</BackButton>
|
||||
</div>
|
||||
<div className="flex-shrink-0">{controls}</div>
|
||||
</Titlebar>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
|
||||
import { TDraftEvent, TRelaySet } from '@/types'
|
||||
import { TDraftEvent, TMailboxRelay, TRelaySet } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import {
|
||||
@@ -182,6 +182,35 @@ export async function createCommentDraftEvent(
|
||||
}
|
||||
}
|
||||
|
||||
export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraftEvent {
|
||||
return {
|
||||
kind: kinds.RelayList,
|
||||
content: '',
|
||||
tags: mailboxRelays.map(({ url, scope }) =>
|
||||
scope === 'both' ? ['r', url] : ['r', url, scope]
|
||||
),
|
||||
created_at: dayjs().unix()
|
||||
}
|
||||
}
|
||||
|
||||
export function createFollowListDraftEvent(tags: string[][], content?: string): TDraftEvent {
|
||||
return {
|
||||
kind: kinds.Contacts,
|
||||
content: content ?? '',
|
||||
created_at: dayjs().unix(),
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
export function createProfileDraftEvent(content: string, tags: string[][] = []): TDraftEvent {
|
||||
return {
|
||||
kind: kinds.Metadata,
|
||||
content,
|
||||
tags,
|
||||
created_at: dayjs().unix()
|
||||
}
|
||||
}
|
||||
|
||||
function generateImetaTags(imageUrls: string[], pictureInfos: { url: string; tags: string[][] }[]) {
|
||||
return imageUrls.map((imageUrl) => {
|
||||
const pictureInfo = pictureInfos.find((info) => info.url === imageUrl)
|
||||
|
||||
@@ -110,15 +110,16 @@ export function getRelayListFromRelayListEvent(event?: Event) {
|
||||
export function getProfileFromProfileEvent(event: Event) {
|
||||
try {
|
||||
const profileObj = JSON.parse(event.content)
|
||||
const username =
|
||||
profileObj.display_name?.trim() ||
|
||||
profileObj.name?.trim() ||
|
||||
profileObj.nip05?.split('@')[0]?.trim()
|
||||
return {
|
||||
pubkey: event.pubkey,
|
||||
banner: profileObj.banner,
|
||||
avatar: profileObj.picture,
|
||||
username:
|
||||
profileObj.display_name?.trim() ||
|
||||
profileObj.name?.trim() ||
|
||||
profileObj.nip05?.split('@')[0]?.trim() ||
|
||||
formatPubkey(event.pubkey),
|
||||
username: username || formatPubkey(event.pubkey),
|
||||
original_username: username,
|
||||
nip05: profileObj.nip05,
|
||||
about: profileObj.about,
|
||||
created_at: event.created_at
|
||||
|
||||
@@ -39,6 +39,7 @@ export const toFollowingList = (pubkey: string) => {
|
||||
}
|
||||
export const toRelaySettings = () => '/relay-settings'
|
||||
export const toSettings = () => '/settings'
|
||||
export const toProfileEditor = () => '/profile-editor'
|
||||
|
||||
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
|
||||
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function FollowingListPage({ id, index }: { id?: string; index?:
|
||||
return (
|
||||
<SecondaryPageLayout
|
||||
index={index}
|
||||
titlebarContent={
|
||||
title={
|
||||
profile?.username
|
||||
? t("username's following", { username: profile.username })
|
||||
: t('Following')
|
||||
|
||||
@@ -2,7 +2,7 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
|
||||
export default function LoadingPage({ title, index }: { title?: string; index?: number }) {
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={title}>
|
||||
<SecondaryPageLayout index={index} title={title}>
|
||||
<div className="text-muted-foreground text-center">
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function NoteListPage({ index }: { index?: number }) {
|
||||
}, [searchParams, relayUrlsString])
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton>
|
||||
<SecondaryPageLayout index={index} title={title} displayScrollToTopButton>
|
||||
<NoteList key={title} filter={filter} relayUrls={urls} />
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function NotePage({ id, index }: { id?: string; index?: number })
|
||||
|
||||
if (!event && isFetching) {
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Note')}>
|
||||
<SecondaryPageLayout index={index} title={t('Note')}>
|
||||
<div className="px-4">
|
||||
<Skeleton className="w-10 h-10 rounded-full" />
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@ export default function NotePage({ id, index }: { id?: string; index?: number })
|
||||
|
||||
if (isPictureEvent(event) && isSmallScreen) {
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Note')} displayScrollToTopButton>
|
||||
<SecondaryPageLayout index={index} title={t('Note')} displayScrollToTopButton>
|
||||
<PictureNote key={`note-${event.id}`} event={event} fetchNoteStats />
|
||||
<Separator className="mb-2 mt-4" />
|
||||
<Nip22ReplyNoteList
|
||||
@@ -50,7 +50,7 @@ export default function NotePage({ id, index }: { id?: string; index?: number })
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Note')} displayScrollToTopButton>
|
||||
<SecondaryPageLayout index={index} title={t('Note')} displayScrollToTopButton>
|
||||
<div className="px-4">
|
||||
{rootEventId !== parentEventId && (
|
||||
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
|
||||
|
||||
185
src/pages/secondary/ProfileEditorPage/index.tsx
Normal file
185
src/pages/secondary/ProfileEditorPage/index.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import Uploader from '@/components/PostEditor/Uploader'
|
||||
import ProfileBanner from '@/components/ProfileBanner'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { EMAIL_REGEX } from '@/constants'
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { createProfileDraftEvent } from '@/lib/draft-event'
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Loader, Upload } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ProfileEditorPage({ index }: { index?: number }) {
|
||||
const { t } = useTranslation()
|
||||
const { pop } = useSecondaryPage()
|
||||
const { account, profile, profileEvent, publish, updateProfileEvent } = useNostr()
|
||||
const [banner, setBanner] = useState<string>('')
|
||||
const [avatar, setAvatar] = useState<string>('')
|
||||
const [username, setUsername] = useState<string>('')
|
||||
const [about, setAbout] = useState<string>('')
|
||||
const [nip05, setNip05] = useState<string>('')
|
||||
const [nip05Error, setNip05Error] = useState<string>('')
|
||||
const [hasChanged, setHasChanged] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploadingBanner, setUploadingBanner] = useState(false)
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||
const defaultImage = useMemo(
|
||||
() => (account ? generateImageByPubkey(account.pubkey) : undefined),
|
||||
[account]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setBanner(profile.banner ?? '')
|
||||
setAvatar(profile.avatar ?? '')
|
||||
setUsername(profile.original_username ?? '')
|
||||
setAbout(profile.about ?? '')
|
||||
setNip05(profile.nip05 ?? '')
|
||||
} else {
|
||||
setBanner('')
|
||||
setAvatar('')
|
||||
setUsername('')
|
||||
setAbout('')
|
||||
setNip05('')
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
if (!account || !profile) return null
|
||||
|
||||
const save = async () => {
|
||||
if (nip05 && !EMAIL_REGEX.test(nip05)) {
|
||||
setNip05Error(t('Invalid NIP-05 address'))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setHasChanged(false)
|
||||
const oldProfileContent = profileEvent ? JSON.parse(profileEvent.content) : {}
|
||||
const newProfileContent = {
|
||||
...oldProfileContent,
|
||||
display_name: username,
|
||||
displayName: username,
|
||||
name: oldProfileContent.name ?? username,
|
||||
about,
|
||||
nip05,
|
||||
banner,
|
||||
picture: avatar
|
||||
}
|
||||
const profileDraftEvent = createProfileDraftEvent(
|
||||
JSON.stringify(newProfileContent),
|
||||
profileEvent?.tags
|
||||
)
|
||||
const newProfileEvent = await publish(profileDraftEvent)
|
||||
updateProfileEvent(newProfileEvent)
|
||||
setSaving(false)
|
||||
pop()
|
||||
}
|
||||
|
||||
const onBannerUploadSuccess = ({ url }: { url: string }) => {
|
||||
setBanner(url)
|
||||
setHasChanged(true)
|
||||
}
|
||||
|
||||
const onAvatarUploadSuccess = ({ url }: { url: string }) => {
|
||||
setAvatar(url)
|
||||
setHasChanged(true)
|
||||
}
|
||||
|
||||
const controls = (
|
||||
<div className="pr-3">
|
||||
<Button className="w-16 rounded-full" onClick={save} disabled={saving || !hasChanged}>
|
||||
{saving ? <Loader className="animate-spin" /> : t('Save')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout index={index} title={profile.username} controls={controls}>
|
||||
<div className="px-4">
|
||||
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
||||
<Uploader
|
||||
onUploadSuccess={onBannerUploadSuccess}
|
||||
onUploadingChange={(uploading) => setTimeout(() => setUploadingBanner(uploading), 50)}
|
||||
className="w-full relative cursor-pointer"
|
||||
>
|
||||
<ProfileBanner
|
||||
banner={banner}
|
||||
pubkey={account.pubkey}
|
||||
className="w-full aspect-video object-cover rounded-lg"
|
||||
/>
|
||||
<div className="absolute top-0 bg-muted/30 w-full h-full rounded-lg flex flex-col justify-center items-center">
|
||||
{uploadingBanner ? (
|
||||
<Loader size={36} className="animate-spin" />
|
||||
) : (
|
||||
<Upload size={36} />
|
||||
)}
|
||||
</div>
|
||||
</Uploader>
|
||||
<Uploader
|
||||
onUploadSuccess={onAvatarUploadSuccess}
|
||||
onUploadingChange={(uploading) => setTimeout(() => setUploadingAvatar(uploading), 50)}
|
||||
className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full"
|
||||
>
|
||||
<Avatar className="w-full h-full">
|
||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<img src={defaultImage} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute top-0 bg-muted/30 w-full h-full rounded-full flex flex-col justify-center items-center">
|
||||
{uploadingAvatar ? <Loader className="animate-spin" /> : <Upload />}
|
||||
</div>
|
||||
</Uploader>
|
||||
</div>
|
||||
<div className="pt-14 space-y-4">
|
||||
<Item>
|
||||
<ItemTitle>{t('Display Name')}</ItemTitle>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value)
|
||||
setHasChanged(true)
|
||||
}}
|
||||
/>
|
||||
</Item>
|
||||
<Item>
|
||||
<ItemTitle>{t('Bio')}</ItemTitle>
|
||||
<Textarea
|
||||
className="h-44"
|
||||
value={about}
|
||||
onChange={(e) => {
|
||||
setAbout(e.target.value)
|
||||
setHasChanged(true)
|
||||
}}
|
||||
/>
|
||||
</Item>
|
||||
<Item>
|
||||
<ItemTitle>{t('Nostr Address (NIP-05)')}</ItemTitle>
|
||||
<Input
|
||||
value={nip05}
|
||||
onChange={(e) => {
|
||||
setNip05Error('')
|
||||
setNip05(e.target.value)
|
||||
setHasChanged(true)
|
||||
}}
|
||||
className={nip05Error ? 'border-destructive' : ''}
|
||||
/>
|
||||
{nip05Error && <div className="text-xs text-destructive pl-3">{nip05Error}</div>}
|
||||
</Item>
|
||||
</div>
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ children }: { children: React.ReactNode }) {
|
||||
return <div className="text-sm font-semibold text-muted-foreground pl-3">{children}</div>
|
||||
}
|
||||
|
||||
function Item({ children }: { children: React.ReactNode }) {
|
||||
return <div className="space-y-1">{children}</div>
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export default function ProfileListPage({ index }: { index?: number }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton>
|
||||
<SecondaryPageLayout index={index} title={title} displayScrollToTopButton>
|
||||
<div className="space-y-2 px-4">
|
||||
{Array.from(pubkeySet).map((pubkey, index) => (
|
||||
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||
|
||||
@@ -6,13 +6,14 @@ import ProfileBanner from '@/components/ProfileBanner'
|
||||
import PubkeyCopy from '@/components/PubkeyCopy'
|
||||
import QrCodePopover from '@/components/QrCodePopover'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchFollowings, useFetchProfile } from '@/hooks'
|
||||
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { toFollowingList } from '@/lib/link'
|
||||
import { toFollowingList, toProfileEditor } from '@/lib/link'
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
||||
import { useFeed } from '@/providers/FeedProvider'
|
||||
import { useFollowList } from '@/providers/FollowListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
@@ -22,6 +23,7 @@ import NotFoundPage from '../NotFoundPage'
|
||||
|
||||
export default function ProfilePage({ id, index }: { id?: string; index?: number }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { profile, isFetching } = useFetchProfile(id)
|
||||
const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey)
|
||||
const { relayUrls: currentRelayUrls } = useFeed()
|
||||
@@ -64,7 +66,7 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
|
||||
|
||||
const { banner, username, nip05, about, avatar, pubkey } = profile
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={username} displayScrollToTopButton>
|
||||
<SecondaryPageLayout index={index} title={username} displayScrollToTopButton>
|
||||
<div className="px-4">
|
||||
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
||||
<ProfileBanner
|
||||
@@ -85,7 +87,17 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
|
||||
{t('Follows you')}
|
||||
</div>
|
||||
)}
|
||||
<FollowButton pubkey={pubkey} />
|
||||
{isSelf ? (
|
||||
<Button
|
||||
className="w-20 min-w-20 rounded-full"
|
||||
variant="secondary"
|
||||
onClick={() => push(toProfileEditor())}
|
||||
>
|
||||
{t('Edit')}
|
||||
</Button>
|
||||
) : (
|
||||
<FollowButton pubkey={pubkey} />
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<div className="text-xl font-semibold">{username}</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function RelaySettingsPage({ index }: { index?: number }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Relay settings')}>
|
||||
<SecondaryPageLayout index={index} title={t('Relay settings')}>
|
||||
<Tabs defaultValue="relay-sets" className="px-4 space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="relay-sets">{t('Relay Sets')}</TabsTrigger>
|
||||
|
||||
@@ -4,18 +4,21 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { toRelaySettings } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useTheme } from '@/providers/ThemeProvider'
|
||||
import { TLanguage } from '@/types'
|
||||
import { SelectValue } from '@radix-ui/react-select'
|
||||
import { ChevronRight, Info, Languages, Server, SunMoon } from 'lucide-react'
|
||||
import { Check, ChevronRight, Copy, Info, KeyRound, Languages, Server, SunMoon } from 'lucide-react'
|
||||
import { forwardRef, HTMLProps, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function SettingsPage({ index }: { index?: number }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { nsec } = useNostr()
|
||||
const { push } = useSecondaryPage()
|
||||
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
||||
const { themeSetting, setThemeSetting } = useTheme()
|
||||
const [copiedNsec, setCopiedNsec] = useState(false)
|
||||
|
||||
const handleLanguageChange = (value: TLanguage) => {
|
||||
i18n.changeLanguage(value)
|
||||
@@ -23,7 +26,7 @@ export default function SettingsPage({ index }: { index?: number }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Settings')}>
|
||||
<SecondaryPageLayout index={index} title={t('Settings')}>
|
||||
<SettingItem>
|
||||
<div className="flex items-center gap-4">
|
||||
<Languages />
|
||||
@@ -62,6 +65,21 @@ export default function SettingsPage({ index }: { index?: number }) {
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</SettingItem>
|
||||
{!!nsec && (
|
||||
<SettingItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(nsec)
|
||||
setCopiedNsec(true)
|
||||
setTimeout(() => setCopiedNsec(false), 2000)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<KeyRound />
|
||||
<div>{t('Copy private key (nsec)')}</div>
|
||||
</div>
|
||||
{copiedNsec ? <Check /> : <Copy />}
|
||||
</SettingItem>
|
||||
)}
|
||||
<AboutInfoDialog>
|
||||
<SettingItem>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createFollowListDraftEvent } from '@/lib/draft-event'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import client from '@/services/client.service'
|
||||
import { TDraftEvent } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { useNostr } from './NostrProvider'
|
||||
|
||||
@@ -57,12 +56,10 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
|
||||
const follow = async (pubkey: string) => {
|
||||
if (isFetching || !accountPubkey) return
|
||||
|
||||
const newFollowListDraftEvent: TDraftEvent = {
|
||||
kind: kinds.Contacts,
|
||||
content: followListEvent?.content ?? '',
|
||||
created_at: dayjs().unix(),
|
||||
tags: (followListEvent?.tags ?? []).concat([['p', pubkey]])
|
||||
}
|
||||
const newFollowListDraftEvent = createFollowListDraftEvent(
|
||||
(followListEvent?.tags ?? []).concat([['p', pubkey]]),
|
||||
followListEvent?.content
|
||||
)
|
||||
const newFollowListEvent = await publish(newFollowListDraftEvent)
|
||||
client.updateFollowListCache(accountPubkey, newFollowListEvent)
|
||||
updateFollowListEvent(newFollowListEvent)
|
||||
@@ -72,14 +69,10 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
|
||||
const unfollow = async (pubkey: string) => {
|
||||
if (isFetching || !accountPubkey || !followListEvent) return
|
||||
|
||||
const newFollowListDraftEvent: TDraftEvent = {
|
||||
kind: kinds.Contacts,
|
||||
content: followListEvent.content ?? '',
|
||||
created_at: dayjs().unix(),
|
||||
tags: followListEvent.tags.filter(
|
||||
([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey
|
||||
)
|
||||
}
|
||||
const newFollowListDraftEvent = createFollowListDraftEvent(
|
||||
followListEvent.tags.filter(([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey),
|
||||
followListEvent.content
|
||||
)
|
||||
const newFollowListEvent = await publish(newFollowListDraftEvent)
|
||||
client.updateFollowListCache(accountPubkey, newFollowListEvent)
|
||||
updateFollowListEvent(newFollowListEvent)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import LoginDialog from '@/components/LoginDialog'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { useToast } from '@/hooks'
|
||||
import {
|
||||
getFollowingsFromFollowListEvent,
|
||||
getProfileFromProfileEvent,
|
||||
getRelayListFromRelayListEvent
|
||||
} from '@/lib/event'
|
||||
import { formatPubkey } from '@/lib/pubkey'
|
||||
import client from '@/services/client.service'
|
||||
import storage from '@/services/storage.service'
|
||||
import { ISigner, TAccount, TAccountPointer, TDraftEvent, TProfile, TRelayList } from '@/types'
|
||||
@@ -18,10 +20,12 @@ import { NsecSigner } from './nsec.signer'
|
||||
type TNostrContext = {
|
||||
pubkey: string | null
|
||||
profile: TProfile | null
|
||||
profileEvent: Event | null
|
||||
relayList: TRelayList | null
|
||||
followings: string[] | null
|
||||
account: TAccountPointer | null
|
||||
accounts: TAccountPointer[]
|
||||
nsec: string | null
|
||||
switchAccount: (account: TAccountPointer | null) => Promise<void>
|
||||
nsecLogin: (nsec: string) => Promise<string>
|
||||
nip07Login: () => Promise<string>
|
||||
@@ -38,6 +42,7 @@ type TNostrContext = {
|
||||
updateRelayListEvent: (relayListEvent: Event) => void
|
||||
getFollowings: (pubkey: string) => Promise<string[]>
|
||||
updateFollowListEvent: (followListEvent: Event) => void
|
||||
updateProfileEvent: (profileEvent: Event) => void
|
||||
}
|
||||
|
||||
const NostrContext = createContext<TNostrContext | undefined>(undefined)
|
||||
@@ -53,9 +58,11 @@ export const useNostr = () => {
|
||||
export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
const { toast } = useToast()
|
||||
const [account, setAccount] = useState<TAccountPointer | null>(null)
|
||||
const [nsec, setNsec] = useState<string | null>(null)
|
||||
const [signer, setSigner] = useState<ISigner | null>(null)
|
||||
const [openLoginDialog, setOpenLoginDialog] = useState(false)
|
||||
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||
const [profileEvent, setProfileEvent] = useState<Event | null>(null)
|
||||
const [relayList, setRelayList] = useState<TRelayList | null>(null)
|
||||
const [followings, setFollowings] = useState<string[] | null>(null)
|
||||
|
||||
@@ -71,43 +78,69 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setRelayList(null)
|
||||
setFollowings(null)
|
||||
setProfile(null)
|
||||
setProfileEvent(null)
|
||||
setNsec(null)
|
||||
if (!account) {
|
||||
setRelayList(null)
|
||||
setFollowings(null)
|
||||
setProfile(null)
|
||||
return
|
||||
}
|
||||
|
||||
const storedNsec = storage.getAccountNsec(account.pubkey)
|
||||
if (storedNsec) {
|
||||
setNsec(storedNsec)
|
||||
}
|
||||
const storedRelayListEvent = storage.getAccountRelayListEvent(account.pubkey)
|
||||
if (storedRelayListEvent) {
|
||||
setRelayList(
|
||||
storedRelayListEvent ? getRelayListFromRelayListEvent(storedRelayListEvent) : null
|
||||
)
|
||||
}
|
||||
const followListEvent = storage.getAccountFollowListEvent(account.pubkey)
|
||||
if (followListEvent) {
|
||||
setFollowings(getFollowingsFromFollowListEvent(followListEvent))
|
||||
const storedFollowListEvent = storage.getAccountFollowListEvent(account.pubkey)
|
||||
if (storedFollowListEvent) {
|
||||
setFollowings(getFollowingsFromFollowListEvent(storedFollowListEvent))
|
||||
}
|
||||
const profileEvent = storage.getAccountProfileEvent(account.pubkey)
|
||||
if (profileEvent) {
|
||||
setProfile(getProfileFromProfileEvent(profileEvent))
|
||||
const storedProfileEvent = storage.getAccountProfileEvent(account.pubkey)
|
||||
if (storedProfileEvent) {
|
||||
setProfileEvent(storedProfileEvent)
|
||||
setProfile(getProfileFromProfileEvent(storedProfileEvent))
|
||||
}
|
||||
client.fetchRelayListEvent(account.pubkey).then((relayListEvent) => {
|
||||
if (!relayListEvent) return
|
||||
client.fetchRelayListEvent(account.pubkey).then(async (relayListEvent) => {
|
||||
if (!relayListEvent) {
|
||||
if (storedRelayListEvent) return
|
||||
|
||||
setRelayList({ write: BIG_RELAY_URLS, read: BIG_RELAY_URLS })
|
||||
return
|
||||
}
|
||||
const isNew = storage.setAccountRelayListEvent(relayListEvent)
|
||||
if (!isNew) return
|
||||
setRelayList(getRelayListFromRelayListEvent(relayListEvent))
|
||||
})
|
||||
client.fetchFollowListEvent(account.pubkey).then((followListEvent) => {
|
||||
if (!followListEvent) return
|
||||
client.fetchFollowListEvent(account.pubkey).then(async (followListEvent) => {
|
||||
if (!followListEvent) {
|
||||
if (storedFollowListEvent) return
|
||||
|
||||
setFollowings([])
|
||||
return
|
||||
}
|
||||
const isNew = storage.setAccountFollowListEvent(followListEvent)
|
||||
if (!isNew) return
|
||||
setFollowings(getFollowingsFromFollowListEvent(followListEvent))
|
||||
})
|
||||
client.fetchProfileEvent(account.pubkey).then((profileEvent) => {
|
||||
if (!profileEvent) return
|
||||
client.fetchProfileEvent(account.pubkey).then(async (profileEvent) => {
|
||||
if (!profileEvent) {
|
||||
if (storedProfileEvent) return
|
||||
|
||||
setProfile({
|
||||
pubkey: account.pubkey,
|
||||
username: formatPubkey(account.pubkey)
|
||||
})
|
||||
return
|
||||
}
|
||||
const isNew = storage.setAccountProfileEvent(profileEvent)
|
||||
if (!isNew) return
|
||||
setProfileEvent(profileEvent)
|
||||
setProfile(getProfileFromProfileEvent(profileEvent))
|
||||
})
|
||||
}, [account])
|
||||
@@ -280,17 +313,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
setFollowings(getFollowingsFromFollowListEvent(followListEvent))
|
||||
}
|
||||
|
||||
const updateProfileEvent = (profileEvent: Event) => {
|
||||
const isNew = storage.setAccountProfileEvent(profileEvent)
|
||||
if (!isNew) return
|
||||
setProfileEvent(profileEvent)
|
||||
setProfile(getProfileFromProfileEvent(profileEvent))
|
||||
client.updateProfileCache(profileEvent)
|
||||
}
|
||||
|
||||
return (
|
||||
<NostrContext.Provider
|
||||
value={{
|
||||
pubkey: account?.pubkey ?? null,
|
||||
profile,
|
||||
profileEvent,
|
||||
relayList,
|
||||
followings,
|
||||
account,
|
||||
accounts: storage
|
||||
.getAccounts()
|
||||
.map((act) => ({ pubkey: act.pubkey, signerType: act.signerType })),
|
||||
nsec,
|
||||
switchAccount,
|
||||
nsecLogin,
|
||||
nip07Login,
|
||||
@@ -303,7 +346,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
getRelayList,
|
||||
updateRelayListEvent,
|
||||
getFollowings,
|
||||
updateFollowListEvent
|
||||
updateFollowListEvent,
|
||||
updateProfileEvent
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -4,6 +4,7 @@ import FollowingListPage from './pages/secondary/FollowingListPage'
|
||||
import HomePage from './pages/secondary/HomePage'
|
||||
import NoteListPage from './pages/secondary/NoteListPage'
|
||||
import NotePage from './pages/secondary/NotePage'
|
||||
import ProfileEditorPage from './pages/secondary/ProfileEditorPage'
|
||||
import ProfileListPage from './pages/secondary/ProfileListPage'
|
||||
import ProfilePage from './pages/secondary/ProfilePage'
|
||||
import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
|
||||
@@ -17,7 +18,8 @@ const ROUTES = [
|
||||
{ path: '/users/:id', element: <ProfilePage /> },
|
||||
{ path: '/users/:id/following', element: <FollowingListPage /> },
|
||||
{ path: '/relay-settings', element: <RelaySettingsPage /> },
|
||||
{ path: '/settings', element: <SettingsPage /> }
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{ path: '/profile-editor', element: <ProfileEditorPage /> }
|
||||
]
|
||||
|
||||
export const routes = ROUTES.map(({ path, element }) => ({
|
||||
|
||||
@@ -399,6 +399,11 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
updateProfileCache(event: NEvent) {
|
||||
this.profileEventDataloader.clear(event.pubkey)
|
||||
this.profileEventDataloader.prime(event.pubkey, Promise.resolve(event))
|
||||
}
|
||||
|
||||
async fetchProfiles(relayUrls: string[], filter: Filter): Promise<TProfile[]> {
|
||||
const events = await this.pool.querySync(relayUrls, {
|
||||
...filter,
|
||||
|
||||
@@ -151,6 +151,11 @@ class StorageService {
|
||||
return this.currentAccount
|
||||
}
|
||||
|
||||
getAccountNsec(pubkey: string) {
|
||||
const account = this.accounts.find((act) => act.pubkey === pubkey && act.signerType === 'nsec')
|
||||
return account?.nsec
|
||||
}
|
||||
|
||||
addAccount(account: TAccount) {
|
||||
if (this.accounts.find((act) => isSameAccount(act, account))) {
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Event } from 'nostr-tools'
|
||||
export type TProfile = {
|
||||
username: string
|
||||
pubkey: string
|
||||
original_username?: string
|
||||
banner?: string
|
||||
avatar?: string
|
||||
nip05?: string
|
||||
@@ -69,3 +70,9 @@ export type TFeedType = 'following' | 'relays' | 'temporary'
|
||||
export type TLanguage = 'en' | 'zh'
|
||||
|
||||
export type TImageInfo = { url: string; blurHash?: string; dim?: { width: number; height: number } }
|
||||
|
||||
export type TMailboxRelayScope = 'read' | 'write' | 'both'
|
||||
export type TMailboxRelay = {
|
||||
url: string
|
||||
scope: TMailboxRelayScope
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user