Skip to main content
Version: 2.0.0

Unread counts and notifications

Prerequisites

This guide builds on Chat rooms and private DMs: it uses its tables, its find_membership function, its views and its client. If you haven't built chat rooms yet, follow that guide first.

In this guide, you'll show how many messages each room has that the player hasn't read, such as "general (3)", and let them know when a new message arrives in another room. The database remembers what each player has read, so reading a room on one device clears its count on the others.

How it works​

  • A private read_marker table holds the id of the last message each player read in each room.
  • A mark_read reducer moves the caller's marker forward, and never back.
  • A my_read_markers view returns the caller's markers.
  • Clients count each room's unread messages from my_messages, which they already have: the messages that other players sent after the marker.
  • When the player opens a room, or a message arrives in the room they have open, the client marks the room as read.
  • When a message arrives in another room, the client shows a notice, such as "New message from Alice in general".

Each message's id is larger than the ids of the messages sent before it, so one number per room is enough: the unread messages are the ones with a larger id. Because the marker never moves back, a late or repeated request can't make messages unread again.

Define the table and the view​

Add the read_marker table. It's private, so players can't read what others have read. It has an index on member, so the reducer and the view can find a player's markers quickly.

Then add the my_read_markers view, which returns the caller's markers.

const readMarker = table(
  { name: 'read_marker' },
  {
    id: t.u64().primaryKey().autoInc(),
    roomId: t.u64(),
    member: t.identity().index('btree'),
    lastReadId: t.u64(),
  }
);

const spacetimedb = schema({ player, chatRoom, roomMember, message, directMessage, readMarker });
export default spacetimedb;

// The caller's read markers.
export const myReadMarkers = spacetimedb.view(
  { name: 'my_read_markers', public: true },
  t.array(readMarker.rowType),
  ctx => ctx.from.readMarker.where(m => m.member.eq(ctx.sender))
);

A new table is always a safe change to a published module: the existing tables and their rows stay as they are.

Mark messages as read​

Add a find_read_marker function that returns a player's marker in a room, if there is one. It works like find_membership: it reads the player's markers through the member index, and looks for the room among them.

Then add a mark_read reducer. It takes a room and the id of the last message the player read in it, and:

  • Rejects players who aren't in the room.
  • Rejects messages that aren't in the room. Without this check, a client could set its marker to an id that no message has yet, and the next messages would show as read.
  • Creates the player's marker if they don't have one yet, or moves it forward. If the message is older than the marker, it changes nothing.

Finally, replace leave_room with a version that also deletes the player's marker, so that no marker stays behind for a room they left.

// Returns the read marker of `member` in the room, if there is one.
function findReadMarker(ctx: Ctx, roomId: bigint, member: Identity) {
  for (const row of ctx.db.readMarker.member.filter(member)) {
    if (row.roomId === roomId) {
      return row;
    }
  }
  return undefined;
}

export const markRead = spacetimedb.reducer(
  { roomId: t.u64(), lastReadId: t.u64() },
  (ctx, { roomId, lastReadId }) => {
    if (!findMembership(ctx, roomId, ctx.sender)) {
      throw new SenderError("You're not in this room");
    }
    const msg = ctx.db.message.id.find(lastReadId);
    if (!msg || msg.roomId !== roomId) {
      throw new SenderError('Message not found');
    }
    const marker = findReadMarker(ctx, roomId, ctx.sender);
    if (!marker) {
      ctx.db.readMarker.insert({ id: 0n, roomId, member: ctx.sender, lastReadId });
    } else if (lastReadId > marker.lastReadId) {
      ctx.db.readMarker.id.update({ ...marker, lastReadId });
    }
  }
);

export const leaveRoom = spacetimedb.reducer({ roomId: t.u64() }, (ctx, { roomId }) => {
  const row = findMembership(ctx, roomId, ctx.sender);
  if (row) {
    ctx.db.roomMember.id.delete(row.id);
  }
  const marker = findReadMarker(ctx, roomId, ctx.sender);
  if (marker) {
    ctx.db.readMarker.id.delete(marker.id);
  }
});

A player who joins a room has no marker there yet, so the room's earlier messages all count as unread. The client marks them as read as soon as the player opens the room.

Show unread counts​

On the client, add my_read_markers to the subscription of the chat rooms client: tables.myReadMarkers in TypeScript, q => q.From.MyReadMarkers() in C#, and |q| q.from.my_read_markers() in Rust. Then add the functions below:

  • last_read_id returns the player's marker in a room, or 0 if they don't have one.
  • unread_count counts the messages of a room that have a larger id than the marker, leaving out the player's own messages.
  • mark_room_read calls mark_read with the newest message of a room. It doesn't call it if there's nothing new, so opening a room that's already read sends nothing.
  • show_unread_counts shows each of the player's rooms, with its count if it has unread messages.

Show the counts again whenever a marker changes, or the player joins or leaves a room.

