diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 31c4fad..a7354b3 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,10 +1,11 @@
-import { BrowserRouter, Routes, Route } from "react-router-dom";
+import { BrowserRouter, Routes, Route, useNavigate, matchRoutes, type RouteObject } from "react-router-dom";
import { ElectronTitleBar } from "./Electron";
import { useAppState } from "./pages/chat/state";
-import { useEffect, useState, lazy } from "react";
-import ProtectedRoute from "./pages/ProtectedRoute";
-import NotFoundPage from "./pages/not-found/NotFoundPage";
-import DownloadAppPage from "./pages/download-app/DownloadAppPage";
+import { lazy, useEffect, useState } from "react";
+import { parseProfileLink } from "./core/profileLinks.ts";
+import NotFoundPage from "./pages/not-found/NotFoundPage.tsx";
+import ProtectedRoute from "./pages/ProtectedRoute.tsx";
+import DownloadAppPage from "./pages/download-app/DownloadAppPage.tsx";
// Lazy load route components
const HomePage = lazy(() => import("./pages/home/HomePage"));
@@ -12,11 +13,63 @@ const LoginPage = lazy(() => import("./pages/auth/LoginPage"));
const RegisterPage = lazy(() => import("./pages/auth/RegisterPage"));
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
+const routeConfig: RouteObject[] = [
+ { path: "/", element: },
+ { path: "/login", element: },
+ { path: "/register", element: },
+ { path: "/download-app", element: },
+ {
+ path: "/chat",
+ element: (
+
+
+
+ )
+ },
+ { path: "*", element: }
+];
+
+function SmartCatchAll() {
+ const navigate = useNavigate();
+ const [showNotFound, setShowNotFound] = useState(false);
+
+ function isValidRoute(path: string): boolean {
+ const validRoutes = routeConfig.filter(route => route.path !== "*");
+ const matches = matchRoutes(validRoutes, path);
+
+ return Boolean(matches && matches.length > 0);
+ }
+
+ useEffect(() => {
+ if (isValidRoute(location.pathname)) {
+ setShowNotFound(false);
+ return;
+ }
+
+ // Check if it's a profile link
+ const profileInfo = parseProfileLink(); // no url specified intentionally to let it use the current url
+
+ if (profileInfo) {
+ setShowNotFound(false);
+ navigate("/chat", {
+ replace: true,
+ state: { profileInfo }
+ });
+ } else {
+ setShowNotFound(true);
+ }
+ }, [navigate]);
+
+ // Show 404 page
+ if (showNotFound) {
+ return ;
+ }
+}
+
export default function App() {
const { restoreUserFromStorage } = useAppState();
const [authReady, setAuthReady] = useState(false);
- // Restore user from localStorage on app initialization
useEffect(() => {
restoreUserFromStorage().finally(() => {
setAuthReady(true);
@@ -28,18 +81,9 @@ export default function App() {
- } />
- } />
- } />
- } />
-
-
-
-
- } />
-
- } />
+ {routeConfig.map((route, index) => (
+
+ ))}
diff --git a/frontend/src/core/profileLinks.ts b/frontend/src/core/profileLinks.ts
new file mode 100644
index 0000000..873b92a
--- /dev/null
+++ b/frontend/src/core/profileLinks.ts
@@ -0,0 +1,39 @@
+/**
+ * @fileoverview Utility functions for handling profile links
+ * @description Functions to parse and handle profile links in markdown content.
+ * Supports two formats:
+ * - fromchat.ru/@username (e.g., fromchat.ru/@john_doe)
+ * - fromchat.ru/?u= (e.g., fromchat.ru/?u=123)
+ * @author Cursor
+ * @version 1.0.0
+ */
+
+import escapeStringRegexp from "escape-string-regexp";
+
+/**
+ * Parses a profile link URL and extracts user information
+ * @param url - The URL to parse
+ * @returns Object with user ID and username if it's a valid profile link, null otherwise
+ */
+export function parseProfileLink(url: string = location.pathname): { userId?: number; username?: string } | null {
+ try {
+ let host: string = url.startsWith("@") ? "" : !url.startsWith("/") ? "https://fromchat.ru/" : "/";
+
+ // Handle fromchat.ru/@username format
+ const usernameMatch = url.match(new RegExp(`${escapeStringRegexp(host)}@([a-zA-Z0-9_-]+)`));
+ if (usernameMatch) {
+ return { username: usernameMatch[1] };
+ }
+
+ // Handle fromchat.ru/?u= format
+ const userIdMatch = url.match(new RegExp(`${escapeStringRegexp(host)}\\?u=(\\d+)`));
+ if (userIdMatch) {
+ return { userId: Number(userIdMatch[1]) };
+ }
+
+ return null;
+ } catch (error) {
+ console.error('Error parsing profile link:', error);
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/pages/chat/css/_message.scss b/frontend/src/pages/chat/css/_message.scss
index 15e0069..2662ad6 100644
--- a/frontend/src/pages/chat/css/_message.scss
+++ b/frontend/src/pages/chat/css/_message.scss
@@ -375,3 +375,25 @@
justify-content: center;
}
}
+
+// Mention link styling
+.message-content {
+ .mention-link {
+ color: $color-dark-primary;
+ text-decoration: none;
+ font-weight: 500;
+ border-radius: 4px;
+ padding: 2px 4px;
+ transition: all 0.2s ease;
+ background-color: rgba(145, 206, 244, 0.1); // TODO adjust
+
+ &:hover {
+ background-color: rgba(145, 206, 244, 0.2); // TODO adjust
+ transform: translateY(-1px);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+ }
+}
diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx
index 1183ab9..56486bc 100644
--- a/frontend/src/pages/chat/ui/ChatPage.tsx
+++ b/frontend/src/pages/chat/ui/ChatPage.tsx
@@ -3,9 +3,69 @@ import { RightPanel } from "./right/RightPanel";
import "@/pages/chat/css/chat.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { CallWindow } from "./right/calls/CallWindow";
+import { useEffect, useRef } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+import { useAppState } from "@/pages/chat/state";
+import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi";
export default function ChatPage() {
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { user, setProfileDialog } = useAppState();
+ const processedProfile = useRef(null);
+
+ // Handle profile links ONLY from navigation state (from SmartCatchAll)
+ useEffect(() => {
+ async function handleProfileLink() {
+ if (!user.authToken) return;
+
+ // Only process profile links that come from navigation state (SmartCatchAll)
+ // This prevents re-processing on page refresh
+ if (!location.state?.profileInfo) return;
+
+ const profileInfo = location.state.profileInfo;
+
+ // Create a unique key for this profile
+ const profileKey = profileInfo.userId
+ ? `user_${profileInfo.userId}`
+ : `username_${profileInfo.username}`;
+
+ // Skip if we've already processed this exact profile
+ if (processedProfile.current === profileKey) return;
+
+ processedProfile.current = profileKey; // Mark this specific profile as processed
+
+ try {
+ let userProfile;
+
+ if (profileInfo.userId) {
+ // Fetch by user ID
+ userProfile = await fetchUserProfileById(user.authToken, profileInfo.userId);
+ } else if (profileInfo.username) {
+ // Fetch by username
+ userProfile = await fetchUserProfile(user.authToken, profileInfo.username);
+ }
+
+ if (userProfile) {
+ setProfileDialog({
+ ...userProfile,
+ userId: userProfile.id,
+ memberSince: userProfile.created_at,
+ isOwnProfile: userProfile.id === user.currentUser?.id
+ });
+ }
+
+ // Clear the navigation state to prevent re-processing on refresh
+ navigate(location.pathname, { replace: true, state: null });
+ } catch (error) {
+ console.error("Failed to fetch user profile from URL:", error);
+ }
+ }
+
+ handleProfileLink();
+ }, [location.state, user.authToken, user.currentUser?.id, setProfileDialog, navigate, location.pathname]);
+
if (navigateDownloadApp) return navigateDownloadApp;
return (
diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx
index e864b3b..480ab4a 100644
--- a/frontend/src/pages/chat/ui/right/Message.tsx
+++ b/frontend/src/pages/chat/ui/right/Message.tsx
@@ -10,10 +10,11 @@ import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { getAuthHeaders } from "@/core/api/authApi";
import { useAppState } from "@/pages/chat/state";
-import { fetchUserProfileById } from "@/core/api/profileApi";
+import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi";
import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer";
import { createPortal } from "react-dom";
+import { parseProfileLink } from "@/core/profileLinks";
interface MessageReactionsProps {
reactions?: Reaction[];
@@ -147,7 +148,6 @@ interface Rect {
}
export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false, dmRecipientPublicKey }: MessageProps) {
- const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
const [decryptedFiles, updateDecryptedFiles] = useImmer