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)
|
- [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,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,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": {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user