Then change show_room so that it also marks the room as read. The TypeScript and C# clients also keep track of the room on screen, in openRoomId, so show_conversation changes too. The Rust client already knows which room is on screen.

// The room on screen, if any.
let openRoomId: bigint | undefined;

// The id of the last message the player read in the room, or 0 if they haven't read any.
function lastReadId(roomId: bigint) {
  for (const marker of conn.db.myReadMarkers.iter()) {
    if (marker.roomId === roomId) {
      return marker.lastReadId;
    }
  }
  return 0n;
}

// The number of messages that other players sent in the room after the player's last read one.
function unreadCount(roomId: bigint) {
  const lastRead = lastReadId(roomId);
  let count = 0;
  for (const m of conn.db.myMessages.iter()) {
    if (m.roomId === roomId && m.id > lastRead && !m.sender.isEqual(conn.identity!)) {
      count++;
    }
  }
  return count;
}

// Marks every message of the room as read, if there are new ones.
function markRoomRead(roomId: bigint) {
  let newest = 0n;
  for (const m of conn.db.myMessages.iter()) {
    if (m.roomId === roomId && m.id > newest) {
      newest = m.id;
    }
  }
  if (newest > lastReadId(roomId)) {
    conn.reducers.markRead({ roomId, lastReadId: newest }).catch(console.error);
  }
}

function showUnreadCounts() {
  for (const room of conn.db.myRooms.iter()) {
    const count = unreadCount(room.id);
    console.log(count > 0 ? `${room.name} (${count})` : room.name);
  }
}

conn.db.myReadMarkers.onInsert(() => showUnreadCounts());
conn.db.myReadMarkers.onUpdate(() => showUnreadCounts());
conn.db.myRooms.onInsert(() => showUnreadCounts());
conn.db.myRooms.onDelete(() => showUnreadCounts());

function showRoom(roomId: bigint) {
  openRoomId = roomId;
  markRoomRead(roomId);
  render = () => {
    const messages = Array.from(conn.db.myMessages.iter())
      .filter(m => m.roomId === roomId)
      .sort((a, b) => (a.id < b.id ? -1 : 1));
    for (const m of messages) {
      console.log(`${nameOf(m.sender)}: ${m.text}`);
    }
  };
  render();
}

function showConversation(other: Identity) {
  openRoomId = undefined;
  render = () => {
    const sent = Array.from(conn.db.mySentDms.iter()).filter(m => m.recipient.isEqual(other));
    const received = Array.from(conn.db.myReceivedDms.iter()).filter(m => m.sender.isEqual(other));
    const messages = [...sent, ...received].sort((a, b) => (a.id < b.id ? -1 : 1));
    for (const m of messages) {
      console.log(`${nameOf(m.sender)}: ${m.text}`);
    }
  };
  render();
}

Notify about new messages​

When a message arrives in the room on screen, mark the room as read, so the player's other devices clear it too. Wait for the marker to change before showing the counts again, or the room briefly shows 1 unread message. When a message arrives in another room, show a notice, then the counts.

Only notify about messages that other players just sent. Inserts into my_messages also happen when the client subscribes, and when the player joins a room and gets its earlier messages. The event of each insert tells them apart:

  • SubscribeApplied: the client just subscribed, and is getting the rows that already exist.
  • Reducer: a reducer this client called made the change, such as join_room or send_message.
  • Transaction: another client's reducer made the change. That's the one to notify about.
conn.db.myMessages.onInsert((ctx, message) => {
  if (message.roomId === openRoomId) {
    markRoomRead(message.roomId);
    return;
  }
  if (ctx.event.tag === 'Transaction') {
    const room = conn.db.chatRoom.id.find(message.roomId);
    console.log(`New message from ${nameOf(message.sender)} in ${room?.name}`);
  }
  showUnreadCounts();
});

The counts show again for each message that arrives, so subscribing or joining a room with a long history shows them many times in a row. As in the chat rooms guide, if rendering is expensive, render at most once per frame instead.

What's next?​

You now show unread counts that stay in sync across the player's devices, and notify them about new messages:

  • A private table holds the id of the last message each player read in each room, and a view gives each player their own.
  • A reducer only moves a marker forward, and only to a message of the room.
  • Clients count the unread messages themselves, from the messages they already have.
  • Clients tell new messages from other players apart from the ones they get when they subscribe or join a room, with the event of each insert.

To adapt it to your app:

  • DMs. Add a marker for each conversation, with the other player's identity instead of a room, and count the DMs you received after it.
  • Big rooms. The client counts unread messages from the messages it has, so it needs every message of every room the player is in. If you only send clients the latest messages, for example with pagination, keep an unread count in each player's marker instead: send_message adds one to the count of every other member of the room, and mark_read sets it back to zero.
  • Mentions. To notify players only when someone mentions them, check the text of each message for their name before showing the notice.
  • Players who aren't online. Clients only see new messages while they're connected. To reach players who aren't, send them an email from your module, as in Send an email from your module.

To learn more about the features used here, see Views and Indexes.

Next, you can make a top 100 leaderboard.