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 { resolveAvatar } from '../lib/resolveAvatar';
|
||||
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
|
||||
import { DmChatBackground } from '../components/chat/DmChatBackground';
|
||||
import { useDmRealtime } from '../hooks/useChatRealtime';
|
||||
import { useColors } from '../lib/theme';
|
||||
import { useThemeStore } from '../stores/theme';
|
||||
|
||||
type DmHistoryResponse = {
|
||||
partner: {
|
||||
@ -59,10 +61,14 @@ export default function DmScreen() {
|
||||
const flatRef = useRef<FlatList>(null);
|
||||
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 [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||
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>(
|
||||
null,
|
||||
);
|
||||
@ -84,6 +90,7 @@ export default function DmScreen() {
|
||||
const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
|
||||
console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length);
|
||||
setPartner(data.partner);
|
||||
partnerRef.current = data.partner;
|
||||
const msgs: ChatMsg[] = data.messages.map((m: any) => ({
|
||||
id: m.id,
|
||||
userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId),
|
||||
@ -125,13 +132,14 @@ export default function DmScreen() {
|
||||
if (row.receiver_id !== myUserId) return;
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === row.id)) return prev;
|
||||
const p = partnerRef.current;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: row.id,
|
||||
userId: row.sender_id,
|
||||
nickname: partner?.nickname ?? '?',
|
||||
avatar: partner?.avatar ?? null,
|
||||
nickname: p?.nickname ?? '?',
|
||||
avatar: p?.avatar ?? null,
|
||||
content: row.content ?? '',
|
||||
replyTo: null,
|
||||
attachmentUrl: row.attachment_url ?? null,
|
||||
@ -146,7 +154,7 @@ export default function DmScreen() {
|
||||
];
|
||||
});
|
||||
},
|
||||
[myUserId, partner],
|
||||
[myUserId],
|
||||
);
|
||||
useDmRealtime(userId, onDmInsert, !!myUserId);
|
||||
|
||||
@ -253,7 +261,6 @@ export default function DmScreen() {
|
||||
{partner?.nickname ?? '…'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ width: 36 }} />
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
@ -261,36 +268,39 @@ export default function DmScreen() {
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
{isLoading && messages.length === 0 ? (
|
||||
<View style={styles.loadingBox}>
|
||||
<ActivityIndicator color={colors.brandOrange} />
|
||||
</View>
|
||||
) : messages.length === 0 ? (
|
||||
<View style={styles.loadingBox}>
|
||||
<Ionicons name="chatbubble-outline" size={42} color="#d4d4d4" />
|
||||
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
ref={flatRef}
|
||||
data={messages}
|
||||
style={{ flex: 1 }}
|
||||
renderItem={({ item, index }) => (
|
||||
<ChatBubble
|
||||
msg={item}
|
||||
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
|
||||
isLastInGroup={!sameAuthor(item, messages[index + 1])}
|
||||
onReply={startReply}
|
||||
onLike={toggleLike}
|
||||
onOpenImage={() => {}}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(m) => m.id}
|
||||
contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
|
||||
/>
|
||||
)}
|
||||
<View style={{ flex: 1, backgroundColor: chatBg }}>
|
||||
<DmChatBackground />
|
||||
{isLoading && messages.length === 0 ? (
|
||||
<View style={styles.loadingBox}>
|
||||
<ActivityIndicator color={colors.brandOrange} />
|
||||
</View>
|
||||
) : messages.length === 0 ? (
|
||||
<View style={styles.loadingBox}>
|
||||
<Ionicons name="chatbubble-outline" size={42} color="#d4d4d4" />
|
||||
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
ref={flatRef}
|
||||
data={messages}
|
||||
style={{ flex: 1 }}
|
||||
renderItem={({ item, index }) => (
|
||||
<ChatBubble
|
||||
msg={item}
|
||||
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
|
||||
isLastInGroup={!sameAuthor(item, messages[index + 1])}
|
||||
onReply={startReply}
|
||||
onLike={toggleLike}
|
||||
onOpenImage={() => {}}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(m) => m.id}
|
||||
contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
|
||||
<ChatInput
|
||||
@ -311,7 +321,6 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: colors.bg,
|
||||
@ -330,8 +339,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginHorizontal: 8,
|
||||
marginLeft: 8,
|
||||
},
|
||||
headerAvatar: {
|
||||
width: 32,
|
||||
|
||||
@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StyleSheet,
|
||||
@ -14,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { resolveAvatar } from '../../lib/resolveAvatar';
|
||||
import { useColors } from '../../lib/theme';
|
||||
import { useThemeStore } from '../../stores/theme';
|
||||
|
||||
export type ChatMsg = {
|
||||
id: string;
|
||||
@ -53,6 +53,19 @@ function formatTime(ts: string) {
|
||||
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({
|
||||
msg,
|
||||
showName = false,
|
||||
@ -66,20 +79,30 @@ export function ChatBubble({
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const styles = makeStyles(colors);
|
||||
const bubbleColors = useBubbleColors();
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
|
||||
const isImageOnly =
|
||||
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
|
||||
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
|
||||
? isLastInGroup
|
||||
? { borderBottomRightRadius: 6 }
|
||||
: { borderTopRightRadius: 6, borderBottomRightRadius: 6 }
|
||||
: isLastInGroup
|
||||
? { borderBottomLeftRadius: 6 }
|
||||
: { borderTopLeftRadius: 6, borderBottomLeftRadius: 6 };
|
||||
const ownBubbleRadius = {
|
||||
borderTopLeftRadius: 14,
|
||||
borderTopRightRadius: isFirstInGroup ? 14 : 4,
|
||||
borderBottomLeftRadius: 14,
|
||||
borderBottomRightRadius: isLastInGroup ? 4 : 4,
|
||||
};
|
||||
|
||||
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() {
|
||||
if (msg.content) Clipboard.setStringAsync(msg.content);
|
||||
@ -95,7 +118,6 @@ export function ChatBubble({
|
||||
{ marginTop: isFirstInGroup ? 8 : 2 },
|
||||
]}
|
||||
>
|
||||
{/* Avatar slot left (last of group, not own) */}
|
||||
{!msg.isOwn && (
|
||||
<View style={styles.avatarSlot}>
|
||||
{isLastInGroup ? (
|
||||
@ -111,31 +133,28 @@ export function ChatBubble({
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
<TouchableOpacity
|
||||
delayLongPress={350}
|
||||
onLongPress={() => setActionsOpen(true)}
|
||||
onPress={() => {
|
||||
/* tap eats - keeps long-press primary */
|
||||
}}
|
||||
activeOpacity={1}
|
||||
style={[
|
||||
styles.bubble,
|
||||
msg.isOwn ? styles.bubbleOwn : styles.bubbleOther,
|
||||
cornerStyle,
|
||||
msg.isOwn ? ownBubbleRadius : otherBubbleRadius,
|
||||
{ backgroundColor: bubbleBg },
|
||||
!msg.isOwn && styles.bubbleOtherBorder,
|
||||
isImageOnly && { padding: 4 },
|
||||
]}
|
||||
>
|
||||
{/* Reply preview */}
|
||||
{msg.replyTo && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
/* could implement scroll-to */
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
style={[
|
||||
styles.replyPreview,
|
||||
{
|
||||
backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.18)' : '#e5e5e5',
|
||||
borderLeftColor: msg.isOwn ? '#fff' : '#007AFF',
|
||||
backgroundColor: msg.isOwn
|
||||
? 'rgba(0,0,0,0.08)'
|
||||
: colors.surfaceElevated,
|
||||
borderLeftColor: bubbleColors.replyBarColor,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@ -143,7 +162,7 @@ export function ChatBubble({
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
color: msg.isOwn ? '#fff' : '#007AFF',
|
||||
color: bubbleColors.replyBarColor,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@ -153,20 +172,19 @@ export function ChatBubble({
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: msg.isOwn ? 'rgba(255,255,255,0.85)' : '#737373',
|
||||
color: colors.textMuted,
|
||||
marginTop: 1,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{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') : '…')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Image attachment */}
|
||||
{msg.attachmentUrl && msg.attachmentType === 'image' && (
|
||||
<TouchableOpacity
|
||||
onPress={() => onOpenImage(msg.attachmentUrl!)}
|
||||
@ -194,7 +212,6 @@ export function ChatBubble({
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* File attachment */}
|
||||
{msg.attachmentUrl && msg.attachmentType !== 'image' && (
|
||||
<View
|
||||
style={{
|
||||
@ -204,20 +221,16 @@ export function ChatBubble({
|
||||
paddingVertical: 8,
|
||||
borderRadius: 10,
|
||||
marginBottom: 4,
|
||||
backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.15)' : '#e5e5e5',
|
||||
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="document-attach"
|
||||
size={18}
|
||||
color={msg.isOwn ? '#fff' : '#525252'}
|
||||
/>
|
||||
<Ionicons name="document-attach" size={18} color={colors.textMuted} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
marginLeft: 8,
|
||||
color: msg.isOwn ? '#fff' : '#171717',
|
||||
color: bubbleText,
|
||||
flex: 1,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
@ -227,21 +240,14 @@ export function ChatBubble({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{msg.content !== '' && (
|
||||
<Text
|
||||
style={[
|
||||
styles.content,
|
||||
{ color: msg.isOwn ? '#ffffff' : colors.text },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.content, { color: bubbleText }]}>
|
||||
{msg.content}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Footer: timestamp + read-receipt inline below content */}
|
||||
{!isImageOnly && (
|
||||
<View style={[styles.footer, { justifyContent: msg.isOwn ? 'flex-end' : 'flex-start' }]}>
|
||||
<View style={styles.footer}>
|
||||
{msg.likesCount > 0 && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}>
|
||||
<Ionicons name="heart" size={10} color="#f87171" />
|
||||
@ -250,7 +256,7 @@ export function ChatBubble({
|
||||
fontSize: 10,
|
||||
marginLeft: 2,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: msg.isOwn ? 'rgba(255,255,255,0.75)' : '#a3a3a3',
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{msg.likesCount}
|
||||
@ -261,7 +267,7 @@ export function ChatBubble({
|
||||
style={{
|
||||
fontSize: 10,
|
||||
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)}
|
||||
@ -270,17 +276,16 @@ export function ChatBubble({
|
||||
<Ionicons
|
||||
name={msg.readAt ? 'checkmark-done' : 'checkmark'}
|
||||
size={12}
|
||||
color={msg.readAt ? '#93c5fd' : 'rgba(255,255,255,0.7)'}
|
||||
style={{ marginLeft: 3 }}
|
||||
color={msg.readAt ? bubbleColors.readColor : 'rgba(0,0,0,0.35)'}
|
||||
style={{ marginLeft: 2 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Long-press action sheet */}
|
||||
<Modal
|
||||
visible={actionsOpen}
|
||||
transparent
|
||||
@ -288,7 +293,7 @@ export function ChatBubble({
|
||||
onRequestClose={() => setActionsOpen(false)}
|
||||
>
|
||||
<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} />
|
||||
<TouchableOpacity
|
||||
style={styles.sheetItem}
|
||||
@ -324,7 +329,7 @@ export function ChatBubble({
|
||||
<Text style={styles.sheetText}>{t('chat.copy')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Pressable>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
@ -354,37 +359,33 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||
nickname: {
|
||||
fontSize: 11,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
color: '#007AFF',
|
||||
color: '#25D366',
|
||||
marginBottom: 3,
|
||||
marginLeft: 12,
|
||||
},
|
||||
bubble: {
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 14,
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 6,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 2,
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 3,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
elevation: 1,
|
||||
},
|
||||
bubbleOwn: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
bubbleOther: {
|
||||
backgroundColor: colors.surface,
|
||||
bubbleOtherBorder: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.border,
|
||||
borderColor: 'rgba(0,0,0,0.06)',
|
||||
},
|
||||
replyPreview: {
|
||||
borderLeftWidth: 3,
|
||||
borderRadius: 8,
|
||||
borderLeftWidth: 4,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
marginBottom: 6,
|
||||
},
|
||||
imageWrap: {
|
||||
borderRadius: 14,
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
@ -412,8 +413,10 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 3,
|
||||
marginTop: 4,
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
sheetBackdrop: {
|
||||
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