feat: generate new account & profile editor

This commit is contained in:
codytseng
2025-01-14 18:09:31 +08:00
parent 3f031da748
commit 78629dd64f
33 changed files with 535 additions and 142 deletions

View 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)
}

View File

@@ -1,34 +1,38 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { TSignerType } from '@/types'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AccountList from '../AccountList' import AccountList from '../AccountList'
import BunkerLogin from './BunkerLogin' import BunkerLogin from './BunkerLogin'
import PrivateKeyLogin from './NsecLogin' import PrivateKeyLogin from './NsecLogin'
import GenerateNewAccount from './GenerateNewAccount'
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | null
export default function AccountManager({ close }: { close?: () => void }) { export default function AccountManager({ close }: { close?: () => void }) {
const [loginMethod, setLoginMethod] = useState<TSignerType | null>(null) const [page, setPage] = useState<TAccountManagerPage>(null)
return ( return (
<> <>
{loginMethod === 'nsec' ? ( {page === 'nsec' ? (
<PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} /> <PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : loginMethod === 'bunker' ? ( ) : page === 'bunker' ? (
<BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} /> <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({ function AccountManagerNav({
setLoginMethod, setPage,
close close
}: { }: {
setLoginMethod: (method: TSignerType) => void setPage: (page: TAccountManagerPage) => void
close?: () => void close?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -44,12 +48,19 @@ function AccountManagerNav({
{t('Login with Browser Extension')} {t('Login with Browser Extension')}
</Button> </Button>
)} )}
<Button variant="secondary" onClick={() => setLoginMethod('bunker')} className="w-full"> <Button variant="secondary" onClick={() => setPage('bunker')} className="w-full">
{t('Login with Bunker')} {t('Login with Bunker')}
</Button> </Button>
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full"> <Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
{t('Login with Private Key')} {t('Login with Private Key')}
</Button> </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 && ( {accounts.length > 0 && (
<> <>
<Separator /> <Separator />

View File

@@ -33,7 +33,7 @@ export default function LoginDialog({
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-96 max-h-[90vh] overflow-auto"> <DialogContent className="w-[520px] max-h-[90vh] overflow-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="hidden" /> <DialogTitle className="hidden" />
<DialogDescription className="hidden" /> <DialogDescription className="hidden" />

View File

@@ -6,10 +6,10 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import { TMailboxRelay, TMailboxRelayScope } from '@/types'
import { CircleX, Server } from 'lucide-react' import { CircleX, Server } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { TMailboxRelay, TMailboxRelayScope } from './types'
export default function MailboxRelay({ export default function MailboxRelay({
mailboxRelay, mailboxRelay,

View File

@@ -1,11 +1,10 @@
import { useToast } from '@/hooks' import { useToast } from '@/hooks'
import { createRelayListDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import dayjs from 'dayjs' import { TMailboxRelay } from '@/types'
import { CloudUpload, Loader } from 'lucide-react' import { CloudUpload, Loader } from 'lucide-react'
import { kinds } from 'nostr-tools'
import { useState } from 'react' import { useState } from 'react'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import { TMailboxRelay } from './types'
export default function SaveButton({ export default function SaveButton({
mailboxRelays, mailboxRelays,
@@ -24,14 +23,7 @@ export default function SaveButton({
if (!pubkey) return if (!pubkey) return
setPushing(true) setPushing(true)
const event = { const event = createRelayListDraftEvent(mailboxRelays)
kind: kinds.RelayList,
content: '',
tags: mailboxRelays.map(({ url, scope }) =>
scope === 'both' ? ['r', url] : ['r', url, scope]
),
created_at: dayjs().unix()
}
const relayListEvent = await publish(event) const relayListEvent = await publish(event)
updateRelayListEvent(relayListEvent) updateRelayListEvent(relayListEvent)
toast({ toast({

View File

@@ -1,12 +1,12 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { TMailboxRelay, TMailboxRelayScope } from '@/types'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import MailboxRelay from './MailboxRelay' import MailboxRelay from './MailboxRelay'
import NewMailboxRelayInput from './NewMailboxRelayInput' import NewMailboxRelayInput from './NewMailboxRelayInput'
import SaveButton from './SaveButton' import SaveButton from './SaveButton'
import { TMailboxRelay, TMailboxRelayScope } from './types'
export default function MailboxSetting() { export default function MailboxSetting() {
const { t } = useTranslation() const { t } = useTranslation()

View File

@@ -1,5 +0,0 @@
export type TMailboxRelayScope = 'read' | 'write' | 'both'
export type TMailboxRelay = {
url: string
scope: TMailboxRelayScope
}

View File

@@ -7,7 +7,7 @@ import { useToast } from '@/hooks/use-toast'
import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event' import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' 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 { Event, kinds } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -32,6 +32,7 @@ export default function NormalPostContent({
const [posting, setPosting] = useState(false) const [posting, setPosting] = useState(false)
const [showMoreOptions, setShowMoreOptions] = useState(false) const [showMoreOptions, setShowMoreOptions] = useState(false)
const [addClientTag, setAddClientTag] = useState(false) const [addClientTag, setAddClientTag] = useState(false)
const [uploadingPicture, setUploadingPicture] = useState(false)
const canPost = !!content && !posting const canPost = !!content && !posting
useEffect(() => { useEffect(() => {
@@ -116,7 +117,13 @@ export default function NormalPostContent({
setPictureInfos((prev) => [...prev, { url, tags }]) setPictureInfos((prev) => [...prev, { url, tags }])
setContent((prev) => `${prev}\n${url}`) 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 <Button
variant="link" variant="link"
className="text-foreground gap-0 px-0" className="text-foreground gap-0 px-0"

View File

@@ -5,8 +5,9 @@ import { Textarea } from '@/components/ui/textarea'
import { StorageKey } from '@/constants' import { StorageKey } from '@/constants'
import { useToast } from '@/hooks/use-toast' import { useToast } from '@/hooks/use-toast'
import { createPictureNoteDraftEvent } from '@/lib/draft-event' import { createPictureNoteDraftEvent } from '@/lib/draft-event'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' 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 { Dispatch, SetStateAction, useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -177,6 +178,7 @@ function PictureUploader({
> >
}) { }) {
const [index, setIndex] = useState(-1) const [index, setIndex] = useState(-1)
const [uploading, setUploading] = useState(false)
return ( return (
<> <>
@@ -203,11 +205,20 @@ function PictureUploader({
</div> </div>
))} ))}
<Uploader <Uploader
variant="big"
onUploadSuccess={({ url, tags }) => { onUploadSuccess={({ url, tags }) => {
setPictureInfos((prev) => [...prev, { 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> </div>
{index >= 0 && {index >= 0 &&
createPortal( createPortal(

View File

@@ -1,19 +1,21 @@
import { Button } from '@/components/ui/button'
import { useToast } from '@/hooks/use-toast' import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ImageUp, Loader, LoaderCircle, Plus } from 'lucide-react' import { useRef } from 'react'
import { useRef, useState } from 'react'
import { z } from 'zod' import { z } from 'zod'
export default function Uploader({ export default function Uploader({
children,
onUploadSuccess, onUploadSuccess,
variant = 'button' onUploadingChange,
className,
accept = 'image/*'
}: { }: {
children: React.ReactNode
onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void 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 { signHttpAuth } = useNostr()
const { toast } = useToast() const { toast } = useToast()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -26,7 +28,7 @@ export default function Uploader({
formData.append('file', file) formData.append('file', file)
try { try {
setUploading(true) onUploadingChange?.(true)
const url = 'https://nostr.build/api/v2/nip96/upload' const url = 'https://nostr.build/api/v2/nip96/upload'
const auth = await signHttpAuth(url, 'POST') const auth = await signHttpAuth(url, 'POST')
const response = await fetch(url, { const response = await fetch(url, {
@@ -60,7 +62,7 @@ export default function Uploader({
fileInputRef.current.value = '' fileInputRef.current.value = ''
} }
} finally { } finally {
setUploading(false) onUploadingChange?.(false)
} }
} }
@@ -71,41 +73,16 @@ export default function Uploader({
} }
} }
if (variant === 'button') {
return ( return (
<> <div onClick={handleUploadClick} className={className}>
<Button variant="secondary" onClick={handleUploadClick} disabled={uploading}> {children}
{uploading ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
</Button>
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleFileChange} onChange={handleFileChange}
accept="image/*,video/*,audio/*" accept={accept}
/> />
</>
)
}
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>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
accept="image/*"
/>
</>
) )
} }

View File

@@ -24,3 +24,4 @@ export const PICTURE_EVENT_KIND = 20
export const COMMENT_EVENT_KIND = 1111 export const COMMENT_EVENT_KIND = 1111
export const URL_REGEX = /(https?:\/\/[^\s"']+)/g export const URL_REGEX = /(https?:\/\/[^\s"']+)/g
export const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/

View File

@@ -1,11 +1,15 @@
import { userIdToPubkey } from '@/lib/pubkey'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TProfile } from '@/types' import { TProfile } from '@/types'
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
export function useFetchProfile(id?: string) { export function useFetchProfile(id?: string) {
const { profile: currentAccountProfile } = useNostr()
const [isFetching, setIsFetching] = useState(true) const [isFetching, setIsFetching] = useState(true)
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [profile, setProfile] = useState<TProfile | null>(null) const [profile, setProfile] = useState<TProfile | null>(null)
const pubkey = useMemo(() => (id ? userIdToPubkey(id) : undefined), [id])
useEffect(() => { useEffect(() => {
const fetchProfile = async () => { const fetchProfile = async () => {
@@ -31,5 +35,11 @@ export function useFetchProfile(id?: string) {
fetchProfile() fetchProfile()
}, [id]) }, [id])
useEffect(() => {
if (currentAccountProfile && pubkey === currentAccountProfile.pubkey) {
setProfile(currentAccountProfile)
}
}, [currentAccountProfile])
return { isFetching, error, profile } return { isFetching, error, profile }
} }

View File

@@ -127,6 +127,17 @@ export default {
'write relays description': 'write relays description':
'Write relays are used to publish your events. Other users will seek your events from your write relays.', 'Write relays are used to publish your events. Other users will seek your events from your write relays.',
'read & write relays notice': '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)'
} }
} }

View File

@@ -127,6 +127,17 @@ export default {
'读服务器用于寻找与您有关的事件。其他用户会将想要你看到的事件发布到您的读服务器。', '读服务器用于寻找与您有关的事件。其他用户会将想要你看到的事件发布到您的读服务器。',
'write relays description': '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)'
} }
} }

View File

@@ -1,7 +1,6 @@
import BackButton from '@/components/BackButton' import BackButton from '@/components/BackButton'
import BottomNavigationBar from '@/components/BottomNavigationBar' import BottomNavigationBar from '@/components/BottomNavigationBar'
import ScrollToTopButton from '@/components/ScrollToTopButton' import ScrollToTopButton from '@/components/ScrollToTopButton'
import ThemeToggle from '@/components/ThemeToggle'
import { Titlebar } from '@/components/Titlebar' import { Titlebar } from '@/components/Titlebar'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
@@ -11,13 +10,15 @@ import { useEffect, useRef, useState } from 'react'
export default function SecondaryPageLayout({ export default function SecondaryPageLayout({
children, children,
index, index,
titlebarContent, title,
controls,
hideBackButton = false, hideBackButton = false,
displayScrollToTopButton = false displayScrollToTopButton = false
}: { }: {
children?: React.ReactNode children?: React.ReactNode
index?: number index?: number
titlebarContent?: React.ReactNode title?: React.ReactNode
controls?: React.ReactNode
hideBackButton?: boolean hideBackButton?: boolean
displayScrollToTopButton?: boolean displayScrollToTopButton?: boolean
}): JSX.Element { }): JSX.Element {
@@ -90,7 +91,8 @@ export default function SecondaryPageLayout({
}} }}
> >
<SecondaryPageTitlebar <SecondaryPageTitlebar
content={titlebarContent} title={title}
controls={controls}
hideBackButton={hideBackButton} hideBackButton={hideBackButton}
visible={visible} visible={visible}
/> />
@@ -104,11 +106,13 @@ export default function SecondaryPageLayout({
} }
export function SecondaryPageTitlebar({ export function SecondaryPageTitlebar({
content, title,
controls,
hideBackButton = false, hideBackButton = false,
visible = true visible = true
}: { }: {
content?: React.ReactNode title?: React.ReactNode
controls?: React.ReactNode
hideBackButton?: boolean hideBackButton?: boolean
visible?: boolean visible?: boolean
}): JSX.Element { }): JSX.Element {
@@ -116,8 +120,12 @@ export function SecondaryPageTitlebar({
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<Titlebar className="h-12 flex gap-1 p-1 items-center font-semibold" visible={visible}> <Titlebar
<BackButton hide={hideBackButton}>{content}</BackButton> 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> </Titlebar>
) )
} }
@@ -125,11 +133,9 @@ export function SecondaryPageTitlebar({
return ( return (
<Titlebar className="h-12 flex gap-1 p-1 justify-between items-center font-semibold"> <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"> <div className="flex items-center gap-1 flex-1 w-0">
<BackButton hide={hideBackButton}>{content}</BackButton> <BackButton hide={hideBackButton}>{title}</BackButton>
</div>
<div className="flex-shrink-0 flex items-center">
<ThemeToggle />
</div> </div>
<div className="flex-shrink-0">{controls}</div>
</Titlebar> </Titlebar>
) )
} }

View File

@@ -1,5 +1,5 @@
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants' 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 dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { 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[][] }[]) { function generateImetaTags(imageUrls: string[], pictureInfos: { url: string; tags: string[][] }[]) {
return imageUrls.map((imageUrl) => { return imageUrls.map((imageUrl) => {
const pictureInfo = pictureInfos.find((info) => info.url === imageUrl) const pictureInfo = pictureInfos.find((info) => info.url === imageUrl)

View File

@@ -110,15 +110,16 @@ export function getRelayListFromRelayListEvent(event?: Event) {
export function getProfileFromProfileEvent(event: Event) { export function getProfileFromProfileEvent(event: Event) {
try { try {
const profileObj = JSON.parse(event.content) const profileObj = JSON.parse(event.content)
const username =
profileObj.display_name?.trim() ||
profileObj.name?.trim() ||
profileObj.nip05?.split('@')[0]?.trim()
return { return {
pubkey: event.pubkey, pubkey: event.pubkey,
banner: profileObj.banner, banner: profileObj.banner,
avatar: profileObj.picture, avatar: profileObj.picture,
username: username: username || formatPubkey(event.pubkey),
profileObj.display_name?.trim() || original_username: username,
profileObj.name?.trim() ||
profileObj.nip05?.split('@')[0]?.trim() ||
formatPubkey(event.pubkey),
nip05: profileObj.nip05, nip05: profileObj.nip05,
about: profileObj.about, about: profileObj.about,
created_at: event.created_at created_at: event.created_at

View File

@@ -39,6 +39,7 @@ export const toFollowingList = (pubkey: string) => {
} }
export const toRelaySettings = () => '/relay-settings' export const toRelaySettings = () => '/relay-settings'
export const toSettings = () => '/settings' export const toSettings = () => '/settings'
export const toProfileEditor = () => '/profile-editor'
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}` export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}` export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`

View File

@@ -46,7 +46,7 @@ export default function FollowingListPage({ id, index }: { id?: string; index?:
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
index={index} index={index}
titlebarContent={ title={
profile?.username profile?.username
? t("username's following", { username: profile.username }) ? t("username's following", { username: profile.username })
: t('Following') : t('Following')

View File

@@ -2,7 +2,7 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
export default function LoadingPage({ title, index }: { title?: string; index?: number }) { export default function LoadingPage({ title, index }: { title?: string; index?: number }) {
return ( return (
<SecondaryPageLayout index={index} titlebarContent={title}> <SecondaryPageLayout index={index} title={title}>
<div className="text-muted-foreground text-center"> <div className="text-muted-foreground text-center">
<div>Loading...</div> <div>Loading...</div>
</div> </div>

View File

@@ -43,7 +43,7 @@ export default function NoteListPage({ index }: { index?: number }) {
}, [searchParams, relayUrlsString]) }, [searchParams, relayUrlsString])
return ( return (
<SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton> <SecondaryPageLayout index={index} title={title} displayScrollToTopButton>
<NoteList key={title} filter={filter} relayUrls={urls} /> <NoteList key={title} filter={filter} relayUrls={urls} />
</SecondaryPageLayout> </SecondaryPageLayout>
) )

View File

@@ -26,7 +26,7 @@ export default function NotePage({ id, index }: { id?: string; index?: number })
if (!event && isFetching) { if (!event && isFetching) {
return ( return (
<SecondaryPageLayout index={index} titlebarContent={t('Note')}> <SecondaryPageLayout index={index} title={t('Note')}>
<div className="px-4"> <div className="px-4">
<Skeleton className="w-10 h-10 rounded-full" /> <Skeleton className="w-10 h-10 rounded-full" />
</div> </div>
@@ -37,7 +37,7 @@ export default function NotePage({ id, index }: { id?: string; index?: number })
if (isPictureEvent(event) && isSmallScreen) { if (isPictureEvent(event) && isSmallScreen) {
return ( return (
<SecondaryPageLayout index={index} titlebarContent={t('Note')} displayScrollToTopButton> <SecondaryPageLayout index={index} title={t('Note')} displayScrollToTopButton>
<PictureNote key={`note-${event.id}`} event={event} fetchNoteStats /> <PictureNote key={`note-${event.id}`} event={event} fetchNoteStats />
<Separator className="mb-2 mt-4" /> <Separator className="mb-2 mt-4" />
<Nip22ReplyNoteList <Nip22ReplyNoteList
@@ -50,7 +50,7 @@ export default function NotePage({ id, index }: { id?: string; index?: number })
} }
return ( return (
<SecondaryPageLayout index={index} titlebarContent={t('Note')} displayScrollToTopButton> <SecondaryPageLayout index={index} title={t('Note')} displayScrollToTopButton>
<div className="px-4"> <div className="px-4">
{rootEventId !== parentEventId && ( {rootEventId !== parentEventId && (
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} /> <ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />

View 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>
}

View File

@@ -80,7 +80,7 @@ export default function ProfileListPage({ index }: { index?: number }) {
} }
return ( return (
<SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton> <SecondaryPageLayout index={index} title={title} displayScrollToTopButton>
<div className="space-y-2 px-4"> <div className="space-y-2 px-4">
{Array.from(pubkeySet).map((pubkey, index) => ( {Array.from(pubkeySet).map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} /> <UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />

View File

@@ -6,13 +6,14 @@ import ProfileBanner from '@/components/ProfileBanner'
import PubkeyCopy from '@/components/PubkeyCopy' import PubkeyCopy from '@/components/PubkeyCopy'
import QrCodePopover from '@/components/QrCodePopover' import QrCodePopover from '@/components/QrCodePopover'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFetchFollowings, useFetchProfile } from '@/hooks' import { useFetchFollowings, useFetchProfile } from '@/hooks'
import { useFetchRelayList } from '@/hooks/useFetchRelayList' import { useFetchRelayList } from '@/hooks/useFetchRelayList'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toFollowingList } from '@/lib/link' import { toFollowingList, toProfileEditor } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
import { SecondaryPageLink } from '@/PageManager' import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import { useFollowList } from '@/providers/FollowListProvider' import { useFollowList } from '@/providers/FollowListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@@ -22,6 +23,7 @@ import NotFoundPage from '../NotFoundPage'
export default function ProfilePage({ id, index }: { id?: string; index?: number }) { export default function ProfilePage({ id, index }: { id?: string; index?: number }) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage()
const { profile, isFetching } = useFetchProfile(id) const { profile, isFetching } = useFetchProfile(id)
const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey) const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey)
const { relayUrls: currentRelayUrls } = useFeed() 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 const { banner, username, nip05, about, avatar, pubkey } = profile
return ( return (
<SecondaryPageLayout index={index} titlebarContent={username} displayScrollToTopButton> <SecondaryPageLayout index={index} title={username} displayScrollToTopButton>
<div className="px-4"> <div className="px-4">
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2"> <div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
<ProfileBanner <ProfileBanner
@@ -85,7 +87,17 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
{t('Follows you')} {t('Follows you')}
</div> </div>
)} )}
{isSelf ? (
<Button
className="w-20 min-w-20 rounded-full"
variant="secondary"
onClick={() => push(toProfileEditor())}
>
{t('Edit')}
</Button>
) : (
<FollowButton pubkey={pubkey} /> <FollowButton pubkey={pubkey} />
)}
</div> </div>
<div className="pt-2"> <div className="pt-2">
<div className="text-xl font-semibold">{username}</div> <div className="text-xl font-semibold">{username}</div>

View File

@@ -8,7 +8,7 @@ export default function RelaySettingsPage({ index }: { index?: number }) {
const { t } = useTranslation() const { t } = useTranslation()
return ( 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"> <Tabs defaultValue="relay-sets" className="px-4 space-y-4">
<TabsList> <TabsList>
<TabsTrigger value="relay-sets">{t('Relay Sets')}</TabsTrigger> <TabsTrigger value="relay-sets">{t('Relay Sets')}</TabsTrigger>

View File

@@ -4,18 +4,21 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toRelaySettings } from '@/lib/link' import { toRelaySettings } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useTheme } from '@/providers/ThemeProvider' import { useTheme } from '@/providers/ThemeProvider'
import { TLanguage } from '@/types' import { TLanguage } from '@/types'
import { SelectValue } from '@radix-ui/react-select' 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 { forwardRef, HTMLProps, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function SettingsPage({ index }: { index?: number }) { export default function SettingsPage({ index }: { index?: number }) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { nsec } = useNostr()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage) const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
const { themeSetting, setThemeSetting } = useTheme() const { themeSetting, setThemeSetting } = useTheme()
const [copiedNsec, setCopiedNsec] = useState(false)
const handleLanguageChange = (value: TLanguage) => { const handleLanguageChange = (value: TLanguage) => {
i18n.changeLanguage(value) i18n.changeLanguage(value)
@@ -23,7 +26,7 @@ export default function SettingsPage({ index }: { index?: number }) {
} }
return ( return (
<SecondaryPageLayout index={index} titlebarContent={t('Settings')}> <SecondaryPageLayout index={index} title={t('Settings')}>
<SettingItem> <SettingItem>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Languages /> <Languages />
@@ -62,6 +65,21 @@ export default function SettingsPage({ index }: { index?: number }) {
</div> </div>
<ChevronRight /> <ChevronRight />
</SettingItem> </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> <AboutInfoDialog>
<SettingItem> <SettingItem>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

View File

@@ -1,8 +1,7 @@
import { createFollowListDraftEvent } from '@/lib/draft-event'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TDraftEvent } from '@/types' import { Event } from 'nostr-tools'
import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useMemo, useState } from 'react' import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
@@ -57,12 +56,10 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
const follow = async (pubkey: string) => { const follow = async (pubkey: string) => {
if (isFetching || !accountPubkey) return if (isFetching || !accountPubkey) return
const newFollowListDraftEvent: TDraftEvent = { const newFollowListDraftEvent = createFollowListDraftEvent(
kind: kinds.Contacts, (followListEvent?.tags ?? []).concat([['p', pubkey]]),
content: followListEvent?.content ?? '', followListEvent?.content
created_at: dayjs().unix(), )
tags: (followListEvent?.tags ?? []).concat([['p', pubkey]])
}
const newFollowListEvent = await publish(newFollowListDraftEvent) const newFollowListEvent = await publish(newFollowListDraftEvent)
client.updateFollowListCache(accountPubkey, newFollowListEvent) client.updateFollowListCache(accountPubkey, newFollowListEvent)
updateFollowListEvent(newFollowListEvent) updateFollowListEvent(newFollowListEvent)
@@ -72,14 +69,10 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
const unfollow = async (pubkey: string) => { const unfollow = async (pubkey: string) => {
if (isFetching || !accountPubkey || !followListEvent) return if (isFetching || !accountPubkey || !followListEvent) return
const newFollowListDraftEvent: TDraftEvent = { const newFollowListDraftEvent = createFollowListDraftEvent(
kind: kinds.Contacts, followListEvent.tags.filter(([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey),
content: followListEvent.content ?? '', followListEvent.content
created_at: dayjs().unix(),
tags: followListEvent.tags.filter(
([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey
) )
}
const newFollowListEvent = await publish(newFollowListDraftEvent) const newFollowListEvent = await publish(newFollowListDraftEvent)
client.updateFollowListCache(accountPubkey, newFollowListEvent) client.updateFollowListCache(accountPubkey, newFollowListEvent)
updateFollowListEvent(newFollowListEvent) updateFollowListEvent(newFollowListEvent)

View File

@@ -1,10 +1,12 @@
import LoginDialog from '@/components/LoginDialog' import LoginDialog from '@/components/LoginDialog'
import { BIG_RELAY_URLS } from '@/constants'
import { useToast } from '@/hooks' import { useToast } from '@/hooks'
import { import {
getFollowingsFromFollowListEvent, getFollowingsFromFollowListEvent,
getProfileFromProfileEvent, getProfileFromProfileEvent,
getRelayListFromRelayListEvent getRelayListFromRelayListEvent
} from '@/lib/event' } from '@/lib/event'
import { formatPubkey } from '@/lib/pubkey'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/storage.service' import storage from '@/services/storage.service'
import { ISigner, TAccount, TAccountPointer, TDraftEvent, TProfile, TRelayList } from '@/types' import { ISigner, TAccount, TAccountPointer, TDraftEvent, TProfile, TRelayList } from '@/types'
@@ -18,10 +20,12 @@ import { NsecSigner } from './nsec.signer'
type TNostrContext = { type TNostrContext = {
pubkey: string | null pubkey: string | null
profile: TProfile | null profile: TProfile | null
profileEvent: Event | null
relayList: TRelayList | null relayList: TRelayList | null
followings: string[] | null followings: string[] | null
account: TAccountPointer | null account: TAccountPointer | null
accounts: TAccountPointer[] accounts: TAccountPointer[]
nsec: string | null
switchAccount: (account: TAccountPointer | null) => Promise<void> switchAccount: (account: TAccountPointer | null) => Promise<void>
nsecLogin: (nsec: string) => Promise<string> nsecLogin: (nsec: string) => Promise<string>
nip07Login: () => Promise<string> nip07Login: () => Promise<string>
@@ -38,6 +42,7 @@ type TNostrContext = {
updateRelayListEvent: (relayListEvent: Event) => void updateRelayListEvent: (relayListEvent: Event) => void
getFollowings: (pubkey: string) => Promise<string[]> getFollowings: (pubkey: string) => Promise<string[]>
updateFollowListEvent: (followListEvent: Event) => void updateFollowListEvent: (followListEvent: Event) => void
updateProfileEvent: (profileEvent: Event) => void
} }
const NostrContext = createContext<TNostrContext | undefined>(undefined) const NostrContext = createContext<TNostrContext | undefined>(undefined)
@@ -53,9 +58,11 @@ export const useNostr = () => {
export function NostrProvider({ children }: { children: React.ReactNode }) { export function NostrProvider({ children }: { children: React.ReactNode }) {
const { toast } = useToast() const { toast } = useToast()
const [account, setAccount] = useState<TAccountPointer | null>(null) const [account, setAccount] = useState<TAccountPointer | null>(null)
const [nsec, setNsec] = useState<string | null>(null)
const [signer, setSigner] = useState<ISigner | null>(null) const [signer, setSigner] = useState<ISigner | null>(null)
const [openLoginDialog, setOpenLoginDialog] = useState(false) const [openLoginDialog, setOpenLoginDialog] = useState(false)
const [profile, setProfile] = useState<TProfile | null>(null) const [profile, setProfile] = useState<TProfile | null>(null)
const [profileEvent, setProfileEvent] = useState<Event | null>(null)
const [relayList, setRelayList] = useState<TRelayList | null>(null) const [relayList, setRelayList] = useState<TRelayList | null>(null)
const [followings, setFollowings] = useState<string[] | null>(null) const [followings, setFollowings] = useState<string[] | null>(null)
@@ -71,43 +78,69 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!account) {
setRelayList(null) setRelayList(null)
setFollowings(null) setFollowings(null)
setProfile(null) setProfile(null)
setProfileEvent(null)
setNsec(null)
if (!account) {
return return
} }
const storedNsec = storage.getAccountNsec(account.pubkey)
if (storedNsec) {
setNsec(storedNsec)
}
const storedRelayListEvent = storage.getAccountRelayListEvent(account.pubkey) const storedRelayListEvent = storage.getAccountRelayListEvent(account.pubkey)
if (storedRelayListEvent) { if (storedRelayListEvent) {
setRelayList( setRelayList(
storedRelayListEvent ? getRelayListFromRelayListEvent(storedRelayListEvent) : null storedRelayListEvent ? getRelayListFromRelayListEvent(storedRelayListEvent) : null
) )
} }
const followListEvent = storage.getAccountFollowListEvent(account.pubkey) const storedFollowListEvent = storage.getAccountFollowListEvent(account.pubkey)
if (followListEvent) { if (storedFollowListEvent) {
setFollowings(getFollowingsFromFollowListEvent(followListEvent)) setFollowings(getFollowingsFromFollowListEvent(storedFollowListEvent))
} }
const profileEvent = storage.getAccountProfileEvent(account.pubkey) const storedProfileEvent = storage.getAccountProfileEvent(account.pubkey)
if (profileEvent) { if (storedProfileEvent) {
setProfile(getProfileFromProfileEvent(profileEvent)) setProfileEvent(storedProfileEvent)
setProfile(getProfileFromProfileEvent(storedProfileEvent))
}
client.fetchRelayListEvent(account.pubkey).then(async (relayListEvent) => {
if (!relayListEvent) {
if (storedRelayListEvent) return
setRelayList({ write: BIG_RELAY_URLS, read: BIG_RELAY_URLS })
return
} }
client.fetchRelayListEvent(account.pubkey).then((relayListEvent) => {
if (!relayListEvent) return
const isNew = storage.setAccountRelayListEvent(relayListEvent) const isNew = storage.setAccountRelayListEvent(relayListEvent)
if (!isNew) return if (!isNew) return
setRelayList(getRelayListFromRelayListEvent(relayListEvent)) setRelayList(getRelayListFromRelayListEvent(relayListEvent))
}) })
client.fetchFollowListEvent(account.pubkey).then((followListEvent) => { client.fetchFollowListEvent(account.pubkey).then(async (followListEvent) => {
if (!followListEvent) return if (!followListEvent) {
if (storedFollowListEvent) return
setFollowings([])
return
}
const isNew = storage.setAccountFollowListEvent(followListEvent) const isNew = storage.setAccountFollowListEvent(followListEvent)
if (!isNew) return if (!isNew) return
setFollowings(getFollowingsFromFollowListEvent(followListEvent)) setFollowings(getFollowingsFromFollowListEvent(followListEvent))
}) })
client.fetchProfileEvent(account.pubkey).then((profileEvent) => { client.fetchProfileEvent(account.pubkey).then(async (profileEvent) => {
if (!profileEvent) return if (!profileEvent) {
if (storedProfileEvent) return
setProfile({
pubkey: account.pubkey,
username: formatPubkey(account.pubkey)
})
return
}
const isNew = storage.setAccountProfileEvent(profileEvent) const isNew = storage.setAccountProfileEvent(profileEvent)
if (!isNew) return if (!isNew) return
setProfileEvent(profileEvent)
setProfile(getProfileFromProfileEvent(profileEvent)) setProfile(getProfileFromProfileEvent(profileEvent))
}) })
}, [account]) }, [account])
@@ -280,17 +313,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setFollowings(getFollowingsFromFollowListEvent(followListEvent)) setFollowings(getFollowingsFromFollowListEvent(followListEvent))
} }
const updateProfileEvent = (profileEvent: Event) => {
const isNew = storage.setAccountProfileEvent(profileEvent)
if (!isNew) return
setProfileEvent(profileEvent)
setProfile(getProfileFromProfileEvent(profileEvent))
client.updateProfileCache(profileEvent)
}
return ( return (
<NostrContext.Provider <NostrContext.Provider
value={{ value={{
pubkey: account?.pubkey ?? null, pubkey: account?.pubkey ?? null,
profile, profile,
profileEvent,
relayList, relayList,
followings, followings,
account, account,
accounts: storage accounts: storage
.getAccounts() .getAccounts()
.map((act) => ({ pubkey: act.pubkey, signerType: act.signerType })), .map((act) => ({ pubkey: act.pubkey, signerType: act.signerType })),
nsec,
switchAccount, switchAccount,
nsecLogin, nsecLogin,
nip07Login, nip07Login,
@@ -303,7 +346,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
getRelayList, getRelayList,
updateRelayListEvent, updateRelayListEvent,
getFollowings, getFollowings,
updateFollowListEvent updateFollowListEvent,
updateProfileEvent
}} }}
> >
{children} {children}

View File

@@ -4,6 +4,7 @@ import FollowingListPage from './pages/secondary/FollowingListPage'
import HomePage from './pages/secondary/HomePage' import HomePage from './pages/secondary/HomePage'
import NoteListPage from './pages/secondary/NoteListPage' import NoteListPage from './pages/secondary/NoteListPage'
import NotePage from './pages/secondary/NotePage' import NotePage from './pages/secondary/NotePage'
import ProfileEditorPage from './pages/secondary/ProfileEditorPage'
import ProfileListPage from './pages/secondary/ProfileListPage' import ProfileListPage from './pages/secondary/ProfileListPage'
import ProfilePage from './pages/secondary/ProfilePage' import ProfilePage from './pages/secondary/ProfilePage'
import RelaySettingsPage from './pages/secondary/RelaySettingsPage' import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
@@ -17,7 +18,8 @@ const ROUTES = [
{ path: '/users/:id', element: <ProfilePage /> }, { path: '/users/:id', element: <ProfilePage /> },
{ path: '/users/:id/following', element: <FollowingListPage /> }, { path: '/users/:id/following', element: <FollowingListPage /> },
{ path: '/relay-settings', element: <RelaySettingsPage /> }, { 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 }) => ({ export const routes = ROUTES.map(({ path, element }) => ({

View File

@@ -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[]> { async fetchProfiles(relayUrls: string[], filter: Filter): Promise<TProfile[]> {
const events = await this.pool.querySync(relayUrls, { const events = await this.pool.querySync(relayUrls, {
...filter, ...filter,

View File

@@ -151,6 +151,11 @@ class StorageService {
return this.currentAccount return this.currentAccount
} }
getAccountNsec(pubkey: string) {
const account = this.accounts.find((act) => act.pubkey === pubkey && act.signerType === 'nsec')
return account?.nsec
}
addAccount(account: TAccount) { addAccount(account: TAccount) {
if (this.accounts.find((act) => isSameAccount(act, account))) { if (this.accounts.find((act) => isSameAccount(act, account))) {
return return

View File

@@ -3,6 +3,7 @@ import { Event } from 'nostr-tools'
export type TProfile = { export type TProfile = {
username: string username: string
pubkey: string pubkey: string
original_username?: string
banner?: string banner?: string
avatar?: string avatar?: string
nip05?: string nip05?: string
@@ -69,3 +70,9 @@ export type TFeedType = 'following' | 'relays' | 'temporary'
export type TLanguage = 'en' | 'zh' export type TLanguage = 'en' | 'zh'
export type TImageInfo = { url: string; blurHash?: string; dim?: { width: number; height: number } } 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
}