모바일 로드 오류 수정
This commit is contained in:
@@ -243,11 +243,11 @@ function App() {
|
|||||||
|
|
||||||
const priorityMarketsRef = useRef<string[]>([symbol]);
|
const priorityMarketsRef = useRef<string[]>([symbol]);
|
||||||
const getPriorityMarkets = useCallback(() => priorityMarketsRef.current, []);
|
const getPriorityMarkets = useCallback(() => priorityMarketsRef.current, []);
|
||||||
|
/** 마켓 패널을 열기 전에는 우선 종목만 — PC·모바일 공통 (ticker 429 방지) */
|
||||||
const marketTickerOpts = useMemo(() => ({
|
const marketTickerOpts = useMemo(() => ({
|
||||||
lite: isMobile && !showMarketPanel,
|
loadFull: showMarketPanel,
|
||||||
loadFull: !isMobile || showMarketPanel,
|
|
||||||
getPriorityMarkets,
|
getPriorityMarkets,
|
||||||
}), [isMobile, showMarketPanel, getPriorityMarkets]);
|
}), [showMarketPanel, getPriorityMarkets]);
|
||||||
const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts);
|
const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts);
|
||||||
|
|
||||||
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
|
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
FAVORITES_CHANGED_EVENT,
|
FAVORITES_CHANGED_EVENT,
|
||||||
} from '../utils/marketStorage';
|
} from '../utils/marketStorage';
|
||||||
import { UPBIT_API } from '../utils/upbitApi';
|
import { UPBIT_API } from '../utils/upbitApi';
|
||||||
|
import { fetchTickersThrottled } from '../utils/upbitTickerFetch';
|
||||||
import { safeToFixed } from '../utils/safeFormat';
|
import { safeToFixed } from '../utils/safeFormat';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
@@ -137,24 +138,20 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
const fetchTickers = useCallback(async () => {
|
const fetchTickers = useCallback(async () => {
|
||||||
const codes = marketCodesRef.current;
|
const codes = marketCodesRef.current;
|
||||||
if (!codes) return;
|
if (!codes) return;
|
||||||
const allCodes = codes.split(',');
|
const allCodes = codes.split(',').filter(Boolean);
|
||||||
const BATCH = 100;
|
const rawList = await fetchTickersThrottled(allCodes);
|
||||||
const results: Array<{ market: string } & TickerInfo> = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < allCodes.length; i += BATCH) {
|
|
||||||
const batch = allCodes.slice(i, i + BATCH).join(',');
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${UPBIT_API}/ticker?markets=${batch}`);
|
|
||||||
if (!res.ok) continue;
|
|
||||||
const data: unknown = await res.json();
|
|
||||||
if (!Array.isArray(data)) continue;
|
|
||||||
results.push(...(data as Array<{ market: string } & TickerInfo>));
|
|
||||||
} catch { /* ignore partial failures */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
setTickers(prev => {
|
setTickers(prev => {
|
||||||
const map: Record<string, TickerInfo> = { ...prev };
|
const map: Record<string, TickerInfo> = { ...prev };
|
||||||
for (const t of results) map[t.market] = t;
|
for (const t of rawList) {
|
||||||
|
const market = (t.market ?? t.code) as string;
|
||||||
|
if (!market) continue;
|
||||||
|
map[market] = {
|
||||||
|
trade_price: t.trade_price,
|
||||||
|
signed_change_rate: t.signed_change_rate,
|
||||||
|
acc_trade_price_24h: t.acc_trade_price_24h,
|
||||||
|
};
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
* 업비트 마켓 목록 + 실시간 시세 훅
|
* 업비트 마켓 목록 + 실시간 시세 훅
|
||||||
*
|
*
|
||||||
* 1) /v1/market/all — 마켓 목록
|
* 1) /v1/market/all — 마켓 목록
|
||||||
* 2) /v1/ticker — 초기 시세 (lite: 우선 종목만, full: 전 종목)
|
* 2) /v1/ticker — 우선 종목 즉시, 전 종목은 패널 열림·지연 후 (429 방지)
|
||||||
* 3) upbitWsBroker ticker 구독
|
* 3) upbitWsBroker ticker (우선 종목 위주)
|
||||||
* 4) REST 보조 폴링 30초
|
* 4) REST 보조 폴링 30초
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { setMarketNames } from '../utils/marketNameCache';
|
import { setMarketNames } from '../utils/marketNameCache';
|
||||||
import { UPBIT_API } from '../utils/upbitApi';
|
import { UPBIT_API } from '../utils/upbitApi';
|
||||||
|
import { fetchTickersThrottled, type UpbitTickerRaw } from '../utils/upbitTickerFetch';
|
||||||
import { subscribeUpbitWs } from '../utils/upbitWsBroker';
|
import { subscribeUpbitWs } from '../utils/upbitWsBroker';
|
||||||
|
|
||||||
export type ChangeDir = 'RISE' | 'FALL' | 'EVEN';
|
export type ChangeDir = 'RISE' | 'FALL' | 'EVEN';
|
||||||
@@ -34,18 +35,18 @@ export interface MarketInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UseMarketTickerOptions {
|
export interface UseMarketTickerOptions {
|
||||||
/** 모바일 등: 우선 종목만 먼저 로드 */
|
/** 우선 종목만 먼저 로드 (기본: 마켓 패널 닫힘) */
|
||||||
lite?: boolean;
|
lite?: boolean;
|
||||||
/** lite 시 즉시 ticker REST·WS 할 마켓 (차트·관심 등) */
|
|
||||||
priorityMarkets?: string[];
|
priorityMarkets?: string[];
|
||||||
/** 렌더마다 최신 우선 종목 (watchlist 등 — 훅 순서 유지용) */
|
|
||||||
getPriorityMarkets?: () => string[];
|
getPriorityMarkets?: () => string[];
|
||||||
/** true면 전 종목 ticker 로드 (마켓 패널 열림 등) */
|
/** true일 때만 전 종목 REST·폴링 (마켓 패널 열림) */
|
||||||
loadFull?: boolean;
|
loadFull?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REST_POLL_INTERVAL = 30_000;
|
const REST_POLL_INTERVAL = 30_000;
|
||||||
const WS_BATCH_MS = 400;
|
const WS_BATCH_MS = 400;
|
||||||
|
const FULL_LOAD_DEFER_MS = 2_000;
|
||||||
|
const WS_TICKER_CAP = 80;
|
||||||
const LITE_BASE_MARKETS = ['KRW-BTC', 'KRW-USDT'];
|
const LITE_BASE_MARKETS = ['KRW-BTC', 'KRW-USDT'];
|
||||||
|
|
||||||
function toChangeDir(raw: UpbitTickerRaw): ChangeDir {
|
function toChangeDir(raw: UpbitTickerRaw): ChangeDir {
|
||||||
@@ -60,20 +61,6 @@ interface UpbitMarketAll {
|
|||||||
english_name: string;
|
english_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpbitTickerRaw {
|
|
||||||
market?: string;
|
|
||||||
code?: string;
|
|
||||||
trade_price: number;
|
|
||||||
signed_change_rate: number;
|
|
||||||
signed_change_price: number;
|
|
||||||
acc_trade_price_24h: number;
|
|
||||||
acc_trade_volume_24h?: number;
|
|
||||||
opening_price: number;
|
|
||||||
high_price: number;
|
|
||||||
low_price: number;
|
|
||||||
change?: 'RISE' | 'FALL' | 'EVEN';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMarketCode(raw: UpbitTickerRaw): string {
|
function getMarketCode(raw: UpbitTickerRaw): string {
|
||||||
return (raw.code ?? raw.market ?? '') as string;
|
return (raw.code ?? raw.market ?? '') as string;
|
||||||
}
|
}
|
||||||
@@ -93,18 +80,6 @@ async function fetchAllMarkets(): Promise<MarketInfo[]> {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAllTickers(markets: string[]): Promise<UpbitTickerRaw[]> {
|
|
||||||
const results: UpbitTickerRaw[] = [];
|
|
||||||
for (let i = 0; i < markets.length; i += 100) {
|
|
||||||
const chunk = markets.slice(i, i + 100);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${UPBIT_API}/ticker?markets=${chunk.join(',')}`);
|
|
||||||
if (res.ok) results.push(...(await res.json()));
|
|
||||||
} catch { /* 부분 실패 무시 */ }
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rawToTicker(raw: UpbitTickerRaw, koreanName: string): TickerData {
|
function rawToTicker(raw: UpbitTickerRaw, koreanName: string): TickerData {
|
||||||
return {
|
return {
|
||||||
market: getMarketCode(raw),
|
market: getMarketCode(raw),
|
||||||
@@ -121,10 +96,26 @@ function rawToTicker(raw: UpbitTickerRaw, koreanName: string): TickerData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergePriorityMarkets(priorityMarkets?: string[]): string[] {
|
||||||
|
const set = new Set(LITE_BASE_MARKETS);
|
||||||
|
priorityMarkets?.forEach(m => { if (m?.startsWith('KRW-')) set.add(m); });
|
||||||
|
return [...set];
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsTickerMarkets(all: string[], priority: string[]): string[] {
|
||||||
|
const set = new Set(priority);
|
||||||
|
for (const m of all) {
|
||||||
|
if (set.size >= WS_TICKER_CAP) break;
|
||||||
|
set.add(m);
|
||||||
|
}
|
||||||
|
return [...set];
|
||||||
|
}
|
||||||
|
|
||||||
function subscribeTickerWs(
|
function subscribeTickerWs(
|
||||||
markets: string[],
|
markets: string[],
|
||||||
onBatch: (rawList: UpbitTickerRaw[]) => void,
|
onBatch: (rawList: UpbitTickerRaw[]) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
|
if (markets.length === 0) return () => {};
|
||||||
const batch = new Map<string, UpbitTickerRaw>();
|
const batch = new Map<string, UpbitTickerRaw>();
|
||||||
let batchTimer: ReturnType<typeof setTimeout> | null = null;
|
let batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -145,15 +136,8 @@ function subscribeTickerWs(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergePriorityMarkets(priorityMarkets?: string[]): string[] {
|
|
||||||
const set = new Set(LITE_BASE_MARKETS);
|
|
||||||
priorityMarkets?.forEach(m => { if (m?.startsWith('KRW-')) set.add(m); });
|
|
||||||
return [...set];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
||||||
const lite = opts.lite ?? false;
|
const loadFull = opts.loadFull ?? false;
|
||||||
const loadFull = opts.loadFull ?? !lite;
|
|
||||||
const priorityList = mergePriorityMarkets(opts.getPriorityMarkets?.() ?? opts.priorityMarkets);
|
const priorityList = mergePriorityMarkets(opts.getPriorityMarkets?.() ?? opts.priorityMarkets);
|
||||||
const priorityKey = priorityList.join(',');
|
const priorityKey = priorityList.join(',');
|
||||||
|
|
||||||
@@ -167,11 +151,11 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
|||||||
const marketInfoMapRef = useRef<Map<string, MarketInfo>>(new Map());
|
const marketInfoMapRef = useRef<Map<string, MarketInfo>>(new Map());
|
||||||
const destroyWsRef = useRef<(() => void) | null>(null);
|
const destroyWsRef = useRef<(() => void) | null>(null);
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const wsMarketsRef = useRef<string[]>([]);
|
|
||||||
const fullLoadedRef = useRef(false);
|
const fullLoadedRef = useRef(false);
|
||||||
|
const initDoneRef = useRef(false);
|
||||||
|
|
||||||
const applyRawList = useCallback((rawList: UpbitTickerRaw[]) => {
|
const applyRawList = useCallback((rawList: UpbitTickerRaw[]) => {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current || rawList.length === 0) return;
|
||||||
|
|
||||||
setTickers(prev => {
|
setTickers(prev => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -196,35 +180,43 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startPoll = useCallback((markets: string[]) => {
|
|
||||||
clearPoll();
|
|
||||||
pollTimerRef.current = setInterval(async () => {
|
|
||||||
if (!mountedRef.current) return;
|
|
||||||
try {
|
|
||||||
const rawList = await fetchAllTickers(markets);
|
|
||||||
applyRawList(rawList);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, REST_POLL_INTERVAL);
|
|
||||||
}, [applyRawList, clearPoll]);
|
|
||||||
|
|
||||||
const attachWs = useCallback((markets: string[]) => {
|
const attachWs = useCallback((markets: string[]) => {
|
||||||
destroyWsRef.current?.();
|
destroyWsRef.current?.();
|
||||||
wsMarketsRef.current = markets;
|
|
||||||
destroyWsRef.current = subscribeTickerWs(markets, applyRawList);
|
destroyWsRef.current = subscribeTickerWs(markets, applyRawList);
|
||||||
}, [applyRawList]);
|
}, [applyRawList]);
|
||||||
|
|
||||||
const loadTickersFor = useCallback(async (markets: string[]) => {
|
const startPoll = useCallback((markets: string[]) => {
|
||||||
|
clearPoll();
|
||||||
if (markets.length === 0) return;
|
if (markets.length === 0) return;
|
||||||
try {
|
pollTimerRef.current = setInterval(() => {
|
||||||
const rawList = await fetchAllTickers(markets);
|
if (!mountedRef.current) return;
|
||||||
if (mountedRef.current) applyRawList(rawList);
|
void fetchTickersThrottled(markets).then(raw => {
|
||||||
} catch { /* ignore */ }
|
if (raw.length > 0) applyRawList(raw);
|
||||||
|
});
|
||||||
|
}, REST_POLL_INTERVAL);
|
||||||
|
}, [applyRawList, clearPoll]);
|
||||||
|
|
||||||
|
const loadTickersFor = useCallback(async (markets: string[]) => {
|
||||||
|
const rawList = await fetchTickersThrottled(markets);
|
||||||
|
if (mountedRef.current && rawList.length > 0) applyRawList(rawList);
|
||||||
|
return rawList;
|
||||||
}, [applyRawList]);
|
}, [applyRawList]);
|
||||||
|
|
||||||
// 마켓 목록 + 초기 ticker (lite / full)
|
const runFullTickerLoad = useCallback(async () => {
|
||||||
|
const all = marketListRef.current;
|
||||||
|
if (all.length === 0 || fullLoadedRef.current) return;
|
||||||
|
await loadTickersFor(all);
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
fullLoadedRef.current = true;
|
||||||
|
attachWs(wsTickerMarkets(all, priorityList));
|
||||||
|
startPoll(all);
|
||||||
|
}, [loadTickersFor, attachWs, startPoll, priorityList]);
|
||||||
|
|
||||||
|
// 1) 마켓 목록 + 우선 종목 ticker (마운트 1회)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
fullLoadedRef.current = false;
|
fullLoadedRef.current = false;
|
||||||
|
initDoneRef.current = false;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
let infos: MarketInfo[] = [];
|
let infos: MarketInfo[] = [];
|
||||||
@@ -239,15 +231,16 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
|||||||
if (!allMarkets.includes('KRW-USDT')) allMarkets.push('KRW-USDT');
|
if (!allMarkets.includes('KRW-USDT')) allMarkets.push('KRW-USDT');
|
||||||
marketListRef.current = allMarkets;
|
marketListRef.current = allMarkets;
|
||||||
|
|
||||||
const initialMarkets = loadFull ? allMarkets : priorityList;
|
await loadTickersFor(priorityList);
|
||||||
await loadTickersFor(initialMarkets);
|
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
attachWs(initialMarkets);
|
initDoneRef.current = true;
|
||||||
startPoll(initialMarkets);
|
attachWs(wsTickerMarkets(allMarkets, priorityList));
|
||||||
|
|
||||||
if (loadFull) fullLoadedRef.current = true;
|
if (loadFull) {
|
||||||
|
setTimeout(() => { void runFullTickerLoad(); }, FULL_LOAD_DEFER_MS);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -256,25 +249,25 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
|||||||
destroyWsRef.current = null;
|
destroyWsRef.current = null;
|
||||||
clearPoll();
|
clearPoll();
|
||||||
};
|
};
|
||||||
}, [loadFull, priorityKey, loadTickersFor, attachWs, startPoll, clearPoll, priorityList]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- init only
|
||||||
|
}, []);
|
||||||
|
|
||||||
// lite → full 확장 (마켓 패널 열림 등)
|
// 2) 마켓 패널 열림 → 전 종목 (지연·스로틀)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadFull || fullLoadedRef.current) return;
|
if (!loadFull || !initDoneRef.current) return;
|
||||||
if (marketListRef.current.length === 0) return;
|
if (fullLoadedRef.current) return;
|
||||||
|
|
||||||
let cancelled = false;
|
const t = setTimeout(() => { void runFullTickerLoad(); }, FULL_LOAD_DEFER_MS);
|
||||||
(async () => {
|
return () => clearTimeout(t);
|
||||||
|
}, [loadFull, runFullTickerLoad]);
|
||||||
|
|
||||||
|
// 3) 우선 종목 변경 (lite) — 전체 재시작 없이 보강
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initDoneRef.current || fullLoadedRef.current) return;
|
||||||
|
void loadTickersFor(priorityList);
|
||||||
const all = marketListRef.current;
|
const all = marketListRef.current;
|
||||||
await loadTickersFor(all);
|
if (all.length > 0) attachWs(wsTickerMarkets(all, priorityList));
|
||||||
if (cancelled || !mountedRef.current) return;
|
}, [priorityKey, loadTickersFor, attachWs, priorityList]);
|
||||||
fullLoadedRef.current = true;
|
|
||||||
attachWs(all);
|
|
||||||
startPoll(all);
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [loadFull, loadTickersFor, attachWs, startPoll]);
|
|
||||||
|
|
||||||
return { tickers, marketInfos, loading, usdRate };
|
return { tickers, marketInfos, loading, usdRate };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 업비트 /ticker REST — 청크·지연·429 백오프 (공유)
|
||||||
|
*/
|
||||||
|
import { UPBIT_API } from './upbitApi';
|
||||||
|
|
||||||
|
export interface UpbitTickerRaw {
|
||||||
|
market?: string;
|
||||||
|
code?: string;
|
||||||
|
trade_price: number;
|
||||||
|
signed_change_rate: number;
|
||||||
|
signed_change_price: number;
|
||||||
|
acc_trade_price_24h: number;
|
||||||
|
acc_trade_volume_24h?: number;
|
||||||
|
opening_price: number;
|
||||||
|
high_price: number;
|
||||||
|
low_price: number;
|
||||||
|
change?: 'RISE' | 'FALL' | 'EVEN';
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 60;
|
||||||
|
const CHUNK_DELAY_MS = 150;
|
||||||
|
const MAX_CHUNK_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(r => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 중복 제거 후 청크 단위 ticker 조회 (429 시 백오프) */
|
||||||
|
export async function fetchTickersThrottled(markets: string[]): Promise<UpbitTickerRaw[]> {
|
||||||
|
const unique = [...new Set(markets.filter(m => m?.startsWith('KRW-')))];
|
||||||
|
if (unique.length === 0) return [];
|
||||||
|
|
||||||
|
const results: UpbitTickerRaw[] = [];
|
||||||
|
for (let i = 0; i < unique.length; i += CHUNK_SIZE) {
|
||||||
|
const chunk = unique.slice(i, i + CHUNK_SIZE);
|
||||||
|
let done = false;
|
||||||
|
for (let attempt = 0; attempt < MAX_CHUNK_ATTEMPTS && !done; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${UPBIT_API}/ticker?markets=${chunk.join(',')}`);
|
||||||
|
if (res.status === 429) {
|
||||||
|
await sleep(500 * (attempt + 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as UpbitTickerRaw[];
|
||||||
|
if (Array.isArray(data)) results.push(...data);
|
||||||
|
}
|
||||||
|
done = true;
|
||||||
|
} catch {
|
||||||
|
await sleep(300 * (attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i + CHUNK_SIZE < unique.length) await sleep(CHUNK_DELAY_MS);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user