Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ebf0a29b5 | |||
| e6acb04398 | |||
| 8d5cd78b04 | |||
| 0b4454a4c2 |
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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…",
|
||||
|
||||
@@ -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
@@ -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…",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user