diff --git a/src/components/AccountManager/GenerateNewAccount.tsx b/src/components/AccountManager/GenerateNewAccount.tsx new file mode 100644 index 0000000..02679a7 --- /dev/null +++ b/src/components/AccountManager/GenerateNewAccount.tsx @@ -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 ( + <> +
+ {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.' + )} +
+
+ + + +
+ + + + ) +} + +function generateNsec() { + const sk = generateSecretKey() + return nsecEncode(sk) +} diff --git a/src/components/AccountManager/index.tsx b/src/components/AccountManager/index.tsx index 1e33085..d6245f5 100644 --- a/src/components/AccountManager/index.tsx +++ b/src/components/AccountManager/index.tsx @@ -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(null) + const [page, setPage] = useState(null) return ( <> - {loginMethod === 'nsec' ? ( - setLoginMethod(null)} onLoginSuccess={() => close?.()} /> - ) : loginMethod === 'bunker' ? ( - setLoginMethod(null)} onLoginSuccess={() => close?.()} /> + {page === 'nsec' ? ( + setPage(null)} onLoginSuccess={() => close?.()} /> + ) : page === 'bunker' ? ( + setPage(null)} onLoginSuccess={() => close?.()} /> + ) : page === 'generate' ? ( + setPage(null)} onLoginSuccess={() => 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')} )} - - + +
+ {t("Don't have an account yet?")} +
+ {accounts.length > 0 && ( <> diff --git a/src/components/LoginDialog/index.tsx b/src/components/LoginDialog/index.tsx index a6c4387..af1f925 100644 --- a/src/components/LoginDialog/index.tsx +++ b/src/components/LoginDialog/index.tsx @@ -33,7 +33,7 @@ export default function LoginDialog({ return ( - + diff --git a/src/components/MailboxSetting/MailboxRelay.tsx b/src/components/MailboxSetting/MailboxRelay.tsx index 07063d7..6b71745 100644 --- a/src/components/MailboxSetting/MailboxRelay.tsx +++ b/src/components/MailboxSetting/MailboxRelay.tsx @@ -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, diff --git a/src/components/MailboxSetting/SaveButton.tsx b/src/components/MailboxSetting/SaveButton.tsx index 1d87c8e..8e53733 100644 --- a/src/components/MailboxSetting/SaveButton.tsx +++ b/src/components/MailboxSetting/SaveButton.tsx @@ -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({ diff --git a/src/components/MailboxSetting/index.tsx b/src/components/MailboxSetting/index.tsx index 6145f1a..645225a 100644 --- a/src/components/MailboxSetting/index.tsx +++ b/src/components/MailboxSetting/index.tsx @@ -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() diff --git a/src/components/MailboxSetting/types.ts b/src/components/MailboxSetting/types.ts deleted file mode 100644 index 31a11cf..0000000 --- a/src/components/MailboxSetting/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type TMailboxRelayScope = 'read' | 'write' | 'both' -export type TMailboxRelay = { - url: string - scope: TMailboxRelayScope -} diff --git a/src/components/PostEditor/NormalPostContent.tsx b/src/components/PostEditor/NormalPostContent.tsx index 001b3fd..8768a6e 100644 --- a/src/components/PostEditor/NormalPostContent.tsx +++ b/src/components/PostEditor/NormalPostContent.tsx @@ -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/*" + > + + - - - ) - } - return ( - <> -
- {uploading ? : } -
+
+ {children} - +
) } diff --git a/src/constants.ts b/src/constants.ts index af2b7c0..ae3362f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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,}$/ diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 24ce366..7c5fec1 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -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(null) const [profile, setProfile] = useState(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 } } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index ae3605e..553ec09 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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)' } } diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 4208976..7746ce3 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -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)' } } diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index f2b5dea..5332539 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -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({ }} > @@ -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 ( - - {content} + + {title} +
{controls}
) } @@ -125,11 +133,9 @@ export function SecondaryPageTitlebar({ return (
- {content} -
-
- + {title}
+
{controls}
) } diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 2b7e1f8..a7198ce 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -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) diff --git a/src/lib/event.ts b/src/lib/event.ts index e7fb50b..d8ad877 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -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 diff --git a/src/lib/link.ts b/src/lib/link.ts index a941fb3..9d2b307 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -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}` diff --git a/src/pages/secondary/FollowingListPage/index.tsx b/src/pages/secondary/FollowingListPage/index.tsx index f2d0ce9..185e101 100644 --- a/src/pages/secondary/FollowingListPage/index.tsx +++ b/src/pages/secondary/FollowingListPage/index.tsx @@ -46,7 +46,7 @@ export default function FollowingListPage({ id, index }: { id?: string; index?: return ( +
Loading...
diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index 742bc58..e783c2f 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -43,7 +43,7 @@ export default function NoteListPage({ index }: { index?: number }) { }, [searchParams, relayUrlsString]) return ( - + ) diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 79e2e2c..34ff444 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -26,7 +26,7 @@ export default function NotePage({ id, index }: { id?: string; index?: number }) if (!event && isFetching) { return ( - +
@@ -37,7 +37,7 @@ export default function NotePage({ id, index }: { id?: string; index?: number }) if (isPictureEvent(event) && isSmallScreen) { return ( - + +
{rootEventId !== parentEventId && ( diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx new file mode 100644 index 0000000..a616bcc --- /dev/null +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -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('') + const [avatar, setAvatar] = useState('') + const [username, setUsername] = useState('') + const [about, setAbout] = useState('') + const [nip05, setNip05] = useState('') + const [nip05Error, setNip05Error] = useState('') + 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 = ( +
+ +
+ ) + + return ( + +
+
+ setTimeout(() => setUploadingBanner(uploading), 50)} + className="w-full relative cursor-pointer" + > + +
+ {uploadingBanner ? ( + + ) : ( + + )} +
+
+ 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" + > + + + + + + +
+ {uploadingAvatar ? : } +
+
+
+
+ + {t('Display Name')} + { + setUsername(e.target.value) + setHasChanged(true) + }} + /> + + + {t('Bio')} +