From 2591b2a89ccfe4017d912f3baa69ebcd86597d34 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sat, 30 May 2026 11:18:51 +0200 Subject: [PATCH] feat(chat): WhatsApp-Style Reaktions-/Aktions-Popup (DM) + Reaction-Pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MessageActionMenu: an der Bubble verankert (measureInWindow) statt zentriert, Blur-Backdrop, Emoji-Leiste oben (fremd) + Aktions-Liste unten (fremd: Antworten/Kopieren, eigen: Kopieren/Löschen) - ChatBubble: Long-Press → measure + Menu, Reaction-Pills unter Bubble, Tombstone "Nachricht gelöscht"; ersetzt @expo-action-sheet - dm.tsx: optimistisches Reaction-Toggle + Delete-Confirm + Realtime-Refetch (Reaction-Changes + Partner-Soft-Delete) - useChatRealtime: DM-Hook lauscht zusätzlich auf reactions + message-UPDATE - PostCommentsSheet: optimistisches Herz + Realtime-Subscription + größeres Icon - i18n (de/en/fr/ar): chat.delete/message_deleted/delete_confirm_* + public_domain Co-Authored-By: Claude Opus 4.8 --- apps/rebreak-native/app/dm.tsx | 86 ++++++- .../components/PostCommentsSheet.tsx | 58 ++++- .../components/chat/ChatBubble.tsx | 120 +++++++--- .../components/chat/MessageActionMenu.tsx | 216 ++++++++++++++++++ apps/rebreak-native/hooks/useChatRealtime.ts | 31 ++- apps/rebreak-native/locales/ar.json | 4 + apps/rebreak-native/locales/de.json | 4 + apps/rebreak-native/locales/en.json | 4 + apps/rebreak-native/locales/fr.json | 4 + 9 files changed, 494 insertions(+), 33 deletions(-) create mode 100644 apps/rebreak-native/components/chat/MessageActionMenu.tsx diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx index 68a37e7..9127987 100644 --- a/apps/rebreak-native/app/dm.tsx +++ b/apps/rebreak-native/app/dm.tsx @@ -23,7 +23,7 @@ import * as ImagePicker from 'expo-image-picker'; // TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14 import * as FileSystem from 'expo-file-system/legacy'; import { apiFetch } from '../lib/api'; -import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble'; +import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble'; import { DmChatBackground } from '../components/chat/DmChatBackground'; import { useDmRealtime } from '../hooks/useChatRealtime'; import { useColors } from '../lib/theme'; @@ -158,6 +158,8 @@ export default function DmScreen() { createdAt: m.createdAt, isOwn: m.isOwn, readAt: m.readAt, + reactions: m.reactions ?? [], + deleted: m.deleted ?? false, })); setMessages(msgs); requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: false })); @@ -210,7 +212,11 @@ export default function DmScreen() { }, [myUserId], ); - useDmRealtime(userId, onDmInsert, !!myUserId); + // Realtime: Partner-Soft-Delete (Tombstone) + Reaktions-Änderungen → refetch. + const refetchHistory = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['dm-history', userId] }); + }, [queryClient, userId]); + useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory); async function pickImage() { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); @@ -333,6 +339,80 @@ export default function DmScreen() { } catch {} } + // Optimistisches Anwenden einer Reaktion auf die aggregierte Pills-Liste + // (WhatsApp-Toggle: gleiches Emoji entfernt, anderes ersetzt meine Reaktion). + function applyReactionOptimistic( + reactions: MessageReaction[], + emoji: string, + ): MessageReaction[] { + const list = reactions.map((r) => ({ ...r })); + const mine = list.find((r) => r.mine); + const toggledOff = mine?.emoji === emoji; + if (mine) { + mine.count -= 1; + mine.mine = false; + } + const cleaned = list.filter((r) => r.count > 0); + if (toggledOff) return cleaned; + const existing = cleaned.find((r) => r.emoji === emoji); + if (existing) { + existing.count += 1; + existing.mine = true; + } else { + cleaned.push({ emoji, count: 1, mine: true }); + } + return cleaned; + } + + async function toggleReaction(msg: ChatMsg, emoji: string) { + setMessages((prev) => + prev.map((m) => + m.id === msg.id + ? { ...m, reactions: applyReactionOptimistic(m.reactions ?? [], emoji) } + : m, + ), + ); + try { + await apiFetch('/api/chat/reaction', { + method: 'POST', + body: { messageId: msg.id, emoji }, + }); + } catch { + refetchHistory(); // Rollback via Server-State + } + } + + function deleteMessage(msg: ChatMsg) { + Alert.alert( + t('chat.delete_confirm_title'), + t('chat.delete_confirm_msg'), + [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('chat.delete'), + style: 'destructive', + onPress: async () => { + setMessages((prev) => + prev.map((m) => + m.id === msg.id + ? { ...m, deleted: true, content: '', attachmentUrl: null, attachmentType: null, reactions: [] } + : m, + ), + ); + try { + await apiFetch('/api/chat/delete-message', { + method: 'POST', + body: { messageId: msg.id }, + }); + } catch { + refetchHistory(); + } + }, + }, + ], + ); + } + function startReply(msg: ChatMsg) { setReplyTo({ id: msg.id, @@ -397,6 +477,8 @@ export default function DmScreen() { isLastInGroup={!sameAuthor(item, messages[index + 1])} onReply={startReply} onLike={toggleLike} + onReact={toggleReaction} + onDelete={deleteMessage} onOpenImage={() => {}} /> )} diff --git a/apps/rebreak-native/components/PostCommentsSheet.tsx b/apps/rebreak-native/components/PostCommentsSheet.tsx index 49e3dd6..1e9cb4a 100644 --- a/apps/rebreak-native/components/PostCommentsSheet.tsx +++ b/apps/rebreak-native/components/PostCommentsSheet.tsx @@ -21,6 +21,8 @@ import { useTranslation } from 'react-i18next'; import { apiFetch } from '../lib/api'; import { formatRelativeTime } from '../lib/formatTime'; import { useColors } from '../lib/theme'; +import { supabase } from '../lib/supabase'; +import type { RealtimeChannel } from '@supabase/supabase-js'; import type { CommunityComment } from '../stores/community'; import { UserAvatar } from './UserAvatar'; @@ -175,6 +177,38 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) { staleTime: 30_000, }); + // Realtime: revalidate comment list when any like on this post's comments changes. + // Requires rebreak.comment_likes to be in supabase_realtime publication — see + // backend migration TODO below. + useEffect(() => { + if (!postId || !visible) return; + let channel: RealtimeChannel | null = null; + let cancelled = false; + + async function subscribe() { + const { data } = await supabase.auth.getSession(); + if (!data.session?.access_token || cancelled) return; + + channel = supabase + .channel(`comment-likes:${postId}:${Date.now()}`) + .on( + 'postgres_changes', + { event: '*', schema: 'rebreak', table: 'comment_likes' }, + () => { + queryClient.invalidateQueries({ queryKey: ['post-comments', postId] }); + }, + ) + .subscribe(); + } + + subscribe(); + + return () => { + cancelled = true; + if (channel) supabase.removeChannel(channel); + }; + }, [postId, visible, queryClient]); + const topLevel = comments.filter((c) => !c.parentCommentId); const repliesFor = (id: string) => comments.filter((c) => c.parentCommentId === id); @@ -205,14 +239,32 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) { const likeComment = useCallback( async (comment: CommunityComment) => { + const queryKey = ['post-comments', postId]; + const optimisticLiked = !comment.userLike; + const countDelta = optimisticLiked ? 1 : -1; + + queryClient.setQueryData(queryKey, (prev) => + prev?.map((c) => + c.id === comment.id + ? { ...c, userLike: optimisticLiked, likesCount: Math.max(0, c.likesCount + countDelta) } + : c, + ), + ); + try { await apiFetch('/api/community/comment-like', { method: 'POST', body: { commentId: comment.id }, }); - queryClient.invalidateQueries({ queryKey: ['post-comments', postId] }); + queryClient.invalidateQueries({ queryKey }); } catch { - // ignore + queryClient.setQueryData(queryKey, (prev) => + prev?.map((c) => + c.id === comment.id + ? { ...c, userLike: comment.userLike, likesCount: comment.likesCount } + : c, + ), + ); } }, [postId, queryClient], @@ -521,7 +573,7 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro diff --git a/apps/rebreak-native/components/chat/ChatBubble.tsx b/apps/rebreak-native/components/chat/ChatBubble.tsx index c58c291..8534574 100644 --- a/apps/rebreak-native/components/chat/ChatBubble.tsx +++ b/apps/rebreak-native/components/chat/ChatBubble.tsx @@ -1,3 +1,4 @@ +import { useRef, useState } from 'react'; import { View, Text, @@ -8,10 +9,12 @@ import { Image } from 'expo-image'; import * as Clipboard from 'expo-clipboard'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; -import { useActionSheet } from '@expo/react-native-action-sheet'; import { useColors } from '../../lib/theme'; import { useThemeStore } from '../../stores/theme'; import { UserAvatar } from '../UserAvatar'; +import { MessageActionMenu, type AnchorRect } from './MessageActionMenu'; + +export type MessageReaction = { emoji: string; count: number; mine: boolean }; export type ChatMsg = { id: string; @@ -34,6 +37,10 @@ export type ChatMsg = { createdAt: string; isOwn: boolean; readAt?: string | null; + /** Aggregierte Emoji-Reaktionen (DM). */ + reactions?: MessageReaction[]; + /** Soft-Delete-Tombstone. */ + deleted?: boolean; }; type Props = { @@ -46,6 +53,10 @@ type Props = { isDM?: boolean; onReply: (msg: ChatMsg) => void; onLike: (msg: ChatMsg) => void; + /** DM-only: Emoji-Reaktion togglen. Group-Chat (deaktiviert) übergibt nichts. */ + onReact?: (msg: ChatMsg, emoji: string) => void; + /** DM-only: eigene Nachricht löschen (Soft-Delete). */ + onDelete?: (msg: ChatMsg) => void; onOpenImage: (url: string) => void; }; @@ -75,38 +86,26 @@ export function ChatBubble({ isDM = false, onReply, onLike, + onReact, + onDelete, onOpenImage, }: Props) { const { t } = useTranslation(); const colors = useColors(); const styles = makeStyles(colors); const bubbleColors = useBubbleColors(); - const { showActionSheetWithOptions } = useActionSheet(); + const bubbleRef = useRef(null); + const [menuVisible, setMenuVisible] = useState(false); + const [anchor, setAnchor] = useState(null); + const hasContent = msg.content !== ''; + const myReaction = msg.reactions?.find((r) => r.mine)?.emoji ?? null; function openActions() { - const hasContent = msg.content !== ''; - const likeLabel = msg.likedByMe ? t('chat.unlike') : t('chat.like'); - const options: string[] = [t('chat.reply'), likeLabel]; - if (hasContent) options.push(t('chat.copy')); - options.push(t('common.cancel')); - const cancelButtonIndex = options.length - 1; - showActionSheetWithOptions( - { - options, - cancelButtonIndex, - title: hasContent - ? msg.content.length > 60 - ? msg.content.slice(0, 60) + '…' - : msg.content - : undefined, - }, - (selected?: number) => { - if (selected === undefined || selected === cancelButtonIndex) return; - if (selected === 0) onReply(msg); - else if (selected === 1) onLike(msg); - else if (selected === 2 && hasContent) copyContent(); - }, - ); + if (msg.deleted) return; // gelöschte Nachrichten: kein Kontextmenü + bubbleRef.current?.measureInWindow((x, y, width, height) => { + setAnchor({ x, y, width, height }); + setMenuVisible(true); + }); } const isImageOnly = @@ -163,6 +162,7 @@ export function ChatBubble({ )} )} - {msg.content !== '' && ( + {msg.deleted ? ( + + {t('chat.message_deleted')} + + ) : msg.content !== '' ? ( {msg.content} - )} + ) : null} {!isImageOnly && ( @@ -333,8 +337,49 @@ export function ChatBubble({ )} + + {/* Emoji-Reaktions-Pills unter der Bubble (DM) */} + {msg.reactions && msg.reactions.length > 0 && ( + + {msg.reactions.map((r) => ( + onReact?.(msg, r.emoji)} + activeOpacity={0.7} + style={[ + styles.reactionPill, + { + backgroundColor: colors.surfaceElevated, + borderColor: r.mine ? colors.brandOrange : colors.border, + }, + ]} + > + {r.emoji} + {r.count > 1 && {r.count}} + + ))} + + )} + + setMenuVisible(false)} + onReact={(emoji) => onReact?.(msg, emoji)} + onReply={() => onReply(msg)} + onCopy={copyContent} + onDelete={() => onDelete?.(msg)} + /> ); } @@ -350,6 +395,27 @@ function makeStyles(colors: ReturnType) { marginRight: 6, justifyContent: 'flex-end', }, + reactionPills: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 4, + marginTop: -6, + marginBottom: 2, + }, + reactionPill: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + paddingHorizontal: 7, + paddingVertical: 2, + }, + reactionPillCount: { + fontSize: 11, + marginLeft: 3, + fontFamily: 'Nunito_600SemiBold', + color: colors.textMuted, + }, bubbleCol: { maxWidth: '76%', }, diff --git a/apps/rebreak-native/components/chat/MessageActionMenu.tsx b/apps/rebreak-native/components/chat/MessageActionMenu.tsx new file mode 100644 index 0000000..03f9f44 --- /dev/null +++ b/apps/rebreak-native/components/chat/MessageActionMenu.tsx @@ -0,0 +1,216 @@ +/** + * MessageActionMenu — WhatsApp-Style Long-Press-Kontextmenü für Chat-Bubbles. + * + * Ersetzt den zentriert gerenderten @expo/react-native-action-sheet. Wird an + * der gedrückten Bubble verankert (per measureInWindow gelieferte `anchor`-Rect): + * - Emoji-Reaktions-Leiste OBEN (nur bei fremden Nachrichten) + * - Aktions-Liste UNTEN (fremd: Antworten + Kopieren / eigen: Kopieren + Löschen) + * Blur-Backdrop (iOS) / semi-transparent (Android). Smart-Position: Menü unter + * der Bubble, oder darüber wenn unten kein Platz ist. + */ +import { useMemo } from 'react'; +import { + Dimensions, + Modal, + Platform, + Pressable, + StyleSheet, + Text, + TouchableOpacity, + View, + useColorScheme, +} from 'react-native'; +import { BlurView } from 'expo-blur'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { useColors } from '../../lib/theme'; + +const IS_IOS = Platform.OS === 'ios'; +const REACTION_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏', '👏']; + +export type AnchorRect = { x: number; y: number; width: number; height: number }; + +type Props = { + visible: boolean; + anchor: AnchorRect | null; + isOwn: boolean; + hasContent: boolean; + /** Aktuelles eigenes Reaktions-Emoji auf dieser Message (für Highlight). */ + myReaction?: string | null; + onClose: () => void; + onReact: (emoji: string) => void; + onReply: () => void; + onCopy: () => void; + onDelete: () => void; +}; + +export function MessageActionMenu({ + visible, + anchor, + isOwn, + hasContent, + myReaction, + onClose, + onReact, + onReply, + onCopy, + onDelete, +}: Props) { + const { t } = useTranslation(); + const colors = useColors(); + const scheme = useColorScheme(); + + const actions = useMemo(() => { + const list: { + key: string; + label: string; + icon: React.ComponentProps['name']; + danger?: boolean; + onPress: () => void; + }[] = []; + if (!isOwn) { + list.push({ key: 'reply', label: t('chat.reply'), icon: 'arrow-undo-outline', onPress: onReply }); + } + if (hasContent) { + list.push({ key: 'copy', label: t('chat.copy'), icon: 'copy-outline', onPress: onCopy }); + } + if (isOwn) { + list.push({ key: 'delete', label: t('chat.delete'), icon: 'trash-outline', danger: true, onPress: onDelete }); + } + return list; + }, [isOwn, hasContent, t, onReply, onCopy, onDelete]); + + if (!anchor) return null; + + const screenW = Dimensions.get('window').width; + const screenH = Dimensions.get('window').height; + const topSafe = 60; + const showReactions = !isOwn; + const barH = showReactions ? 60 : 0; + const estMenuH = actions.length * 52 + 12; + + // Menü unter der Bubble, sonst darüber. + const belowSpace = screenH - (anchor.y + anchor.height); + const placeBelow = belowSpace > estMenuH + 40; + const menuTop = placeBelow + ? anchor.y + anchor.height + 10 + : Math.max(topSafe + barH + 10, anchor.y - estMenuH - 10); + + // Reaktions-Leiste über der Bubble (geclampt unter die Safe-Area). + const barTop = Math.max(topSafe, anchor.y - barH - 4); + + // Horizontale Ausrichtung an der Bubble-Seite. + const sideStyle = isOwn + ? { right: Math.max(12, screenW - (anchor.x + anchor.width)) } + : { left: Math.max(12, anchor.x) }; + + return ( + + + {IS_IOS ? ( + + ) : ( + + )} + + {/* Emoji-Reaktions-Leiste (nur fremde Nachrichten) */} + {showReactions && ( + + {REACTION_EMOJIS.map((emoji) => { + const active = myReaction === emoji; + return ( + { + onReact(emoji); + onClose(); + }} + style={[styles.reactionBtn, active && { backgroundColor: colors.surfaceElevated }]} + > + {emoji} + + ); + })} + + )} + + {/* Aktions-Liste */} + + {actions.map((a, i) => ( + { + a.onPress(); + onClose(); + }} + style={[ + styles.menuItem, + i < actions.length - 1 && { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border }, + ]} + > + + {a.label} + + + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + reactionBar: { + position: 'absolute', + flexDirection: 'row', + alignItems: 'center', + borderRadius: 26, + paddingHorizontal: 6, + paddingVertical: 5, + gap: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 8, + }, + reactionBtn: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, + reactionEmoji: { fontSize: 26 }, + menu: { + position: 'absolute', + minWidth: 200, + borderRadius: 14, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.16, + shadowRadius: 16, + elevation: 10, + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingVertical: 15, + }, + menuLabel: { fontSize: 15, fontFamily: 'Nunito_600SemiBold' }, +}); diff --git a/apps/rebreak-native/hooks/useChatRealtime.ts b/apps/rebreak-native/hooks/useChatRealtime.ts index bea6d42..418226f 100644 --- a/apps/rebreak-native/hooks/useChatRealtime.ts +++ b/apps/rebreak-native/hooks/useChatRealtime.ts @@ -12,6 +12,12 @@ export function useDmRealtime( partnerId: string | undefined, onInsert: (row: any) => void, enabled: boolean = true, + // Partner UPDATEt eine eigene Message (z.B. Soft-Delete deleted_at, read_at). + onUpdate?: (row: any) => void, + // Irgendeine DM-Reaktion hat sich geändert → Caller refetcht die Konversation. + // (Die direct_message_reactions-Tabelle hat keine Partner-Spalte zum Filtern; + // für DM-Volumen ist ein Refetch-on-any akzeptabel.) + onReactionChange?: () => void, ) { useEffect(() => { if (!enabled || !partnerId) return; @@ -37,6 +43,29 @@ export function useDmRealtime( onInsert(payload.new); }, ) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "rebreak", + table: "direct_messages", + filter: `sender_id=eq.${partnerId}`, + }, + (payload: any) => { + onUpdate?.(payload.new); + }, + ) + .on( + "postgres_changes", + { + event: "*", + schema: "rebreak", + table: "direct_message_reactions", + }, + () => { + onReactionChange?.(); + }, + ) .subscribe((status) => { if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") { cleanup(); @@ -61,7 +90,7 @@ export function useDmRealtime( if (reconnectTimer) clearTimeout(reconnectTimer); cleanup(); }; - }, [partnerId, enabled, onInsert]); + }, [partnerId, enabled, onInsert, onUpdate, onReactionChange]); } /** diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json index 0e95055..1456888 100644 --- a/apps/rebreak-native/locales/ar.json +++ b/apps/rebreak-native/locales/ar.json @@ -956,6 +956,10 @@ "like": "إعجاب", "unlike": "إزالة الإعجاب", "copy": "نسخ", + "delete": "حذف", + "message_deleted": "تم حذف الرسالة", + "delete_confirm_title": "حذف الرسالة؟", + "delete_confirm_msg": "سيتم حذف هذه الرسالة للجميع — لا يمكن التراجع عن ذلك.", "image_attachment": "صورة", "file_attachment": "ملف", "upload_failed": "فشل الرفع", diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 8c878d9..95e59da 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -1021,6 +1021,10 @@ "like": "Liken", "unlike": "Like entfernen", "copy": "Kopieren", + "delete": "Löschen", + "message_deleted": "Nachricht gelöscht", + "delete_confirm_title": "Nachricht löschen?", + "delete_confirm_msg": "Diese Nachricht wird für alle gelöscht — das lässt sich nicht rückgängig machen.", "image_attachment": "Bild", "file_attachment": "Datei", "upload_failed": "Upload fehlgeschlagen", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 44db0e0..5a084a7 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -1021,6 +1021,10 @@ "like": "Like", "unlike": "Unlike", "copy": "Copy", + "delete": "Delete", + "message_deleted": "Message deleted", + "delete_confirm_title": "Delete message?", + "delete_confirm_msg": "This message will be deleted for everyone — this can't be undone.", "image_attachment": "Image", "file_attachment": "File", "upload_failed": "Upload failed", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index db4ee82..427d814 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -943,6 +943,10 @@ "like": "J'aime", "unlike": "Retirer le j'aime", "copy": "Copier", + "delete": "Supprimer", + "message_deleted": "Message supprimé", + "delete_confirm_title": "Supprimer le message ?", + "delete_confirm_msg": "Ce message sera supprimé pour tout le monde — c'est irréversible.", "image_attachment": "Image", "file_attachment": "Fichier", "upload_failed": "Échec du téléversement",