A single-page chat client built with SvelteKit 5 (runes) on top of the Ethora / Dappros Platform: REST API for accounts and chat management, ejabberd (XMPP over WebSocket) for real-time messaging.
- Auth — email sign-in and sign-up (
/login,/register) with automatic login after registration; session and XMPP credentials in localStorage. - Chats — public, group and direct (1-1) chats; create, delete (owner), leave; shareable room links that auto-join new members; mediated MUC invites accepted automatically.
- Members — the owner adds/removes members (
/v2/chats/users-access); removed clients are kicked out instantly (XEP-0045 status codes 110+321); a voluntary leave is broadcast via a<status>marker so other clients drop the leaver from the member list without waiting for the backend. - Messaging — live groupchat over XMPP with MAM (XEP-0313) history paging; links are clickable, room links open in-app.
- Delivery & read receipts — own messages go
…(sending) →✓(reached the server) → gray✓✓(delivered to at least one member) → blue✓✓(read by at least one member); the tooltip shows both counts. See Delivery & read receipts. - Media — file upload (
/v1/files/) sent in the Ethora media-stanza format; several files can go in one message with an optional text caption; images open in a PhotoSwipe gallery, video/audio play inline. - Mentions —
@-autocomplete in the composer, sent as XEP-0372 references next to a plain-text body; highlighted rendering, louder notification sound and a "mentioned you" browser notification. - Reactions — emoji reactions on any message (XEP-0444); a hover picker and reaction chips with per-emoji counts. See Reactions.
- Message deletion — own messages, via the Ethora
<delete>stanza; MAM tombstones are filtered out of history. - Presence — online occupants per room, message sounds (quiet tick for background messages, chime for mentions), browser notifications when the tab is hidden.
- Sender identity — names/avatars come from
/chats/mymembers; senders who left are resolved via/v2/chats/usersand labeled "(former member)", deleted accounts show as "Deleted user".
SvelteKit 2 / Svelte 5 (SPA, ssr = false) · Tailwind CSS v4 · Bits UI ·
axios · @xmpp/client · PhotoSwipe
npm install
cp .env.example .env # then fill in the values
npm run dev| Variable | Purpose |
|---|---|
PUBLIC_API_BASE_URL |
Ethora REST API base, e.g. https://api.chat.ethora.com |
PUBLIC_APP_DOMAIN_NAMES |
comma-separated app domain names to switch between, e.g. app,justchatapp; the first is the default. The app id and appToken (sent on login/registration) come from get-config for the selected domain |
PUBLIC_APP_ENV |
set to dev to show a DEV badge |
PUBLIC_XMPP_WS |
XMPP WebSocket endpoint, e.g. wss://xmpp.chat.ethora.com/ws |
PUBLIC_XMPP_HOST |
XMPP domain, e.g. xmpp.chat.ethora.com |
PUBLIC_XMPP_CONFERENCE |
MUC service domain, e.g. conference.xmpp.chat.ethora.com |
| Command | What it does |
|---|---|
npm run dev |
dev server on http://localhost:5173 |
npm run build / npm run preview |
production build / preview |
npm run check |
typecheck with svelte-check |
src/
├── lib/
│ ├── api/ # axios client + REST wrappers (auth, chats, files, users)
│ ├── components/ # dialogs (create chat, manage members, confirm)
│ ├── state/ # Svelte 5 $state modules (chats, messages, xmpp, user lookups)
│ ├── xmpp/client.ts # XMPP connection, MUC, MAM, stanza conventions
│ ├── lightbox.ts # PhotoSwipe wrapper
│ └── sound.ts # synthesized notification sounds (Web Audio)
└── routes/
├── login, register # public auth pages
└── (protected)/ # chat UI + profile, guarded by localStorage token
Learned against the QA backend and verified live; the wire format matters for interop with official Ethora clients:
- Membership = MUC/Sub subscription (
urn:xmpp:mucsub:0), not the join presence. Unsubscribing requires thenickattribute, otherwise the backend keeps the membership. Backend membership changes propagate to/chats/myasynchronously (~10–60 s), so the UI updates local state first. - Media message = body
media+<store>hint +<data isMediafile="true" …>with the uploaded file's metadata. This client extends the format (while staying compatible with the single-file shape): one<data>element per attached file, and the body may carry a text caption instead of the literalmedia— see Media messages. - Delete message =
<body xmlns="wow"/>+<delete id="<archive-id>"/>; the room reflects it to everyone and MAM keeps a tombstone. - Mentions = XEP-0372
<reference type="mention" begin end uri="xmpp:…"/>elements; they survive both live reflection and the MAM archive. - Reactions = XEP-0444
<reactions id="<archive-id>"><reaction>…</reaction></reactions>carrying a reactor's full emoji set (an empty element clears it). Archived as standalone entries, not a rewrite of the target — see Reactions. - Delivery receipts = bodyless groupchat message with
<received xmlns="urn:xmpp:receipts" id="<archive-id>"/>and a<store>hint — see Delivery & read receipts. - Read markers = bodyless groupchat message with
<displayed xmlns="urn:xmpp:chat-markers:0" id="<archive-id>"/>and a<store>hint — see Delivery & read receipts. - Voluntary leave is signalled with
<status>left-room</status>inside the unavailable presence — on the wire a plain leave is otherwise indistinguishable from a connection drop.
Attachments are uploaded to POST /v1/files/ first; the chat message then
only carries their metadata. The classic Ethora shape is one file per
message: the body is the literal media and the metadata sits in a single
<data isMediafile="true" …> element. This client extends that in two
backward-compatible ways — several files ride in one message as one
<data> element each, and the body may carry a text caption instead
of the literal media:
<message id="send-media-message:fcc6…" type="groupchat" to="room@conference…">
<body>look at these two</body> <!-- caption; "media" = no caption -->
<store xmlns="urn:xmpp:hints"/>
<data isMediafile="true" expiresAt="0"
location="https://files…/a62a….jpg" locationPreview="https://files…/6a51….jpg"
mimetype="image/jpeg" originalName="one.jpg" size="68980"
isReply="false" showInChannel="false" mainMessage="" push="true"/>
<data isMediafile="true" expiresAt="0"
location="https://files…/b73b….jpg" locationPreview="https://files…/7b62….jpg"
mimetype="image/jpeg" originalName="two.jpg" size="41656"
isReply="false" showInChannel="false" mainMessage="" push="true"/>
</message>Verified live against the QA ejabberd: a multi-<data> stanza survives both
the MUC reflection and the MAM archive with all elements and the caption
intact.
Client handling:
- Sending: files picked via 📎 are uploaded immediately and staged next to the composer (thumbnails/chips with a remove button); Send ships them all in a single message with the typed text as the caption.
- Receiving: the parser collects all
<data isMediafile="true">children into the message'smedialist, so the official single-file format is just the one-element case. A body other thanmediais the caption. - Rendering: images form a fixed-width (400px) collage — 2 side by side, 3 as one tall + two stacked, 4 as a 2×2 grid, more than 4 as a 2×2 grid with a “+N” veil (the hidden ones are reachable by swiping in the PhotoSwipe gallery). Non-image files are listed below the images with a download button that fetches the file as a blob (no tab flash); video/audio play inline. The caption renders under the attachments.
Emoji reactions follow XEP-0444, sent as a groupchat stanza referencing the
target message's archive id. The <reactions> element carries the reactor's
complete current set of reactions (adding or removing one re-sends the
whole set); an empty element clears them. The emoji travel as shortcodes
(fire, heart, …); a <store> hint archives the stanza.
<message id="message-reaction:1783874025962" type="groupchat" to="room@conference…">
<reactions id="1783612069420353" from="…@xmpp…" xmlns="urn:xmpp:reactions:0">
<reaction>fire</reaction>
</reactions>
<data senderFirstName="Emily" senderLastName="Johnson"/>
<store xmlns="urn:xmpp:hints"/>
</message>
<!-- removing all of this user's reactions: an empty <reactions/> -->
<reactions id="1783612069420353" from="…@xmpp…" xmlns="urn:xmpp:reactions:0"/>Verified live against the QA ejabberd: the room reflects the reaction to every
occupant, and MAM archives each reaction change as its own standalone entry
(with its own <stanza-id>) — it does not rewrite the target message. So the
current reactions are reconstructed by replaying those entries.
Client handling:
- State: per message, a map
reactor nickname → their emoji shortcodes. Because MAM keeps every change as a separate, out-of-order-loadable entry, each reactor's set is applied last-writer-wins by the reaction stanza's own archive id, so paging older history never resurrects a stale set. A reaction whose target message is not loaded yet is parked (like a receipt) and applied when the message arrives. - Sending (
setRoomReaction): clicking a chip or a picker emoji toggles it in the user's set and sends the new full set; applied optimistically, then the server echo (with a real archive id) overrides it. - Rendering: a hover 🙂 picker on every message; chips under the bubble show each emoji with a count, the user's own reactions highlighted, and the reactors' names in the tooltip. Unknown incoming shortcodes render as their raw text, so reactions from other clients degrade gracefully.
Message status shown under every own message: … sending → ✓ reached
the server → gray ✓✓ delivered to at least one member → blue ✓✓ read
by at least one member. Hovering the ticks shows both counts, e.g.
Read by 2 · Delivered to 5 (reading implies receiving, so
delivered ≥ read). Everything below was verified live against the QA
ejabberd before being relied upon.
A MUC room reflects every groupchat message back to its sender, and this
ejabberd preserves the stanza id in that reflection. The client uses
that instead of a separate ack:
<!-- out -->
<message id="local-3f2a…" type="groupchat" to="room@conference…">
<body>hello</body>
</message>
<!-- in: the echo confirms delivery to the server -->
<message id="local-3f2a…" type="groupchat" from="room@conference…/me">
<body>hello</body>
<stanza-id xmlns="urn:xmpp:sid:0" id="1783689048683297"/>
</message>Client handling: sendRoomMessage() appends the message to state
immediately with pending: true and a local-… placeholder id. When the
echo arrives, the live handler matches it by the preserved stanza id
(confirmMessage()), swaps the pending copy for the confirmed one — its
real id is the <stanza-id> archive id — and the … becomes ✓. A failed
send removes the optimistic copy.
When a client receives a live message from someone else, it answers into the room with a bodyless stanza referencing the message's archive id:
<message type="groupchat" to="room@conference…">
<received xmlns="urn:xmpp:receipts" id="1783689048683297"/>
<store xmlns="urn:xmpp:hints"/>
</message>The room reflects it to every occupant (the sender's ✓ turns into ✓✓
live) and the <store> hint makes MAM archive it, so the state survives
page reloads.
Client handling:
- Sending: on every live foreign message; plus a catch-up pass in
loadLastMessages()for messages that arrived while the client was offline. No duplicates: a receipt is always archived after its message, so if ours exists it is inside the same MAM window we just loaded. - Receiving: live receipts and archived ones (they come out of MAM as
entries of their own) land in the message's
receivedBylist. A receipt can reference a message from an older, not-yet-loaded page — those are parked inpendingReceiptsand applied when paging reaches the message.
“Read” is signalled with a displayed marker. Watermark semantics: one
marker on the newest seen message means everything up to and including it
has been read, so opening a chat with any backlog costs a single stanza:
<message type="groupchat" to="room@conference…">
<displayed xmlns="urn:xmpp:chat-markers:0" id="1783689048683297"/>
<store xmlns="urn:xmpp:hints"/>
</message>Client handling:
- Sending (
markRoomDisplayed()): when a chat is opened, when a live message arrives in the currently open chat with the tab visible, and onvisibilitychangewhen the user returns to the tab. It targets the newest non-own message and no-ops if the own watermark already covers it, if the tab is hidden, or while offline. - Receiving: markers (live and from MAM) update a per-room map
displayedUpTo[nickname] = newest marked archive id. A message counts as read by everyone whose watermark is>=its archive id — archive ids are chronologically sortable, so this is a plain comparison, with no per-message bookkeeping. - Rendering (
deliveryStats()): readers = watermarks covering the message; delivered = readers ∪ explicit receipts.read > 0paints the ticks blue.
- Receipts and markers count as MAM entries, which dilutes paging — that is why the sidebar-preview loader fetches 10 entries per room instead of 1.
- Only this client emits receipts/markers. Official Ethora clients don't, so their users never bump the counters — which is also why the group-chat rule is “at least one” rather than “all members”.