From 8d7ed592b8159f6cd103c8ab18ee3b79ad09541c Mon Sep 17 00:00:00 2001 From: byGalax Date: Thu, 14 May 2026 00:19:15 +0200 Subject: [PATCH] feat(mobile): ReactionStrip quick-reactor + ReactionPills display --- apps/mobile/components/ReactionPills.tsx | 67 ++++++++++++++++++++++++ apps/mobile/components/ReactionStrip.tsx | 45 ++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 apps/mobile/components/ReactionPills.tsx create mode 100644 apps/mobile/components/ReactionStrip.tsx diff --git a/apps/mobile/components/ReactionPills.tsx b/apps/mobile/components/ReactionPills.tsx new file mode 100644 index 0000000..39b449d --- /dev/null +++ b/apps/mobile/components/ReactionPills.tsx @@ -0,0 +1,67 @@ +import type { MessageReaction } from '@chat-app/shared/chat'; +import { useMemo } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { colors } from '../theme/colors'; + +interface Props { + reactions: MessageReaction[]; + myUserId: string | null; + onToggle: (emoji: string) => void; +} + +// Renders the per-message reaction badges underneath a bubble. Pills +// the current user has reacted to get a tinted background so they know +// which ones to tap to un-react. +export function ReactionPills({ reactions, myUserId, onToggle }: Props) { + const grouped = useMemo(() => groupByEmoji(reactions, myUserId), [reactions, myUserId]); + if (grouped.length === 0) return null; + return ( + + {grouped.map((g) => ( + onToggle(g.emoji)} + style={[styles.pill, g.mine && styles.pillMine]} + > + {g.emoji} + {g.count} + + ))} + + ); +} + +function groupByEmoji( + rows: MessageReaction[], + myUserId: string | null, +): Array<{ emoji: string; count: number; mine: boolean }> { + const m = new Map(); + for (const r of rows) { + const prev = m.get(r.emoji) ?? { count: 0, mine: false }; + m.set(r.emoji, { + count: prev.count + 1, + mine: prev.mine || (myUserId !== null && r.userId === myUserId), + }); + } + return Array.from(m.entries()).map(([emoji, v]) => ({ emoji, ...v })); +} + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 4 }, + pill: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 12, + backgroundColor: colors.surface, + borderColor: colors.border, + borderWidth: 1, + gap: 4, + }, + pillMine: { backgroundColor: colors.accentMuted, borderColor: colors.accent }, + emoji: { fontSize: 13 }, + count: { color: colors.textMuted, fontSize: 12, fontWeight: '600' }, + countMine: { color: colors.accent }, +}); diff --git a/apps/mobile/components/ReactionStrip.tsx b/apps/mobile/components/ReactionStrip.tsx new file mode 100644 index 0000000..7d2ecf5 --- /dev/null +++ b/apps/mobile/components/ReactionStrip.tsx @@ -0,0 +1,45 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { colors } from '../theme/colors'; + +// Hardcoded 6-emoji quick reactor shown at the top of the long-press +// sheet. A full emoji picker is post-Phase-4; this is the Discord-style +// fast path the vast majority of reactions go through. +export const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🎉'] as const; + +export function ReactionStrip({ onReact }: { onReact: (emoji: string) => void }) { + return ( + + {QUICK_REACTIONS.map((e) => ( + onReact(e)} + style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]} + hitSlop={6} + > + {e} + + ))} + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + justifyContent: 'space-around', + paddingVertical: 12, + paddingHorizontal: 8, + borderBottomColor: colors.border, + borderBottomWidth: 1, + }, + button: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 22, + }, + buttonPressed: { backgroundColor: colors.accentMuted }, + emoji: { fontSize: 26 }, +});