diff --git a/packages/react/src/hooks/useAuthenticatedMediaUrl.js b/packages/react/src/hooks/useAuthenticatedMediaUrl.js
new file mode 100644
index 000000000..f645c9940
--- /dev/null
+++ b/packages/react/src/hooks/useAuthenticatedMediaUrl.js
@@ -0,0 +1,211 @@
+import { useState, useEffect, useContext, useRef } from 'react';
+import RCContext from '../context/RCInstance';
+
+// In-memory cache for protected media blobs to avoid redundant network fetches
+const mediaBlobCache = new Map();
+const MAX_CACHE_SIZE = 100;
+
+const cacheBlob = (url, blob) => {
+ if (mediaBlobCache.size >= MAX_CACHE_SIZE) {
+ const oldestKey = mediaBlobCache.keys().next().value;
+ mediaBlobCache.delete(oldestKey);
+ }
+ mediaBlobCache.set(url, blob);
+};
+
+export const clearMediaBlobCache = () => {
+ mediaBlobCache.clear();
+};
+
+/**
+ * Resolves a media path relative to the host if needed.
+ */
+export const resolveMediaUrl = (mediaPath, host) => {
+ if (!mediaPath || typeof mediaPath !== 'string') {
+ return '';
+ }
+
+ const isHttpUrl = /^https?:\/\//i.test(mediaPath);
+ if (!isHttpUrl && !host) {
+ return mediaPath;
+ }
+
+ try {
+ const targetUrl = isHttpUrl ? new URL(mediaPath) : new URL(mediaPath, host);
+ return targetUrl.toString();
+ } catch (e) {
+ return isHttpUrl ? mediaPath : `${host || ''}${mediaPath}`;
+ }
+};
+
+/**
+ * Checks whether a media URL targets an internal Rocket.Chat /file-upload/ path.
+ */
+export const isProtectedFileUpload = (mediaPath, host) => {
+ if (!mediaPath || typeof mediaPath !== 'string') {
+ return false;
+ }
+
+ const isHttpUrl = /^https?:\/\//i.test(mediaPath);
+ if (!isHttpUrl && !host) {
+ return false;
+ }
+
+ try {
+ const targetUrl = isHttpUrl ? new URL(mediaPath) : new URL(mediaPath, host);
+ const hostUrl = host ? new URL(host) : null;
+ const isSameHost = hostUrl
+ ? targetUrl.origin === hostUrl.origin
+ : !isHttpUrl;
+
+ return isSameHost && targetUrl.pathname.startsWith('/file-upload/');
+ } catch (e) {
+ return false;
+ }
+};
+
+const getCredentialsFromUser = (user, RCInstance) => {
+ const token =
+ user?.authToken || user?.data?.authToken || RCInstance?.token || null;
+ const userId =
+ user?.userId ||
+ user?.data?.userId ||
+ user?._id ||
+ RCInstance?.userId ||
+ null;
+ return { token, userId };
+};
+
+const useAuthenticatedMediaUrl = (mediaPath, host) => {
+ const { RCInstance } = useContext(RCContext);
+ const effectiveHost = host || RCInstance?.getHost?.() || '';
+
+ const [mediaUrl, setMediaUrl] = useState(() =>
+ resolveMediaUrl(mediaPath, effectiveHost)
+ );
+
+ const activeBlobUrlRef = useRef(null);
+
+ useEffect(() => {
+ let isMounted = true;
+ const controller = new AbortController();
+
+ if (!mediaPath || typeof mediaPath !== 'string') {
+ setMediaUrl('');
+ return undefined;
+ }
+
+ const cleanUrl = resolveMediaUrl(mediaPath, effectiveHost);
+ const isProtected = isProtectedFileUpload(mediaPath, effectiveHost);
+
+ // External media or non-protected uploads are delivered directly with zero overhead
+ if (!isProtected) {
+ setMediaUrl(cleanUrl);
+ return undefined;
+ }
+
+ const loadProtectedMedia = async (user) => {
+ const { token, userId } = getCredentialsFromUser(user, RCInstance);
+
+ if (!token || !userId) {
+ if (isMounted) setMediaUrl(cleanUrl);
+ return;
+ }
+
+ // Check in-memory blob cache first
+ if (mediaBlobCache.has(cleanUrl)) {
+ const cachedBlob = mediaBlobCache.get(cleanUrl);
+ const objectUrl = URL.createObjectURL(cachedBlob);
+ if (isMounted) {
+ if (activeBlobUrlRef.current) {
+ URL.revokeObjectURL(activeBlobUrlRef.current);
+ }
+ activeBlobUrlRef.current = objectUrl;
+ setMediaUrl(objectUrl);
+ } else {
+ URL.revokeObjectURL(objectUrl);
+ }
+ return;
+ }
+
+ try {
+ const response = await fetch(cleanUrl, {
+ method: 'GET',
+ headers: {
+ 'X-Auth-Token': token,
+ 'X-User-Id': userId,
+ },
+ credentials: 'include',
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ throw new Error(`Media fetch failed with status ${response.status}`);
+ }
+
+ const blob = await response.blob();
+ cacheBlob(cleanUrl, blob);
+
+ const objectUrl = URL.createObjectURL(blob);
+ if (isMounted) {
+ if (activeBlobUrlRef.current) {
+ URL.revokeObjectURL(activeBlobUrlRef.current);
+ }
+ activeBlobUrlRef.current = objectUrl;
+ setMediaUrl(objectUrl);
+ } else {
+ URL.revokeObjectURL(objectUrl);
+ }
+ } catch (err) {
+ if (err.name !== 'AbortError') {
+ // Gracefully fallback to cleanUrl on fetch error
+ if (isMounted) setMediaUrl(cleanUrl);
+ }
+ }
+ };
+
+ const currentUser = RCInstance?.auth?.currentUser;
+ if (currentUser) {
+ loadProtectedMedia(currentUser);
+ } else if (RCInstance?.auth?.getCurrentUser) {
+ RCInstance.auth
+ .getCurrentUser()
+ .then((user) => {
+ if (isMounted) loadProtectedMedia(user);
+ })
+ .catch(() => {
+ if (isMounted) setMediaUrl(cleanUrl);
+ });
+ } else {
+ loadProtectedMedia(null);
+ }
+
+ const handleAuthChange = (user) => {
+ if (!isMounted) return;
+ if (!user) {
+ clearMediaBlobCache();
+ }
+ loadProtectedMedia(user);
+ };
+
+ if (RCInstance?.auth?.onAuthChange) {
+ RCInstance.auth.onAuthChange(handleAuthChange);
+ }
+
+ return () => {
+ isMounted = false;
+ controller.abort();
+ if (activeBlobUrlRef.current) {
+ URL.revokeObjectURL(activeBlobUrlRef.current);
+ activeBlobUrlRef.current = null;
+ }
+ if (RCInstance?.auth?.removeAuthListener) {
+ RCInstance.auth.removeAuthListener(handleAuthChange);
+ }
+ };
+ }, [RCInstance, mediaPath, effectiveHost]);
+
+ return mediaUrl;
+};
+
+export default useAuthenticatedMediaUrl;
diff --git a/packages/react/src/views/AttachmentHandler/ImageAttachment.js b/packages/react/src/views/AttachmentHandler/ImageAttachment.js
index 84516ce65..6b5071063 100644
--- a/packages/react/src/views/AttachmentHandler/ImageAttachment.js
+++ b/packages/react/src/views/AttachmentHandler/ImageAttachment.js
@@ -5,12 +5,107 @@ import { Box, Avatar, useTheme } from '@embeddedchat/ui-elements';
import AttachmentMetadata from './AttachmentMetadata';
import ImageGallery from '../ImageGallery/ImageGallery';
import RCContext from '../../context/RCInstance';
+import useAuthenticatedMediaUrl from '../../hooks/useAuthenticatedMediaUrl';
+
+const NestedImageAttachment = ({
+ nestedAttachment,
+ host,
+ variantStyles = {},
+ theme,
+ getUserAvatarUrl,
+ extractIdFromUrl,
+ setShowGallery,
+ showGallery,
+}) => {
+ const mediaUrl = useAuthenticatedMediaUrl(nestedAttachment.image_url, host);
+ const titleUrl = useAuthenticatedMediaUrl(
+ nestedAttachment.title_link || nestedAttachment.image_url,
+ host
+ );
+
+ return (
+
+ setShowGallery(true)}
+ css={[
+ css`
+ cursor: pointer;
+ border-radius: inherit;
+ line-height: 0;
+ padding: 0.5rem;
+ `,
+ (nestedAttachment.attachments?.[0]?.type
+ ? variantStyles.pinnedContainer
+ : variantStyles.quoteContainer) ||
+ css`
+ ${nestedAttachment.attachments?.[0]?.type === 'file'
+ ? `border: 2px solid ${theme.colors.border};`
+ : ''}
+ `,
+ ]}
+ >
+ {nestedAttachment.type === 'file' ? (
+
+
+ @{nestedAttachment.author_name}
+
+ ) : (
+ ''
+ )}
+
+
+
+ {showGallery && (
+
+ )}
+
+ );
+};
+
+NestedImageAttachment.propTypes = {
+ nestedAttachment: PropTypes.object,
+ host: PropTypes.string,
+ variantStyles: PropTypes.object,
+ theme: PropTypes.object,
+ getUserAvatarUrl: PropTypes.func,
+ extractIdFromUrl: PropTypes.func,
+ setShowGallery: PropTypes.func,
+ showGallery: PropTypes.bool,
+};
const ImageAttachment = ({
attachment,
host,
type,
- author,
+ author = {},
variantStyles = {},
msg,
}) => {
@@ -22,7 +117,7 @@ const ImageAttachment = ({
return URL;
};
const extractIdFromUrl = (url) => {
- const match = url.match(/\/file-upload\/(.*?)\//);
+ const match = url?.match(/\/file-upload\/(.*?)\//);
return match ? match[1] : null;
};
@@ -35,6 +130,12 @@ const ImageAttachment = ({
setIsExpanded((prevState) => !prevState);
};
+ const mediaUrl = useAuthenticatedMediaUrl(attachment.image_url, host);
+ const titleUrl = useAuthenticatedMediaUrl(
+ attachment.title_link || attachment.image_url,
+ host
+ );
+
return (
setShowGallery(true)}>
(
-
- setShowGallery(true)}
- css={[
- css`
- cursor: pointer;
- border-radius: inherit;
- line-height: 0;
- padding: 0.5rem;
- `,
- (nestedAttachment.attachments[0].type
- ? variantStyles.pinnedContainer
- : variantStyles.quoteContainer) ||
- css`
- ${nestedAttachment.attachments[0].type === 'file'
- ? `border: 2px solid ${theme.colors.border};`
- : ''}
- `,
- ]}
- >
- {nestedAttachment.type === 'file' ? (
- <>
-
-
- @{nestedAttachment.author_name}
-
- >
- ) : (
- ''
- )}
-
-
-
- {showGallery && (
-
- )}
-
+
))}
{showGallery && (
@@ -193,4 +237,8 @@ export default ImageAttachment;
ImageAttachment.propTypes = {
attachment: PropTypes.object,
host: PropTypes.string,
+ type: PropTypes.string,
+ author: PropTypes.object,
+ variantStyles: PropTypes.object,
+ msg: PropTypes.object,
};