mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement fromchat.ru/@username and fromchat.ru/?u=userId links, mentions
This commit is contained in:
+62
-18
@@ -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 { ElectronTitleBar } from "./Electron";
|
||||||
import { useAppState } from "./pages/chat/state";
|
import { useAppState } from "./pages/chat/state";
|
||||||
import { useEffect, useState, lazy } from "react";
|
import { lazy, useEffect, useState } from "react";
|
||||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
import { parseProfileLink } from "./core/profileLinks.ts";
|
||||||
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
import NotFoundPage from "./pages/not-found/NotFoundPage.tsx";
|
||||||
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
import ProtectedRoute from "./pages/ProtectedRoute.tsx";
|
||||||
|
import DownloadAppPage from "./pages/download-app/DownloadAppPage.tsx";
|
||||||
|
|
||||||
// Lazy load route components
|
// Lazy load route components
|
||||||
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
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 RegisterPage = lazy(() => import("./pages/auth/RegisterPage"));
|
||||||
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
||||||
|
|
||||||
|
const routeConfig: RouteObject[] = [
|
||||||
|
{ path: "/", element: <HomePage /> },
|
||||||
|
{ path: "/login", element: <LoginPage /> },
|
||||||
|
{ path: "/register", element: <RegisterPage /> },
|
||||||
|
{ path: "/download-app", element: <DownloadAppPage /> },
|
||||||
|
{
|
||||||
|
path: "/chat",
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ChatPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ path: "*", element: <SmartCatchAll /> }
|
||||||
|
];
|
||||||
|
|
||||||
|
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 <NotFoundPage />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { restoreUserFromStorage } = useAppState();
|
const { restoreUserFromStorage } = useAppState();
|
||||||
const [authReady, setAuthReady] = useState(false);
|
const [authReady, setAuthReady] = useState(false);
|
||||||
|
|
||||||
// Restore user from localStorage on app initialization
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
restoreUserFromStorage().finally(() => {
|
restoreUserFromStorage().finally(() => {
|
||||||
setAuthReady(true);
|
setAuthReady(true);
|
||||||
@@ -28,18 +81,9 @@ export default function App() {
|
|||||||
<ElectronTitleBar />
|
<ElectronTitleBar />
|
||||||
<div id="main-wrapper">
|
<div id="main-wrapper">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
{routeConfig.map((route, index) => (
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route key={index} path={route.path} element={route.element} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
))}
|
||||||
<Route path="/download-app" element={<DownloadAppPage />} />
|
|
||||||
<Route path="/">
|
|
||||||
<Route path="chat" element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<ChatPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
|
||||||
</Route>
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -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=<userId> (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=<userId> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -375,3 +375,25 @@
|
|||||||
justify-content: center;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,69 @@ import { RightPanel } from "./right/RightPanel";
|
|||||||
import "@/pages/chat/css/chat.scss";
|
import "@/pages/chat/css/chat.scss";
|
||||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||||
import { CallWindow } from "./right/calls/CallWindow";
|
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() {
|
export default function ChatPage() {
|
||||||
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, setProfileDialog } = useAppState();
|
||||||
|
const processedProfile = useRef<string | null>(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;
|
if (navigateDownloadApp) return navigateDownloadApp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
|||||||
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||||
import { getAuthHeaders } from "@/core/api/authApi";
|
import { getAuthHeaders } from "@/core/api/authApi";
|
||||||
import { useAppState } from "@/pages/chat/state";
|
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 { ub64 } from "@/utils/utils";
|
||||||
import { useImmer } from "use-immer";
|
import { useImmer } from "use-immer";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { parseProfileLink } from "@/core/profileLinks";
|
||||||
|
|
||||||
interface MessageReactionsProps {
|
interface MessageReactionsProps {
|
||||||
reactions?: Reaction[];
|
reactions?: Reaction[];
|
||||||
@@ -147,7 +148,6 @@ interface Rect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
||||||
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
|
|
||||||
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
|
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
|
||||||
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
||||||
const [downloadingPaths, updateDownloadingPaths] = useImmer<Set<string>>(new Set());
|
const [downloadingPaths, updateDownloadingPaths] = useImmer<Set<string>>(new Set());
|
||||||
@@ -164,15 +164,29 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
||||||
const dmEnvelope = message.runtimeData?.dmEnvelope;
|
const dmEnvelope = message.runtimeData?.dmEnvelope;
|
||||||
|
|
||||||
useEffect(() => {
|
const formattedMessage = useMemo(() => {
|
||||||
(async () => {
|
// First, temporarily replace existing fromchat.ru links to avoid conflicts
|
||||||
setFormattedMessage({
|
const linkPlaceholders: string[] = [];
|
||||||
__html: DOMPurify.sanitize(
|
let content = message.content.replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => {
|
||||||
await parse(message.content)
|
const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`;
|
||||||
).trim()
|
linkPlaceholders.push(match);
|
||||||
});
|
return placeholder;
|
||||||
})();
|
});
|
||||||
}, [message]);
|
|
||||||
|
// Now process @mentions that aren't in existing links
|
||||||
|
content = content.replace(/@([a-zA-Z0-9_.-]+)/g, (match, username) => {
|
||||||
|
return `<a href="https://fromchat.ru/@${username}" class="mention-link">${match}</a>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
// Auto-decrypt images in DMs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -406,6 +420,44 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleLinkClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
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) {
|
function handleContextMenu(e: React.MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -460,7 +512,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
</Quote>
|
</Quote>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`message-content ${isEmojiMessage ? "emoji-content" : ""} ${isSingleEmojiMessage ? "single-emoji-content" : ""}`} dangerouslySetInnerHTML={formattedMessage} />
|
<div
|
||||||
|
className={`message-content ${isEmojiMessage ? "emoji-content" : ""} ${isSingleEmojiMessage ? "single-emoji-content" : ""}`}
|
||||||
|
dangerouslySetInnerHTML={formattedMessage}
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
/>
|
||||||
|
|
||||||
{message.files && message.files.length > 0 && (
|
{message.files && message.files.length > 0 && (
|
||||||
<mdui-list className="message-attachments">
|
<mdui-list className="message-attachments">
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.2.7",
|
"dompurify": "^3.2.7",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
|
"escape-string-regexp": "^5.0.0",
|
||||||
"marked": "^16.3.0",
|
"marked": "^16.3.0",
|
||||||
"mdui": "^2.1.4",
|
"mdui": "^2.1.4",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user