Private
Public Access
Compare commits
3
Commits
v2026.10.13
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e07c00b99 | ||
|
|
319b7097c2 | ||
|
|
0065b4ced3 |
No files matched your search
+133
-3
@@ -17,6 +17,7 @@ attachments, notifications, and personalizing your account.
|
||||
- [Presence and notifications](#presence-and-notifications)
|
||||
- [Your profile](#your-profile)
|
||||
- [Room details](#room-details)
|
||||
- [Integrations](#integrations)
|
||||
- [Active sessions](#active-sessions)
|
||||
- [Staying up to date](#staying-up-to-date)
|
||||
|
||||
@@ -133,7 +134,9 @@ you'll see an error before it uploads.
|
||||
Hover over a message and click the 🙂 icon in its action row to react
|
||||
with an emoji. Reactions from everyone appear as small pills under the
|
||||
message with a count; click an existing pill to add or remove your own
|
||||
reaction to it. Hovering a pill shows who reacted.
|
||||
reaction to it. Hovering a pill shows who reacted. Once a message has
|
||||
reactions, a small **+🙂** button at the end of the row of pills lets you
|
||||
add a different emoji without going back to the action row.
|
||||
|
||||
Anyone can add a custom emoji: open the emoji picker (the 🙂 button, either
|
||||
in the composer or on a message) and click **+ Add** in the **Custom**
|
||||
@@ -207,8 +210,135 @@ you can:
|
||||
- Leave the room — unless you're the owner, in which case ownership has
|
||||
to be transferred to someone else first
|
||||
|
||||
Room admins and owners see additional management options here that
|
||||
aren't covered in this guide.
|
||||
Room admins and owners see additional management options here, including
|
||||
**Integrations** (see [Integrations](#integrations)).
|
||||
|
||||
## Integrations
|
||||
|
||||
DS Chat can exchange messages with other tools, three ways. The first two
|
||||
are set up by a room's admins or owners; the third by a site admin.
|
||||
|
||||
| You want to… | Use |
|
||||
| --- | --- |
|
||||
| Post into a room from a script, CI job, or another service | [Incoming webhook](#incoming-webhooks) |
|
||||
| Get notified elsewhere when a room's messages change | [Outgoing event subscription](#outgoing-event-subscriptions) |
|
||||
| Have a program read and write messages as its own account | [Bot](#bots) |
|
||||
|
||||
Links in messages already get a preview card on their own, and Web Push
|
||||
and email notifications work without any setup — see
|
||||
[Presence and notifications](#presence-and-notifications).
|
||||
|
||||
### Incoming webhooks
|
||||
|
||||
An incoming webhook is a private URL that posts into one room.
|
||||
|
||||
1. Open the room's details panel (the info icon in the header) and expand
|
||||
**Integrations**. You need to be an admin or the owner of the room.
|
||||
2. Under **Incoming webhooks**, add an optional description (such as
|
||||
"Deploy notices") and create it. The full URL is listed there whenever
|
||||
you need to copy it again.
|
||||
3. Send a `POST` with a JSON body to that URL:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://chat.example.com/api/webhooks/incoming/<token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content": "Deploy finished: **v2026.10.22** is live"}'
|
||||
```
|
||||
|
||||
The message appears in the room right away, formatted like any other
|
||||
(markdown works), attributed to whoever created the webhook, and it
|
||||
notifies members the same way a typed message would. A successful post
|
||||
returns `204`; an unknown or revoked URL returns `404`, and an archived
|
||||
room returns `403`.
|
||||
|
||||
**Treat the URL like a password.** There's no other authentication: anyone
|
||||
who has it can post into the room. If it leaks, click **Revoke** next to
|
||||
it in the same panel and create a new one.
|
||||
|
||||
### Outgoing event subscriptions
|
||||
|
||||
An outgoing subscription sends DS Chat's events to a URL you control.
|
||||
|
||||
1. In the same **Integrations** panel, under **Outgoing event
|
||||
subscriptions**, enter the URL and tick the events you want:
|
||||
`message.created` (a new message) and/or `message.updated` (an edit).
|
||||
2. Click **Add**. A **signing secret** appears once — copy it now, it
|
||||
won't be shown again.
|
||||
|
||||
Each event arrives as a `POST` with a JSON body:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "message.created",
|
||||
"data": { "id": "…", "room_id": "…", "username": "alice", "content": "hello", "…": "…" }
|
||||
}
|
||||
```
|
||||
|
||||
To let you confirm a request really came from DS Chat, each one carries an
|
||||
`X-DS-Chat-Signature: sha256=<hex>` header: the HMAC-SHA256 of the raw
|
||||
request body, keyed with your signing secret. For example, in Python:
|
||||
|
||||
```python
|
||||
import hashlib, hmac
|
||||
|
||||
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
valid = hmac.compare_digest(expected, request_signature_header)
|
||||
```
|
||||
|
||||
Things to know:
|
||||
|
||||
- **Delivery is best-effort.** Each event is tried once with a short
|
||||
timeout; if your endpoint is down or slow, that event is dropped and
|
||||
not retried. Don't rely on it as your only record of a conversation.
|
||||
- **Only message creation and edits** are sent. Deletions, reactions,
|
||||
and joins don't generate events.
|
||||
- **The URL must be publicly reachable.** Addresses that point to
|
||||
private or internal networks (localhost, `192.168.x.x`, and so on) are
|
||||
rejected when you add the subscription.
|
||||
- **Revoke** removes a subscription immediately.
|
||||
|
||||
### Bots
|
||||
|
||||
A bot is an account for a program rather than a person. It signs in with
|
||||
an API token instead of a password and uses the same interface as the
|
||||
app itself, so it can read and write messages in real time.
|
||||
|
||||
Only **site admins** create bots: open the admin page, choose the **Bots**
|
||||
tab, and click **Create bot**, then **Issue token** and pick what the
|
||||
token may do:
|
||||
|
||||
- `read:messages` — read a room's message history
|
||||
- `write:messages` — send, edit, and delete messages
|
||||
- `manage:rooms` — recognized, but doesn't grant anything beyond what the
|
||||
bot's room role already allows
|
||||
|
||||
The token is shown once — copy it then. You can revoke it from the same
|
||||
tab at any time.
|
||||
|
||||
A bot is held to the same rules as any member: it can only see and post
|
||||
in rooms it belongs to, and its room role limits what it can do there.
|
||||
The token can narrow that further, never widen it.
|
||||
|
||||
The bot connects to the WebSocket at `/ws/chat`, sending
|
||||
`Authorization: Bearer <token>` with the connection, and then speaks
|
||||
simple JSON messages:
|
||||
|
||||
```json
|
||||
{"type": "join", "room_id": "<room id>"}
|
||||
{"type": "message", "room_id": "<room id>", "content": "Hello from a bot"}
|
||||
{"type": "edit", "room_id": "<room id>", "message_id": "<id>", "content": "Fixed"}
|
||||
```
|
||||
|
||||
Messages from the room arrive on the same connection as they happen. The
|
||||
same bearer token also works for the regular `/api/` endpoints, such as
|
||||
fetching a room's history.
|
||||
|
||||
The server sends a `{"type": "ping"}` every 30 seconds to check a
|
||||
connection is alive; a bot may ignore it, since bot connections are never
|
||||
dropped for staying silent.
|
||||
|
||||
There is currently **no rate limiting** on bots (or anyone else), so keep
|
||||
automation polite.
|
||||
|
||||
## Staying up to date
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ds-chat"
|
||||
version = "2026.10.13"
|
||||
version = "2026.10.22"
|
||||
description = "DS Chat backend service"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "2026.10.13",
|
||||
"version": "2026.10.22",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -391,6 +391,7 @@
|
||||
}
|
||||
|
||||
.message-reaction-pills {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
@@ -430,3 +431,24 @@
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* The picker anchors to the row of pills, not to this button: the button's
|
||||
position moves right with every added reaction, and a picker hanging
|
||||
off it ran past the edge of a phone screen. (No `position` here on
|
||||
purpose -- .message-reaction-wrap's relative would re-anchor it.) */
|
||||
.message-reaction-add-wrap {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* #78: always visible once a message has reactions, so adding to the pile
|
||||
doesn't need the hover-row button. Same shape as the pills it sits
|
||||
beside, kept quieter so it doesn't read as another reaction. */
|
||||
.message-reaction-add {
|
||||
gap: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.message-reaction-add:hover,
|
||||
.message-reaction-add:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { Message } from '../types'
|
||||
import { MessageList } from './MessageList'
|
||||
|
||||
vi.mock('../context/AuthContext', () => ({
|
||||
useAuth: () => ({ user: { id: 'u1', username: 'alice', emoji_scale: 'normal' } }),
|
||||
}))
|
||||
vi.mock('../context/CustomEmojiContext', () => ({
|
||||
useCustomEmoji: () => ({ byShortcode: new Map(), list: [], refresh: () => {} }),
|
||||
}))
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom doesn't implement it; MessageList scrolls to the bottom on mount.
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function message(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: 'm1',
|
||||
room_id: 'r1',
|
||||
user_id: 'u2',
|
||||
username: 'bob',
|
||||
content: 'hello',
|
||||
image_id: null,
|
||||
file: null,
|
||||
link_preview: null,
|
||||
reactions: [],
|
||||
created_at: '2026-10-20T16:46:00Z',
|
||||
edited_at: null,
|
||||
deleted_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderList(messages: Message[], onReact = vi.fn()) {
|
||||
render(
|
||||
<MessageList
|
||||
roomId="r1"
|
||||
messages={messages}
|
||||
members={[]}
|
||||
myRooms={new Map()}
|
||||
onEdit={() => {}}
|
||||
onReact={onReact}
|
||||
onDelete={() => {}}
|
||||
/>,
|
||||
)
|
||||
return onReact
|
||||
}
|
||||
|
||||
const reacted = [{ emoji: '🎉', count: 1, user_ids: ['u2'] }]
|
||||
|
||||
describe('MessageList add-reaction button (#78)', () => {
|
||||
it('shows one in the reaction row only when the message already has reactions', () => {
|
||||
renderList([
|
||||
message({ id: 'm1', content: 'with', reactions: reacted }),
|
||||
message({ id: 'm2', content: 'without', reactions: [] }),
|
||||
])
|
||||
const pills = document.querySelectorAll('.message-reaction-pills')
|
||||
expect(pills).toHaveLength(1)
|
||||
expect(within(pills[0] as HTMLElement).getByRole('button', { name: 'Add reaction' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the hover-row button on every message', () => {
|
||||
renderList([
|
||||
message({ id: 'm1', reactions: reacted }),
|
||||
message({ id: 'm2', content: 'plain', reactions: [] }),
|
||||
])
|
||||
// Two on the reacted message (row + pills), one on the plain one.
|
||||
expect(screen.getAllByRole('button', { name: 'Add reaction' })).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('opens the picker next to the pills and reacts with the pick', () => {
|
||||
const onReact = renderList([message({ reactions: reacted })])
|
||||
const pillsAdd = within(document.querySelector('.message-reaction-pills') as HTMLElement).getByRole(
|
||||
'button',
|
||||
{ name: 'Add reaction' },
|
||||
)
|
||||
fireEvent.click(pillsAdd)
|
||||
|
||||
const pickers = document.querySelectorAll('.emoji-picker')
|
||||
expect(pickers).toHaveLength(1)
|
||||
expect(document.querySelector('.message-reaction-pills .emoji-picker')).not.toBeNull()
|
||||
|
||||
fireEvent.click(within(pickers[0] as HTMLElement).getAllByRole('menuitem')[0])
|
||||
expect(onReact).toHaveBeenCalledTimes(1)
|
||||
expect(onReact.mock.calls[0][0]).toBe('m1')
|
||||
expect(document.querySelector('.emoji-picker')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens only one picker at a time, in whichever place was used last', () => {
|
||||
renderList([message({ reactions: reacted })])
|
||||
const [rowButton, pillsButton] = [
|
||||
document.querySelector('.message-row-actions .message-reaction-trigger') as HTMLElement,
|
||||
document.querySelector('.message-reaction-pills .message-reaction-add') as HTMLElement,
|
||||
]
|
||||
|
||||
fireEvent.click(pillsButton)
|
||||
expect(document.querySelectorAll('.emoji-picker')).toHaveLength(1)
|
||||
|
||||
fireEvent.click(rowButton)
|
||||
expect(document.querySelectorAll('.emoji-picker')).toHaveLength(1)
|
||||
expect(document.querySelector('.message-row-actions .emoji-picker')).not.toBeNull()
|
||||
expect(document.querySelector('.message-reaction-pills .emoji-picker')).toBeNull()
|
||||
})
|
||||
|
||||
it('clicking the same button again closes its picker', () => {
|
||||
renderList([message({ reactions: reacted })])
|
||||
const pillsButton = document.querySelector('.message-reaction-pills .message-reaction-add') as HTMLElement
|
||||
fireEvent.click(pillsButton)
|
||||
expect(document.querySelector('.emoji-picker')).not.toBeNull()
|
||||
fireEvent.click(pillsButton)
|
||||
expect(document.querySelector('.emoji-picker')).toBeNull()
|
||||
})
|
||||
|
||||
it('is not offered on a deleted message', () => {
|
||||
renderList([message({ content: null, deleted_at: '2026-10-21T10:00:00Z', reactions: [] })])
|
||||
expect(screen.queryByRole('button', { name: 'Add reaction' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
@@ -146,6 +146,10 @@ export function MessageList({
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
|
||||
const [videoLightbox, setVideoLightbox] = useState<{ src: string; filename: string } | null>(null)
|
||||
const [reactingId, setReactingId] = useState<string | null>(null)
|
||||
// #78: a message has two buttons that open the same picker (the hover-row
|
||||
// one and the one at the end of its reaction pills); this says which one
|
||||
// the picker belongs to, so it renders next to the button that was used.
|
||||
const [reactingFrom, setReactingFrom] = useState<'row' | 'pills'>('row')
|
||||
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
|
||||
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
|
||||
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members])
|
||||
@@ -155,6 +159,25 @@ export function MessageList({
|
||||
return member?.display_name || member?.username || 'someone'
|
||||
}
|
||||
|
||||
function toggleReactionPicker(
|
||||
e: ReactMouseEvent<HTMLButtonElement>,
|
||||
messageId: string,
|
||||
from: 'row' | 'pills',
|
||||
) {
|
||||
if (reactingId === messageId && reactingFrom === from) {
|
||||
setReactingId(null)
|
||||
return
|
||||
}
|
||||
// Flip upward when the picker wouldn't fit below the trigger -- a
|
||||
// message near the bottom of the scrolled list otherwise opens a
|
||||
// picker that runs off-screen and can't be used.
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
setReactionPlacement(spaceBelow < EMOJI_PICKER_MAX_HEIGHT ? 'above' : 'below')
|
||||
setReactingFrom(from)
|
||||
setReactingId(messageId)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
pinnedToBottomRef.current = true
|
||||
bottomRef.current?.scrollIntoView({ block: 'end' })
|
||||
@@ -321,6 +344,28 @@ export function MessageList({
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="message-reaction-add-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="message-reaction-pill message-reaction-add"
|
||||
onClick={(e) => toggleReactionPicker(e, msg.id, 'pills')}
|
||||
aria-label="Add reaction"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
<span aria-hidden="true">🙂</span>
|
||||
</button>
|
||||
{reactingId === msg.id && reactingFrom === 'pills' && (
|
||||
<EmojiPicker
|
||||
onPick={(emoji) => {
|
||||
onReact(msg.id, emoji)
|
||||
setReactingId(null)
|
||||
}}
|
||||
onClose={() => setReactingId(null)}
|
||||
placement={reactionPlacement}
|
||||
align="left"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -332,25 +377,12 @@ export function MessageList({
|
||||
<button
|
||||
type="button"
|
||||
className="message-reaction-trigger"
|
||||
onClick={(e) => {
|
||||
if (reactingId === msg.id) {
|
||||
setReactingId(null)
|
||||
return
|
||||
}
|
||||
// Flip upward when the picker wouldn't fit below the
|
||||
// trigger -- a message near the bottom of the
|
||||
// scrolled list otherwise opens a picker that runs
|
||||
// off-screen and can't be used.
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
setReactionPlacement(spaceBelow < EMOJI_PICKER_MAX_HEIGHT ? 'above' : 'below')
|
||||
setReactingId(msg.id)
|
||||
}}
|
||||
onClick={(e) => toggleReactionPicker(e, msg.id, 'row')}
|
||||
aria-label="Add reaction"
|
||||
>
|
||||
🙂
|
||||
</button>
|
||||
{reactingId === msg.id && (
|
||||
{reactingId === msg.id && reactingFrom === 'row' && (
|
||||
<EmojiPicker
|
||||
onPick={(emoji) => {
|
||||
onReact(msg.id, emoji)
|
||||
|
||||
Reference in new issue
Block a user