Tutorial 11 of 18

How to Build a 1:1 Chat Experience

Build a two-user chat thread with persisted history, live WebSocket messages, typing indicators, and reconnect recovery.

16 min read

A 1:1 chat thread connects two user IDs over WebSocket: load history when opening the thread, send new messages with send(), and append live deliveries from onMessage. Chat is not a separate transport — it uses send() for delivery and chat-specific APIs for history.

Core Building Blocks

connect (ws)
     │
     ├── fetchChatHistory on thread open
     │
     ├── send({ data, saveHistory: true }) → live delivery + persistence
     │
     ├── onMessage → append incoming messages to UI
     │
     └── deleteChatHistoryMessage → moderation / user delete
APIRole
send()Deliver a message to the peer; persist when saveHistory is true
fetchChatHistory()Load past messages for a thread
deleteChatHistoryMessage()Remove a stored message
onMessageReceive live messages and history responses

Thread Model

User A (user-alice)                         User B (user-bob)
       │                                           │
       │  fetchChatHistory([bob]) on open          │
       │  send({ receiverId: "user-bob" })         │
       ├──────────────────────────────────────────►│
       │                                           │
       │◄──────────────────────────────────────────┤
       │         onMessage on both sides            │
  • Thread key — pair (userA, userB); your app defines the sort order for fetchChatHistory.
  • Same appId — both participants use the same DNotifier application.
  • Stable userId — map your authenticated user to a consistent DNotifier ID.

Implementation Checklist

  • Connect on login with transport: "ws" and userId set to the authenticated user.
  • Open the thread with fetchChatHistory({ senderId, receiverIds: [peerId] }).
  • Render history by handling the history response in onMessage.
  • Send messages with send({ senderId, receiverId, data: { type: "text", text } }).
  • Append live onMessage payloads to the thread view.
  • Refetch history after reconnecting to fill delivery gaps.

Message Payload Convention

{
  "type": "text",
  "text": "Hello!"
}

Extend this convention with image, audio, and doc payload types for rich chat.

JavaScript / TypeScript

End-to-end pattern for a two-user chat thread.

Chat Client Skeleton

import WebSocket from "ws";
import { DNotifier } from "@dnotifier-realtime/dnotifier";

const MY_USER_ID = "user-alice";
const PEER_USER_ID = "user-bob";
const messages = [];

const notifier = new DNotifier({
  appId: process.env.DNOTIFIER_APP_ID,
  secret: process.env.DNOTIFIER_SECRET,
  transport: "ws",
  userId: MY_USER_ID,
  WebSocketImpl: WebSocket,
  onConnected: async () => {
    await openThread(PEER_USER_ID);
  },
  onMessage: (msg) => {
    const body = msg.payload.toJSON();

    // History response
    if (Array.isArray(body?.messages)) {
      messages.push(...body.messages);
      renderThread(messages);
      return;
    }

    // Live message
    if (body?.type === "text") {
      messages.push({
        sender: msg.metadata.sender,
        text: body.text,
        timestamp: msg.metadata.timestamp,
        id: msg.metadata.id,
      });
      renderThread(messages);
    }
  },
  onDisconnected: () => showOfflineState(),
});

async function openThread(peerId) {
  await notifier.fetchChatHistory({
    senderId: MY_USER_ID,
    receiverIds: [peerId],
  });
}

async function sendText(text) {
  await notifier.send({
    senderId: MY_USER_ID,
    receiverId: PEER_USER_ID,
    data: { type: "text", text },
    saveHistory: true,
  });
}

await notifier.connect();

Send on User Input

document.querySelector("#send-btn").addEventListener("click", async () => {
  const input = document.querySelector("#message-input");
  const text = input.value.trim();
  if (!text || !notifier.isConnected) return;

  await sendText(text);
  input.value = "";
});

Typing Indicator (Optional)

await notifier.send({
  senderId: MY_USER_ID,
  receiverId: PEER_USER_ID,
  data: { type: "typing" },
  saveHistory: false,
});

Handle the transient event in onMessage:

if (body?.type === "typing") {
  showTypingIndicator(msg.metadata.sender);
}

After Reconnect

onDisconnected: () => {
  showOfflineState();
  scheduleReconnect(async () => {
    await notifier.connect();
    await openThread(PEER_USER_ID);
  });
},

Dart / Flutter

End-to-end pattern for a two-user chat thread.

Chat Client Skeleton

import 'package:dnotifier/dnotifier.dart';

const myUserId = 'user-alice';
const peerUserId = 'user-bob';

class ChatMessage {
  ChatMessage({
    required this.sender,
    required this.text,
    this.id,
    this.timestamp,
  });

  final String sender;
  final String text;
  final String? id;
  final int? timestamp;
}

final messages = <ChatMessage>[];

