feat: zap (#107)

This commit is contained in:
Cody Tseng
2025-03-01 23:52:05 +08:00
committed by GitHub
parent 407a6fb802
commit 249593d547
72 changed files with 2582 additions and 818 deletions

View File

@@ -8,11 +8,11 @@ import { Separator } from '@/components/ui/separator'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import { SimpleUsername } from '@/components/Username'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { toProfile, toSettings } from '@/lib/link'
import { toProfile, toSettings, toWallet } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { ArrowDownUp, ChevronRight, LogOut, Settings, UserRound } from 'lucide-react'
import { ArrowDownUp, ChevronRight, LogOut, Settings, UserRound, Wallet } from 'lucide-react'
import { forwardRef, HTMLProps, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -54,6 +54,10 @@ const MePage = forwardRef((_, ref) => {
<UserRound />
{t('Profile')}
</Item>
<Item onClick={() => push(toWallet())}>
<Wallet />
{t('Wallet')}
</Item>
<Item onClick={() => setLoginDialogOpen(true)}>
<ArrowDownUp /> {t('Switch account')}
</Item>

View File

@@ -24,9 +24,7 @@ const NotificationListPage = forwardRef((_, ref) => {
titlebar={<NotificationListPageTitlebar />}
displayScrollToTopButton
>
<div className="px-4">
<NotificationList ref={notificationListRef} />
</div>
<NotificationList ref={notificationListRef} />
</PrimaryPageLayout>
)
})

View File

@@ -1,6 +1,6 @@
import NoteList from '@/components/NoteList'
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { useFetchRelayInfos, useSearchParams } from '@/hooks'
import { useFetchRelayInfos } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useFeed } from '@/providers/FeedProvider'
import { Filter } from 'nostr-tools'
@@ -11,7 +11,6 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const { relayUrls } = useFeed()
const { searchableRelayUrls } = useFetchRelayInfos(relayUrls)
const { searchParams } = useSearchParams()
const {
title = '',
filter,
@@ -21,6 +20,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
filter?: Filter
urls: string[]
}>(() => {
const searchParams = new URLSearchParams(window.location.search)
const hashtag = searchParams.get('t')
if (hashtag) {
return {
@@ -40,7 +40,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
}
}
return { urls: relayUrls }
}, [searchParams, JSON.stringify(relayUrls)])
}, [JSON.stringify(relayUrls)])
return (
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton>

View File

@@ -4,8 +4,8 @@ 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 { isEmail } from '@/lib/common'
import { createProfileDraftEvent } from '@/lib/draft-event'
import { generateImageByPubkey } from '@/lib/pubkey'
import { useSecondaryPage } from '@/PageManager'
@@ -24,6 +24,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const [about, setAbout] = useState<string>('')
const [nip05, setNip05] = useState<string>('')
const [nip05Error, setNip05Error] = useState<string>('')
const [lightningAddress, setLightningAddress] = useState<string>('')
const [lightningAddressError, setLightningAddressError] = useState<string>('')
const [hasChanged, setHasChanged] = useState(false)
const [saving, setSaving] = useState(false)
const [uploadingBanner, setUploadingBanner] = useState(false)
@@ -40,22 +42,38 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
setUsername(profile.original_username ?? '')
setAbout(profile.about ?? '')
setNip05(profile.nip05 ?? '')
setLightningAddress(profile.lightningAddress || '')
} else {
setBanner('')
setAvatar('')
setUsername('')
setAbout('')
setNip05('')
setLightningAddress('')
}
}, [profile])
if (!account || !profile) return null
const save = async () => {
if (nip05 && !EMAIL_REGEX.test(nip05)) {
if (nip05 && !isEmail(nip05)) {
setNip05Error(t('Invalid NIP-05 address'))
return
}
let lud06 = profile.lud06
let lud16 = profile.lud16
if (lightningAddress) {
if (isEmail(lightningAddress)) {
lud16 = lightningAddress
} else if (lightningAddress.startsWith('lnurl')) {
lud06 = lightningAddress
} else {
setLightningAddressError(t('Invalid Lightning Address'))
return
}
}
setSaving(true)
setHasChanged(false)
const oldProfileContent = profileEvent ? JSON.parse(profileEvent.content) : {}
@@ -67,7 +85,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
about,
nip05,
banner,
picture: avatar
picture: avatar,
lud06,
lud16
}
const profileDraftEvent = createProfileDraftEvent(
JSON.stringify(newProfileContent),
@@ -100,7 +120,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
return (
<SecondaryPageLayout ref={ref} 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">
<div className="relative bg-cover bg-center rounded-lg mb-2">
<Uploader
onUploadSuccess={onBannerUploadSuccess}
onUploadingChange={(uploading) => setTimeout(() => setUploadingBanner(uploading), 50)}
@@ -109,7 +129,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<ProfileBanner
banner={banner}
pubkey={account.pubkey}
className="w-full aspect-video object-cover"
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 ? (
@@ -170,6 +190,21 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
/>
{nip05Error && <div className="text-xs text-destructive pl-3">{nip05Error}</div>}
</Item>
<Item>
<ItemTitle>{t('Lightning Address (or LNURL)')}</ItemTitle>
<Input
value={lightningAddress}
onChange={(e) => {
setLightningAddressError('')
setLightningAddress(e.target.value)
setHasChanged(true)
}}
className={lightningAddressError ? 'border-destructive' : ''}
/>
{lightningAddressError && (
<div className="text-xs text-destructive pl-3">{lightningAddressError}</div>
)}
</Item>
</div>
</div>
</SecondaryPageLayout>
@@ -179,7 +214,7 @@ ProfileEditorPage.displayName = 'ProfileEditorPage'
export default ProfileEditorPage
function ItemTitle({ children }: { children: React.ReactNode }) {
return <div className="text-sm font-semibold text-muted-foreground pl-3">{children}</div>
return <div className="text-sm font-semibold text-muted-foreground">{children}</div>
}
function Item({ children }: { children: React.ReactNode }) {

View File

@@ -1,6 +1,6 @@
import UserItem from '@/components/UserItem'
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { useFetchRelayInfos, useSearchParams } from '@/hooks'
import { useFetchRelayInfos } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useFeed } from '@/providers/FeedProvider'
import client from '@/services/client.service'
@@ -13,7 +13,6 @@ const LIMIT = 50
const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const { searchParams } = useSearchParams()
const { relayUrls } = useFeed()
const { searchableRelayUrls } = useFetchRelayInfos(relayUrls)
const [until, setUntil] = useState<number>(() => dayjs().unix())
@@ -22,12 +21,13 @@ const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
const bottomRef = useRef<HTMLDivElement>(null)
const filter = useMemo(() => {
const f: Filter = { until }
const searchParams = new URLSearchParams(window.location.search)
const search = searchParams.get('s')
if (search) {
f.search = search
}
return f
}, [searchParams, until])
}, [until])
const urls = useMemo(() => {
return filter.search ? searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4) : relayUrls
}, [relayUrls, searchableRelayUrls, filter])

View File

@@ -4,6 +4,7 @@ import NoteList from '@/components/NoteList'
import ProfileAbout from '@/components/ProfileAbout'
import ProfileBanner from '@/components/ProfileBanner'
import ProfileOptions from '@/components/ProfileOptions'
import ProfileZapButton from '@/components/ProfileZapButton'
import PubkeyCopy from '@/components/PubkeyCopy'
import QrCodePopover from '@/components/QrCodePopover'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
@@ -18,7 +19,7 @@ import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
import { useFeed } from '@/providers/FeedProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Link } from 'lucide-react'
import { Link, Zap } from 'lucide-react'
import { forwardRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import NotFoundPage from '../NotFoundPage'
@@ -55,11 +56,13 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number },
if (!profile && isFetching) {
return (
<SecondaryPageLayout index={index} ref={ref}>
<div className="px-4">
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
<Skeleton className="w-full h-full object-cover rounded-lg" />
<Skeleton className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background rounded-full" />
<div className="sm:px-4">
<div className="relative bg-cover bg-center mb-2">
<Skeleton className="w-full aspect-video sm:rounded-lg" />
<Skeleton className="w-24 h-24 absolute bottom-0 left-3 translate-y-1/2 border-4 border-background rounded-full" />
</div>
</div>
<div className="px-4">
<Skeleton className="h-5 w-28 mt-14 mb-1" />
<Skeleton className="h-5 w-56 mt-2 my-1 rounded-full" />
</div>
@@ -68,29 +71,32 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number },
}
if (!profile) return <NotFoundPage />
const { banner, username, about, avatar, pubkey, website } = profile
const { banner, username, about, avatar, pubkey, website, lightningAddress } = profile
return (
<SecondaryPageLayout index={index} title={username} displayScrollToTopButton ref={ref}>
<div className="px-4">
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
<div className="sm:px-4">
<div className="relative bg-cover bg-center mb-2">
<ProfileBanner
banner={banner}
pubkey={pubkey}
className="w-full aspect-video object-cover"
className="w-full aspect-video sm:rounded-lg"
/>
<Avatar className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background">
<Avatar className="w-24 h-24 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background">
<AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultImage} />
</AvatarFallback>
</Avatar>
</div>
</div>
<div className="px-4">
<div className="flex justify-end h-8 gap-2 items-center">
{isFollowingYou && (
<div className="text-muted-foreground rounded-full bg-muted text-xs h-fit px-2">
{t('Follows you')}
</div>
)}
<ProfileOptions pubkey={pubkey} />
{isSelf ? (
<Button
className="w-20 min-w-20 rounded-full"
@@ -100,13 +106,21 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number },
{t('Edit')}
</Button>
) : (
<FollowButton pubkey={pubkey} />
<>
{!!lightningAddress && <ProfileZapButton pubkey={pubkey} />}
<FollowButton pubkey={pubkey} />
</>
)}
<ProfileOptions pubkey={pubkey} />
</div>
<div className="pt-2">
<div className="text-xl font-semibold">{username}</div>
<Nip05 pubkey={pubkey} />
{lightningAddress && (
<div className="text-sm text-yellow-400 flex gap-1 items-center">
<Zap className="size-4" />
{lightningAddress}
</div>
)}
<div className="flex gap-1 mt-1">
<PubkeyCopy pubkey={pubkey} />
<QrCodePopover pubkey={pubkey} />

