From 97f19ce52bc196feebcbeea2af063aac30d54eff Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 20 Oct 2025 21:12:40 +0300 Subject: [PATCH] Implement fromchat.ru/@username and fromchat.ru/?u=userId links, mentions --- frontend/src/App.tsx | 80 +++++++++++++++----- frontend/src/core/profileLinks.ts | 39 ++++++++++ frontend/src/pages/chat/css/_message.scss | 22 ++++++ frontend/src/pages/chat/ui/ChatPage.tsx | 60 +++++++++++++++ frontend/src/pages/chat/ui/right/Message.tsx | 80 +++++++++++++++++--- package.json | 1 + 6 files changed, 252 insertions(+), 30 deletions(-) create mode 100644 frontend/src/core/profileLinks.ts 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>(new Map()); const [loadedImages, updateLoadedImages] = useImmer>(new Set()); const [downloadingPaths, updateDownloadingPaths] = useImmer>(new Set()); @@ -164,15 +164,29 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD const imageRefs = useRef>(new Map()); const dmEnvelope = message.runtimeData?.dmEnvelope; - useEffect(() => { - (async () => { - setFormattedMessage({ - __html: DOMPurify.sanitize( - await parse(message.content) - ).trim() - }); - })(); - }, [message]); + const formattedMessage = useMemo(() => { + // First, temporarily replace existing fromchat.ru links to avoid conflicts + const linkPlaceholders: string[] = []; + let content = message.content.replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => { + const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`; + linkPlaceholders.push(match); + return placeholder; + }); + + // Now process @mentions that aren't in existing links + content = content.replace(/@([a-zA-Z0-9_.-]+)/g, (match, username) => { + return `${match}`; + }); + + // Restore the original links + linkPlaceholders.forEach((link, index) => { + content = content.replace(`__LINK_PLACEHOLDER_${index}__`, link); + }); + + return { + __html: DOMPurify.sanitize(parse(content, { async: false })).trim() + }; + }, [message.content]); // Auto-decrypt images in DMs useEffect(() => { @@ -406,6 +420,44 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD } } + async function handleLinkClick(e: React.MouseEvent) { + const target = e.target as HTMLElement; + + if (target.tagName === 'A') { + const profileLink = parseProfileLink((target as HTMLAnchorElement).href); + + if (profileLink) { + e.preventDefault(); + e.stopPropagation(); + + if (!user.authToken) return; + + try { + let userProfile; + + if (profileLink.userId) { + userProfile = await fetchUserProfileById(user.authToken, profileLink.userId); + } else if (profileLink.username) { + userProfile = await fetchUserProfile(user.authToken, profileLink.username); + } + + if (userProfile) { + setProfileDialog({ + ...userProfile, + userId: userProfile.id, + memberSince: userProfile.created_at, + isOwnProfile: userProfile.id === user.currentUser?.id + }); + } else { + throw new Error("Invalid link: " + (target as HTMLAnchorElement).href); + } + } catch (error) { + console.error("Failed to fetch user profile from link:", error); + } + } + } + } + function handleContextMenu(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); @@ -460,7 +512,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD )} -
+
{message.files && message.files.length > 0 && ( diff --git a/package.json b/package.json index 0559dd5..0949b79 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "dependencies": { "dompurify": "^3.2.7", "electron-squirrel-startup": "^1.0.1", + "escape-string-regexp": "^5.0.0", "marked": "^16.3.0", "mdui": "^2.1.4", "react": "^19.1.1",