diff --git a/apps/desktop/src/context/ConversationsContext.tsx b/apps/desktop/src/context/ConversationsContext.tsx
index 02b68b2..7b070e0 100644
--- a/apps/desktop/src/context/ConversationsContext.tsx
+++ b/apps/desktop/src/context/ConversationsContext.tsx
@@ -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' },
diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx
index 0a51cac..bc3a9bd 100644
--- a/apps/desktop/src/pages/ConversationPage.tsx
+++ b/apps/desktop/src/pages/ConversationPage.tsx
@@ -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() {
) : (
{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 (
-
(
+ 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 (