Compare commits
3 Commits
eda84d4d82
...
a32ef745c1
| Author | SHA1 | Date | |
|---|---|---|---|
| a32ef745c1 | |||
| 18596e6dab | |||
| 13b5f5b855 |
@@ -77,7 +77,9 @@ async def edit_message(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> MessageRead:
|
) -> MessageRead:
|
||||||
return await update_message(db, message_id=message_id, user_id=current_user.id, payload=payload)
|
message = await update_message(db, message_id=message_id, user_id=current_user.id, payload=payload)
|
||||||
|
await realtime_gateway.publish_message_updated(message=message)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -91,6 +93,7 @@ async def remove_message(
|
|||||||
message = await get_message_by_id(db, message_id)
|
message = await get_message_by_id(db, message_id)
|
||||||
await delete_message_for_all(db, message_id=message_id, user_id=current_user.id)
|
await delete_message_for_all(db, message_id=message_id, user_id=current_user.id)
|
||||||
if message:
|
if message:
|
||||||
|
await realtime_gateway.publish_message_deleted(chat_id=message.chat_id, message_id=message_id)
|
||||||
await realtime_gateway.publish_chat_updated(chat_id=message.chat_id)
|
await realtime_gateway.publish_chat_updated(chat_id=message.chat_id)
|
||||||
return
|
return
|
||||||
await delete_message(db, message_id=message_id, user_id=current_user.id)
|
await delete_message(db, message_id=message_id, user_id=current_user.id)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ RealtimeEventName = Literal[
|
|||||||
"disconnect",
|
"disconnect",
|
||||||
"send_message",
|
"send_message",
|
||||||
"receive_message",
|
"receive_message",
|
||||||
|
"message_updated",
|
||||||
|
"message_deleted",
|
||||||
"typing_start",
|
"typing_start",
|
||||||
"typing_stop",
|
"typing_stop",
|
||||||
"message_read",
|
"message_read",
|
||||||
|
|||||||
@@ -208,6 +208,27 @@ class RealtimeGateway:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def publish_message_updated(self, *, message) -> None:
|
||||||
|
message_data = MessageRead.model_validate(message).model_dump(mode="json")
|
||||||
|
await self._publish_chat_event(
|
||||||
|
message.chat_id,
|
||||||
|
event="message_updated",
|
||||||
|
payload={
|
||||||
|
"chat_id": message.chat_id,
|
||||||
|
"message": message_data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def publish_message_deleted(self, *, chat_id: int, message_id: int) -> None:
|
||||||
|
await self._publish_chat_event(
|
||||||
|
chat_id,
|
||||||
|
event="message_deleted",
|
||||||
|
payload={
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def _send_user_event(self, user_id: int, event: OutgoingRealtimeEvent) -> None:
|
async def _send_user_event(self, user_id: int, event: OutgoingRealtimeEvent) -> None:
|
||||||
user_connections = self._connections.get(user_id, {})
|
user_connections = self._connections.get(user_id, {})
|
||||||
if not user_connections:
|
if not user_connections:
|
||||||
|
|||||||
52
docs/core-checklist-status.md
Normal file
52
docs/core-checklist-status.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Core Checklist Status (Web + API)
|
||||||
|
|
||||||
|
Legend:
|
||||||
|
- `DONE` - implemented and works in current web flow.
|
||||||
|
- `PARTIAL` - implemented partly, limited UX/coverage, or needs hardening.
|
||||||
|
- `TODO` - not implemented yet.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
1. Account - `PARTIAL` (email auth, JWT, refresh, logout, reset; sessions exist, full UX still improving)
|
||||||
|
2. User Profile - `DONE` (username, name, avatar, bio, update)
|
||||||
|
3. User Status - `PARTIAL` (online/last seen/offline; "recently" heuristic limited)
|
||||||
|
4. Contacts - `PARTIAL` (list/search/add/remove/block/unblock; UX moved to menu)
|
||||||
|
5. Chat List - `DONE` (all/pinned/archive/sort/unread)
|
||||||
|
6. Chat Types - `DONE` (private/group/channel)
|
||||||
|
7. Chat Creation - `DONE` (private/group/channel)
|
||||||
|
8. Messages (base) - `DONE` (send/read/edit/delete/delete for all)
|
||||||
|
9. Message Types - `PARTIAL` (text/photo/video/docs/audio/voice/circle; GIF/stickers via dedicated system missing)
|
||||||
|
10. Reply/Quote/Threads - `PARTIAL` (reply + quote-like UI, no full thread model)
|
||||||
|
11. Forwarding - `PARTIAL` (single + bulk; "without author" missing)
|
||||||
|
12. Pinning - `DONE` (message/chat pin-unpin)
|
||||||
|
13. Reactions - `DONE`
|
||||||
|
14. Delivery Status - `DONE` (sent/delivered/read)
|
||||||
|
15. Typing Realtime - `PARTIAL` (typing start/stop done; voice/video typing signals limited)
|
||||||
|
16. Media & Attachments - `DONE` (upload/preview/download/gallery)
|
||||||
|
17. Voice Messages - `PARTIAL` (record/send/play/seek/speed; UX still being polished)
|
||||||
|
18. Circle Video Messages - `PARTIAL` (send/play present, recording UX basic)
|
||||||
|
19. Stickers - `TODO`
|
||||||
|
20. GIF - `TODO` (native GIF search/favorites not implemented)
|
||||||
|
21. Message History/Search - `DONE` (history/pagination/chat+global search)
|
||||||
|
22. Text Formatting - `PARTIAL` (bold/italic/underline/spoiler/mono/links supported; toolbar still evolving)
|
||||||
|
23. Groups - `PARTIAL` (create/add/remove/invite link; advanced moderation partial)
|
||||||
|
24. Roles - `DONE` (owner/admin/member)
|
||||||
|
25. Admin Rights - `PARTIAL` (delete/ban-like remove/pin/edit info; full ban system limited)
|
||||||
|
26. Channels - `PARTIAL` (create/post/edit/delete/subscribe/unsubscribe; UX edge-cases still polishing)
|
||||||
|
27. Channel Types - `DONE` (public/private)
|
||||||
|
28. Notifications - `PARTIAL` (browser notifications + mute/settings; no mobile push infra)
|
||||||
|
29. Archive - `DONE`
|
||||||
|
30. Blacklist - `DONE`
|
||||||
|
31. Privacy - `PARTIAL` (PM permission + block; full matrix controls still limited)
|
||||||
|
32. Security - `PARTIAL` (sessions + revoke + 2FA base; UX/TOTP flow ongoing)
|
||||||
|
33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates)
|
||||||
|
34. Sync - `PARTIAL` (cross-device via backend state + realtime; offline reconciliation basic)
|
||||||
|
35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic)
|
||||||
|
|
||||||
|
## Current Focus to reach ~80%
|
||||||
|
|
||||||
|
1. Complete security/privacy UX (sessions revoke behavior, TOTP QR flow, privacy matrix).
|
||||||
|
2. Finish channel/group moderation parity (ban permissions, member action polish).
|
||||||
|
3. Finalize media messaging UX parity (voice/circle controls, unified attachment behaviors).
|
||||||
|
4. Keep realtime strict consistency for all mutations (already improved for edit/delete).
|
||||||
|
5. Raise test coverage for auth/chats/messages/realtime critical paths.
|
||||||
@@ -611,7 +611,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Leave chat
|
{chat.type === "channel" ? "Leave channel" : "Leave chat"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type RecordingState = "idle" | "recording" | "locked";
|
|||||||
|
|
||||||
export function MessageComposer() {
|
export function MessageComposer() {
|
||||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||||
|
const chats = useChatStore((s) => s.chats);
|
||||||
const me = useAuthStore((s) => s.me);
|
const me = useAuthStore((s) => s.me);
|
||||||
const draftsByChat = useChatStore((s) => s.draftsByChat);
|
const draftsByChat = useChatStore((s) => s.draftsByChat);
|
||||||
const setDraft = useChatStore((s) => s.setDraft);
|
const setDraft = useChatStore((s) => s.setDraft);
|
||||||
@@ -52,6 +53,12 @@ export function MessageComposer() {
|
|||||||
const [recordSeconds, setRecordSeconds] = useState(0);
|
const [recordSeconds, setRecordSeconds] = useState(0);
|
||||||
const [dragHint, setDragHint] = useState<"idle" | "lock" | "cancel">("idle");
|
const [dragHint, setDragHint] = useState<"idle" | "lock" | "cancel">("idle");
|
||||||
const hasTextToSend = text.trim().length > 0;
|
const hasTextToSend = text.trim().length > 0;
|
||||||
|
const activeChat = chats.find((chat) => chat.id === activeChatId);
|
||||||
|
const canSendInActiveChat = Boolean(
|
||||||
|
activeChatId &&
|
||||||
|
activeChat &&
|
||||||
|
(activeChat.type !== "channel" || activeChat.my_role === "owner" || activeChat.my_role === "admin" || activeChat.is_saved)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
recordingStateRef.current = recordingState;
|
recordingStateRef.current = recordingState;
|
||||||
@@ -146,7 +153,7 @@ export function MessageComposer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
if (!activeChatId || !text.trim() || !me) {
|
if (!activeChatId || !text.trim() || !me || !canSendInActiveChat) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clientMessageId = makeClientMessageId();
|
const clientMessageId = makeClientMessageId();
|
||||||
@@ -205,7 +212,7 @@ export function MessageComposer() {
|
|||||||
messageType: "file" | "image" | "video" | "audio" | "voice" | "circle_video" = "file",
|
messageType: "file" | "image" | "video" | "audio" | "voice" | "circle_video" = "file",
|
||||||
waveformPoints?: number[] | null
|
waveformPoints?: number[] | null
|
||||||
) {
|
) {
|
||||||
if (!activeChatId || !me) {
|
if (!activeChatId || !me || !canSendInActiveChat) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
@@ -303,7 +310,7 @@ export function MessageComposer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startRecord() {
|
async function startRecord() {
|
||||||
if (recordingState !== "idle") {
|
if (recordingState !== "idle" || !canSendInActiveChat) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -694,11 +701,17 @@ export function MessageComposer() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!canSendInActiveChat && activeChat?.type === "channel" ? (
|
||||||
|
<div className="mb-2 rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||||
|
Read-only channel: only owners and admins can post.
|
||||||
|
</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
|
||||||
className="h-[42px] min-w-[42px] rounded-full bg-slate-700/85 px-3 text-sm font-semibold text-slate-200 hover:bg-slate-700 disabled:opacity-60"
|
className="h-[42px] min-w-[42px] rounded-full bg-slate-700/85 px-3 text-sm font-semibold text-slate-200 hover:bg-slate-700 disabled:opacity-60"
|
||||||
disabled={isUploading || recordingState !== "idle"}
|
disabled={isUploading || recordingState !== "idle" || !canSendInActiveChat}
|
||||||
onClick={() => setShowAttachMenu((v) => !v)}
|
onClick={() => setShowAttachMenu((v) => !v)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -728,7 +741,7 @@ export function MessageComposer() {
|
|||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
accept="image/*,video/*"
|
accept="image/*,video/*"
|
||||||
disabled={isUploading || recordingState !== "idle"}
|
disabled={isUploading || recordingState !== "idle" || !canSendInActiveChat}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const files = event.target.files ? Array.from(event.target.files) : [];
|
const files = event.target.files ? Array.from(event.target.files) : [];
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
@@ -742,7 +755,7 @@ export function MessageComposer() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
disabled={isUploading || recordingState !== "idle"}
|
disabled={isUploading || recordingState !== "idle" || !canSendInActiveChat}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const files = event.target.files ? Array.from(event.target.files) : [];
|
const files = event.target.files ? Array.from(event.target.files) : [];
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
@@ -758,6 +771,7 @@ export function MessageComposer() {
|
|||||||
onClick={() => setShowFormatMenu((v) => !v)}
|
onClick={() => setShowFormatMenu((v) => !v)}
|
||||||
type="button"
|
type="button"
|
||||||
title="Text formatting"
|
title="Text formatting"
|
||||||
|
disabled={!canSendInActiveChat}
|
||||||
>
|
>
|
||||||
Aa
|
Aa
|
||||||
</button>
|
</button>
|
||||||
@@ -765,9 +779,10 @@ export function MessageComposer() {
|
|||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
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={canSendInActiveChat ? "Write a message..." : "Read-only channel"}
|
||||||
rows={1}
|
rows={1}
|
||||||
value={text}
|
value={text}
|
||||||
|
disabled={!canSendInActiveChat}
|
||||||
onKeyDown={onComposerKeyDown}
|
onKeyDown={onComposerKeyDown}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const next = event.target.value;
|
const next = event.target.value;
|
||||||
@@ -783,7 +798,7 @@ export function MessageComposer() {
|
|||||||
{hasTextToSend ? (
|
{hasTextToSend ? (
|
||||||
<button
|
<button
|
||||||
className="h-[42px] w-[56px] rounded-full bg-sky-500 px-3 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60"
|
className="h-[42px] w-[56px] rounded-full bg-sky-500 px-3 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60"
|
||||||
disabled={recordingState !== "idle" || !activeChatId}
|
disabled={recordingState !== "idle" || !activeChatId || !canSendInActiveChat}
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
type="button"
|
type="button"
|
||||||
title="Send message"
|
title="Send message"
|
||||||
@@ -795,7 +810,7 @@ export function MessageComposer() {
|
|||||||
className={`h-[42px] w-[56px] rounded-full px-3 text-sm font-semibold ${
|
className={`h-[42px] w-[56px] rounded-full px-3 text-sm font-semibold ${
|
||||||
recordingState === "idle" ? "bg-emerald-500 text-slate-950 hover:bg-emerald-400" : "bg-slate-700 text-slate-300"
|
recordingState === "idle" ? "bg-emerald-500 text-slate-950 hover:bg-emerald-400" : "bg-slate-700 text-slate-300"
|
||||||
}`}
|
}`}
|
||||||
disabled={isUploading || !activeChatId}
|
disabled={isUploading || !activeChatId || !canSendInActiveChat}
|
||||||
onPointerDown={onMicPointerDown}
|
onPointerDown={onMicPointerDown}
|
||||||
type="button"
|
type="button"
|
||||||
title="Hold to record voice"
|
title="Hold to record voice"
|
||||||
|
|||||||
@@ -1025,6 +1025,9 @@ function canDeleteForEveryone(
|
|||||||
if (chat.type === "channel") {
|
if (chat.type === "channel") {
|
||||||
return chat.my_role === "owner" || chat.my_role === "admin";
|
return chat.my_role === "owner" || chat.my_role === "admin";
|
||||||
}
|
}
|
||||||
|
if (chat.type === "group" && (chat.my_role === "owner" || chat.my_role === "admin")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return message.sender_id === meId;
|
return message.sender_id === meId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,24 @@ export function useRealtime() {
|
|||||||
scheduleReloadChats();
|
scheduleReloadChats();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (event.event === "message_updated") {
|
||||||
|
const chatId = Number(event.payload.chat_id);
|
||||||
|
const message = event.payload.message as Message;
|
||||||
|
if (!Number.isFinite(chatId) || !message?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chatStore.upsertMessage(chatId, message);
|
||||||
|
scheduleReloadChats();
|
||||||
|
}
|
||||||
|
if (event.event === "message_deleted") {
|
||||||
|
const chatId = Number(event.payload.chat_id);
|
||||||
|
const messageId = Number(event.payload.message_id);
|
||||||
|
if (!Number.isFinite(chatId) || !Number.isFinite(messageId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chatStore.removeMessage(chatId, messageId);
|
||||||
|
scheduleReloadChats();
|
||||||
|
}
|
||||||
if (event.event === "chat_updated") {
|
if (event.event === "chat_updated") {
|
||||||
const chatId = Number(event.payload.chat_id);
|
const chatId = Number(event.payload.chat_id);
|
||||||
if (Number.isFinite(chatId)) {
|
if (Number.isFinite(chatId)) {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ interface ChatState {
|
|||||||
status: DeliveryStatus,
|
status: DeliveryStatus,
|
||||||
senderId: number
|
senderId: number
|
||||||
) => void;
|
) => void;
|
||||||
|
upsertMessage: (chatId: number, message: Message) => void;
|
||||||
removeMessage: (chatId: number, messageId: number) => void;
|
removeMessage: (chatId: number, messageId: number) => void;
|
||||||
restoreMessages: (chatId: number, messages: Message[]) => void;
|
restoreMessages: (chatId: number, messages: Message[]) => void;
|
||||||
clearChatMessages: (chatId: number) => void;
|
clearChatMessages: (chatId: number) => void;
|
||||||
@@ -299,6 +300,30 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
messagesByChat: { ...state.messagesByChat, [chatId]: next }
|
messagesByChat: { ...state.messagesByChat, [chatId]: next }
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
upsertMessage: (chatId, message) => {
|
||||||
|
const old = get().messagesByChat[chatId] ?? [];
|
||||||
|
if (!old.length) {
|
||||||
|
set((state) => ({
|
||||||
|
messagesByChat: { ...state.messagesByChat, [chatId]: [message] }
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = old.findIndex((m) => m.id === message.id);
|
||||||
|
if (idx === -1) {
|
||||||
|
const next = [...old, message].sort((a, b) => a.id - b.id);
|
||||||
|
set((state) => ({
|
||||||
|
messagesByChat: { ...state.messagesByChat, [chatId]: next }
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = [...old];
|
||||||
|
const existing = next[idx];
|
||||||
|
const deliveryStatus = mergeDeliveryStatus(message.delivery_status, existing.delivery_status);
|
||||||
|
next[idx] = deliveryStatus ? { ...message, delivery_status: deliveryStatus } : message;
|
||||||
|
set((state) => ({
|
||||||
|
messagesByChat: { ...state.messagesByChat, [chatId]: next }
|
||||||
|
}));
|
||||||
|
},
|
||||||
removeMessage: (chatId, messageId) => {
|
removeMessage: (chatId, messageId) => {
|
||||||
const old = get().messagesByChat[chatId] ?? [];
|
const old = get().messagesByChat[chatId] ?? [];
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user