chahinebrini 2e49aad386 feat(voice+chat): voice notes DM, chat list attachment preview, DiGA milestone modal
Voice Notes (DM):
- WhatsApp-style voice recording bar (shared VoiceRecordingBar component)
- Audio bubbles: 80 fixed-2dp bars (Instagram-style thin), space-between layout,
  deterministic waveform, moving blue position dot, WA gray bar colors
- Cancel flash fix: setIsVoiceRecording delayed 350ms so trash flash is visible
- Mic button 44pt (Apple min), hitSlop on all recording controls
- startReply shows 🎤/📷 label for voice/image instead of empty

Chat list:
- lastAttachmentType from backend (getDmConversations now selects attachmentType)
- Shows '🎤 Sprachnachricht' / '📷 Foto' / '📎 Medien' as fallback per type
- User search second stage: GET /api/users/search?q= + debounced frontend section
- Push preview: audio → '🎤 Sprachnachricht', image → '📷 Foto' (was '📎 Anhang')

Blocker iOS Layer 3 (Screen Time):
- ScreentimePasscodeCard visible in locked-in state (was hidden once both layers active)
- Confirmed status loaded from backend on mount
- Numbered step instructions (iOS has no deep link to passcode dialog)
- Guard: only for unsupervised VPN+FC path (!mdmManaged && !nefilterActive)
- URL fallback: App-Prefs:SCREEN_TIME → App-Prefs:root=SCREEN_TIME → openSettings

DiGA Milestone Modal:
- Day 3/7/10 celebratory bottom sheet with soft demographic data ask
- Per-user/milestone AsyncStorage tracking, never shows if demographics filled
- Opens DemographicsAccordion in profile via ?openDemo=1 param

Lyra coach: contextual DiGA demographic nudge (optional, positive moments only)
i18n: DE/EN/FR/AR for voice_message, photo, media_sent, mic_access, diga_milestone,
  screentime steps, chat search strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 01:59:26 +02:00

219 lines
6.1 KiB
TypeScript

import { usePrisma } from "../utils/prisma";
// ─── Gruppen-Chat ─────────────────────────────────────────────────────────────
export async function getChatMessages(limit = 100) {
const db = usePrisma();
return db.chatMessage.findMany({
where: { roomId: null },
orderBy: { createdAt: "asc" },
take: limit,
select: { id: true, content: true, createdAt: true, userId: true },
});
}
export async function createChatMessage(userId: string, content: string) {
const db = usePrisma();
return db.chatMessage.create({
data: { userId, content, roomId: null },
select: { id: true, content: true, createdAt: true, userId: true },
});
}
// ─── Direktnachrichten ───────────────────────────────────────────────────────
export async function sendDirectMessage(
senderId: string,
receiverId: string,
content: string,
opts?: {
replyToId?: string;
attachmentUrl?: string;
attachmentType?: string;
attachmentName?: string;
},
) {
const db = usePrisma();
const msg = await db.directMessage.create({
data: {
senderId,
receiverId,
content,
replyToId: opts?.replyToId || null,
attachmentUrl: opts?.attachmentUrl || null,
attachmentType: opts?.attachmentType || null,
attachmentName: opts?.attachmentName || null,
},
select: {
id: true,
content: true,
createdAt: true,
replyToId: true,
attachmentUrl: true,
attachmentType: true,
attachmentName: true,
likesCount: true,
replyTo: {
select: { id: true, senderId: true, content: true },
},
},
});
// Push-Notification (fire-and-forget — blockt Response nicht)
void (async () => {
try {
const { sendChatPush, getDisplayName, truncatePreview } = await import(
"../services/push"
);
const senderName = await getDisplayName(senderId);
const preview = truncatePreview(
content ||
(opts?.attachmentType === "audio" ? "🎤 Sprachnachricht" :
opts?.attachmentType === "image" || opts?.attachmentUrl ? "📷 Foto" : "")
);
console.log(`[dm-push] sender=${senderId} receiver=${receiverId} preview="${preview.slice(0, 30)}"`);
await sendChatPush({
receiverId,
senderName,
preview,
data: { type: "dm", targetId: senderId, messageId: msg.id },
});
} catch (err) {
console.error("[dm-push] failed:", err);
}
})();
return msg;
}
export async function getDmHistory(
userId: string,
partnerId: string,
page = 1,
limit = 50,
) {
const db = usePrisma();
const offset = (page - 1) * limit;
return db.directMessage.findMany({
where: {
OR: [
{ senderId: userId, receiverId: partnerId },
{ senderId: partnerId, receiverId: userId },
],
},
orderBy: { createdAt: "desc" },
skip: offset,
take: limit,
select: {
id: true,
senderId: true,
receiverId: true,
content: true,
createdAt: true,
readAt: true,
replyToId: true,
attachmentUrl: true,
attachmentType: true,
attachmentName: true,
likesCount: true,
deletedAt: true,
reactions: {
select: { userId: true, emoji: true },
},
replyTo: {
select: { id: true, senderId: true, content: true },
},
},
});
}
/**
* Toggle einer Emoji-Reaktion auf eine DM (WhatsApp-Verhalten):
* - kein Eintrag → Reaktion anlegen
* - gleiches Emoji → Reaktion entfernen (toggle off)
* - anderes Emoji → Reaktion ersetzen
* Eine Reaktion pro User pro Message (PK user+message).
*/
export async function toggleDmReaction(
userId: string,
messageId: string,
emoji: string,
) {
const db = usePrisma();
const existing = await db.directMessageReaction.findUnique({
where: { userId_messageId: { userId, messageId } },
});
if (existing) {
if (existing.emoji === emoji) {
await db.directMessageReaction.delete({
where: { userId_messageId: { userId, messageId } },
});
return { emoji: null as string | null };
}
const updated = await db.directMessageReaction.update({
where: { userId_messageId: { userId, messageId } },
data: { emoji },
});
return { emoji: updated.emoji };
}
const created = await db.directMessageReaction.create({
data: { userId, messageId, emoji },
});
return { emoji: created.emoji };
}
/**
* Soft-Delete einer DM (Tombstone). Nur eigene Nachrichten löschbar.
* @returns true wenn gelöscht, false wenn nicht erlaubt/nicht gefunden.
*/
export async function softDeleteDmMessage(userId: string, messageId: string) {
const db = usePrisma();
const res = await db.directMessage.updateMany({
where: { id: messageId, senderId: userId, deletedAt: null },
data: { deletedAt: new Date() },
});
return res.count > 0;
}
export async function markDmsAsRead(senderId: string, receiverId: string) {
const db = usePrisma();
return db.directMessage.updateMany({
where: { senderId, receiverId, readAt: null },
data: { readAt: new Date() },
});
}
export async function getDmConversations(userId: string) {
const db = usePrisma();
// Alle DMs als Sender oder Empfänger, neueste zuerst
return db.directMessage.findMany({
where: {
OR: [{ senderId: userId }, { receiverId: userId }],
},
orderBy: { createdAt: "desc" },
take: 500,
select: {
id: true,
senderId: true,
receiverId: true,
content: true,
createdAt: true,
readAt: true,
attachmentType: true,
},
});
}
export async function countUnreadDms(receiverId: string) {
const db = usePrisma();
const rows = await db.directMessage.findMany({
where: { receiverId, readAt: null },
select: { senderId: true },
});
const byPartner: Record<string, number> = {};
for (const r of rows) {
byPartner[r.senderId] = (byPartner[r.senderId] ?? 0) + 1;
}
return byPartner;
}