Restructure CSS

This commit is contained in:
2025-10-09 22:32:00 +03:00
Unverified
parent 2312569455
commit 0ccade3a91
67 changed files with 110 additions and 509 deletions
+5 -5
View File
@@ -1,15 +1,15 @@
import { useImmer } from "use-immer";
import { AlertsContainer, type Alert, type AlertType } from "../chat/ui/components/Alerts";
import { AuthContainer, AuthHeader } from "../chat/ui/components/Auth";
import type { ErrorResponse, LoginRequest, LoginResponse } from "../chat/core/types";
import { ensureKeysOnLogin } from "../../api/authApi";
import { API_BASE_URL } from "../chat/core/config";
import type { ErrorResponse, LoginRequest, LoginResponse } from "../../core/types";
import { ensureKeysOnLogin } from "../../core/api/authApi";
import { API_BASE_URL } from "../../core/config";
import { useRef } from "react";
import type { TextField } from "mdui/components/text-field";
import { useAppState } from "../chat/ui/state";
import { MaterialTextField } from "../chat/ui/components/core/TextField";
import { initialize, isSupported, startElectronReceiver, subscribe } from "../chat/push-notifications";
import { isElectron } from "../chat/electron/electron";
import { initialize, isSupported, startElectronReceiver, subscribe } from "../../core/push-notifications/push-notifications";
import { isElectron } from "../../core/electron/electron";
import { useNavigate } from "react-router-dom";
import "./auth.scss";
+3 -3
View File
@@ -3,11 +3,11 @@ import { AuthContainer, AuthHeader } from "../chat/ui/components/Auth";
import { AlertsContainer, type Alert, type AlertType } from "../chat/ui/components/Alerts";
import { useRef } from "react";
import { TextField } from "mdui/components/text-field";
import type { ErrorResponse, RegisterRequest, LoginResponse } from "../chat/core/types";
import { API_BASE_URL } from "../chat/core/config";
import type { ErrorResponse, RegisterRequest, LoginResponse } from "../../core/types";
import { API_BASE_URL } from "../../core/config";
import { useAppState } from "../chat/ui/state";
import { MaterialTextField } from "../chat/ui/components/core/TextField";
import { ensureKeysOnLogin } from "../../api/authApi";
import { ensureKeysOnLogin } from "../../core/api/authApi";
import { useNavigate } from "react-router-dom";
import "./auth.scss";
+2 -2
View File
@@ -1,5 +1,5 @@
@use "../../css/common/colors" as *;
@use "../../css/common/material" as *;
@use "../../css/colors" as *;
@use "../../css/material" as *;
.auth-container {
display: flex;
-32
View File
@@ -1,32 +0,0 @@
/**
* @fileoverview Application configuration constants
* @description Contains all configuration values used throughout the application
* @author Cursor
* @version 1.0.0
*/
/**
* Base domain name for all requests in production
* @constant
*/
export const BASE_DOMAIN = import.meta.env.VITE_API_BASE_URL ?? "fromchat.ru";
/**
* Base API endpoint for all backend requests
* @constant
*/
export const API_BASE_URL = `${location.host ? "" : `https://${BASE_DOMAIN}`}/api`;
/**
* Full API URL including hostname and port for WebSocket connections
* @constant
*/
export const API_WS_BASE_URL = `${location.host || BASE_DOMAIN}/api`;
/**
* Application name displayed in UI and document title
* @constant
*/
export const PRODUCT_NAME = "FromChat";
export const MINIMUM_WIDTH = 800;
-12
View File
@@ -1,12 +0,0 @@
/**
* @fileoverview Application initialization logic
* @description Handles initial application setup and state
* @author FromChat Team
* @version 1.0.0
*/
import { PRODUCT_NAME } from "./config";
import { enableMapSet } from "immer";
document.title = PRODUCT_NAME;
enableMapSet();
-431
View File
@@ -1,431 +0,0 @@
/**
* @fileoverview Global TypeScript type definitions
* @description Contains all type definitions used throughout the application
* @author Cursor
* @version 1.0.0
*/
/**
* HTTP headers object type
* @typedef {Object.<string, string>} Headers
*/
export type Headers = {[x: string]: string}
/**
* API error response structure
* @interface ErrorResponse
* @property {string} message - Error message from the server
*/
export interface ErrorResponse {
message: string;
}
/**
* 2D coordinate structure
* @interface Size2D
* @property {number} x - X coordinate
* @property {number} y - Y coordinate
*/
export interface Size2D {
x: number;
y: number;
}
export interface Rect extends Size2D {
width: number;
height: number;
}
// App types
/**
* Chat message structure
* @interface Message
* @property {number} id - Unique message identifier
* @property {string} username - Username of the message sender
* @property {string} content - Message content
* @property {boolean} is_read - Whether the message has been read
* @property {boolean} is_edited - Whether the message has been edited
* @property {string} timestamp - ISO timestamp of the message
* @property {string} [profile_picture] - URL to sender's profile picture
* @property {Message} [reply_to] - The message this is replying to
*/
export interface Reaction {
emoji: string;
count: number;
users: Array<{
id: number;
username: string;
}>;
}
export interface Message {
id: number;
username: string;
content: string;
is_read: boolean;
is_edited: boolean;
timestamp: string;
profile_picture?: string;
reply_to?: Message;
files?: Attachment[];
reactions?: Reaction[];
runtimeData?: {
dmEnvelope?: DmEnvelope;
sendingState?: {
status: 'sending' | 'sent' | 'failed';
tempId?: string; // Temporary ID for tracking until server confirms
retryData?: {
content: string;
replyToId?: number;
files?: File[];
};
};
}
}
/**
* Collection of messages
* @interface Messages
* @property {Message[]} messages - Array of message objects
*/
export interface Messages {
messages: Message[];
}
/**
* User information structure
* @interface User
* @property {number} id - Unique user identifier
* @property {string} created_at - ISO timestamp of account creation
* @property {string} last_seen - ISO timestamp of last activity
* @property {boolean} online - Whether the user is currently online
* @property {string} username - Username
* @property {string} [bio] - User biography
*/
export interface User {
id: number;
created_at: string;
last_seen: string;
online: boolean;
username: string;
admin?: boolean;
bio?: string;
profile_picture: string;
}
/**
* User profile response structure
* @interface UserProfile
* @property {number} id - Unique user identifier
* @property {string} username - Username
* @property {string} [profile_picture] - URL to user's profile picture
* @property {string} [bio] - User biography
* @property {boolean} online - Whether the user is currently online
* @property {string} last_seen - ISO timestamp of last activity
* @property {string} created_at - ISO timestamp of account creation
*/
export interface UserProfile {
id: number;
username: string;
profile_picture?: string;
bio?: string;
online: boolean;
last_seen: string;
created_at: string;
}
// ----------
// API models
// ----------
// Requests
/**
* Login request structure
* @interface LoginRequest
* @property {string} username - Username for authentication
* @property {string} password - Password for authentication
*/
export interface LoginRequest {
username: string;
password: string;
}
/**
* Registration request structure
* @interface RegisterRequest
* @property {string} username - Desired username
* @property {string} password - Desired password
* @property {string} confirm_password - Password confirmation
*/
export interface RegisterRequest {
username: string;
password: string;
confirm_password: string;
}
export interface UploadPublicKeyRequest {
publicKey: string;
}
export interface SendDMRequest {
recipientId: number;
iv: string;
ciphertext: string;
salt: string;
iv2: string;
wrappedMk: string;
replyToId?: number;
}
// Responses
/**
* Login response structure
* @interface LoginResponse
* @property {User} user - User information
* @property {string} token - JWT authentication token
*/
export interface LoginResponse {
user: User;
token: string;
}
export interface BackupBlob {
blob: string;
}
export interface BaseDmEnvelope {
iv: string;
ciphertext: string;
salt: string;
iv2: string;
wrappedMk: string;
recipientId: number;
}
export interface DmEnvelope extends BaseDmEnvelope {
id: number;
senderId: number;
files?: DmFile[];
timestamp: string;
reactions?: Reaction[];
}
export interface DmFile {
name: string;
id: number;
path: string;
}
export interface DmEditedPayload {
id: number;
iv: string;
ciphertext: string;
timestamp: string
}
export interface DmDeletedPayload {
id: number;
senderId: number;
recipientId: number
}
export interface FetchDMResponse {
messages: DmEnvelope[]
}
export interface DmEncryptedJSON {
type: "text",
data: {
content: string;
reply_to_id?: number;
files?: Attachment[];
}
}
// ---------------
// WebSocket types
// ---------------
/**
* WebSocket message structure
* @interface WebSocketMessage
* @property {string} type - Message type identifier
* @property {WebSocketCredentials} [credentials] - Authentication credentials
* @property {any} [data] - Message payload data
* @property {WebSocketError} [error] - Error information if applicable
*/
export interface WebSocketMessage<T> {
type: string;
credentials?: WebSocketCredentials;
data?: T;
error?: WebSocketError;
}
/**
* WebSocket error structure
* @interface WebSocketError
* @property {number} code - Error code
* @property {string} detail - Error detail message
*/
export interface WebSocketError {
code: number;
detail: string;
}
/**
* WebSocket authentication credentials
* @interface WebSocketCredentials
* @property {string} scheme - Authentication scheme (e.g., "Bearer")
* @property {string} credentials - Authentication token or credentials
*/
export interface WebSocketCredentials {
scheme: string;
credentials: string;
}
export interface Attachment {
path: string;
encrypted: boolean;
name: string;
}
// -----------------------
// WebSocket message types
// -----------------------
// Utils
export interface DMEditPayload {
id: number;
iv: string;
ciphertext: string;
iv2: string;
wrappedMk: string;
salt: string;
}
// Requests
export interface DMEditRequest extends WebSocketMessage {
type: "dmEdit",
credentials: WebSocketCredentials;
data: DMEditPayload
}
export interface SendMessageRequest extends WebSocketMessage {
type: "sendMessage",
credentials: WebSocketCredentials;
data: {
content: string;
reply_to_id: number | null;
}
}
export interface AddReactionRequest extends WebSocketMessage {
type: "addReaction",
credentials: WebSocketCredentials;
data: {
message_id: number;
emoji: string;
}
}
export interface AddDmReactionRequest extends WebSocketMessage {
type: "addDmReaction",
credentials: WebSocketCredentials;
data: {
dm_envelope_id: number;
emoji: string;
}
}
// Messages
export interface DMNewWebSocketMessage extends WebSocketMessage {
type: "dmNew",
data: DmEnvelope
}
export interface DMEditedWebSocketMessage extends WebSocketMessage {
type: "dmEdited",
data: DMEditPayload
}
export interface DMDeletedWebSocketMessage extends WebSocketMessage {
type: "dmDeleted",
data: {
id: number;
}
}
export interface MessageEditedWebSocketMessage extends WebSocketMessage {
type: "messageEdited",
data: Partial<Message> & { id: number }
}
export interface MessageDeletedWebSocketMessage extends WebSocketMessage {
type: "messageDeleted",
data: {
message_id: number;
}
}
export interface NewMessageWebSocketMessage extends WebSocketMessage {
type: "newMessage",
data: Message
}
export interface ReactionUpdateWebSocketMessage extends WebSocketMessage {
type: "reactionUpdate",
data: {
message_id: number;
emoji: string;
action: "added" | "removed";
user_id: number;
username: string;
reactions: Reaction[];
}
}
export interface DMReactionUpdateWebSocketMessage extends WebSocketMessage {
type: "dmReactionUpdate",
data: {
dm_envelope_id: number;
emoji: string;
action: "added" | "removed";
user_id: number;
username: string;
reactions: Reaction[];
}
}
// Shared types
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage | DMReactionUpdateWebSocketMessage
export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage | ReactionUpdateWebSocketMessage
// -----------
// Encrypted message JSON (plaintext structure before encryption)
// -----------
export type ChatMessageKind = "text"; // Extendable for future kinds
export interface EncryptedTextMessageData {
content: string;
files?: Attachment[];
reply_to_id?: number | null;
}
export interface EncryptedMessageJson {
type: ChatMessageKind;
data: EncryptedTextMessageData;
}
// -----------
// React types
// -----------
export interface DialogProps {
isOpen: boolean;
onOpenChange: (value: boolean) => void;
}
-108
View File
@@ -1,108 +0,0 @@
/**
* @fileoverview WebSocket connection management for real-time chat
* @description Handles WebSocket connections, message processing, and auto-reconnection
* @author Cursor
* @version 1.0.0
*/
import { API_WS_BASE_URL } from "./config";
import type { WebSocketMessage } from "./types";
import { delay } from "../../../utils/utils";
/**
* Creates a new WebSocket connection to the chat server
* @returns {WebSocket} New WebSocket instance
* @private
*/
function create(): WebSocket {
let prefix = "ws://";
if (location.protocol.includes("https")) {
prefix = "wss://";
}
return new WebSocket(`${prefix}${API_WS_BASE_URL}/chat/ws`);
}
/**
* Global WebSocket instance
* @type {WebSocket}
*/
export let websocket: WebSocket = create();
/**
* Global WebSocket message handler reference
* This will be set by the active panel to handle incoming messages
*/
let globalMessageHandler: ((response: WebSocketMessage<any>) => void) | null = null;
/**
* Set the global WebSocket message handler
* @param handler - Function to handle WebSocket messages
*/
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<any>) => void) | null): void {
globalMessageHandler = handler;
}
export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
console.log("WebSocket request:", payload);
return new Promise((resolve, reject) => {
function requestInner() {
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);
}
if (websocket.readyState == 0) {
websocket.addEventListener("open", requestInner);
setTimeout(() => reject("Request timed out"), 10000);
} else {
requestInner();
}
})
}
/**
* 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
* again.
*
* @private
*/
async function onError() {
console.warn("WebSocket disconnected, retrying in 3 seconds...");
await delay(3000);
websocket = create();
let listener: () => void | null;
listener = () => {
console.log("WebSocket successfully reconnected!");
websocket.removeEventListener("open", listener);
}
websocket.addEventListener("open", listener);
websocket.addEventListener("error", onError);
}
// --------------
// Initialization
// --------------
websocket.addEventListener("message", (e) => {
try {
const response: WebSocketMessage<any> = JSON.parse(e.data);
// Route message to global handler if set
if (globalMessageHandler) {
globalMessageHandler(response);
}
} catch (error) {
console.error("Error parsing WebSocket message:", error);
}
});
websocket.addEventListener("error", onError);
+2 -2
View File
@@ -1,5 +1,5 @@
@use "../../../css/common/colors" as *;
@use "../../../css/common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Animation for reactions appearing/disappearing
+2 -2
View File
@@ -1,5 +1,5 @@
@use "../../../css/common/colors" as *;
@use "../../../css/common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.chat-input-wrapper {
@@ -1,5 +1,5 @@
@use "../../../css/common/colors" as *;
@use "../../../css/common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Reaction bar styles (standalone)
+2 -2
View File
@@ -1,5 +1,5 @@
@use "../../../css/common/colors" as *;
@use "../../../css/common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
#chat-interface {
+2 -2
View File
@@ -1,5 +1,5 @@
@use "../../../css/common/colors" as *;
@use "../../../css/common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.header {
@@ -1,5 +1,5 @@
@use "../../../css/common/colors" as *;
@use "../../../css/common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Reaction styles
+2 -2
View File
@@ -1,5 +1,5 @@
@use "../../../css/common/colors" as *;
@use "../../../css/common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.quote.contextual-content > .quote-inner {
@@ -1,5 +1,5 @@
@use "../../../css/common/colors" as *;
@use "../../../css/common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Profile styles
@@ -1,5 +1,5 @@
@use "../../../css/common/colors" as *;
@use "../../../css/common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.chat-main {
@@ -1,5 +1,5 @@
@use "../../../css/common/colors" as *;
@use "../../../css/common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Settings styles
@@ -1,41 +0,0 @@
@use "../../../css/common/material" as *;
#electron-title-bar {
display: none;
}
html.electron {
#electron-title-bar {
display: flex;
flex-direction: row;
gap: 8px;
min-height: 40px;
background-color: $color-dark-surface-container;
width: 100%;
-webkit-app-region: drag;
user-select: none;
z-index: 10;
transition: background-color 0.5s ease;
flex-shrink: 0;
&.color-surface {
background-color: $color-dark-surface;
}
}
#window-title {
flex: 1;
display: flex;
align-items: center;
font-weight: 500;
}
#main-wrapper {
flex: 1;
min-height: 0;
}
&.platform-darwin .macos-padding {
width: 80px;
}
}
@@ -1,17 +0,0 @@
/**
* @fileoverview Electron-specific code
* @description This module initializes Electron-specific functionality.
* @author denis0001-dev
* @version 1.0.0
*/
import "./electron.scss";
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface != undefined;
if (isElectron) {
console.log("Running in Electron");
document.documentElement.classList.add("electron", `platform-${window.electronInterface.platform}`);
} else {
console.log("Running in normal browser");
}
@@ -1,269 +0,0 @@
import { API_BASE_URL } from "./core/config";
import { isElectron } from "./electron/electron";
import { websocket } from "./core/websocket";
import type { NewMessageWebSocketMessage, WebSocketMessage } from "./core/types";
import serviceWorker from "../../../service-worker?worker&url";
export interface PushSubscriptionData {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
export interface NotificationPayload {
title: string;
body: string;
icon?: string;
image?: string;
tag?: string;
data?: any;
}
// Global state
let isInitialized = false;
let registration: ServiceWorkerRegistration | null = null;
let subscription: PushSubscription | null = null;
let isElectronReceiverRunning = false;
let messageListener: ((event: MessageEvent) => void) | null = null;
// Helper functions
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
async function subscribeToWebPush(): Promise<PushSubscription | null> {
if (!registration) {
throw new Error("Service Worker not initialized");
}
try {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
"BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo"
).slice().buffer
});
console.log("Push subscription successful");
return subscription;
} catch (error) {
console.error("Push subscription failed:", error);
return null;
}
}
async function sendSubscriptionToServer(token: string): Promise<boolean> {
if (!subscription) {
throw new Error("No push subscription available");
}
const subscriptionData: PushSubscriptionData = {
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(subscription.getKey("p256dh")!),
auth: arrayBufferToBase64(subscription.getKey("auth")!)
}
};
try {
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(subscriptionData)
});
return response.ok;
} catch (error) {
console.error("Failed to send subscription to server:", error);
return false;
}
}
async function showMessageNotification(message: any): Promise<void> {
try {
await showNotification({
title: `New message from ${message.username}`,
body: message.content.length > 100
? message.content.substring(0, 100) + "..."
: message.content,
icon: message.profile_picture || "/logo.png",
tag: `message_${message.id}`,
data: {
type: "public_message",
message_id: message.id,
sender_id: message.user_id,
sender_username: message.username
}
});
} catch (error) {
console.error("Failed to show message notification:", error);
}
}
async function handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void> {
// Handle notifications for new messages
if (response.type === "newMessage" && response.data) {
const newResponse = response as NewMessageWebSocketMessage;
await showMessageNotification(newResponse.data);
}
}
// Public API functions
export async function initialize(): Promise<boolean> {
if (isInitialized) {
return true;
}
try {
if (isElectron) {
// For Electron, we just need to request permission
const permission = await window.electronInterface.notifications.requestPermission();
isInitialized = permission === "granted";
return isInitialized;
} else {
// For web browsers, initialize service worker and push manager
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
console.log("Push messaging is not supported");
return false;
}
try {
registration = await navigator.serviceWorker.register(serviceWorker, { type: "module" });
console.log("Service Worker registered successfully");
const permission = await Notification.requestPermission();
if (permission === "granted") {
await subscribeToWebPush();
isInitialized = true;
}
return isInitialized;
} catch (error) {
console.error("Service Worker registration failed:", error);
return false;
}
}
} catch (error) {
console.error("Failed to initialize notification service:", error);
return false;
}
}
export async function subscribe(token: string): Promise<boolean> {
if (!isInitialized) {
return false;
}
if (isElectron) {
// In Electron, we don't need server-side subscription
return true;
}
return await sendSubscriptionToServer(token);
}
export async function showNotification(payload: NotificationPayload): Promise<boolean> {
if (isElectron) {
try {
return await window.electronInterface.notifications.show({
title: payload.title,
body: payload.body,
icon: payload.icon,
tag: payload.tag
});
} catch (error) {
console.error("Failed to show Electron notification:", error);
return false;
}
}
// For web browsers, notifications are handled by the service worker
// when push messages are received from the server
return false;
}
export async function unsubscribe(): Promise<boolean> {
if (isElectron) {
// In Electron, we don't need to unsubscribe from server
return true;
}
if (!subscription) {
return true;
}
try {
const result = await subscription.unsubscribe();
subscription = null;
return result;
} catch (error) {
console.error("Failed to unsubscribe:", error);
return false;
}
}
export function isSupported(): boolean {
if (isElectron) {
return true; // Electron always supports notifications
}
return "serviceWorker" in navigator && "PushManager" in window;
}
// Electron-specific functions
export async function startElectronReceiver(): Promise<void> {
if (!isElectron || isElectronReceiverRunning) {
return;
}
isElectronReceiverRunning = true;
// Add our own message listener to the existing WebSocket
messageListener = (event: MessageEvent) => {
try {
const response: WebSocketMessage<any> = JSON.parse(event.data);
handleWebSocketMessage(response);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
websocket.addEventListener('message', messageListener);
}
export function stopElectronReceiver(): void {
if (!isElectron) {
return;
}
isElectronReceiverRunning = false;
// Remove our message listener
if (messageListener) {
websocket.removeEventListener('message', messageListener);
messageListener = null;
}
}
@@ -1,5 +1,5 @@
import { PRODUCT_NAME } from "../../core/config";
import { isElectron } from "../../electron/electron";
import { PRODUCT_NAME } from "../../../../core/config";
import { isElectron } from "../../../../core/electron/electron";
export function ElectronTitleBar() {
return isElectron && (
@@ -1,4 +1,4 @@
import { PRODUCT_NAME } from "../../../core/config";
import { PRODUCT_NAME } from "../../../../../core/config";
import useProfile from "../../hooks/useProfile";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import { useState } from "react";
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { MaterialDialog } from "../core/Dialog";
import { RichTextArea } from "../core/RichTextArea";
import type { Message } from "../../../core/types";
import type { Message } from "../../../../../core/types";
import Quote from "../core/Quote";
import AnimatedHeight from "../core/animations/AnimatedHeight";
import { useImmer } from "use-immer";
@@ -1,15 +1,15 @@
import { Message } from "./Message";
import { useAppState } from "../../state";
import type { Message as MessageType } from "../../../core/types";
import type { UserProfile } from "../../../core/types";
import type { Message as MessageType } from "../../../../../core/types";
import type { UserProfile } from "../../../../../core/types";
import { UserProfileDialog } from "./UserProfileDialog";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { fetchUserProfile } from "../../../../../api/profileApi";
import { fetchUserProfile } from "../../../../../core/api/profileApi";
import { useEffect, useState, type ReactNode } from "react";
import { delay } from "../../../../../utils/utils";
import { MaterialDialog } from "../core/Dialog";
import { request } from "../../../core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "../../../core/types";
import { request } from "../../../../../core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "../../../../../core/types";
interface ChatMessagesProps {
messages?: MessageType[];
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { useDM, type DMUser } from "../../hooks/useDM";
import { useAppState } from "../../state";
import { fetchUserPublicKey } from "../../../../../api/dmApi";
import { fetchUserPublicKey } from "../../../../../core/api/dmApi";
import defaultAvatar from "../../../../../images/default-avatar.png";
export function DMUsersList() {
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
import type { Size2D } from "../../../core/types";
import type { Size2D } from "../../../../../core/types";
interface BaseEmojiMenuProps {
isOpen: boolean;
@@ -1,4 +1,4 @@
import { PRODUCT_NAME } from "../../../core/config";
import { PRODUCT_NAME } from "../../../../../core/config";
import { useAppState } from "../../state";
import defaultAvatar from "../../../../../images/default-avatar.png";
import { useState, type FormEvent } from "react";
@@ -1,14 +1,14 @@
import { formatTime, id } from "../../../../../utils/utils";
import type { Attachment, Message as MessageType } from "../../../core/types";
import type { Attachment, Message as MessageType } from "../../../../../core/types";
import defaultAvatar from "../../../../../images/default-avatar.png";
import Quote from "../core/Quote";
import { parse } from "marked";
import DOMPurify from "dompurify";
import { useEffect, useState, useRef } from "react";
import { getCurrentKeys } from "../../../../../api/authApi";
import { getCurrentKeys } from "../../../../../core/api/authApi";
import { ecdhSharedSecret, deriveWrappingKey } from "../../../../../utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "../../../../../utils/crypto/symmetric";
import { getAuthHeaders } from "../../../../../api/authApi";
import { getAuthHeaders } from "../../../../../core/api/authApi";
import { useAppState } from "../../state";
import { ub64 } from "../../../../../utils/utils";
import { useImmer } from "use-immer";
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import type { Message, Size2D } from "../../../core/types";
import type { Message, Size2D } from "../../../../../core/types";
import { EmojiMenu } from "./EmojiMenu";
interface MessageContextMenuProps {
@@ -3,8 +3,8 @@ import { useAppState } from "../../state";
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper";
import { setGlobalMessageHandler } from "../../../core/websocket";
import type { Message, WebSocketMessage } from "../../../core/types";
import { setGlobalMessageHandler } from "../../../../../core/websocket";
import type { Message, WebSocketMessage } from "../../../../../core/types";
import defaultAvatar from "../../../../../images/default-avatar.png";
import AnimatedOpacity from "../core/animations/AnimatedOpacity";
import type { DMPanel } from "../../panels/DMPanel";
@@ -1,6 +1,6 @@
import { useAppState } from "../../state";
import { useState, useEffect } from "react";
import type { Reaction } from "../../../core/types";
import type { Reaction } from "../../../../../core/types";
interface MessageReactionsProps {
reactions?: Reaction[];
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import type { Message } from "../../../core/types";
import type { Message } from "../../../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { MaterialTextField } from "../core/TextField";
@@ -1,5 +1,5 @@
import type { DialogProps } from "../../../core/types";
import type { UserProfile } from "../../../core/types";
import type { DialogProps } from "../../../../../core/types";
import type { UserProfile } from "../../../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { formatTime } from "../../../../../utils/utils";
import defaultAvatar from "../../../../../images/default-avatar.png";
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import type { Size2D, Rect } from "../../../core/types";
import type { Size2D, Rect } from "../../../../../core/types";
interface ImageCropperProps {
onCrop: (croppedImageData: string) => void;
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, type FormEvent } from "react";
import defaultAvatar from "../../../../../images/default-avatar.png";
import type { TextField } from "mdui/components/text-field";
import type { DialogProps } from "../../../core/types";
import type { DialogProps } from "../../../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import useProfile from "../../hooks/useProfile";
import { ImageCropper } from "./ImageCropper";
@@ -1,12 +1,12 @@
import { useState, useEffect } from "react";
import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config";
import type { DialogProps } from "../../../core/types";
import { PRODUCT_NAME, API_BASE_URL } from "../../../../../core/config";
import type { DialogProps } from "../../../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../push-notifications";
import { isElectron } from "../../../electron/electron";
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../../../core/push-notifications/push-notifications";
import { isElectron } from "../../../../../core/electron/electron";
import { useAppState } from "../../state";
import type { Switch } from "mdui/components/switch";
import { getAuthHeaders } from "../../../../../api/authApi";
import { getAuthHeaders } from "../../../../../core/api/authApi";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings");
+3 -3
View File
@@ -6,9 +6,9 @@ import {
fetchDMHistory,
decryptDm,
sendDMViaWebSocket
} from "../../../../api/dmApi";
import type { User, Message, DmEncryptedJSON } from "../../core/types";
import { websocket } from "../../core/websocket";
} from "../../../../core/api/dmApi";
import type { User, Message, DmEncryptedJSON } from "../../../../core/types";
import { websocket } from "../../../../core/websocket";
export interface DMUser extends User {
lastMessage?: string;
@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from "react";
import { useAppState } from "../state";
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../../../api/profileApi";
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../../../core/api/profileApi";
import { showSuccess, showError } from "../../../../utils/notification";
export default function useProfile() {
+2 -2
View File
@@ -6,8 +6,8 @@ import {
sendDmWithFiles,
editDmEnvelope,
deleteDmEnvelope
} from "../../../../api/dmApi";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../core/types";
} from "../../../../core/api/dmApi";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../../../core/types";
import type { UserState } from "../state";
export interface DMPanelData {
@@ -1,4 +1,4 @@
import type { Message, WebSocketMessage } from "../../core/types";
import type { Message, WebSocketMessage } from "../../../../core/types";
import type { UserState } from "../state";
export interface MessagePanelState {
@@ -1,8 +1,8 @@
import { MessagePanel } from "./MessagePanel";
import { API_BASE_URL } from "../../core/config";
import { getAuthHeaders } from "../../../../api/authApi";
import { request } from "../../core/websocket";
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../core/types";
import { API_BASE_URL } from "../../../../core/config";
import { getAuthHeaders } from "../../../../core/api/authApi";
import { request } from "../../../../core/websocket";
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../../../core/types";
import type { UserState } from "../state";
export class PublicChatPanel extends MessagePanel {
@@ -1,13 +0,0 @@
import { LeftPanel } from "../components/chat/LeftPanel";
import { RightPanel } from "../components/chat/RightPanel";
export default function ChatScreen() {
return (
<div id="chat-interface">
<div className="all-container">
<LeftPanel />
<RightPanel />
</div>
</div>
);
}
@@ -1,25 +0,0 @@
export default function DownloadAppScreen() {
return (
<div className="download-app-screen">
<div className="inner">
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
<p>
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
вам нужно скачать приложение мессенджера.
</p>
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
<mdui-button>Скачать на GitHub</mdui-button>
</a>
<p>
Если возникнут сложности или есть вопросы, нажмите кнопку!
</p>
<a href="https://t.me/denis0001-dev">
<mdui-button>Написать в поддержку</mdui-button>
</a>
</div>
</div>
)
}
@@ -1,142 +0,0 @@
import { useImmer } from "use-immer";
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
import { AuthContainer, AuthHeader } from "../components/Auth";
import type { ErrorResponse, LoginRequest, LoginResponse } from "../../core/types";
import { ensureKeysOnLogin } from "../../../../api/authApi";
import { API_BASE_URL } from "../../core/config";
import { useRef } from "react";
import type { TextField } from "mdui/components/text-field";
import { useAppState } from "../state";
import { useNavigate } from "react-router-dom";
import { MaterialTextField } from "../components/core/TextField";
import { initialize, isSupported, startElectronReceiver, subscribe } from "../../push-notifications";
import { isElectron } from "../../electron/electron";
export default function LoginScreen() {
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setUser = useAppState(state => state.setUser);
const navigate = useNavigate();
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const usernameElement = useRef<TextField>(null);
const passwordElement = useRef<TextField>(null);
return (
<AuthContainer>
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
<div className="auth-body">
<AlertsContainer alerts={alerts} />
<form
onSubmit={async (e) => {
e.preventDefault();
const username = usernameElement.current!.value.trim();
const password = passwordElement.current!.value.trim();
if (!username || !password) {
showAlert("danger", "Пожалуйста, заполните все поля");
return;
}
try {
const request: LoginRequest = {
username: username,
password: password
}
const response = await fetch(`${API_BASE_URL}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request)
});
if (response.ok) {
const data: LoginResponse = await response.json();
// Store the JWT token first
setUser(data.token, data.user);
// Setup keys with the token we just received
try {
await ensureKeysOnLogin(password, data.token);
} catch (e) {
console.error("Key setup failed:", e);
}
navigate("/chat");
// Initialize notifications
try {
if (isSupported()) {
const initialized = await initialize();
if (initialized) {
await subscribe(data.token);
// For Electron, start the notification receiver
if (isElectron) {
await startElectronReceiver();
}
console.log("Notifications enabled");
} else {
console.log("Notification permission denied");
}
} else {
console.log("Notifications not supported");
}
} catch (e) {
console.error("Notification setup failed:", e);
}
} else {
const data: ErrorResponse = await response.json();
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
}
} catch (error) {
showAlert("danger", "Ошибка соединения с сервером");
}
}}>
<MaterialTextField
label="Имя пользователя"
id="login-username"
name="username"
variant="outlined"
icon="person--filled"
autocomplete="username"
required
ref={usernameElement} />
<MaterialTextField
label="Пароль"
id="login-password"
name="password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="current-password"
required
ref={passwordElement} />
<mdui-button type="submit">Войти</mdui-button>
</form>
<div className="text-center">
<p>
Ещё нет аккаунта?
<a
href="#"
className="link"
onClick={() => navigate("/register")}>
Зарегистрируйтесь
</a>
</p>
</div>
</div>
</AuthContainer>
)
}
@@ -1,148 +0,0 @@
import { useImmer } from "use-immer";
// import { showLogin } from "../../navigation";
import { AuthContainer, AuthHeader } from "../components/Auth";
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
import { useRef } from "react";
import { TextField } from "mdui/components/text-field";
import type { ErrorResponse, RegisterRequest, LoginResponse } from "../../core/types";
import { API_BASE_URL } from "../../core/config";
import { useAppState } from "../state";
import { useNavigate } from "react-router-dom";
import { MaterialTextField } from "../components/core/TextField";
import { ensureKeysOnLogin } from "../../../../api/authApi";
export default function RegisterScreen() {
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setUser = useAppState(state => state.setUser);
const navigate = useNavigate();
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const usernameElement = useRef<TextField>(null);
const passwordElement = useRef<TextField>(null);
const confirmPasswordElement = useRef<TextField>(null);
return (
<AuthContainer>
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
<div className="auth-body">
<AlertsContainer alerts={alerts} />
<form onSubmit={async (e) => {
e.preventDefault();
const username = usernameElement.current!.value.trim();
const password = passwordElement.current!.value.trim();
const confirmPassword = confirmPasswordElement.current!.value.trim();
if (!username || !password || !confirmPassword) {
showAlert("danger", "Пожалуйста, заполните все поля");
return;
}
if (password !== confirmPassword) {
showAlert("danger", "Пароли не совпадают");
return;
}
if (username.length < 3 || username.length > 20) {
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
return;
}
if (password.length < 5 || password.length > 50) {
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
return;
}
try {
const request: RegisterRequest = {
username: username,
password: password,
confirm_password: confirmPassword
}
const response = await fetch(`${API_BASE_URL}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request)
});
if (response.ok) {
const data: LoginResponse = await response.json();
// Store the JWT token first
setUser(data.token, data.user);
// Setup keys with the token we just received
try {
await ensureKeysOnLogin(password, data.token);
} catch (e) {
console.error("Key setup failed:", e);
}
navigate("/chat");
} else {
const data: ErrorResponse = await response.json();
showAlert("danger", data.message || "Ошибка при регистрации");
}
} catch (error) {
showAlert("danger", "Ошибка соединения с сервером");
}
}}>
<MaterialTextField
label="Имя пользователя"
id="register-username"
name="username"
variant="outlined"
icon="person--filled"
autocomplete="username"
maxlength={20}
counter
required
ref={usernameElement} />
<MaterialTextField
label="Пароль"
id="register-password"
name="password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required
ref={passwordElement} />
<MaterialTextField
label="Подтвердите пароль"
id="register-confirm-password"
name="confirm_password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required
ref={confirmPasswordElement} />
<mdui-button type="submit">Зарегистрироваться</mdui-button>
</form>
<div className="text-center">
<p>
Уже есть аккаунт?
<a
href="#"
id="login-link"
className="link"
onClick={() => navigate("/login")}>
Войдите
</a>
</p>
</div>
</div>
</AuthContainer>
)
}
+7 -7
View File
@@ -1,14 +1,14 @@
import { create } from "zustand";
import type { Message, User } from "../core/types";
import { request } from "../core/websocket";
import type { Message, User } from "../../../core/types";
import { request } from "../../../core/websocket";
import { MessagePanel } from "./panels/MessagePanel";
import { PublicChatPanel } from "./panels/PublicChatPanel";
import { DMPanel, type DMPanelData } from "./panels/DMPanel";
import { getAuthHeaders } from "../../../api/authApi";
import { restoreKeys } from "../../../api/authApi";
import { API_BASE_URL } from "../core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "../push-notifications";
import { isElectron } from "../electron/electron";
import { getAuthHeaders } from "../../../core/api/authApi";
import { restoreKeys } from "../../../core/api/authApi";
import { API_BASE_URL } from "../../../core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "../../../core/push-notifications/push-notifications";
import { isElectron } from "../../../core/electron/electron";
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
+1 -1
View File
@@ -1,6 +1,6 @@
import { Navigate, useNavigate } from "react-router-dom";
import { useAppState } from "../chat/ui/state";
import { isElectron } from "../chat/electron/electron";
import { isElectron } from "../../core/electron/electron";
import "./home.scss";
function GitHubLink({ children }: { children: React.ReactNode }) {
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../../css/common/material" as *;
@use "../../css/material" as *;
.homepage {
min-height: 100vh;