<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat · REALM examples</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
<style>
body { margin: 0; min-height: 100svh; display: grid; place-items: center; padding: 1.5rem 1rem; box-sizing: border-box; }
main { width: 100%; display: grid; justify-items: center; }
@media (max-width: 720px) { body { padding: 1rem 0.5rem; } }
</style>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/unicons@alpha/dist/unicons.html"></import-element>
<import-element from="./chat-app.html"></import-element>
<main><chat-app></chat-app></main>
</body>
</html>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/avatar.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/badge.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input-group.html"></import-element>
<import-element from="./chat-thread.html"></import-element>
<custom-element name="chat-app">
<script type="module">
import { chat, open } from "./chat.js";
function search(field) {
chat.query = field.value;
}
function select(conversation) {
open(conversation.id);
}
observe(() => {
document.title = chat.title;
});
</script>
<style>
:host {
display: grid;
grid-template-columns: calc(var(--spacing) * 80) minmax(0, 1fr);
width: min(calc(var(--spacing) * 260), 100%);
height: min(calc(100dvh - var(--spacing) * 12), calc(var(--spacing) * 180));
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
box-shadow: var(--shadow-sm);
}
aside {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: calc(var(--spacing) * 3);
min-height: 0;
padding: calc(var(--spacing) * 4) calc(var(--spacing) * 3);
border-right: 1px solid var(--border);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(var(--spacing) * 2);
padding: 0 calc(var(--spacing) * 1);
}
h1 {
margin: 0;
font-size: var(--text-xl);
letter-spacing: -0.02em;
}
nav {
min-height: 0;
overflow-y: auto;
}
ul {
display: grid;
gap: calc(var(--spacing) * 1);
margin: 0;
padding: 0;
list-style: none;
}
.conversation {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: calc(var(--spacing) * 3);
width: 100%;
padding: calc(var(--spacing) * 2);
border: 0;
border-radius: var(--radius-lg);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.conversation:hover {
background: var(--accent);
}
.conversation:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
.conversation[aria-current="true"] {
background: var(--secondary);
}
.text {
display: grid;
min-width: 0;
}
.name {
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
}
.preview {
overflow: hidden;
color: var(--muted-foreground);
font-size: var(--text-xs);
white-space: nowrap;
text-overflow: ellipsis;
}
.meta {
display: grid;
justify-items: end;
gap: calc(var(--spacing) * 1);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.meta ui-badge:not([data-unread]) {
visibility: hidden;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.empty {
padding: calc(var(--spacing) * 4);
color: var(--muted-foreground);
font-size: var(--text-sm);
text-align: center;
}
@media (max-width: 720px) {
:host {
grid-template-columns: minmax(0, 1fr);
height: calc(100dvh - var(--spacing) * 8);
}
aside {
border-right: 0;
}
:host(:state(state-chat-thread)) aside,
:host(:not(:state(state-chat-thread))) chat-thread {
display: none;
}
}
</style>
<aside>
<header>
<h1>Chat</h1>
<is-visible when="#chat.unread">
<ui-badge shape="pill"><slot name="#chat.unread"></slot> unread</ui-badge>
</is-visible>
</header>
<search>
<ui-input-group type="search" aria-label="Search conversations" placeholder="Search conversations…" oninput="search(this)">
<ui-input-group-addon><u-icon name="search"></u-icon></ui-input-group-addon>
</ui-input-group>
</search>
<nav aria-label="Conversations">
<ul>
<li repeat="#chat.list" as="conversation" key="id">
<button class="conversation" type="button" _aria-current="#conversation.current" onclick="select(conversation)">
<ui-avatar aria-hidden="true"><ui-avatar-fallback><slot name="#conversation.initials"></slot></ui-avatar-fallback></ui-avatar>
<span class="text">
<span class="name"><slot name="#conversation.name"></slot></span>
<span class="preview"><slot name="#conversation.preview"></slot></span>
</span>
<span class="meta">
<span><slot name="#conversation.time"></slot></span>
<ui-badge shape="round" _data-unread="#conversation.hasUnread"><slot name="#conversation.unread"></slot><span class="sr-only"> unread</span></ui-badge>
</span>
</button>
</li>
</ul>
<is-hidden when="#chat.list.length">
<p class="empty">No conversations match your search.</p>
</is-hidden>
</nav>
</aside>
<chat-thread></chat-thread>
</custom-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/attachment.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/avatar.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/bubble.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/message.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/message-scroller.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/textarea.html"></import-element>
<custom-element name="chat-thread">
<script type="module">
import { attachmentFor, chat, outgoing, reply } from "./chat.js";
const draft = useState({ text: "", attachment: null });
function typed(field) {
draft.text = field.value;
}
function enter(event) {
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
refs.composer.requestSubmit();
}
function pick() {
refs.file.click();
}
function picked(input) {
draft.attachment = attachmentFor(input.files[0]);
input.value = "";
}
function detach() {
URL.revokeObjectURL(draft.attachment.href);
draft.attachment = null;
}
function send(event) {
event.preventDefault();
const target = chat.active;
const message = outgoing(draft.text, draft.attachment);
if (!message) return;
target.messages.push(message);
draft.text = "";
draft.attachment = null;
setTimeout(() => {
target.typing = true;
}, 600);
setTimeout(() => reply(target), 2000);
}
function back() {
chat.thread = false;
}
</script>
<style>
:host {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-width: 0;
min-height: 0;
}
header {
display: flex;
align-items: center;
gap: calc(var(--spacing) * 3);
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
border-bottom: 1px solid var(--border);
}
.back {
display: none;
}
h2 {
margin: 0;
font-size: var(--text-base);
}
header p {
margin: 0;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.messages {
display: flex;
flex-direction: column;
gap: calc(var(--spacing) * 4);
padding: calc(var(--spacing) * 4);
}
.typing {
display: inline-flex;
gap: calc(var(--spacing) * 1);
padding-block: calc(var(--spacing) * 1);
}
.typing i {
width: calc(var(--spacing) * 1.5);
height: calc(var(--spacing) * 1.5);
border-radius: var(--radius-full);
background: currentColor;
animation: blink 1.2s infinite both;
}
.typing i:nth-child(2) {
animation-delay: 0.2s;
}
.typing i:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes blink {
0%,
80%,
100% {
opacity: 0.25;
}
40% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.typing i {
animation: none;
}
}
form {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: end;
gap: calc(var(--spacing) * 2);
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
border-top: 1px solid var(--border);
}
.pending {
grid-column: 1 / -1;
}
input[type="file"] {
display: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 720px) {
.back {
display: inline-flex;
}
header,
form {
padding-inline: calc(var(--spacing) * 3);
}
}
</style>
<header>
<ui-button class="back" variant="ghost" size="icon-sm" aria-label="Back to conversations" onclick="back()"><u-icon name="arrow-left"></u-icon></ui-button>
<ui-avatar aria-hidden="true"><ui-avatar-fallback><slot name="#chat.active.initials"></slot></ui-avatar-fallback></ui-avatar>
<div>
<h2><slot name="#chat.active.name"></slot></h2>
<p><slot name="#chat.status"></slot></p>
</div>
</header>
<ui-message-scroller>
<div class="messages">
<repeat-list of="#chat.messages" as="message" key="id">
<ui-message _align="#message.align">
<ui-message-avatar><ui-avatar aria-hidden="true"><ui-avatar-fallback><slot name="#message.initials"></slot></ui-avatar-fallback></ui-avatar></ui-message-avatar>
<ui-message-header><slot name="#message.sender"></slot></ui-message-header>
<is-visible when="#message.text">
<ui-bubble _variant="#message.variant" _align="#message.align"><slot name="#message.text"></slot></ui-bubble>
</is-visible>
<is-visible when="#message.attachment">
<ui-attachment _name="#message.attachment.name" _size="#message.attachment.size" _href="#message.attachment.href">
<ui-attachment-media><u-icon name="file-text"></u-icon></ui-attachment-media>
</ui-attachment>
</is-visible>
<ui-message-footer><slot name="#message.time"></slot></ui-message-footer>
</ui-message>
</repeat-list>
<is-visible when="#chat.active.typing">
<ui-message>
<ui-message-avatar><ui-avatar aria-hidden="true"><ui-avatar-fallback><slot name="#chat.active.initials"></slot></ui-avatar-fallback></ui-avatar></ui-message-avatar>
<ui-bubble variant="muted">
<span class="typing" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="sr-only"><slot name="#chat.active.name"></slot> is typing</span>
</ui-bubble>
</ui-message>
</is-visible>
</div>
</ui-message-scroller>
<form ref="composer" aria-label="Write a message" onsubmit="send(event)">
<is-visible when="#draft.attachment">
<ui-attachment class="pending" _name="#draft.attachment.name" _size="#draft.attachment.size">
<ui-attachment-media><u-icon name="paperclip"></u-icon></ui-attachment-media>
<ui-attachment-actions>
<ui-button variant="ghost" size="icon-xs" aria-label="Remove attachment" onclick="detach()"><u-icon name="x"></u-icon></ui-button>
</ui-attachment-actions>
</ui-attachment>
</is-visible>
<input ref="file" type="file" aria-label="Attach a file" onchange="picked(this)">
<ui-button variant="ghost" size="icon" aria-label="Attach a file" onclick="pick()"><u-icon name="paperclip"></u-icon></ui-button>
<ui-textarea rows="1" aria-label="Message" placeholder="Write a message…" _value="#draft.text" oninput="typed(this)" onkeydown="enter(event)"></ui-textarea>
<ui-button type="submit" size="icon" aria-label="Send"><u-icon name="send"></u-icon></ui-button>
</form>
</custom-element>
import { defineStore } from "https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js";
const clock = new Intl.DateTimeFormat("en", { hour: "numeric", minute: "2-digit" });
export const now = () => clock.format(new Date());
const initials = (name) =>
name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2);
const file = (name, size, text) => ({
name,
size,
href: `data:text/plain;charset=utf-8,${encodeURIComponent(text)}`,
});
const message = (from, text, time, attachment = null) => ({
id: crypto.randomUUID(),
from,
text,
time,
attachment,
});
// Each contact answers with the next line of their script, then starts again.
const REPLIES = {
ana: [
"Sounds good to me.",
"Can you send the draft before lunch?",
"Perfect, I'll review it this afternoon.",
],
ben: ["Ha, classic.", "On it.", "Let me check and get back to you."],
chloe: ["Thanks! 🙌", "I'll bring snacks.", "See you there."],
dev: ["Merged.", "CI is green now.", "Shipping it after the standup."],
};
const conversation = (id, name, status, unread, messages) => ({
id,
name,
initials: initials(name),
status,
unread,
typing: false,
replies: 0,
messages,
});
export const chat = defineStore("example-chat", {
conversations: [
conversation("ana", "Ana Lopez", "Product designer", 0, [
message("them", "Morning! Did you get a chance to look at the onboarding flow?", "9:12 AM"),
message("me", "Yes, I left a few comments on the second screen.", "9:20 AM"),
message(
"them",
"Great, here are the updated mockups.",
"9:24 AM",
file("onboarding-v3.txt", "2 KB", "Onboarding flow, version 3"),
),
]),
conversation("ben", "Ben Carter", "Support lead", 2, [
message("them", "A customer asked for a dark mode export.", "Yesterday"),
message("them", "Is that on the roadmap?", "Yesterday"),
]),
conversation("chloe", "Chloe Park", "Office manager", 1, [
message("them", "Team lunch is on Friday at 12:30.", "Mon"),
]),
conversation("dev", "Dev Patel", "Engineer", 0, [
message("me", "Can you review my pull request?", "Sun"),
message(
"them",
"Sure, attaching my notes.",
"Sun",
file("review-notes.txt", "1 KB", "Looks good. Two small nits inline."),
),
]),
],
activeId: "ana",
thread: false,
query: "",
get active() {
return this.conversations.find((item) => item.id === this.activeId);
},
get list() {
const query = this.query.trim().toLowerCase();
return this.conversations
.filter((item) => item.name.toLowerCase().includes(query))
.map((item) => {
const last = item.messages.at(-1);
return {
...item,
current: item.id === this.activeId,
preview: item.typing ? "typing…" : `${last.from === "me" ? "You: " : ""}${last.text}`,
time: last.time,
hasUnread: item.unread > 0,
};
});
},
get unread() {
return this.conversations.reduce((total, item) => total + item.unread, 0);
},
get title() {
return `${this.unread ? `(${this.unread}) ` : ""}Chat · REALM examples`;
},
get messages() {
const active = this.active;
return active.messages.map((item) => ({
...item,
mine: item.from === "me",
align: item.from === "me" ? "end" : "start",
variant: item.from === "me" ? "default" : "muted",
initials: item.from === "me" ? "ME" : active.initials,
sender: item.from === "me" ? "You" : active.name,
}));
},
get status() {
return this.active.typing ? "typing…" : this.active.status;
},
});
export const open = (id) => {
chat.activeId = id;
chat.active.unread = 0;
chat.thread = true;
};
const SIZES = ["B", "KB", "MB", "GB"];
const humanSize = (bytes) => {
const power = Math.min(Math.floor(Math.log(Math.max(bytes, 1)) / Math.log(1024)), 3);
return `${Math.round(bytes / 1024 ** power)} ${SIZES[power]}`;
};
export const attachmentFor = (picked) =>
picked
? { name: picked.name, size: humanSize(picked.size), href: URL.createObjectURL(picked) }
: null;
export const outgoing = (text, attachment) => {
const body = text.trim();
if (!body && !attachment) return null;
return message("me", body, now(), attachment);
};
export const reply = (target) => {
const lines = REPLIES[target.id];
target.messages.push(message("them", lines[target.replies % lines.length], now()));
target.replies++;
target.typing = false;
if (target.id !== chat.activeId) target.unread++;
};