mobile download
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';
|
||||
import type { TabId } from '../components/TabBar';
|
||||
|
||||
interface NavContextValue {
|
||||
tab: TabId;
|
||||
setTab: (t: TabId) => void;
|
||||
focusMarket: string | null;
|
||||
openVirtualFocus: (market: string) => void;
|
||||
clearVirtualFocus: () => void;
|
||||
}
|
||||
|
||||
const NavContext = createContext<NavContextValue | null>(null);
|
||||
|
||||
export function NavigationProvider({ children }: { children: React.ReactNode }) {
|
||||
const [tab, setTab] = useState<TabId>('virtual');
|
||||
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||
|
||||
const openVirtualFocus = useCallback((market: string) => {
|
||||
setFocusMarket(market);
|
||||
setTab('virtual');
|
||||
}, []);
|
||||
|
||||
const clearVirtualFocus = useCallback(() => setFocusMarket(null), []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ tab, setTab, focusMarket, openVirtualFocus, clearVirtualFocus }),
|
||||
[tab, focusMarket, openVirtualFocus, clearVirtualFocus],
|
||||
);
|
||||
|
||||
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
|
||||
}
|
||||
|
||||
export function useNavigation() {
|
||||
const ctx = useContext(NavContext);
|
||||
if (!ctx) throw new Error('useNavigation requires NavigationProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
deleteAllTradeSignals,
|
||||
deleteTradeSignal,
|
||||
deleteTradeSignalsBatch,
|
||||
loadTradeSignals,
|
||||
type TradeSignalDto,
|
||||
} from '../lib/shared';
|
||||
import { getUiPreferences, patchUiPreferences } from '@frontend/utils/uiPreferencesDb';
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export interface TradeSignalInfo {
|
||||
market: string;
|
||||
signalType: string;
|
||||
price: number;
|
||||
candleTime: number;
|
||||
strategyName?: string;
|
||||
strategyId?: number;
|
||||
executionType?: string;
|
||||
candleType?: string;
|
||||
}
|
||||
|
||||
export interface TradeNotificationItem extends TradeSignalInfo {
|
||||
id: string;
|
||||
dbId?: number;
|
||||
isRead: boolean;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
interface TradeNotificationContextValue {
|
||||
toastNotifications: TradeNotificationItem[];
|
||||
allNotifications: TradeNotificationItem[];
|
||||
unreadCount: number;
|
||||
addNotification: (signal: TradeSignalInfo & { dbId?: number }) => void;
|
||||
dismissToast: (id: string) => void;
|
||||
dismissAllToasts: () => void;
|
||||
markAsRead: (id: string) => void;
|
||||
markAllAsRead: () => void;
|
||||
deleteNotification: (id: string) => Promise<void>;
|
||||
deleteAllNotifications: () => Promise<void>;
|
||||
refreshHistory: () => Promise<void>;
|
||||
}
|
||||
|
||||
const TradeNotificationContext = createContext<TradeNotificationContextValue | null>(null);
|
||||
|
||||
function makeId(signal: Pick<TradeSignalInfo, 'market' | 'candleTime' | 'signalType'>): string {
|
||||
return `${signal.market}:${signal.candleTime}:${signal.signalType}`;
|
||||
}
|
||||
|
||||
function loadReadIds(): Set<string> {
|
||||
return new Set(getUiPreferences().tradeNotifications?.readIds ?? []);
|
||||
}
|
||||
|
||||
function saveReadIds(ids: Set<string>) {
|
||||
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } });
|
||||
}
|
||||
|
||||
function loadHiddenIds(): Set<string> {
|
||||
return new Set(getUiPreferences().tradeNotifications?.hiddenIds ?? []);
|
||||
}
|
||||
|
||||
function saveHiddenIds(ids: Set<string>) {
|
||||
patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } });
|
||||
}
|
||||
|
||||
function dtoToItem(dto: TradeSignalDto, readIds: Set<string>): TradeNotificationItem {
|
||||
const id = `${dto.market}:${dto.candleTime}:${dto.signalType}`;
|
||||
return {
|
||||
id,
|
||||
dbId: dto.id,
|
||||
market: dto.market,
|
||||
signalType: dto.signalType,
|
||||
price: dto.price,
|
||||
candleTime: dto.candleTime,
|
||||
strategyName: dto.strategyName,
|
||||
strategyId: dto.strategyId,
|
||||
executionType: dto.executionType,
|
||||
candleType: dto.candleType,
|
||||
isRead: readIds.has(id),
|
||||
receivedAt: new Date(dto.createdAt).getTime() || dto.candleTime * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTradeNotification() {
|
||||
const ctx = useContext(TradeNotificationContext);
|
||||
if (!ctx) throw new Error('useTradeNotification requires provider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function TradeNotificationProvider({
|
||||
children,
|
||||
soundEnabled = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
soundEnabled?: boolean;
|
||||
}) {
|
||||
const [toasts, setToasts] = useState<TradeNotificationItem[]>([]);
|
||||
const [all, setAll] = useState<TradeNotificationItem[]>([]);
|
||||
const readRef = useRef(loadReadIds());
|
||||
const hiddenRef = useRef(loadHiddenIds());
|
||||
|
||||
const refreshHistory = useCallback(async () => {
|
||||
readRef.current = loadReadIds();
|
||||
hiddenRef.current = loadHiddenIds();
|
||||
const dtos = await loadTradeSignals();
|
||||
const items = (dtos ?? [])
|
||||
.map(d => dtoToItem(d, readRef.current))
|
||||
.filter(i => !hiddenRef.current.has(i.id))
|
||||
.sort((a, b) => b.receivedAt - a.receivedAt);
|
||||
setAll(items);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshHistory();
|
||||
const t = setInterval(() => void refreshHistory(), 20000);
|
||||
return () => clearInterval(t);
|
||||
}, [refreshHistory]);
|
||||
|
||||
const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => {
|
||||
const id = makeId(signal);
|
||||
if (hiddenRef.current.has(id)) return;
|
||||
const item: TradeNotificationItem = {
|
||||
...signal,
|
||||
id,
|
||||
isRead: readRef.current.has(id),
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
setAll(prev => {
|
||||
const filtered = prev.filter(p => p.id !== id);
|
||||
return [item, ...filtered].slice(0, 300);
|
||||
});
|
||||
if (!item.isRead) {
|
||||
setToasts(prev => [item, ...prev.filter(p => p.id !== id)].slice(0, 50));
|
||||
if (soundEnabled && typeof navigator !== 'undefined' && 'vibrate' in navigator) {
|
||||
navigator.vibrate(80);
|
||||
}
|
||||
}
|
||||
}, [soundEnabled]);
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
readRef.current.add(id);
|
||||
saveReadIds(readRef.current);
|
||||
setAll(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n)));
|
||||
}, []);
|
||||
|
||||
const dismissAllToasts = useCallback(() => {
|
||||
setToasts(prev => {
|
||||
prev.forEach(t => readRef.current.add(t.id));
|
||||
saveReadIds(readRef.current);
|
||||
return [];
|
||||
});
|
||||
setAll(prev => prev.map(n => ({ ...n, isRead: true })));
|
||||
}, []);
|
||||
|
||||
const markAsRead = useCallback((id: string) => {
|
||||
readRef.current.add(id);
|
||||
saveReadIds(readRef.current);
|
||||
setAll(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n)));
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const markAllAsRead = useCallback(() => {
|
||||
all.forEach(n => readRef.current.add(n.id));
|
||||
saveReadIds(readRef.current);
|
||||
setAll(prev => prev.map(n => ({ ...n, isRead: true })));
|
||||
setToasts([]);
|
||||
}, [all]);
|
||||
|
||||
const deleteNotification = useCallback(async (id: string) => {
|
||||
const item = all.find(n => n.id === id);
|
||||
hiddenRef.current.add(id);
|
||||
saveHiddenIds(hiddenRef.current);
|
||||
if (item?.dbId) await deleteTradeSignal(item.dbId);
|
||||
setAll(prev => prev.filter(n => n.id !== id));
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, [all]);
|
||||
|
||||
const deleteAllNotifications = useCallback(async () => {
|
||||
await deleteAllTradeSignals();
|
||||
all.forEach(n => hiddenRef.current.add(n.id));
|
||||
saveHiddenIds(hiddenRef.current);
|
||||
setAll([]);
|
||||
setToasts([]);
|
||||
}, [all]);
|
||||
|
||||
const unreadCount = useMemo(() => all.filter(n => !n.isRead).length, [all]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
toastNotifications: toasts,
|
||||
allNotifications: all,
|
||||
unreadCount,
|
||||
addNotification,
|
||||
dismissToast,
|
||||
dismissAllToasts,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
deleteNotification,
|
||||
deleteAllNotifications,
|
||||
refreshHistory,
|
||||
}),
|
||||
[toasts, all, unreadCount, addNotification, dismissToast, dismissAllToasts, markAsRead, markAllAsRead, deleteNotification, deleteAllNotifications, refreshHistory],
|
||||
);
|
||||
|
||||
return (
|
||||
<TradeNotificationContext.Provider value={value}>{children}</TradeNotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user