Merge pull request #3941 from myxmaster/refactor/centralize-nfc-scan
refactor: centralize NFC scanning into NFCUtils
This commit is contained in:
+1
-1
@@ -11,7 +11,7 @@
|
||||
"<rootDir>/node_modules"
|
||||
],
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(react-native|@react-native|react-native-blob-util|react-native-randombytes|dateformat|nostr-tools|@nostr|@noble|@scure|uuid)/)"
|
||||
"node_modules/(?!(react-native|@react-native|react-native-blob-util|react-native-randombytes|react-native-nfc-manager|dateformat|nostr-tools|@nostr|@noble|@scure|uuid)/)"
|
||||
],
|
||||
"testPathIgnorePatterns": [
|
||||
"check-styles.test.ts"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Explicit jest type reference required: the import chain goes through
|
||||
// react-native-nfc-manager's ambient module declaration, which causes
|
||||
// tsserver to lose automatic @types/jest inclusion (tsc is unaffected).
|
||||
/// <reference types="jest" />
|
||||
|
||||
jest.mock('react-native-nfc-manager', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
isEnabled: () => {},
|
||||
setEventListener: () => {},
|
||||
start: () => {},
|
||||
registerTagEvent: () => {},
|
||||
unregisterTagEvent: () => {}
|
||||
},
|
||||
NfcEvents: { DiscoverTag: 'DiscoverTag', SessionClosed: 'SessionClosed' },
|
||||
Ndef: { text: { decodePayload: () => {} } }
|
||||
}));
|
||||
|
||||
import { decodeNdefTextPayload } from './NFCUtils';
|
||||
|
||||
// NDEF Text Record payload structure:
|
||||
// byte 0: status byte — lower 6 bits = language code length
|
||||
// bytes 1–N: language code (e.g. "en" = 2 bytes)
|
||||
// remaining: actual text (UTF-8)
|
||||
const withHeader = (...textBytes: number[]) =>
|
||||
new Uint8Array([0x02, 0x65, 0x6e, ...textBytes]); // 0x02 = 2-char lang, "en"
|
||||
|
||||
describe('decodeNdefTextPayload', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(decodeNdefTextPayload(new Uint8Array([]))).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when payload contains only the header', () => {
|
||||
expect(decodeNdefTextPayload(withHeader())).toBe('');
|
||||
});
|
||||
|
||||
it('decodes a plain ASCII payload', () => {
|
||||
// "hello" = 0x68 0x65 0x6c 0x6c 0x6f
|
||||
expect(
|
||||
decodeNdefTextPayload(withHeader(0x68, 0x65, 0x6c, 0x6c, 0x6f))
|
||||
).toBe('hello');
|
||||
});
|
||||
|
||||
it('decodes a multi-byte UTF-8 character (ü = 0xC3 0xBC)', () => {
|
||||
expect(decodeNdefTextPayload(withHeader(0xc3, 0xbc))).toBe('ü');
|
||||
});
|
||||
|
||||
it('returns null for an invalid UTF-8 sequence', () => {
|
||||
expect(decodeNdefTextPayload(withHeader(0xff))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a truncated multi-byte sequence', () => {
|
||||
// 0xC3 starts a 2-byte sequence but has no continuation byte
|
||||
expect(decodeNdefTextPayload(withHeader(0xc3))).toBeNull();
|
||||
});
|
||||
});
|
||||
+56
-28
@@ -1,6 +1,24 @@
|
||||
import NfcManager from 'react-native-nfc-manager';
|
||||
import { Platform } from 'react-native';
|
||||
import NfcManager, {
|
||||
NfcEvents,
|
||||
TagEvent,
|
||||
Ndef
|
||||
} from 'react-native-nfc-manager';
|
||||
import ModalStore from '../stores/ModalStore';
|
||||
|
||||
export function decodeNdefTextPayload(data: Uint8Array): string | null {
|
||||
if (data.length === 0) return '';
|
||||
const langCodeLen = data[0] & 0x3f;
|
||||
const textData = data.slice(1 + langCodeLen);
|
||||
try {
|
||||
return new global.TextDecoder('utf-8', { fatal: true }).decode(
|
||||
textData
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether NFC is enabled on the device.
|
||||
* If not, shows the AndroidNfcModal with the disabled state and returns false.
|
||||
@@ -18,37 +36,47 @@ export async function checkNfcEnabled(
|
||||
return true;
|
||||
}
|
||||
|
||||
class NFCUtils {
|
||||
nfcUtf8ArrayToStr = (data: any) => {
|
||||
const extraByteMap = [1, 1, 1, 1, 2, 2, 3, 0];
|
||||
const count = data.length;
|
||||
let str = '';
|
||||
export async function scanNfcTag(
|
||||
modalStore: ModalStore
|
||||
): Promise<string | undefined> {
|
||||
if (!(await checkNfcEnabled(modalStore))) return undefined;
|
||||
|
||||
for (let index = 0; index < count; ) {
|
||||
let ch = data[index++];
|
||||
if (ch & 0x80) {
|
||||
let extra = extraByteMap[(ch >> 3) & 0x07];
|
||||
if (!(ch & 0x40) || !extra || index + extra > count) {
|
||||
return null;
|
||||
}
|
||||
NfcManager.setEventListener(NfcEvents.DiscoverTag, null);
|
||||
NfcManager.setEventListener(NfcEvents.SessionClosed, null);
|
||||
await NfcManager.start().catch((e) => console.warn(e.message));
|
||||
|
||||
ch = ch & (0x3f >> extra);
|
||||
for (; extra > 0; extra -= 1) {
|
||||
const chx = data[index++];
|
||||
if ((chx & 0xc0) !== 0x80) {
|
||||
return null;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let tagFound: TagEvent | null = null;
|
||||
|
||||
ch = (ch << 6) | (chx & 0x3f);
|
||||
}
|
||||
if (Platform.OS === 'android') modalStore.toggleAndroidNfcModal(true);
|
||||
|
||||
NfcManager.setEventListener(NfcEvents.DiscoverTag, (tag: TagEvent) => {
|
||||
tagFound = tag;
|
||||
if (!tag.ndefMessage?.[0]?.payload) return resolve(undefined);
|
||||
const bytes = new Uint8Array(tag.ndefMessage[0].payload);
|
||||
|
||||
let str: string;
|
||||
const decoded = Ndef.text.decodePayload(bytes);
|
||||
if (decoded.match(/^(https?|lnurl)/)) {
|
||||
str = decoded;
|
||||
} else {
|
||||
str = decodeNdefTextPayload(bytes) || '';
|
||||
}
|
||||
|
||||
str += String.fromCharCode(ch);
|
||||
}
|
||||
if (Platform.OS === 'android')
|
||||
modalStore.toggleAndroidNfcModal(false);
|
||||
|
||||
return str.slice(3);
|
||||
};
|
||||
resolve(str);
|
||||
NfcManager.unregisterTagEvent().catch(() => 0);
|
||||
});
|
||||
|
||||
NfcManager.setEventListener(NfcEvents.SessionClosed, () => {
|
||||
if (Platform.OS === 'android')
|
||||
modalStore.toggleAndroidNfcModal(false);
|
||||
|
||||
if (!tagFound) resolve(undefined);
|
||||
});
|
||||
|
||||
NfcManager.registerTagEvent();
|
||||
});
|
||||
}
|
||||
|
||||
const nfcUtils = new NFCUtils();
|
||||
export default nfcUtils;
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
import { LNURLWithdrawParams } from 'js-lnurl';
|
||||
import { ButtonGroup, Icon } from '@rneui/themed';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import NfcManager, {
|
||||
NfcEvents,
|
||||
TagEvent,
|
||||
Ndef
|
||||
} from 'react-native-nfc-manager';
|
||||
import NfcManager from 'react-native-nfc-manager';
|
||||
import { Route } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
@@ -56,7 +52,7 @@ import UnitsStore from '../../stores/UnitsStore';
|
||||
import CashuInvoice from '../../models/CashuInvoice';
|
||||
|
||||
import { localeString } from '../../utils/LocaleUtils';
|
||||
import NFCUtils, { checkNfcEnabled } from '../../utils/NFCUtils';
|
||||
import { scanNfcTag } from '../../utils/NFCUtils';
|
||||
import { themeColor } from '../../utils/ThemeUtils';
|
||||
import { getAmountFromSats } from '../../utils/AmountUtils';
|
||||
|
||||
@@ -262,63 +258,9 @@ export default class ReceiveEcash extends React.Component<
|
||||
});
|
||||
};
|
||||
|
||||
disableNfc = () => {
|
||||
NfcManager.setEventListener(NfcEvents.DiscoverTag, null);
|
||||
NfcManager.setEventListener(NfcEvents.SessionClosed, null);
|
||||
};
|
||||
|
||||
enableNfc = async () => {
|
||||
const { ModalStore } = this.props;
|
||||
|
||||
if (!(await checkNfcEnabled(ModalStore))) return;
|
||||
|
||||
this.disableNfc();
|
||||
await NfcManager.start().catch((e) => console.warn(e.message));
|
||||
|
||||
return new Promise((resolve: any) => {
|
||||
let tagFound: TagEvent | null = null;
|
||||
|
||||
// enable NFC
|
||||
if (Platform.OS === 'android')
|
||||
ModalStore.toggleAndroidNfcModal(true);
|
||||
|
||||
NfcManager.setEventListener(
|
||||
NfcEvents.DiscoverTag,
|
||||
(tag: TagEvent) => {
|
||||
tagFound = tag;
|
||||
const bytes = new Uint8Array(
|
||||
tagFound.ndefMessage[0].payload
|
||||
);
|
||||
|
||||
let str;
|
||||
const decoded = Ndef.text.decodePayload(bytes);
|
||||
if (decoded.match(/^(https?|lnurl)/)) {
|
||||
str = decoded;
|
||||
} else {
|
||||
str = NFCUtils.nfcUtf8ArrayToStr(bytes) || '';
|
||||
}
|
||||
|
||||
// close NFC
|
||||
if (Platform.OS === 'android')
|
||||
ModalStore.toggleAndroidNfcModal(false);
|
||||
|
||||
resolve(this.validateAddress(str));
|
||||
NfcManager.unregisterTagEvent().catch(() => 0);
|
||||
}
|
||||
);
|
||||
|
||||
NfcManager.setEventListener(NfcEvents.SessionClosed, () => {
|
||||
// close NFC
|
||||
if (Platform.OS === 'android')
|
||||
ModalStore.toggleAndroidNfcModal(false);
|
||||
|
||||
if (!tagFound) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
NfcManager.registerTagEvent();
|
||||
});
|
||||
scanNfc = async () => {
|
||||
const str = await scanNfcTag(this.props.ModalStore);
|
||||
if (str) this.validateAddress(str);
|
||||
};
|
||||
|
||||
validateAddress = (text: string) => {
|
||||
@@ -822,7 +764,7 @@ export default class ReceiveEcash extends React.Component<
|
||||
/>
|
||||
}
|
||||
onPress={() =>
|
||||
this.enableNfc()
|
||||
this.scanNfc()
|
||||
}
|
||||
secondary
|
||||
/>
|
||||
|
||||
+7
-60
@@ -1,15 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View,
|
||||
TouchableOpacity
|
||||
} from 'react-native';
|
||||
import { ScrollView, StyleSheet, View, TouchableOpacity } from 'react-native';
|
||||
import { Divider } from '@rneui/themed';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import NfcManager, { NfcEvents, TagEvent } from 'react-native-nfc-manager';
|
||||
import NfcManager from 'react-native-nfc-manager';
|
||||
import { Route } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
@@ -30,7 +24,7 @@ import TextInput from '../components/TextInput';
|
||||
import UTXOPicker from '../components/UTXOPicker';
|
||||
|
||||
import handleAnything from '../utils/handleAnything';
|
||||
import NFCUtils, { checkNfcEnabled } from '../utils/NFCUtils';
|
||||
import { scanNfcTag } from '../utils/NFCUtils';
|
||||
import NodeUriUtils from '../utils/NodeUriUtils';
|
||||
import BackendUtils from '../utils/BackendUtils';
|
||||
import ValidationUtils from '../utils/ValidationUtils';
|
||||
@@ -180,56 +174,9 @@ export default class OpenChannel extends React.Component<
|
||||
this.setState({ nfcSupported });
|
||||
}
|
||||
|
||||
disableNfc = () => {
|
||||
NfcManager.setEventListener(NfcEvents.DiscoverTag, null);
|
||||
NfcManager.setEventListener(NfcEvents.SessionClosed, null);
|
||||
};
|
||||
|
||||
enableNfc = async () => {
|
||||
const { ModalStore } = this.props;
|
||||
|
||||
if (!(await checkNfcEnabled(ModalStore))) return;
|
||||
|
||||
this.disableNfc();
|
||||
await NfcManager.start();
|
||||
|
||||
return new Promise((resolve: any) => {
|
||||
let tagFound: TagEvent | null = null;
|
||||
|
||||
// enable NFC
|
||||
if (Platform.OS === 'android')
|
||||
ModalStore.toggleAndroidNfcModal(true);
|
||||
|
||||
NfcManager.setEventListener(
|
||||
NfcEvents.DiscoverTag,
|
||||
(tag: TagEvent) => {
|
||||
tagFound = tag;
|
||||
const bytes = new Uint8Array(
|
||||
tagFound.ndefMessage[0].payload
|
||||
);
|
||||
const str = NFCUtils.nfcUtf8ArrayToStr(bytes) || '';
|
||||
|
||||
// close NFC
|
||||
if (Platform.OS === 'android')
|
||||
ModalStore.toggleAndroidNfcModal(false);
|
||||
|
||||
resolve(this.validateNodeUri(str));
|
||||
NfcManager.unregisterTagEvent().catch(() => 0);
|
||||
}
|
||||
);
|
||||
|
||||
NfcManager.setEventListener(NfcEvents.SessionClosed, () => {
|
||||
// close NFC
|
||||
if (Platform.OS === 'android')
|
||||
ModalStore.toggleAndroidNfcModal(false);
|
||||
|
||||
if (!tagFound) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
NfcManager.registerTagEvent();
|
||||
});
|
||||
scanNfc = async () => {
|
||||
const str = await scanNfcTag(this.props.ModalStore);
|
||||
if (str) this.validateNodeUri(str);
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: OpenChannelProps) {
|
||||
@@ -1384,7 +1331,7 @@ export default class OpenChannel extends React.Component<
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
}
|
||||
onPress={() => this.enableNfc()}
|
||||
onPress={() => this.scanNfc()}
|
||||
secondary
|
||||
/>
|
||||
</View>
|
||||
|
||||
+6
-64
@@ -14,11 +14,7 @@ import { LNURLWithdrawParams } from 'js-lnurl';
|
||||
import { ButtonGroup, Icon } from '@rneui/themed';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import _map from 'lodash/map';
|
||||
import NfcManager, {
|
||||
NfcEvents,
|
||||
TagEvent,
|
||||
Ndef
|
||||
} from 'react-native-nfc-manager';
|
||||
import NfcManager from 'react-native-nfc-manager';
|
||||
import { Route } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
@@ -79,7 +75,7 @@ import BalanceStore from '../stores/BalanceStore';
|
||||
import { localeString } from '../utils/LocaleUtils';
|
||||
import BackendUtils from '../utils/BackendUtils';
|
||||
import Base64Utils from '../utils/Base64Utils';
|
||||
import NFCUtils, { checkNfcEnabled } from '../utils/NFCUtils';
|
||||
import { scanNfcTag } from '../utils/NFCUtils';
|
||||
import { themeColor } from '../utils/ThemeUtils';
|
||||
import { SATS_PER_BTC } from '../utils/UnitsUtils';
|
||||
import { getAmountFromSats } from '../utils/AmountUtils';
|
||||
@@ -674,63 +670,9 @@ export default class Receive extends React.Component<
|
||||
});
|
||||
};
|
||||
|
||||
disableNfc = () => {
|
||||
NfcManager.setEventListener(NfcEvents.DiscoverTag, null);
|
||||
NfcManager.setEventListener(NfcEvents.SessionClosed, null);
|
||||
};
|
||||
|
||||
enableNfc = async () => {
|
||||
const { ModalStore } = this.props;
|
||||
|
||||
if (!(await checkNfcEnabled(ModalStore))) return;
|
||||
|
||||
this.disableNfc();
|
||||
await NfcManager.start().catch((e) => console.warn(e.message));
|
||||
|
||||
return new Promise((resolve: any) => {
|
||||
let tagFound: TagEvent | null = null;
|
||||
|
||||
// enable NFC
|
||||
if (Platform.OS === 'android')
|
||||
ModalStore.toggleAndroidNfcModal(true);
|
||||
|
||||
NfcManager.setEventListener(
|
||||
NfcEvents.DiscoverTag,
|
||||
(tag: TagEvent) => {
|
||||
tagFound = tag;
|
||||
const bytes = new Uint8Array(
|
||||
tagFound.ndefMessage[0].payload
|
||||
);
|
||||
|
||||
let str;
|
||||
const decoded = Ndef.text.decodePayload(bytes);
|
||||
if (decoded.match(/^(https?|lnurl)/)) {
|
||||
str = decoded;
|
||||
} else {
|
||||
str = NFCUtils.nfcUtf8ArrayToStr(bytes) || '';
|
||||
}
|
||||
|
||||
// close NFC
|
||||
if (Platform.OS === 'android')
|
||||
ModalStore.toggleAndroidNfcModal(false);
|
||||
|
||||
resolve(this.validateAddress(str));
|
||||
NfcManager.unregisterTagEvent().catch(() => 0);
|
||||
}
|
||||
);
|
||||
|
||||
NfcManager.setEventListener(NfcEvents.SessionClosed, () => {
|
||||
// close NFC
|
||||
if (Platform.OS === 'android')
|
||||
ModalStore.toggleAndroidNfcModal(false);
|
||||
|
||||
if (!tagFound) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
NfcManager.registerTagEvent();
|
||||
});
|
||||
scanNfc = async () => {
|
||||
const str = await scanNfcTag(this.props.ModalStore);
|
||||
if (str) this.validateAddress(str);
|
||||
};
|
||||
|
||||
validateAddress = (text: string) => {
|
||||
@@ -2340,7 +2282,7 @@ export default class Receive extends React.Component<
|
||||
/>
|
||||
}
|
||||
onPress={() =>
|
||||
this.enableNfc()
|
||||
this.scanNfc()
|
||||
}
|
||||
secondary
|
||||
/>
|
||||
|
||||
+6
-65
@@ -2,7 +2,6 @@ import * as React from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
Image,
|
||||
Platform,
|
||||
NativeModules,
|
||||
NativeEventEmitter,
|
||||
StyleSheet,
|
||||
@@ -16,11 +15,7 @@ import {
|
||||
import { Chip, Icon } from '@rneui/themed';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import NfcManager, {
|
||||
NfcEvents,
|
||||
TagEvent,
|
||||
Ndef
|
||||
} from 'react-native-nfc-manager';
|
||||
import NfcManager from 'react-native-nfc-manager';
|
||||
import { ParamListBase, Route } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
@@ -53,7 +48,7 @@ import UTXOPicker from '../components/UTXOPicker';
|
||||
|
||||
import BackendUtils from '../utils/BackendUtils';
|
||||
import { errorToUserFriendly } from '../utils/ErrorUtils';
|
||||
import NFCUtils, { checkNfcEnabled } from '../utils/NFCUtils';
|
||||
import { scanNfcTag } from '../utils/NFCUtils';
|
||||
import { localeString } from '../utils/LocaleUtils';
|
||||
import { themeColor } from '../utils/ThemeUtils';
|
||||
import { getUnformattedAmount, getAmountFromSats } from '../utils/AmountUtils';
|
||||
@@ -369,63 +364,9 @@ export default class Send extends React.Component<SendProps, SendState> {
|
||||
);
|
||||
};
|
||||
|
||||
disableNfc = () => {
|
||||
NfcManager.setEventListener(NfcEvents.DiscoverTag, null);
|
||||
NfcManager.setEventListener(NfcEvents.SessionClosed, null);
|
||||
};
|
||||
|
||||
enableNfc = async () => {
|
||||
const { ModalStore } = this.props;
|
||||
|
||||
if (!(await checkNfcEnabled(ModalStore))) return;
|
||||
|
||||
this.disableNfc();
|
||||
await NfcManager.start().catch((e) => console.warn(e.message));
|
||||
|
||||
return new Promise((resolve: any) => {
|
||||
let tagFound: TagEvent | null = null;
|
||||
|
||||
// enable NFC
|
||||
if (Platform.OS === 'android')
|
||||
ModalStore.toggleAndroidNfcModal(true);
|
||||
|
||||
NfcManager.setEventListener(
|
||||
NfcEvents.DiscoverTag,
|
||||
(tag: TagEvent) => {
|
||||
tagFound = tag;
|
||||
const bytes = new Uint8Array(
|
||||
tagFound.ndefMessage[0].payload
|
||||
);
|
||||
|
||||
let str;
|
||||
const decoded = Ndef.text.decodePayload(bytes);
|
||||
if (decoded.match(/^(https?|lnurl)/)) {
|
||||
str = decoded;
|
||||
} else {
|
||||
str = NFCUtils.nfcUtf8ArrayToStr(bytes) || '';
|
||||
}
|
||||
|
||||
// close NFC
|
||||
if (Platform.OS === 'android')
|
||||
ModalStore.toggleAndroidNfcModal(false);
|
||||
|
||||
resolve(this.validateAddress(str));
|
||||
NfcManager.unregisterTagEvent().catch(() => 0);
|
||||
}
|
||||
);
|
||||
|
||||
NfcManager.setEventListener(NfcEvents.SessionClosed, () => {
|
||||
// close NFC
|
||||
if (Platform.OS === 'android')
|
||||
ModalStore.toggleAndroidNfcModal(false);
|
||||
|
||||
if (!tagFound) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
NfcManager.registerTagEvent();
|
||||
});
|
||||
scanNfc = async () => {
|
||||
const str = await scanNfcTag(this.props.ModalStore);
|
||||
if (str) this.validateAddress(str);
|
||||
};
|
||||
|
||||
selectUTXOs = (
|
||||
@@ -858,7 +799,7 @@ export default class Send extends React.Component<SendProps, SendState> {
|
||||
{nfcSupported && (
|
||||
<View style={{ marginRight: 15 }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => this.enableNfc()}
|
||||
onPress={() => this.scanNfc()}
|
||||
>
|
||||
<NFC
|
||||
stroke={themeColor('text')}
|
||||
|
||||
Reference in New Issue
Block a user