3 Commits
Author SHA1 Message Date
ksmithandClaude Sonnet 5.5 5e07c00b99 Document integrations in the user guide
Adds an Integrations section covering incoming webhooks, outgoing event
subscriptions (including verifying the signature and the best-effort
delivery limits), and bots with API tokens. Nothing in the guide
mentioned any of these; the only write-up was the developer README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 17:28:50 -06:00
ksmithandClaude Sonnet 5.5 319b7097c2 Bump to v2026.10.22
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:49:44 -06:00
ksmithandClaude Sonnet 5.5 0065b4ced3 Add an add-reaction button to the reaction row (#78)
Adding to a message that already had reactions meant going back up to
the hover row's 🙂. A "+🙂" button now sits at the end of the row of
reaction pills, always visible once the message has at least one
reaction, and opens the same emoji picker.

The picker anchors to the left edge of the pill row rather than the
button: the button moves right with every added reaction, and a picker
hanging off it ran past the edge of a phone screen. Both buttons share
the picker's open state, so only one picker is ever open at a time.

MessageList had no tests; this adds a few covering the new button.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:49:44 -06:00
6 changed files with 327 additions and 21 deletions

No files matched your search

+133 -3
View File
@@ -17,6 +17,7 @@ attachments, notifications, and personalizing your account.
- [Presence and notifications](#presence-and-notifications) - [Presence and notifications](#presence-and-notifications)
- [Your profile](#your-profile) - [Your profile](#your-profile)
- [Room details](#room-details) - [Room details](#room-details)
- [Integrations](#integrations)
- [Active sessions](#active-sessions) - [Active sessions](#active-sessions)
- [Staying up to date](#staying-up-to-date) - [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 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 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 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 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** 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 - Leave the room — unless you're the owner, in which case ownership has
to be transferred to someone else first to be transferred to someone else first
Room admins and owners see additional management options here that Room admins and owners see additional management options here, including
aren't covered in this guide. **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 ## Staying up to date
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ds-chat" name = "ds-chat"
version = "2026.10.13" version = "2026.10.22"
description = "DS Chat backend service" description = "DS Chat backend service"
license = { text = "AGPL-3.0-or-later" } license = { text = "AGPL-3.0-or-later" }
requires-python = ">=3.11" requires-python = ">=3.11"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "2026.10.13", "version": "2026.10.22",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"scripts": { "scripts": {
+22
View File
@@ -391,6 +391,7 @@
} }
.message-reaction-pills { .message-reaction-pills {
position: relative;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
@@ -430,3 +431,24 @@
width: 100%; width: 100%;
resize: vertical; 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()
})
})
+48 -16
View File
@@ -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 { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms' import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
@@ -146,6 +146,10 @@ export function MessageList({
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null) const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [videoLightbox, setVideoLightbox] = useState<{ src: string; filename: string } | null>(null) const [videoLightbox, setVideoLightbox] = useState<{ src: string; filename: string } | null>(null)
const [reactingId, setReactingId] = useState<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 [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null) const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]) 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' 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(() => { useEffect(() => {
pinnedToBottomRef.current = true pinnedToBottomRef.current = true
bottomRef.current?.scrollIntoView({ block: 'end' }) bottomRef.current?.scrollIntoView({ block: 'end' })
@@ -321,6 +344,28 @@ export function MessageList({
</button> </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> </div>
)} )}
</> </>
@@ -332,25 +377,12 @@ export function MessageList({
<button <button
type="button" type="button"
className="message-reaction-trigger" className="message-reaction-trigger"
onClick={(e) => { onClick={(e) => toggleReactionPicker(e, msg.id, 'row')}
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)
}}
aria-label="Add reaction" aria-label="Add reaction"
> >
🙂 🙂
</button> </button>
{reactingId === msg.id && ( {reactingId === msg.id && reactingFrom === 'row' && (
<EmojiPicker <EmojiPicker
onPick={(emoji) => { onPick={(emoji) => {
onReact(msg.id, emoji) onReact(msg.id, emoji)