Compare commits

...

4 Commits

Author SHA1 Message Date
lemon 3ebf0a29b5 Explain profile wallet payment type 2026-05-27 23:57:35 -07:00
lemon e6acb04398 Use user icon for profile wallet fallback 2026-05-27 23:57:35 -07:00
lemon 8d5cd78b04 Add common profile translation 2026-05-27 23:57:35 -07:00
lemon 0b4454a4c2 Add profile wallet option for campaigns 2026-05-27 23:57:35 -07:00
21 changed files with 277 additions and 145 deletions
+73 -8
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { Loader2, Search, PartyPopper } from 'lucide-react';
import { Loader2, Search, PartyPopper, X } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -8,7 +8,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useToast } from '@/hooks/useToast';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { useAuthor } from '@/hooks/useAuthor';
import { getPubkeyFromNip19, useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { useSearchPeopleLists, type PeopleListSearchResult } from '@/hooks/useSearchPeopleLists';
import { parseAuthorEvent } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
@@ -44,10 +45,12 @@ export function PersonSearch({
onAdd,
onAddMany,
excludePubkeys,
showPeopleLists = true,
}: {
onAdd: (profile: SearchProfile) => void;
onAddMany: (profiles: SearchProfile[], sourceTitle?: string) => void;
onAddMany?: (profiles: SearchProfile[], sourceTitle?: string) => void;
excludePubkeys: string[];
showPeopleLists?: boolean;
}) {
const { nostr } = useNostr();
const { toast } = useToast();
@@ -57,7 +60,7 @@ export function PersonSearch({
const inputRef = useRef<HTMLInputElement>(null);
const { data: profiles, isFetching } = useSearchProfiles(query);
const { data: peopleLists, isFetching: isFetchingPeopleLists } = useSearchPeopleLists(query);
const { data: peopleLists, isFetching: isFetchingPeopleLists } = useSearchPeopleLists(query, showPeopleLists);
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
const filteredProfiles = useMemo(
@@ -65,10 +68,22 @@ export function PersonSearch({
[profiles, excludeSet],
);
const filteredPeopleLists = useMemo(
() => (peopleLists ?? []).filter((pack) => pack.pubkeys.some((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey.toLowerCase()))),
[peopleLists, excludeSet],
() => showPeopleLists
? (peopleLists ?? []).filter((pack) => pack.pubkeys.some((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey.toLowerCase())))
: [],
[peopleLists, excludeSet, showPeopleLists],
);
const hasResults = filteredProfiles.length > 0 || filteredPeopleLists.length > 0;
const directPubkey = useMemo(() => getPubkeyFromNip19(query), [query]);
const directProfile = directPubkey && !excludeSet.has(directPubkey)
? filteredProfiles.find((profile) => profile.pubkey === directPubkey) ?? makeFallbackProfile(directPubkey)
: undefined;
const visibleProfiles = useMemo(
() => directProfile
? [directProfile, ...filteredProfiles.filter((profile) => profile.pubkey !== directProfile.pubkey)]
: filteredProfiles,
[directProfile, filteredProfiles],
);
const hasResults = visibleProfiles.length > 0 || filteredPeopleLists.length > 0;
const isSearching = isFetching || isFetchingPeopleLists || isAddingPack;
useEffect(() => {
@@ -98,6 +113,8 @@ export function PersonSearch({
return;
}
if (!onAddMany) return;
if (eligiblePubkeys.length > 20 && !window.confirm(`Add ${eligiblePubkeys.length} people from ${pack.title}?`)) {
return;
}
@@ -147,6 +164,12 @@ export function PersonSearch({
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && directProfile) {
e.preventDefault();
handleSelect(directProfile);
}
}}
onFocus={() => {
if (query.trim().length > 0 && hasResults) {
setDropdownOpen(true);
@@ -168,7 +191,7 @@ export function PersonSearch({
>
{hasResults ? (
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredProfiles.map((profile) => (
{visibleProfiles.map((profile) => (
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
))}
{filteredPeopleLists.map((pack) => (
@@ -185,6 +208,48 @@ export function PersonSearch({
);
}
export function SelectedPersonRow({
profile,
onRemove,
removeAriaLabel,
}: {
profile: SearchProfile;
onRemove: () => void;
removeAriaLabel: string;
}) {
const author = useAuthor(profile.pubkey);
const metadata = author.data?.metadata ?? profile.metadata;
const event = author.data?.event ?? profile.event;
const displayName = metadata.display_name || metadata.name || genUserName(profile.pubkey);
const picture = sanitizeUrl(metadata.picture);
return (
<div className="rounded-lg bg-secondary/30 p-2.5">
<div className="flex items-center gap-3">
<Avatar className="size-8 shrink-0">
{picture && <AvatarImage src={picture} alt="" />}
<AvatarFallback className="text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
<EmojifiedText tags={event.tags}>{displayName}</EmojifiedText>
</div>
</div>
<button
type="button"
onClick={onRemove}
aria-label={removeAriaLabel}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<X className="size-4" />
</button>
</div>
</div>
);
}
/** A follow pack / follow set search result row. */
function PeopleListSearchResultItem({ pack, onClick }: { pack: PeopleListSearchResult; onClick: (pack: PeopleListSearchResult) => void }) {
return (
+2 -2
View File
@@ -47,7 +47,7 @@ function getAddressKey(event: NostrEvent): string {
}
/** Search NIP-51 starter packs and follow sets by title. */
export function useSearchPeopleLists(query: string) {
export function useSearchPeopleLists(query: string, enabled = true) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const debouncedQuery = useDebounce(query, 300);
@@ -94,7 +94,7 @@ export function useSearchPeopleLists(query: string) {
return matches.slice(0, 5);
},
enabled: trimmedQuery.length >= 2,
enabled: enabled && trimmedQuery.length >= 2,
staleTime: 30 * 1000,
placeholderData: (prev) => prev,
});
+1 -1
View File
@@ -13,7 +13,7 @@ export interface SearchProfile {
event: NostrEvent;
}
function getPubkeyFromNip19(value: string): string | undefined {
export function getPubkeyFromNip19(value: string): string | undefined {
const trimmed = value.trim().replace(/^nostr:/, '');
if (!trimmed) return undefined;
+3 -1
View File
@@ -33,7 +33,8 @@
"countryFilterAriaLabel": "تصفية حسب البلد",
"countrySearchPlaceholder": "ابحث عن البلدان…",
"countryNoResults": "لم يتم العثور على بلدان.",
"countryGlobal": "عالمي"
"countryGlobal": "عالمي",
"profile": "الملف الشخصي"
},
"translate": {
"translate": "ترجمة",
@@ -422,6 +423,7 @@
"acceptAll": "قبول جميع أنواع الدفع",
"acceptPublic": "قبول الدفعات العامة فقط",
"acceptPrivate": "قبول الدفعات الخاصة فقط",
"profileWalletPublicOnly": "محافظ الملفات الشخصية تستقبل الدفعات العامة على السلسلة فقط.",
"customWalletIntro": "أدخل عنوان بيتكوين، رمز دفع صامت، أو كليهما. يلزم واحد على الأقل.",
"bitcoinAddress": "عنوان بيتكوين",
"bitcoinAddressPlaceholder": "bc1q… أو bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "Filter by country",
"countrySearchPlaceholder": "Search countries\u2026",
"countryNoResults": "No countries found.",
"countryGlobal": "Global"
"countryGlobal": "Global",
"profile": "Profile"
},
"translate": {
"translate": "Translate",
@@ -857,6 +858,7 @@
"acceptAll": "Accept all payment types",
"acceptPublic": "Accept public payments only",
"acceptPrivate": "Accept private payments only",
"profileWalletPublicOnly": "Profile wallets receive public on-chain payments only.",
"customWalletIntro": "Enter a Bitcoin address, a silent-payment code, or both. At least one is required.",
"bitcoinAddress": "Bitcoin address",
"bitcoinAddressPlaceholder": "bc1q… or bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "Filtrar por país",
"countrySearchPlaceholder": "Buscar países…",
"countryNoResults": "No se encontraron países.",
"countryGlobal": "Global"
"countryGlobal": "Global",
"profile": "Perfil"
},
"translate": {
"translate": "Traducir",
@@ -434,6 +435,7 @@
"acceptAll": "Aceptar todos los pagos",
"acceptPublic": "Aceptar solo pagos públicos",
"acceptPrivate": "Aceptar solo pagos privados",
"profileWalletPublicOnly": "Las billeteras de perfil solo reciben pagos públicos on-chain.",
"customWalletIntro": "Ingresa una dirección de Bitcoin, un código de pago silencioso o ambos. Se requiere al menos uno.",
"bitcoinAddress": "Dirección de Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… o bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "فیلتر بر اساس کشور",
"countrySearchPlaceholder": "جستجوی کشورها…",
"countryNoResults": "هیچ کشوری پیدا نشد.",
"countryGlobal": "جهانی"
"countryGlobal": "جهانی",
"profile": "نمایه"
},
"translate": {
"translate": "ترجمه",
@@ -434,6 +435,7 @@
"acceptAll": "پذیرش همهٔ نوع‌های پرداخت",
"acceptPublic": "پذیرش فقط پرداخت‌های عمومی",
"acceptPrivate": "پذیرش فقط پرداخت‌های خصوصی",
"profileWalletPublicOnly": "کیف‌پول‌های نمایه فقط پرداخت‌های عمومی درون‌زنجیره‌ای را دریافت می‌کنند.",
"customWalletIntro": "یک نشانی بیت‌کوین، یک کد پرداخت بی‌صدا یا هر دو را وارد کن. حداقل یکی الزامی است.",
"bitcoinAddress": "نشانی بیت‌کوین",
"bitcoinAddressPlaceholder": "bc1q… یا bc1p…",
+3 -1
View File
@@ -36,7 +36,8 @@
"countryFilterAriaLabel": "Filtrer par pays",
"countrySearchPlaceholder": "Rechercher des pays…",
"countryNoResults": "Aucun pays trouvé.",
"countryGlobal": "Mondial"
"countryGlobal": "Mondial",
"profile": "Profil"
},
"translate": {
"translate": "Traduire",
@@ -856,6 +857,7 @@
"acceptAll": "Accepter tous les types de paiement",
"acceptPublic": "Accepter uniquement les paiements publics",
"acceptPrivate": "Accepter uniquement les paiements privés",
"profileWalletPublicOnly": "Les portefeuilles de profil reçoivent uniquement des paiements publics on-chain.",
"customWalletIntro": "Saisissez une adresse Bitcoin, un code de paiement silencieux, ou les deux. Au moins un est obligatoire.",
"bitcoinAddress": "Adresse Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… ou bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "देश के अनुसार फ़िल्टर करें",
"countrySearchPlaceholder": "देश खोजें…",
"countryNoResults": "कोई देश नहीं मिला.",
"countryGlobal": "वैश्विक"
"countryGlobal": "वैश्विक",
"profile": "प्रोफ़ाइल"
},
"translate": {
"translate": "अनुवाद करें",
@@ -866,6 +867,7 @@
"acceptAll": "सभी भुगतान प्रकार स्वीकार करें",
"acceptPublic": "केवल सार्वजनिक भुगतान स्वीकार करें",
"acceptPrivate": "केवल निजी भुगतान स्वीकार करें",
"profileWalletPublicOnly": "प्रोफ़ाइल वॉलेट केवल सार्वजनिक ऑन-चेन भुगतान प्राप्त करते हैं।",
"customWalletIntro": "एक Bitcoin एड्रेस, एक साइलेंट-पेमेंट कोड, या दोनों दर्ज करें। कम से कम एक ज़रूरी है।",
"bitcoinAddress": "Bitcoin एड्रेस",
"bitcoinAddressPlaceholder": "bc1q… या bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "Saring berdasarkan negara",
"countrySearchPlaceholder": "Cari negara…",
"countryNoResults": "Tidak ada negara yang ditemukan.",
"countryGlobal": "Global"
"countryGlobal": "Global",
"profile": "Profil"
},
"translate": {
"translate": "Terjemahkan",
@@ -866,6 +867,7 @@
"acceptAll": "Terima semua jenis pembayaran",
"acceptPublic": "Hanya terima pembayaran publik",
"acceptPrivate": "Hanya terima pembayaran privat",
"profileWalletPublicOnly": "Dompet profil hanya menerima pembayaran publik on-chain.",
"customWalletIntro": "Masukkan alamat Bitcoin, kode silent-payment, atau keduanya. Setidaknya satu wajib diisi.",
"bitcoinAddress": "Alamat Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… atau bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "ច្រោះតាមប្រទេស",
"countrySearchPlaceholder": "ស្វែងរកប្រទេស…",
"countryNoResults": "រកមិនឃើញប្រទេសទេ។",
"countryGlobal": "សកល"
"countryGlobal": "សកល",
"profile": "ប្រវត្តិរូប"
},
"translate": {
"translate": "បកប្រែ",
@@ -434,6 +435,7 @@
"acceptAll": "ទទួលយកការទូទាត់គ្រប់ប្រភេទ",
"acceptPublic": "ទទួលយកការទូទាត់សាធារណៈតែប៉ុណ្ណោះ",
"acceptPrivate": "ទទួលយកការទូទាត់ឯកជនតែប៉ុណ្ណោះ",
"profileWalletPublicOnly": "កាបូបប្រវត្តិរូបទទួលបានតែការទូទាត់សាធារណៈនៅលើខ្សែសង្វាក់ប៉ុណ្ណោះ។",
"customWalletIntro": "បញ្ចូលអាសយដ្ឋានប៊ីតខញ លេខកូដបង់ប្រាក់ស្ងាត់ ឬទាំងពីរ។ ត្រូវការយ៉ាងហោចណាស់មួយ។",
"bitcoinAddress": "អាសយដ្ឋានប៊ីតខញ",
"bitcoinAddressPlaceholder": "bc1q… ឬ bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "د هیواد له مخې فلټر",
"countrySearchPlaceholder": "هیوادونه ولټوئ…",
"countryNoResults": "هیڅ هیواد ونه موندل شو.",
"countryGlobal": "نړیوال"
"countryGlobal": "نړیوال",
"profile": "پروفایل"
},
"translate": {
"translate": "ژباړل",
@@ -434,6 +435,7 @@
"acceptAll": "د ټولو پیسو ډولونو منل",
"acceptPublic": "یوازې د عامه پیسو منل",
"acceptPrivate": "یوازې د خصوصي پیسو منل",
"profileWalletPublicOnly": "د پروفایل والټونه یوازې عامه پر زنځیر پیسې ترلاسه کوي.",
"customWalletIntro": "د بټ‌کوین پته، د چپ پیسو کوډ، یا دواړه دننه کړئ. لږ تر لږه یو ته اړتیا ده.",
"bitcoinAddress": "د بټ‌کوین پته",
"bitcoinAddressPlaceholder": "bc1q… یا bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "Filtrar por país",
"countrySearchPlaceholder": "Pesquisar países…",
"countryNoResults": "Nenhum país encontrado.",
"countryGlobal": "Global"
"countryGlobal": "Global",
"profile": "Perfil"
},
"translate": {
"translate": "Traduzir",
@@ -866,6 +867,7 @@
"acceptAll": "Aceitar todos os tipos de pagamento",
"acceptPublic": "Aceitar apenas pagamentos públicos",
"acceptPrivate": "Aceitar apenas pagamentos privados",
"profileWalletPublicOnly": "Carteiras de perfil recebem apenas pagamentos públicos on-chain.",
"customWalletIntro": "Digite um endereço Bitcoin, um código de pagamento silencioso, ou ambos. Pelo menos um é obrigatório.",
"bitcoinAddress": "Endereço Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… ou bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "Фильтр по стране",
"countrySearchPlaceholder": "Поиск стран…",
"countryNoResults": "Страны не найдены.",
"countryGlobal": "Глобально"
"countryGlobal": "Глобально",
"profile": "Профиль"
},
"translate": {
"translate": "Перевести",
@@ -866,6 +867,7 @@
"acceptAll": "Принимать все типы платежей",
"acceptPublic": "Принимать только публичные платежи",
"acceptPrivate": "Принимать только приватные платежи",
"profileWalletPublicOnly": "Кошельки профиля принимают только публичные ончейн-платежи.",
"customWalletIntro": "Введите Bitcoin-адрес, код тихого платежа или оба. Требуется хотя бы один.",
"bitcoinAddress": "Bitcoin-адрес",
"bitcoinAddressPlaceholder": "bc1q… или bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "Sefa nenyika",
"countrySearchPlaceholder": "Tsvaga nyika…",
"countryNoResults": "Hapana nyika dzakawanikwa.",
"countryGlobal": "Pasi rose"
"countryGlobal": "Pasi rose",
"profile": "Profile"
},
"translate": {
"translate": "Dudzira",
@@ -434,6 +435,7 @@
"acceptAll": "Gamuchira mhando dzese dzemubhadharo",
"acceptPublic": "Gamuchira chete mibhadharo yepachena",
"acceptPrivate": "Gamuchira chete mibhadharo yakavanzika",
"profileWalletPublicOnly": "Mawallet eprofile anogamuchira chete mibhadharo yepachena yeon-chain.",
"customWalletIntro": "Isa kero yeBitcoin, kodhi yemubhadharo unyararo, kana zvose. Imwechete inodikanwa zvirinani.",
"bitcoinAddress": "Kero yeBitcoin",
"bitcoinAddressPlaceholder": "bc1q… kana bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "Chuja kwa nchi",
"countrySearchPlaceholder": "Tafuta nchi…",
"countryNoResults": "Hakuna nchi zilizopatikana.",
"countryGlobal": "Kimataifa"
"countryGlobal": "Kimataifa",
"profile": "Wasifu"
},
"translate": {
"translate": "Tafsiri",
@@ -865,6 +866,7 @@
"acceptAll": "Kubali aina zote za malipo",
"acceptPublic": "Kubali malipo ya umma pekee",
"acceptPrivate": "Kubali malipo ya faragha pekee",
"profileWalletPublicOnly": "Pochi za wasifu hupokea malipo ya umma ya on-chain pekee.",
"customWalletIntro": "Weka anwani ya Bitcoin, msimbo wa malipo ya kimya, au zote mbili. Angalau moja inahitajika.",
"bitcoinAddress": "Anwani ya Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… au bc1p…",
+3 -1
View File
@@ -36,7 +36,8 @@
"countryFilterAriaLabel": "Ülkeye göre filtrele",
"countrySearchPlaceholder": "Ülke ara…",
"countryNoResults": "Ülke bulunamadı.",
"countryGlobal": "Küresel"
"countryGlobal": "Küresel",
"profile": "Profil"
},
"translate": {
"translate": "Çevir",
@@ -865,6 +866,7 @@
"acceptAll": "Tüm ödeme türlerini kabul et",
"acceptPublic": "Yalnızca açık ödemeleri kabul et",
"acceptPrivate": "Yalnızca gizli ödemeleri kabul et",
"profileWalletPublicOnly": "Profil cüzdanları yalnızca herkese açık zincir üstü ödemeler alır.",
"customWalletIntro": "Bir Bitcoin adresi, bir sessiz ödeme kodu ya da her ikisini birden girin. En az biri zorunludur.",
"bitcoinAddress": "Bitcoin adresi",
"bitcoinAddressPlaceholder": "bc1q… veya bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "依國家篩選",
"countrySearchPlaceholder": "搜尋國家…",
"countryNoResults": "找不到國家。",
"countryGlobal": "全球"
"countryGlobal": "全球",
"profile": "個人資料"
},
"translate": {
"translate": "翻譯",
@@ -434,6 +435,7 @@
"acceptAll": "接受所有支付型別",
"acceptPublic": "僅接受公開支付",
"acceptPrivate": "僅接受私密支付",
"profileWalletPublicOnly": "個人資料錢包僅接收公開鏈上支付。",
"customWalletIntro": "輸入比特幣地址、靜默支付代碼或兩者皆可。至少需要一個。",
"bitcoinAddress": "比特幣地址",
"bitcoinAddressPlaceholder": "bc1q… 或 bc1p…",
+3 -1
View File
@@ -37,7 +37,8 @@
"countryFilterAriaLabel": "按国家筛选",
"countrySearchPlaceholder": "搜索国家…",
"countryNoResults": "未找到国家。",
"countryGlobal": "全球"
"countryGlobal": "全球",
"profile": "个人资料"
},
"translate": {
"translate": "翻译",
@@ -434,6 +435,7 @@
"acceptAll": "接受所有支付类型",
"acceptPublic": "仅接受公开支付",
"acceptPrivate": "仅接受私密支付",
"profileWalletPublicOnly": "个人资料钱包仅接收公开链上支付。",
"customWalletIntro": "输入比特币地址、静默支付代码或两者皆可。至少需要一个。",
"bitcoinAddress": "比特币地址",
"bitcoinAddressPlaceholder": "bc1q… 或 bc1p…",
+148 -73
View File
@@ -13,6 +13,7 @@ import {
HelpCircle,
Loader2,
MapPin,
User,
Wallet,
X,
} from 'lucide-react';
@@ -21,6 +22,7 @@ import { CoverImageField } from '@/components/CoverImageField';
import { CountryFlag } from '@/components/CountryFlag';
import { FormSection } from '@/components/FormSection';
import { OrganizationContextChip } from '@/components/OrganizationContextChip';
import { PersonSearch } from '@/components/PersonSearch';
import { LoginArea } from '@/components/auth/LoginArea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -44,6 +46,7 @@ import { useHdWallet } from '@/hooks/useHdWallet';
import { useManageableOrganizations } from '@/hooks/useManageableOrganizations';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import type { SearchProfile } from '@/hooks/useSearchProfiles';
import {
CAMPAIGN_KIND,
encodeCampaignNaddr,
@@ -51,6 +54,7 @@ import {
parseCampaignWallet,
slugifyCampaignIdentifier,
} from '@/lib/campaign';
import { nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
import { getTodayDateInput } from '@/lib/dateInput';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { genUserName } from '@/lib/genUserName';
@@ -68,6 +72,8 @@ interface EditTarget {
relays?: string[];
}
type WalletSource = 'mine' | 'profile' | 'custom';
function getEditTarget(value: string | null): EditTarget | null {
if (!value) return null;
@@ -142,7 +148,8 @@ export function CreateCampaignPage() {
* Wallet form state.
*
* - {@link walletSource} picks the top-level source: `'mine'` (the
* user's HD wallet, only selectable when nsec is available) or
* user's HD wallet, only selectable when nsec is available),
* `'profile'` (derive a Taproot address from a selected npub), or
* `'custom'` (paste any mainnet bech32(m) endpoint).
* - {@link mineAccept} picks which donation types the HD-wallet
* campaign accepts:
@@ -165,7 +172,7 @@ export function CreateCampaignPage() {
* where the hook resolves a tick later, but the common nsec-login
* path no longer flickers through `'custom'` on mount.
*/
const [walletSource, setWalletSource] = useState<'mine' | 'custom'>(
const [walletSource, setWalletSource] = useState<WalletSource>(
() => (hdWalletAvailable ? 'mine' : 'custom'),
);
const [mineAccept, setMineAccept] = useState<'all' | 'public' | 'private'>(
@@ -173,6 +180,7 @@ export function CreateCampaignPage() {
);
const [customOnchain, setCustomOnchain] = useState('');
const [customSp, setCustomSp] = useState('');
const [profileWalletOwner, setProfileWalletOwner] = useState<SearchProfile | null>(null);
const [goalUsd, setGoalUsd] = useState('');
const [deadline, setDeadline] = useState('');
const [countryQuery, setCountryQuery] = useState('');
@@ -220,12 +228,11 @@ export function CreateCampaignPage() {
setWalletDefaultsApplied(true);
}, [isEditMode, walletDefaultsApplied, hdWalletAvailable, silentPaymentSupported]);
// Without nsec access, the dropdown is hidden and `walletSource` is
// pinned to `'custom'`. Guard against a stale `'mine'` from a prior
// logged-in session if the user signs out without unmounting the
// page.
// Without nsec access, only the built-in HD wallet is unavailable.
// Profile-derived and custom endpoints still work, so only coerce a
// stale `'mine'` from a prior logged-in session.
useEffect(() => {
if (!hdWalletAvailable && walletSource !== 'custom') {
if (!hdWalletAvailable && walletSource === 'mine') {
setWalletSource('custom');
}
}, [hdWalletAvailable, walletSource]);
@@ -289,6 +296,7 @@ export function CreateCampaignPage() {
setWalletSource('custom');
setCustomOnchain(editCampaign.wallets.onchain?.value ?? '');
setCustomSp(editCampaign.wallets.sp?.value ?? '');
setProfileWalletOwner(null);
setWalletDefaultsApplied(true);
setGoalUsd(editCampaign.goalUsd !== undefined ? String(editCampaign.goalUsd) : '');
setDeadline(formatDateInput(editCampaign.deadline));
@@ -326,7 +334,9 @@ export function CreateCampaignPage() {
// index is advanced later (just before the `w` tag is
// appended) so a validation failure between here and there
// doesn't burn an index.
// - 'custom' → validate the typed values up-front. At least one
// - 'profile' → derive a regular Taproot receive address from the
// selected Nostr pubkey.
// - 'custom' → validate the typed values up-front. At least one
// must parse to its expected mode.
//
// `willUseHdOnchain` and `spWallet` are the resolved targets;
@@ -350,6 +360,15 @@ export function CreateCampaignPage() {
if (wantsSp && hdWallet.silentPaymentAddress) {
spWallet = parseCampaignWallet(hdWallet.silentPaymentAddress.address);
}
} else if (walletSource === 'profile') {
if (!profileWalletOwner) {
throw new Error(t('campaignsCreate.errorWalletRequired'));
}
const derivedAddress = nostrPubkeyToBitcoinAddress(profileWalletOwner.pubkey);
customOnchainWallet = derivedAddress ? parseCampaignWallet(derivedAddress) : null;
if (!customOnchainWallet || customOnchainWallet.mode !== 'onchain') {
throw new Error(t('campaignsCreate.errorOnchainInvalid'));
}
} else {
// 'custom'
const customOnchainTrimmed = customOnchain.trim();
@@ -688,6 +707,8 @@ export function CreateCampaignPage() {
onWalletSourceChange={setWalletSource}
mineAccept={mineAccept}
onMineAcceptChange={setMineAccept}
profileWalletOwner={profileWalletOwner}
onProfileWalletOwnerChange={setProfileWalletOwner}
customOnchain={customOnchain}
onCustomOnchainChange={setCustomOnchain}
parsedCustomOnchain={parsedCustomOnchain}
@@ -881,6 +902,8 @@ function WalletPicker({
onWalletSourceChange,
mineAccept,
onMineAcceptChange,
profileWalletOwner,
onProfileWalletOwnerChange,
customOnchain,
onCustomOnchainChange,
parsedCustomOnchain,
@@ -892,10 +915,12 @@ function WalletPicker({
silentPaymentSupported: boolean;
displayName: string;
picture?: string;
walletSource: 'mine' | 'custom';
onWalletSourceChange: (value: 'mine' | 'custom') => void;
walletSource: WalletSource;
onWalletSourceChange: (value: WalletSource) => void;
mineAccept: 'all' | 'public' | 'private';
onMineAcceptChange: (value: 'all' | 'public' | 'private') => void;
profileWalletOwner: SearchProfile | null;
onProfileWalletOwnerChange: (value: SearchProfile | null) => void;
customOnchain: string;
onCustomOnchainChange: (value: string) => void;
parsedCustomOnchain: ReturnType<typeof parseCampaignWallet>;
@@ -904,83 +929,133 @@ function WalletPicker({
parsedCustomSp: ReturnType<typeof parseCampaignWallet>;
}) {
const { t } = useTranslation();
const profileWalletAuthor = useAuthor(profileWalletOwner?.pubkey);
const profileWalletMetadata = profileWalletAuthor.data?.metadata ?? profileWalletOwner?.metadata;
const initial = displayName.charAt(0).toUpperCase() || '?';
const myWalletLabel = displayName
? t('campaignsCreate.myWalletLabel', { name: displayName })
: t('campaignsCreate.myWalletDefault');
const profileWalletLabel = profileWalletOwner
? profileWalletMetadata?.display_name || profileWalletMetadata?.name || genUserName(profileWalletOwner.pubkey)
: t('common.profile');
const profileWalletPicture = sanitizeUrl(profileWalletMetadata?.picture);
const profileWalletFallback = profileWalletOwner
? profileWalletLabel.charAt(0).toUpperCase() || '?'
: <User className="size-3.5" aria-hidden="true" />;
const canAcceptSilentPayments = silentPaymentSupported;
const handleWalletSourceChange = (value: WalletSource) => {
onWalletSourceChange(value);
if (value === 'profile' && mineAccept !== 'public') {
onMineAcceptChange('public');
}
};
return (
<div className="space-y-3">
{hdWalletAvailable ? (
<>
<Select value={walletSource} onValueChange={(v) => onWalletSourceChange(v as 'mine' | 'custom')}>
<SelectTrigger className="h-12">
<SelectValue placeholder={t('campaignsCreate.walletChoose')}>
{walletSource === 'custom' ? (
<span className="inline-flex items-center gap-2">
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Wallet className="size-3.5" />
</span>
<span className="text-sm">{t('campaignsCreate.walletCustom')}</span>
</span>
) : (
<span className="inline-flex items-center gap-2">
<Avatar className="size-7 shrink-0">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<span className="truncate text-sm">{myWalletLabel}</span>
</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="mine">
<span className="inline-flex items-center gap-2">
<Avatar className="size-7 shrink-0">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<span className="text-sm">{myWalletLabel}</span>
<Select value={walletSource} onValueChange={(v) => handleWalletSourceChange(v as WalletSource)}>
<SelectTrigger className="h-12">
<SelectValue placeholder={t('campaignsCreate.walletChoose')}>
{walletSource === 'custom' ? (
<span className="inline-flex items-center gap-2">
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Wallet className="size-3.5" />
</span>
</SelectItem>
<SelectItem value="custom">
<span className="inline-flex items-center gap-2">
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Wallet className="size-3.5" />
</span>
<span className="text-sm">{t('campaignsCreate.walletCustom')}</span>
</span>
</SelectItem>
</SelectContent>
</Select>
{walletSource === 'mine' && (
<Select
value={mineAccept}
onValueChange={(v) => onMineAcceptChange(v as 'all' | 'public' | 'private')}
>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" disabled={!silentPaymentSupported}>
{t('campaignsCreate.acceptAll')}
</SelectItem>
<SelectItem value="public">{t('campaignsCreate.acceptPublic')}</SelectItem>
<SelectItem value="private" disabled={!silentPaymentSupported}>
{t('campaignsCreate.acceptPrivate')}
</SelectItem>
</SelectContent>
</Select>
<span className="text-sm">{t('campaignsCreate.walletCustom')}</span>
</span>
) : walletSource === 'profile' ? (
<span className="inline-flex items-center gap-2">
<Avatar className="size-7 shrink-0">
<AvatarImage src={profileWalletPicture} alt={profileWalletLabel} />
<AvatarFallback>{profileWalletFallback}</AvatarFallback>
</Avatar>
<span className="truncate text-sm">{profileWalletLabel}</span>
</span>
) : (
<span className="inline-flex items-center gap-2">
<Avatar className="size-7 shrink-0">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<span className="truncate text-sm">{myWalletLabel}</span>
</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{hdWalletAvailable && (
<SelectItem value="mine">
<span className="inline-flex items-center gap-2">
<Avatar className="size-7 shrink-0">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<span className="text-sm">{myWalletLabel}</span>
</span>
</SelectItem>
)}
</>
) : (
<SelectItem value="profile">
<span className="inline-flex items-center gap-2">
<Avatar className="size-7 shrink-0">
<AvatarImage src={profileWalletPicture} alt={profileWalletLabel} />
<AvatarFallback>{profileWalletFallback}</AvatarFallback>
</Avatar>
<span className="text-sm">{profileWalletLabel}</span>
</span>
</SelectItem>
<SelectItem value="custom">
<span className="inline-flex items-center gap-2">
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Wallet className="size-3.5" />
</span>
<span className="text-sm">{t('campaignsCreate.walletCustom')}</span>
</span>
</SelectItem>
</SelectContent>
</Select>
{walletSource === 'mine' && (
<Select
value={mineAccept}
onValueChange={(v) => onMineAcceptChange(v as 'all' | 'public' | 'private')}
>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" disabled={!canAcceptSilentPayments}>
{t('campaignsCreate.acceptAll')}
</SelectItem>
<SelectItem value="public">{t('campaignsCreate.acceptPublic')}</SelectItem>
<SelectItem value="private" disabled={!canAcceptSilentPayments}>
{t('campaignsCreate.acceptPrivate')}
</SelectItem>
</SelectContent>
</Select>
)}
{walletSource === 'profile' && (
<p className="text-xs text-muted-foreground">
{t('campaignsCreate.profileWalletPublicOnly')}
</p>
)}
{!hdWalletAvailable && walletSource === 'custom' && (
<p className="text-xs text-muted-foreground">
{t('campaignsCreate.customWalletIntro')}
</p>
)}
{walletSource === 'profile' && (
<div className="space-y-3 pt-1">
<PersonSearch
onAdd={(profile) => onProfileWalletOwnerChange(profile)}
excludePubkeys={profileWalletOwner ? [profileWalletOwner.pubkey] : []}
showPeopleLists={false}
/>
</div>
)}
{walletSource === 'custom' && (
<div className="space-y-3 pt-1">
<CustomWalletInput
+5 -45
View File
@@ -15,13 +15,12 @@ import {
X,
} from 'lucide-react';
import { PersonSearch } from '@/components/PersonSearch';
import { PersonSearch, SelectedPersonRow } from '@/components/PersonSearch';
import { CoverImageField } from '@/components/CoverImageField';
import { CountryFlag } from '@/components/CountryFlag';
import { FormSection } from '@/components/FormSection';
import { LoginArea } from '@/components/auth/LoginArea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -42,7 +41,6 @@ import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { getCountryInfo, searchCountries, type CountryEntry } from '@/lib/countries';
import { parseContentTagInput } from '@/lib/contentTags';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { withAgoraTag } from '@/lib/agoraNoteTags';
@@ -664,9 +662,12 @@ export function CreateCommunityPage() {
</Label>
<div className="space-y-1.5">
{moderators.map((moderator) => (
<ModeratorRow
<SelectedPersonRow
key={moderator.pubkey}
profile={moderator}
removeAriaLabel={t('groups.create.removeModeratorAria', {
name: moderator.metadata.display_name || moderator.metadata.name || moderator.pubkey,
})}
onRemove={() => removeModerator(moderator.pubkey)}
/>
))}
@@ -838,45 +839,4 @@ function CountrySelect({
);
}
function ModeratorRow({
profile,
onRemove,
}: {
profile: SearchProfile;
onRemove: () => void;
}) {
const { t } = useTranslation();
const displayName =
profile.metadata.display_name ||
profile.metadata.name ||
genUserName(profile.pubkey);
const picture = sanitizeUrl(profile.metadata.picture);
return (
<div className="rounded-lg bg-secondary/30 p-2.5">
<div className="flex items-center gap-3">
<Avatar className="size-8 shrink-0">
{picture && <AvatarImage src={picture} alt="" />}
<AvatarFallback className="text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{displayName}</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
aria-label={t('groups.create.removeModeratorAria', { name: displayName })}
className="shrink-0"
>
<X className="size-4" />
</Button>
</div>
</div>
);
}
export default CreateCommunityPage;