Private
Public Access
Add emoji picker and message reactions (Gitea issue #11)
Composer gains an emoji picker (static curated unicode list, insert at cursor position) and messages gain Slack/Mattermost-style reactions: react with any emoji, toggle off by reacting again, see who reacted via a tooltip on each pill. Backend: MessageReaction model (unique on message_id+user_id+emoji backs toggle semantics), WS "reaction" envelope broadcasts the full recomputed reaction list per message (same approach as message edits), REST message list embeds reactions so a reload doesn't lose state that only arrived over WS. Frontend: shared EmojiPicker component (anchored popover, Escape/outside- click dismiss via new useEscapeKey hook) used by both the composer and a new hover-revealed reaction trigger on each message row.
This commit is contained in:
+33
-6
@@ -1,4 +1,4 @@
|
|||||||
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads)
|
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions)
|
||||||
|
|
||||||
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
|
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
|
||||||
CRUD (open and private), room roles (owner/admin/member) and invites, a
|
CRUD (open and private), room roles (owner/admin/member) and invites, a
|
||||||
@@ -6,8 +6,9 @@ WebSocket chat endpoint that fans out across multiple app-server instances
|
|||||||
via Redis pub/sub, Web Push notifications for offline room members, a
|
via Redis pub/sub, Web Push notifications for offline room members, a
|
||||||
site-admin portal (user/room/bot management + an audit log), a bot/
|
site-admin portal (user/room/bot management + an audit log), a bot/
|
||||||
extension layer (scoped API tokens, live bot WebSocket access, incoming and
|
extension layer (scoped API tokens, live bot WebSocket access, incoming and
|
||||||
outgoing webhooks, message editing), and image uploads in chat messages. See
|
outgoing webhooks, message editing), image uploads in chat messages, and
|
||||||
`../ARCHITECTURE.md` for the full system design and the phased build plan.
|
emoji reactions on messages. See `../ARCHITECTURE.md` for the full system
|
||||||
|
design and the phased build plan.
|
||||||
|
|
||||||
This is an **invite-only site**: there is no public registration endpoint.
|
This is an **invite-only site**: there is no public registration endpoint.
|
||||||
Accounts are created by an operator on the app server — see step 4 below.
|
Accounts are created by an operator on the app server — see step 4 below.
|
||||||
@@ -119,9 +120,10 @@ app/
|
|||||||
and on-disk save/read -- see Image uploads below
|
and on-disk save/read -- see Image uploads below
|
||||||
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
|
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
|
||||||
models/ SQLAlchemy models (users, rooms, room_memberships,
|
models/ SQLAlchemy models (users, rooms, room_memberships,
|
||||||
messages, message_images, room_invites,
|
messages, message_images, message_reactions,
|
||||||
push_subscriptions, admin_audit_log, api_tokens,
|
room_invites, push_subscriptions,
|
||||||
webhooks_incoming, event_subscriptions)
|
admin_audit_log, api_tokens, webhooks_incoming,
|
||||||
|
event_subscriptions)
|
||||||
schemas/ Pydantic request/response models
|
schemas/ Pydantic request/response models
|
||||||
routers/ auth, rooms, invites, push, admin, bots, webhooks, health
|
routers/ auth, rooms, invites, push, admin, bots, webhooks, health
|
||||||
services/ business logic called by routers
|
services/ business logic called by routers
|
||||||
@@ -349,6 +351,31 @@ cleanup job for this yet. Not a security issue, since serving still goes
|
|||||||
through the same room-membership gate as everything else; just an eventual
|
through the same room-membership gate as everything else; just an eventual
|
||||||
disk-space housekeeping item.
|
disk-space housekeeping item.
|
||||||
|
|
||||||
|
## Emoji & reactions
|
||||||
|
|
||||||
|
An emoji picker in the frontend composer is purely client-side (a static
|
||||||
|
curated unicode list, no backend involvement). Message **reactions** are
|
||||||
|
full-stack: `message_reactions` (`app/models/message_reaction.py`) has
|
||||||
|
`message_id`, `user_id`, `emoji`, and a `UniqueConstraint` on all three
|
||||||
|
backing toggle semantics — the same user reacting with the same emoji on
|
||||||
|
the same message twice removes it (Slack/Mattermost convention).
|
||||||
|
`message_service.toggle_reaction` is a plain select-then-delete-or-insert,
|
||||||
|
no upsert needed.
|
||||||
|
|
||||||
|
WS `"reaction"` envelope (`room_id`, `message_id`, `emoji`) toggles a
|
||||||
|
reaction; the server broadcasts the message's **full recomputed** reaction
|
||||||
|
list (`{"type": "reaction_update", "id", "room_id", "reactions": [...]}`),
|
||||||
|
not an add/remove delta — same approach `message_update` already uses for
|
||||||
|
edits, keeping client-side state a simple replace rather than a merge.
|
||||||
|
`GET /{room_id}/messages` embeds each message's `reactions` too
|
||||||
|
(`message_service.get_reactions_for_messages`, batched, not N+1), so a page
|
||||||
|
reload doesn't lose reaction state that only ever arrived over WS.
|
||||||
|
|
||||||
|
Scope cuts: no outgoing-webhook event type for reactions (`VALID_EVENT_TYPES`
|
||||||
|
in `webhook_service.py` is unchanged — same restraint as image uploads), no
|
||||||
|
reaction-count limit or rate limiting, no custom/uploaded emoji (unicode
|
||||||
|
only, curated client-side list in `frontend/src/lib/emoji.ts`).
|
||||||
|
|
||||||
## Notes / scope decisions
|
## Notes / scope decisions
|
||||||
|
|
||||||
- Invite-only site registration: no `POST /api/auth/register`. Accounts are
|
- Invite-only site registration: no `POST /api/auth/register`. Accounts are
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""message reactions
|
||||||
|
|
||||||
|
Revision ID: 1d355add7299
|
||||||
|
Revises: c610bdb04567
|
||||||
|
Create Date: 2026-08-14 12:59:40.013407
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '1d355add7299'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'c610bdb04567'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('message_reactions',
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('message_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('emoji', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['message_id'], ['messages.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('message_id', 'user_id', 'emoji', name='uq_message_reactions_message_user_emoji')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_message_reactions_message_id'), 'message_reactions', ['message_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_message_reactions_message_id'), table_name='message_reactions')
|
||||||
|
op.drop_table('message_reactions')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -6,6 +6,7 @@ from app.models.invite import InviteStatus, RoomInvite
|
|||||||
from app.models.membership import RoomMembership, RoomRole
|
from app.models.membership import RoomMembership, RoomRole
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.message_image import MessageImage
|
from app.models.message_image import MessageImage
|
||||||
|
from app.models.message_reaction import MessageReaction
|
||||||
from app.models.push_subscription import PushSubscription
|
from app.models.push_subscription import PushSubscription
|
||||||
from app.models.room import Room
|
from app.models.room import Room
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -19,6 +20,7 @@ __all__ = [
|
|||||||
"RoomRole",
|
"RoomRole",
|
||||||
"Message",
|
"Message",
|
||||||
"MessageImage",
|
"MessageImage",
|
||||||
|
"MessageReaction",
|
||||||
"RoomInvite",
|
"RoomInvite",
|
||||||
"InviteStatus",
|
"InviteStatus",
|
||||||
"PushSubscription",
|
"PushSubscription",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MessageReaction(Base):
|
||||||
|
__tablename__ = "message_reactions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"message_id", "user_id", "emoji",
|
||||||
|
name="uq_message_reactions_message_user_emoji",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("messages.id"), index=True, nullable=False)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||||
|
emoji: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
message = relationship("Message")
|
||||||
|
user = relationship("User")
|
||||||
@@ -42,7 +42,7 @@ from app.schemas.webhook import (
|
|||||||
WebhookIncomingCreate,
|
WebhookIncomingCreate,
|
||||||
WebhookIncomingRead,
|
WebhookIncomingRead,
|
||||||
)
|
)
|
||||||
from app.services.message_service import list_recent_messages
|
from app.services.message_service import get_reactions_for_messages, list_recent_messages
|
||||||
from app.services.room_service import (
|
from app.services.room_service import (
|
||||||
CannotRemoveOwnerError,
|
CannotRemoveOwnerError,
|
||||||
DuplicateRoomError,
|
DuplicateRoomError,
|
||||||
@@ -295,6 +295,7 @@ async def get_room_messages_endpoint(
|
|||||||
require_scope(request, "read:messages")
|
require_scope(request, "read:messages")
|
||||||
await require_room_member(room_id, current_user, db)
|
await require_room_member(room_id, current_user, db)
|
||||||
messages = await list_recent_messages(db, room_id, limit)
|
messages = await list_recent_messages(db, room_id, limit)
|
||||||
|
reactions_by_message = await get_reactions_for_messages(db, [m.id for m in messages])
|
||||||
return [
|
return [
|
||||||
MessageRead(
|
MessageRead(
|
||||||
id=m.id,
|
id=m.id,
|
||||||
@@ -303,6 +304,7 @@ async def get_room_messages_endpoint(
|
|||||||
username=m.user.username,
|
username=m.user.username,
|
||||||
content=m.content,
|
content=m.content,
|
||||||
image_id=m.image_id,
|
image_id=m.image_id,
|
||||||
|
reactions=reactions_by_message.get(m.id, []),
|
||||||
created_at=m.created_at,
|
created_at=m.created_at,
|
||||||
edited_at=m.edited_at,
|
edited_at=m.edited_at,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ from datetime import datetime
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ReactionSummary(BaseModel):
|
||||||
|
emoji: str
|
||||||
|
count: int
|
||||||
|
user_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
class MessageRead(BaseModel):
|
class MessageRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -13,5 +19,6 @@ class MessageRead(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
content: str | None
|
content: str | None
|
||||||
image_id: uuid.UUID | None
|
image_id: uuid.UUID | None
|
||||||
|
reactions: list[ReactionSummary]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
edited_at: datetime | None
|
edited_at: datetime | None
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models import Message, Room, RoomMembership, User
|
from app.models import Message, Room, RoomMembership, User
|
||||||
|
from app.schemas.message import ReactionSummary
|
||||||
from app.services.push_service import send_push_to_user
|
from app.services.push_service import send_push_to_user
|
||||||
from app.services.webhook_service import dispatch_event
|
from app.services.webhook_service import dispatch_event
|
||||||
from app.ws.broadcaster import RoomBroadcaster
|
from app.ws.broadcaster import RoomBroadcaster
|
||||||
@@ -49,6 +50,7 @@ def _message_payload(message: Message, username: str) -> dict:
|
|||||||
"username": username,
|
"username": username,
|
||||||
"content": message.content,
|
"content": message.content,
|
||||||
"image_id": str(message.image_id) if message.image_id else None,
|
"image_id": str(message.image_id) if message.image_id else None,
|
||||||
|
"reactions": [],
|
||||||
"created_at": message.created_at.isoformat(),
|
"created_at": message.created_at.isoformat(),
|
||||||
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||||
}
|
}
|
||||||
@@ -83,3 +85,21 @@ async def broadcast_message_update(
|
|||||||
}
|
}
|
||||||
await broadcaster.publish(room_id, payload)
|
await broadcaster.publish(room_id, payload)
|
||||||
await dispatch_event(db, "message.updated", room_id, payload)
|
await dispatch_event(db, "message.updated", room_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_reaction_update(
|
||||||
|
broadcaster: RoomBroadcaster,
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
reactions: list[ReactionSummary],
|
||||||
|
) -> None:
|
||||||
|
payload = {
|
||||||
|
"type": "reaction_update",
|
||||||
|
"id": str(message_id),
|
||||||
|
"room_id": str(room_id),
|
||||||
|
"reactions": [r.model_dump() for r in reactions],
|
||||||
|
}
|
||||||
|
await broadcaster.publish(room_id, payload)
|
||||||
|
# Deliberately no dispatch_event() call -- reactions don't get an
|
||||||
|
# outgoing-webhook event type, matching the same scope cut made for
|
||||||
|
# image uploads (see backend/README.md).
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.models import Message
|
from app.models import Message, MessageReaction
|
||||||
|
from app.schemas.message import ReactionSummary
|
||||||
|
|
||||||
|
|
||||||
class MessageNotFoundError(Exception):
|
class MessageNotFoundError(Exception):
|
||||||
@@ -59,3 +61,53 @@ async def list_recent_messages(
|
|||||||
messages = list(result.scalars().all())
|
messages = list(result.scalars().all())
|
||||||
messages.reverse()
|
messages.reverse()
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
async def get_reactions_for_messages(
|
||||||
|
db: AsyncSession, message_ids: list[uuid.UUID]
|
||||||
|
) -> dict[uuid.UUID, list[ReactionSummary]]:
|
||||||
|
if not message_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(MessageReaction)
|
||||||
|
.where(MessageReaction.message_id.in_(message_ids))
|
||||||
|
.order_by(MessageReaction.created_at)
|
||||||
|
)
|
||||||
|
# Grouped in Python rather than a GROUP BY/array_agg query -- the row
|
||||||
|
# count per room-history page is small, and this keeps the ordering
|
||||||
|
# (first-reacted emoji first, first-reacted user first within it)
|
||||||
|
# trivial instead of relying on Postgres-specific aggregate ordering.
|
||||||
|
by_message: dict[uuid.UUID, dict[str, list[str]]] = defaultdict(dict)
|
||||||
|
for reaction in result.scalars().all():
|
||||||
|
emoji_map = by_message[reaction.message_id]
|
||||||
|
emoji_map.setdefault(reaction.emoji, []).append(str(reaction.user_id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
message_id: [
|
||||||
|
ReactionSummary(emoji=emoji, count=len(user_ids), user_ids=user_ids)
|
||||||
|
for emoji, user_ids in emoji_map.items()
|
||||||
|
]
|
||||||
|
for message_id, emoji_map in by_message.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def toggle_reaction(
|
||||||
|
db: AsyncSession, message_id: uuid.UUID, user_id: uuid.UUID, emoji: str
|
||||||
|
) -> list[ReactionSummary]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(MessageReaction).where(
|
||||||
|
MessageReaction.message_id == message_id,
|
||||||
|
MessageReaction.user_id == user_id,
|
||||||
|
MessageReaction.emoji == emoji,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
await db.delete(existing)
|
||||||
|
else:
|
||||||
|
db.add(MessageReaction(message_id=message_id, user_id=user_id, emoji=emoji))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
reactions = await get_reactions_for_messages(db, [message_id])
|
||||||
|
return reactions.get(message_id, [])
|
||||||
|
|||||||
+40
-2
@@ -6,14 +6,19 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import ApiToken, MessageImage, RoomMembership, User
|
from app.models import ApiToken, Message, MessageImage, RoomMembership, User
|
||||||
from app.services.bot_service import resolve_token
|
from app.services.bot_service import resolve_token
|
||||||
from app.services.message_events import broadcast_message_update, broadcast_new_message
|
from app.services.message_events import (
|
||||||
|
broadcast_message_update,
|
||||||
|
broadcast_new_message,
|
||||||
|
broadcast_reaction_update,
|
||||||
|
)
|
||||||
from app.services.message_service import (
|
from app.services.message_service import (
|
||||||
MessageNotFoundError,
|
MessageNotFoundError,
|
||||||
NotMessageAuthorError,
|
NotMessageAuthorError,
|
||||||
create_message,
|
create_message,
|
||||||
edit_message,
|
edit_message,
|
||||||
|
toggle_reaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["ws"])
|
router = APIRouter(tags=["ws"])
|
||||||
@@ -27,6 +32,7 @@ class ClientEnvelope(BaseModel):
|
|||||||
content: str | None = None
|
content: str | None = None
|
||||||
image_id: uuid.UUID | None = None
|
image_id: uuid.UUID | None = None
|
||||||
message_id: uuid.UUID | None = None
|
message_id: uuid.UUID | None = None
|
||||||
|
emoji: str | None = None
|
||||||
|
|
||||||
|
|
||||||
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
|
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
|
||||||
@@ -163,6 +169,38 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
|||||||
continue
|
continue
|
||||||
await broadcast_message_update(db, broadcaster, envelope.room_id, message)
|
await broadcast_message_update(db, broadcaster, envelope.room_id, message)
|
||||||
|
|
||||||
|
elif envelope.type == "reaction":
|
||||||
|
if (
|
||||||
|
envelope.room_id is None
|
||||||
|
or envelope.message_id is None
|
||||||
|
or not envelope.emoji
|
||||||
|
or len(envelope.emoji) > 8
|
||||||
|
):
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "error", "detail": "room_id, message_id, and emoji required"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if _missing_scope(api_token, "write:messages"):
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "error", "detail": "Token missing required scope: write:messages"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if envelope.room_id not in joined_rooms or not await _is_room_member(
|
||||||
|
db, envelope.room_id, user.id
|
||||||
|
):
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "error", "detail": "Not a member of this room"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
target_message = await db.get(Message, envelope.message_id)
|
||||||
|
if target_message is None or target_message.room_id != envelope.room_id:
|
||||||
|
await websocket.send_json({"type": "error", "detail": "Message not found"})
|
||||||
|
continue
|
||||||
|
reactions = await toggle_reaction(db, envelope.message_id, user.id, envelope.emoji)
|
||||||
|
await broadcast_reaction_update(
|
||||||
|
broadcaster, envelope.room_id, envelope.message_id, reactions
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "error", "detail": f"Unknown message type: {envelope.type}"}
|
{"type": "error", "detail": f"Unknown message type: {envelope.type}"}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.user import UserCreate
|
||||||
|
from app.services.auth_service import register_user
|
||||||
|
|
||||||
|
|
||||||
|
def _unique(prefix: str) -> str:
|
||||||
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _register_ws(ws_client, username: str) -> dict:
|
||||||
|
async def _seed():
|
||||||
|
async with ws_client.session_factory() as session:
|
||||||
|
await register_user(
|
||||||
|
session,
|
||||||
|
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||||
|
)
|
||||||
|
|
||||||
|
ws_client.portal.call(_seed)
|
||||||
|
resp = ws_client.post(
|
||||||
|
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_admin_ws(ws_client, user_id: str) -> None:
|
||||||
|
async def _promote():
|
||||||
|
async with ws_client.session_factory() as session:
|
||||||
|
user = await session.get(User, uuid.UUID(user_id))
|
||||||
|
user.is_site_admin = True
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
ws_client.portal.call(_promote)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_reaction_toggle_add(ws_client):
|
||||||
|
_register_ws(ws_client, _unique("alice"))
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||||
|
message = ws.receive_json()
|
||||||
|
assert message["reactions"] == []
|
||||||
|
|
||||||
|
ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "👍"})
|
||||||
|
update = ws.receive_json()
|
||||||
|
assert update["type"] == "reaction_update"
|
||||||
|
assert update["id"] == message["id"]
|
||||||
|
assert len(update["reactions"]) == 1
|
||||||
|
assert update["reactions"][0]["emoji"] == "👍"
|
||||||
|
assert update["reactions"][0]["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_reaction_toggle_remove(ws_client):
|
||||||
|
alice = _register_ws(ws_client, _unique("alice"))
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||||
|
message = ws.receive_json()
|
||||||
|
|
||||||
|
ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "👍"})
|
||||||
|
first = ws.receive_json()
|
||||||
|
assert len(first["reactions"]) == 1
|
||||||
|
|
||||||
|
ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "👍"})
|
||||||
|
second = ws.receive_json()
|
||||||
|
assert second["reactions"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaction_appears_in_rest_message_list(ws_client):
|
||||||
|
_register_ws(ws_client, _unique("alice"))
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||||
|
message = ws.receive_json()
|
||||||
|
ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "🔥"})
|
||||||
|
assert ws.receive_json()["type"] == "reaction_update"
|
||||||
|
|
||||||
|
history = ws_client.get(f"/api/rooms/{room['id']}/messages").json()
|
||||||
|
persisted = next(m for m in history if m["id"] == message["id"])
|
||||||
|
assert persisted["reactions"] == [{"emoji": "🔥", "count": 1, "user_ids": [message["user_id"]]}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaction_broadcasts_to_other_room_members(ws_client):
|
||||||
|
alice = _register_ws(ws_client, _unique("alice"))
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
bob = _register_ws(ws_client, _unique("bob"))
|
||||||
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||||
|
|
||||||
|
ws_client.post(
|
||||||
|
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||||
|
)
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as alice_ws:
|
||||||
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert alice_ws.receive_json()["type"] == "joined"
|
||||||
|
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
|
||||||
|
message = alice_ws.receive_json()
|
||||||
|
|
||||||
|
ws_client.post(
|
||||||
|
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
|
||||||
|
)
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||||
|
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert bob_ws.receive_json()["type"] == "joined"
|
||||||
|
|
||||||
|
ws_client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username_or_email": alice["username"], "password": "password123"},
|
||||||
|
)
|
||||||
|
alice_ws.send_json(
|
||||||
|
{"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "🎉"}
|
||||||
|
)
|
||||||
|
assert alice_ws.receive_json()["type"] == "reaction_update"
|
||||||
|
update = bob_ws.receive_json()
|
||||||
|
assert update["type"] == "reaction_update"
|
||||||
|
assert update["reactions"][0]["emoji"] == "🎉"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaction_requires_room_membership(ws_client_factory):
|
||||||
|
owner_client = ws_client_factory()
|
||||||
|
outsider_client = ws_client_factory()
|
||||||
|
|
||||||
|
_register_ws(owner_client, _unique("alice"))
|
||||||
|
room = owner_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
with owner_client.websocket_connect("/ws/chat") as owner_ws:
|
||||||
|
owner_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert owner_ws.receive_json()["type"] == "joined"
|
||||||
|
owner_ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||||
|
message = owner_ws.receive_json()
|
||||||
|
|
||||||
|
_register_ws(outsider_client, _unique("mallory"))
|
||||||
|
with outsider_client.websocket_connect("/ws/chat") as outsider_ws:
|
||||||
|
outsider_ws.send_json(
|
||||||
|
{"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "👍"}
|
||||||
|
)
|
||||||
|
resp = outsider_ws.receive_json()
|
||||||
|
assert resp["type"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_reaction_without_write_scope_rejected(ws_client_factory):
|
||||||
|
ws_client = ws_client_factory()
|
||||||
|
admin = _register_ws(ws_client, _unique("admin"))
|
||||||
|
_make_admin_ws(ws_client, admin["id"])
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||||
|
message = ws.receive_json()
|
||||||
|
|
||||||
|
bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
|
||||||
|
token = ws_client.post(
|
||||||
|
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages"]}
|
||||||
|
).json()["token"]
|
||||||
|
ws_client.post(f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat", headers={"Authorization": f"Bearer {token}"}) as bot_ws:
|
||||||
|
bot_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert bot_ws.receive_json()["type"] == "joined"
|
||||||
|
bot_ws.send_json(
|
||||||
|
{"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "👍"}
|
||||||
|
)
|
||||||
|
resp = bot_ws.receive_json()
|
||||||
|
assert resp["type"] == "error"
|
||||||
|
assert "write:messages" in resp["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaction_empty_emoji_rejected(ws_client):
|
||||||
|
_register_ws(ws_client, _unique("alice"))
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||||
|
message = ws.receive_json()
|
||||||
|
|
||||||
|
ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": ""})
|
||||||
|
resp = ws.receive_json()
|
||||||
|
assert resp["type"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaction_invalid_message_id_rejected(ws_client):
|
||||||
|
_register_ws(ws_client, _unique("alice"))
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
ws.send_json(
|
||||||
|
{"type": "reaction", "room_id": room["id"], "message_id": str(uuid.uuid4()), "emoji": "👍"}
|
||||||
|
)
|
||||||
|
resp = ws.receive_json()
|
||||||
|
assert resp["type"] == "error"
|
||||||
|
assert "Message not found" in resp["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaction_wrong_room_rejected(ws_client):
|
||||||
|
_register_ws(ws_client, _unique("alice"))
|
||||||
|
room_a = ws_client.post("/api/rooms", json={"name": _unique("room-a")}).json()
|
||||||
|
room_b = ws_client.post("/api/rooms", json={"name": _unique("room-b")}).json()
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||||
|
ws.send_json({"type": "join", "room_id": room_a["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
ws.send_json({"type": "message", "room_id": room_a["id"], "content": "hello"})
|
||||||
|
message = ws.receive_json()
|
||||||
|
|
||||||
|
ws.send_json({"type": "join", "room_id": room_b["id"]})
|
||||||
|
assert ws.receive_json()["type"] == "joined"
|
||||||
|
ws.send_json(
|
||||||
|
{"type": "reaction", "room_id": room_b["id"], "message_id": message["id"], "emoji": "👍"}
|
||||||
|
)
|
||||||
|
resp = ws.receive_json()
|
||||||
|
assert resp["type"] == "error"
|
||||||
|
assert "Message not found" in resp["detail"]
|
||||||
@@ -54,6 +54,13 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
|
|||||||
m.id === envelope.id ? { ...m, content: envelope.content, edited_at: envelope.edited_at } : m,
|
m.id === envelope.id ? { ...m, content: envelope.content, edited_at: envelope.edited_at } : m,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
} else if (envelope.type === 'reaction_update') {
|
||||||
|
setHistory((prev) =>
|
||||||
|
prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
|
||||||
|
)
|
||||||
|
setLive((prev) =>
|
||||||
|
prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
|
||||||
|
)
|
||||||
} else if (envelope.type === 'error') {
|
} else if (envelope.type === 'error') {
|
||||||
setWsError(envelope.detail)
|
setWsError(envelope.detail)
|
||||||
}
|
}
|
||||||
@@ -61,7 +68,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
|
|||||||
|
|
||||||
const onUnauthenticated = useCallback(() => navigate('/login'), [navigate])
|
const onUnauthenticated = useCallback(() => navigate('/login'), [navigate])
|
||||||
|
|
||||||
const { connected, send, sendEdit } = useChatSocket({ roomId: room.id, onMessage, onUnauthenticated })
|
const { connected, send, sendEdit, sendReaction } = useChatSocket({ roomId: room.id, onMessage, onUnauthenticated })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="chat-pane">
|
<section className="chat-pane">
|
||||||
@@ -99,7 +106,13 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MessageList roomId={room.id} messages={[...history, ...live]} members={members} onEdit={sendEdit} />
|
<MessageList
|
||||||
|
roomId={room.id}
|
||||||
|
messages={[...history, ...live]}
|
||||||
|
members={members}
|
||||||
|
onEdit={sendEdit}
|
||||||
|
onReact={sendReaction}
|
||||||
|
/>
|
||||||
<Composer roomId={room.id} roomName={room.name} disabled={!connected} onSend={send} />
|
<Composer roomId={room.id} roomName={room.name} disabled={!connected} onSend={send} />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,6 +53,35 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-emoji-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-emoji-trigger {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--ds-border);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-emoji-trigger:hover:not(:disabled) {
|
||||||
|
border-color: var(--ds-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-emoji-trigger:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.composer-attach {
|
.composer-attach {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
||||||
import { useOnlineStatus } from '../hooks/useOnlineStatus'
|
import { useOnlineStatus } from '../hooks/useOnlineStatus'
|
||||||
import { uploadRoomImage } from '../api/rooms'
|
import { uploadRoomImage } from '../api/rooms'
|
||||||
|
import { EmojiPicker } from './EmojiPicker'
|
||||||
import './Composer.css'
|
import './Composer.css'
|
||||||
|
|
||||||
interface ComposerProps {
|
interface ComposerProps {
|
||||||
@@ -15,6 +16,7 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
|||||||
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
|
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
|
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const online = useOnlineStatus()
|
const online = useOnlineStatus()
|
||||||
@@ -69,6 +71,24 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertEmoji(emoji: string) {
|
||||||
|
const el = textareaRef.current
|
||||||
|
setEmojiPickerOpen(false)
|
||||||
|
if (!el) {
|
||||||
|
setValue((v) => v + emoji)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = el.selectionStart ?? value.length
|
||||||
|
const end = el.selectionEnd ?? value.length
|
||||||
|
setValue(value.slice(0, start) + emoji + value.slice(end))
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.focus()
|
||||||
|
const cursor = start + emoji.length
|
||||||
|
el.setSelectionRange(cursor, cursor)
|
||||||
|
autoGrow()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="composer">
|
<div className="composer">
|
||||||
{pendingImage && (
|
{pendingImage && (
|
||||||
@@ -116,6 +136,25 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="composer-emoji-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="composer-emoji-trigger"
|
||||||
|
onClick={() => setEmojiPickerOpen((v) => !v)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Insert an emoji"
|
||||||
|
>
|
||||||
|
🙂
|
||||||
|
</button>
|
||||||
|
{emojiPickerOpen && (
|
||||||
|
<EmojiPicker
|
||||||
|
onPick={insertEmoji}
|
||||||
|
onClose={() => setEmojiPickerOpen(false)}
|
||||||
|
placement="above"
|
||||||
|
align="left"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
rows={1}
|
rows={1}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.emoji-picker-scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 31;
|
||||||
|
width: 280px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--ds-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--sp-2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker-below {
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker-above {
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker-category-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--ds-muted);
|
||||||
|
padding: 4px 4px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker-item {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 6px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker-item:hover {
|
||||||
|
background: var(--ds-surface-2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEscapeKey } from '../hooks/useEscapeKey'
|
||||||
|
import { EMOJI_CATEGORIES } from '../lib/emoji'
|
||||||
|
import './EmojiPicker.css'
|
||||||
|
|
||||||
|
interface EmojiPickerProps {
|
||||||
|
onPick: (emoji: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
placement?: 'above' | 'below'
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
|
||||||
|
useEscapeKey(onClose)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="emoji-picker-scrim" onClick={onClose} />
|
||||||
|
<div
|
||||||
|
className={`emoji-picker emoji-picker-${placement} emoji-picker-${align}`}
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
{EMOJI_CATEGORIES.map((category) => (
|
||||||
|
<div key={category.label} className="emoji-picker-category">
|
||||||
|
<div className="emoji-picker-category-label">{category.label}</div>
|
||||||
|
<div className="emoji-picker-grid">
|
||||||
|
{category.emoji.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="emoji-picker-item"
|
||||||
|
onClick={() => onPick(emoji)}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEscapeKey } from '../hooks/useEscapeKey'
|
||||||
import './ImageLightbox.css'
|
import './ImageLightbox.css'
|
||||||
|
|
||||||
interface ImageLightboxProps {
|
interface ImageLightboxProps {
|
||||||
@@ -7,13 +7,7 @@ interface ImageLightboxProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ImageLightbox({ src, onClose }: ImageLightboxProps) {
|
export function ImageLightbox({ src, onClose }: ImageLightboxProps) {
|
||||||
useEffect(() => {
|
useEscapeKey(onClose)
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') onClose()
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [onClose])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="image-lightbox" onClick={onClose}>
|
<div className="image-lightbox" onClick={onClose}>
|
||||||
|
|||||||
@@ -76,11 +76,20 @@
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-edit-link {
|
.message-row-actions {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
right: var(--sp-2);
|
right: var(--sp-2);
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row:hover .message-row-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-edit-link {
|
||||||
background: var(--ds-surface-2);
|
background: var(--ds-surface-2);
|
||||||
border: 1px solid var(--ds-border);
|
border: 1px solid var(--ds-border);
|
||||||
color: var(--ds-muted);
|
color: var(--ds-muted);
|
||||||
@@ -95,8 +104,52 @@
|
|||||||
border-color: var(--ds-accent);
|
border-color: var(--ds-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-row:hover .message-edit-link {
|
.message-reaction-wrap {
|
||||||
display: inline;
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reaction-trigger {
|
||||||
|
background: var(--ds-surface-2);
|
||||||
|
border: 1px solid var(--ds-border);
|
||||||
|
color: var(--ds-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reaction-trigger:hover {
|
||||||
|
border-color: var(--ds-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reaction-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reaction-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--ds-surface-2);
|
||||||
|
border: 1px solid var(--ds-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
color: var(--ds-muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reaction-pill:hover {
|
||||||
|
border-color: var(--ds-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reaction-pill-mine {
|
||||||
|
border-color: var(--ds-accent);
|
||||||
|
color: var(--ds-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-edit-input {
|
.message-edit-input {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getRoomImageUrl } from '../api/rooms'
|
|||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { senderColorIndex } from '../lib/messageGrouping'
|
import { senderColorIndex } from '../lib/messageGrouping'
|
||||||
import type { ChatMessageEnvelope, Message, RoomMember } from '../types'
|
import type { ChatMessageEnvelope, Message, RoomMember } from '../types'
|
||||||
|
import { EmojiPicker } from './EmojiPicker'
|
||||||
import { ImageLightbox } from './ImageLightbox'
|
import { ImageLightbox } from './ImageLightbox'
|
||||||
import { UserAvatar } from './UserAvatar'
|
import { UserAvatar } from './UserAvatar'
|
||||||
import './MessageList.css'
|
import './MessageList.css'
|
||||||
@@ -12,14 +13,20 @@ interface MessageListProps {
|
|||||||
messages: (Message | ChatMessageEnvelope)[]
|
messages: (Message | ChatMessageEnvelope)[]
|
||||||
members: RoomMember[]
|
members: RoomMember[]
|
||||||
onEdit: (messageId: string, content: string) => void
|
onEdit: (messageId: string, content: string) => void
|
||||||
|
onReact: (messageId: string, emoji: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageList({ roomId, messages, members, onEdit }: MessageListProps) {
|
export function MessageList({ roomId, messages, members, onEdit, onReact }: MessageListProps) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
|
||||||
|
const [reactingId, setReactingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
function usernameFor(userId: string): string {
|
||||||
|
return members.find((m) => m.user_id === userId)?.username ?? 'someone'
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ block: 'end' })
|
bottomRef.current?.scrollIntoView({ block: 'end' })
|
||||||
@@ -92,10 +99,52 @@ export function MessageList({ roomId, messages, members, onEdit }: MessageListPr
|
|||||||
{msg.edited_at && <span className="message-edited"> (edited)</span>}
|
{msg.edited_at && <span className="message-edited"> (edited)</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{msg.reactions.length > 0 && (
|
||||||
|
<div className="message-reaction-pills">
|
||||||
|
{msg.reactions.map((r) => {
|
||||||
|
const mineReaction = !!user && r.user_ids.includes(user.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.emoji}
|
||||||
|
type="button"
|
||||||
|
className={`message-reaction-pill${mineReaction ? ' message-reaction-pill-mine' : ''}`}
|
||||||
|
title={r.user_ids.map(usernameFor).join(', ')}
|
||||||
|
onClick={() => onReact(msg.id, r.emoji)}
|
||||||
|
>
|
||||||
|
<span>{r.emoji}</span>
|
||||||
|
<span>{r.count}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{mine && !editing && (
|
{!editing && (
|
||||||
|
<div className="message-row-actions">
|
||||||
|
<div className="message-reaction-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="message-reaction-trigger"
|
||||||
|
onClick={() => setReactingId(reactingId === msg.id ? null : msg.id)}
|
||||||
|
aria-label="Add reaction"
|
||||||
|
>
|
||||||
|
🙂
|
||||||
|
</button>
|
||||||
|
{reactingId === msg.id && (
|
||||||
|
<EmojiPicker
|
||||||
|
onPick={(emoji) => {
|
||||||
|
onReact(msg.id, emoji)
|
||||||
|
setReactingId(null)
|
||||||
|
}}
|
||||||
|
onClose={() => setReactingId(null)}
|
||||||
|
placement="below"
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{mine && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="message-edit-link"
|
className="message-edit-link"
|
||||||
@@ -106,6 +155,8 @@ export function MessageList({ roomId, messages, members, onEdit }: MessageListPr
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useEscapeKey(onEscape: () => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onEscape()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onEscape])
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export interface EmojiCategory {
|
||||||
|
label: string
|
||||||
|
emoji: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMOJI_CATEGORIES: EmojiCategory[] = [
|
||||||
|
{
|
||||||
|
label: 'Smileys',
|
||||||
|
emoji: [
|
||||||
|
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃',
|
||||||
|
'😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😋', '😛', '😜',
|
||||||
|
'🤪', '🤨', '🧐', '🤓', '😎', '🥳', '😏', '😒', '😞', '😔',
|
||||||
|
'😢', '😭', '😤', '😠', '😡', '🤯', '😳', '🥵', '🥶', '😱',
|
||||||
|
'😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶',
|
||||||
|
'😐', '😑', '😬', '🙄', '😯', '😴', '🤤', '😪', '😵', '🤢',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gestures',
|
||||||
|
emoji: [
|
||||||
|
'👍', '👎', '👏', '🙌', '🙏', '🤝', '👋', '✌️', '🤞', '🤟',
|
||||||
|
'🤘', '👌', '🤙', '☝️', '👆', '👇', '👈', '👉', '💪', '🫡',
|
||||||
|
'🫶', '🤲',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Hearts',
|
||||||
|
emoji: [
|
||||||
|
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
|
||||||
|
'❤️🔥', '💯', '✨', '💖', '💕', '💗',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Objects & Symbols',
|
||||||
|
emoji: [
|
||||||
|
'🔥', '🎉', '🎊', '🎂', '🎁', '🚀', '⭐', '🌟', '✅', '❌',
|
||||||
|
'⚡', '💡', '📌', '📎', '🐛', '🍕', '🍔', '☕', '🍺', '🎯',
|
||||||
|
'⏰', '💰', '🔒', '🔑', '📢', '❓', '❗', '💬', '👀', '🎈',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Animals & Nature',
|
||||||
|
emoji: [
|
||||||
|
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯',
|
||||||
|
'🦁', '🐸', '🐵', '🦄', '🐔', '🐢', '🐍', '🌈', '☀️', '🌙',
|
||||||
|
'⛅', '🌧️', '❄️', '🌸', '🌵', '🌴',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ALL_EMOJI = EMOJI_CATEGORIES.flatMap((c) => c.emoji)
|
||||||
@@ -51,6 +51,12 @@ export interface MyInvite extends Invite {
|
|||||||
invited_by_username: string
|
invited_by_username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReactionSummary {
|
||||||
|
emoji: string
|
||||||
|
count: number
|
||||||
|
user_ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string
|
id: string
|
||||||
room_id: string
|
room_id: string
|
||||||
@@ -58,6 +64,7 @@ export interface Message {
|
|||||||
username: string
|
username: string
|
||||||
content: string | null
|
content: string | null
|
||||||
image_id: string | null
|
image_id: string | null
|
||||||
|
reactions: ReactionSummary[]
|
||||||
created_at: string
|
created_at: string
|
||||||
edited_at: string | null
|
edited_at: string | null
|
||||||
}
|
}
|
||||||
@@ -70,6 +77,7 @@ export interface ChatMessageEnvelope {
|
|||||||
username: string
|
username: string
|
||||||
content: string | null
|
content: string | null
|
||||||
image_id: string | null
|
image_id: string | null
|
||||||
|
reactions: ReactionSummary[]
|
||||||
created_at: string
|
created_at: string
|
||||||
edited_at: string | null
|
edited_at: string | null
|
||||||
}
|
}
|
||||||
@@ -82,6 +90,13 @@ export interface ChatMessageUpdateEnvelope {
|
|||||||
edited_at: string | null
|
edited_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatReactionUpdateEnvelope {
|
||||||
|
type: 'reaction_update'
|
||||||
|
id: string
|
||||||
|
room_id: string
|
||||||
|
reactions: ReactionSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatJoinedEnvelope {
|
export interface ChatJoinedEnvelope {
|
||||||
type: 'joined'
|
type: 'joined'
|
||||||
room_id: string
|
room_id: string
|
||||||
@@ -95,6 +110,7 @@ export interface ChatErrorEnvelope {
|
|||||||
export type ServerEnvelope =
|
export type ServerEnvelope =
|
||||||
| ChatMessageEnvelope
|
| ChatMessageEnvelope
|
||||||
| ChatMessageUpdateEnvelope
|
| ChatMessageUpdateEnvelope
|
||||||
|
| ChatReactionUpdateEnvelope
|
||||||
| ChatJoinedEnvelope
|
| ChatJoinedEnvelope
|
||||||
| ChatErrorEnvelope
|
| ChatErrorEnvelope
|
||||||
|
|
||||||
|
|||||||
@@ -96,5 +96,11 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS
|
|||||||
ws.send(JSON.stringify({ type: 'edit', room_id: roomId, message_id: messageId, content }))
|
ws.send(JSON.stringify({ type: 'edit', room_id: roomId, message_id: messageId, content }))
|
||||||
}, [roomId])
|
}, [roomId])
|
||||||
|
|
||||||
return { connected, send, sendEdit }
|
const sendReaction = useCallback((messageId: string, emoji: string) => {
|
||||||
|
const ws = socketRef.current
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
ws.send(JSON.stringify({ type: 'reaction', room_id: roomId, message_id: messageId, emoji }))
|
||||||
|
}, [roomId])
|
||||||
|
|
||||||
|
return { connected, send, sendEdit, sendReaction }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user