فهرست منبع

support images in chat (#2268)

* support images in chat
resolves #1314
* Fab's comments
Fred Lefévère-Laoide 5 ماه پیش
والد
کامیت
4ffb7985b8

+ 5 - 6
doc/gui/examples/controls/chat_calculator.py

@@ -16,20 +16,19 @@
 # Human-computer dialog UI based on the chat control.
 # Human-computer dialog UI based on the chat control.
 # -----------------------------------------------------------------------------------------
 # -----------------------------------------------------------------------------------------
 from math import cos, pi, sin, sqrt, tan  # noqa: F401
 from math import cos, pi, sin, sqrt, tan  # noqa: F401
-from typing import Optional
 
 
 from taipy.gui import Gui
 from taipy.gui import Gui
 
 
 # The user interacts with the Python interpreter
 # The user interacts with the Python interpreter
 users = ["human", "Result"]
 users = ["human", "Result"]
-messages: list[tuple[str, str, str, Optional[str]]] = []
+messages: list[tuple[str, str, str]] = []
 
 
 
 
 def evaluate(state, var_name: str, payload: dict):
 def evaluate(state, var_name: str, payload: dict):
     # Retrieve the callback parameters
     # Retrieve the callback parameters
-    (_, _, expression, sender_id, image_url) = payload.get("args", [])
+    (_, _, expression, sender_id, _) = payload.get("args", [])
     # Add the input content as a sent message
     # Add the input content as a sent message
-    messages.append((f"{len(messages)}", expression, sender_id, image_url))
+    messages.append((f"{len(messages)}", expression, sender_id))
     # Default message used if evaluation fails
     # Default message used if evaluation fails
     result = "Invalid expression"
     result = "Invalid expression"
     try:
     try:
@@ -38,12 +37,12 @@ def evaluate(state, var_name: str, payload: dict):
     except Exception:
     except Exception:
         pass
         pass
     # Add the result as an incoming message
     # Add the result as an incoming message
-    messages.append((f"{len(messages)}", result, users[1], None))
+    messages.append((f"{len(messages)}", result, users[1]))
     state.messages = messages
     state.messages = messages
 
 
 
 
 page = """
 page = """
-<|{messages}|chat|users={users}|sender_id={users[0]}|on_action=evaluate|>
+<|{messages}|chat|users={users}|sender_id={users[0]}|on_action=evaluate|don't allow_send_images|>
 """
 """
 
 
 Gui(page).run(title="Chat - Calculator")
 Gui(page).run(title="Chat - Calculator")

+ 4 - 2
doc/gui/examples/controls/chat_discuss.py

@@ -25,7 +25,7 @@ from taipy.gui import Gui, Icon
 from taipy.gui.gui_actions import navigate, notify
 from taipy.gui.gui_actions import navigate, notify
 
 
 username = ""
 username = ""
-users: list[Union[str, Icon]] = []
+users: list[tuple[str, Union[str, Icon]]] = []
 messages: list[tuple[str, str, str, Optional[str]]] = []
 messages: list[tuple[str, str, str, Optional[str]]] = []
 
 
 Gui.add_shared_variables("messages", "users")
 Gui.add_shared_variables("messages", "users")
@@ -82,4 +82,6 @@ discuss_page = """
 """
 """
 
 
 pages = {"register": register_page, "discuss": discuss_page}
 pages = {"register": register_page, "discuss": discuss_page}
-gui = Gui(pages=pages).run(title="Chat - Discuss")
+
+if __name__ == "__main__":
+    gui = Gui(pages=pages).run(title="Chat - Discuss")

+ 47 - 0
doc/gui/examples/controls/chat_images.py

@@ -0,0 +1,47 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+# A chatting application based on the chat control.
+# In order to see the users' avatars, the image files must be stored next to this script.
+# If you want to test this application locally, you need to use several browsers and/or
+# incognito windows so a given user's context is not reused.
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui, Icon
+
+msgs = [
+    ["1", "msg 1", "Alice", None],
+    ["2", "msg From Another unknown User", "Charles", None],
+    ["3", "This from the sender User", "taipy", "./beatrix-avatar.png"],
+    ["4", "And from another known one", "Alice", None],
+]
+users = [
+    ["Alice", Icon("./alice-avatar.png", "Alice avatar")],
+    ["Charles", Icon("./charles-avatar.png", "Charles avatar")],
+    ["taipy", Icon("./beatrix-avatar.png", "Beatrix avatar")],
+]
+
+
+def on_action(state, id: str, payload: dict):
+    (reason, varName, text, senderId, imageData) = payload.get("args", [])
+    msgs.append([f"{len(msgs) +1 }", text, senderId, imageData])
+    state.msgs = msgs
+
+
+page = """
+<|{msgs}|chat|users={users}|allow_send_images|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Chat - Images")

+ 8 - 9
doc/gui/examples/controls/chat_messages.py

@@ -14,7 +14,7 @@ from taipy.gui import Gui, Icon
 msgs = [
 msgs = [
     ["1", "msg 1", "Alice", None],
     ["1", "msg 1", "Alice", None],
     ["2", "msg From Another unknown User", "Charles", None],
     ["2", "msg From Another unknown User", "Charles", None],
-    ["3", "This from the sender User", "taipy", "./sample.jpeg"],
+    ["3", "This from the sender User", "taipy", None],
     ["4", "And from another known one", "Alice", None],
     ["4", "And from another known one", "Alice", None],
 ]
 ]
 users = [
 users = [
@@ -25,15 +25,12 @@ users = [
 
 
 
 
 def on_action(state, var_name: str, payload: dict):
 def on_action(state, var_name: str, payload: dict):
-    args = payload.get("args", [])
-    msgs.append([f"{len(msgs) +1 }", args[2], args[3], args[4]])
+    (reason, varName, text, senderId, imageData) = payload.get("args", [])
+    msgs.append([f"{len(msgs) +1 }", text, senderId, imageData])
     state.msgs = msgs
     state.msgs = msgs
 
 
 
 
-Gui(
-    """
-<|toggle|theme|>
-# Test Chat
+page="""
 <|1 1 1|layout|
 <|1 1 1|layout|
 <|{msgs}|chat|users={users}|show_sender={True}|>
 <|{msgs}|chat|users={users}|show_sender={True}|>
 
 
@@ -42,5 +39,7 @@ Gui(
 <|{msgs}|chat|users={users}|show_sender={True}|not with_input|>
 <|{msgs}|chat|users={users}|show_sender={True}|not with_input|>
 |>
 |>
 
 
-""",
-).run()
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Chat - Simple")

+ 1 - 1
frontend/taipy-gui/src/components/Router.tsx

@@ -152,7 +152,7 @@ const Router = () => {
                                         ) : null}
                                         ) : null}
                                     </Box>
                                     </Box>
                                     <ErrorBoundary FallbackComponent={ErrorFallback}>
                                     <ErrorBoundary FallbackComponent={ErrorFallback}>
-                                        <TaipyNotification alerts={state.alerts} />
+                                        <TaipyNotification notifications={state.notifications} />
                                         <UIBlocker block={state.block} />
                                         <UIBlocker block={state.block} />
                                         <Navigate
                                         <Navigate
                                             to={state.navigateTo}
                                             to={state.navigateTo}

+ 94 - 63
frontend/taipy-gui/src/components/Taipy/Chat.spec.tsx

@@ -22,15 +22,24 @@ import { TaipyContext } from "../../context/taipyContext";
 import { stringIcon } from "../../utils/icon";
 import { stringIcon } from "../../utils/icon";
 import { TableValueType } from "./tableUtils";
 import { TableValueType } from "./tableUtils";
 
 
+import { toDataUrl } from "../../utils/image";
+jest.mock('../../utils/image', () => ({
+    toDataUrl: (url: string) => new Promise((resolve) => resolve(url)),
+  }));
+
 const valueKey = "Infinite-Entity--asc";
 const valueKey = "Infinite-Entity--asc";
 const messages: TableValueType = {
 const messages: TableValueType = {
     [valueKey]: {
     [valueKey]: {
         data: [
         data: [
-    ["1", "msg 1", "Fred"],
-    ["2", "msg From Another unknown User", "Fredo"],
-    ["3", "This from the sender User", "taipy"],
-    ["4", "And from another known one", "Fredi"],
-], rowcount: 4, start: 0}};
+            ["1", "msg 1", "Fred"],
+            ["2", "msg From Another unknown User", "Fredo"],
+            ["3", "This from the sender User", "taipy"],
+            ["4", "And from another known one", "Fredi"],
+        ],
+        rowcount: 4,
+        start: 0,
+    },
+};
 const user1: [string, stringIcon] = ["Fred", { path: "/images/favicon.png", text: "Fred.png" }];
 const user1: [string, stringIcon] = ["Fred", { path: "/images/favicon.png", text: "Fred.png" }];
 const user2: [string, stringIcon] = ["Fredi", { path: "/images/fred.png", text: "Fredi.png" }];
 const user2: [string, stringIcon] = ["Fredi", { path: "/images/fred.png", text: "Fredi.png" }];
 const users = [user1, user2];
 const users = [user1, user2];
@@ -46,32 +55,48 @@ describe("Chat Component", () => {
         expect(input.tagName).toBe("INPUT");
         expect(input.tagName).toBe("INPUT");
     });
     });
     it("uses the class", async () => {
     it("uses the class", async () => {
-        const { getByText } = render(<Chat messages={messages} className="taipy-chat" defaultKey={valueKey} mode="raw" />);
+        const { getByText } = render(
+            <Chat messages={messages} className="taipy-chat" defaultKey={valueKey} mode="raw" />
+        );
         const elt = getByText(searchMsg);
         const elt = getByText(searchMsg);
-        expect(elt.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement).toHaveClass("taipy-chat");
+        expect(
+            elt.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement
+        ).toHaveClass("taipy-chat");
     });
     });
     it("can display an avatar", async () => {
     it("can display an avatar", async () => {
-        const { getByAltText } = render(<Chat messages={messages} users={users} defaultKey={valueKey} mode="raw"/>);
+        const { getByAltText } = render(<Chat messages={messages} users={users} defaultKey={valueKey} mode="raw" />);
         const elt = getByAltText("Fred.png");
         const elt = getByAltText("Fred.png");
         expect(elt.tagName).toBe("IMG");
         expect(elt.tagName).toBe("IMG");
     });
     });
     it("is disabled", async () => {
     it("is disabled", async () => {
-        const { getAllByRole } = render(<Chat messages={messages} active={false} defaultKey={valueKey} mode="raw"/>);
-        const elts = getAllByRole("button");
-        elts.forEach((elt) => expect(elt).toHaveClass("Mui-disabled"));
+        const { getByLabelText, getByRole } = render(<Chat messages={messages} active={false} defaultKey={valueKey} mode="raw" />);
+        const elt = getByLabelText("message (taipy)");
+        expect(elt).toHaveClass("Mui-disabled");
+        expect(getByRole("button", { name: /send message/i })).toHaveClass("Mui-disabled");
     });
     });
     it("is enabled by default", async () => {
     it("is enabled by default", async () => {
-        const { getAllByRole } = render(<Chat messages={messages} defaultKey={valueKey} mode="raw"/>);
-        const elts = getAllByRole("button");
-        elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
+        const { getByLabelText, getByRole } = render(<Chat messages={messages} defaultKey={valueKey} mode="raw" />);
+        const elt = getByLabelText("message (taipy)");
+        expect(elt).not.toHaveClass("Mui-disabled");
+        const sendButton = getByRole("button", { name: /send message/i });
+        expect(sendButton).toHaveClass("Mui-disabled");
+        await userEvent.click(elt);
+        await userEvent.keyboard("new message");
+        expect(sendButton).not.toHaveClass("Mui-disabled");
     });
     });
     it("is enabled by active", async () => {
     it("is enabled by active", async () => {
-        const { getAllByRole } = render(<Chat messages={messages} active={true} defaultKey={valueKey} mode="raw"/>);
-        const elts = getAllByRole("button");
-        elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
+        const { getByLabelText, getByRole } = render(<Chat messages={messages} active={true} defaultKey={valueKey} mode="raw" />);
+        const elt = getByLabelText("message (taipy)");
+        expect(elt).not.toHaveClass("Mui-disabled");
+        const sendButton = getByRole("button", { name: /send message/i });
+        expect(sendButton).toHaveClass("Mui-disabled");
+        await userEvent.click(elt);
+        await userEvent.keyboard("new message");
+        expect(elt).not.toHaveClass("Mui-disabled");
+        expect(sendButton).not.toHaveClass("Mui-disabled");
     });
     });
     it("can hide input", async () => {
     it("can hide input", async () => {
-        render(<Chat messages={messages} withInput={false} className="taipy-chat" defaultKey={valueKey} mode="raw"/>);
+        render(<Chat messages={messages} withInput={false} className="taipy-chat" defaultKey={valueKey} mode="raw" />);
         const elt = document.querySelector(".taipy-chat input");
         const elt = document.querySelector(".taipy-chat input");
         expect(elt).toBeNull();
         expect(elt).toBeNull();
     });
     });
@@ -81,13 +106,17 @@ describe("Chat Component", () => {
         await waitFor(() => expect(elt?.querySelector("p")).not.toBeNull());
         await waitFor(() => expect(elt?.querySelector("p")).not.toBeNull());
     });
     });
     it("can render pre", async () => {
     it("can render pre", async () => {
-        const { getByText } = render(<Chat messages={messages} defaultKey={valueKey} className="taipy-chat"  mode="pre" />);
+        const { getByText } = render(
+            <Chat messages={messages} defaultKey={valueKey} className="taipy-chat" mode="pre" />
+        );
         const elt = getByText(searchMsg);
         const elt = getByText(searchMsg);
         expect(elt.tagName).toBe("PRE");
         expect(elt.tagName).toBe("PRE");
         expect(elt.parentElement).toHaveClass("taipy-chat-pre");
         expect(elt.parentElement).toHaveClass("taipy-chat-pre");
     });
     });
     it("can render raw", async () => {
     it("can render raw", async () => {
-        const { getByText } = render(<Chat messages={messages} defaultKey={valueKey} className="taipy-chat"  mode="raw" />);
+        const { getByText } = render(
+            <Chat messages={messages} defaultKey={valueKey} className="taipy-chat" mode="raw" />
+        );
         const elt = getByText(searchMsg);
         const elt = getByText(searchMsg);
         expect(elt).toHaveClass("taipy-chat-raw");
         expect(elt).toHaveClass("taipy-chat-raw");
     });
     });
@@ -96,7 +125,7 @@ describe("Chat Component", () => {
         const state: TaipyState = INITIAL_STATE;
         const state: TaipyState = INITIAL_STATE;
         const { getByLabelText } = render(
         const { getByLabelText } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
             <TaipyContext.Provider value={{ state, dispatch }}>
-                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
+                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw" />
             </TaipyContext.Provider>
             </TaipyContext.Provider>
         );
         );
         const elt = getByLabelText("message (taipy)");
         const elt = getByLabelText("message (taipy)");
@@ -108,7 +137,7 @@ describe("Chat Component", () => {
             context: undefined,
             context: undefined,
             payload: {
             payload: {
                 action: undefined,
                 action: undefined,
-                args: ["Enter", "varName", "new message", "taipy",null],
+                args: ["Enter", "varName", "new message", "taipy", null],
             },
             },
         });
         });
     });
     });
@@ -117,89 +146,91 @@ describe("Chat Component", () => {
         const state: TaipyState = INITIAL_STATE;
         const state: TaipyState = INITIAL_STATE;
         const { getByLabelText, getByRole } = render(
         const { getByLabelText, getByRole } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
             <TaipyContext.Provider value={{ state, dispatch }}>
-                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
+                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw" />
             </TaipyContext.Provider>
             </TaipyContext.Provider>
         );
         );
         const elt = getByLabelText("message (taipy)");
         const elt = getByLabelText("message (taipy)");
         await userEvent.click(elt);
         await userEvent.click(elt);
         await userEvent.keyboard("new message");
         await userEvent.keyboard("new message");
-        await userEvent.click(getByRole("button",{ name: /send message/i }))
+        await userEvent.click(getByRole("button", { name: /send message/i }));
         expect(dispatch).toHaveBeenCalledWith({
         expect(dispatch).toHaveBeenCalledWith({
             type: "SEND_ACTION_ACTION",
             type: "SEND_ACTION_ACTION",
             name: "",
             name: "",
             context: undefined,
             context: undefined,
             payload: {
             payload: {
                 action: undefined,
                 action: undefined,
-                args: ["click", "varName", "new message", "taipy",null],
+                args: ["click", "varName", "new message", "taipy", null],
             },
             },
         });
         });
     });
     });
-    it("handle image upload",async()=>{
+    it("handle image upload", async () => {
         const dispatch = jest.fn();
         const dispatch = jest.fn();
         const state: TaipyState = INITIAL_STATE;
         const state: TaipyState = INITIAL_STATE;
-        const { getByLabelText,getByText,getByAltText,queryByText,getByRole } = render(
+        const { getByLabelText, getByText, getByAltText, queryByText, getByRole } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
             <TaipyContext.Provider value={{ state, dispatch }}>
-                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
+                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw" />
             </TaipyContext.Provider>
             </TaipyContext.Provider>
         );
         );
-        const file = new File(['(⌐□_□)'], 'test.png', { type: 'image/png' });
-        URL.createObjectURL = jest.fn(() => 'mocked-url');
+        const file = new File(["(⌐□_□)"], "test.png", { type: "image/png" });
+        URL.createObjectURL = jest.fn(() => "mocked-url");
         URL.revokeObjectURL = jest.fn();
         URL.revokeObjectURL = jest.fn();
 
 
-        const attachButton = getByLabelText('upload image');
+        const attachButton = getByLabelText("upload image");
         expect(attachButton).toBeInTheDocument();
         expect(attachButton).toBeInTheDocument();
 
 
-
         const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
         const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
         expect(fileInput).toBeInTheDocument();
         expect(fileInput).toBeInTheDocument();
         fireEvent.change(fileInput, { target: { files: [file] } });
         fireEvent.change(fileInput, { target: { files: [file] } });
 
 
         await waitFor(() => {
         await waitFor(() => {
-            const chipWithImage = getByText('test.png');
+            const chipWithImage = getByText("test.png");
             expect(chipWithImage).toBeInTheDocument();
             expect(chipWithImage).toBeInTheDocument();
-            const previewImg = getByAltText('Image preview');
+            const previewImg = getByAltText("Image preview");
             expect(previewImg).toBeInTheDocument();
             expect(previewImg).toBeInTheDocument();
-            expect(previewImg).toHaveAttribute('src', 'mocked-url');
-          });
+            expect(previewImg).toHaveAttribute("src", "mocked-url");
+        });
 
 
-          const elt = getByLabelText("message (taipy)");
-          await userEvent.click(elt);
-          await userEvent.keyboard("Test message with image");
-          await userEvent.click(getByRole("button",{ name: /send message/i }))
+        const elt = getByLabelText("message (taipy)");
+        await userEvent.click(elt);
+        await userEvent.keyboard("Test message with image");
+        await userEvent.click(getByRole("button", { name: /send message/i }));
 
 
-          expect(dispatch).toHaveBeenNthCalledWith(2,
-            expect.objectContaining({
-              type: 'SEND_ACTION_ACTION',
-              payload: expect.objectContaining({
-                args: ['click', 'varName', 'Test message with image', 'taipy', 'mocked-url']
-              })
-            })
-          );
-          await waitFor(() => {
-            const chipWithImage = queryByText('test.png');
+        // needed mocked toDataUrl
+        await waitFor(() => {
+            expect(dispatch).toHaveBeenCalledWith(
+                expect.objectContaining({
+                    type: "SEND_ACTION_ACTION",
+                    payload: expect.objectContaining({
+                        args: ["click", "varName", "Test message with image", "taipy", "mocked-url"],
+                    }),
+                })
+            );
+        });
+        await waitFor(() => {
+            const chipWithImage = queryByText("test.png");
             expect(chipWithImage).not.toBeInTheDocument();
             expect(chipWithImage).not.toBeInTheDocument();
-          });
-          jest.restoreAllMocks()
-    })
-    it("Not upload image over a file size limit",async()=>{
+        });
+        jest.restoreAllMocks();
+    });
+    it("Not upload image over a file size limit", async () => {
         const dispatch = jest.fn();
         const dispatch = jest.fn();
         const state: TaipyState = INITIAL_STATE;
         const state: TaipyState = INITIAL_STATE;
-        const { getByText,getByAltText } = render(
+        const { getByText, getByAltText } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
             <TaipyContext.Provider value={{ state, dispatch }}>
-                <Chat messages={messages} updateVarName="varName" maxFileSize={0} defaultKey={valueKey} mode="raw"/>
+                <Chat messages={messages} updateVarName="varName" maxFileSize={0} defaultKey={valueKey} mode="raw" />
             </TaipyContext.Provider>
             </TaipyContext.Provider>
         );
         );
-        const file = new File(['(⌐□_□)'], 'test.png', { type: 'image/png' });
-        URL.createObjectURL = jest.fn(() => 'mocked-url');
+        const file = new File(["(⌐□_□)"], "test.png", { type: "image/png" });
+        URL.createObjectURL = jest.fn(() => "mocked-url");
 
 
         const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
         const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
         expect(fileInput).toBeInTheDocument();
         expect(fileInput).toBeInTheDocument();
         fireEvent.change(fileInput, { target: { files: [file] } });
         fireEvent.change(fileInput, { target: { files: [file] } });
 
 
         await waitFor(() => {
         await waitFor(() => {
-            expect(() =>getByText('test.png')).toThrow()
-            expect(()=>getByAltText('Image preview')).toThrow();
-          });
-          jest.restoreAllMocks()
-    })
+            expect(() => getByText("test.png")).toThrow();
+            expect(() => getByAltText("Image preview")).toThrow();
+        });
+        jest.restoreAllMocks();
+    });
 });
 });

+ 142 - 94
frontend/taipy-gui/src/components/Taipy/Chat.tsx

@@ -21,6 +21,7 @@ import React, {
     useEffect,
     useEffect,
     ReactNode,
     ReactNode,
     lazy,
     lazy,
+    ChangeEvent,
 } from "react";
 } from "react";
 import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
 import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
 import Avatar from "@mui/material/Avatar";
 import Avatar from "@mui/material/Avatar";
@@ -39,7 +40,11 @@ import AttachFile from "@mui/icons-material/AttachFile";
 import ArrowDownward from "@mui/icons-material/ArrowDownward";
 import ArrowDownward from "@mui/icons-material/ArrowDownward";
 import ArrowUpward from "@mui/icons-material/ArrowUpward";
 import ArrowUpward from "@mui/icons-material/ArrowUpward";
 
 
-import { createRequestInfiniteTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers";
+import {
+    createNotificationAction,
+    createRequestInfiniteTableUpdateAction,
+    createSendActionNameAction,
+} from "../../context/taipyReducers";
 import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
 import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
 import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
 import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
 import { LoVElt, useLovListMemo } from "./lovUtils";
 import { LoVElt, useLovListMemo } from "./lovUtils";
@@ -49,6 +54,7 @@ import { RowType, TableValueType } from "./tableUtils";
 import { Stack } from "@mui/material";
 import { Stack } from "@mui/material";
 import { getComponentClassName } from "./TaipyStyle";
 import { getComponentClassName } from "./TaipyStyle";
 import { noDisplayStyle } from "./utils";
 import { noDisplayStyle } from "./utils";
+import { toDataUrl } from "../../utils/image";
 
 
 const Markdown = lazy(() => import("react-markdown"));
 const Markdown = lazy(() => import("react-markdown"));
 
 
@@ -65,6 +71,7 @@ interface ChatProps extends TaipyActiveProps {
     pageSize?: number;
     pageSize?: number;
     showSender?: boolean;
     showSender?: boolean;
     mode?: string;
     mode?: string;
+    allowSendImages?: boolean;
 }
 }
 
 
 const ENTER_KEY = "Enter";
 const ENTER_KEY = "Enter";
@@ -135,7 +142,7 @@ const defaultBoxSx = {
 } as SxProps<Theme>;
 } as SxProps<Theme>;
 const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } as SxProps<Theme>;
 const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } as SxProps<Theme>;
 const anchorSx = { overflowAnchor: "auto", height: "1px", width: "100%" } as SxProps<Theme>;
 const anchorSx = { overflowAnchor: "auto", height: "1px", width: "100%" } as SxProps<Theme>;
-const imageSx = {width:3/5, height:"auto"}
+const imageSx = { width: 3 / 5, height: "auto" };
 interface key2Rows {
 interface key2Rows {
     key: string;
     key: string;
 }
 }
@@ -166,16 +173,11 @@ const ChatRow = (props: ChatRowProps) => {
             justifyContent={sender ? "flex-end" : undefined}
             justifyContent={sender ? "flex-end" : undefined}
         >
         >
             <Grid sx={sender ? senderMsgSx : undefined}>
             <Grid sx={sender ? senderMsgSx : undefined}>
-            {image?(
-                <Grid container justifyContent={sender ? "flex-end" : undefined}>
-                <Box
-                                component="img"
-                                sx={imageSx}
-                                alt="Uploaded image"
-                                src={image}
-                            />
-                </Grid>
-                            ):null}
+                {image ? (
+                    <Grid container justifyContent={sender ? "flex-end" : undefined}>
+                        <Box component="img" sx={imageSx} alt="Uploaded image" src={image} />
+                    </Grid>
+                ) : null}
                 {(!sender || showSender) && avatar ? (
                 {(!sender || showSender) && avatar ? (
                     <Stack direction="row" gap={1} justifyContent={sender ? "flex-end" : undefined}>
                     <Stack direction="row" gap={1} justifyContent={sender ? "flex-end" : undefined}>
                         {!sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
                         {!sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
@@ -227,9 +229,10 @@ const Chat = (props: ChatProps) => {
         onAction,
         onAction,
         withInput = true,
         withInput = true,
         defaultKey = "",
         defaultKey = "",
-        maxFileSize= 1 * 1024 * 1024, // 1 MB
+        maxFileSize = .8 * 1024 * 1024, // 0.8 MB
         pageSize = 50,
         pageSize = 50,
         showSender = false,
         showSender = false,
+        allowSendImages = true,
     } = props;
     } = props;
     const dispatch = useDispatch();
     const dispatch = useDispatch();
     const module = useModule();
     const module = useModule();
@@ -240,6 +243,7 @@ const Chat = (props: ChatProps) => {
     const scrollDivRef = useRef<HTMLDivElement>(null);
     const scrollDivRef = useRef<HTMLDivElement>(null);
     const anchorDivRef = useRef<HTMLElement>(null);
     const anchorDivRef = useRef<HTMLElement>(null);
     const isAnchorDivVisible = useElementVisible(anchorDivRef);
     const isAnchorDivVisible = useElementVisible(anchorDivRef);
+    const [enableSend, setEnableSend] = useState(false);
     const [showMessage, setShowMessage] = useState(false);
     const [showMessage, setShowMessage] = useState(false);
     const [anchorPopup, setAnchorPopup] = useState<HTMLDivElement | null>(null);
     const [anchorPopup, setAnchorPopup] = useState<HTMLDivElement | null>(null);
     const [selectedFile, setSelectedFile] = useState<File | null>(null);
     const [selectedFile, setSelectedFile] = useState<File | null>(null);
@@ -269,61 +273,94 @@ const Chat = (props: ChatProps) => {
         [props.height]
         [props.height]
     );
     );
 
 
+    const onChangeHandler = useCallback((evt: ChangeEvent<HTMLInputElement>) => setEnableSend(!!evt.target.value), []);
+
+    const sendAction = useCallback(
+        (elt: HTMLInputElement | null | undefined, reason: string) => {
+            if (elt && (elt?.value || imagePreview)) {
+                toDataUrl(imagePreview)
+                    .then((dataUrl) => {
+                        dispatch(
+                            createSendActionNameAction(
+                                id,
+                                module,
+                                onAction,
+                                reason,
+                                updateVarName,
+                                elt?.value,
+                                senderId,
+                                dataUrl
+                            )
+                        );
+                        elt.value = "";
+                        setSelectedFile(null);
+                        setImagePreview((url) => {
+                            url && URL.revokeObjectURL(url);
+                            return null;
+                        });
+                        fileInputRef.current && (fileInputRef.current.value = "");
+                    })
+                    .catch(console.log);
+            }
+        },
+        [imagePreview, updateVarName, onAction, senderId, id, dispatch, module]
+    );
+
     const handleAction = useCallback(
     const handleAction = useCallback(
         (evt: KeyboardEvent<HTMLDivElement>) => {
         (evt: KeyboardEvent<HTMLDivElement>) => {
             if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && ENTER_KEY == evt.key) {
             if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && ENTER_KEY == evt.key) {
-                const elt = evt.currentTarget.querySelector("input");
-                if (elt?.value) {
-                    dispatch(
-                        createSendActionNameAction(id, module, onAction, evt.key, updateVarName, elt?.value,senderId, imagePreview)
-                    );
-                    elt.value = "";
-                    setSelectedFile(null);
-                    setImagePreview(null);
-                }
+                sendAction(evt.currentTarget.querySelector("input"), evt.key);
                 evt.preventDefault();
                 evt.preventDefault();
             }
             }
         },
         },
-        [imagePreview, updateVarName, onAction, senderId, id, dispatch, module]
+        [sendAction]
     );
     );
 
 
     const handleClick = useCallback(
     const handleClick = useCallback(
         (evt: MouseEvent<HTMLButtonElement>) => {
         (evt: MouseEvent<HTMLButtonElement>) => {
-            const elt = evt.currentTarget.parentElement?.parentElement?.querySelector("input");
-            if (elt?.value) {
-                dispatch(
-                    createSendActionNameAction(id, module, onAction, "click", updateVarName, elt?.value,senderId,imagePreview)
-                );
-                elt.value = "";
-                setSelectedFile(null);
-                setImagePreview(null);
-            }
+            sendAction(evt.currentTarget.parentElement?.parentElement?.querySelector("input"), "click");
             evt.preventDefault();
             evt.preventDefault();
         },
         },
-        [imagePreview,updateVarName, onAction, senderId, id, dispatch, module]
+        [sendAction]
     );
     );
 
 
-    const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
-            const file = event.target.files ? event.target.files[0] : null;
-            if (file) {
-                if (file.type.startsWith("image/") && file.size <= maxFileSize) {
-                    setSelectedFile(file);
-                    const newImagePreview = URL.createObjectURL(file);
-                    setImagePreview(newImagePreview);
-                    setObjectURLs((prevURLs) => [...prevURLs, newImagePreview]);
-                } else {
-                    setSelectedFile(null);
-                    setImagePreview(null);
-                }
+    const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
+        const file = event.target.files ? event.target.files[0] : null;
+        if (file) {
+            if (file.type.startsWith("image/") && file.size <= maxFileSize) {
+                setSelectedFile(file);
+                const newImagePreview = URL.createObjectURL(file);
+                setImagePreview(newImagePreview);
+                setObjectURLs((prevURLs) => [...prevURLs, newImagePreview]);
+            } else {
+                dispatch(
+                    createNotificationAction({
+                        atype: "info",
+                        message:
+                            file.size > maxFileSize
+                                ? `Image size is limited to ${maxFileSize / 1024} KB`
+                                : "Only image file are authorized",
+                        system: false,
+                        duration: 3000,
+                    })
+                );
+                setSelectedFile(null);
+                setImagePreview(null);
+                fileInputRef.current && (fileInputRef.current.value = "");
             }
             }
-        };
-
-    const handleAttachClick = useCallback(() => {
-        if (fileInputRef.current) {
-            fileInputRef.current.click();
         }
         }
-    }, [fileInputRef]);
+    }, [maxFileSize, dispatch]);
 
 
+    const handleAttachClick = useCallback(() => fileInputRef.current && fileInputRef.current.click(), [fileInputRef]);
+
+    const handleImageDelete = useCallback(() => {
+        setSelectedFile(null);
+        setImagePreview((url) => {
+            url && URL.revokeObjectURL(url);
+            return null;
+        });
+        fileInputRef.current && (fileInputRef.current.value = "");
+    }, []);
 
 
     const avatars = useMemo(() => {
     const avatars = useMemo(() => {
         return users.reduce((pv, elt) => {
         return users.reduce((pv, elt) => {
@@ -477,7 +514,11 @@ const Chat = (props: ChatProps) => {
                                 senderId={senderId}
                                 senderId={senderId}
                                 message={`${row[columns[1]]}`}
                                 message={`${row[columns[1]]}`}
                                 name={columns[2] ? `${row[columns[2]]}` : "Unknown"}
                                 name={columns[2] ? `${row[columns[2]]}` : "Unknown"}
-                                image={columns[3] && columns[3] != "_tp_index" && row[columns[3]] ? `${row[columns[3]]}` : undefined}
+                                image={
+                                    columns[3] && columns[3] != "_tp_index" && row[columns[3]]
+                                        ? `${row[columns[3]]}`
+                                        : undefined
+                                }
                                 className={className}
                                 className={className}
                                 getAvatar={getAvatar}
                                 getAvatar={getAvatar}
                                 index={idx}
                                 index={idx}
@@ -498,60 +539,67 @@ const Chat = (props: ChatProps) => {
                 </Popper>
                 </Popper>
                 {withInput ? (
                 {withInput ? (
                     <>
                     <>
-                    {imagePreview && selectedFile && (
+                        {imagePreview && (
                             <Box mb={1}>
                             <Box mb={1}>
                                 <Chip
                                 <Chip
-                                    label={selectedFile.name}
-                                    avatar={<Avatar alt="Image preview" src={imagePreview}/>}
-                                    onDelete={() => setSelectedFile(null)}
+                                    label={selectedFile?.name}
+                                    avatar={<Avatar alt="Image preview" src={imagePreview} />}
+                                    onDelete={handleImageDelete}
                                     variant="outlined"
                                     variant="outlined"
                                 />
                                 />
                             </Box>
                             </Box>
                         )}
                         )}
-                    <input
+                        <input
                             type="file"
                             type="file"
                             ref={fileInputRef}
                             ref={fileInputRef}
                             style={noDisplayStyle}
                             style={noDisplayStyle}
-                            onChange={(e) => handleFileSelect(e)}
+                            onChange={handleFileSelect}
                             accept="image/*"
                             accept="image/*"
                         />
                         />
 
 
-                    <TextField
-                        margin="dense"
-                        fullWidth
-                        className={getSuffixedClassNames(className, "-input")}
-                        label={`message (${senderId})`}
-                        disabled={!active}
-                        onKeyDown={handleAction}
-                        slotProps={{
-                            input: {
-                                startAdornment: (<InputAdornment position="start">
-                                    <IconButton
-                                            aria-label="upload image"
-                                            onClick={handleAttachClick}
-                                            edge="start"
-                                            disabled={!active}
-                                        >
-                                       <AttachFile color={disableColor("primary", !active)} />
-                                    </IconButton>
-
-                                </InputAdornment>),
-                                endAdornment: (
-                                    <InputAdornment position="end">
-                                        <IconButton
-                                            aria-label="send message"
-                                            onClick={handleClick}
-                                            edge="end"
-                                            disabled={!active}
-                                        >
-                                            <Send color={disableColor("primary", !active)} />
-                                        </IconButton>
-                                    </InputAdornment>
-                                ),
-                            },
-                        }}
-                        sx={inputSx}
-                    />
+                        <TextField
+                            margin="dense"
+                            fullWidth
+                            onChange={onChangeHandler}
+                            className={getSuffixedClassNames(className, "-input")}
+                            label={`message (${senderId})`}
+                            disabled={!active}
+                            onKeyDown={handleAction}
+                            slotProps={{
+                                input: {
+                                    startAdornment: allowSendImages ? (
+                                        <InputAdornment position="start">
+                                            <IconButton
+                                                aria-label="upload image"
+                                                onClick={handleAttachClick}
+                                                edge="start"
+                                                disabled={!active}
+                                            >
+                                                <AttachFile color={disableColor("primary", !active)} />
+                                            </IconButton>
+                                        </InputAdornment>
+                                    ) : undefined,
+                                    endAdornment: (
+                                        <InputAdornment position="end">
+                                            <IconButton
+                                                aria-label="send message"
+                                                onClick={handleClick}
+                                                edge="end"
+                                                disabled={!active || !(enableSend || imagePreview)}
+                                            >
+                                                <Send
+                                                    color={disableColor(
+                                                        "primary",
+                                                        !active || !(enableSend || imagePreview)
+                                                    )}
+                                                />
+                                            </IconButton>
+                                        </InputAdornment>
+                                    ),
+                                },
+                            }}
+                            sx={inputSx}
+                        />
                     </>
                     </>
                 ) : null}
                 ) : null}
                 {props.children}
                 {props.children}

+ 4 - 4
frontend/taipy-gui/src/components/Taipy/FileSelector.spec.tsx

@@ -189,7 +189,7 @@ describe("FileSelector Component", () => {
         // Check if the alert action has been dispatched
         // Check if the alert action has been dispatched
         expect(mockDispatch).toHaveBeenCalledWith(
         expect(mockDispatch).toHaveBeenCalledWith(
             expect.objectContaining({
             expect.objectContaining({
-                type: "SET_ALERT",
+                type: "SET_NOTIFICATION",
                 atype: "success",
                 atype: "success",
                 duration: 3000,
                 duration: 3000,
                 message: "mocked response",
                 message: "mocked response",
@@ -225,7 +225,7 @@ describe("FileSelector Component", () => {
         // Check if the alert action has been dispatched
         // Check if the alert action has been dispatched
         expect(mockDispatch).toHaveBeenCalledWith(
         expect(mockDispatch).toHaveBeenCalledWith(
             expect.objectContaining({
             expect.objectContaining({
-                type: "SET_ALERT",
+                type: "SET_NOTIFICATION",
                 atype: "error",
                 atype: "error",
                 duration: 3000,
                 duration: 3000,
                 message: "Upload failed",
                 message: "Upload failed",
@@ -302,7 +302,7 @@ describe("FileSelector Component", () => {
 
 
         // Wait for the upload to complete
         // Wait for the upload to complete
         await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
         await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
-        
+
         // Check for input element
         // Check for input element
         const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input");
         const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input");
         expect(inputElt).toBeInTheDocument();
         expect(inputElt).toBeInTheDocument();
@@ -331,7 +331,7 @@ describe("FileSelector Component", () => {
 
 
         // Wait for the upload to complete
         // Wait for the upload to complete
         await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
         await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
-        
+
         // Check for input element
         // Check for input element
         const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input");
         const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input");
         expect(inputElt).toBeInTheDocument();
         expect(inputElt).toBeInTheDocument();

+ 5 - 5
frontend/taipy-gui/src/components/Taipy/FileSelector.tsx

@@ -28,7 +28,7 @@ import Tooltip from "@mui/material/Tooltip";
 import UploadFile from "@mui/icons-material/UploadFile";
 import UploadFile from "@mui/icons-material/UploadFile";
 
 
 import { TaipyContext } from "../../context/taipyContext";
 import { TaipyContext } from "../../context/taipyContext";
-import { createAlertAction, createSendActionNameAction } from "../../context/taipyReducers";
+import { createNotificationAction, createSendActionNameAction } from "../../context/taipyReducers";
 import { useClassNames, useDynamicProperty, useModule } from "../../utils/hooks";
 import { useClassNames, useDynamicProperty, useModule } from "../../utils/hooks";
 import { expandSx, getCssSize, noDisplayStyle, TaipyActiveProps } from "./utils";
 import { expandSx, getCssSize, noDisplayStyle, TaipyActiveProps } from "./utils";
 import { uploadFile } from "../../workers/fileupload";
 import { uploadFile } from "../../workers/fileupload";
@@ -75,8 +75,8 @@ const FileSelector = (props: FileSelectorProps) => {
         notify = true,
         notify = true,
         withBorder = true,
         withBorder = true,
     } = props;
     } = props;
-    const directoryProps = ["d", "dir", "directory", "folder"].includes(selectionType?.toLowerCase()) ? 
-                           {webkitdirectory: "", directory: "", mozdirectory: "", nwdirectory: ""} : 
+    const directoryProps = ["d", "dir", "directory", "folder"].includes(selectionType?.toLowerCase()) ?
+                           {webkitdirectory: "", directory: "", mozdirectory: "", nwdirectory: ""} :
                            undefined;
                            undefined;
     const [dropLabel, setDropLabel] = useState("");
     const [dropLabel, setDropLabel] = useState("");
     const [dropSx, setDropSx] = useState<SxProps | undefined>(defaultSx);
     const [dropSx, setDropSx] = useState<SxProps | undefined>(defaultSx);
@@ -123,14 +123,14 @@ const FileSelector = (props: FileSelectorProps) => {
                         onAction && dispatch(createSendActionNameAction(id, module, onAction));
                         onAction && dispatch(createSendActionNameAction(id, module, onAction));
                         notify &&
                         notify &&
                             dispatch(
                             dispatch(
-                                createAlertAction({ atype: "success", message: value, system: false, duration: 3000 })
+                                createNotificationAction({ atype: "success", message: value, system: false, duration: 3000 })
                             );
                             );
                     },
                     },
                     (reason) => {
                     (reason) => {
                         setUpload(false);
                         setUpload(false);
                         notify &&
                         notify &&
                             dispatch(
                             dispatch(
-                                createAlertAction({ atype: "error", message: reason, system: false, duration: 3000 })
+                                createNotificationAction({ atype: "error", message: reason, system: false, duration: 3000 })
                             );
                             );
                     }
                     }
                 );
                 );

+ 21 - 21
frontend/taipy-gui/src/components/Taipy/Notification.spec.tsx

@@ -16,13 +16,13 @@ import { render, screen, waitFor } from "@testing-library/react";
 import "@testing-library/jest-dom";
 import "@testing-library/jest-dom";
 import { SnackbarProvider } from "notistack";
 import { SnackbarProvider } from "notistack";
 
 
-import Alert from "./Notification";
-import { AlertMessage } from "../../context/taipyReducers";
+import TaipyNotification from "./Notification";
+import { NotificationMessage } from "../../context/taipyReducers";
 import userEvent from "@testing-library/user-event";
 import userEvent from "@testing-library/user-event";
 
 
 const defaultMessage = "message";
 const defaultMessage = "message";
-const defaultAlerts: AlertMessage[] = [{ atype: "success", message: defaultMessage, system: true, duration: 3000 }];
-const getAlertsWithType = (aType: string) => [{ ...defaultAlerts[0], atype: aType }];
+const defaultNotifications: NotificationMessage[] = [{ atype: "success", message: defaultMessage, system: true, duration: 3000 }];
+const getNotificationsWithType = (aType: string) => [{ ...defaultNotifications[0], atype: aType }];
 
 
 class myNotification {
 class myNotification {
     static requestPermission = jest.fn(() => Promise.resolve("granted"));
     static requestPermission = jest.fn(() => Promise.resolve("granted"));
@@ -39,7 +39,7 @@ describe("Alert Component", () => {
     it("renders", async () => {
     it("renders", async () => {
         const { getByText } = render(
         const { getByText } = render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={defaultAlerts} />
+                <TaipyNotification notifications={defaultNotifications} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const elt = getByText(defaultMessage);
         const elt = getByText(defaultMessage);
@@ -48,7 +48,7 @@ describe("Alert Component", () => {
     it("displays a success alert", async () => {
     it("displays a success alert", async () => {
         const { getByText } = render(
         const { getByText } = render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={defaultAlerts} />
+                <TaipyNotification notifications={defaultNotifications} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const elt = getByText(defaultMessage);
         const elt = getByText(defaultMessage);
@@ -57,7 +57,7 @@ describe("Alert Component", () => {
     it("displays an error alert", async () => {
     it("displays an error alert", async () => {
         const { getByText } = render(
         const { getByText } = render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={getAlertsWithType("error")} />
+                <TaipyNotification notifications={getNotificationsWithType("error")} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const elt = getByText(defaultMessage);
         const elt = getByText(defaultMessage);
@@ -66,7 +66,7 @@ describe("Alert Component", () => {
     it("displays a warning alert", async () => {
     it("displays a warning alert", async () => {
         const { getByText } = render(
         const { getByText } = render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={getAlertsWithType("warning")} />
+                <TaipyNotification notifications={getNotificationsWithType("warning")} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const elt = getByText(defaultMessage);
         const elt = getByText(defaultMessage);
@@ -75,7 +75,7 @@ describe("Alert Component", () => {
     it("displays an info alert", async () => {
     it("displays an info alert", async () => {
         const { getByText } = render(
         const { getByText } = render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={getAlertsWithType("info")} />
+                <TaipyNotification notifications={getNotificationsWithType("info")} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const elt = getByText(defaultMessage);
         const elt = getByText(defaultMessage);
@@ -86,12 +86,12 @@ describe("Alert Component", () => {
         link.rel = "icon";
         link.rel = "icon";
         link.href = "/test-icon.png";
         link.href = "/test-icon.png";
         document.head.appendChild(link);
         document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
+        const alerts: NotificationMessage[] = [
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
         ];
         ];
         render(
         render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const linkElement = document.querySelector("link[rel='icon']");
         const linkElement = document.querySelector("link[rel='icon']");
@@ -107,7 +107,7 @@ describe("Alert Component", () => {
         const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false }];
         const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false }];
         render(
         render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const closeButton = await screen.findByRole("button", { name: /close/i });
         const closeButton = await screen.findByRole("button", { name: /close/i });
@@ -122,14 +122,14 @@ describe("Alert Component", () => {
         const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
         const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
         const { rerender } = render(
         const { rerender } = render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         await screen.findByRole("button", { name: /close/i });
         await screen.findByRole("button", { name: /close/i });
         const newAlerts = [{ atype: "", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
         const newAlerts = [{ atype: "", message: "Test Alert", duration: 3000, system: false, notificationId: "aNotificationId" }];
         rerender(
         rerender(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={newAlerts} />
+                <TaipyNotification notifications={newAlerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         await waitFor(() => {
         await waitFor(() => {
@@ -141,7 +141,7 @@ describe("Alert Component", () => {
     it("does nothing when alert is undefined", async () => {
     it("does nothing when alert is undefined", async () => {
         render(
         render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={[]} />
+                <TaipyNotification notifications={[]} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         expect(Notification.requestPermission).not.toHaveBeenCalled();
         expect(Notification.requestPermission).not.toHaveBeenCalled();
@@ -152,12 +152,12 @@ describe("Alert Component", () => {
         link.rel = "icon";
         link.rel = "icon";
         link.href = "/test-icon.png";
         link.href = "/test-icon.png";
         document.head.appendChild(link);
         document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
+        const alerts: NotificationMessage[] = [
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
         ];
         ];
         render(
         render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const linkElement = document.querySelector("link[rel='icon']");
         const linkElement = document.querySelector("link[rel='icon']");
@@ -169,12 +169,12 @@ describe("Alert Component", () => {
         const link = document.createElement("link");
         const link = document.createElement("link");
         link.rel = "icon";
         link.rel = "icon";
         document.head.appendChild(link);
         document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
+        const alerts: NotificationMessage[] = [
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
         ];
         ];
         render(
         render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const linkElement = document.querySelector("link[rel='icon']");
         const linkElement = document.querySelector("link[rel='icon']");
@@ -187,12 +187,12 @@ describe("Alert Component", () => {
         link.rel = "shortcut icon";
         link.rel = "shortcut icon";
         link.href = "/test-shortcut-icon.png";
         link.href = "/test-shortcut-icon.png";
         document.head.appendChild(link);
         document.head.appendChild(link);
-        const alerts: AlertMessage[] = [
+        const alerts: NotificationMessage[] = [
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
             { atype: "success", message: "This is a system alert", system: true, duration: 3000 },
         ];
         ];
         render(
         render(
             <SnackbarProvider>
             <SnackbarProvider>
-                <Alert alerts={alerts} />
+                <TaipyNotification notifications={alerts} />
             </SnackbarProvider>,
             </SnackbarProvider>,
         );
         );
         const linkElement = document.querySelector("link[rel='shortcut icon']");
         const linkElement = document.querySelector("link[rel='shortcut icon']");

+ 21 - 19
frontend/taipy-gui/src/components/Taipy/Notification.tsx

@@ -16,32 +16,32 @@ import { SnackbarKey, useSnackbar, VariantType } from "notistack";
 import IconButton from "@mui/material/IconButton";
 import IconButton from "@mui/material/IconButton";
 import CloseIcon from "@mui/icons-material/Close";
 import CloseIcon from "@mui/icons-material/Close";
 
 
-import { AlertMessage, createDeleteAlertAction } from "../../context/taipyReducers";
+import { NotificationMessage, createDeleteAlertAction } from "../../context/taipyReducers";
 import { useDispatch } from "../../utils/hooks";
 import { useDispatch } from "../../utils/hooks";
 
 
 interface NotificationProps {
 interface NotificationProps {
-    alerts: AlertMessage[];
+    notifications: NotificationMessage[];
 }
 }
 
 
-const TaipyNotification = ({ alerts }: NotificationProps) => {
-    const alert = alerts.length ? alerts[0] : undefined;
+const TaipyNotification = ({ notifications }: NotificationProps) => {
+    const notification = notifications.length ? notifications[0] : undefined;
     const { enqueueSnackbar, closeSnackbar } = useSnackbar();
     const { enqueueSnackbar, closeSnackbar } = useSnackbar();
     const dispatch = useDispatch();
     const dispatch = useDispatch();
 
 
-    const resetAlert = useCallback(
+    const resetNotification = useCallback(
         (key: SnackbarKey) => () => {
         (key: SnackbarKey) => () => {
             closeSnackbar(key);
             closeSnackbar(key);
         },
         },
         [closeSnackbar]
         [closeSnackbar]
     );
     );
 
 
-    const notifAction = useCallback(
+    const notificationAction = useCallback(
         (key: SnackbarKey) => (
         (key: SnackbarKey) => (
-            <IconButton size="small" aria-label="close" color="inherit" onClick={resetAlert(key)}>
+            <IconButton size="small" aria-label="close" color="inherit" onClick={resetNotification(key)}>
                 <CloseIcon fontSize="small" />
                 <CloseIcon fontSize="small" />
             </IconButton>
             </IconButton>
         ),
         ),
-        [resetAlert]
+        [resetNotification]
     );
     );
 
 
     const faviconUrl = useMemo(() => {
     const faviconUrl = useMemo(() => {
@@ -55,25 +55,27 @@ const TaipyNotification = ({ alerts }: NotificationProps) => {
     }, []);
     }, []);
 
 
     useEffect(() => {
     useEffect(() => {
-        if (alert) {
-            const notificationId = alert.notificationId || "";
-            if (alert.atype === "") {
+        if (notification) {
+            const notificationId = notification.notificationId || "";
+            if (notification.atype === "") {
                 closeSnackbar(notificationId);
                 closeSnackbar(notificationId);
             } else {
             } else {
-                enqueueSnackbar(alert.message, {
-                    variant: alert.atype as VariantType,
-                    action: notifAction,
-                    autoHideDuration: alert.duration,
+                enqueueSnackbar(notification.message, {
+                    variant: notification.atype as VariantType,
+                    action: notificationAction,
+                    autoHideDuration: notification.duration,
                     key: notificationId,
                     key: notificationId,
                 });
                 });
-                alert.system && new Notification(document.title || "Taipy", { body: alert.message, icon: faviconUrl });
+                notification.system &&
+                    new Notification(document.title || "Taipy", { body: notification.message, icon: faviconUrl });
             }
             }
             dispatch(createDeleteAlertAction(notificationId));
             dispatch(createDeleteAlertAction(notificationId));
         }
         }
-    }, [alert, enqueueSnackbar, closeSnackbar, notifAction, faviconUrl, dispatch]);
+    }, [notification, enqueueSnackbar, closeSnackbar, notificationAction, faviconUrl, dispatch]);
+
     useEffect(() => {
     useEffect(() => {
-        alert?.system && window.Notification && Notification.requestPermission();
-    }, [alert?.system]);
+        notification?.system && window.Notification && Notification.requestPermission();
+    }, [notification?.system]);
 
 
     return null;
     return null;
 };
 };

+ 95 - 54
frontend/taipy-gui/src/context/taipyReducers.spec.ts

@@ -14,10 +14,10 @@
 import "@testing-library/jest-dom";
 import "@testing-library/jest-dom";
 import {
 import {
     addRows,
     addRows,
-    AlertMessage,
+    NotificationMessage,
     BlockMessage,
     BlockMessage,
     createAckAction,
     createAckAction,
-    createAlertAction,
+    createNotificationAction,
     createBlockAction,
     createBlockAction,
     createDownloadAction,
     createDownloadAction,
     createIdAction,
     createIdAction,
@@ -43,6 +43,7 @@ import {
     TaipyBaseAction,
     TaipyBaseAction,
     taipyReducer,
     taipyReducer,
     Types,
     Types,
+    TaipyState,
 } from "./taipyReducers";
 } from "./taipyReducers";
 import { WsMessage } from "./wsUtils";
 import { WsMessage } from "./wsUtils";
 import { changeFavicon, getLocalStorageValue, IdMessage } from "./utils";
 import { changeFavicon, getLocalStorageValue, IdMessage } from "./utils";
@@ -50,7 +51,7 @@ import { Socket } from "socket.io-client";
 import { Dispatch } from "react";
 import { Dispatch } from "react";
 import { parseData } from "../utils/dataFormat";
 import { parseData } from "../utils/dataFormat";
 import * as wsUtils from "./wsUtils";
 import * as wsUtils from "./wsUtils";
-import { nanoid } from 'nanoid';
+import { nanoid } from "nanoid";
 
 
 jest.mock("./utils", () => ({
 jest.mock("./utils", () => ({
     ...jest.requireActual("./utils"),
     ...jest.requireActual("./utils"),
@@ -85,14 +86,14 @@ describe("reducer", () => {
             } as TaipyBaseAction).locations
             } as TaipyBaseAction).locations
         ).toBeDefined();
         ).toBeDefined();
     });
     });
-    it("set alert", async () => {
+    it("set notification", async () => {
         expect(
         expect(
             taipyReducer({ ...INITIAL_STATE }, {
             taipyReducer({ ...INITIAL_STATE }, {
-                type: "SET_ALERT",
+                type: "SET_NOTIFICATION",
                 atype: "i",
                 atype: "i",
                 message: "message",
                 message: "message",
                 system: "system",
                 system: "system",
-            } as TaipyBaseAction).alerts
+            } as TaipyBaseAction).notifications
         ).toHaveLength(1);
         ).toHaveLength(1);
     });
     });
     it("set show block", async () => {
     it("set show block", async () => {
@@ -201,12 +202,20 @@ describe("reducer", () => {
             } as TaipyBaseAction).data.partial
             } as TaipyBaseAction).data.partial
         ).toBeUndefined();
         ).toBeUndefined();
     });
     });
-    it("creates an alert action", () => {
-        expect(createAlertAction({ atype: "I", message: "message" } as AlertMessage).type).toBe("SET_ALERT");
-        expect(createAlertAction({ atype: "err", message: "message" } as AlertMessage).atype).toBe("error");
-        expect(createAlertAction({ atype: "Wa", message: "message" } as AlertMessage).atype).toBe("warning");
-        expect(createAlertAction({ atype: "sUc", message: "message" } as AlertMessage).atype).toBe("success");
-        expect(createAlertAction({ atype: "  ", message: "message" } as AlertMessage).atype).toBe("");
+    it("creates a notification action", () => {
+        expect(createNotificationAction({ atype: "I", message: "message" } as NotificationMessage).type).toBe(
+            "SET_NOTIFICATION"
+        );
+        expect(createNotificationAction({ atype: "err", message: "message" } as NotificationMessage).atype).toBe(
+            "error"
+        );
+        expect(createNotificationAction({ atype: "Wa", message: "message" } as NotificationMessage).atype).toBe(
+            "warning"
+        );
+        expect(createNotificationAction({ atype: "sUc", message: "message" } as NotificationMessage).atype).toBe(
+            "success"
+        );
+        expect(createNotificationAction({ atype: "  ", message: "message" } as NotificationMessage).atype).toBe("");
     });
     });
 });
 });
 
 
@@ -543,7 +552,7 @@ describe("createSendUpdateAction function", () => {
 describe("taipyReducer function", () => {
 describe("taipyReducer function", () => {
     it("should not change state for SOCKET_CONNECTED action if isSocketConnected is already true", () => {
     it("should not change state for SOCKET_CONNECTED action if isSocketConnected is already true", () => {
         const action = { type: Types.SocketConnected };
         const action = { type: Types.SocketConnected };
-        const initialState = { ...INITIAL_STATE, isSocketConnected: true };
+        const initialState: TaipyState = { ...INITIAL_STATE, isSocketConnected: true };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         const expectedState = { ...initialState, isSocketConnected: true };
         const expectedState = { ...initialState, isSocketConnected: true };
         expect(newState).toEqual(expectedState);
         expect(newState).toEqual(expectedState);
@@ -569,9 +578,9 @@ describe("taipyReducer function", () => {
         const newState = taipyReducer({ ...INITIAL_STATE }, action);
         const newState = taipyReducer({ ...INITIAL_STATE }, action);
         expect(newState.locations).toEqual(action.payload.value);
         expect(newState.locations).toEqual(action.payload.value);
     });
     });
-    it("should handle SET_ALERT action", () => {
+    it("should handle SET_NOTIFICATION action", () => {
         const action = {
         const action = {
-            type: Types.SetAlert,
+            type: Types.SetNotification,
             atype: "error",
             atype: "error",
             message: "some error message",
             message: "some error message",
             system: true,
             system: true,
@@ -579,7 +588,7 @@ describe("taipyReducer function", () => {
             notificationId: nanoid(),
             notificationId: nanoid(),
         };
         };
         const newState = taipyReducer({ ...INITIAL_STATE }, action);
         const newState = taipyReducer({ ...INITIAL_STATE }, action);
-        expect(newState.alerts).toContainEqual({
+        expect(newState.notifications).toContainEqual({
             atype: action.atype,
             atype: action.atype,
             message: action.message,
             message: action.message,
             system: action.system,
             system: action.system,
@@ -587,57 +596,89 @@ describe("taipyReducer function", () => {
             notificationId: action.notificationId,
             notificationId: action.notificationId,
         });
         });
     });
     });
-    it("should handle DELETE_ALERT action", () => {
+    it("should handle DELETE_NOTIFICATION action", () => {
         const notificationId1 = "id-1234";
         const notificationId1 = "id-1234";
         const notificationId2 = "id-5678";
         const notificationId2 = "id-5678";
-        const initialState = {
+        const initialState: TaipyState = {
             ...INITIAL_STATE,
             ...INITIAL_STATE,
-            alerts: [
-                { atype: "error", message: "First Alert", system: true, duration: 5000, notificationId: notificationId1 },
-                { atype: "warning", message: "Second Alert", system: false, duration: 3000, notificationId: notificationId2 },
+            notifications: [
+                {
+                    atype: "error",
+                    message: "First Notification",
+                    system: true,
+                    duration: 5000,
+                    notificationId: notificationId1,
+                },
+                {
+                    atype: "warning",
+                    message: "Second Notification",
+                    system: false,
+                    duration: 3000,
+                    notificationId: notificationId2,
+                },
             ],
             ],
         };
         };
-        const action = { type: Types.DeleteAlert, notificationId: notificationId1 };
+        const action = { type: Types.DeleteNotification, notificationId: notificationId1 };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
-        expect(newState.alerts).toEqual([{ atype: "warning", message: "Second Alert", system: false, duration: 3000, notificationId: notificationId2 }]);
+        expect(newState.notifications).toEqual([
+            {
+                atype: "warning",
+                message: "Second Notification",
+                system: false,
+                duration: 3000,
+                notificationId: notificationId2,
+            },
+        ]);
     });
     });
-    it('should not modify state if DELETE_ALERT does not match any notificationId', () => {
+    it("should not modify state if DELETE_NOTIFICATION does not match any notificationId", () => {
         const notificationId1 = "id-1234";
         const notificationId1 = "id-1234";
         const notificationId2 = "id-5678";
         const notificationId2 = "id-5678";
         const nonExistentId = "000000";
         const nonExistentId = "000000";
-        const initialState = {
+        const initialState: TaipyState = {
             ...INITIAL_STATE,
             ...INITIAL_STATE,
-            alerts: [
-                { atype: "error", message: "First Alert", system: true, duration: 5000, notificationId: notificationId1 },
-                { atype: "warning", message: "Second Alert", system: false, duration: 3000, notificationId: notificationId2 },
+            notifications: [
+                {
+                    atype: "error",
+                    message: "First Notification",
+                    system: true,
+                    duration: 5000,
+                    notificationId: notificationId1,
+                },
+                {
+                    atype: "warning",
+                    message: "Second Notification",
+                    system: false,
+                    duration: 3000,
+                    notificationId: notificationId2,
+                },
             ],
             ],
         };
         };
-        const action = { type: Types.DeleteAlert, notificationId: nonExistentId };
+        const action = { type: Types.DeleteNotification, notificationId: nonExistentId };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState).toEqual(initialState);
         expect(newState).toEqual(initialState);
     });
     });
-    it("should not modify state if no alerts are present", () => {
-        const initialState = { ...INITIAL_STATE, alerts: [] };
-        const action = { type: Types.DeleteAlert };
+    it("should not modify state if no notification are present", () => {
+        const initialState: TaipyState = { ...INITIAL_STATE, notifications: [] };
+        const action = { type: Types.DeleteNotification };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState).toEqual(initialState);
         expect(newState).toEqual(initialState);
     });
     });
-    it("should handle DELETE_ALERT action even when no notificationId is passed", () => {
+    it("should handle DELETE_NOTIFICATION action even when no notificationId is passed", () => {
         const notificationId1 = "id-1234";
         const notificationId1 = "id-1234";
         const notificationId2 = "id-5678";
         const notificationId2 = "id-5678";
 
 
-        const initialState = {
+        const initialState: TaipyState = {
             ...INITIAL_STATE,
             ...INITIAL_STATE,
-            alerts: [
+            notifications: [
                 {
                 {
-                    message: "alert1",
+                    message: "Notification1",
                     atype: "type1",
                     atype: "type1",
                     system: true,
                     system: true,
                     duration: 5000,
                     duration: 5000,
                     notificationId: notificationId1,
                     notificationId: notificationId1,
                 },
                 },
                 {
                 {
-                    message: "alert2",
+                    message: "Notification2",
                     atype: "type2",
                     atype: "type2",
                     system: false,
                     system: false,
                     duration: 3000,
                     duration: 3000,
@@ -645,11 +686,11 @@ describe("taipyReducer function", () => {
                 },
                 },
             ],
             ],
         };
         };
-        const action = { type: Types.DeleteAlert, notificationId: notificationId1 };
+        const action = { type: Types.DeleteNotification, notificationId: notificationId1 };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
-        expect(newState.alerts).toEqual([
+        expect(newState.notifications).toEqual([
             {
             {
-                message: "alert2",
+                message: "Notification2",
                 atype: "type2",
                 atype: "type2",
                 system: false,
                 system: false,
                 duration: 3000,
                 duration: 3000,
@@ -658,7 +699,7 @@ describe("taipyReducer function", () => {
         ]);
         ]);
     });
     });
     it("should handle SET_BLOCK action", () => {
     it("should handle SET_BLOCK action", () => {
-        const initialState = { ...INITIAL_STATE, block: undefined };
+        const initialState: TaipyState = { ...INITIAL_STATE, block: undefined };
         const action = {
         const action = {
             type: Types.SetBlock,
             type: Types.SetBlock,
             noCancel: false,
             noCancel: false,
@@ -675,7 +716,7 @@ describe("taipyReducer function", () => {
         });
         });
     });
     });
     it("should handle NAVIGATE action", () => {
     it("should handle NAVIGATE action", () => {
-        const initialState = {
+        const initialState: TaipyState = {
             ...INITIAL_STATE,
             ...INITIAL_STATE,
             navigateTo: undefined,
             navigateTo: undefined,
             navigateParams: undefined,
             navigateParams: undefined,
@@ -696,31 +737,31 @@ describe("taipyReducer function", () => {
         expect(newState.navigateForce).toEqual(true);
         expect(newState.navigateForce).toEqual(true);
     });
     });
     it("should handle CLIENT_ID action", () => {
     it("should handle CLIENT_ID action", () => {
-        const initialState = { ...INITIAL_STATE, id: "oldId" };
+        const initialState: TaipyState = { ...INITIAL_STATE, id: "oldId" };
         const action = { type: Types.ClientId, id: "newId" };
         const action = { type: Types.ClientId, id: "newId" };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState.id).toEqual("newId");
         expect(newState.id).toEqual("newId");
     });
     });
     it("should handle ACKNOWLEDGEMENT action", () => {
     it("should handle ACKNOWLEDGEMENT action", () => {
-        const initialState = { ...INITIAL_STATE, ackList: ["ack1", "ack2"] };
+        const initialState: TaipyState = { ...INITIAL_STATE, ackList: ["ack1", "ack2"] };
         const action = { type: Types.Acknowledgement, id: "ack1" };
         const action = { type: Types.Acknowledgement, id: "ack1" };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState.ackList).toEqual(["ack2"]);
         expect(newState.ackList).toEqual(["ack2"]);
     });
     });
     it("should handle SET_MENU action", () => {
     it("should handle SET_MENU action", () => {
-        const initialState = { ...INITIAL_STATE, menu: {} };
+        const initialState: TaipyState = { ...INITIAL_STATE, menu: {} };
         const action = { type: Types.SetMenu, menu: { menu1: "item1", menu2: "item2" } };
         const action = { type: Types.SetMenu, menu: { menu1: "item1", menu2: "item2" } };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState.menu).toEqual({ menu1: "item1", menu2: "item2" });
         expect(newState.menu).toEqual({ menu1: "item1", menu2: "item2" });
     });
     });
     it("should handle DOWNLOAD_FILE action", () => {
     it("should handle DOWNLOAD_FILE action", () => {
-        const initialState = { ...INITIAL_STATE, download: undefined };
+        const initialState: TaipyState = { ...INITIAL_STATE, download: undefined };
         const action = { type: Types.DownloadFile, content: "fileContent", name: "fileName", onAction: "fileAction" };
         const action = { type: Types.DownloadFile, content: "fileContent", name: "fileName", onAction: "fileAction" };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState.download).toEqual({ content: "fileContent", name: "fileName", onAction: "fileAction" });
         expect(newState.download).toEqual({ content: "fileContent", name: "fileName", onAction: "fileAction" });
     });
     });
     it("should handle PARTIAL action", () => {
     it("should handle PARTIAL action", () => {
-        const initialState = { ...INITIAL_STATE, data: { test: false } };
+        const initialState: TaipyState = { ...INITIAL_STATE, data: { test: false } };
         const actionCreate = {
         const actionCreate = {
             type: Types.Partial,
             type: Types.Partial,
             name: "test",
             name: "test",
@@ -738,7 +779,7 @@ describe("taipyReducer function", () => {
         expect(newState.data.test).toBeUndefined();
         expect(newState.data.test).toBeUndefined();
     });
     });
     it("should handle MULTIPLE_UPDATE action", () => {
     it("should handle MULTIPLE_UPDATE action", () => {
-        const initialState = { ...INITIAL_STATE, data: { test1: false, test2: false } };
+        const initialState: TaipyState = { ...INITIAL_STATE, data: { test1: false, test2: false } };
         const action = {
         const action = {
             type: Types.MultipleUpdate,
             type: Types.MultipleUpdate,
             payload: [
             payload: [
@@ -757,13 +798,13 @@ describe("taipyReducer function", () => {
         expect(newState.data.test2).toEqual(true);
         expect(newState.data.test2).toEqual(true);
     });
     });
     it("should handle SetTimeZone action with fromBackend true", () => {
     it("should handle SetTimeZone action with fromBackend true", () => {
-        const initialState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
+        const initialState: TaipyState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: true } };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: true } };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState.timeZone).toEqual("newTimeZone");
         expect(newState.timeZone).toEqual("newTimeZone");
     });
     });
     it("should handle SetTimeZone action with fromBackend false and localStorage value", () => {
     it("should handle SetTimeZone action with fromBackend false and localStorage value", () => {
-        const initialState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
+        const initialState: TaipyState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
         const localStorageTimeZone = "localStorageTimeZone";
         const localStorageTimeZone = "localStorageTimeZone";
         localStorage.setItem("timeZone", localStorageTimeZone);
         localStorage.setItem("timeZone", localStorageTimeZone);
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: false } };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: false } };
@@ -772,13 +813,13 @@ describe("taipyReducer function", () => {
         localStorage.removeItem("timeZone");
         localStorage.removeItem("timeZone");
     });
     });
     it("should handle SetTimeZone action with fromBackend false and no localStorage value", () => {
     it("should handle SetTimeZone action with fromBackend false and no localStorage value", () => {
-        const initialState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
+        const initialState: TaipyState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: false } };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "newTimeZone", fromBackend: false } };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState.timeZone).toEqual("UTC");
         expect(newState.timeZone).toEqual("UTC");
     });
     });
     it("should handle SetTimeZone action with no change in timeZone", () => {
     it("should handle SetTimeZone action with no change in timeZone", () => {
-        const initialState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
+        const initialState: TaipyState = { ...INITIAL_STATE, timeZone: "oldTimeZone" };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "oldTimeZone", fromBackend: true } };
         const action = { type: Types.SetTimeZone, payload: { timeZone: "oldTimeZone", fromBackend: true } };
         const newState = taipyReducer(initialState, action);
         const newState = taipyReducer(initialState, action);
         expect(newState).toEqual(initialState);
         expect(newState).toEqual(initialState);
@@ -888,7 +929,7 @@ describe("messageToAction function", () => {
         expect(result).toEqual(expected);
         expect(result).toEqual(expected);
     });
     });
     it('should call createAlertAction if message type is "AL"', () => {
     it('should call createAlertAction if message type is "AL"', () => {
-        const message: WsMessage & Partial<AlertMessage> = {
+        const message: WsMessage & Partial<NotificationMessage> = {
             type: "AL",
             type: "AL",
             atype: "I",
             atype: "I",
             name: "someName",
             name: "someName",
@@ -899,7 +940,7 @@ describe("messageToAction function", () => {
             ack_id: "someAckId",
             ack_id: "someAckId",
         };
         };
         const result = messageToAction(message);
         const result = messageToAction(message);
-        const expectedResult = createAlertAction(message as unknown as AlertMessage);
+        const expectedResult = createNotificationAction(message as unknown as NotificationMessage);
         expect(result).toEqual(expectedResult);
         expect(result).toEqual(expectedResult);
     });
     });
     it('should call createBlockAction if message type is "BL"', () => {
     it('should call createBlockAction if message type is "BL"', () => {

+ 28 - 28
frontend/taipy-gui/src/context/taipyReducers.ts

@@ -37,8 +37,8 @@ export enum Types {
     SetLocations = "SET_LOCATIONS",
     SetLocations = "SET_LOCATIONS",
     SetTheme = "SET_THEME",
     SetTheme = "SET_THEME",
     SetTimeZone = "SET_TIMEZONE",
     SetTimeZone = "SET_TIMEZONE",
-    SetAlert = "SET_ALERT",
-    DeleteAlert = "DELETE_ALERT",
+    SetNotification = "SET_NOTIFICATION",
+    DeleteNotification = "DELETE_NOTIFICATION",
     SetBlock = "SET_BLOCK",
     SetBlock = "SET_BLOCK",
     Navigate = "NAVIGATE",
     Navigate = "NAVIGATE",
     ClientId = "CLIENT_ID",
     ClientId = "CLIENT_ID",
@@ -63,7 +63,7 @@ export interface TaipyState {
     dateFormat?: string;
     dateFormat?: string;
     dateTimeFormat?: string;
     dateTimeFormat?: string;
     numberFormat?: string;
     numberFormat?: string;
-    alerts: AlertMessage[];
+    notifications: NotificationMessage[];
     block?: BlockMessage;
     block?: BlockMessage;
     navigateTo?: string;
     navigateTo?: string;
     navigateParams?: Record<string, string>;
     navigateParams?: Record<string, string>;
@@ -87,7 +87,7 @@ export interface NamePayload {
     payload: Record<string, unknown>;
     payload: Record<string, unknown>;
 }
 }
 
 
-export interface AlertMessage {
+export interface NotificationMessage {
     atype: string;
     atype: string;
     message: string;
     message: string;
     system: boolean;
     system: boolean;
@@ -108,7 +108,7 @@ interface TaipyMultipleMessageAction extends TaipyBaseAction {
     actions: TaipyBaseAction[];
     actions: TaipyBaseAction[];
 }
 }
 
 
-interface TaipyAlertAction extends TaipyBaseAction, AlertMessage {}
+interface TaipyNotificationAction extends TaipyBaseAction, NotificationMessage {}
 
 
 interface TaipyDeleteAlertAction extends TaipyBaseAction {
 interface TaipyDeleteAlertAction extends TaipyBaseAction {
     notificationId: string;
     notificationId: string;
@@ -201,7 +201,7 @@ export const INITIAL_STATE: TaipyState = {
     id: getLocalStorageValue(TAIPY_CLIENT_ID, ""),
     id: getLocalStorageValue(TAIPY_CLIENT_ID, ""),
     menu: {},
     menu: {},
     ackList: [],
     ackList: [],
-    alerts: [],
+    notifications: [],
 };
 };
 
 
 export const taipyInitialize = (initialState: TaipyState): TaipyState => ({
 export const taipyInitialize = (initialState: TaipyState): TaipyState => ({
@@ -217,7 +217,7 @@ export const messageToAction = (message: WsMessage) => {
         } else if (message.type === "U") {
         } else if (message.type === "U") {
             return createUpdateAction(message as unknown as NamePayload);
             return createUpdateAction(message as unknown as NamePayload);
         } else if (message.type === "AL") {
         } else if (message.type === "AL") {
-            return createAlertAction(message as unknown as AlertMessage);
+            return createNotificationAction(message as unknown as NotificationMessage);
         } else if (message.type === "BL") {
         } else if (message.type === "BL") {
             return createBlockAction(message as unknown as BlockMessage);
             return createBlockAction(message as unknown as BlockMessage);
         } else if (message.type === "NA") {
         } else if (message.type === "NA") {
@@ -374,26 +374,26 @@ export const taipyReducer = (state: TaipyState, baseAction: TaipyBaseAction): Ta
             };
             };
         case Types.SetLocations:
         case Types.SetLocations:
             return { ...state, locations: action.payload.value as Record<string, string> };
             return { ...state, locations: action.payload.value as Record<string, string> };
-        case Types.SetAlert:
-            const alertAction = action as unknown as TaipyAlertAction;
+        case Types.SetNotification:
+            const notificationAction = action as unknown as TaipyNotificationAction;
             return {
             return {
                 ...state,
                 ...state,
-                alerts: [
-                    ...state.alerts,
+                notifications: [
+                    ...state.notifications,
                     {
                     {
-                        atype: alertAction.atype,
-                        message: alertAction.message,
-                        system: alertAction.system,
-                        duration: alertAction.duration,
-                        notificationId: alertAction.notificationId || nanoid(),
+                        atype: notificationAction.atype,
+                        message: notificationAction.message,
+                        system: notificationAction.system,
+                        duration: notificationAction.duration,
+                        notificationId: notificationAction.notificationId || nanoid(),
                     },
                     },
                 ],
                 ],
             };
             };
-        case Types.DeleteAlert:
-            const deleteAlertAction = action as unknown as TaipyAlertAction;
+        case Types.DeleteNotification:
+            const deleteNotificationAction = action as unknown as TaipyNotificationAction;
             return {
             return {
                 ...state,
                 ...state,
-                alerts: state.alerts.filter(alert => alert.notificationId !== deleteAlertAction.notificationId),
+                notifications: state.notifications.filter(notification => notification.notificationId !== deleteNotificationAction.notificationId),
             };
             };
         case Types.SetBlock:
         case Types.SetBlock:
             const blockAction = action as unknown as TaipyBlockAction;
             const blockAction = action as unknown as TaipyBlockAction;
@@ -802,7 +802,7 @@ export const createTimeZoneAction = (timeZone: string, fromBackend = false): Tai
     payload: { timeZone: timeZone, fromBackend: fromBackend },
     payload: { timeZone: timeZone, fromBackend: fromBackend },
 });
 });
 
 
-const getAlertType = (aType: string) => {
+const getNotificationType = (aType: string) => {
     aType = aType.trim();
     aType = aType.trim();
     if (aType) {
     if (aType) {
         aType = aType.charAt(0).toLowerCase();
         aType = aType.charAt(0).toLowerCase();
@@ -820,17 +820,17 @@ const getAlertType = (aType: string) => {
     return aType;
     return aType;
 };
 };
 
 
-export const createAlertAction = (alert: AlertMessage): TaipyAlertAction => ({
-    type: Types.SetAlert,
-    atype: getAlertType(alert.atype),
-    message: alert.message,
-    system: alert.system,
-    duration: alert.duration,
-    notificationId: alert.notificationId,
+export const createNotificationAction = (notification: NotificationMessage): TaipyNotificationAction => ({
+    type: Types.SetNotification,
+    atype: getNotificationType(notification.atype),
+    message: notification.message,
+    system: notification.system,
+    duration: notification.duration,
+    notificationId: notification.notificationId,
 });
 });
 
 
 export const createDeleteAlertAction = (notificationId: string): TaipyDeleteAlertAction => ({
 export const createDeleteAlertAction = (notificationId: string): TaipyDeleteAlertAction => ({
-    type: Types.DeleteAlert,
+    type: Types.DeleteNotification,
     notificationId,
     notificationId,
 });
 });
 
 

+ 30 - 0
frontend/taipy-gui/src/utils/image.ts

@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021-2024 Avaiga Private Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+
+export const toDataUrl = (url: string | null) =>
+    new Promise((resolve, reject) => {
+        if (!url) {
+            resolve(null);
+        }
+        const xhr = new XMLHttpRequest();
+        xhr.onload = () => {
+            const reader = new FileReader();
+            reader.onloadend = () => resolve(reader.result);
+            reader.onerror = () => reject(reader.error);
+            reader.readAsDataURL(xhr.response);
+        };
+        xhr.onerror = reject;
+        xhr.open("GET", url || "");
+        xhr.responseType = "blob";
+        xhr.send();
+    });

+ 3 - 2
taipy/gui/_renderers/factory.py

@@ -116,9 +116,10 @@ class _Factory:
                 ("users", PropertyType.lov),
                 ("users", PropertyType.lov),
                 ("sender_id",),
                 ("sender_id",),
                 ("height",),
                 ("height",),
-                ("page_size", PropertyType.number, 50),
-                ("max_file_size", PropertyType.number, 1 * 1024 * 1024),
+                ("page_size", PropertyType.number),
+                ("max_file_size", PropertyType.number),
                 ("show_sender", PropertyType.boolean, False),
                 ("show_sender", PropertyType.boolean, False),
+                ("allow_send_images", PropertyType.boolean, True),
                 ("mode",),
                 ("mode",),
             ]
             ]
         ),
         ),

+ 8 - 2
taipy/gui/viselements.json

@@ -1815,8 +1815,14 @@
                     {
                     {
                         "name": "max_file_size",
                         "name": "max_file_size",
                         "type": "int",
                         "type": "int",
-                        "default_value": "1024 * 1024",
-                        "doc": "The maximum allowable file size, in bytes, for files uploaded to a chat message.\nThe default is 1 MB."
+                        "default_value": "0.8 * 1024 * 1024",
+                        "doc": "The maximum allowable file size, in bytes, for files uploaded to a chat message.\nThe default is 0.8 MB."
+                    },
+                    {
+                        "name": "allow_send_images",
+                        "type": "bool",
+                        "default_value": "True",
+                        "doc": "TODO if True, an upload image icon is shown."
                     }
                     }
                 ]
                 ]
             }
             }