View File

@@ -1,14 +1,25 @@
import AboutInfoDialog from '@/components/AboutInfoDialog'
import Donation from '@/components/Donation'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toRelaySettings } from '@/lib/link'
import { toRelaySettings, toWallet } 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 { Check, ChevronRight, Copy, Info, KeyRound, Languages, Server, SunMoon } from 'lucide-react'
import {
Check,
ChevronRight,
Copy,
Info,
KeyRound,
Languages,
Server,
SunMoon,
Wallet
} from 'lucide-react'
import { forwardRef, HTMLProps, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -66,6 +77,13 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</div>
<ChevronRight />
</SettingItem>
<SettingItem onClick={() => push(toWallet())}>
<div className="flex items-center gap-4">
<Wallet />
<div>{t('Wallet')}</div>
</div>
<ChevronRight />
</SettingItem>
{!!nsec && (
<SettingItem
onClick={() => {
@@ -110,6 +128,9 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</div>
</SettingItem>
</AboutInfoDialog>
<div className="px-4 mt-4">
<Donation />
</div>
</SecondaryPageLayout>
)
})

View File

@@ -0,0 +1,38 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useZap } from '@/providers/ZapProvider'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function DefaultZapAmountInput() {
const { t } = useTranslation()
const { defaultZapSats, updateDefaultSats } = useZap()
const [defaultZapAmountInput, setDefaultZapAmountInput] = useState(defaultZapSats)
return (
<div className="w-full space-y-1">
<Label htmlFor="default-zap-amount-input">{t('Default zap amount')}</Label>
<div className="flex w-full items-center gap-2">
<Input
id="default-zap-amount-input"
value={defaultZapAmountInput}
onChange={(e) => {
setDefaultZapAmountInput((pre) => {
if (e.target.value === '') {
return 0
}
let num = parseInt(e.target.value, 10)
if (isNaN(num) || num < 0) {
num = pre
}
return num
})
}}
onBlur={() => {
updateDefaultSats(defaultZapAmountInput)
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useZap } from '@/providers/ZapProvider'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function DefaultZapCommentInput() {
const { t } = useTranslation()
const { defaultZapComment, updateDefaultComment } = useZap()
const [defaultZapCommentInput, setDefaultZapCommentInput] = useState(defaultZapComment)
return (
<div className="w-full space-y-1">
<Label htmlFor="default-zap-comment-input">{t('Default zap comment')}</Label>
<div className="flex w-full items-center gap-2">
<Input
id="default-zap-comment-input"
value={defaultZapCommentInput}
onChange={(e) => setDefaultZapCommentInput(e.target.value)}
onBlur={() => {
updateDefaultComment(defaultZapCommentInput)
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/hooks'
import { isEmail } from '@/lib/common'
import { createProfileDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function LightningAddressInput() {
const { t } = useTranslation()
const { toast } = useToast()
const { profile, profileEvent, publish, updateProfileEvent } = useNostr()
const [lightningAddress, setLightningAddress] = useState('')
const [hasChanged, setHasChanged] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (profile) {
setLightningAddress(profile.lightningAddress || '')
}
}, [profile])
if (!profile || !profileEvent) {
return null
}
const handleSave = async () => {
setSaving(true)
let lud06 = profile.lud06
let lud16 = profile.lud16
if (lightningAddress.startsWith('lnurl')) {
lud06 = lightningAddress
} else if (isEmail(lightningAddress)) {
lud16 = lightningAddress
} else {
toast({
title: 'Invalid Lightning Address',
description: 'Please enter a valid Lightning Address or LNURL',
variant: 'destructive'
})
setSaving(false)
return
}
const oldProfileContent = profileEvent ? JSON.parse(profileEvent.content) : {}
const newProfileContent = {
...oldProfileContent,
lud06,
lud16
}
const profileDraftEvent = createProfileDraftEvent(
JSON.stringify(newProfileContent),
profileEvent?.tags
)
const newProfileEvent = await publish(profileDraftEvent)
await updateProfileEvent(newProfileEvent)
setSaving(false)
}
return (
<div className="w-full space-y-1">
<Label htmlFor="ln-address">{t('Lightning Address (or LNURL)')}</Label>
<div className="flex w-full items-center gap-2">
<Input
id="ln-address"
placeholder="xxxxxxxx@xxx.xxx"
value={lightningAddress}
onChange={(e) => {
setLightningAddress(e.target.value)
setHasChanged(true)
}}
/>
<Button onClick={handleSave} disabled={saving || !hasChanged} className="w-20">
{saving ? <Loader className="animate-spin" /> : 'Save'}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useZap } from '@/providers/ZapProvider'
import { useTranslation } from 'react-i18next'
export default function QuickZapSwitch() {
const { t } = useTranslation()
const { quickZap, updateQuickZap } = useZap()
return (
<div className="w-full flex justify-between items-center">
<Label htmlFor="quick-zap-switch">
<div className="text-base font-medium">{t('Quick zap')}</div>
<div className="text-muted-foreground text-sm">
{t('If enabled, you can zap with a single click')}
</div>
</Label>
<Switch id="quick-zap-switch" checked={quickZap} onCheckedChange={updateQuickZap} />
</div>
)
}

View File

@@ -0,0 +1,26 @@
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { Button as BcButton } from '@getalby/bitcoin-connect-react'
import { forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
import DefaultZapAmountInput from './DefaultZapAmountInput'
import DefaultZapCommentInput from './DefaultZapCommentInput'
import LightningAddressInput from './LightningAddressInput'
import QuickZapSwitch from './QuickZapSwitch'
const WalletPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Wallet')}>
<div className="px-4 pt-2 space-y-4">
<BcButton />
<LightningAddressInput />
<DefaultZapAmountInput />
<DefaultZapCommentInput />
<QuickZapSwitch />
</div>
</SecondaryPageLayout>
)
})
WalletPage.displayName = 'WalletPage'
export default WalletPage