From c999ccd7e5c68628cca2e34880ab0ed33030d8bd Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Sat, 6 Jun 2026 12:16:13 +0000 Subject: [PATCH] fix(messaging): #30 iOS safe-area on message input + Deleted-vs-Unknown user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships 2 of the 5 messaging fixes from #30 (the other 3 — password-manager full-page setup, autocomplete hints, the encrypted-with-previous-keys indicator — already shipped). Fix #1 (iOS Safari input hidden behind the home indicator): the real root cause was a missing viewport-fit=cover, which left env(safe-area-inset-*) at 0. Add viewportFit:'cover' to the root viewport, then extend the message-input padding (ChatWindow input row pb → max(1.5rem, env(safe-area-inset-bottom))) and the fixed /messages container bottom inset (calc(7rem + env(safe-area-inset-bottom))) so the input clears the indicator. Fix #5 (deleted vs unknown user): ConversationView now distinguishes a genuinely missing profile row (maybeSingle → null = "Deleted User") from a transient query error (kept neutral as "Unknown User") and a present-but-unnamed profile (display_name || username fallback). Extracted as a pure resolveParticipantName helper + unit-tested (3 cases). ConversationListItem is intentionally NOT changed — its `participant?` prop can't distinguish null (deleted) from undefined (not-yet-loaded), so forcing "Deleted User" there would mislabel loading states. Tests: 3 new resolveParticipantName cases (error → Unknown, null → Deleted, present → display_name/username/fallback). type-check / lint / build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/layout.tsx | 5 ++++ src/app/messages/page.tsx | 2 +- .../organisms/ChatWindow/ChatWindow.tsx | 6 +++-- .../ConversationView.test.tsx | 27 ++++++++++++++++++- .../ConversationView/ConversationView.tsx | 25 ++++++++++++----- 5 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fe88e7a3..2fdfc52c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -60,6 +60,11 @@ export const viewport: Viewport = { width: 'device-width', initialScale: 1, maximumScale: 5, + // viewport-fit=cover lets the app draw into the iOS safe-area insets and makes + // env(safe-area-inset-*) non-zero, so safe-area padding (e.g. the messaging + // input row, #30 fix #1) actually clears the home indicator. Without this, + // env() resolves to 0 and the padding is a no-op. + viewportFit: 'cover', themeColor: [ { media: '(prefers-color-scheme: light)', color: '#f5f0eb' }, { media: '(prefers-color-scheme: dark)', color: '#1a1a2e' }, diff --git a/src/app/messages/page.tsx b/src/app/messages/page.tsx index e966f287..5d8d596e 100644 --- a/src/app/messages/page.tsx +++ b/src/app/messages/page.tsx @@ -75,7 +75,7 @@ function MessagesLayout() { -
+
- {/* Row 3: Message Input (auto height) */} -
+ {/* Row 3: Message Input (auto height). pb keeps the 1.5rem base but + extends past the iOS home indicator via the safe-area inset (#30 + fix #1; needs viewport-fit=cover, set in the root layout). */} +
{ } }); }); + +describe('resolveParticipantName (#30 fix #5)', () => { + it('returns Unknown User on a query error (transient — do not mislabel)', () => { + expect(resolveParticipantName(null, true)).toBe('Unknown User'); + expect(resolveParticipantName({ display_name: 'Ada' }, true)).toBe( + 'Unknown User' + ); + }); + + it('returns Deleted User when the profile row is null (no error)', () => { + expect(resolveParticipantName(null, false)).toBe('Deleted User'); + }); + + it('prefers display_name, then username, for a present profile', () => { + expect( + resolveParticipantName({ display_name: 'Ada', username: 'ada99' }, false) + ).toBe('Ada'); + expect( + resolveParticipantName({ display_name: null, username: 'ada99' }, false) + ).toBe('ada99'); + expect( + resolveParticipantName({ display_name: '', username: '' }, false) + ).toBe('Unknown User'); + }); +}); diff --git a/src/components/organisms/ConversationView/ConversationView.tsx b/src/components/organisms/ConversationView/ConversationView.tsx index 4c699295..be31e265 100644 --- a/src/components/organisms/ConversationView/ConversationView.tsx +++ b/src/components/organisms/ConversationView/ConversationView.tsx @@ -13,6 +13,22 @@ import type { DecryptedMessage } from '@/types/messaging'; const logger = createLogger('organisms:ConversationView'); +/** + * Resolve the display name for the other 1-to-1 participant, distinguishing + * three cases (#30 fix #5): + * - query failed (transient/RLS) → 'Unknown User' (neutral; don't mislabel) + * - no profile row (null) → 'Deleted User' (account genuinely gone) + * - profile present → display_name || username || 'Unknown User' + */ +export function resolveParticipantName( + profile: { username?: string | null; display_name?: string | null } | null, + hadError: boolean +): string { + if (hadError) return 'Unknown User'; + if (!profile) return 'Deleted User'; + return profile.display_name || profile.username || 'Unknown User'; +} + export interface ConversationViewProps { /** Conversation to display. The component owns all message state internally; * changing this prop resets and reloads. */ @@ -121,13 +137,10 @@ export default function ConversationView({ if (profileError) { logger.warn('Profile query error', { error: profileError.message }); - setParticipantName('Unknown User'); - return; } - - setParticipantName( - profile?.display_name || profile?.username || 'Unknown User' - ); + // Distinguish deleted account (null row) from a transient query error and + // from a present-but-unnamed profile (#30 fix #5). + setParticipantName(resolveParticipantName(profile, !!profileError)); } catch (err) { logger.warn('Error loading participant info', { error: err }); setParticipantName('Unknown User');