358 Commits

Author SHA1 Message Date
chahinebrini
c2323c1aba fix(native): read { items, counts, limits } from custom-domains GET
The /api/custom-domains endpoint changed shape with the slot-pool split
in commit f2b81ee — it now returns { items, counts, limits } where it
used to return a bare CustomDomain[]. The hook was still matching
Array.isArray(res) or res.domains and silently fell back to an empty
list, so a successful POST went unreflected on the blocker page (user
reported "kein fehler aber domain taucht nicht in der liste" after
adding communications@only4subscribers.com).

Now reads items / counts / limits when present, prefers the API-driven
counts and limits over the client-side derivation (still kept as a
fallback for the stale-bundle window between deploys). Legacy bare-
array + { domains } shapes still resolve too in case a cached client
hits this code path before the new backend lands.
2026-05-16 02:32:21 +02:00
chahinebrini
63b6d2ff11 fix(native): provisional slot limits match the backend plan (5/5, 10/10)
The user looked at the legend dashboard and saw 4/8 + 0/2 for the web
and mail buckets instead of the agreed 10/10. The hook's client-side
provisional limits were holdovers from an earlier sketch — 8/2 for
legend, 4/1 for free/pro — that never matched plan-features.ts on the
backend (5/5 and 10/10 respectively). Brought them in line so the
header counters read correctly until the API-driven values land via
the new GET /api/custom-domains response shape.
2026-05-16 02:24:55 +02:00
chahinebrini
e370842072 fix(native): domains-section flat, mails-section collapsible only
DomainSection bekommt collapsible-Prop (default false).
Domains-Section: kein Chevron, kein useState, Content immer sichtbar.
Mails-Section: collapsible={true} + open/onToggle wie bisher.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:20:38 +02:00
chahinebrini
f4da81f551 feat(native/blocker): two collapsible sections + new AddDomainSheet layout
The Seiten/Mails top-tabs added in 5c6fa3d are gone. Per the user's
revised vision, web-domains and mail-patterns live side by side as two
collapsible <DomainSection>s with their own header, slot pill, progress
bar, and add-button — closer to the original Eigene-Domains affordance
plus a sibling Eigene-Mails section. Both default open; chevron-up/down
per the existing icon convention.

AddDomainSheet was rewritten from scratch to fix the layout-bug
visible in the screenshot — SheetFieldStack's two-ScrollView intro/
fields split was wrong for a single-input use case and was rendering
the chip at the bottom of the scroll area with a huge gap under the
TypePicker. The new sheet is a plain ScrollView with TypePicker, label,
TextInput, help-card, preview-card, warning-card, confirm-row, and the
Cancel + Hinzufügen buttons stacked top-to-bottom with `gap: 12`. No
Pressable anywhere — TouchableOpacity only, per the hard rule.

DomainGrid is now a pure tile renderer: the header / slot pill / add
affordance live on the section component above it. Its `kind` prop
(renamed from `activeTab`) drives the type filter — for v1.0, mail
means strictly `mail_domain` (display-name is gone).

i18n: new keys section_domains / section_mails / add_sheet_cta. mail-
related copy (label, placeholder, help, empty) had every "Display-Name"
mention stripped so the user can't read about an option that doesn't
ship.

Progressbar inline in DomainSection with the same Animated.timing
pattern DeviceProgressBar uses, with a 3-step color threshold
(green / brandOrange / error) keyed on the bucket fill ratio.
2026-05-16 02:19:27 +02:00
chahinebrini
1e07e8303f fix(native): mail-pattern domain extraction + drop Pressable from FormSheet
Two bugs reported on the new mail-pattern flow:

1. The sheet sent the full local@domain.tld pattern to the backend so a
   user blocking communications@only4-subscribers.com would only catch
   that exact local-part — newsletter@, info@, promo@ from the same
   sender would slip through. Casino affiliates rotate the local-part
   on every blast while keeping the domain stable, so we now strip the
   local-part on submit. The preview-card under the input shows what
   actually gets stored (only4-subscribers.com), so the user sees the
   pattern that will hit. Bare tokens without "@" stay as-is and reach
   the backend as display-name candidates.

2. FormSheet's backdrop was a <Pressable> — straight violation of the
   "TouchableOpacity, never Pressable" rule. Swapped for
   <TouchableOpacity activeOpacity={1}> so the tap-to-dismiss still
   works with no visible feedback on the dim layer.
2026-05-16 02:03:53 +02:00
chahinebrini
5c6fa3d45b feat(native/blocker): underlined Seiten/Mails tabs + per-type counter
Top-tabs above the custom-domains grid: Seiten (web) and Mails (mail_*).
2px underline highlight in colors.brandOrange for the active tab, the
muted label otherwise — matches the community/feed tab style we already
use. Pill segmented control would have needed extra inset math for two
tabs without adding clarity.

- DomainGrid filters items by the active tab. Tab-specific empty-state
  copy and icon (mail-outline for the Mails tab) so the empty Mails tab
  doesn't read like a broken Web view.
