Реалізація реакцій на повідомлення у чаті мобільного додатку
Довге натиснення на сповіщення → пікер еміджі → анімована реакція під сповіщенням. Виглядає просто, але у продакшені вспливають деталі: два користувачи реагують одночасно — лічильник дублюється. Або пікер перекривається клавіатурою. Або анімація прибігає тому що висота ячейки пересчитується без контексту.
Як устроєні дані
Таблиця message_reactions: message_id, user_id, emoji (unicode-символ або short code), created_at, UNIQUE (message_id, user_id, emoji). Індекс по message_id — він потрібен для швидкого отримання всіх реакцій до сповіщення.
У ответе API сповіщення включає агреговані реакції:
"reactions": [
{ "emoji": "👍", "count": 5, "reacted_by_me": true },
{ "emoji": "❤️", "count": 2, "reacted_by_me": false }
]
Групування SELECT emoji, COUNT(*), bool_or(user_id = $current_user_id) — виконується швидко при індексі по message_id. При великому числі реакцій (тисячи) — кеш у Redis Hash з інвалідацією.
При додаванні/видаленні реакції сервер рассилає WS-событие reaction.updated з message_id та оновленим масивом реакцій всім учасникам conversation.
UI та анімації
Пікер еміджі по long press
На iOS: UILongPressGestureRecognizer з minimumPressDuration = 0.35. При спрацюванні — вичислюємо позицію ячейки у superview координатах через convert(cell.frame, to: view), показуємо кастомний UIView-попап з quick-reactions (6-8 еміджі) позиціонований над сповіщенням. Haptic feedback через UIImpactFeedbackGenerator(style: .medium).impactOccurred().
У SwiftUI — .onLongPressGesture(minimumDuration: 0.35) + overlay з кастомним ReactionPickerView через ZStack.
На Android (Jetpack Compose): pointerInput(Unit) { detectTapGestures(onLongPress = {...}) } — показуємо Popup з Row quick-reactions.
Повний emoji-picker (якщо потрібен) — бібліотека emoji-picker-react для Flutter Web, EmojiPicker для Android (бібліотека emoji-picker-android), кастомна UICollectionView по категоріям для iOS.
Відображення реакцій під сповіщенням
Горизонтальний flow-ряд кнопок-пилюль: [emoji + count]. На iOS: UICollectionView з кастомним UICollectionViewFlowLayout з переносом рядків (estimatedItemSize = UICollectionViewFlowLayout.automaticSize). Або простіше — UIStackView з isLayoutMarginsRelativeArrangement та ручним wrapping.
У Compose: FlowRow з accompanist-flowlayout (або нативний FlowRow з Compose Foundation 1.5+) — зручніше.
Критичний момент: при додаванні нової реакції висота ячейки може збільшитися (додався новий ряд). Без правильної анімації список дергається. У UIKit — performBatchUpdates з reloadItems(at:) + UIView.animate. У Compose — animateContentSize() на контейнері реакцій.
Анімація додавання
Нова реакція: іконка «з'являється» з scale 0.3 → 1.2 → 1.0 + opacity 0 → 1. У UIKit — CASpringAnimation на transform.scale. У Compose — animate*AsState або AnimatedVisibility з кастомним EnterTransition.
Інкремент лічильника: число «прокручується» вверх (старое йде вверх, нове з'являється знизу). UIKit — CATransition(type: .push, subtype: .fromTop) на UILabel. Compose — AnimatedContent з slideInVertically + slideOutVertically.
Список реагуючих
Тап на реакцію-пилюлю → bottom sheet зі списком користувачів. iOS: UISheetPresentationController (iOS 15+) з detents: [.medium()]. Android: ModalBottomSheet у Material3. Дані: GET /messages/{id}/reactions?emoji=👍 → масив {user_id, display_name, avatar_url}.
Своя реакція
Якщо reacted_by_me = true — пилюля підсвічена (акцентний border або background). Тап по ній — видаляє реакцію (toggle). Optimistic update: одразу змінюємо UI, откатуємо при помилці.
Обсяг робіт
Реакції як окрема фіча — 1-3 дні при наявності готового чата. Включає: API endpoint (add/remove), WS-событие, UI-компонент реакцій, пікер, анімації, список реагуючих. Вартість залежить від платформи (iOS, Android або обі) та наявності існуючого WS-протоколу у проекті.







