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.