Manual Recipes Grouped Feed Without Flicker

Grouped Feed Without Flicker

elements man recipes/grouped-feed Read as markdown

A 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 lines
interface 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.

Terminal
elements 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.ts
import { 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:for over one flat list. A loop inside a loop rebuilds the inner one whenever the outer row updates.
  • e:key takes 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:key and 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:if and e:key.