- mail_display_name tiles hide the submit-to-global button entirely —
  matches the v1.0 backend lock; the user can't accidentally tap into a
  400 from the API.
- useCustomDomains exposes countsByType + limits. Provisional client-
  side estimation until the new API response shape (extended in the
  parallel backend commit f2b81ee) is wired through — same TS shape,
  so dropping the estimation is a one-line swap when ready.
- AddDomainSheet picks up initialType so tapping "+" while the Mails tab
  is active opens the sheet pre-selected to E-Mail. Plan-limit error
  handling maps WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED to the right
  per-bucket message.

i18n: tabs_web / tabs_mail / count_label / error_web_limit_reached /
error_mail_limit_reached / empty_web / empty_mail across DE/EN/FR with
%{var} placeholders.
2026-05-16 02:03:41 +02:00
chahinebrini
4eab5df7e2 feat(native/blocker): type picker + mail patterns in AddDomainSheet
AddDomainSheet now opens with a Seite / E-Mail segmented control.
Web keeps the existing flow (label, placeholder, favicon preview,
domain normalization). Mail switches to a free-form pattern input
(address / domain / display-name — user types what they see in
their inbox) with a mail-icon preview after the field is filled.
addDomain(pattern, kind) now sends { pattern, kind: 'web' | 'mail' }
and the server decides the concrete type. Type field flows through
the CustomDomain type so DomainGrid tiles render the mail-outline
icon for mail entries instead of the favicon fallback.

i18n: blocker.type_web / type_mail / add_web_* / add_mail_* across
de/en/fr with %{var} placeholders per repo convention.
2026-05-16 01:54:32 +02:00
chahinebrini
ba170afd20 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.
2026-05-16 01:53:03 +02:00
chahinebrini
d11d548c10 feat(native/chat): partner avatars + pill-shape search field
- DmItem now goes through resolveAvatar(partnerAvatar, partnerName) so
  the Dicebear-initials fallback kicks in for null avatars, hero ids
  resolve to their image url, and direct URLs pass through. Adds the
  PostCard-style avatarLoadFailed state for graceful broken-image
  fallback.
- Search row pill-shaped (borderRadius 999) with 16px horizontal padding
  and the outline search icon for better visual rhythm.
2026-05-16 01:51:59 +02:00
chahinebrini
40ccefab5b fix(native): replace #007AFF with colors.brandOrange in dm + room screens
Consistent with chat.tsx refactor — ActivityIndicator, joinBtn, and
avatarEdit badge all now use the theme token.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:28:08 +02:00
chahinebrini
a8ccfab274 feat(native): chat v1.0 — DM-only layout, search field, theme colors
Removes 2-tab Groups/DMs layout; Chat screen is now DM-only for v1.0.
Groups tab state, rooms query, RoomCard/CreateRoomSheet imports removed.
Replaces static title+create-button header with sticky search field
(client-side filter on partnerName + lastMessage). No create-DM button
added — /dm-new route does not exist yet (follow-up task).

All #007AFF in chat.tsx replaced with colors.brandOrange.
Adds chat.search_placeholder to de/en/fr locales.
Tab-bar styles kept in makeStyles (dead code, v1.1 Groups comeback path).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:28:04 +02:00
chahinebrini
500f673e53 fix(native/community): sync foreign likes_count into PostCard.localCount
Pure additive change — wasLikingRef + a small useEffect right after the
existing useState declarations. handleLike, the heart animation, localLike,
the memo comparator, and the render path are not touched.

Mechanism:
  - useCommunityRealtime already patches the React-Query cache on UPDATE
    events for rebreak.community_posts (the table IS in supabase_realtime
    — verified via pg_publication_tables on staging today).
  - The cache patch propagates to PostCard as a new post.likesCount prop.
  - The useState seed (post.likesCount on mount) was never re-read after
    the first render — the source of the bug.
  - The new useEffect mirrors post.likesCount into localCount with one
    guard: when isLiking transitions from true → false, skip the first
    run. The cache patch from our own action arrives ~100–300ms after
    the API response settles, so on the immediate run the prop is still
    stale; skipping prevents an overwrite of the value handleLike just
    set. The next prop change (cache patch arrival) re-fires the effect
    and syncs correctly.
  - Pure foreign likes (no own action in flight) sync immediately.

Earlier attempts (4c4792c, d28d1f1) tried to refactor wider — both broke
own-likes / comments / animations. This commit deliberately changes only
the new code paths.
2026-05-16 01:08:14 +02:00
chahinebrini
7c6b463acb Revert "fix(native/community): derive heart state from props + store-optimistic delta"
This reverts commit d28d1f145d9bdaa45fb788aaef69c645719f56bb.
2026-05-16 00:48:14 +02:00
chahinebrini
964dc2b6e0 fix(native/games): game-over modal — maxHeight 85%, KeyboardAvoidingView, Button comp, regenerate
Four issues from the screenshot review plus one new affordance:

