Real-time Chat
A collaborative chat app with live cursors and typing indicators. Demonstrates CRDT sync, presence tracking, and reactive peer state.
Synced Store
Create src/stores/chat.store.ts:
ts
import { defineStore, createWebSocketTransport } from '@akashjs/runtime';
const transport = createWebSocketTransport('wss://sync.example.com');
export const useChat = defineStore('chat', {
state: () => ({
messages: [] as Array<{ id: number; author: string; text: string; time: number }>,
topic: 'General',
}),
actions: {
send(author: string, text: string) {
this.messages.update((msgs) => [
...msgs,
{ id: Date.now(), author, text, time: Date.now() },
]);
},
setTopic(topic: string) {
this.topic.set(topic);
},
},
// Real-time sync — all connected users see messages instantly
sync: {
transport,
room: 'chat-room',
presence: true,
},
// Messages persist locally for offline access
persist: true,
});Chat Component
Create src/routes/page.akash:
html
<script lang="ts">
import { signal, effect, onMount } from '@akashjs/runtime';
import { useTypingIndicator } from '@akashjs/runtime';
import { useChat } from '@/stores/chat.store';
const chat = useChat();
const input = signal('');
const username = signal(localStorage.getItem('chat-name') || '');
const showNamePrompt = signal(!username());
// Connect to sync room
chat.$sync.connect();
// Set presence with username
effect(() => {
if (username()) {
chat.$sync.presence.set({ name: username() });
}
});
// Typing indicator
const typing = useTypingIndicator(chat.$sync, { timeout: 2000 });
// Auto-scroll to bottom on new messages
let messagesDiv: HTMLElement | null = null;
effect(() => {
chat.messages(); // track
if (messagesDiv) {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
});
function setName() {
const name = username().trim();
if (!name) return;
localStorage.setItem('chat-name', name);
showNamePrompt.set(false);
}
function sendMessage() {
const text = input().trim();
if (!text) return;
chat.send(username(), text);
input.set('');
typing.stop();
}
function handleInput(e: Event) {
input.set((e.target as HTMLInputElement).value);
typing.start(); // broadcasts typing state to peers
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
</script>
<template>
<div class="chat">
<!-- Name prompt -->
<Show when={showNamePrompt()}>
{() => (
<div class="name-prompt">
<h2>Enter your name</h2>
<form onSubmit|preventDefault={setName}>
<input
bind:value={username}
placeholder="Your name..."
autofocus
/>
<button type="submit">Join Chat</button>
</form>
</div>
)}
</Show>
<!-- Chat UI -->
<Show when={!showNamePrompt()}>
{() => (
<div class="chat-container">
<!-- Header -->
<header>
<h2>{chat.topic()}</h2>
<div class="peers">
<span class="dot"></span>
{chat.$sync.peers().length + 1} online
</div>
</header>
<!-- Online users -->
<div class="online-bar">
<For each={[...chat.$sync.peerPresence().entries()]} key={([id]) => id}>
{([id, peer]) => (
<span class="peer-badge">{peer.name || 'Anonymous'}</span>
)}
</For>
</div>
<!-- Messages -->
<div class="messages" ref={(el) => { messagesDiv = el; }}>
<For each={chat.messages()} key={(m) => m.id}>
{(msg) => (
<div class={msg.author === username() ? 'message own' : 'message'}>
<div class="meta">
<strong>{msg.author}</strong>
<span class="time">{formatTime(msg.time)}</span>
</div>
<p>{msg.text}</p>
</div>
)}
</For>
<Show when={chat.messages().length === 0}>
{() => <p class="empty">No messages yet. Say hello!</p>}
</Show>
</div>
<!-- Typing indicator -->
<Show when={typing.othersTyping().length > 0}>
{() => (
<div class="typing">
{typing.othersTyping().length === 1
? 'Someone is typing...'
: `${typing.othersTyping().length} people are typing...`}
</div>
)}
</Show>
<!-- Input -->
<form class="input-bar" onSubmit|preventDefault={sendMessage}>
<input
value={input()}
onInput={handleInput}
placeholder="Type a message..."
/>
<button type="submit" disabled={!input().trim()}>Send</button>
</form>
</div>
)}
</Show>
</div>
</template>
<style scoped>
.chat {
max-width: 600px;
margin: 0 auto;
height: 100vh;
display: flex;
flex-direction: column;
font-family: system-ui, sans-serif;
}
.name-prompt {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 1rem;
}
.name-prompt form {
display: flex;
gap: 0.5rem;
}
.name-prompt input {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
}
.name-prompt button {
padding: 0.75rem 1.5rem;
background: #6750a4;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #eee;
}
header h2 { margin: 0; }
.peers {
display: flex;
align-items: center;
gap: 0.5rem;
color: #666;
font-size: 0.875rem;
}
.dot {
width: 8px;
height: 8px;
background: #22c55e;
border-radius: 50%;
}
.online-bar {
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid #f0f0f0;
flex-wrap: wrap;
}
.peer-badge {
padding: 0.25rem 0.5rem;
background: #f3f0ff;
color: #6750a4;
border-radius: 12px;
font-size: 0.75rem;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.message {
max-width: 80%;
padding: 0.5rem 0.75rem;
background: #f5f5f5;
border-radius: 12px;
border-top-left-radius: 4px;
}
.message.own {
align-self: flex-end;
background: #6750a4;
color: white;
border-top-left-radius: 12px;
border-top-right-radius: 4px;
}
.meta {
display: flex;
gap: 0.5rem;
align-items: baseline;
font-size: 0.75rem;
margin-bottom: 0.25rem;
}
.message.own .meta { color: rgba(255,255,255,0.8); }
.time { color: #999; }
.message.own .time { color: rgba(255,255,255,0.6); }
.message p { margin: 0; }
.empty {
text-align: center;
color: #999;
padding: 3rem;
}
.typing {
padding: 0.5rem 1rem;
color: #999;
font-size: 0.875rem;
font-style: italic;
}
.input-bar {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid #eee;
}
.input-bar input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 20px;
font-size: 1rem;
}
.input-bar button {
padding: 0.75rem 1.5rem;
background: #6750a4;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
}
.input-bar button:disabled {
opacity: 0.5;
}
</style>What This Demonstrates
defineStorewithsync— messages synced to all peers via CRDT in real-timepersist: true— messages saved locally for offline access$sync.connect()— connect to the sync room$sync.presence.set()— broadcast your name to peers$sync.peerPresence()— see who's online (returnsMap<peerId, data>)$sync.peers()— count connected peersuseTypingIndicator— auto-timeout typing state,othersTyping()shows who's typingeffect()— auto-scroll on new messages, update presence on name changebind:value— two-way input bindingclass:own— conditional styling for own messagesrefcallback — capture DOM element for scroll controlpersist + sync— offline-first: works without network, syncs when connected
