Private
Public Access
Add @-mention highlighting, sidebar badge, and push customization (#39)
@username tokens in a sent message are parsed against the room's actual members (skipping fenced/inline code, so pasted code isn't misread) and recorded as MessageMention rows, reusing #38's read-tracking and offline-member broadcast infrastructure rather than building a parallel notification path: - Sidebar: a mentioned-and-unread room shows a distinct highlight- colored badge instead of (not alongside) the plain unread dot -- computed the same way as has_unread, just scoped to messages that mention the caller, and cleared by the same last_read_at mark-read flow. - Push notifications: a mentioned offline recipient gets "X mentioned you: ..." instead of the generic "X: ...", still per-recipient since the same message can page some room members and not others. - Message rendering: a validated @username is highlighted inline, implemented by turning it into a `[@username](mention:username)` link before markdown parsing and overriding link rendering to style `mention:`-scheme links as a span instead of an anchor -- reuses markdown-to-jsx's existing parser rather than hand-rolling text-node splitting. - Composer: typing @ opens an autocomplete dropdown of matching room members (arrow keys to navigate, Enter/Tab/click to insert, Escape or moving the cursor away to dismiss). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -184,7 +184,13 @@ export function ChatPane({
|
||||
onEdit={sendEdit}
|
||||
onReact={sendReaction}
|
||||
/>
|
||||
<Composer roomId={room.id} roomName={room.name} disabled={!connected} onSend={send} />
|
||||
<Composer
|
||||
roomId={room.id}
|
||||
roomName={room.name}
|
||||
members={members}
|
||||
disabled={!connected}
|
||||
onSend={send}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.composer-box textarea {
|
||||
.composer-textarea-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer-box textarea {
|
||||
width: 100%;
|
||||
resize: none;
|
||||
background: var(--ds-surface-2);
|
||||
border: 1px solid var(--ds-border);
|
||||
|
||||
@@ -1,20 +1,49 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
} from 'react'
|
||||
import { useOnlineStatus } from '../hooks/useOnlineStatus'
|
||||
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
|
||||
import { getUploadLimit } from '../api/uploads'
|
||||
import { formatFileSize } from '../lib/fileSize'
|
||||
import type { RoomMember } from '../types'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import { MentionAutocomplete } from './MentionAutocomplete'
|
||||
import './Composer.css'
|
||||
|
||||
interface ComposerProps {
|
||||
roomId: string
|
||||
roomName: string
|
||||
members: RoomMember[]
|
||||
disabled?: boolean
|
||||
onSend: (content: string, imageId?: string, fileId?: string) => void
|
||||
}
|
||||
|
||||
interface MentionQuery {
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps) {
|
||||
// Scans left from the cursor for an active "@partial" token -- an '@' not
|
||||
// preceded by a word character (so "foo@bar" mid-email doesn't trigger)
|
||||
// with only mention-safe characters between it and the cursor (a space
|
||||
// breaks out of the query entirely, closing the dropdown).
|
||||
function detectMentionQuery(text: string, cursor: number): MentionQuery | null {
|
||||
let i = cursor - 1
|
||||
while (i >= 0 && /[a-zA-Z0-9_.-]/.test(text[i])) i--
|
||||
if (i < 0 || text[i] !== '@') return null
|
||||
const prevChar = text[i - 1]
|
||||
if (prevChar && /\w/.test(prevChar)) return null
|
||||
return { start: i, end: cursor, text: text.slice(i + 1, cursor) }
|
||||
}
|
||||
|
||||
export function Composer({ roomId, roomName, members, disabled, onSend }: ComposerProps) {
|
||||
const [value, setValue] = useState('')
|
||||
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
|
||||
const [pendingFile, setPendingFile] = useState<{ id: string; filename: string; size: number } | null>(
|
||||
@@ -24,10 +53,18 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
|
||||
const [maxUploadBytes, setMaxUploadBytes] = useState<number | null>(null)
|
||||
const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null)
|
||||
const [mentionActiveIndex, setMentionActiveIndex] = useState(0)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const online = useOnlineStatus()
|
||||
|
||||
const mentionMatches = useMemo(() => {
|
||||
if (!mentionQuery) return []
|
||||
const q = mentionQuery.text.toLowerCase()
|
||||
return members.filter((m) => m.username.toLowerCase().startsWith(q)).slice(0, 8)
|
||||
}, [mentionQuery, members])
|
||||
|
||||
useEffect(() => {
|
||||
getUploadLimit()
|
||||
.then((limit) => setMaxUploadBytes(limit.max_upload_bytes))
|
||||
@@ -49,18 +86,68 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
||||
if (!trimmed && !pendingImage && !pendingFile) return
|
||||
onSend(trimmed, pendingImage?.id, pendingFile?.id)
|
||||
setValue('')
|
||||
setMentionQuery(null)
|
||||
removePendingImage()
|
||||
setPendingFile(null)
|
||||
requestAnimationFrame(autoGrow)
|
||||
}
|
||||
|
||||
function selectMention(username: string) {
|
||||
const query = mentionQuery
|
||||
if (!query) return
|
||||
const el = textareaRef.current
|
||||
const next = value.slice(0, query.start) + '@' + username + ' ' + value.slice(query.end)
|
||||
setValue(next)
|
||||
setMentionQuery(null)
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return
|
||||
el.focus()
|
||||
const cursor = query.start + username.length + 2 // '@' + username + trailing space
|
||||
el.setSelectionRange(cursor, cursor)
|
||||
autoGrow()
|
||||
})
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (mentionQuery && mentionMatches.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setMentionActiveIndex((i) => (i + 1) % mentionMatches.length)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setMentionActiveIndex((i) => (i - 1 + mentionMatches.length) % mentionMatches.length)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
selectMention(mentionMatches[mentionActiveIndex].username)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setMentionQuery(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// Re-detects the active @query on every cursor move, not just typing --
|
||||
// React's onSelect fires for clicks and arrow-key navigation too, so
|
||||
// moving the cursor out of a partial mention (without deleting it) still
|
||||
// correctly closes the dropdown.
|
||||
function handleSelectionChange(e: FormEvent<HTMLTextAreaElement>) {
|
||||
const el = e.currentTarget
|
||||
const query = detectMentionQuery(el.value, el.selectionStart ?? 0)
|
||||
setMentionQuery(query)
|
||||
setMentionActiveIndex(0)
|
||||
}
|
||||
|
||||
async function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
@@ -205,19 +292,32 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
autoGrow()
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`}
|
||||
spellCheck
|
||||
/>
|
||||
<div className="composer-textarea-wrap">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
autoGrow()
|
||||
setMentionQuery(detectMentionQuery(e.target.value, e.target.selectionStart ?? 0))
|
||||
setMentionActiveIndex(0)
|
||||
}}
|
||||
onSelect={handleSelectionChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`}
|
||||
spellCheck
|
||||
/>
|
||||
{mentionQuery && mentionMatches.length > 0 && (
|
||||
<MentionAutocomplete
|
||||
matches={mentionMatches}
|
||||
activeIndex={mentionActiveIndex}
|
||||
onPick={selectMention}
|
||||
onHover={setMentionActiveIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-send"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
.mention-autocomplete {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
z-index: 31;
|
||||
width: 240px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
background: var(--ds-surface);
|
||||
border: 1px solid var(--ds-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mention-autocomplete-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
.mention-autocomplete-item-active,
|
||||
.mention-autocomplete-item:hover {
|
||||
background: var(--ds-surface-2);
|
||||
}
|
||||
|
||||
.mention-autocomplete-username {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
color: var(--ds-accent);
|
||||
}
|
||||
|
||||
.mention-autocomplete-display-name {
|
||||
font-size: 0.76rem;
|
||||
color: var(--ds-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { RoomMember } from '../types'
|
||||
import './MentionAutocomplete.css'
|
||||
|
||||
interface MentionAutocompleteProps {
|
||||
matches: RoomMember[]
|
||||
activeIndex: number
|
||||
onPick: (username: string) => void
|
||||
onHover: (index: number) => void
|
||||
}
|
||||
|
||||
export function MentionAutocomplete({ matches, activeIndex, onPick, onHover }: MentionAutocompleteProps) {
|
||||
return (
|
||||
<div className="mention-autocomplete" role="listbox">
|
||||
{matches.map((member, i) => (
|
||||
<button
|
||||
key={member.user_id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
className={`mention-autocomplete-item${i === activeIndex ? ' mention-autocomplete-item-active' : ''}`}
|
||||
// Selecting must survive the textarea's blur (which would
|
||||
// otherwise fire first and could dismiss the dropdown) --
|
||||
// onMouseDown fires before blur, onClick fires after.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onPick(member.username)}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<span className="mention-autocomplete-username">@{member.username}</span>
|
||||
{member.display_name && (
|
||||
<span className="mention-autocomplete-display-name">{member.display_name}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import Markdown from 'markdown-to-jsx'
|
||||
import type { ReactNode } from 'react'
|
||||
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
|
||||
|
||||
interface MessageContentProps {
|
||||
content: string
|
||||
// Validated against actual room members so a bare '@' in prose can't
|
||||
// false-positive -- optional since FilePreviewModal reuses this same
|
||||
// component for markdown file previews, where "@mentioning a person"
|
||||
// doesn't apply.
|
||||
memberUsernames?: Set<string>
|
||||
}
|
||||
|
||||
interface MarkdownImageLinkProps {
|
||||
@@ -24,6 +30,27 @@ function MarkdownImageLink({ src, alt, title }: MarkdownImageLinkProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface MarkdownLinkProps {
|
||||
href?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
// highlightMentions (below) turns a validated @username into a
|
||||
// `[@username](mention:username)` link so markdown-to-jsx parses it as a
|
||||
// normal link node -- this override is what turns that back into a styled
|
||||
// span instead of an actual anchor. Everything else renders as a real link,
|
||||
// same as before mentions existed.
|
||||
function MarkdownLink({ href, children }: MarkdownLinkProps) {
|
||||
if (href?.startsWith('mention:')) {
|
||||
return <span className="message-mention">{children}</span>
|
||||
}
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const SHORTCODE_PATTERN = /:([a-z0-9_+-]+):/g
|
||||
|
||||
// Converts a complete `:name:` shortcode to its emoji, skipping fenced code
|
||||
@@ -56,6 +83,41 @@ function convertShortcodes(text: string): string {
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
|
||||
|
||||
// Turns a validated @username into `[@username](mention:username)` --
|
||||
// markdown-to-jsx parses that as an ordinary link node, which the `a`
|
||||
// override above then renders as a styled span instead of an anchor. Skips
|
||||
// fenced code blocks and inline code spans, same convention (and same
|
||||
// reasoning) as convertShortcodes above -- pasted code containing a bare
|
||||
// '@' shouldn't light up as if someone were paged. Kept in sync with
|
||||
// backend/app/services/mention_service.py's equivalent server-side skip
|
||||
// logic, which decides who actually gets notified.
|
||||
function highlightMentions(text: string, memberUsernames: Set<string>): string {
|
||||
if (memberUsernames.size === 0) return text
|
||||
const lines = text.split('\n')
|
||||
let inFence = false
|
||||
return lines
|
||||
.map((line) => {
|
||||
if (/^\s*```/.test(line)) {
|
||||
inFence = !inFence
|
||||
return line
|
||||
}
|
||||
if (inFence) return line
|
||||
return line
|
||||
.split(/(`+[^`]*`+)/g)
|
||||
.map((part, i) =>
|
||||
i % 2 === 0
|
||||
? part.replace(MENTION_PATTERN, (match, username) =>
|
||||
memberUsernames.has(username) ? `[${match}](mention:${username})` : match,
|
||||
)
|
||||
: part,
|
||||
)
|
||||
.join('')
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// CommonMark treats a single newline as a soft break (rendered as a space),
|
||||
// not a visible line break -- only a trailing double-space or blank line
|
||||
// produces one. The Composer's Shift+Enter has always inserted a plain
|
||||
@@ -87,11 +149,12 @@ export const MARKDOWN_OPTIONS = {
|
||||
// and printed literally instead of being parsed into elements.
|
||||
disableParsingRawHTML: true,
|
||||
overrides: {
|
||||
a: { props: { target: '_blank', rel: 'noopener noreferrer' } },
|
||||
a: { component: MarkdownLink },
|
||||
img: { component: MarkdownImageLink },
|
||||
},
|
||||
}
|
||||
|
||||
export function MessageContent({ content }: MessageContentProps) {
|
||||
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(content))}</Markdown>
|
||||
export function MessageContent({ content, memberUsernames }: MessageContentProps) {
|
||||
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
|
||||
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(withMentions))}</Markdown>
|
||||
}
|
||||
|
||||
@@ -120,6 +120,14 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message-mention {
|
||||
background: color-mix(in srgb, var(--ds-highlight) 18%, transparent);
|
||||
color: var(--ds-highlight);
|
||||
border-radius: 5px;
|
||||
padding: 0 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message-text code {
|
||||
background: var(--ds-surface-2);
|
||||
padding: 1px 5px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { formatFileSize } from '../lib/fileSize'
|
||||
@@ -76,6 +76,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
|
||||
const [reactingId, setReactingId] = useState<string | null>(null)
|
||||
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])
|
||||
|
||||
function displayNameForUserId(userId: string): string {
|
||||
const member = members.find((m) => m.user_id === userId)
|
||||
@@ -166,7 +167,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
|
||||
)}
|
||||
{msg.content && (
|
||||
<div className="message-text">
|
||||
<MessageContent content={msg.content} />
|
||||
<MessageContent content={msg.content} memberUsernames={memberUsernames} />
|
||||
{msg.edited_at && <span className="message-edited"> (edited)</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -58,3 +58,14 @@
|
||||
border-radius: 50%;
|
||||
background: var(--ds-accent);
|
||||
}
|
||||
|
||||
/* Distinct from the plain unread dot -- --ds-highlight is already this
|
||||
app's second brand color (see tokens.css), reused here rather than
|
||||
introducing a new semantic color just for mentions. */
|
||||
.room-row-mention-dot {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--ds-highlight);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,12 @@ export function RoomRow({ room, colorIndex, active }: RoomRowProps) {
|
||||
</div>
|
||||
{room.description && <div className="room-row-subtitle">{room.description}</div>}
|
||||
</div>
|
||||
{room.has_unread && !active && <span className="room-row-unread-dot" aria-label="Unread messages" />}
|
||||
{!active && room.has_mention && (
|
||||
<span className="room-row-mention-dot" aria-label="You were mentioned" />
|
||||
)}
|
||||
{!active && !room.has_mention && room.has_unread && (
|
||||
<span className="room-row-unread-dot" aria-label="Unread messages" />
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ export function ChatShellPage() {
|
||||
|
||||
const socket = useChatSocketContext()
|
||||
|
||||
const setRoomUnread = useCallback((id: string, hasUnread: boolean) => {
|
||||
setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: hasUnread } : r)))
|
||||
const clearRoomIndicators = useCallback((id: string) => {
|
||||
setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: false, has_mention: false } : r)))
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
@@ -68,9 +68,17 @@ export function ChatShellPage() {
|
||||
socket.subscribe((envelope) => {
|
||||
if (envelope.type === 'room_added') refreshRooms()
|
||||
else if (envelope.type === 'member_updated' && envelope.room_id === roomId) refreshMembers()
|
||||
else if (envelope.type === 'unread_update') setRoomUnread(envelope.room_id, true)
|
||||
else if (envelope.type === 'unread_update') {
|
||||
setRooms((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === envelope.room_id
|
||||
? { ...r, has_unread: true, has_mention: r.has_mention || envelope.mentioned }
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
[socket, refreshRooms, refreshMembers, roomId, setRoomUnread],
|
||||
[socket, refreshRooms, refreshMembers, roomId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -115,7 +123,7 @@ export function ChatShellPage() {
|
||||
onToggleInfo={() => setInfoOpen((v) => !v)}
|
||||
infoOpen={infoOpen}
|
||||
socket={socket}
|
||||
onRoomRead={(id) => setRoomUnread(id, false)}
|
||||
onRoomRead={clearRoomIndicators}
|
||||
/>
|
||||
) : (
|
||||
!isMobile && (
|
||||
|
||||
@@ -67,6 +67,9 @@ export interface RoomListItem extends Room {
|
||||
export interface MyRoomItem extends Room {
|
||||
role: RoomRole
|
||||
has_unread: boolean
|
||||
// Unread and mentions the current user -- takes visual priority over
|
||||
// has_unread in the sidebar (see RoomRow.tsx), not shown alongside it.
|
||||
has_mention: boolean
|
||||
}
|
||||
|
||||
export interface RoomMember {
|
||||
@@ -171,6 +174,7 @@ export interface ChatMemberUpdatedEnvelope {
|
||||
export interface ChatUnreadUpdateEnvelope {
|
||||
type: 'unread_update'
|
||||
room_id: string
|
||||
mentioned: boolean
|
||||
}
|
||||
|
||||
export type ServerEnvelope =
|
||||
|
||||
Reference in New Issue
Block a user