fix(messages): peer avatar visibility in chat bubbles

- ConversationsContext: subscribe to profiles UPDATE realtime so
  conversation members[].profile picks up peer avatar / displayName changes
  without a manual refresh
- ConversationPage: skip call_event messages when computing run boundaries.
  Previously a peer bubble followed by a call event from the same sender
  was treated as mid-run -> avatar slot collapsed to a placeholder
- DM peer-profile fallback already added in previous commit covers transient
  member-lookup misses
This commit is contained in:
2026-04-20 00:49:21 +02:00
parent 4db65993d5
commit 1fab2edc57
2 changed files with 47 additions and 4 deletions
@@ -151,6 +151,15 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
.on('postgres_changes', { event: '*', schema: 'public', table: 'conversations' }, () => {
void refresh();
})
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'profiles' },
() => {
// Peer updated their profile (e.g. uploaded avatar / changed name).
// Re-pull conversations so members[].profile picks up the new data.
void refresh();
},
)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' },
+38 -4
View File
@@ -1,3 +1,4 @@
import { parseMessagePayload } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -204,12 +205,26 @@ export function ConversationPage() {
) : (
<ul className="space-y-0.5">
{messages.map((m, idx) => {
const prev = messages[idx - 1];
const next = messages[idx + 1];
const grouped = idx > 0 && prev?.senderId === m.senderId;
// Skip call_event messages when computing run boundaries — they
// render as centred separators, not chat bubbles, so they
// shouldn't count toward sender continuity. Without this,
// a real bubble followed by a call event from the same sender
// would be treated as "in the middle of a run" and lose its
// avatar.
const prev = findAdjacent(messages, idx, -1);
const next = findAdjacent(messages, idx, +1);
const grouped = !!prev && prev.senderId === m.senderId;
// Anchor avatar on the LAST message of a run so it aligns with
// the bubble's tail (bottom corner). Tail is bottom-left for
// mine, bottom-right for peer — see rounded-[…_4px_…] above.
const isLastOfRun = !next || next.senderId !== m.senderId;
const senderProfile =
// DM fallback: if member lookup fails (e.g. transient sync), fall
// back to conversation.peer so the peer's avatar still resolves.
const memberProfile =
conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null;
const senderProfile =
memberProfile ??
(m.senderId !== myId ? (conversation?.peer ?? null) : null);
return (
<li key={m.id}>
<MessageBubble
@@ -305,6 +320,25 @@ export function ConversationPage() {
);
}
// Walks `messages` from `idx + step` skipping call_event entries until a
// regular bubble is found or the array boundary is reached. Used to decide
// run-grouping for avatar placement so call separators don't bleed into
// sender continuity.
function findAdjacent<T extends { plaintext: string | null }>(
messages: T[],
idx: number,
step: 1 | -1,
): T | undefined {
let i = idx + step;
while (i >= 0 && i < messages.length) {
const m = messages[i];
if (!m) return undefined;
if (parseMessagePayload(m.plaintext).kind !== 'call_event') return m;
i += step;
}
return undefined;
}
function Banner({ children }: { children: React.ReactNode }) {
return (
<div