1. Modal overflowing on small devices — capped at maxHeight: '85%'. Header
   (handle bar + Lyra avatar + title + subtitle) stays fixed above a
   ScrollView body; action buttons stay fixed below with a border separator.
   Stat cards, star rating, and TextInput now live inside the scrollable body.

2. Keyboard pushed the TextInput out of sight — replaced the bespoke
   Keyboard.addListener + Animated.multiply lift hack (Easing, keyboardLiftY,
   the whole apparatus) with a plain KeyboardAvoidingView wrapper
   (behavior="padding" iOS / "height" Android). ScrollView already had
   keyboardShouldPersistTaps="handled" so taps on Posten/Abbrechen still
   work while the keyboard is up.

3. All four action buttons (Nochmal, Beenden, Abbrechen, Posten) plus the
   inner Save-Rating CTA now route through components/Button.tsx — picks
   up the slimmer paddingVertical:12 default from the central component.
   Posten gets the paper-plane icon. Nochmal + Posten = primary, Beenden +
   Abbrechen = secondary.

4. New "Neuer Vorschlag" regenerate button (ghost variant, sm size,
   refresh-outline icon) sits between the TextInput and the Abbrechen/
   Posten row. Reuses POST /api/games/share-text — no new endpoint. Tracks
   the last Lyra-generated text in a ref so we can detect user edits; if
   the user has modified the suggestion, taps go through an Alert.alert
   confirm before overwrite. Spinner during the regen call, Posten /
   Abbrechen stay active. i18n keys gameOver.regen_* across DE/EN/FR.
2026-05-16 00:44:44 +02:00
chahinebrini
d28d1f145d fix(native/community): derive heart state from props + store-optimistic delta
Replaces the previous mirrored localCount / localLike useState with derived
values computed from `post.likesCount` / `post.userLike` plus the existing
optimisticLikes entry from the community store. The local-state mirror was
the root cause of two separate bugs:

1. Foreign likes never reflected — useState seeded once from props on mount,
   so the React-Query cache patch in useCommunityRealtime updated the prop
   but the displayed count stayed frozen at the mount value.
2. The earlier sync-via-useEffect attempt (4c4792c, reverted in ab9472b)
   broke own-likes because clearing optimistic state could happen before
   the cache patch landed, so useEffect re-read a stale `post.likesCount`
   and snapped the count back down — visible as a 2 → 1 → 2 flicker on tap,
   and as the heart staying red after a toggle-off.

The fix is to NOT mirror at all. The store's `optimisticLikes` map already
stores `{ delta, userLike }` per post (it was set but never read before).
Render path now:
  displayedLike  = optimistic?.userLike  ?? (post.userLike === 'like' ? 'like' : null)
  displayedCount = (post.likesCount ?? 0) + (optimistic?.delta ?? 0)

In handleLike, after the API responds, the React-Query cache is patched
synchronously with the server-truth response before clearOptimisticLike
runs — so the moment the delta drops to 0, the prop already reflects the
new count. No race window, no useEffect, no own/foreign distinction needed.

`isLiking` is still kept as a re-tap guard against double-tap-mid-flight.
2026-05-16 00:40:46 +02:00
chahinebrini
a735f9a2ab feat(native): bound-device states + release flow in Devices page
MobileDeviceRow now handles three binding states driven by
boundToPlan / releaseRequestedAt from the UserDevice type:

- Bound, no release pending: blue "Gebunden" badge next to device name;
  trash icon replaced by lock-open icon → Alert → requestRelease()
- Release active (countdown running): footer shows "Freigabe in Xh Ymin"
  in amber; close-circle icon → Alert → cancelRelease()
- Current device (isCurrent): existing behaviour unchanged, no action
  button regardless of binding state

releaseAt is computed client-side as releaseRequestedAt + 24h — avoids
a backend round-trip for the countdown display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:37:32 +02:00
chahinebrini
0d073b398f feat(native): DEVICE_LOCKED sign-in handling + DeviceLockedPanel UI
After Supabase auth succeeds the store calls POST /api/devices/check-lock
(x-device-id auto-attached via apiFetch). A 409 DEVICE_LOCKED response
triggers a Supabase sign-out and returns { deviceLocked } instead of
proceeding. The signin screen swaps to DeviceLockedPanel which shows:
- lock icon + headline + explanatory body
- amber countdown badge if a release is already in progress
- grey hint pointing to the email notification
- primary CTA to go back and sign in with the original account

Backend TODO: POST /api/devices/check-lock endpoint — same device-lock
query as login.post.ts but callable with a valid Supabase session token
(for email-login flow that bypasses /api/auth/login).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:37:22 +02:00
chahinebrini
edf047eacf feat(native): device-lock i18n keys + devices store type extensions
- Add auth.device_locked_* keys (DE/EN/FR): headline, body, countdown,
  email_hint, use_original CTA, back link
