---
title: "Dms"
canonical_url: "https://scholarxiv.com/developers/docs/dms"
markdown_url: "https://scholarxiv.com/developers/docs/dms.md"
---

> For the complete documentation index, see [llms.txt](/llms.txt).

# Dms
URL: /developers/docs/dms
LLM index: /llms.txt

# Direct messages

DMs are ScholarXIV's first collaborative surface. The bar is Telegram: fast, live, every
everyday chat action (reply, forward, edit, delete, react, pin, search), plus the thing
only a research app can do — papers, collections, datasets and profiles travel as live
cards, not pasted links.

## What's in it

- **Chats:** one-to-one DMs, groups (title, admins, add/remove, leave) and **Saved
  Messages** — a chat with yourself, the quickest place to park a paper.
- **Messages:** text with light formatting (`**bold**`, `_italic_`, `` `code` ``, links),
  reply with a quote, forward (keeps "Forwarded from"), edit (marked "edited"), delete
  for me or for everyone, emoji reactions, pin (pinned bar at the top), copy.
- **Live:** new messages, edits, reactions, read receipts (✓ sent, ✓✓ read), typing
  indicator and online / last seen, pushed over one SSE stream per tab.
- **Research cards:**
  - **Paper** — title, authors, year and an abstract snippet; open it, save it to your
    library or add it to a collection without leaving the chat.
  - **Collection** — title, paper count and visibility. Sharing a private collection you
    own or edit can grant everyone in the chat view access in the same step.
  - **Dataset** and **profile** cards.
  - **Files and images** through the existing upload pipeline (`dm` scope); only people
    in the chat can open them.
  - **Smart links:** an arXiv link or id, a `/collections/<id>` link or a dataset link in a
    message becomes the matching card automatically.
- **Chat info:** members, and a **shared library** of everything posted in the chat —
  Papers, Collections, Files, Links — so a chat doubles as a reading list.
- **List:** search, unread counts, pinned chats, mute, archive; drafts are kept per chat.
- **Safety:** block a person; choose who can message you (everyone, or nobody new);
  sending is rate-limited.
- **Entry points:** Messages in the sidebar (with an unread badge), a Message button on
  `@username` profiles, and "Send in a DM" on papers and collections.

## Data model

| Collection | One document per | Notes |
| --- | --- | --- |
| `dm_conversations` | chat | `kind` direct, group or self; `memberIds`; `directKey` (sorted pair) is unique so a pair has one chat; `lastMessage` for the list. |
| `dm_members` | person in a chat | `lastReadAt`, `pinned`, `muted`, `archived`, `clearedAt` (delete history for me), `leftAt`. |
| `dm_messages` | message | text, `shares` (card snapshots), `replyTo`, `forwardedFrom`, `reactions`, `editedAt`, `deletedAt` (for everyone), `hiddenFor` (for me), `clientId` for idempotent retries. |
| `dm_signals` | typing ping | TTL of a few seconds; only exists to be streamed. |
| `dm_presence` | person | `lastSeenAt`, refreshed while a stream is open. |
| `dm_blocks`, `dm_settings` | block / person | who can message whom. |

Cards store a **snapshot** (title, authors, count…) taken when sent, so a chat renders
without a lookup per card, and links open the live page.

## Live updates

`GET /api/dms/stream` opens a MongoDB change stream (Atlas is a replica set) on the DM
collections and forwards events for the viewer's chats. If change streams are unavailable
it falls back to polling. The stream closes itself after a few minutes and the browser
reconnects with the last event time, so it fits serverless limits.

## Stages

1. Data model, rules (pure, tested), server store and REST API.
2. Live stream: messages, typing, presence, read receipts.
3. Messages page: chat list, thread, composer, reply / edit / forward / react / pin / delete.
4. Research cards, smart links, uploads, and "Send in a DM" from papers, collections and profiles.
5. Groups, Saved Messages, chat info with the shared library, search, block and privacy, sidebar badge.

## Progress

(One entry per stage below.)

### Stage 1: data model, rules and API

- **Added:** `src/lib/dms/` (types, text helpers, rules), `src/lib/server/dms/` (store, card snapshots, route wrapper) and the REST routes under `src/routes/api/dms/`. Indexes for every DM collection, including a unique key per direct pair and a 30-second TTL on typing pings.
- **Uploads:** new `dm` scope; `/api/uploads/<id>` now also lets someone open a file that was sent in a chat they're in.
- **Tests:** 16 unit tests (smart links, formatting, previews, permissions, reactions, flood guard) and 8 integration flows run against a throwaway database on the real cluster (`DM_INTEGRATION=1 npx vitest run src/lib/server/dms`); the database is dropped afterwards. All pass.
- **Bug caught by the integration run:** an empty date filter matched no messages; fixed before commit.

### Stage 2: live stream

