feat: add waveform voice messages end-to-end
All checks were successful
CI / test (push) Successful in 23s
All checks were successful
CI / test (push) Successful in 23s
This commit is contained in:
27
alembic/versions/0020_attachment_waveform_data.py
Normal file
27
alembic/versions/0020_attachment_waveform_data.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""add waveform data to attachments
|
||||||
|
|
||||||
|
Revision ID: 0020_attachment_waveform_data
|
||||||
|
Revises: 0019_user_privacy_fields
|
||||||
|
Create Date: 2026-03-08
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0020_attachment_waveform_data"
|
||||||
|
down_revision: str | None = "0019_user_privacy_fields"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("attachments", sa.Column("waveform_data", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("attachments", "waveform_data")
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import ForeignKey, Integer, String
|
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.base import Base
|
from app.database.base import Base
|
||||||
@@ -12,5 +12,6 @@ class Attachment(Base):
|
|||||||
file_url: Mapped[str] = mapped_column(String(1024), nullable=False)
|
file_url: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
file_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
file_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
|
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
waveform_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
message = relationship("Message", back_populates="attachments")
|
message = relationship("Message", back_populates="attachments")
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ async def create_attachment(
|
|||||||
file_url: str,
|
file_url: str,
|
||||||
file_type: str,
|
file_type: str,
|
||||||
file_size: int,
|
file_size: int,
|
||||||
|
waveform_data: str | None = None,
|
||||||
) -> Attachment:
|
) -> Attachment:
|
||||||
attachment = Attachment(
|
attachment = Attachment(
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
file_url=file_url,
|
file_url=file_url,
|
||||||
file_type=file_type,
|
file_type=file_type,
|
||||||
file_size=file_size,
|
file_size=file_size,
|
||||||
|
waveform_data=waveform_data,
|
||||||
)
|
)
|
||||||
db.add(attachment)
|
db.add(attachment)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -46,3 +48,15 @@ async def list_chat_attachments(
|
|||||||
stmt = stmt.where(Attachment.id < before_id)
|
stmt = stmt.where(Attachment.id < before_id)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return [(row[0], row[1]) for row in result.all()]
|
return [(row[0], row[1]) for row in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_attachments_by_message_ids(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
message_ids: list[int],
|
||||||
|
) -> list[Attachment]:
|
||||||
|
if not message_ids:
|
||||||
|
return []
|
||||||
|
stmt = select(Attachment).where(Attachment.message_id.in_(message_ids))
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class AttachmentCreateRequest(BaseModel):
|
|||||||
file_url: str = Field(min_length=1, max_length=1024)
|
file_url: str = Field(min_length=1, max_length=1024)
|
||||||
file_type: str = Field(min_length=1, max_length=64)
|
file_type: str = Field(min_length=1, max_length=64)
|
||||||
file_size: int = Field(gt=0)
|
file_size: int = Field(gt=0)
|
||||||
|
waveform_points: list[int] | None = Field(default=None, min_length=8, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
class AttachmentRead(BaseModel):
|
class AttachmentRead(BaseModel):
|
||||||
@@ -31,6 +32,7 @@ class AttachmentRead(BaseModel):
|
|||||||
file_url: str
|
file_url: str
|
||||||
file_type: str
|
file_type: str
|
||||||
file_size: int
|
file_size: int
|
||||||
|
waveform_points: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ChatAttachmentRead(BaseModel):
|
class ChatAttachmentRead(BaseModel):
|
||||||
@@ -42,3 +44,4 @@ class ChatAttachmentRead(BaseModel):
|
|||||||
file_url: str
|
file_url: str
|
||||||
file_type: str
|
file_type: str
|
||||||
file_size: int
|
file_size: int
|
||||||
|
waveform_points: list[int] | None = None
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
|
import json
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -29,6 +30,35 @@ ALLOWED_MIME_TYPES = {
|
|||||||
"text/plain",
|
"text/plain",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_waveform(points: list[int] | None) -> list[int] | None:
|
||||||
|
if points is None:
|
||||||
|
return None
|
||||||
|
normalized = [max(0, min(31, int(value))) for value in points]
|
||||||
|
if len(normalized) < 8:
|
||||||
|
return None
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_waveform(raw: str | None) -> list[int] | None:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
return None
|
||||||
|
result: list[int] = []
|
||||||
|
for value in parsed[:256]:
|
||||||
|
if isinstance(value, int):
|
||||||
|
result.append(max(0, min(31, value)))
|
||||||
|
elif isinstance(value, float):
|
||||||
|
result.append(max(0, min(31, int(value))))
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return result or None
|
||||||
|
|
||||||
def _normalize_mime(file_type: str) -> str:
|
def _normalize_mime(file_type: str) -> str:
|
||||||
return file_type.split(";", maxsplit=1)[0].strip().lower()
|
return file_type.split(";", maxsplit=1)[0].strip().lower()
|
||||||
|
|
||||||
@@ -125,16 +155,27 @@ async def store_attachment_metadata(
|
|||||||
detail="Only the message sender can attach files",
|
detail="Only the message sender can attach files",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
normalized_waveform = _normalize_waveform(payload.waveform_points)
|
||||||
attachment = await repository.create_attachment(
|
attachment = await repository.create_attachment(
|
||||||
db,
|
db,
|
||||||
message_id=payload.message_id,
|
message_id=payload.message_id,
|
||||||
file_url=payload.file_url,
|
file_url=payload.file_url,
|
||||||
file_type=payload.file_type,
|
file_type=payload.file_type,
|
||||||
file_size=payload.file_size,
|
file_size=payload.file_size,
|
||||||
|
waveform_data=json.dumps(normalized_waveform, ensure_ascii=True) if normalized_waveform else None,
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(attachment)
|
await db.refresh(attachment)
|
||||||
return AttachmentRead.model_validate(attachment)
|
return AttachmentRead.model_validate(
|
||||||
|
{
|
||||||
|
"id": attachment.id,
|
||||||
|
"message_id": attachment.message_id,
|
||||||
|
"file_url": attachment.file_url,
|
||||||
|
"file_type": attachment.file_type,
|
||||||
|
"file_size": attachment.file_size,
|
||||||
|
"waveform_points": _decode_waveform(attachment.waveform_data),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def list_attachments_for_chat(
|
async def list_attachments_for_chat(
|
||||||
@@ -162,6 +203,7 @@ async def list_attachments_for_chat(
|
|||||||
file_url=attachment.file_url,
|
file_url=attachment.file_url,
|
||||||
file_type=attachment.file_type,
|
file_type=attachment.file_type,
|
||||||
file_size=attachment.file_size,
|
file_size=attachment.file_size,
|
||||||
|
waveform_points=_decode_waveform(attachment.waveform_data),
|
||||||
)
|
)
|
||||||
for attachment, message in rows
|
for attachment, message in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class MessageRead(BaseModel):
|
|||||||
type: MessageType
|
type: MessageType
|
||||||
text: str | None
|
text: str | None
|
||||||
delivery_status: Literal["sending", "sent", "delivered", "read"] | None = None
|
delivery_status: Literal["sending", "sent", "delivered", "read"] | None = None
|
||||||
|
attachment_waveform: list[int] | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -5,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.chats import repository as chats_repository
|
from app.chats import repository as chats_repository
|
||||||
from app.chats.models import ChatMemberRole, ChatType
|
from app.chats.models import ChatMemberRole, ChatType
|
||||||
from app.chats.service import ensure_chat_membership
|
from app.chats.service import ensure_chat_membership
|
||||||
|
from app.media import repository as media_repository
|
||||||
from app.messages import repository
|
from app.messages import repository
|
||||||
from app.messages.models import Message
|
from app.messages.models import Message
|
||||||
from app.messages.spam_guard import enforce_message_spam_policy
|
from app.messages.spam_guard import enforce_message_spam_policy
|
||||||
@@ -110,11 +113,32 @@ async def get_messages(
|
|||||||
messages = await repository.list_chat_messages(db, chat_id, user_id=user_id, limit=safe_limit, before_id=before_id)
|
messages = await repository.list_chat_messages(db, chat_id, user_id=user_id, limit=safe_limit, before_id=before_id)
|
||||||
if not messages:
|
if not messages:
|
||||||
return messages
|
return messages
|
||||||
|
message_ids = [message.id for message in messages]
|
||||||
|
attachments = await media_repository.list_attachments_by_message_ids(db, message_ids=message_ids)
|
||||||
|
waveform_by_message_id: dict[int, list[int]] = {}
|
||||||
|
for attachment in attachments:
|
||||||
|
if not attachment.waveform_data:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(attachment.waveform_data)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
continue
|
||||||
|
values: list[int] = []
|
||||||
|
for item in parsed[:256]:
|
||||||
|
if isinstance(item, (int, float)):
|
||||||
|
values.append(max(0, min(31, int(item))))
|
||||||
|
if values:
|
||||||
|
waveform_by_message_id[attachment.message_id] = values
|
||||||
receipts = await repository.list_chat_receipts(db, chat_id=chat_id)
|
receipts = await repository.list_chat_receipts(db, chat_id=chat_id)
|
||||||
other_receipts = [receipt for receipt in receipts if receipt.user_id != user_id]
|
other_receipts = [receipt for receipt in receipts if receipt.user_id != user_id]
|
||||||
if not other_receipts:
|
if not other_receipts:
|
||||||
return messages
|
return messages
|
||||||
for message in messages:
|
for message in messages:
|
||||||
|
waveform = waveform_by_message_id.get(message.id)
|
||||||
|
if waveform:
|
||||||
|
setattr(message, "attachment_waveform", waveform)
|
||||||
if message.sender_id != user_id:
|
if message.sender_id != user_id:
|
||||||
continue
|
continue
|
||||||
is_read = any((receipt.last_read_message_id or 0) >= message.id for receipt in other_receipts)
|
is_read = any((receipt.last_read_message_id or 0) >= message.id for receipt in other_receipts)
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ Rules:
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"text": "Hello",
|
"text": "Hello",
|
||||||
"delivery_status": "read",
|
"delivery_status": "read",
|
||||||
|
"attachment_waveform": [4, 7, 10, 9, 6],
|
||||||
"created_at": "2026-03-08T10:02:00Z",
|
"created_at": "2026-03-08T10:02:00Z",
|
||||||
"updated_at": "2026-03-08T10:02:00Z"
|
"updated_at": "2026-03-08T10:02:00Z"
|
||||||
}
|
}
|
||||||
@@ -339,7 +340,8 @@ Rules:
|
|||||||
"message_id": 100,
|
"message_id": 100,
|
||||||
"file_url": "https://.../bucket/uploads/....jpg",
|
"file_url": "https://.../bucket/uploads/....jpg",
|
||||||
"file_type": "image/jpeg",
|
"file_type": "image/jpeg",
|
||||||
"file_size": 123456
|
"file_size": 123456,
|
||||||
|
"waveform_points": [4, 7, 10, 9, 6]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -351,7 +353,8 @@ Rules:
|
|||||||
"message_id": 100,
|
"message_id": 100,
|
||||||
"file_url": "https://...",
|
"file_url": "https://...",
|
||||||
"file_type": "image/jpeg",
|
"file_type": "image/jpeg",
|
||||||
"file_size": 123456
|
"file_size": 123456,
|
||||||
|
"waveform_points": [4, 7, 10, 9, 6]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -147,12 +147,19 @@ export async function uploadToPresignedUrl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function attachFile(messageId: number, fileUrl: string, fileType: string, fileSize: number): Promise<void> {
|
export async function attachFile(
|
||||||
|
messageId: number,
|
||||||
|
fileUrl: string,
|
||||||
|
fileType: string,
|
||||||
|
fileSize: number,
|
||||||
|
waveformPoints?: number[] | null
|
||||||
|
): Promise<void> {
|
||||||
await http.post("/media/attachments", {
|
await http.post("/media/attachments", {
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
file_url: fileUrl,
|
file_url: fileUrl,
|
||||||
file_type: fileType,
|
file_type: fileType,
|
||||||
file_size: fileSize
|
file_size: fileSize,
|
||||||
|
waveform_points: waveformPoints ?? undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface Message {
|
|||||||
client_message_id?: string;
|
client_message_id?: string;
|
||||||
delivery_status?: DeliveryStatus;
|
delivery_status?: DeliveryStatus;
|
||||||
is_pending?: boolean;
|
is_pending?: boolean;
|
||||||
|
attachment_waveform?: number[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageReaction {
|
export interface MessageReaction {
|
||||||
@@ -123,4 +124,5 @@ export interface ChatAttachment {
|
|||||||
file_url: string;
|
file_url: string;
|
||||||
file_type: string;
|
file_type: string;
|
||||||
file_size: number;
|
file_size: number;
|
||||||
|
waveform_points?: number[] | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ export function MessageComposer() {
|
|||||||
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
|
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
||||||
|
const [showFormatMenu, setShowFormatMenu] = useState(false);
|
||||||
const [captionDraft, setCaptionDraft] = useState("");
|
const [captionDraft, setCaptionDraft] = useState("");
|
||||||
const mediaInputRef = useRef<HTMLInputElement | null>(null);
|
const mediaInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
const [recordingState, setRecordingState] = useState<RecordingState>("idle");
|
const [recordingState, setRecordingState] = useState<RecordingState>("idle");
|
||||||
const [recordSeconds, setRecordSeconds] = useState(0);
|
const [recordSeconds, setRecordSeconds] = useState(0);
|
||||||
@@ -169,7 +171,11 @@ export function MessageComposer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpload(file: File, messageType: "file" | "image" | "video" | "audio" | "voice" = "file") {
|
async function handleUpload(
|
||||||
|
file: File,
|
||||||
|
messageType: "file" | "image" | "video" | "audio" | "voice" = "file",
|
||||||
|
waveformPoints?: number[] | null
|
||||||
|
) {
|
||||||
if (!activeChatId || !me) {
|
if (!activeChatId || !me) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -186,12 +192,19 @@ export function MessageComposer() {
|
|||||||
senderId: me.id,
|
senderId: me.id,
|
||||||
type: messageType,
|
type: messageType,
|
||||||
text: upload.file_url,
|
text: upload.file_url,
|
||||||
clientMessageId
|
clientMessageId,
|
||||||
});
|
});
|
||||||
const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, clientMessageId, replyToMessageId);
|
const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, clientMessageId, replyToMessageId);
|
||||||
confirmMessageByClientId(activeChatId, clientMessageId, message);
|
const messageWithWaveform = waveformPoints?.length ? { ...message, attachment_waveform: waveformPoints } : message;
|
||||||
|
confirmMessageByClientId(activeChatId, clientMessageId, messageWithWaveform);
|
||||||
try {
|
try {
|
||||||
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
|
await attachFile(
|
||||||
|
message.id,
|
||||||
|
upload.file_url,
|
||||||
|
file.type || "application/octet-stream",
|
||||||
|
file.size,
|
||||||
|
waveformPoints ?? null
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
setUploadError("File sent, but metadata save failed. Please refresh chat.");
|
setUploadError("File sent, but metadata save failed. Please refresh chat.");
|
||||||
}
|
}
|
||||||
@@ -297,7 +310,8 @@ export function MessageComposer() {
|
|||||||
}
|
}
|
||||||
const blob = new Blob(data, { type: "audio/webm" });
|
const blob = new Blob(data, { type: "audio/webm" });
|
||||||
const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" });
|
const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" });
|
||||||
await handleUpload(file, "voice");
|
const waveform = await buildWaveformPoints(blob, 64);
|
||||||
|
await handleUpload(file, "voice", waveform);
|
||||||
};
|
};
|
||||||
recorderRef.current = recorder;
|
recorderRef.current = recorder;
|
||||||
recorder.start();
|
recorder.start();
|
||||||
@@ -453,6 +467,58 @@ export function MessageComposer() {
|
|||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertFormatting(startTag: string, endTag = startTag, placeholder = "text") {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = textarea.selectionStart ?? text.length;
|
||||||
|
const end = textarea.selectionEnd ?? text.length;
|
||||||
|
const selected = text.slice(start, end);
|
||||||
|
const middle = selected || placeholder;
|
||||||
|
const nextValue = `${text.slice(0, start)}${startTag}${middle}${endTag}${text.slice(end)}`;
|
||||||
|
setText(nextValue);
|
||||||
|
if (activeChatId) {
|
||||||
|
setDraft(activeChatId, nextValue);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textarea.focus();
|
||||||
|
if (selected) {
|
||||||
|
const pos = start + startTag.length + middle.length + endTag.length;
|
||||||
|
textarea.setSelectionRange(pos, pos);
|
||||||
|
} else {
|
||||||
|
const selStart = start + startTag.length;
|
||||||
|
const selEnd = selStart + middle.length;
|
||||||
|
textarea.setSelectionRange(selStart, selEnd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertLink() {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = textarea.selectionStart ?? text.length;
|
||||||
|
const end = textarea.selectionEnd ?? text.length;
|
||||||
|
const selected = text.slice(start, end).trim() || "text";
|
||||||
|
const href = window.prompt("Enter URL (https://...)");
|
||||||
|
if (!href) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const link = `[${selected}](${href.trim()})`;
|
||||||
|
const nextValue = `${text.slice(0, start)}${link}${text.slice(end)}`;
|
||||||
|
setText(nextValue);
|
||||||
|
if (activeChatId) {
|
||||||
|
setDraft(activeChatId, nextValue);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textarea.focus();
|
||||||
|
const pos = start + link.length;
|
||||||
|
textarea.setSelectionRange(pos, pos);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-slate-700/50 bg-slate-900/55 p-3">
|
<div className="border-t border-slate-700/50 bg-slate-900/55 p-3">
|
||||||
{activeChatId && replyToByChat[activeChatId] ? (
|
{activeChatId && replyToByChat[activeChatId] ? (
|
||||||
@@ -492,6 +558,32 @@ export function MessageComposer() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showFormatMenu ? (
|
||||||
|
<div className="mb-2 flex items-center gap-1 rounded-2xl border border-slate-700/80 bg-slate-900/95 px-2 py-1.5">
|
||||||
|
<button className="rounded px-2 py-1 text-xs hover:bg-slate-800" onClick={() => insertFormatting("||", "||")} type="button" title="Spoiler">
|
||||||
|
👁
|
||||||
|
</button>
|
||||||
|
<button className="rounded px-2 py-1 text-xs font-semibold hover:bg-slate-800" onClick={() => insertFormatting("**", "**")} type="button" title="Bold">
|
||||||
|
B
|
||||||
|
</button>
|
||||||
|
<button className="rounded px-2 py-1 text-xs italic hover:bg-slate-800" onClick={() => insertFormatting("*", "*")} type="button" title="Italic">
|
||||||
|
I
|
||||||
|
</button>
|
||||||
|
<button className="rounded px-2 py-1 text-xs underline hover:bg-slate-800" onClick={() => insertFormatting("__", "__")} type="button" title="Underline">
|
||||||
|
U
|
||||||
|
</button>
|
||||||
|
<button className="rounded px-2 py-1 text-xs line-through hover:bg-slate-800" onClick={() => insertFormatting("~~", "~~")} type="button" title="Strikethrough">
|
||||||
|
S
|
||||||
|
</button>
|
||||||
|
<button className="rounded px-2 py-1 text-xs hover:bg-slate-800" onClick={() => insertFormatting("`", "`")} type="button" title="Monospace">
|
||||||
|
M
|
||||||
|
</button>
|
||||||
|
<button className="rounded px-2 py-1 text-xs hover:bg-slate-800" onClick={insertLink} type="button" title="Link">
|
||||||
|
🔗
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="mb-2 flex items-end gap-2">
|
<div className="mb-2 flex items-end gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -549,7 +641,17 @@ export function MessageComposer() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="h-[42px] min-w-[42px] rounded-full bg-slate-700/85 px-2 text-xs font-semibold text-slate-200 hover:bg-slate-700"
|
||||||
|
onClick={() => setShowFormatMenu((v) => !v)}
|
||||||
|
type="button"
|
||||||
|
title="Text formatting"
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</button>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
className="min-h-[42px] max-h-40 flex-1 resize-y rounded-2xl border border-slate-700/80 bg-slate-800/80 px-4 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
className="min-h-[42px] max-h-40 flex-1 resize-y rounded-2xl border border-slate-700/80 bg-slate-800/80 px-4 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
||||||
placeholder="Write a message..."
|
placeholder="Write a message..."
|
||||||
rows={1}
|
rows={1}
|
||||||
@@ -657,6 +759,38 @@ export function MessageComposer() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildWaveformPoints(blob: Blob, bars = 64): Promise<number[] | null> {
|
||||||
|
try {
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
try {
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(buffer.slice(0));
|
||||||
|
const channel = audioBuffer.getChannelData(0);
|
||||||
|
if (!channel.length || bars < 8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const blockSize = Math.max(1, Math.floor(channel.length / bars));
|
||||||
|
const points: number[] = [];
|
||||||
|
for (let i = 0; i < bars; i += 1) {
|
||||||
|
const start = i * blockSize;
|
||||||
|
const end = Math.min(channel.length, start + blockSize);
|
||||||
|
let sum = 0;
|
||||||
|
for (let j = start; j < end; j += 1) {
|
||||||
|
const sample = channel[j];
|
||||||
|
sum += sample * sample;
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sum / Math.max(1, end - start));
|
||||||
|
points.push(Math.max(1, Math.min(31, Math.round(rms * 42))));
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
} finally {
|
||||||
|
await audioContext.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDuration(totalSeconds: number): string {
|
function formatDuration(totalSeconds: number): string {
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
const seconds = totalSeconds % 60;
|
const seconds = totalSeconds % 60;
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ export function MessageList() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{renderMessageContent(message.type, message.text, {
|
{renderMessageContent(message, {
|
||||||
onAttachmentContextMenu: (event, url) => {
|
onAttachmentContextMenu: (event, url) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void ensureReactionsLoaded(message.id);
|
void ensureReactionsLoaded(message.id);
|
||||||
@@ -784,13 +784,14 @@ export function MessageList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderMessageContent(
|
function renderMessageContent(
|
||||||
messageType: string,
|
message: Message,
|
||||||
text: string | null,
|
|
||||||
opts: {
|
opts: {
|
||||||
onAttachmentContextMenu: (event: MouseEvent<HTMLElement>, url: string) => void;
|
onAttachmentContextMenu: (event: MouseEvent<HTMLElement>, url: string) => void;
|
||||||
onOpenMedia: (url: string, type: "image" | "video") => void;
|
onOpenMedia: (url: string, type: "image" | "video") => void;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
const messageType = message.type;
|
||||||
|
const text = message.text;
|
||||||
if (!text) return <p className="opacity-80">[empty]</p>;
|
if (!text) return <p className="opacity-80">[empty]</p>;
|
||||||
|
|
||||||
if (messageType === "image") {
|
if (messageType === "image") {
|
||||||
@@ -832,11 +833,7 @@ function renderMessageContent(
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
opts.onAttachmentContextMenu(event, text);
|
opts.onAttachmentContextMenu(event, text);
|
||||||
}}>
|
}}>
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
|
<VoiceInlinePlayer src={text} title="Voice message" waveform={message.attachment_waveform ?? null} />
|
||||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎤</span>
|
|
||||||
<span className="font-semibold">Voice message</span>
|
|
||||||
</div>
|
|
||||||
<AudioInlinePlayer src={text} title="Voice message" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1031,6 +1028,92 @@ function AudioInlinePlayer({ src, title }: { src: string; title: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VoiceInlinePlayer({
|
||||||
|
src,
|
||||||
|
title,
|
||||||
|
waveform,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
title: string;
|
||||||
|
waveform: number[] | null;
|
||||||
|
}) {
|
||||||
|
const track = useAudioPlayerStore((s) => s.track);
|
||||||
|
const isPlayingGlobal = useAudioPlayerStore((s) => s.isPlaying);
|
||||||
|
const durationGlobal = useAudioPlayerStore((s) => s.duration);
|
||||||
|
const positionGlobal = useAudioPlayerStore((s) => s.position);
|
||||||
|
const playTrack = useAudioPlayerStore((s) => s.playTrack);
|
||||||
|
const togglePlayGlobal = useAudioPlayerStore((s) => s.togglePlay);
|
||||||
|
const seekToGlobal = useAudioPlayerStore((s) => s.seekTo);
|
||||||
|
|
||||||
|
const isActiveTrack = track?.src === src;
|
||||||
|
const isPlaying = isActiveTrack && isPlayingGlobal;
|
||||||
|
const duration = isActiveTrack ? durationGlobal : 0;
|
||||||
|
const position = isActiveTrack ? positionGlobal : 0;
|
||||||
|
const bars = waveform && waveform.length >= 8 ? waveform : buildFallbackWaveform(src);
|
||||||
|
|
||||||
|
async function togglePlay() {
|
||||||
|
if (isActiveTrack) {
|
||||||
|
await togglePlayGlobal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await playTrack({ src, title });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWaveClick(index: number) {
|
||||||
|
if (!isActiveTrack || duration <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ratio = index / Math.max(1, bars.length - 1);
|
||||||
|
seekToGlobal(duration * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressRatio = duration > 0 ? Math.min(1, Math.max(0, position / duration)) : 0;
|
||||||
|
const activeBars = Math.floor(progressRatio * bars.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-slate-900/40 px-2 py-1.5">
|
||||||
|
<button
|
||||||
|
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-sky-500/80 text-xs text-slate-950 hover:bg-sky-400"
|
||||||
|
onClick={() => void togglePlay()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isPlaying ? "❚❚" : "▶"}
|
||||||
|
</button>
|
||||||
|
<div className="flex min-w-0 flex-1 items-end gap-[2px]">
|
||||||
|
{bars.map((value, index) => (
|
||||||
|
<button
|
||||||
|
className={`w-[3px] rounded-full p-0 ${index <= activeBars ? "bg-sky-300" : "bg-slate-500/60"}`}
|
||||||
|
key={`${src}-${index}`}
|
||||||
|
onClick={() => handleWaveClick(index)}
|
||||||
|
style={{ height: `${Math.max(6, value)}px` }}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="w-10 text-right text-[11px] tabular-nums text-slate-200">
|
||||||
|
{formatAudioTime(duration > 0 ? duration : position)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackWaveform(seed: string, bars = 48): number[] {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < seed.length; i += 1) {
|
||||||
|
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
const result: number[] = [];
|
||||||
|
let value = hash || 1;
|
||||||
|
for (let i = 0; i < bars; i += 1) {
|
||||||
|
value ^= value << 13;
|
||||||
|
value ^= value >> 17;
|
||||||
|
value ^= value << 5;
|
||||||
|
const normalized = Math.abs(value % 20) + 6;
|
||||||
|
result.push(normalized);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function formatAudioTime(seconds: number): string {
|
function formatAudioTime(seconds: number): string {
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||||
const total = Math.floor(seconds);
|
const total = Math.floor(seconds);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function formatMessageHtml(text: string): string {
|
|||||||
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||||
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||||
html = html.replace(/__([^_]+)__/g, "<u>$1</u>");
|
html = html.replace(/__([^_]+)__/g, "<u>$1</u>");
|
||||||
|
html = html.replace(/~~([^~]+)~~/g, "<s>$1</s>");
|
||||||
html = html.replace(/`([^`]+)`/g, "<code class=\"rounded bg-slate-700/60 px-1 py-0.5 text-[12px]\">$1</code>");
|
html = html.replace(/`([^`]+)`/g, "<code class=\"rounded bg-slate-700/60 px-1 py-0.5 text-[12px]\">$1</code>");
|
||||||
html = html.replace(/\|\|([^|]+)\|\|/g, "<span class=\"rounded bg-slate-700/80 px-1 text-transparent hover:text-inherit\">$1</span>");
|
html = html.replace(/\|\|([^|]+)\|\|/g, "<span class=\"rounded bg-slate-700/80 px-1 text-transparent hover:text-inherit\">$1</span>");
|
||||||
html = html.replace(/\n/g, "<br/>");
|
html = html.replace(/\n/g, "<br/>");
|
||||||
|
|||||||
Reference in New Issue
Block a user