Implement fromchat.ru/@username and fromchat.ru/?u=userId links, mentions

This commit is contained in:
2025-10-20 21:12:40 +03:00
Unverified
parent 818b7c1b46
commit 97f19ce52b
6 changed files with 252 additions and 30 deletions
+60
View File
@@ -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<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;
return (
+68 -12
View File
@@ -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<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = 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 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 `<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
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) {
e.preventDefault();
e.stopPropagation();
@@ -460,7 +512,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
</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 && (
<mdui-list className="message-attachments">