- **Added:** `src/lib/server/dms/stream.ts` and `GET /api/dms/stream`. One server-sent-events connection per tab carries new and changed messages, removals ("delete for me" in another tab), chat changes, read receipts, typing and presence.
- **How it stays complete:** the change stream is pinned to the cluster time at connect, so nothing written after `ready` slips past while it spins up. Streams end themselves after 9 minutes (well inside the function limit); the browser reconnects with `?since=` and a catch-up query replays the gap. Without change streams it polls the same catch-up query every 2 seconds.
- **Presence:** an open stream refreshes "last seen" every 25 seconds.
- **Member rows** now carry `updatedAt`, which the catch-up query uses for read receipts and chat changes.
- **Tests:** an integration test opens a stream for one person, has the other send, type and read, and checks each event arrives; then checks a reconnect replays a message sent while disconnected. Passes (9 integration flows in total).
- **Bug caught:** the first version opened the change stream after announcing `ready`, so a message sent immediately could be missed. Fixed by pinning the start time.

### Stages 3–5: Messages page, research cards, groups and entry points

These three landed as one commit: the composer, the cards and the info panel depend on each other, so splitting them would have left commits that don't build.

- **Messages page** (`/messages`, `/messages/<id>`): chat list beside the thread on wide screens, one at a time on phones. Full-viewport like Write and Canvas, so there's no global input box under it.
  - **List:** search, All / Unread / Archived, unread counts (grey when muted), pinned chats on top, "typing…" in the preview, per-chat menu (pin, mute, archive, mark read).
  - **Thread:** day separators, a "New messages" divider where unread starts, runs from one sender grouped, older history loads as you scroll up without losing your place, a jump-to-latest button with a count, typing indicator, online / last seen, pinned bar (click cycles through pins), in-chat search that jumps to the hit.
  - **Messages:** reply (quote is clickable), forward to several chats, edit (↑ on an empty box edits your last one), delete for me / for everyone, eight reactions, pin, copy, double-click to reply, right-click for the menu, ✓ sent / ✓✓ read, big emoji-only messages, retry or discard a failed send.
  - **Composer:** a draft per chat, Enter to send and Shift+Enter for a new line, Esc cancels a reply or edit, paste or drop files, + menu for a file, a paper (title search) or one of your collections.
- **Cards:** paper (open, save to library, add to collection), collection (paper count, visibility, "everyone in this chat can view it" when access was granted), dataset, profile (view, message), file (images inline, other files download), link.
- **Chat info:** members with admin controls (add, remove, make admin), group name and description, mute, and a **Shared in this chat** library with Papers, Collections, Files and Links tabs, each item linking back to its message. Clear history for me, leave group, block.
- **Privacy:** a settings dialog for "who can message me" (Everyone / Nobody new) and the blocked list.
- **Entry points:**
  - **Messages** in the app sidebar, with an unread badge that updates live on every page.
  - **Send** on every paper card (feeds, search, the paper page), which opens "Send in a DM": pick chats or find people, add a note.
  - **Send** on the collection page; for a private collection you own or edit, it can give the recipients view access.
  - **Message** on `@username` profiles (signed-out visitors are sent to sign in first).
- **Checks:** svelte-check shows no errors in any DM file or in the files I touched; the repo total is unchanged apart from SvelteKit's generated types for the new stream route, which `svelte-kit sync` produces. Unit tests (16) and integration flows (9) pass.
- **Not done here:** I couldn't click through the page — that needs a signed-in browser session — so the UI hasn't been seen running. Notifications for messages (email or push) aren't part of this; the live badge and the tab title count cover the in-app case.

### Usernames, @mentions and link previews

- **Username required:** you need a username to message other people (start a chat, create or add to a group, send outside Saved Messages). People without one don't appear in search and can't be added. Messages shows an inline "choose a username" form in the chat list, the New message dialog, the composer and "Send in a DM"; it saves through the existing `/api/user`.
- **@mentions:** typing `@` in the composer lists the chat's members; it filters by username or first letters of a name as you type, and ↑/↓ plus Enter or Tab inserts `@username`. The server records which members each message mentions (recomputed on edit). Mentions of you are highlighted in the thread.
- **"@" badge:** the chat list shows an @ badge beside the unread count when unread messages mention you, also in muted chats. It clears when you read the chat. System notices ("Ada added Bob") no longer count as unread on the server, matching the page.
- **Link previews:** the first ordinary link in a message (not a paper, collection or dataset, which already become cards) gets a preview while you type: site, title, description and image. × sends the link without one. Pages are fetched by the server through the PDF reader's guard (public addresses only, pinned connection, up to 4 redirects, first 256 KB, 4 s timeout) and cached for a week; failures are retried after an hour. The URL check is now shared as `parsePublicUrl` in `public_pdf.ts`.
- **Tests:** 34 DM tests pass, including new ones for the username rule, mention counts, the preview parser and the private-address refusal.
- **Known gap:** preview images load straight from the linked site, so that site sees the viewer's IP address, as with any embedded image. Proxying them would hide it.
- **Mentions don't make cards:** an `@username` in a message stays a clickable name and no longer adds a profile card. Profile cards still come from sharing a profile directly.

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
Docs-scoped sitemap: [/docs/sitemap.md](/docs/sitemap.md).
Well-known sitemap: [/.well-known/sitemap.md](/.well-known/sitemap.md).
