feat(native/dm): WhatsApp-style chat — bg pattern, bubble redesign, avatar + realtime fixes
- Header: partner avatar left-aligned (was centered) - ChatBubble: replace bright blue with subtle mint/brand tint, asymmetric tail-corner radius, footer pinned bottom-right, reply-quote with green side-bar - New DmChatBackground: SVG hex-offset doodle pattern (stars, hearts, clouds, dots) at 7% opacity — light-cream / dark-warm-green base - Avatar in chat list: use resolveAvatar() consistently to handle hero-id, https, and null cases - Realtime subscription: stabilize deps via partnerRef to stop re-subscribing on partner state change - Pressable → TouchableOpacity throughout Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
dba33b5733
commit
6ac6a26b9c
@ -20,8 +20,10 @@ import { supabase } from '../lib/supabase';
|
|||||||
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
|
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
|
||||||
import { resolveAvatar } from '../lib/resolveAvatar';
|
import { resolveAvatar } from '../lib/resolveAvatar';
|
||||||
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
|
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
|
||||||
|
import { DmChatBackground } from '../components/chat/DmChatBackground';
|
||||||
import { useDmRealtime } from '../hooks/useChatRealtime';
|
import { useDmRealtime } from '../hooks/useChatRealtime';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
import { useThemeStore } from '../stores/theme';
|
||||||
|
|
||||||
type DmHistoryResponse = {
|
type DmHistoryResponse = {
|
||||||
partner: {
|
partner: {
|
||||||
@ -59,10 +61,14 @@ export default function DmScreen() {
|
|||||||
const flatRef = useRef<FlatList>(null);
|
const flatRef = useRef<FlatList>(null);
|
||||||
const [myUserId, setMyUserId] = useState<string | undefined>(undefined);
|
const [myUserId, setMyUserId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||||
|
const chatBg = colorScheme === 'dark' ? '#1a1f1e' : '#EDE8E1';
|
||||||
|
|
||||||
const { userId } = useLocalSearchParams<{ userId: string }>();
|
const { userId } = useLocalSearchParams<{ userId: string }>();
|
||||||
|
|
||||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||||
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
|
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
|
||||||
|
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(null);
|
||||||
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
|
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@ -84,6 +90,7 @@ export default function DmScreen() {
|
|||||||
const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
|
const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
|
||||||
console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length);
|
console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length);
|
||||||
setPartner(data.partner);
|
setPartner(data.partner);
|
||||||
|
partnerRef.current = data.partner;
|
||||||
const msgs: ChatMsg[] = data.messages.map((m: any) => ({
|
const msgs: ChatMsg[] = data.messages.map((m: any) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId),
|
userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId),
|
||||||
@ -125,13 +132,14 @@ export default function DmScreen() {
|
|||||||
if (row.receiver_id !== myUserId) return;
|
if (row.receiver_id !== myUserId) return;
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
if (prev.some((m) => m.id === row.id)) return prev;
|
if (prev.some((m) => m.id === row.id)) return prev;
|
||||||
|
const p = partnerRef.current;
|
||||||
return [
|
return [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: row.id,
|
id: row.id,
|
||||||
userId: row.sender_id,
|
userId: row.sender_id,
|
||||||
nickname: partner?.nickname ?? '?',
|
nickname: p?.nickname ?? '?',
|
||||||
avatar: partner?.avatar ?? null,
|
avatar: p?.avatar ?? null,
|
||||||
content: row.content ?? '',
|
content: row.content ?? '',
|
||||||
replyTo: null,
|
replyTo: null,
|
||||||
attachmentUrl: row.attachment_url ?? null,
|
attachmentUrl: row.attachment_url ?? null,
|
||||||
@ -146,7 +154,7 @@ export default function DmScreen() {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[myUserId, partner],
|
[myUserId],
|
||||||
);
|
);
|
||||||
useDmRealtime(userId, onDmInsert, !!myUserId);
|
useDmRealtime(userId, onDmInsert, !!myUserId);
|
||||||
|
|
||||||
@ -253,7 +261,6 @@ export default function DmScreen() {
|
|||||||
{partner?.nickname ?? '…'}
|
{partner?.nickname ?? '…'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ width: 36 }} />
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@ -261,6 +268,8 @@ export default function DmScreen() {
|
|||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
keyboardVerticalOffset={0}
|
keyboardVerticalOffset={0}
|
||||||
>
|
>
|
||||||
|
<View style={{ flex: 1, backgroundColor: chatBg }}>
|
||||||
|
<DmChatBackground />
|
||||||
{isLoading && messages.length === 0 ? (
|
{isLoading && messages.length === 0 ? (
|
||||||
<View style={styles.loadingBox}>
|
<View style={styles.loadingBox}>
|
||||||
<ActivityIndicator color={colors.brandOrange} />
|
<ActivityIndicator color={colors.brandOrange} />
|
||||||
@ -291,6 +300,7 @@ export default function DmScreen() {
|
|||||||
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
|
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
|
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
|
||||||
<ChatInput
|
<ChatInput
|
||||||
@ -311,7 +321,6 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
@ -330,8 +339,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
marginLeft: 8,
|
||||||
marginHorizontal: 8,
|
|
||||||
},
|
},
|
||||||
headerAvatar: {
|
headerAvatar: {
|
||||||
width: 32,
|
width: 32,
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Image,
|
Image,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@ -14,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { resolveAvatar } from '../../lib/resolveAvatar';
|
import { resolveAvatar } from '../../lib/resolveAvatar';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
import { useThemeStore } from '../../stores/theme';
|
||||||
|
|
||||||
export type ChatMsg = {
|
export type ChatMsg = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -53,6 +53,19 @@ function formatTime(ts: string) {
|
|||||||
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useBubbleColors() {
|
||||||
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||||
|
const isDark = colorScheme === 'dark';
|
||||||
|
return {
|
||||||
|
ownBg: isDark ? '#1e4d3a' : '#D1F4CC',
|
||||||
|
ownText: isDark ? '#e8f5e2' : '#0a0a0a',
|
||||||
|
otherBg: isDark ? '#2c2c2e' : '#ffffff',
|
||||||
|
otherText: isDark ? '#ffffff' : '#0a0a0a',
|
||||||
|
replyBarColor: '#25D366',
|
||||||
|
readColor: '#34B7F1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatBubble({
|
export function ChatBubble({
|
||||||
msg,
|
msg,
|
||||||
showName = false,
|
showName = false,
|
||||||
@ -66,20 +79,30 @@ export function ChatBubble({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const styles = makeStyles(colors);
|
const styles = makeStyles(colors);
|
||||||
|
const bubbleColors = useBubbleColors();
|
||||||
const [actionsOpen, setActionsOpen] = useState(false);
|
const [actionsOpen, setActionsOpen] = useState(false);
|
||||||
|
|
||||||
const isImageOnly =
|
const isImageOnly =
|
||||||
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
|
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
|
||||||
const replyHasAttachment = msg.replyTo?.attachmentType === 'image';
|
const replyHasAttachment = msg.replyTo?.attachmentType === 'image';
|
||||||
const avatarUrl = msg.avatar ? msg.avatar : resolveAvatar(null, msg.nickname ?? '?');
|
const avatarUrl = resolveAvatar(msg.avatar, msg.nickname ?? '?');
|
||||||
|
|
||||||
const cornerStyle = msg.isOwn
|
const ownBubbleRadius = {
|
||||||
? isLastInGroup
|
borderTopLeftRadius: 14,
|
||||||
? { borderBottomRightRadius: 6 }
|
borderTopRightRadius: isFirstInGroup ? 14 : 4,
|
||||||
: { borderTopRightRadius: 6, borderBottomRightRadius: 6 }
|
borderBottomLeftRadius: 14,
|
||||||
: isLastInGroup
|
borderBottomRightRadius: isLastInGroup ? 4 : 4,
|
||||||
? { borderBottomLeftRadius: 6 }
|
};
|
||||||
: { borderTopLeftRadius: 6, borderBottomLeftRadius: 6 };
|
|
||||||
|
const otherBubbleRadius = {
|
||||||
|
borderTopLeftRadius: isFirstInGroup ? 14 : 4,
|
||||||
|
borderTopRightRadius: 14,
|
||||||
|
borderBottomLeftRadius: isLastInGroup ? 4 : 4,
|
||||||
|
borderBottomRightRadius: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bubbleBg = msg.isOwn ? bubbleColors.ownBg : bubbleColors.otherBg;
|
||||||
|
const bubbleText = msg.isOwn ? bubbleColors.ownText : bubbleColors.otherText;
|
||||||
|
|
||||||
function copyContent() {
|
function copyContent() {
|
||||||
if (msg.content) Clipboard.setStringAsync(msg.content);
|
if (msg.content) Clipboard.setStringAsync(msg.content);
|
||||||
@ -95,7 +118,6 @@ export function ChatBubble({
|
|||||||
{ marginTop: isFirstInGroup ? 8 : 2 },
|
{ marginTop: isFirstInGroup ? 8 : 2 },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{/* Avatar slot left (last of group, not own) */}
|
|
||||||
{!msg.isOwn && (
|
{!msg.isOwn && (
|
||||||
<View style={styles.avatarSlot}>
|
<View style={styles.avatarSlot}>
|
||||||
{isLastInGroup ? (
|
{isLastInGroup ? (
|
||||||
@ -111,31 +133,28 @@ export function ChatBubble({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Pressable
|
<TouchableOpacity
|
||||||
delayLongPress={350}
|
delayLongPress={350}
|
||||||
onLongPress={() => setActionsOpen(true)}
|
onLongPress={() => setActionsOpen(true)}
|
||||||
onPress={() => {
|
activeOpacity={1}
|
||||||
/* tap eats - keeps long-press primary */
|
|
||||||
}}
|
|
||||||
style={[
|
style={[
|
||||||
styles.bubble,
|
styles.bubble,
|
||||||
msg.isOwn ? styles.bubbleOwn : styles.bubbleOther,
|
msg.isOwn ? ownBubbleRadius : otherBubbleRadius,
|
||||||
cornerStyle,
|
{ backgroundColor: bubbleBg },
|
||||||
|
!msg.isOwn && styles.bubbleOtherBorder,
|
||||||
isImageOnly && { padding: 4 },
|
isImageOnly && { padding: 4 },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{/* Reply preview */}
|
|
||||||
{msg.replyTo && (
|
{msg.replyTo && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
|
||||||
/* could implement scroll-to */
|
|
||||||
}}
|
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
style={[
|
style={[
|
||||||
styles.replyPreview,
|
styles.replyPreview,
|
||||||
{
|
{
|
||||||
backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.18)' : '#e5e5e5',
|
backgroundColor: msg.isOwn
|
||||||
borderLeftColor: msg.isOwn ? '#fff' : '#007AFF',
|
? 'rgba(0,0,0,0.08)'
|
||||||
|
: colors.surfaceElevated,
|
||||||
|
borderLeftColor: bubbleColors.replyBarColor,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@ -143,7 +162,7 @@ export function ChatBubble({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: msg.isOwn ? '#fff' : '#007AFF',
|
color: bubbleColors.replyBarColor,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@ -153,20 +172,19 @@ export function ChatBubble({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: msg.isOwn ? 'rgba(255,255,255,0.85)' : '#737373',
|
color: colors.textMuted,
|
||||||
marginTop: 1,
|
marginTop: 1,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{replyHasAttachment && (
|
{replyHasAttachment && (
|
||||||
<Ionicons name="image" size={11} color={msg.isOwn ? '#fff' : '#737373'} />
|
<Ionicons name="image" size={11} color={colors.textMuted} />
|
||||||
)}{' '}
|
)}{' '}
|
||||||
{msg.replyTo.content || (replyHasAttachment ? t('chat.image_attachment') : '…')}
|
{msg.replyTo.content || (replyHasAttachment ? t('chat.image_attachment') : '…')}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Image attachment */}
|
|
||||||
{msg.attachmentUrl && msg.attachmentType === 'image' && (
|
{msg.attachmentUrl && msg.attachmentType === 'image' && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => onOpenImage(msg.attachmentUrl!)}
|
onPress={() => onOpenImage(msg.attachmentUrl!)}
|
||||||
@ -194,7 +212,6 @@ export function ChatBubble({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File attachment */}
|
|
||||||
{msg.attachmentUrl && msg.attachmentType !== 'image' && (
|
{msg.attachmentUrl && msg.attachmentType !== 'image' && (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -204,20 +221,16 @@ export function ChatBubble({
|
|||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.15)' : '#e5e5e5',
|
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name="document-attach" size={18} color={colors.textMuted} />
|
||||||
name="document-attach"
|
|
||||||
size={18}
|
|
||||||
color={msg.isOwn ? '#fff' : '#525252'}
|
|
||||||
/>
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
color: msg.isOwn ? '#fff' : '#171717',
|
color: bubbleText,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@ -227,21 +240,14 @@ export function ChatBubble({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{msg.content !== '' && (
|
{msg.content !== '' && (
|
||||||
<Text
|
<Text style={[styles.content, { color: bubbleText }]}>
|
||||||
style={[
|
|
||||||
styles.content,
|
|
||||||
{ color: msg.isOwn ? '#ffffff' : colors.text },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer: timestamp + read-receipt inline below content */}
|
|
||||||
{!isImageOnly && (
|
{!isImageOnly && (
|
||||||
<View style={[styles.footer, { justifyContent: msg.isOwn ? 'flex-end' : 'flex-start' }]}>
|
<View style={styles.footer}>
|
||||||
{msg.likesCount > 0 && (
|
{msg.likesCount > 0 && (
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}>
|
||||||
<Ionicons name="heart" size={10} color="#f87171" />
|
<Ionicons name="heart" size={10} color="#f87171" />
|
||||||
@ -250,7 +256,7 @@ export function ChatBubble({
|
|||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: msg.isOwn ? 'rgba(255,255,255,0.75)' : '#a3a3a3',
|
color: colors.textMuted,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{msg.likesCount}
|
{msg.likesCount}
|
||||||
@ -261,7 +267,7 @@ export function ChatBubble({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: msg.isOwn ? 'rgba(255,255,255,0.7)' : colors.textMuted,
|
color: msg.isOwn ? 'rgba(0,0,0,0.45)' : colors.textMuted,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatTime(msg.createdAt)}
|
{formatTime(msg.createdAt)}
|
||||||
@ -270,17 +276,16 @@ export function ChatBubble({
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name={msg.readAt ? 'checkmark-done' : 'checkmark'}
|
name={msg.readAt ? 'checkmark-done' : 'checkmark'}
|
||||||
size={12}
|
size={12}
|
||||||
color={msg.readAt ? '#93c5fd' : 'rgba(255,255,255,0.7)'}
|
color={msg.readAt ? bubbleColors.readColor : 'rgba(0,0,0,0.35)'}
|
||||||
style={{ marginLeft: 3 }}
|
style={{ marginLeft: 2 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Long-press action sheet */}
|
|
||||||
<Modal
|
<Modal
|
||||||
visible={actionsOpen}
|
visible={actionsOpen}
|
||||||
transparent
|
transparent
|
||||||
@ -288,7 +293,7 @@ export function ChatBubble({
|
|||||||
onRequestClose={() => setActionsOpen(false)}
|
onRequestClose={() => setActionsOpen(false)}
|
||||||
>
|
>
|
||||||
<TouchableOpacity style={styles.sheetBackdrop} onPress={() => setActionsOpen(false)} activeOpacity={1}>
|
<TouchableOpacity style={styles.sheetBackdrop} onPress={() => setActionsOpen(false)} activeOpacity={1}>
|
||||||
<Pressable style={styles.sheet} onPress={() => {}}>
|
<TouchableOpacity style={styles.sheet} onPress={() => {}} activeOpacity={1}>
|
||||||
<View style={styles.sheetGrabber} />
|
<View style={styles.sheetGrabber} />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.sheetItem}
|
style={styles.sheetItem}
|
||||||
@ -324,7 +329,7 @@ export function ChatBubble({
|
|||||||
<Text style={styles.sheetText}>{t('chat.copy')}</Text>
|
<Text style={styles.sheetText}>{t('chat.copy')}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
@ -354,37 +359,33 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
nickname: {
|
nickname: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#007AFF',
|
color: '#25D366',
|
||||||
marginBottom: 3,
|
marginBottom: 3,
|
||||||
marginLeft: 12,
|
marginLeft: 12,
|
||||||
},
|
},
|
||||||
bubble: {
|
bubble: {
|
||||||
borderRadius: 20,
|
paddingHorizontal: 12,
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
paddingBottom: 6,
|
paddingBottom: 6,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOpacity: 0.06,
|
shadowOpacity: 0.08,
|
||||||
shadowRadius: 2,
|
shadowRadius: 3,
|
||||||
shadowOffset: { width: 0, height: 1 },
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
elevation: 1,
|
||||||
},
|
},
|
||||||
bubbleOwn: {
|
bubbleOtherBorder: {
|
||||||
backgroundColor: '#007AFF',
|
|
||||||
},
|
|
||||||
bubbleOther: {
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
borderColor: colors.border,
|
borderColor: 'rgba(0,0,0,0.06)',
|
||||||
},
|
},
|
||||||
replyPreview: {
|
replyPreview: {
|
||||||
borderLeftWidth: 3,
|
borderLeftWidth: 4,
|
||||||
borderRadius: 8,
|
borderRadius: 6,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
imageWrap: {
|
imageWrap: {
|
||||||
borderRadius: 14,
|
borderRadius: 10,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
@ -412,8 +413,10 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
footer: {
|
footer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
gap: 3,
|
gap: 3,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
|
alignSelf: 'flex-end',
|
||||||
},
|
},
|
||||||
sheetBackdrop: {
|
sheetBackdrop: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
123
apps/rebreak-native/components/chat/DmChatBackground.tsx
Normal file
123
apps/rebreak-native/components/chat/DmChatBackground.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useWindowDimensions, View } from 'react-native';
|
||||||
|
import Svg, { Circle, Path, Line, Rect } from 'react-native-svg';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
|
const TILE = 80;
|
||||||
|
const OPACITY = 0.07;
|
||||||
|
|
||||||
|
type Symbol = 'star' | 'heart' | 'check' | 'dot' | 'cloud' | 'wave' | 'diamond';
|
||||||
|
|
||||||
|
const SEQUENCE: Symbol[] = [
|
||||||
|
'star', 'dot', 'heart', 'wave', 'check', 'dot', 'cloud',
|
||||||
|
'diamond', 'dot', 'star', 'check', 'heart', 'dot', 'wave',
|
||||||
|
];
|
||||||
|
|
||||||
|
function SymbolShape({ type, color }: { type: Symbol; color: string }) {
|
||||||
|
switch (type) {
|
||||||
|
case 'star':
|
||||||
|
return (
|
||||||
|
<Path
|
||||||
|
d="M10 2 L11.8 7.6 L18 7.6 L13 11.2 L14.8 16.8 L10 13.2 L5.2 16.8 L7 11.2 L2 7.6 L8.2 7.6 Z"
|
||||||
|
fill={color}
|
||||||
|
opacity={OPACITY}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'heart':
|
||||||
|
return (
|
||||||
|
<Path
|
||||||
|
d="M10 15 C10 15 3 10 3 6 C3 3.8 4.8 2 7 2 C8.3 2 9.5 2.7 10 3.7 C10.5 2.7 11.7 2 13 2 C15.2 2 17 3.8 17 6 C17 10 10 15 10 15 Z"
|
||||||
|
fill={color}
|
||||||
|
opacity={OPACITY}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'check':
|
||||||
|
return (
|
||||||
|
<Path
|
||||||
|
d="M3 9 L7 13 L17 4"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
opacity={OPACITY}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'dot':
|
||||||
|
return <Circle cx="10" cy="10" r="3.5" fill={color} opacity={OPACITY} />;
|
||||||
|
case 'cloud':
|
||||||
|
return (
|
||||||
|
<Path
|
||||||
|
d="M5 12 C3.3 12 2 10.7 2 9 C2 7.4 3.2 6.1 4.7 6 C5 4.3 6.5 3 8.3 3 C9.8 3 11.1 3.9 11.7 5.2 C12 5.1 12.3 5 12.7 5 C14.5 5 16 6.5 16 8.3 C16 10.3 14.3 12 12.3 12 Z"
|
||||||
|
fill={color}
|
||||||
|
opacity={OPACITY}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'wave':
|
||||||
|
return (
|
||||||
|
<Path
|
||||||
|
d="M2 10 Q5 7 8 10 Q11 13 14 10 Q17 7 20 10"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
opacity={OPACITY}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'diamond':
|
||||||
|
return (
|
||||||
|
<Path
|
||||||
|
d="M10 2 L18 10 L10 18 L2 10 Z"
|
||||||
|
fill={color}
|
||||||
|
opacity={OPACITY}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DmChatBackground() {
|
||||||
|
const { width, height } = useWindowDimensions();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
|
const patternColor = colors.text;
|
||||||
|
|
||||||
|
const cols = Math.ceil(width / TILE) + 1;
|
||||||
|
const rows = Math.ceil(height / TILE) + 1;
|
||||||
|
|
||||||
|
const symbols = useMemo(() => {
|
||||||
|
const items: { x: number; y: number; type: Symbol; rotate: number }[] = [];
|
||||||
|
let seq = 0;
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const offsetX = r % 2 === 0 ? 0 : TILE / 2;
|
||||||
|
items.push({
|
||||||
|
x: c * TILE + offsetX,
|
||||||
|
y: r * TILE,
|
||||||
|
type: SEQUENCE[seq % SEQUENCE.length],
|
||||||
|
rotate: [0, 15, -10, 30, -20, 5, -15][seq % 7],
|
||||||
|
});
|
||||||
|
seq++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [cols, rows]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} pointerEvents="none">
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
{symbols.map((s, i) => (
|
||||||
|
<Svg
|
||||||
|
key={i}
|
||||||
|
x={s.x - 10}
|
||||||
|
y={s.y - 10}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<SymbolShape type={s.type} color={patternColor} />
|
||||||
|
</Svg>
|
||||||
|
))}
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user