- Add devices.bound_badge + devices.release_* keys (DE/EN/FR) for the
  bound-device / release-flow in the Devices page
- Extend UserDevice interface with boundToPlan and releaseRequestedAt
- Add requestRelease + cancelRelease store actions calling the new
  POST /api/devices/:id/request-release|cancel-release endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:37:12 +02:00
chahinebrini
ab9472b976 Revert "fix(native/community): sync realtime-patched likes_count back into PostCard"
This reverts commit 4c4792c153aa6949fc656ed570c0c147ba33ec87.
2026-05-16 00:35:21 +02:00
chahinebrini
4c4792c153 fix(native/community): sync realtime-patched likes_count back into PostCard
`useCommunityRealtime` was already patching the React-Query cache
on community_posts UPDATE events — likesCount, dislikesCount, userLike
all reached the component as props on re-render. But PostCard was
seeding `localLike` / `localCount` once via useState initial values
and never re-reading the props after mount, so a like from another
account showed up as a notification but the heart counter stayed
stale until pull-to-refresh.

Added a useEffect that mirrors `post.likesCount` / `post.userLike`
back into local state, guarded by `isLiking` so an in-flight
optimistic update isn't clobbered by a concurrent realtime patch
of the same row.

Handles unlike (decrement) on the same path, plus off-screen posts
which get the patched cache value on remount and feed-list cards
that refresh in place without scroll.
2026-05-16 00:25:38 +02:00
chahinebrini
a57a873215 refactor(native/profile): use native iOS crop UI for avatar, drop custom sheet
ImagePicker.launchImageLibraryAsync now opens with `allowsEditing: true`
and `aspect: [1, 1]`, which triggers Apple's built-in square crop UI
(pan + zoom on the user's selection). The output URI is the actually
cropped image — fixing the long-standing bug where AvatarCropSheet
displayed a visual transform but `manipulateAsync` only resized the
original, so any pan/zoom the user did was discarded on confirm.

Removes the entire AvatarCropSheet component (~285 lines) and its sole
consumer wiring in profile/edit.tsx. The avatar continues to render as
a circle everywhere via borderRadius — the underlying square output is
just storage-agnostic.

Native-look-first per memory rule, zero new dependencies, no new
native module to link.
2026-05-16 00:25:18 +02:00
chahinebrini
0fc8ab1687 fix(native/profile): round avatar crop frame to match circular avatar display
Avatars render as circles everywhere (AppHeader, PostCard, profile
page), so a square crop frame let users compose an image that looked
fine in the cropper and got visibly clipped (lost corners, off-center
faces) after upload.

Switched the crop frame to a perfect circle by setting borderRadius =
CROP_SIZE / 2 on both the frame and the overflow mask. Replaced the
four square corner markers with a single thin white ring overlay
around the circle. Output is still a 512×512 JPEG — the consumer-side
border-radius does the visual circle, so the underlying square is
storage-agnostic and re-usable if we ever surface a non-circular
avatar elsewhere.
2026-05-15 23:55:57 +02:00
chahinebrini
5d74214822 fix(native/community): ComposeCard avatar reads from useMe, not auth metadata
The composer on the index page was rendering whatever avatar was set
in `auth.users.user_metadata.avatar_id` at signup time — never updated
when the user changes their avatar via Profile-Edit (those edits go to
`profiles` table only, JWT claims stay stale).

useMe() is the single source of truth that joins both server-side (see
hooks/useMe.ts:15-18 comment that explicitly lists ComposeCard as a
consumer that should subscribe). Switched the avatar + nickname reads
to useMe(); future PATCH /api/auth/me followed by invalidateMe() now
updates the composer avatar in real time alongside the AppHeader.
2026-05-15 23:55:57 +02:00
chahinebrini
917361131d fix(native/dev): default REBREAK_ENABLE_FAMILY_CONTROLS=1 in local dev scripts
When developing on a physical iPhone via `./dev-iphone.sh`, Metro runs
with whatever env the user's shell has — and the FamilyControls flag
was missing unless the user remembered to prefix every command with
`REBREAK_ENABLE_FAMILY_CONTROLS=1 …`. Forgetting it meant
`app.config.ts` evaluated `process.env.REBREAK_ENABLE_FAMILY_CONTROLS`
to falsy, so the JS bundle had `extra.familyControlsEnabled = false`
and the Blocker page kept showing "App-Lock — Bald" instead of the
real LayerSwitchCard, even though the dev binary did have the FC
entitlement.

Local dev scripts now default the var to "1" with shell-level
override (e.g. `REBREAK_ENABLE_FAMILY_CONTROLS=0 ./dev-iphone.sh`
when you want to verify the TestFlight/prod fallback UI). EAS
profile env (eas.json) keeps its own explicit setting and is
unaffected.
2026-05-15 23:46:28 +02:00
chahinebrini
d840247c98 feat(native): help section — FAQ, Contact, About, Crisis pages
New route group app/help/ with 4 sub-pages navigable from settings.

- help/faq.tsx: accordion with 8 Q&As (drafted by UI agent, see below)
- help/contact.tsx: mailto:hilfe@rebreak.org with prefilled subject,
  address block (Rebreak placeholder — TODO verify legal entity name)
- help/about.tsx: mission text + 3 fact rows (DiGA, Hetzner, DSGVO)
- help/crisis.tsx: BZgA 0800 1 372 700, check-dein-spiel.de,
  anonyme-spieler.org, Telefonseelsorge 0800 111 0 111, emergency
  112-box with error-color border treatment. Disclaimer at bottom.

All pages use AppHeader showBack for correct back-button.
All strings in help.* namespace in DE/EN/FR locales.

FAQ answers drafted by UI agent — pending lyra-persona tone review:
  faq_a1 (what is Rebreak), faq_a2 (blocker), faq_a3 (Mac DNS),
  faq_a4 (cancel sub), faq_a5 (data), faq_a6 (bug report),
  faq_a7 (whitelist), faq_a8 (DiGA).
FR locale: faq answers are DE-fallback text (TODO: translate properly).
Contact address block: placeholder — TODO confirm legal entity + address.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:42:45 +02:00
chahinebrini
943afb827b feat(native): settings overhaul — DEV cleanup + notifications section
- Remove __DEV__ debug section from settings.tsx:
  Plan-Override-Row (4 rows: LLM, TTS, plan-override, realtime),
  PlanPickerSheetContent, planSheetRef, DEV_PLANS/DEV_PLAN_ACCENT,
  ActivityIndicator import. The debug.tsx page + realtimeDebug.ts store
  are kept — only UI entry points removed from settings.
- Wire notifications section: pushEnabled/streakReminderEnabled toggles
  + streakReminderTime picker (hour/minute scroll, quarters). State
  persisted in AsyncStorage via new stores/notificationPrefs.ts.
  setPushEnabled calls Notifications.requestPermissionsAsync() — if
  denied, toggle stays off. scheduleNotificationAsync is a TODO (no
  backend endpoint yet).
- Add _layout.tsx Stack.Screen entry for help/* route group.
- i18n: new keys settings.notifications_push_sublabel,
  notifications_streak_time, notifications_hour/minute,
  section_help, help_faq/contact/about/crisis + descs in DE/EN/FR.

TODO: expo-notifications scheduleNotificationAsync wiring once
backend streak-reminder endpoint exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:42:33 +02:00
chahinebrini
a9fb9273b8 feat(native): replace device text-counter with animated progress bar
- DeviceProgressBar component: 6px pill-bar, Animated.timing (380ms) on count change, brandOrange at limit / success otherwise
- devices.tsx: swaps counterText block for <DeviceProgressBar> (Legend-only gating preserved)
- locales (de/en/fr): counter_some/counter_limit → progress_label + progress_at_limit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:37:28 +02:00
chahinebrini
701e32c36e fix(native/i18n): devices counter — use Vue-style %{var} placeholders
The new counter_some / counter_limit keys (added in e8ea005) used
i18next default {{var}} braces, but lib/i18n.ts configures the
interpolator with prefix: '%{', suffix: '}' (legacy Nuxt locale-file
convention, kept verbatim when ported to RN). Result: the placeholders
rendered literally on screen ("{{count}} von {{max}} Geräten…").

Switched all three locales (DE/EN/FR) to %{var}. Also dropped the
literal "+ " prefix from the add_device label — the button now renders
an Ionicons `add-circle-outline`, so the duplicate "+" was redundant.
2026-05-15 23:31:35 +02:00
chahinebrini
e4ac3ae51c refactor(native): central Button component + sweep across devices/plan flows
Replaces ad-hoc TouchableOpacity+styled-Text pairs with a single
`<Button>` covering the four variants we actually use (primary,
secondary, ghost, destructive), with size (sm/md/lg), loading,
disabled, icon, iconPosition, and a style escape hatch.

Migrated files: AddMacSheet, AddWindowsSheet, PlanChangeSheet,
devices.tsx CTA, settings SubscriptionSheet CTA.

Skipped (kept as-is to avoid hostile overrides): auth flow buttons
(Google/Apple OAuth with custom SVGs), list-row Touchables, blocker
& mail components (separate sweep when those screens come up).

paddingVertical default 12 (md) — matches the slimmer-buttons direction
we landed on in the devices-page redesign.
2026-05-15 23:31:26 +02:00
chahinebrini
e8ea00568e feat(native): devices page — 2-line entries, single UIMenu CTA, dynamic counter, slimmer buttons
- MobileDeviceRow: collapse to 2 lines (name+badge / lastSeen · seit date)
- ProtectedDeviceRow: collapse to 2 lines (name+badge / seit date or degraded hint)
- Both rows now use alignItems:center for visual parity
- Replace dual Mac/Windows buttons with single UIMenu "+ neues Gerät hinzufügen"
- MenuView disabled (no-op TouchableOpacity) when at device limit
- Dynamic counter below subtitle: "X von 3 Geräten · noch Y frei" / "Maximum erreicht"
- paddingVertical 16→12 on all primary CTAs in devices.tsx, AddMacSheet, AddWindowsSheet
- New i18n keys: devices.add_device, devices.counter_some, devices.counter_limit (DE/EN/FR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:10:09 +02:00
chahinebrini
8851f36f65 fix(native): protectedDevices store — unwrap response shape
Backend GET /api/devices/protected returns:
  { success, data: { devices, plan, max, isLegend } }

apiFetch already unwraps `data`, leaving us with the object
{ devices, plan, max, isLegend } — not an array.

Old code did `Array.isArray(res) ? res : []` on that object, which
silently fell through to an empty list. Effect: enrolled protected
devices (Mac/Windows) never appeared in the Geräte screen even though
the DB row existed and the API responded correctly.

Fix: read res.devices instead of assuming the response is the array.
2026-05-15 23:02:26 +02:00
chahinebrini
42a8223bfc feat(native): auto-detect Mac activation via Supabase Realtime
Replaces the manual "I've installed it" button in AddMacSheet with an
auto-advancing waiting-pill. As soon as the backend flips status from
pending → active (triggered by the DoH handshake from the AdGuard
watcher), the sheet jumps to the success step automatically.

- useProtectedDevicesRealtime hook subscribes to rebreak.protected_devices
  UPDATE events for the current user, with auto-reconnect on CHANNEL_ERROR
- AddMacSheet listens only while in step 2 (download/install)
- devices.tsx keeps a list-level subscription so the table refreshes even
  if the user dismissed the sheet before activation
- i18n: waiting_install / waiting_hint / activated_toast (DE + EN)
2026-05-15 22:41:25 +02:00
chahinebrini
db377da7ce fix(native): realtime disconnect bug — accessToken callback + AppState handler
Bug (diagnosed by backyard, see project_session_2026-05-15_push.md):
- Manual `supabase.realtime.setAuth()` calls in subscribe-hooks set
  `_manuallySetToken=true` internally, blocking the automatic token-refresh
  on heartbeat. After ~1h the cached access_token expires → Postgres-Changes
  silently stop arriving (channel still shows "joined" but no events).
- Plus: no AppState handler → no Foreground-Reconnect trigger after
  Background-kill of WebSocket.

Fix A — lib/supabase.ts: createClient now passes a `realtime.accessToken`
async callback that returns the current session token. Heartbeat picks
fresh tokens automatically, no manual setAuth needed.

Fix A — all 5 manual `supabase.realtime.setAuth()` calls removed from
useChatRealtime, useCommunityRealtime, useDomainSubmissionRealtime,
stores/notifications. Token is handled by the callback now.

Fix B — _layout.tsx: AppState listener calls
supabase.auth.startAutoRefresh()/stopAutoRefresh() — official Supabase RN
pattern. On Foreground-Return, onAuthStateChange fires TOKEN_REFRESHED →
realtime.setAuth gets called internally.

Required for upcoming Auto-Detect protected-device handshake (Realtime
channel listens on protected_devices status transitions pending→active).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:48:54 +02:00
chahinebrini
cd5efab6e1 feat(native): use expo-device for reliable device-info headers
Constants.platform.ios.model returns only generic "iPhone" instead of
"iPhone 15 Pro" + osVersion was unreliable. Switched lib/deviceId.ts to
expo-device which exposes Device.deviceName ("iPhone von Chahine"),
Device.modelName ("iPhone 15 Pro") and Device.osVersion ("26.4.2") on real
devices. Constants stays as fallback for Simulator/Web.

Backend touchDevice + auto-register already backfill these fields from the
x-device-* headers (commit 60f608d) — but only with proper Frontend values
which Constants couldn't provide.

Requires new native build (versionCode 8) since expo-device is a native
module — current TestFlight build (7) still ships with old Constants logic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:45:33 +02:00
chahinebrini
e1ba0ebeaf chore(native): bump versionCode/buildNumber to 7 for device-info + i18n release
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:17:43 +02:00
chahinebrini
d55cbc11b2 fix(native): mail-sheet modal-conflict + google-oauth picker + feed-bg contrast
- mail/MailAccountSettingsSheet: handleSaveTitle + handleSavePassword now
  dismiss sheet FIRST, then trigger parent SuccessAlert via setTimeout(350ms).
  Fixes iOS "already presenting" crash + page-freeze when editing mailbox name.
  Also fixes double-click-needed UX bug.
- stores/auth: signOut adds WebBrowser.coolDownAsync() to clear OAuth cookies.
  signInWithOAuth for Google adds prompt=select_account — forces account-picker
  on every sign-in attempt instead of auto-reusing previous account.
- app/(app)/index: feed page uses colors.groupedBg instead of colors.bg —
  matches iOS Mail/Messages list-style, post-cards stand out clearer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:16:34 +02:00
chahinebrini
804d4a5861 feat(native): device-info api headers + DeviceLimitSheet UI + profile i18n sweep
- lib/api.ts: sends x-device-name + x-device-model + x-device-os headers
  (cached per session, URL-encoded). Backend persists into user_devices for
  visual differentiation in DeviceLimitSheet.
- DeviceLimitReachedSheet: renders name (primary) + model · OS-version
  (secondary), "Dieses Gerät"-Pill on isCurrent. Stale phantoms become
  distinguishable.
- Profile i18n sweep: 8 keys × 3 languages = 24 fixes — all {{var}} placeholders
  switched to %{var} matching i18next config (Vue-i18n leftover from Nuxt-port).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:16:22 +02:00
chahinebrini
0452007daf chore(native): bump iOS buildNumber 4 → 6 for fr-locale + lyra-voice release
Sync mit Android versionCode 6 (buildNumber 5 wurde nie als iOS-EAS-Build
genutzt, daher direkt auf 6 für konsistente Plattform-Nummerierung).

Changelog für buildNumber 6:
- French app-language for test customers
- Lyra voice picker (legend tier)
- Realtime debug page (DEV-only)
- Mail-filter refactor: Groq removed, deterministic pipeline only
- Bugfix: protectedDevices array guard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:41:13 +02:00
chahinebrini
fb29c061c3 chore(native): bump android versionCode to 6 for fr-locale + lyra-voice release
Changelog für versionCode 6:
- French app-language for test customers
- Lyra voice picker (legend tier)
- Realtime debug page (DEV-only)
- Mail-filter refactor: Groq removed, deterministic pipeline only
- Bugfix: protectedDevices array guard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 11:03:43 +02:00
chahinebrini
10219e5f68 feat(i18n): add french as 3rd app language
Für Test-Kunden: vollständige fr-Locale mit Tonalität für Recovery-Kontext
(„addiction", „Série", „Période de blocage"). Eigenname „Lyra" und Brand
„Rebreak" bleiben unübersetzt.

- locales/fr.json: 1:1 key parity zu de.json/en.json (UI-Agent-Output)
- lib/i18n.ts: fr in resources + initialLng-Detection (Device-Locale fr → fr)
- stores/language.ts: AppLanguage union ergänzt um 'fr', init-Logic + persistence
- app/settings.tsx: Sprach-Picker mit dritter Option Français
- de.json/en.json: language_fr-Label + language_desc trilingual

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 11:03:37 +02:00
chahinebrini
740589db5b chore(native): bump android versionCode to 5 for mail-page-ui release
Covers 10 commits: reactive mail page, donut/legend layout overhaul,
multi-layer classifier, DSGVO Art.17 sample cleanup. CHANGELOG.md added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:16:02 +02:00
chahinebrini
a0d67f33a8 feat(native): realtime debug page + protected-devices array guard
- stores/realtimeDebug.ts: neuer DEV-only Zustand-Store mit connection-state,
  reconnect-counter, token-expiry-countdown, channel-liste, rolling log-buffer
  (last 100 events). Hookt Phoenix-Socket open/close/reconnect + Channel-subscribe.
- _layout.tsx: initRealtimeDebug() im __DEV__-Block beim App-Start.
- debug.tsx: zwei neue Cards (RealtimeStatusCard + RealtimeLogCard) mit
  1s-Tick-Refresh, Copy + Clear Buttons. Settings-Entry 'Realtime connection (DEV)'.
- protectedDevices.ts: Array.isArray-Guard für apiFetch-Response — verhindert
  TypeError 'devices.filter is not a function' wenn Backend Non-Array zurückgibt.

Diagnostik-Tool für Realtime-Disconnect-Bug bei lange eingeloggten Usern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:15:57 +02:00
chahinebrini
d9bb7ef91a feat(native): lyra voice picker UI + me-hydration
- settings.tsx: neuer Abschnitt 'Lyra (Legend)' — nur sichtbar wenn plan==='legend',
  UIMenu mit 3 Optionen (Standard / Stimme 1 / Stimme 2), chevron-forward Anchor.
  Optimistic Update via PATCH /api/profile/me/lyra-voice, Revert bei Error.
- useMe.ts: lyraVoiceId im Me-Type — Hydration aus /api/auth/me beim App-Start.
- de.json + en.json: settings.lyra_voice + lyra_voice_default/_1/_2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:15:49 +02:00
chahinebrini
c218287c5e fix(mail): legend bottom-aligned mit donut-baseline für visuelle zentrierung
Donut-Bounding-Box ist asymmetrisch (Bogen oben, Center-Number bei ~70%
der Box-Höhe unten). alignItems:center zentrierte Legend gegen die
Box-Mitte → visuell zu hoch. alignItems:flex-end aligned Legend an
Donut-Baseline → Legend-Mitte landet auf Donut-Center-Number-Höhe.
Plus paddingBottom:12 damit Legend nicht direkt am Card-Border klebt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:08:53 +02:00
chahinebrini
1d93ada275 fix(mail): revert marginBottom hack — layout was breaking out of card
Mein letzter marginBottom:-28 Versuch hat den Donut-Wrapper Layout-Width
durcheinandergebracht — Donut ragte links aus der Card. Zurück zum
clean Layout ohne negative Margin. Kleine vertikale Asymmetrie zwischen
Donut-Center-Number und Legend-Mitte bleibt akzeptiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:02:56 +02:00
chahinebrini
778d3b6746 fix(mail): legend vertikal zentral gegenüber donut-center-number
Donut-Box ist asymmetrisch: SVG-Höhe 118px, aber Center-Number sitzt bei
y≈81 (Bogen oben, Number unten-mitte). alignItems:center zentriert die
Legend gegen die SVG-Box-Mitte (y=59) — visuell zu hoch, weil die echte
Donut-Mitte unten liegt.

Fix: marginBottom:-28 am Donut-Wrapper. Reduziert die effektive Box-Höhe
von 118 auf 90px → alignItems:center positioniert Legend dann gegen die
visuelle Donut-Mitte statt der Bounding-Box-Mitte. Donut-Bogen overflows
sichtbar nach unten (kein Clipping).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:57:18 +02:00
chahinebrini
55cba9a3fe fix(mail): legend takes natural width inside card + bar-chart always trims to hit-range
1. Legend-Wrapper: feste 180px-Width raus, stattdessen flex:1 + minWidth:0.
   Mit Donut 200px + gap 20 + Card-paddingHorizontal 16+16 wäre 200+20+180+32=432
   zu breit — kleine iPhones haben effektive Card-Width <380px. Legend ragte
   raus. Jetzt: Legend nimmt verfügbaren Rest-Platz, Texte trunken bei Bedarf.

2. useMailConnectionStats: zoom IMMER wenn nonEmpty.length > 0, nicht nur
   bei sparse-data-Bedingung. Bei 30-Tage-Range mit 1 Hit wurde das vorher
   trotzdem als 30 leere Bars + 1 Bar gerendert (Logik nonEmpty*3<raw greift
   zwar mathematisch, aber nicht aggressiv genug für wirklichen Visual-Fix).
   Jetzt: trim ALWAYS auf [firstHit..lastHit] — bei 1 Hit = 1 Bar, bei 5 Hits
   über 10 Tage = 10 Bars (5 mit Daten, 5 dazwischen). Konsistent visuell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:53:08 +02:00
chahinebrini
b47ac2427e fix(mail): legend rows justify-between + per-connection chart sparse-data zoom
1. Donut-Legend-Rows als space-between: Name links + dot, Count rechts.
   Vorher: alle Elemente eng aneinander (gap:6), Count direkt nach Name.
   Jetzt: feste Legend-Width 180px, jede Row hat Name+Dot links (flex:1)
   und Count rechts mit Whitespace dazwischen.

2. Per-Connection-Bar-Chart in Account-Card: sparse-data-zoom.
   Vorher: bei nonEmpty.length > 0 && days <= 7 wurde gezoomt — bei 30-Tage-
   Range mit nur 1-2 Hits passierte das aber NICHT → 30 leere Bars + 1 Bar
   ganz rechts (Screenshot bei GMX-expanded).
   Jetzt: zoom IMMER wenn nonEmpty.length * 3 < raw.length (= mehr als
   2/3 der Range sind leer). Trim auf die echte Hit-Range. User sieht
   damit nur die Tage mit Daten + die paar dazwischen, statt 30 leere
   Slots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:48:51 +02:00
chahinebrini
aac6c00720 fix(mail): donut card layout — justify-start statt center
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:36:12 +02:00
chahinebrini
2ea0cfec96 fix(mail): donut card layout from scratch — center, breathing room, no truncation
User-Feedback nach mehreren Iterationen: vorheriges Layout war kaputt
(Donut zu klein, Total links statt im Center, Legend mit "G.." truncated).
Frischer Ansatz:

- DONUT_WIDTH 180 → 200 (Center-Number-Math passt, sitzt sauber im Bogen-Hohlraum)
- Container: flex-row, alignItems center, justifyContent center, gap 20
- KEIN flexShrink/maxWidth am Legend-Wrapper mehr (war Ursache des Quetschens)
- Truncation nur am einzelnen Text-Element via maxWidth: 160 + numberOfLines: 1
  (statt am ganzen Wrapper) — schützt nur extrem lange Domains
- Donut + Legend nehmen ihre natural-width, Container zentriert beides

Plus i18n: "Blockiert — letzte 30 Tage" → "Blockiert" (DE+EN).
Das hardcoded 30 war falsch wenn die Connection nur 2 Tage Daten hat.
Echte Range-Info kommt schon aus dem Sublabel "N Mails blockiert · M letzte
Woche".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:33:39 +02:00