feat(native): chat tab badge for unread DMs
Adds a tabBarBadge on the bottom Chat tab driven by the same dm-conversations query the chat screen already uses — React Query dedupes the call. Badge shows the unread total (capped to "99+") and disappears when 0. Query is gated on session so unauthenticated launches don't fire it.
This commit is contained in:
parent
d11d548c10
commit
ba170afd20
@ -12,6 +12,9 @@ import { MailConsentReminderSheet } from '../../components/mail/MailConsentRemin
|
|||||||
import { protection } from '../../lib/protection';
|
import { protection } from '../../lib/protection';
|
||||||
import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons';
|
import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
type DmConvUnreadSlice = { unreadCount?: number };
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -26,6 +29,17 @@ export default function AppLayout() {
|
|||||||
const rearmInFlightRef = useRef(false);
|
const rearmInFlightRef = useRef(false);
|
||||||
const bypassNotifiedRef = useRef(false);
|
const bypassNotifiedRef = useRef(false);
|
||||||
|
|
||||||
|
// Unread DMs → badge on the Chat tab. Same query key chat.tsx uses, so
|
||||||
|
// React Query dedupes (no double fetch when both layouts mount).
|
||||||
|
const { data: dmConvs = [] } = useQuery<DmConvUnreadSlice[]>({
|
||||||
|
queryKey: ['dm-conversations'],
|
||||||
|
queryFn: () => apiFetch('/api/chat/dm-conversations'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
enabled: !!session,
|
||||||
|
});
|
||||||
|
const unreadDms = dmConvs.reduce((sum, c) => sum + (c.unreadCount ?? 0), 0);
|
||||||
|
const chatBadge = unreadDms > 0 ? (unreadDms > 99 ? '99+' : String(unreadDms)) : undefined;
|
||||||
|
|
||||||
// Android-Tab-Icons müssen async aus Ionicons-Font generiert werden (kein
|
// Android-Tab-Icons müssen async aus Ionicons-Font generiert werden (kein
|
||||||
// SF-Symbol-Support). preloadTabIcons() läuft schon beim Modul-Import — hier
|
// SF-Symbol-Support). preloadTabIcons() läuft schon beim Modul-Import — hier
|
||||||
// nur den ready-State tracken damit wir re-rendern wenn der Cache fertig ist.
|
// nur den ready-State tracken damit wir re-rendern wenn der Cache fertig ist.
|
||||||
@ -207,6 +221,7 @@ export default function AppLayout() {
|
|||||||
name="chat"
|
name="chat"
|
||||||
options={{
|
options={{
|
||||||
title: t('tabs.chat'),
|
title: t('tabs.chat'),
|
||||||
|
tabBarBadge: chatBadge,
|
||||||
tabBarIcon: () =>
|
tabBarIcon: () =>
|
||||||
Platform.OS === 'ios'
|
Platform.OS === 'ios'
|
||||||
? { sfSymbol: 'bubble.left.and.bubble.right.fill' }
|
? { sfSymbol: 'bubble.left.and.bubble.right.fill' }
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
-- Migration: 20260516_custom_domain_type
|
||||||
|
--
|
||||||
|
-- Erweitert user_custom_domains um das `type`-Feld.
|
||||||
|
-- Drei erlaubte Typen:
|
||||||
|
-- 'web' — Web-Domain-Block (bisheriges Verhalten, Default)
|
||||||
|
-- 'mail_domain' — Sender-Domain-Block im Mail-Filter (analog web, nutzt Domain-Match-Layer)
|
||||||
|
-- 'mail_display_name' — Sender-Display-Name-Pattern-Block (Substring-Match, case-insensitive)
|
||||||
|
--
|
||||||
|
-- Slot-Pool: alle drei Types teilen den gleichen Slot-Pool pro Plan
|
||||||
|
-- (Free: 5, Pro: 5, Legend: 10 — countActiveCustomDomains() zählt alle Types zusammen).
|
||||||
|
--
|
||||||
|
-- DSGVO-Hinweis:
|
||||||
|
-- Display-Name-Patterns (type='mail_display_name') sind reine Heuristik-Patterns
|
||||||
|
-- die vom User selbst eingetragen werden (z.B. "EXTRASPIN").
|
||||||
|
-- Sie gelten NICHT als personenbezogene Daten (Art. 4 DSGVO) —
|
||||||
|
-- es handelt sich um selbst-definierte Schlagworte, nicht um identifizierbare PII.
|
||||||
|
-- Die Patterns unterliegen dennoch Art. 17 DSGVO (Löschrecht via CASCADE userId).
|
||||||
|
--
|
||||||
|
-- Non-breaking: alle existierenden Rows erhalten type='web' als Default.
|
||||||
|
|
||||||
|
ALTER TABLE rebreak.user_custom_domains
|
||||||
|
ADD COLUMN IF NOT EXISTS type TEXT NOT NULL DEFAULT 'web';
|
||||||
|
|
||||||
|
ALTER TABLE rebreak.user_custom_domains
|
||||||
|
ADD CONSTRAINT user_custom_domains_type_check
|
||||||
|
CHECK (type IN ('web', 'mail_domain', 'mail_display_name'));
|
||||||
|
|
||||||
|
-- Index für effiziente type-basierte Abfragen pro User
|
||||||
|
-- (getCustomMailDisplayNames läuft bei jedem Mail-Scan)
|
||||||
|
CREATE INDEX IF NOT EXISTS user_custom_domains_user_type_idx
|
||||||
|
ON rebreak.user_custom_domains (user_id, type);
|
||||||
@ -371,12 +371,17 @@ model UserCustomDomain {
|
|||||||
source String @default("manual")
|
source String @default("manual")
|
||||||
// "active" | "submitted" | "approved" | "rejected"
|
// "active" | "submitted" | "approved" | "rejected"
|
||||||
status String @default("active")
|
status String @default("active")
|
||||||
|
// "web" | "mail_domain" | "mail_display_name"
|
||||||
|
// Alle Types teilen den gleichen Slot-Pool (countActiveCustomDomains() zählt total).
|
||||||
|
// mail_display_name: Substring-Heuristik gegen Sender-Display-Name (kein PII, Art. 4 DSGVO)
|
||||||
|
type String @default("web")
|
||||||
postId String? @map("post_id") @db.Uuid
|
postId String? @map("post_id") @db.Uuid
|
||||||
addedAt DateTime @default(now()) @map("added_at")
|
addedAt DateTime @default(now()) @map("added_at")
|
||||||
|
|
||||||
submission DomainSubmission?
|
submission DomainSubmission?
|
||||||
|
|
||||||
@@unique([userId, domain])
|
@@unique([userId, domain])
|
||||||
|
@@index([userId, type])
|
||||||
@@map("user_custom_domains")
|
@@map("user_custom_domains")
|
||||||
@@schema("rebreak")
|
@@schema("rebreak")
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user