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');