Telegram Mini App для инвесторов GonkaHub работал отлично на десктопе. Но основная аудитория использует телефоны — iPhone, Android. И тут всплыла проблема: нужно переставлять 10-20 адресов нод по приоритету, менять группы. Drag-and-drop был критичен для UX. Установил react-beautiful-dnd, написал код. На десктопе работает. Открываю в Telegram на iPhone — ничего не происходит. Зажимаю элемент, пытаюсь тащить — тишина.
Telegram WebView не поддерживает HTML5 Drag and Drop API. Стандартные библиотеки (react-beautiful-dnd, dnd-kit) полагаются на события ondragstart, ondrag, ondrop. В WebView внутри Telegram эти события просто не срабатывают. iOS и Android WebView перехватывают touch events для собственных нужд — скролл, zoom, системные жесты. Библиотеки бесполезны.
Плюс параллельно всплыл баг с наградами. Пользователи жаловались, что отображаемые rewards не совпадают с блокчейном. Оказалось, GPU_WEIGHT_FACTOR в коде был 437, но с эпохи 159 блокчейн изменил формулу и правильное значение стало 293. Баг занижал показываемые цифры. Нужно было фиксить оба вопроса.
Кастомный touch-based drag-and-drop
Решение: написать собственную реализацию через touch events. Long-press запускает drag — зажал палец на 800ms, элемент подхватывается. Отпустил палец — элемент падает в drop target.
Первый компонент — useLongPress хук. Он различает long-press и обычный скролл. Если палец сдвинулся больше 10px за время ожидания, это скролл, отменяю long-press. Отслеживаю через onTouchStart/onTouchMove/onTouchEnd:
// useLongPress.ts
export function useLongPress({ onLongPress, onPress, delay = 800, moveThreshold = 10 }) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startPosRef = useRef({ x: 0, y: 0 });
const start = useCallback((e: React.TouchEvent) => {
const { clientX: x, clientY: y } = e.touches[0];
startPosRef.current = { x, y };
timerRef.current = setTimeout(() => {
onLongPress({ x, y });
}, delay);
}, [onLongPress, delay]);
const move = useCallback((e: React.TouchEvent) => {
if (!timerRef.current) return;
const { clientX, clientY } = e.touches[0];
const dx = Math.abs(clientX - startPosRef.current.x);
const dy = Math.abs(clientY - startPosRef.current.y);
if (dx > moveThreshold || dy > moveThreshold) {
clearTimeout(timerRef.current); // Это скролл, не drag
timerRef.current = null;
}
}, [moveThreshold]);
return { onTouchStart: start, onTouchMove: move, onTouchEnd: end };
}
Delay 800ms — оптимальная задержка. Меньше — слишком легко активировать drag случайно. Больше — раздражает. moveThreshold 10px отсекает микродвижения пальца.
Второй компонент — DragContext. React context для глобального состояния: isDragging, draggedAddress, dragPosition, dropTarget. Все компоненты подписаны через useDrag() хук. Когда long-press срабатывает, вызываю startDrag(address) из контекста. Состояние обновляется, все компоненты реагируют.
// DragContext.tsx
export function DragProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<DragState>({
isDragging: false,
draggedAddress: null,
dragPosition: null,
dropTarget: null,
});
const startDrag = useCallback((address: string, position: { x: number; y: number }) => {
setState({
isDragging: true,
draggedAddress: address,
dragPosition: position,
dropTarget: null,
});
}, []);
return (
<DragContext.Provider value={{ ...state, startDrag, updatePosition, endDrag }}>
{children}
{state.isDragging && <DragOverlay />}
</DragContext.Provider>
);
}
DragOverlay — плавающий элемент под пальцем. Абсолютное позиционирование, координаты из dragPosition. Полупрозрачный клон оригинального элемента с opacity: 0.8 и scale(1.05).
Drop targets — каждый GroupChip реагирует на hover draggedAddress. Когда палец над чипом группы, показываю InsertionLine (тонкая синяя линия) в месте вставки. При onTouchEnd проверяю dropTarget из контекста и вызываю moveAddress(draggedAddress, targetGroup).
Баг с GPU_WEIGHT_FACTOR
Параллельно копался в расчёте наград. Rewards отображались меньше, чем в блокчейне. Нашёл hardcoded константу GPU_WEIGHT_FACTOR = 437 в коде расчёта. Проверил блокчейн — с эпохи 159 формула изменилась, правильное значение 293. Старое значение завышало вес GPU в формуле, занижало итоговый reward.
Фикс: вынес WEIGHT_FACTOR в настройки с привязкой к эпохе. До эпохи 159 — 437, после — 293:
function getWeightFactor(epoch: number): number {
return epoch >= 159 ? 293 : 437;
}
function calculateReward(gpuStake: number, epoch: number): number {
const factor = getWeightFactor(epoch);
return (gpuStake * factor) / TOTAL_NETWORK_WEIGHT;
}
Заодно добавил NodeRewardBreakdown компонент для участников с несколькими нодами. Показывает вклад каждой ноды отдельно: адрес, stake, reward. Помогает понять, какая нода приносит больше.
Результат
Кастомный drag-and-drop работает в Telegram Mini App на iOS и Android. Long-press на 800ms, палец тащит элемент, DragOverlay следует за touch координатами. Drop targets реагируют, InsertionLine показывает место вставки. Инвесторы переставляют адреса на телефоне.
GPU_WEIGHT_FACTOR исправлен. Rewards отображаются корректно с учётом изменений блокчейна с эпохи 159. NodeRewardBreakdown даёт детализацию для multi-node участников. Три дня работы, 28-30 января 2026. Финальный коммит пушил 30-го вечером, сразу deploy в production.
Основной урок: Telegram WebView — это не обычный браузер. HTML5 API работают выборочно. Touch events надёжнее, но требуют кастомной логики. Long-press + DragContext + абсолютное позиционирование — рабочая связка для мобильного drag-and-drop.