feat: enhance attachment display in inbox

This commit is contained in:
Mohamed Marrouchi 2025-01-17 08:00:45 +01:00
parent f7363563ad
commit 8d1bb47b2a
7 changed files with 134 additions and 50 deletions

View File

@ -7,20 +7,22 @@
*/ */
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { Button, Dialog, DialogContent } from "@mui/material"; import { Box, Button, Dialog, DialogContent, Typography } from "@mui/material";
import { FC } from "react"; import { FC } from "react";
import { DialogTitle } from "@/app-components/dialogs"; import { DialogTitle } from "@/app-components/dialogs";
import { useDialog } from "@/hooks/useDialog"; import { useDialog } from "@/hooks/useDialog";
import { useGetAttachmentUrl } from "@/hooks/useGetAttachmentUrl"; import { useGetAttachmentMetadata } from "@/hooks/useGetAttachmentMetadata";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { import {
FileType, FileType,
IAttachmentPayload,
StdIncomingAttachmentMessage, StdIncomingAttachmentMessage,
StdOutgoingAttachmentMessage, StdOutgoingAttachmentMessage,
} from "@/types/message.types"; } from "@/types/message.types";
interface AttachmentInterface { interface AttachmentInterface {
name?: string;
url?: string; url?: string;
} }
@ -70,17 +72,23 @@ const componentMap: { [key in FileType]: FC<AttachmentInterface> } = {
const { t } = useTranslate(); const { t } = useTranslate();
return ( return (
<div> <Box>
<span style={{ fontWeight: "bold" }}>{t("label.attachment")}: </span> <Typography
component="span"
className="cs-message__text-content"
mr={2}
>
{props.name}
</Typography>
<Button <Button
href={props.url} href={props.url}
endIcon={<DownloadIcon />} endIcon={<DownloadIcon />}
color="inherit" color="inherit"
variant="text" variant="contained"
> >
{t("button.download")} {t("button.download")}
</Button> </Button>
</div> </Box>
); );
}, },
[FileType.video]: ({ url }: AttachmentInterface) => ( [FileType.video]: ({ url }: AttachmentInterface) => (
@ -91,23 +99,43 @@ const componentMap: { [key in FileType]: FC<AttachmentInterface> } = {
[FileType.unknown]: ({ url }: AttachmentInterface) => <>Unknown Type:{url}</>, [FileType.unknown]: ({ url }: AttachmentInterface) => <>Unknown Type:{url}</>,
}; };
export const AttachmentViewer = (props: { export const MessageAttachmentViewer = ({
attachment,
}: {
attachment: IAttachmentPayload;
}) => {
const metadata = useGetAttachmentMetadata(attachment.payload);
const AttachmentViewerForType = componentMap[attachment.type];
if (!metadata) {
return <>No attachment to display</>;
}
return <AttachmentViewerForType url={metadata.url} name={metadata.name} />;
};
export const MessageAttachmentsViewer = (props: {
message: StdIncomingAttachmentMessage | StdOutgoingAttachmentMessage; message: StdIncomingAttachmentMessage | StdOutgoingAttachmentMessage;
}) => { }) => {
const message = props.message; const message = props.message;
const getUrl = useGetAttachmentUrl();
// if the attachment is an array show a 4x4 grid with a +{number of remaining attachment} and open a modal to show the list of attachments // if the attachment is an array show a 4x4 grid with a +{number of remaining attachment} and open a modal to show the list of attachments
// Remark: Messenger doesn't send multiple attachments when user sends multiple at once, it only relays the first one to Hexabot // Remark: Messenger doesn't send multiple attachments when user sends multiple at once, it only relays the first one to Hexabot
// TODO: Implenent this // TODO: Implenent this
if (!message.attachment) { if (!message.attachment) {
return <>No attachment to display</>; return <>No attachment to display</>;
} else if (Array.isArray(message.attachment)) {
return <>Not yet Implemented</>;
} }
const AttachmentViewerForType = componentMap[message.attachment.type]; const attachments = Array.isArray(message.attachment)
const url = getUrl(message.attachment?.payload); ? message.attachment
: [message.attachment];
return <AttachmentViewerForType url={url} />; return attachments.map((attachment, idx) => {
return (
<MessageAttachmentViewer
key={`${attachment.payload.id}-${idx}`}
attachment={attachment}
/>
);
});
}; };

View File

@ -21,7 +21,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
import { useGetAttachmentUrl } from "@/hooks/useGetAttachmentUrl"; import { useGetAttachmentMetadata } from "@/hooks/useGetAttachmentMetadata";
import { import {
AnyButton as ButtonType, AnyButton as ButtonType,
OutgoingPopulatedListMessage, OutgoingPopulatedListMessage,
@ -190,7 +190,7 @@ const ListCard = forwardRef<
buttons: ButtonType[]; buttons: ButtonType[];
} }
>(function ListCardRef(props, ref) { >(function ListCardRef(props, ref) {
const getUrl = useGetAttachmentUrl(); const metadata = useGetAttachmentMetadata(props.content.image_url?.payload);
return ( return (
<Card <Card
@ -205,9 +205,9 @@ const ListCard = forwardRef<
ref={ref} ref={ref}
id={"A" + props.id} id={"A" + props.id}
> >
{props.content.image_url ? ( {metadata ? (
<CardMedia <CardMedia
image={getUrl(props.content.image_url.payload)} image={metadata.url}
sx={{ height: "185px" }} sx={{ height: "185px" }}
title={props.content.title} title={props.content.title}
/> />

View File

@ -1,11 +1,12 @@
/* /*
* Copyright © 2024 Hexastack. All rights reserved. * Copyright © 2025 Hexastack. All rights reserved.
* *
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/ */
import { Message, MessageModel } from "@chatscope/chat-ui-kit-react"; import { Message, MessageModel } from "@chatscope/chat-ui-kit-react";
import MenuRoundedIcon from "@mui/icons-material/MenuRounded"; import MenuRoundedIcon from "@mui/icons-material/MenuRounded";
import ReplyIcon from "@mui/icons-material/Reply"; import ReplyIcon from "@mui/icons-material/Reply";
@ -17,7 +18,7 @@ import { EntityType } from "@/services/types";
import { IMessage, IMessageFull } from "@/types/message.types"; import { IMessage, IMessageFull } from "@/types/message.types";
import { buildURL } from "@/utils/URL"; import { buildURL } from "@/utils/URL";
import { AttachmentViewer } from "../components/AttachmentViewer"; import { MessageAttachmentsViewer } from "../components/AttachmentViewer";
import { Carousel } from "../components/Carousel"; import { Carousel } from "../components/Carousel";
function hasSameSender( function hasSameSender(
@ -110,7 +111,7 @@ export function getMessageContent(
if ("attachment" in message) { if ("attachment" in message) {
content.push( content.push(
<Message.CustomContent> <Message.CustomContent>
<AttachmentViewer message={message} /> <MessageAttachmentsViewer message={message} />
</Message.CustomContent>, </Message.CustomContent>,
); );
} }

View File

@ -0,0 +1,52 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { EntityType } from "@/services/types";
import { TAttachmentForeignKey } from "@/types/message.types";
import {
extractFilenameFromUrl,
getAttachmentDownloadUrl,
} from "@/utils/attachment";
import { useGet } from "./crud/useGet";
import { useConfig } from "./useConfig";
export const useGetAttachmentMetadata = (
attachmentPayload?: TAttachmentForeignKey,
) => {
const { apiUrl } = useConfig();
const { data: attachment } = useGet(
attachmentPayload?.id || "",
{
entity: EntityType.ATTACHMENT,
},
{
enabled: !!attachmentPayload?.id,
},
);
if (!attachmentPayload) {
return null;
}
if (attachment) {
return {
name: attachmentPayload.id
? attachment.name
: extractFilenameFromUrl(attachment.url),
url: getAttachmentDownloadUrl(apiUrl, attachment),
};
}
const url = getAttachmentDownloadUrl(apiUrl, attachmentPayload);
return {
name: extractFilenameFromUrl(url || "/#"),
url,
};
};

View File

@ -1,20 +0,0 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { AttachmentForeignKey } from "@/types/message.types";
import { getAttachmentDownloadUrl } from "@/utils/attachment";
import { useConfig } from "./useConfig";
export const useGetAttachmentUrl = () => {
const { apiUrl } = useConfig();
return (attachment: AttachmentForeignKey) => {
return getAttachmentDownloadUrl(apiUrl, attachment);
};
};

View File

@ -42,7 +42,7 @@ export enum FileType {
} }
// Attachments // Attachments
export interface AttachmentAttrs { export interface IAttachmentAttrs {
name: string; name: string;
type: string; type: string;
size: number; size: number;
@ -51,15 +51,15 @@ export interface AttachmentAttrs {
url?: string; url?: string;
} }
export type AttachmentForeignKey = { export type TAttachmentForeignKey = {
id: string | null; id: string | null;
/** @deprecated use id instead */ /** @deprecated use id instead */
url?: string; url?: string;
}; };
export interface AttachmentPayload { export interface IAttachmentPayload {
type: FileType; type: FileType;
payload: AttachmentForeignKey; payload: TAttachmentForeignKey;
} }
// Content // Content
@ -95,7 +95,7 @@ export type Payload =
} }
| { | {
type: PayloadType.attachments; type: PayloadType.attachments;
attachments: AttachmentPayload; attachments: IAttachmentPayload;
}; };
export enum QuickReplyType { export enum QuickReplyType {
@ -164,7 +164,7 @@ export type StdOutgoingListMessage = {
}; };
export type StdOutgoingAttachmentMessage = { export type StdOutgoingAttachmentMessage = {
// Stored in DB as `AttachmentPayload`, `Attachment` when populated for channels relaying // Stored in DB as `AttachmentPayload`, `Attachment` when populated for channels relaying
attachment: AttachmentPayload; attachment: IAttachmentPayload;
quickReplies?: StdQuickReply[]; quickReplies?: StdQuickReply[];
}; };
@ -187,7 +187,7 @@ export type StdIncomingLocationMessage = {
export type StdIncomingAttachmentMessage = { export type StdIncomingAttachmentMessage = {
type: PayloadType.attachments; type: PayloadType.attachments;
serialized_text: string; serialized_text: string;
attachment: AttachmentPayload | AttachmentPayload[]; attachment: IAttachmentPayload | IAttachmentPayload[];
}; };
export type StdPluginMessage = { export type StdPluginMessage = {

View File

@ -7,7 +7,8 @@
*/ */
import { AttachmentForeignKey, FileType } from "@/types/message.types"; import { IAttachment } from "@/types/attachment.types";
import { FileType, TAttachmentForeignKey } from "@/types/message.types";
import { buildURL } from "./URL"; import { buildURL } from "./URL";
@ -40,9 +41,31 @@ export function getFileType(mimeType: string): FileType {
export function getAttachmentDownloadUrl( export function getAttachmentDownloadUrl(
baseUrl: string, baseUrl: string,
attachment: AttachmentForeignKey, attachment: TAttachmentForeignKey | IAttachment,
) { ) {
return "id" in attachment && attachment.id return "id" in attachment && attachment.id
? buildURL(baseUrl, `/attachment/download/${attachment.id}`) ? buildURL(baseUrl, `/attachment/download/${attachment.id}`)
: attachment.url; : attachment.url;
} }
export function extractFilenameFromUrl(url: string) {
try {
// Parse the URL to ensure it is valid
const parsedUrl = new URL(url);
// Extract the pathname (part after the domain)
const pathname = parsedUrl.pathname;
// Extract the last segment of the pathname
const filename = pathname.substring(pathname.lastIndexOf("/") + 1);
// Check if a valid filename exists
if (filename && filename.includes(".")) {
return filename;
}
// If no valid filename, return the full URL
return url;
} catch (error) {
// If the URL is invalid, return the input as-is
return url;
}
}