mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Add private messages
This commit is contained in:
@@ -6,3 +6,4 @@ When you work with UI:
|
||||
|
||||
1. Use MDUI components as HTML elements
|
||||
2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML.
|
||||
3. The supporting text slot for MDUI lists is "description".
|
||||
@@ -38,6 +38,36 @@ class Message(Base):
|
||||
reply_to = relationship("Message", remote_side=[id])
|
||||
|
||||
|
||||
class CryptoPublicKey(Base):
|
||||
__tablename__ = "crypto_public_key"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||
public_key_b64 = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class CryptoBackup(Base):
|
||||
__tablename__ = "crypto_backup"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||
blob_json = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class DMEnvelope(Base):
|
||||
__tablename__ = "dm_envelope"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
iv_b64 = Column(Text, nullable=False)
|
||||
ciphertext_b64 = Column(Text, nullable=False)
|
||||
salt_b64 = Column(Text, nullable=False)
|
||||
iv2_b64 = Column(Text, nullable=False)
|
||||
wrapped_mk_b64 = Column(Text, nullable=False)
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
|
||||
# Pydantic модели
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
|
||||
@@ -2,10 +2,9 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from routes.messaging import convert_message
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import LoginRequest, RegisterRequest, User
|
||||
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup
|
||||
from utils import create_token, get_password_hash, verify_password
|
||||
from validation import is_valid_password, is_valid_username
|
||||
|
||||
@@ -121,6 +120,48 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crypto/public-key")
|
||||
def get_public_key(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
||||
return {"publicKey": row.public_key_b64 if row else None}
|
||||
|
||||
|
||||
@router.post("/crypto/public-key")
|
||||
def set_public_key(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
pk = payload.get("publicKey")
|
||||
if not pk:
|
||||
raise HTTPException(status_code=400, detail="publicKey required")
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
||||
if row:
|
||||
row.public_key_b64 = pk
|
||||
else:
|
||||
row = CryptoPublicKey(user_id=current_user.id, public_key_b64=pk)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/crypto/backup")
|
||||
def get_backup(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
||||
return {"blob": row.blob_json if row else None}
|
||||
|
||||
|
||||
@router.post("/crypto/backup")
|
||||
def set_backup(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
blob = payload.get("blob")
|
||||
if not blob:
|
||||
raise HTTPException(status_code=400, detail="blob required")
|
||||
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
||||
if row:
|
||||
row.blob_json = blob
|
||||
else:
|
||||
row = CryptoBackup(user_id=current_user.id, blob_json=blob)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/admin/user/{user_id}")
|
||||
def delete_user_as_owner(
|
||||
user_id: int,
|
||||
@@ -161,3 +202,19 @@ def logout(
|
||||
"status": "success",
|
||||
"message": "Logged out successfully"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
users = db.query(User).order_by(User.username.asc()).all()
|
||||
return {
|
||||
"users": [
|
||||
convert_user(u) for u in users if u.id != current_user.id
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crypto/public-key/of/{user_id}")
|
||||
def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
|
||||
return {"publicKey": row.public_key_b64 if row else None}
|
||||
+133
-1
@@ -5,7 +5,7 @@ from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from constants import OWNER_USERNAME
|
||||
from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User
|
||||
from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User, DMEnvelope
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
@@ -68,6 +68,82 @@ async def get_messages(db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/dm/send")
|
||||
async def dm_send(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
|
||||
for key in required:
|
||||
if key not in payload:
|
||||
raise HTTPException(status_code=400, detail=f"Missing {key}")
|
||||
env = DMEnvelope(
|
||||
sender_id=current_user.id,
|
||||
recipient_id=int(payload["recipientId"]),
|
||||
iv_b64=payload["iv"],
|
||||
ciphertext_b64=payload["ciphertext"],
|
||||
salt_b64=payload["salt"],
|
||||
iv2_b64=payload["iv2"],
|
||||
wrapped_mk_b64=payload["wrappedMk"],
|
||||
)
|
||||
db.add(env)
|
||||
db.commit()
|
||||
db.refresh(env)
|
||||
return {"status": "ok", "id": env.id}
|
||||
|
||||
|
||||
@router.get("/dm/fetch")
|
||||
async def dm_fetch(since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
q = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id)
|
||||
if since:
|
||||
q = q.filter(DMEnvelope.id > since)
|
||||
envs = q.order_by(DMEnvelope.id.asc()).all()
|
||||
return {
|
||||
"status": "ok",
|
||||
"messages": [
|
||||
{
|
||||
"id": e.id,
|
||||
"senderId": e.sender_id,
|
||||
"recipientId": e.recipient_id,
|
||||
"iv": e.iv_b64,
|
||||
"ciphertext": e.ciphertext_b64,
|
||||
"salt": e.salt_b64,
|
||||
"iv2": e.iv2_b64,
|
||||
"wrappedMk": e.wrapped_mk_b64,
|
||||
"timestamp": e.timestamp.isoformat(),
|
||||
}
|
||||
for e in envs
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dm/history/{other_user_id}")
|
||||
async def dm_history(other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
envs = (
|
||||
db.query(DMEnvelope)
|
||||
.filter(
|
||||
((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id))
|
||||
| ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id))
|
||||
)
|
||||
.order_by(DMEnvelope.id.asc())
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"messages": [
|
||||
{
|
||||
"id": e.id,
|
||||
"senderId": e.sender_id,
|
||||
"recipientId": e.recipient_id,
|
||||
"iv": e.iv_b64,
|
||||
"ciphertext": e.ciphertext_b64,
|
||||
"salt": e.salt_b64,
|
||||
"iv2": e.iv2_b64,
|
||||
"wrappedMk": e.wrapped_mk_b64,
|
||||
"timestamp": e.timestamp.isoformat(),
|
||||
}
|
||||
for e in envs
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.put("/edit_message/{message_id}")
|
||||
async def edit_message(
|
||||
message_id: int,
|
||||
@@ -147,6 +223,7 @@ async def reply_message(
|
||||
class MessaggingSocketManager:
|
||||
def __init__(self) -> None:
|
||||
self.connections: list[WebSocket] = []
|
||||
self.user_by_ws: dict[WebSocket, int] = {}
|
||||
|
||||
async def send_error(self, websocket: WebSocket, type: str, e: HTTPException):
|
||||
await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}})
|
||||
@@ -169,12 +246,19 @@ class MessaggingSocketManager:
|
||||
return None
|
||||
|
||||
if type == "ping":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if current_user:
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
except HTTPException:
|
||||
pass
|
||||
await websocket.send_json({"type": "ping", "data": {"status": "success"}})
|
||||
elif type == "getMessages":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
|
||||
await websocket.send_json({"type": type, "data": await get_messages(current_user, db)})
|
||||
except HTTPException as e:
|
||||
@@ -184,6 +268,7 @@ class MessaggingSocketManager:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
|
||||
request: SendMessageRequest = SendMessageRequest.model_validate(data["data"])
|
||||
|
||||
@@ -196,6 +281,46 @@ class MessaggingSocketManager:
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "dmSend":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
payload = data["data"]
|
||||
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
|
||||
for key in required:
|
||||
if key not in payload:
|
||||
raise HTTPException(status_code=400, detail=f"Missing {key}")
|
||||
env = DMEnvelope(
|
||||
sender_id=current_user.id,
|
||||
recipient_id=int(payload["recipientId"]),
|
||||
iv_b64=payload["iv"],
|
||||
ciphertext_b64=payload["ciphertext"],
|
||||
salt_b64=payload["salt"],
|
||||
iv2_b64=payload["iv2"],
|
||||
wrapped_mk_b64=payload["wrappedMk"],
|
||||
)
|
||||
db.add(env)
|
||||
db.commit()
|
||||
db.refresh(env)
|
||||
await self.send_to_user(env.recipient_id, {
|
||||
"type": "dmNew",
|
||||
"data": {
|
||||
"id": env.id,
|
||||
"senderId": env.sender_id,
|
||||
"recipientId": env.recipient_id,
|
||||
"iv": env.iv_b64,
|
||||
"ciphertext": env.ciphertext_b64,
|
||||
"salt": env.salt_b64,
|
||||
"iv2": env.iv2_b64,
|
||||
"wrappedMk": env.wrapped_mk_b64,
|
||||
"timestamp": env.timestamp.isoformat(),
|
||||
}
|
||||
})
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "editMessage":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
@@ -264,11 +389,18 @@ class MessaggingSocketManager:
|
||||
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
|
||||
finally:
|
||||
self.connections.remove(websocket)
|
||||
if websocket in self.user_by_ws:
|
||||
del self.user_by_ws[websocket]
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
for websocket in self.connections:
|
||||
await websocket.send_json(message)
|
||||
|
||||
async def send_to_user(self, user_id: int, message: dict):
|
||||
for websocket in self.connections:
|
||||
if self.user_by_ws.get(websocket) == user_id:
|
||||
await websocket.send_json(message)
|
||||
|
||||
messagingManager = MessaggingSocketManager()
|
||||
|
||||
@router.websocket("/chat/ws")
|
||||
|
||||
@@ -147,6 +147,9 @@
|
||||
<mdui-tab value="contacts">
|
||||
Контакты
|
||||
</mdui-tab>
|
||||
<mdui-tab value="dms">
|
||||
ЛС
|
||||
</mdui-tab>
|
||||
|
||||
<mdui-tab-panel slot="panel" value="chats">
|
||||
<mdui-list>
|
||||
@@ -160,6 +163,9 @@
|
||||
</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="dms">
|
||||
<mdui-list id="dm-users"></mdui-list>
|
||||
</mdui-tab-panel>
|
||||
</mdui-tabs>
|
||||
</div>
|
||||
<mdui-bottom-app-bar>
|
||||
@@ -411,6 +417,12 @@
|
||||
<span class="stat-value last-seen"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-actions">
|
||||
<mdui-button id="dm-button" variant="filled">
|
||||
<mdui-icon slot="icon" name="chat--filled"></mdui-icon>
|
||||
Send Message
|
||||
</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mdui-dialog>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { showLogin } from "../navigation";
|
||||
import type { Headers, User } from "../core/types";
|
||||
import type { Headers, User, WebSocketMessage } from "../core/types";
|
||||
import { clearAlerts } from "./auth";
|
||||
import { request } from "../websocket";
|
||||
|
||||
/**
|
||||
* Current authenticated user information
|
||||
@@ -23,6 +24,21 @@ export let authToken: string | null = null;
|
||||
export function setUser(token: string, user: User) {
|
||||
authToken = token
|
||||
currentUser = user
|
||||
|
||||
try {
|
||||
const payload: WebSocketMessage = {
|
||||
type: "ping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: {}
|
||||
}
|
||||
|
||||
request(payload).then(() => {
|
||||
console.log("Ping succeeded")
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
import { initializeProfile } from "../userPanel/profile/profile";
|
||||
import type { ErrorResponse, LoginResponse, LoginRequest, RegisterRequest } from "../core/types";
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { loadChat, showLogin, showRegister } from "../navigation";
|
||||
import { showChat, showLogin, showRegister } from "../navigation";
|
||||
import { setUser } from "./api";
|
||||
import { ensureKeysOnLogin } from "./crypto";
|
||||
import { id } from "../utils/utils";
|
||||
|
||||
/**
|
||||
@@ -71,7 +72,12 @@ async function handleLogin(e: Event): Promise<void> {
|
||||
const data: LoginResponse = await response.json();
|
||||
// Store the JWT token
|
||||
setUser(data.token, data.user)
|
||||
loadChat();
|
||||
try {
|
||||
await ensureKeysOnLogin(password);
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
showChat();
|
||||
initializeProfile(); // Initialize profile after login
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { getAuthHeaders } from "./api";
|
||||
import { generateX25519KeyPair } from "../crypto/asymmetric";
|
||||
import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../crypto/backup";
|
||||
import { b64, ub64 } from "../utils/utils";
|
||||
import type { BackupBlob, UploadPublicKeyRequest } from "../core/types";
|
||||
|
||||
let currentPublicKey: Uint8Array | null = null;
|
||||
let currentPrivateKey: Uint8Array | null = null;
|
||||
|
||||
async function fetchPublicKey(): Promise<Uint8Array | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers: getAuthHeaders(true) });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data?.publicKey) return null;
|
||||
return ub64(data.publicKey);
|
||||
}
|
||||
|
||||
async function uploadPublicKey(publicKey: Uint8Array): Promise<void> {
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
}
|
||||
|
||||
await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(true),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchBackupBlob(): Promise<string | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "GET",
|
||||
headers: getAuthHeaders(true)
|
||||
});
|
||||
if (res.ok) {
|
||||
const response: BackupBlob = await res.json();
|
||||
return response.blob;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBackupBlob(blobJson: string): Promise<void> {
|
||||
const payload: BackupBlob = { blob: blobJson }
|
||||
|
||||
await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(true),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export interface UserKeyPairMemory {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
export function getCurrentKeys(): UserKeyPairMemory | null {
|
||||
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function ensureKeysOnLogin(password: string): Promise<UserKeyPairMemory> {
|
||||
// Try to restore from backup
|
||||
const blobJson = await fetchBackupBlob();
|
||||
if (blobJson) {
|
||||
const blob = decodeBlob(blobJson);
|
||||
const bundle = await decryptBackupWithPassword(password, blob);
|
||||
currentPrivateKey = bundle.privateKey;
|
||||
// Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous
|
||||
// In our simple scheme, we rely on server having the public key or we reupload generated one on first setup
|
||||
const serverPub = await fetchPublicKey();
|
||||
if (serverPub) {
|
||||
currentPublicKey = serverPub;
|
||||
} else {
|
||||
// We don't have the corresponding public key from server; regenerate pair to resync
|
||||
const pair = generateX25519KeyPair();
|
||||
currentPublicKey = pair.publicKey;
|
||||
currentPrivateKey = pair.privateKey;
|
||||
await uploadPublicKey(currentPublicKey);
|
||||
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(newBlob));
|
||||
}
|
||||
return { publicKey: currentPublicKey!, privateKey: currentPrivateKey! };
|
||||
}
|
||||
|
||||
// First-time setup: generate keys and upload
|
||||
const pair = generateX25519KeyPair();
|
||||
currentPublicKey = pair.publicKey;
|
||||
currentPrivateKey = pair.privateKey;
|
||||
await uploadPublicKey(currentPublicKey);
|
||||
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(encBlob));
|
||||
return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
|
||||
}
|
||||
|
||||
|
||||
+31
-66
@@ -6,13 +6,14 @@
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { websocket } from "../websocket";
|
||||
import { request } from "../websocket";
|
||||
import type { Message, Messages, WebSocketMessage } from "../core/types";
|
||||
import { formatTime } from "../utils/utils";
|
||||
import { show as showContextMenu } from "./contextMenu";
|
||||
import { show as showUserProfileDialog } from "./profileDialog";
|
||||
import defaultAvatar from "../resources/images/default-avatar.png";
|
||||
import { authToken, currentUser, getAuthHeaders } from "../auth/api";
|
||||
import { ChatPanelController, PublicChatPanel } from "./panel";
|
||||
|
||||
/**
|
||||
* Adds a new message to the chat interface
|
||||
@@ -122,43 +123,15 @@ export function addMessage(message: Message, isAuthor: boolean): void {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads chat messages from the server
|
||||
*/
|
||||
export function loadMessages(): void {
|
||||
fetch(`${API_BASE_URL}/get_messages`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then((data: Messages) => {
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
const messagesContainer = document.querySelector('.chat-messages') as HTMLElement;
|
||||
|
||||
const lastMessage = messagesContainer.lastElementChild as HTMLElement
|
||||
let lastMessageId: number = 0
|
||||
if (lastMessage) {
|
||||
lastMessageId = Number(lastMessage.dataset.id)
|
||||
}
|
||||
|
||||
// Добавляем только новые сообщения
|
||||
data.messages.forEach(msg => {
|
||||
if (msg.id > lastMessageId) {
|
||||
addMessage(msg, msg.username == currentUser!.username);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message via WebSocket
|
||||
*/
|
||||
export function sendMessage(): void {
|
||||
export async function sendMessage(): Promise<void> {
|
||||
const input = document.querySelector('.message-input') as HTMLInputElement;
|
||||
const message = input.value.trim();
|
||||
|
||||
if (message) {
|
||||
const payload: WebSocketMessage = {
|
||||
const response = await request({
|
||||
data: {
|
||||
content: message
|
||||
},
|
||||
@@ -167,29 +140,15 @@ export function sendMessage(): void {
|
||||
credentials: authToken!
|
||||
},
|
||||
type: "sendMessage"
|
||||
}
|
||||
})
|
||||
|
||||
let callback: ((e: MessageEvent) => void) | null = null
|
||||
callback = (e) => {
|
||||
websocket.removeEventListener("message", callback!);
|
||||
const response: WebSocketMessage = JSON.parse(e.data)
|
||||
console.log(response)
|
||||
if (!response.error) {
|
||||
input.value = "";
|
||||
}
|
||||
console.log(response)
|
||||
if (!response.error) {
|
||||
input.value = "";
|
||||
}
|
||||
websocket.addEventListener("message", callback);
|
||||
|
||||
websocket.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
document.getElementById('message-form')!.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates an existing message in the chat interface
|
||||
* @param {Message} message - Updated message object
|
||||
@@ -230,22 +189,28 @@ export function removeMessage(messageId: number): void {
|
||||
* @param {WebSocketMessage} response - WebSocket response
|
||||
*/
|
||||
export function handleWebSocketMessage(response: WebSocketMessage): void {
|
||||
switch (response.type) {
|
||||
case 'messageEdited':
|
||||
if (response.data) {
|
||||
updateMessage(response.data);
|
||||
}
|
||||
break;
|
||||
case 'messageDeleted':
|
||||
if (response.data && response.data.message_id) {
|
||||
removeMessage(response.data.message_id);
|
||||
}
|
||||
break;
|
||||
case 'newMessage':
|
||||
if (response.data) {
|
||||
const isAuthor = response.data.username === currentUser?.username;
|
||||
addMessage(response.data, isAuthor);
|
||||
}
|
||||
break;
|
||||
if (ChatPanelController.active == publicChatPanel) {
|
||||
switch (response.type) {
|
||||
case 'messageEdited':
|
||||
if (response.data) {
|
||||
updateMessage(response.data);
|
||||
}
|
||||
break;
|
||||
case 'messageDeleted':
|
||||
if (response.data && response.data.message_id) {
|
||||
removeMessage(response.data.message_id);
|
||||
}
|
||||
break;
|
||||
case 'newMessage':
|
||||
if (response.data) {
|
||||
const isAuthor = response.data.username === currentUser?.username;
|
||||
addMessage(response.data, isAuthor);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const publicChatPanel = new PublicChatPanel();
|
||||
|
||||
publicChatPanel.activate();
|
||||
@@ -5,8 +5,8 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { websocket } from "../websocket";
|
||||
import type { Message, WebSocketMessage } from "../core/types";
|
||||
import { request } from "../websocket";
|
||||
import type { Message } from "../core/types";
|
||||
import { showSuccess, showError } from "../utils/notification";
|
||||
import { delay, id } from "../utils/utils";
|
||||
import type { Dialog } from "mdui/components/dialog";
|
||||
@@ -85,8 +85,15 @@ export function show(message: Message, x: number, y: number): void {
|
||||
const isAuthor = message.username === currentUser?.username;
|
||||
const isOwner = currentUser?.admin;
|
||||
|
||||
// Check if we're in a DM conversation
|
||||
const isInDm = document.querySelector('.chat-tabs mdui-tab[value="dms"]')?.getAttribute('active') === 'true';
|
||||
|
||||
// Show edit only for own messages
|
||||
editItem.style.display = isAuthor ? 'flex' : 'none';
|
||||
deleteItem.style.display = (isAuthor || isOwner) ? 'flex' : 'none';
|
||||
|
||||
// Show delete for own messages, admin on any message, or in DMs for any message
|
||||
const canDelete = isAuthor || isOwner || isInDm;
|
||||
deleteItem.style.display = canDelete ? 'flex' : 'none';
|
||||
|
||||
// Position the menu properly
|
||||
menu.style.display = 'block';
|
||||
@@ -184,7 +191,7 @@ function hideEditDialog(): void {
|
||||
* Saves the edited message
|
||||
* @private
|
||||
*/
|
||||
function saveEdit(): void {
|
||||
async function saveEdit(): Promise<void> {
|
||||
if (!currentMessage) return;
|
||||
|
||||
const textField = editDialog.querySelector('#edit-message-input') as TextField;
|
||||
@@ -195,7 +202,7 @@ function saveEdit(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: WebSocketMessage = {
|
||||
const response = await request({
|
||||
type: "editMessage",
|
||||
data: {
|
||||
message_id: currentMessage.id,
|
||||
@@ -205,22 +212,14 @@ function saveEdit(): void {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken!
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
let callback: ((e: MessageEvent) => void) | null = null;
|
||||
callback = (e) => {
|
||||
websocket.removeEventListener("message", callback!);
|
||||
const response: WebSocketMessage = JSON.parse(e.data);
|
||||
|
||||
if (response.error) {
|
||||
showError(response.error.detail);
|
||||
} else {
|
||||
showSuccess('Message edited successfully');
|
||||
hideEditDialog();
|
||||
}
|
||||
};
|
||||
websocket.addEventListener("message", callback);
|
||||
websocket.send(JSON.stringify(payload));
|
||||
if (response.error) {
|
||||
showError(response.error.detail);
|
||||
} else {
|
||||
showSuccess('Message edited successfully');
|
||||
hideEditDialog();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,7 +256,7 @@ function hideReplyDialog(): void {
|
||||
* Sends the reply message
|
||||
* @private
|
||||
*/
|
||||
function sendReply(): void {
|
||||
async function sendReply(): Promise<void> {
|
||||
if (!currentMessage) return;
|
||||
|
||||
const textField = replyDialog.querySelector('#reply-message-input') as TextField;
|
||||
@@ -268,7 +267,7 @@ function sendReply(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: WebSocketMessage = {
|
||||
const response = await request({
|
||||
type: "replyMessage",
|
||||
data: {
|
||||
content: content,
|
||||
@@ -278,25 +277,17 @@ function sendReply(): void {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken!
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
let callback: ((e: MessageEvent) => void) | null = null;
|
||||
callback = (e) => {
|
||||
websocket.removeEventListener("message", callback!);
|
||||
const response: WebSocketMessage = JSON.parse(e.data);
|
||||
|
||||
if (response.error) {
|
||||
showError(response.error.detail);
|
||||
} else {
|
||||
showSuccess('Reply sent successfully');
|
||||
hideReplyDialog();
|
||||
if (textField) {
|
||||
textField.value = '';
|
||||
}
|
||||
if (response.error) {
|
||||
showError(response.error.detail);
|
||||
} else {
|
||||
showSuccess('Reply sent successfully');
|
||||
hideReplyDialog();
|
||||
if (textField) {
|
||||
textField.value = '';
|
||||
}
|
||||
};
|
||||
websocket.addEventListener("message", callback);
|
||||
websocket.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,12 +295,12 @@ function sendReply(): void {
|
||||
* @param {Message} message - The message to delete
|
||||
* @private
|
||||
*/
|
||||
function deleteMessage(message: Message): void {
|
||||
async function deleteMessage(message: Message): Promise<void> {
|
||||
if (!confirm('Are you sure you want to delete this message?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: WebSocketMessage = {
|
||||
const response = await request({
|
||||
type: "deleteMessage",
|
||||
data: {
|
||||
message_id: message.id
|
||||
@@ -318,21 +309,13 @@ function deleteMessage(message: Message): void {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken!
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
let callback: ((e: MessageEvent) => void) | null = null;
|
||||
callback = (e) => {
|
||||
websocket.removeEventListener("message", callback!);
|
||||
const response: WebSocketMessage = JSON.parse(e.data);
|
||||
|
||||
if (response.error) {
|
||||
showError(response.error.detail);
|
||||
} else {
|
||||
showSuccess('Message deleted successfully');
|
||||
}
|
||||
};
|
||||
websocket.addEventListener("message", callback);
|
||||
websocket.send(JSON.stringify(payload));
|
||||
if (response.error) {
|
||||
showError(response.error.detail);
|
||||
} else {
|
||||
showSuccess('Message deleted successfully');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,392 @@
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { authToken, getAuthHeaders, currentUser } from "../auth/api";
|
||||
import { DmPanel } from "./panel";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric";
|
||||
import { randomBytes } from "../crypto/kdf";
|
||||
import { getCurrentKeys } from "../auth/crypto";
|
||||
import { request, websocket } from "../websocket";
|
||||
import type { FetchDMResponse, SendDMRequest, WebSocketMessage, User, DmEnvelope } from "../core/types";
|
||||
import type { Tabs } from "mdui/components/tabs";
|
||||
import { b64, ub64 } from "../utils/utils";
|
||||
|
||||
export async function sendDm(recipientId: number, recipientPublicKeyB64: string, plaintext: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(true),
|
||||
body: JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function fetchDm(since?: number): Promise<DmEnvelope[]> {
|
||||
const url = new URL(`${API_BASE_URL}/dm/fetch`);
|
||||
if (since) url.searchParams.set("since", String(since));
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: getAuthHeaders(true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: FetchDMResponse = await response.json();
|
||||
return data.messages ?? [];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Obtain the key
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
||||
|
||||
// Decrypt
|
||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
||||
return new TextDecoder().decode(msg);
|
||||
}
|
||||
|
||||
let activeDm: { userId: number; username: string; publicKey: string | null } | null = null;
|
||||
let usersLoaded = false;
|
||||
let dmPanel: DmPanel | null = null;
|
||||
const dmBadgeByUserId: Map<number, HTMLElement> = new Map();
|
||||
const dmSupportingTextByUserId: Map<number, HTMLElement> = new Map();
|
||||
|
||||
function getLastReadId(userId: number): number {
|
||||
try {
|
||||
const v = localStorage.getItem(`dmLastRead:${userId}`);
|
||||
return v ? Number(v) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setLastReadId(userId: number, id: number): void {
|
||||
try {
|
||||
localStorage.setItem(`dmLastRead:${userId}`, String(id));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(true) });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const list = document.getElementById("dm-users")!;
|
||||
list.innerHTML = "";
|
||||
(data.users || []).forEach((u: User) => {
|
||||
const item = document.createElement("mdui-list-item");
|
||||
item.id = `dm-user-${u.id}`;
|
||||
|
||||
// Add avatar
|
||||
const avatar = document.createElement("img");
|
||||
avatar.src = u.profile_picture || "./src/resources/images/default-avatar.png";
|
||||
avatar.alt = u.username;
|
||||
avatar.slot = "icon";
|
||||
avatar.style.width = "40px";
|
||||
avatar.style.height = "40px";
|
||||
avatar.style.borderRadius = "50%";
|
||||
avatar.style.objectFit = "cover";
|
||||
|
||||
// Handle avatar load error
|
||||
avatar.addEventListener("error", () => {
|
||||
avatar.src = "./src/resources/images/default-avatar.png";
|
||||
});
|
||||
|
||||
item.appendChild(avatar);
|
||||
|
||||
// Set headline (username)
|
||||
item.setAttribute("headline", u.username);
|
||||
|
||||
// Add supporting text container (hidden until loaded)
|
||||
const lastMessageEl = document.createElement("div");
|
||||
lastMessageEl.slot = "description";
|
||||
lastMessageEl.style.fontSize = "12px";
|
||||
lastMessageEl.style.color = "var(--mdui-color-on-surface-variant)";
|
||||
lastMessageEl.style.whiteSpace = "pre-line";
|
||||
lastMessageEl.style.display = "none";
|
||||
item.appendChild(lastMessageEl);
|
||||
dmSupportingTextByUserId.set(u.id, lastMessageEl);
|
||||
|
||||
// Add unread badge (hidden by default)
|
||||
const badge = document.createElement("mdui-badge");
|
||||
badge.setAttribute("slot", "end-icon");
|
||||
badge.style.display = "none";
|
||||
item.appendChild(badge);
|
||||
dmBadgeByUserId.set(u.id, badge);
|
||||
|
||||
// Load last message when element becomes visible
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
loadLastMessage(u.id);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(item);
|
||||
|
||||
item.addEventListener("click", async () => {
|
||||
activeDm = { userId: u.id, username: u.username, publicKey: null };
|
||||
if (!dmPanel) {
|
||||
dmPanel = new DmPanel(
|
||||
async (text: string) => {
|
||||
if (activeDm?.publicKey) {
|
||||
// WebSocket realtime send
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) return;
|
||||
|
||||
// Encryption key
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(activeDm.publicKey));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the message
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(text));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: activeDm.userId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
}
|
||||
|
||||
request({
|
||||
type: "dmSend",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken!
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
// Load DM history for the active conversation
|
||||
if (!activeDm?.publicKey || !dmPanel) return;
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${activeDm.userId}`, {
|
||||
headers: getAuthHeaders(true)
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const messages: DmEnvelope[] = data.messages || [];
|
||||
const container = document.getElementById("chat-messages")!;
|
||||
container.innerHTML = "";
|
||||
let maxIncomingId = 0;
|
||||
for (const env of messages) {
|
||||
try {
|
||||
// Always use other user's public key for ECDH (our private + their public)
|
||||
const text = await decryptDm(env, activeDm.publicKey!);
|
||||
const isAuthor = env.senderId !== activeDm.userId;
|
||||
const username = isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown");
|
||||
dmPanel.appendMessageWithId({
|
||||
id: env.id,
|
||||
content: text,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false
|
||||
});
|
||||
if (env.senderId === activeDm.userId && env.id > maxIncomingId) {
|
||||
maxIncomingId = env.id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error while loading message:", e);
|
||||
}
|
||||
}
|
||||
container.scrollTop = container.scrollHeight;
|
||||
if (maxIncomingId > 0) {
|
||||
setLastReadId(activeDm.userId, maxIncomingId);
|
||||
const badgeEl = dmBadgeByUserId.get(activeDm.userId);
|
||||
if (badgeEl) {
|
||||
badgeEl.style.display = "none";
|
||||
badgeEl.textContent = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
dmPanel.setOtherUser(u.username);
|
||||
dmPanel.setTitle(u.username);
|
||||
dmPanel.clearMessages();
|
||||
|
||||
// Add profile click functionality to chat header
|
||||
const chatHeaderAvatar = document.querySelector('.chat-header-avatar') as HTMLElement;
|
||||
if (chatHeaderAvatar) {
|
||||
chatHeaderAvatar.style.cursor = 'pointer';
|
||||
chatHeaderAvatar.onclick = () => dmPanel?.onProfileClicked();
|
||||
}
|
||||
|
||||
const resPk = await fetch(`${API_BASE_URL}/crypto/public-key/of/${u.id}`, { headers: getAuthHeaders(true) });
|
||||
if (resPk.ok) {
|
||||
const pkData = await resPk.json();
|
||||
activeDm!.publicKey = pkData.publicKey;
|
||||
}
|
||||
|
||||
// Only activate after we have the public key so loader can decrypt
|
||||
dmPanel.activate();
|
||||
// Clear unread badge on open
|
||||
const badgeEl = dmBadgeByUserId.get(u.id);
|
||||
if (badgeEl) {
|
||||
badgeEl.textContent = "";
|
||||
badgeEl.style.display = "none";
|
||||
}
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLastMessage(userId: number): Promise<void> {
|
||||
try {
|
||||
const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(true) });
|
||||
if (!pkRes.ok) return;
|
||||
const pkData = await pkRes.json();
|
||||
const otherPk = pkData.publicKey as string;
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=50`, {
|
||||
headers: getAuthHeaders(true)
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const messages: DmEnvelope[] = data.messages || [];
|
||||
const supporting = dmSupportingTextByUserId.get(userId);
|
||||
const badgeEl = dmBadgeByUserId.get(userId);
|
||||
if (!supporting || !badgeEl) return;
|
||||
let lastPlaintext: string | null = null;
|
||||
let lastEnv: DmEnvelope | null = null;
|
||||
for (const env of messages) {
|
||||
if (!lastEnv || env.id > lastEnv.id) lastEnv = env;
|
||||
}
|
||||
if (lastEnv) {
|
||||
try { lastPlaintext = await decryptDm(lastEnv, otherPk); } catch {}
|
||||
}
|
||||
if (lastPlaintext && lastPlaintext.trim().length > 0) {
|
||||
const lines = lastPlaintext.split(/\r?\n/).slice(0, 2);
|
||||
supporting.textContent = lines.join("\n");
|
||||
supporting.style.display = "block";
|
||||
} else {
|
||||
supporting.textContent = "";
|
||||
supporting.style.display = "none";
|
||||
}
|
||||
const lastRead = getLastReadId(userId);
|
||||
let unread = 0;
|
||||
for (const env of messages) {
|
||||
if (env.senderId === userId && env.id > lastRead) unread++;
|
||||
}
|
||||
if (unread > 0) {
|
||||
badgeEl.textContent = String(unread);
|
||||
badgeEl.style.display = "inline-flex";
|
||||
} else {
|
||||
badgeEl.textContent = "";
|
||||
badgeEl.style.display = "none";
|
||||
}
|
||||
} else {
|
||||
const supporting = dmSupportingTextByUserId.get(userId);
|
||||
if (supporting) {
|
||||
supporting.textContent = "";
|
||||
supporting.style.display = "none";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const supporting = dmSupportingTextByUserId.get(userId);
|
||||
if (supporting) {
|
||||
supporting.textContent = "";
|
||||
supporting.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
const tabs = document.querySelector(".chat-tabs mdui-tabs") as Tabs;
|
||||
const dmTab = tabs?.querySelector('mdui-tab[value="dms"]')!;
|
||||
function ensureUsersLoaded() {
|
||||
if (!usersLoaded) {
|
||||
usersLoaded = true;
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
dmTab.addEventListener("click", ensureUsersLoaded);
|
||||
tabs.addEventListener("change", (e: any) => {
|
||||
if (e.detail?.value === "dms") {
|
||||
ensureUsersLoaded();
|
||||
dmPanel?.activate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
// realtime incoming DMs
|
||||
websocket.addEventListener("message", async (e) => {
|
||||
try {
|
||||
const msg: WebSocketMessage = JSON.parse((e as MessageEvent).data);
|
||||
if (msg.type === "dmNew") {
|
||||
if (activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) {
|
||||
// Always use other user's public key (our private is implied by getCurrentKeys)
|
||||
const plaintext = await decryptDm(msg.data, activeDm.publicKey!);
|
||||
if (dmPanel) {
|
||||
const isAuthor = msg.data.senderId !== activeDm.userId;
|
||||
dmPanel.appendMessageWithId({
|
||||
id: msg.data.id,
|
||||
content: plaintext,
|
||||
username: isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"),
|
||||
timestamp: msg.data.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false
|
||||
});
|
||||
}
|
||||
if (msg.data.senderId === activeDm.userId) {
|
||||
setLastReadId(activeDm.userId, Math.max(getLastReadId(activeDm.userId), msg.data.id));
|
||||
}
|
||||
} else {
|
||||
const otherUserId = msg.data.senderId;
|
||||
const badgeEl = dmBadgeByUserId.get(otherUserId);
|
||||
if (badgeEl) {
|
||||
const current = Number(badgeEl.textContent || 0);
|
||||
const next = (current || 0) + 1;
|
||||
badgeEl.textContent = String(next);
|
||||
badgeEl.style.display = "inline-flex";
|
||||
}
|
||||
try {
|
||||
const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key/of/${otherUserId}`, { headers: getAuthHeaders(true) });
|
||||
if (pkRes.ok) {
|
||||
const pkData = await pkRes.json();
|
||||
const plaintext = await decryptDm(msg.data, pkData.publicKey);
|
||||
const supporting = dmSupportingTextByUserId.get(otherUserId);
|
||||
if (supporting && plaintext) {
|
||||
const lines = plaintext.split(/\r?\n/).slice(0, 2);
|
||||
supporting.textContent = lines.join("\n");
|
||||
supporting.style.display = lines.length ? "block" : "none";
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { authToken, currentUser, getAuthHeaders } from "../auth/api";
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import type { Message, Messages, WebSocketMessage } from "../core/types";
|
||||
import { request } from "../websocket";
|
||||
import { addMessage } from "./chat";
|
||||
import { show as showContextMenu } from "./contextMenu";
|
||||
import { show as showProfileDialog } from "./profileDialog";
|
||||
|
||||
const titleEl = document.getElementById("chat-name")!;
|
||||
const messages = document.getElementById("chat-messages")!;
|
||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||
const form = document.getElementById("message-form") as HTMLFormElement;
|
||||
|
||||
export abstract class ChatPanelController {
|
||||
static active: ChatPanelController | null = null;
|
||||
static mounted = false;
|
||||
|
||||
activate(): void {
|
||||
ChatPanelController.active = this;
|
||||
if (currentUser) {
|
||||
this.loadMessages();
|
||||
}
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
titleEl.textContent = title;
|
||||
}
|
||||
|
||||
clearMessages(): void {
|
||||
messages.innerHTML = "";
|
||||
}
|
||||
|
||||
appendSimple(text: string, isAuthor: boolean): void {
|
||||
const div = document.createElement("div");
|
||||
div.className = `message ${isAuthor ? "sent" : "received"}`;
|
||||
const inner = document.createElement("div");
|
||||
inner.className = "message-inner";
|
||||
const content = document.createElement("div");
|
||||
content.className = "message-content";
|
||||
content.textContent = text;
|
||||
inner.appendChild(content);
|
||||
div.appendChild(inner);
|
||||
messages.appendChild(div);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
protected abstract onSubmit(text: string): void | Promise<void>;
|
||||
protected abstract loadMessages(): void | Promise<void>;
|
||||
public abstract onProfileClicked(): void;
|
||||
|
||||
static mountOnce(): void {
|
||||
if (this.mounted) return;
|
||||
this.mounted = true;
|
||||
if (!form) return;
|
||||
form.addEventListener(
|
||||
"submit",
|
||||
(e) => {
|
||||
if (!ChatPanelController.active) return; // let others handle
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
Promise.resolve(ChatPanelController.active.onSubmit(text)).finally(() => {
|
||||
input.value = "";
|
||||
});
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicChatPanel extends ChatPanelController {
|
||||
protected async onSubmit(text: string): Promise<void> {
|
||||
const payload: WebSocketMessage = {
|
||||
data: { content: text },
|
||||
credentials: { scheme: "Bearer", credentials: authToken! },
|
||||
type: "sendMessage"
|
||||
};
|
||||
await request(payload);
|
||||
}
|
||||
|
||||
protected loadMessages(): void {
|
||||
fetch(`${API_BASE_URL}/get_messages`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then((data: Messages) => {
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
messages.innerHTML = "";
|
||||
|
||||
const messagesContainer = document.querySelector('.chat-messages') as HTMLElement;
|
||||
|
||||
const lastMessage = messagesContainer.lastElementChild as HTMLElement
|
||||
let lastMessageId: number = 0
|
||||
if (lastMessage) {
|
||||
lastMessageId = Number(lastMessage.dataset.id)
|
||||
}
|
||||
|
||||
// Добавляем только новые сообщения
|
||||
data.messages.forEach(msg => {
|
||||
console.log(msg);
|
||||
if (msg.id > lastMessageId) {
|
||||
addMessage(msg, msg.username == currentUser!.username);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onProfileClicked(): void {
|
||||
// Public chat doesn't have a specific profile to show
|
||||
}
|
||||
}
|
||||
|
||||
export class DmPanel extends ChatPanelController {
|
||||
private sender: (text: string) => Promise<void>;
|
||||
private loader: () => Promise<void> | void;
|
||||
private otherUsername: string | null = null;
|
||||
|
||||
constructor(sender: (text: string) => Promise<void>, loader: () => Promise<void> | void) {
|
||||
super();
|
||||
this.sender = sender;
|
||||
this.loader = loader;
|
||||
}
|
||||
|
||||
setOtherUser(username: string): void {
|
||||
this.otherUsername = username;
|
||||
}
|
||||
|
||||
protected async onSubmit(text: string): Promise<void> {
|
||||
this.appendSimple(text, true);
|
||||
await this.sender(text);
|
||||
}
|
||||
|
||||
protected loadMessages(): void | Promise<void> {
|
||||
return this.loader();
|
||||
}
|
||||
|
||||
public onProfileClicked(): void {
|
||||
if (this.otherUsername) {
|
||||
// Import and show the profile dialog
|
||||
showProfileDialog(this.otherUsername!);
|
||||
}
|
||||
}
|
||||
|
||||
appendMessageWithId(message: Message): void {
|
||||
const div = document.createElement("div");
|
||||
const isAuthor = message.username === currentUser?.username;
|
||||
div.className = `message ${isAuthor ? "sent" : "received"}`;
|
||||
div.setAttribute("data-message-id", message.id.toString());
|
||||
div.setAttribute("data-timestamp", message.timestamp);
|
||||
|
||||
const inner = document.createElement("div");
|
||||
inner.className = "message-inner";
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.className = "message-content";
|
||||
content.textContent = message.content;
|
||||
|
||||
inner.appendChild(content);
|
||||
div.appendChild(inner);
|
||||
|
||||
// Add context menu support
|
||||
div.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(message, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
messages.appendChild(div);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
ChatPanelController.mountOnce();
|
||||
@@ -9,8 +9,9 @@ import { getAuthHeaders, currentUser } from "../auth/api";
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import type { UserProfile } from "../core/types";
|
||||
import { showError, showSuccess } from "../utils/notification";
|
||||
import { formatTime, id } from "../utils/utils";
|
||||
import { delay, formatTime, id } from "../utils/utils";
|
||||
import defaultAvatar from "../resources/images/default-avatar.png";
|
||||
import type { Tabs } from "mdui/components/tabs";
|
||||
import type { Dialog } from "mdui/components/dialog";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
|
||||
@@ -36,6 +37,41 @@ function bindEvents(): void {
|
||||
editBioBtn?.addEventListener('click', () => startEditBio());
|
||||
saveBioBtn?.addEventListener('click', () => saveBio());
|
||||
cancelBioBtn?.addEventListener('click', () => cancelEditBio());
|
||||
|
||||
// DM button event
|
||||
const dmButton = dialog?.querySelector('#dm-button');
|
||||
dmButton?.addEventListener('click', () => startDm());
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a direct message conversation
|
||||
* @private
|
||||
*/
|
||||
async function startDm(): Promise<void> {
|
||||
if (!currentProfile || isOwnProfile) return;
|
||||
|
||||
// Hide the profile dialog
|
||||
hide();
|
||||
|
||||
// Switch to DMs tab
|
||||
const tabs = document.querySelector('.chat-tabs mdui-tabs') as Tabs;
|
||||
if (tabs) {
|
||||
tabs.value = 'dms';
|
||||
}
|
||||
|
||||
// Find and click on the user in the DM users list
|
||||
await delay(100);
|
||||
const dmUsersList = document.getElementById("dm-users");
|
||||
if (dmUsersList) {
|
||||
const userItems = dmUsersList.querySelectorAll('mdui-list-item');
|
||||
for (const item of userItems) {
|
||||
const headline = item.getAttribute('headline');
|
||||
if (headline === currentProfile?.username) {
|
||||
(item as HTMLElement).click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+35
@@ -5,6 +5,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* HTTP headers object type
|
||||
* @typedef {Object.<string, string>} Headers
|
||||
@@ -83,6 +84,7 @@ export interface User {
|
||||
username: string;
|
||||
admin?: boolean;
|
||||
bio?: string;
|
||||
profile_picture: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +138,19 @@ export interface RegisterRequest {
|
||||
confirm_password: string;
|
||||
}
|
||||
|
||||
export interface UploadPublicKeyRequest {
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
export interface SendDMRequest {
|
||||
recipientId: number;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
}
|
||||
|
||||
// Responses
|
||||
|
||||
/**
|
||||
@@ -149,6 +164,26 @@ export interface LoginResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface BackupBlob {
|
||||
blob: string;
|
||||
}
|
||||
|
||||
export interface DmEnvelope {
|
||||
id: number;
|
||||
senderId: number;
|
||||
recipientId: number;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface FetchDMResponse {
|
||||
messages: DmEnvelope[]
|
||||
}
|
||||
|
||||
// ---------------
|
||||
// WebSocket types
|
||||
// ---------------
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import nacl from "tweetnacl";
|
||||
import { hkdfExtractAndExpand } from "../crypto/kdf";
|
||||
|
||||
export interface X25519KeyPair {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
export function generateX25519KeyPair(): X25519KeyPair {
|
||||
const kp = nacl.box.keyPair();
|
||||
return { publicKey: kp.publicKey, privateKey: kp.secretKey };
|
||||
}
|
||||
|
||||
export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array {
|
||||
// nacl.box.before returns shared key (Curve25519, XSalsa20-Poly1305 context). We use it as IKM into HKDF.
|
||||
return nacl.box.before(theirPublicKey, myPrivateKey);
|
||||
}
|
||||
|
||||
export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> {
|
||||
return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric";
|
||||
import { importPassword, deriveKEK, randomBytes } from "./kdf";
|
||||
|
||||
export interface PrivateKeyBundle {
|
||||
version: 1;
|
||||
privateKey: Uint8Array; // X25519 private key
|
||||
}
|
||||
|
||||
export interface EncryptedBackupBlob {
|
||||
salt: Uint8Array; // for PBKDF2 derivation of KEK
|
||||
iv: Uint8Array; // AES-GCM IV
|
||||
ciphertext: Uint8Array; // encrypted serialized PrivateKeyBundle
|
||||
}
|
||||
|
||||
export function serializeBundle(bundle: PrivateKeyBundle): Uint8Array {
|
||||
const header = new Uint8Array([bundle.version]);
|
||||
const len = new Uint8Array(new Uint32Array([bundle.privateKey.length]).buffer);
|
||||
const out = new Uint8Array(1 + 4 + bundle.privateKey.length);
|
||||
out.set(header, 0);
|
||||
out.set(len, 1);
|
||||
out.set(bundle.privateKey, 5);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function deserializeBundle(data: Uint8Array): PrivateKeyBundle {
|
||||
const version = data[0] as 1;
|
||||
const len = new Uint32Array(data.slice(1, 5).buffer)[0];
|
||||
const pk = data.slice(5, 5 + len);
|
||||
return { version, privateKey: pk };
|
||||
}
|
||||
|
||||
export async function encryptBackupWithPassword(password: string, bundle: PrivateKeyBundle): Promise<EncryptedBackupBlob> {
|
||||
const salt = randomBytes(16);
|
||||
const pw = await importPassword(password);
|
||||
const kek = await deriveKEK(pw, salt);
|
||||
const serialized = serializeBundle(bundle);
|
||||
const { iv, ciphertext } = await aesGcmEncrypt(kek, serialized);
|
||||
return { salt, iv, ciphertext };
|
||||
}
|
||||
|
||||
export async function decryptBackupWithPassword(password: string, blob: EncryptedBackupBlob): Promise<PrivateKeyBundle> {
|
||||
const pw = await importPassword(password);
|
||||
const kek = await deriveKEK(pw, blob.salt);
|
||||
const plaintext = await aesGcmDecrypt(kek, blob.iv, blob.ciphertext);
|
||||
return deserializeBundle(plaintext);
|
||||
}
|
||||
|
||||
export function encodeBlob(blob: EncryptedBackupBlob): string {
|
||||
function b64(a: Uint8Array) { return btoa(String.fromCharCode(...a)); }
|
||||
return JSON.stringify({
|
||||
salt: b64(blob.salt),
|
||||
iv: b64(blob.iv),
|
||||
ciphertext: b64(blob.ciphertext)
|
||||
});
|
||||
}
|
||||
|
||||
export function decodeBlob(json: string): EncryptedBackupBlob {
|
||||
function ub64(s: string) {
|
||||
const bin = atob(s);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
return arr;
|
||||
}
|
||||
const obj = JSON.parse(json);
|
||||
return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export async function importPassword(password: string): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]);
|
||||
}
|
||||
|
||||
export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise<CryptoKey> {
|
||||
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
|
||||
passwordKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise<Uint8Array> {
|
||||
const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial;
|
||||
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
||||
const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info;
|
||||
|
||||
const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]);
|
||||
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
export function randomBytes(length: number): Uint8Array {
|
||||
const out = new Uint8Array(length);
|
||||
crypto.getRandomValues(out);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface AesGcmCiphertext {
|
||||
iv: Uint8Array;
|
||||
ciphertext: Uint8Array;
|
||||
}
|
||||
|
||||
export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | ArrayBuffer): Promise<AesGcmCiphertext> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const plaintextBuffer = plaintext instanceof Uint8Array ? plaintext.buffer as ArrayBuffer : plaintext;
|
||||
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintextBuffer);
|
||||
return { iv, ciphertext: new Uint8Array(ct) };
|
||||
}
|
||||
|
||||
export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
|
||||
// Normalize IV to ArrayBuffer (12 bytes for AES-GCM)
|
||||
const ivBuf: ArrayBuffer = iv instanceof Uint8Array
|
||||
? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength)
|
||||
: (iv as ArrayBuffer);
|
||||
|
||||
// Normalize ciphertext to a contiguous ArrayBuffer slice
|
||||
const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array
|
||||
? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength)
|
||||
: (ciphertext as ArrayBuffer);
|
||||
|
||||
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBuf }, key, ctBuf);
|
||||
return new Uint8Array(pt as ArrayBuffer);
|
||||
}
|
||||
|
||||
export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> {
|
||||
const keyBuffer = rawKey instanceof Uint8Array ? rawKey.buffer as ArrayBuffer : rawKey;
|
||||
return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
@@ -17,3 +17,5 @@ import "./userPanel/profile/profile";
|
||||
import "./chat/contextMenu";
|
||||
import "./chat/profileDialog";
|
||||
import "./electron/electron";
|
||||
import "./chat/panel";
|
||||
import "./chat/dm";
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clearAlerts } from "./auth/auth";
|
||||
import { loadMessages } from "./chat/chat";
|
||||
import { publicChatPanel } from "./chat/chat";
|
||||
import { id } from "./utils/utils";
|
||||
|
||||
const loginForm = id("login-form");
|
||||
@@ -36,14 +36,7 @@ export function showChat(): void {
|
||||
loginForm.style.display = 'none';
|
||||
registerForm.style.display = 'none';
|
||||
chatInterface.style.display = 'block';
|
||||
loadMessages();
|
||||
titleBar.classList.remove("color-surface");
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the chat interface and initializes messaging.
|
||||
*/
|
||||
export function loadChat(): void {
|
||||
showChat();
|
||||
loadMessages();
|
||||
publicChatPanel.activate();
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
import { Dialog } from "mdui/components/dialog";
|
||||
import { loadProfilePicture } from "./profile/upload";
|
||||
import { id } from "../utils/utils";
|
||||
import { publicChatPanel } from "../chat/chat";
|
||||
|
||||
// сварачивание и разворачивание чата
|
||||
const chatCollapseBtn = id('hide-chat')!;
|
||||
@@ -74,12 +75,14 @@ function setupChatSwitching(): void {
|
||||
chat1.addEventListener('click', () => {
|
||||
animateChatSwitch(() => {
|
||||
chatName.textContent = 'Общий чат';
|
||||
publicChatPanel.activate();
|
||||
});
|
||||
});
|
||||
|
||||
chat2.addEventListener('click', () => {
|
||||
animateChatSwitch(() => {
|
||||
chatName.textContent = 'Общий чат 2';
|
||||
publicChatPanel.activate();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,15 @@ export function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
|
||||
export function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); }
|
||||
export function ub64(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function id<T extends Element = HTMLElement>(id: string): T {
|
||||
return document.getElementById(id) as unknown as T
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { handleWebSocketMessage } from "./chat/chat";
|
||||
import { API_WS_BASE_URL } from "./core/config";
|
||||
import type { WebSocketMessage } from "./core/types";
|
||||
import { delay } from "./utils/utils";
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,20 @@ function create(): WebSocket {
|
||||
*/
|
||||
export let websocket: WebSocket = create();
|
||||
|
||||
export function request(payload: WebSocketMessage): Promise<WebSocketMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let listener: ((e: MessageEvent) => void) | null = null;
|
||||
listener = (e) => {
|
||||
resolve(JSON.parse(e.data));
|
||||
websocket.removeEventListener("message", listener!);
|
||||
}
|
||||
websocket.addEventListener("message", listener);
|
||||
websocket.send(JSON.stringify(payload))
|
||||
|
||||
setTimeout(() => reject("Request timed out"), 10000);
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will wait 3 seconds and them attempts to reconnect the WebSocket.
|
||||
* If it fails, tries again in an endless loop until the connection is established
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
|
||||
+2
-1
@@ -44,6 +44,7 @@
|
||||
"vite-plugin-html": "^3.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"mdui": "^2.1.4"
|
||||
"mdui": "^2.1.4",
|
||||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user