Future<void> main() async {
  late final DNotifier notifier;

  notifier = DNotifier(
    appId: 'your-app-id',
    secret: 'your-app-secret',
    transport: 'ws',
    userId: myUserId,
    onConnected: () async {
      await openThread(notifier, peerUserId);
    },
    onMessage: (msg) {
      final body = msg.payload.toJSON();

      if (body is Map && body['messages'] is List) {
        for (final item in body['messages'] as List) {
          if (item is Map) {
            messages.add(ChatMessage(
              sender: item['sender']?.toString() ?? '',
              text: item['text']?.toString() ?? '',
              id: item['id']?.toString(),
            ));
          }
        }
        renderThread(messages);
        return;
      }

      if (body is Map && body['type'] == 'text') {
        messages.add(ChatMessage(
          sender: msg.metadata.sender,
          text: body['text']?.toString() ?? '',
          id: msg.metadata.id,
          timestamp: msg.metadata.timestamp,
        ));
        renderThread(messages);
      }
    },
    onDisconnected: ({code, reason}) => showOfflineState(),
  );

  await notifier.connect();
}

Future<void> openThread(DNotifier notifier, String peerId) async {
  await notifier.fetchChatHistory(
    senderId: myUserId,
    receiverIds: [peerId],
  );
}

Future<void> sendText(DNotifier notifier, String text) async {
  await notifier.send(
    senderId: myUserId,
    receiverId: peerUserId,
    data: {'type': 'text', 'text': text},
    saveHistory: true,
  );
}

void renderThread(List<ChatMessage> items) {
  // Bind to your UI.
}

Flutter Send Field

class ChatInput extends StatefulWidget {
  const ChatInput({super.key, required this.notifier});
  final DNotifier notifier;

  @override
  State<ChatInput> createState() => _ChatInputState();
}

class _ChatInputState extends State<ChatInput> {
  final _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: TextField(controller: _controller),
        ),
        IconButton(
          icon: const Icon(Icons.send),
          onPressed: () async {
            final text = _controller.text.trim();
            if (text.isEmpty || !widget.notifier.isConnected) return;
            await sendText(widget.notifier, text);
            _controller.clear();
          },
        ),
      ],
    );
  }
}

Typing Indicator (Optional)

await notifier.send(
  senderId: myUserId,
  receiverId: peerUserId,
  data: {'type': 'typing'},
  saveHistory: false,
);

After Reconnect

onDisconnected: ({code, reason}) async {
  showOfflineState();
  await reconnectWithBackoff(notifier);
  await openThread(notifier, peerUserId);
},

Python

End-to-end pattern for a two-user chat thread.

Chat Client Skeleton

import asyncio
from dataclasses import dataclass
from typing import Optional
from dnotifier import DNotifier

MY_USER_ID = "user-alice"
PEER_USER_ID = "user-bob"

@dataclass
class ChatMessage:
    sender: str
    text: str
    id: Optional[str] = None
    timestamp: Optional[int] = None

messages: list[ChatMessage] = []

async def open_thread(notifier: DNotifier, peer_id: str):
    await notifier.fetch_chat_history(
        sender_id=MY_USER_ID,
        receiver_ids=[peer_id],
    )

async def send_text(notifier: DNotifier, text: str):
    await notifier.send(
        sender_id=MY_USER_ID,
        receiver_id=PEER_USER_ID,
        data={"type": "text", "text": text},
        save_history=True,
    )

def on_message(msg):
    body = msg.payload.to_json()

    if isinstance(body, dict) and isinstance(body.get("messages"), list):
        for item in body["messages"]:
            if isinstance(item, dict):
                messages.append(ChatMessage(
                    sender=str(item.get("sender", "")),
                    text=str(item.get("text", "")),
                    id=item.get("id"),
                ))
        render_thread(messages)
        return

    if isinstance(body, dict) and body.get("type") == "text":
        messages.append(ChatMessage(
            sender=msg.metadata.sender,
            text=str(body.get("text", "")),
            id=msg.metadata.id,
            timestamp=msg.metadata.timestamp,
        ))
        render_thread(messages)

async def main():
    notifier = DNotifier(
        app_id="your-app-id",
        secret="your-app-secret",
        transport="ws",
        user_id=MY_USER_ID,
        on_connected=lambda: asyncio.create_task(
            open_thread(notifier, PEER_USER_ID)
        ),
        on_message=on_message,
        on_disconnected=lambda code=None, reason=None: show_offline_state(),
    )
    await notifier.connect()
    await asyncio.sleep(3600)

def render_thread(items: list[ChatMessage]):
    # Bind to your UI.
    pass

def show_offline_state():
    pass

asyncio.run(main())

CLI Send Loop

async def chat_loop(notifier: DNotifier):
    while True:
        text = input("> ").strip()
        if not text:
            continue
        await send_text(notifier, text)

Typing Indicator (Optional)

await notifier.send(
    sender_id=MY_USER_ID,
    receiver_id=PEER_USER_ID,
    data={"type": "typing"},
    save_history=False,
)

After Reconnect

async def on_reconnect(notifier: DNotifier):
    await notifier.connect()
    await open_thread(notifier, PEER_USER_ID)