Grouped Feed Without Flicker
elements man recipes/grouped-feed Read as markdownA chat feed with day separators and consecutive messages from one person grouped into a run. One flat loop over the messages, with each grouping decision carried as a field on the row, patches only the row that changed when a message arrives.
The flat loop
Keep the loop flat. Compute one line per message, and carry the facts that depend on its neighbour as fields on that line:
Flat linesinterface Line { id: string; message: Message; dayLabel: string; startsDay: boolean; startsRun: boolean; } function dayOf(m: Message): string { return m.createdAt.toDateString(); } function lines(messages: LiveView<Message>): Line[] { let out: Line[] = []; let prev: Message | undefined; for (let m of messages.sort((a, b) => +a.createdAt - +b.createdAt)) { let startsDay = prev === undefined || dayOf(prev) !== dayOf(m); out.push({ id: m.id, message: m, dayLabel: dayOf(m), startsDay, startsRun: startsDay || prev === undefined || prev.userName !== m.userName || +m.createdAt - +prev.createdAt > 5 * 60 * 1000, }); prev = m; } return out; }
Flat loop<ul class="feed"> <li e:for={line of lines(messages)} e:key={(line: Line) => line.id} class="msg"> <p e:if={line.startsDay} class="day">{line.dayLabel}</p> <strong e:if={line.startsRun}>{line.message.userName}</strong> <span class="body">{line.message.body}</span> </li> </ul>
The day separator and the author name become e:if on the row that starts
them, so there is no second level to rebuild. lines() mints a fresh wrapper
for every message on every change and the rows still patch: e:key pins each
line to its message id, and a wrapper whose fields hold the same values is not
a new value. Only a row whose facts actually moved re-renders, which is what
you want when a message arrives and the row above it stops being the last of
its run.
Grouping into days with an outer loop over [day, rows] tuples would hand the
row diff a fresh inner array on every evaluation, and each group would
re-render as a whole. The flat loop keeps every update to the one row it
touches.
lines() reads createdAt, so the composer's insert passes
createdAt: new Date(), and the optimistic row takes its place in the right
day and run from the first frame.
Reactions
A reaction belongs to a message, and a windowed feed holds only the messages
it has loaded. Store the reactions on the message row and they arrive with it:
in the first page, in every more(), and in the live update when someone
reacts. A second view of the reactions table would hold every reaction in the
room, and a lookup inside the row would scan it for each message.
Terminalelements create migration 'add reactions'
app/migrations/<timestamp>-add-reactions.migration.sql-- add reactions alter table chatMessages add column reactions jsonb not null default '{}'; create table chatReactions ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), messageId uuid not null references chatMessages(id) on delete cascade, userId uuid not null references chatUsers(id) on delete cascade, emoji text not null, unique (messageId, userId, emoji) ); -- Fold each reaction into its message's reactions column, in one update. create or replace function chatReactionsApply() returns trigger language plpgsql as $$ begin if tg_op = 'INSERT' then update chatMessages set reactions = jsonb_set( reactions, array[new.emoji], coalesce(reactions -> new.emoji, '[]') || to_jsonb(new.userId::text)) where id = new.messageId; else update chatMessages set reactions = case when (reactions -> old.emoji) - old.userId::text = '[]' then reactions - old.emoji else jsonb_set(reactions, array[old.emoji], (reactions -> old.emoji) - old.userId::text) end where id = old.messageId; end if; return null; end; $$; create trigger chatReactionsApplyTrigger after insert or delete on chatReactions for each row execute function chatReactionsApply(); -- The notify trigger from chat-rooms, now carrying reactions. create or replace function chatMessagesNotify() returns trigger language plpgsql as $$ declare r record; payload text; begin r := coalesce(new, old); payload := json_build_object( 'op', lower(tg_op), 'data', json_build_object( 'id', r.id, 'createdAt', json_build_object('$type', 'Date', '$value', (extract(epoch from r.createdAt) * 1000)::bigint), 'roomId', r.roomId, 'userId', r.userId, 'userName', r.userName, 'body', r.body, 'reactions', r.reactions ) )::text; -- NOTIFY takes under 8000 bytes. A larger row goes as its id, and the -- server reads the row back. if octet_length(payload) >= 8000 then payload := json_build_object('op', lower(tg_op), 'id', r.id)::text; end if; perform pg_notify(channel_name('chat_messages'), payload); return r; end; $$;
chatReactions is the source of truth, one row per user, emoji and message;
the unique constraint makes a double click harmless. chatReactionsApply folds
each insert or delete into chatMessages.reactions with jsonb operators inside
one update, shaped { "👍": ["<userId>", ...] }. Two people reacting at
once queue on the row lock, and neither overwrites the other. That update
fires chatMessagesNotify, redefined here to carry reactions, so the changed
row reaches every page with the room open.
In app/shared/services/chat.ts, add the field and keep it out of the
browser's hands:
app/shared/services/chat.tsimport { ForbiddenError, LiveTable, session } from "@elements/app"; export interface Message { // ... reactions: Record<string, string[]>; } export let chatMessages: LiveTable<Message> = new LiveTable<Message>({ insert: (item) => { session.isLoggedInOrThrow(); return chatMessages.insert({ ...item, reactions: {}, }); }, update: () => { throw new ForbiddenError(); }, });
A message starts with no reactions whatever the browser sent, and no update
through the view can write them. Pass reactions: {} in the composer's
insert as well, so the optimistic row has something to render.
A reaction is toggled by an @rpc in the room's template.ehtml. It takes
the user from the session, never from an argument, so a user can add or remove
only their own:
template.ehtml/** @rpc */ export function toggleReaction(messageId: string, emoji: string) { session.isLoggedInOrThrow(); let userId = session.getOrThrow("userId"); let removed = sql(` delete from chatReactions where messageId = ${messageId} and userId = ${userId} and emoji = ${emoji} returning id `); if (removed.empty()) { sql(` insert into chatReactions ( messageId, userId, emoji ) values ( ${messageId}, ${userId}, ${emoji} ) on conflict do nothing `); } }
The row reads the count and your own state straight off the message:
template.ehtml<li e:for={line of lines(messages)} e:key={(line: Line) => line.id} class="msg"> <strong e:if={line.startsRun}>{line.message.userName}</strong> <span class="body">{line.message.body}</span> <button e:for={[emoji, users] of Object.entries(line.message.reactions)} e:key={([emoji]) => emoji} class={users.includes(session.get("userId")!) ? "reaction mine" : "reaction"} onclick={() => toggleReaction(line.message.id, emoji)}> {emoji} {users.length} </button> <button onclick={() => toggleReaction(line.message.id, "👍")}>+👍</button> </li>
The count is the array's length and "mine" is whether it holds your id. The buttons are a loop inside the row, and that fits here: the row changes exactly when its reactions do, so rebuilding its buttons is the update.
Rules of thumb
- One
e:forover one flat list. A loop inside a loop rebuilds the inner one whenever the outer row updates. e:keytakes a function:e:key={(line: Line) => line.id}.- Put a grouping decision on the row as a boolean, not in the structure.
- A fresh wrapper object per row patches cleanly:
e:keyand equal field values keep the row. - Child rows a window has to page with, such as reactions, go on the parent row, not in a second view.
Related
livetable/mutations: inserting through a view, which is what broadcasts.recipes/chat-rooms: the full chat app this feed belongs in.html:e:for,e:ifande:key.