add zambia phone numbers

This commit is contained in:
Blake Kaufman
2026-06-03 11:49:42 -04:00
parent f636c80f45
commit b91985894e
12 changed files with 143 additions and 61 deletions
@@ -54,7 +54,8 @@ import getReceiveAddressAndContactForContactsPayment from '../contacts/internalC
import { hasStringAsync } from 'expo-clipboard';
import { scheduleOnRN } from 'react-native-worklets';
import { KeyboardController } from 'react-native-keyboard-controller';
import { parsePhoneNumberWithError } from 'libphonenumber-js';
import { isPhonePaymentNumber } from '../../../../functions/sendBitcoin/getPhonePaymentAddress';
import IconActionCircle from '../../../../functions/CustomElements/actionCircleContainer';
const ContactRow = ({
contact,
@@ -361,18 +362,10 @@ export default function HalfModalSendOptions({
return inputText.startsWith('@') ? inputText.slice(1).trim() : '';
}, [inputText]);
const kenyanPhoneNumber = useMemo(() => {
const stripped = inputText.trim();
if (!stripped) return null;
try {
const normalized = stripped.startsWith('+') ? stripped : `+${stripped}`;
const parsed = parsePhoneNumberWithError(normalized);
if (parsed.country === 'KE' && parsed.isValid()) {
return parsed.number.slice(1);
}
} catch {}
return null;
}, [inputText]);
const isPhoneNumber = useMemo(
() => isPhonePaymentNumber(inputText),
[inputText],
);
const contentOpacity = useSharedValue(1);
const contentTranslateX = useSharedValue(0);
@@ -443,21 +436,6 @@ export default function HalfModalSendOptions({
if (!inputText.trim()) return;
const input = inputText.trim();
try {
const phoneNormalized = input.startsWith('+') ? input : `+${input}`;
const parsed = parsePhoneNumberWithError(phoneNormalized);
if (parsed.country === 'KE' && parsed.isValid()) {
const lightningAddress = `${parsed.number.slice(1)}@bitcoin.co.ke`;
handleBackPressFunction(async () => {
navigate.replace('ConfirmPaymentScreen', {
btcAdress: lightningAddress,
fromPage: '',
});
});
return;
}
} catch {}
const normalized = input.startsWith('@')
? input.slice(1).toLowerCase()
: input.toLowerCase();
@@ -1153,19 +1131,31 @@ export default function HalfModalSendOptions({
/>
</View>
) : null
) : kenyanPhoneNumber ? (
) : isPhoneNumber ? (
<View style={styles.noContactContainer}>
<ThemeIcon iconName={'Phone'} />
<ThemeText
styles={styles.emptyTitle}
content={t('wallet.halfModal.kenyanPhoneTitle', {
number: inputText.trim(),
})}
<IconActionCircle
size={70}
icon={'Phone'}
customBackgroundColor={
theme && darkModeType ? backgroundColor : undefined
}
bottomOffset={10}
/>
<ThemeText
styles={styles.emptySubtext}
content={t('wallet.halfModal.kenyanPhoneDesc')}
styles={[
styles.emptySubtext,
{ fontSize: SIZES.smedium, marginBottom: 10 },
]}
content={t('wallet.halfModal.phonePaymentDesc')}
/>
<ThemeText
styles={[
styles.emptyTitle,
{ marginTop: 0, fontSize: SIZES.xLarge },
]}
content={inputText}
/>
<CustomButton
buttonStyles={{ ...CENTER, marginTop: 'auto' }}
textContent={t('constants.pay')}
@@ -23,6 +23,9 @@ import { receiveSparkLightningPayment } from '../../../../../functions/spark';
import { isBlitzLNURLAddress } from '../../../../../functions/lnurl';
import { handleBrantaVerification } from '../../../../../functions/branta/index';
import { Image as ExpoImage } from 'expo-image';
import getPhonePaymentAddress, {
getPhonePaymentCandidates,
} from '../../../../../functions/sendBitcoin/getPhonePaymentAddress';
export default async function decodeSendAddress(props) {
let {
@@ -115,10 +118,19 @@ export default async function decodeSendAddress(props) {
};
}
// Phone-number payments (KE/ZM): convert the dialed number into a provider
// lightning address. Use the preferred candidate (KE first) optimistically so
// parseInput's own LNURL fetch validates it; probe further only if it fails.
const phoneInput = btcAdress;
const phoneCandidates = getPhonePaymentCandidates(phoneInput);
const isPhonePayment = phoneCandidates.length > 0;
if (isPhonePayment) btcAdress = phoneCandidates[0];
if (
btcAdress.startsWith('@') ||
btcAdress.length <= 30 ||
isBlitzLNURLAddress(btcAdress)
!isPhonePayment &&
(btcAdress.startsWith('@') ||
btcAdress.length <= 30 ||
isBlitzLNURLAddress(btcAdress))
) {
let username = '';
@@ -234,10 +246,22 @@ export default async function decodeSendAddress(props) {
input = await chosenPath;
if (!input) throw new Error('Invalid address provided');
} catch (err) {
console.log(err, 'parse error');
return goBackFunction(
t('wallet.sendPages.handlingAddressErrors.parseError'),
);
if (isPhonePayment && phoneCandidates.length > 1) {
const resolved = await getPhonePaymentAddress(phoneInput);
btcAdress = resolved;
try {
input = await parseInput(resolved);
} catch (err) {
return goBackFunction(
t('wallet.sendPages.handlingAddressErrors.parseError'),
);
}
} else {
console.log(err, 'parse error');
return goBackFunction(
t('wallet.sendPages.handlingAddressErrors.parseError'),
);
}
}
let processedPaymentInfo;
@@ -8,6 +8,7 @@ export default function IconActionCircle({
size = 80,
icon,
bottomOffset = 0,
customBackgroundColor,
}) {
const { theme, darkModeType } = useGlobalThemeContext();
const { backgroundColor, backgroundOffset } = GetThemeColors();
@@ -16,7 +17,7 @@ export default function IconActionCircle({
style={[
styles.iconContainer,
{
backgroundColor: backgroundOffset,
backgroundColor: customBackgroundColor ?? backgroundOffset,
borderColor: backgroundColor,
},
{
@@ -32,6 +33,7 @@ export default function IconActionCircle({
theme && darkModeType ? COLORS.darkModeText : COLORS.primary
}
iconName={icon}
size={Math.round(size * 0.375)}
/>
</View>
);
@@ -0,0 +1,66 @@
import { parsePhoneNumberWithError } from 'libphonenumber-js';
import getLNURLDetails from '../lnurl/getLNURLDetails';
// country -> bitcoin payment provider; formatNumber emits the provider's
// canonical format regardless of whether input was national or international.
const PHONE_PAYMENT_PROVIDERS = {
KE: {
domain: 'bitcoin.co.ke',
formatNumber: parsed => parsed.number.slice(1),
}, // 254...
ZM: {
domain: 'bitzed.xyz',
formatNumber: parsed => `0${parsed.nationalNumber}`,
}, // 0977...
};
// Returns the provider lightning addresses the input is valid for, in
// PHONE_PAYMENT_PROVIDERS order (KE before ZM). Accepts national or
// international input. A bare national number in the overlapping 075/076/077
// range is valid for both KE and ZM, so this can return more than one.
export function getPhonePaymentCandidates(input) {
const stripped = (input || '').trim();
if (!stripped) return [];
// Try international form first, then national form per supported country.
const normalized = stripped.startsWith('+') ? stripped : `+${stripped}`;
const attempts = [
[normalized, undefined],
...Object.keys(PHONE_PAYMENT_PROVIDERS).map(country => [stripped, country]),
];
const seen = new Set();
const candidates = [];
for (const [value, defaultCountry] of attempts) {
try {
const parsed = parsePhoneNumberWithError(value, defaultCountry);
const provider = PHONE_PAYMENT_PROVIDERS[parsed.country];
if (provider && parsed.isValid() && !seen.has(parsed.country)) {
seen.add(parsed.country);
candidates.push(`${provider.formatNumber(parsed)}@${provider.domain}`);
}
} catch {}
}
return candidates;
}
// Sync gate for the preview UI: is this input a payable phone number?
export function isPhonePaymentNumber(input) {
return getPhonePaymentCandidates(input).length > 0;
}
// Resolves the input to a single lightning address. When the number is valid
// for multiple supported countries (overlapping 075/076/077 range), probe each
// candidate's LNURL endpoint in order (KE first) and use the first one that
// resolves to a valid pay request; otherwise fall back to the last candidate.
export default async function getPhonePaymentAddress(input) {
const candidates = getPhonePaymentCandidates(input);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
for (let i = 0; i < candidates.length - 1; i++) {
const details = await getLNURLDetails(candidates[i]);
if (details && details.tag === 'payRequest') return candidates[i];
}
return candidates[candidates.length - 1];
}
+2 -2
View File
@@ -1005,8 +1005,8 @@
"poolsDescription": "Sammeln Sie Beiträge von einer Gruppe für ein gemeinsames Ziel.",
"noContactHead": "{{username}} nicht gefunden",
"noContactDesc": "Fügen Sie {{username}} zu Ihren Kontakten hinzu, damit der Kontakt hier künftig angezeigt wird, oder fahren Sie fort, um jetzt eine Zahlung zu senden.",
"kenyanPhoneTitle": "{{number}} bezahlen",
"kenyanPhoneDesc": "Sie sind dabei, eine kenianische Telefonnummer zu bezahlen",
"phonePaymentTitle": "{{number}} bezahlen",
"phonePaymentDesc": "Senden an Mobilnummer",
"depositFunds": "Guthaben einzahlen",
"depositFundsSubtitle": "Fügen Sie Ihrem Wallet Guthaben hinzu",
"onChainBitcoin": "Bitcoin-Adresse",
+2 -2
View File
@@ -1005,8 +1005,8 @@
"poolsDescription": "Collect contributions from a group toward a shared goal.",
"noContactHead": "{{username}} not found",
"noContactDesc": "Add {{username}} to your contacts to find them here in the future, or continue to send a payment now.",
"kenyanPhoneTitle": "Pay {{number}}",
"kenyanPhoneDesc": "You are about to pay a Kenyan phone number",
"phonePaymentTitle": "Pay {{number}}",
"phonePaymentDesc": "Sending to mobile number",
"depositFunds": "Deposit Funds",
"depositFundsSubtitle": "Add funds to your wallet",
"onChainBitcoin": "Bitcoin Address",
+2 -2
View File
@@ -1005,8 +1005,8 @@
"poolsDescription": "Reúne contribuciones de un grupo para un objetivo compartido.",
"noContactHead": "{{username}} no encontrado",
"noContactDesc": "Agrega a {{username}} a tus contactos para encontrarlo aquí en el futuro, o continúa para enviar un pago ahora.",
"kenyanPhoneTitle": "Pagar a {{number}}",
"kenyanPhoneDesc": "Estás a punto de pagar a un número de teléfono de Kenia",
"phonePaymentTitle": "Pagar a {{number}}",
"phonePaymentDesc": "Enviando a un número móvil",
"depositFunds": "Depositar fondos",
"depositFundsSubtitle": "Agrega fondos a tu billetera",
"onChainBitcoin": "Dirección Bitcoin",
+2 -2
View File
@@ -1005,8 +1005,8 @@
"poolsDescription": "Collectez des contributions dun groupe pour un objectif commun.",
"noContactHead": "{{username}} introuvable",
"noContactDesc": "Ajoutez {{username}} à vos contacts pour le retrouver ici à lavenir, ou continuez pour envoyer un paiement maintenant.",
"kenyanPhoneTitle": "Payer {{number}}",
"kenyanPhoneDesc": "Vous êtes sur le point de payer un numéro de téléphone kényan",
"phonePaymentTitle": "Payer {{number}}",
"phonePaymentDesc": "Envoi vers un numéro de mobile",
"depositFunds": "Déposer des fonds",
"depositFundsSubtitle": "Ajoutez des fonds à votre portefeuille",
"onChainBitcoin": "Adresse Bitcoin",
+2 -2
View File
@@ -1005,8 +1005,8 @@
"poolsDescription": "Raccogli contributi da un gruppo per un obiettivo condiviso.",
"noContactHead": "{{username}} non trovato",
"noContactDesc": "Aggiungi {{username}} ai tuoi contatti per trovarlo qui in futuro, oppure continua per inviare un pagamento adesso.",
"kenyanPhoneTitle": "Paga {{number}}",
"kenyanPhoneDesc": "Stai per pagare un numero di telefono keniota",
"phonePaymentTitle": "Paga {{number}}",
"phonePaymentDesc": "Invio a un numero di cellulare",
"depositFunds": "Deposita fondi",
"depositFundsSubtitle": "Aggiungi fondi al tuo wallet",
"onChainBitcoin": "Indirizzo Bitcoin",
+2 -2
View File
@@ -1005,8 +1005,8 @@
"poolsDescription": "Colete pagamentos para um objetivo",
"noContactHead": "{{username}} não encontrado",
"noContactDesc": "Adicione {{username}} aos seus contatos para encontrá-lo aqui no futuro, ou continue para enviar um pagamento agora.",
"kenyanPhoneTitle": "Pagar {{number}}",
"kenyanPhoneDesc": "Você está prestes a pagar um número de telefone queniano",
"phonePaymentTitle": "Pagar {{number}}",
"phonePaymentDesc": "Enviando para número de celular",
"depositFunds": "Depositar fundos",
"depositFundsSubtitle": "Adicione fundos à sua carteira",
"onChainBitcoin": "Endereço Bitcoin",
+2 -2
View File
@@ -1005,8 +1005,8 @@
"poolsDescription": "Собирайте взносы от группы для общей цели.",
"noContactHead": "{{username}} не найден",
"noContactDesc": "Добавьте {{username}} в свои контакты, чтобы находить здесь в будущем, или продолжите, чтобы отправить платёж сейчас.",
"kenyanPhoneTitle": "Оплатить {{number}}",
"kenyanPhoneDesc": "Вы собираетесь оплатить кенийский номер телефона",
"phonePaymentTitle": "Оплатить {{number}}",
"phonePaymentDesc": "Отправка на мобильный номер",
"depositFunds": "Пополнить баланс",
"depositFundsSubtitle": "Добавьте средства в ваш кошелёк",
"onChainBitcoin": "Bitcoin-адрес",
+2 -2
View File
@@ -1005,8 +1005,8 @@
"poolsDescription": "Samla in bidrag från en grupp till ett gemensamt mål.",
"noContactHead": "{{username}} hittades inte",
"noContactDesc": "Lägg till {{username}} i dina kontakter för att hitta dem här i framtiden, eller fortsätt för att skicka en betalning nu.",
"kenyanPhoneTitle": "Betala {{number}}",
"kenyanPhoneDesc": "Du är på väg att betala ett kenyanskt telefonnummer",
"phonePaymentTitle": "Betala {{number}}",
"phonePaymentDesc": "Skickar till mobilnummer",
"depositFunds": "Sätt in pengar",
"depositFundsSubtitle": "Lägg till pengar i din plånbok",
"onChainBitcoin": "Bitcoin-adress",