diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 337559b404..ccf362d516 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,3 +9,7 @@ Fixes # (issue) ## Video/Screenshots + +## PR Test Details + +**Note**: The PR will be ready for live testing at https://rocketchat.github.io/EmbeddedChat/pulls/pr- after approval. Contributors are requested to replace `` with the actual PR number. diff --git a/.github/workflows/build-and-lint.yml b/.github/workflows/build-and-lint.yml index d92c4001a5..375ec8c3af 100644 --- a/.github/workflows/build-and-lint.yml +++ b/.github/workflows/build-and-lint.yml @@ -34,7 +34,7 @@ jobs: ${{ runner.os }}-yarn- - name: Install dependencies - run: yarn + run: yarn install - name: Format check run: yarn format:check diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml new file mode 100644 index 0000000000..66c97af32c --- /dev/null +++ b/.github/workflows/build-pr.yml @@ -0,0 +1,65 @@ +name: Build PR-Preview + +on: + pull_request_review: + types: submitted + +concurrency: + group: ${{github.workflow}}-${{github.ref}} + cancel-in-progress: true + +env: + LAYOUT_EDITOR_BASE_URL: "/EmbeddedChat/pulls/pr-${{github.event.pull_request.number}}/layout_editor" + DOCS_BASE_URL: "/EmbeddedChat/pulls/pr-${{github.event.pull_request.number}}/docs" + STORYBOOK_RC_HOST: "https://demo.qa.rocket.chat" + +jobs: + build: + if: github.event.review.state == 'approved' && (github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'OWNER') + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "16.19.0" + + - name: Install Dependencies + run: yarn install + + - name: Build packages + run: yarn build && yarn build:storybook + + - name: Setup Node.js for Docs + uses: actions/setup-node@v4 + with: + node-version: "18.x" + + - name: "Install dependencies for docs" + run: yarn install + working-directory: packages/docs/ + + - name: Build Docs + run: yarn build + working-directory: packages/docs/ + + - name: Prepare Build Folder + run: | + mkdir -p build/pulls/pr-${{github.event.pull_request.number}}/ + mkdir -p build/pulls/pr-${{github.event.pull_request.number}}/ui-elements + mkdir -p build/pulls/pr-${{github.event.pull_request.number}}/layout_editor + mkdir -p build/pulls/pr-${{github.event.pull_request.number}}/docs + + mv -v packages/react/storybook-static/* build/pulls/pr-${{github.event.pull_request.number}}/ + mv -v packages/ui-elements/storybook-static/* build/pulls/pr-${{github.event.pull_request.number}}/ui-elements/ + mv -v packages/layout_editor/dist/* build/pulls/pr-${{github.event.pull_request.number}}/layout_editor/ + mv -v packages/docs/build/* build/pulls/pr-${{github.event.pull_request.number}}/docs/ + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: github-pages + path: build/ diff --git a/.github/workflows/deploy-pr.yml b/.github/workflows/deploy-pr.yml new file mode 100644 index 0000000000..4f687507d3 --- /dev/null +++ b/.github/workflows/deploy-pr.yml @@ -0,0 +1,36 @@ +name: Deploy PR-Preview + +on: + workflow_run: + workflows: ["Build PR-Preview"] + types: + - completed + +permissions: + contents: write + pages: write + +jobs: + deploy: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + + steps: + - uses: actions/download-artifact@v4 + with: + name: github-pages + path: build/ + github-token: ${{github.token}} + repository: ${{github.repository}} + run-id: ${{github.event.workflow_run.id}} + + - name: Deploy to GitHub Pages + uses: crazy-max/ghaction-github-pages@v2 + with: + target_branch: gh-deploy + build_dir: build/ + commit_message: "Deploy to Github Pages" + jekyll: false + keep_history: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3099455b0c..4055f20ca6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Build and Publish Storybook to GitHub Pages +name: Build and Publish on: push: @@ -30,19 +30,10 @@ jobs: node-version: "16.19.0" - name: Install Dependencies - run: yarn - - - name: Build Storybook - run: yarn build:storybook - working-directory: packages/react - - - name: Build UI-Elements - run: yarn build:storybook - working-directory: packages/ui-elements + run: yarn install - - name: Build Layout Editor - run: npm run build - working-directory: packages/layout_editor + - name: Build packages + run: yarn build && yarn build:storybook - name: Setup Node.js for Docs uses: actions/setup-node@v4 diff --git a/.github/workflows/pr-cleanup.yml b/.github/workflows/pr-cleanup.yml new file mode 100644 index 0000000000..c5b878373c --- /dev/null +++ b/.github/workflows/pr-cleanup.yml @@ -0,0 +1,34 @@ +name: Pull Request Cleanup +on: + pull_request_target: + types: [closed] + +jobs: + cleanup: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + ref: gh-deploy + + - name: Check if Deployment Exists + id: check_deployment + run: | + if [ -d "pulls/pr-${{ github.event.pull_request.number }}" ]; then + echo "deployment_exists=true" >> $GITHUB_ENV + else + echo "deployment_exists=false" >> $GITHUB_ENV + fi + + - name: Remove Deployment + if: env.deployment_exists == 'true' + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git fetch origin gh-deploy + git checkout gh-deploy + git rm -r pulls/pr-${{github.event.pull_request.number}} + git commit -m "Remove deployment for PR #${{github.event.pull_request.number}}" + git push origin gh-deploy diff --git a/packages/api/playground/index.html b/packages/api/playground/index.html index 6091350192..d850068e98 100644 --- a/packages/api/playground/index.html +++ b/packages/api/playground/index.html @@ -24,6 +24,14 @@ .playground-output #output{ white-space: pre-wrap; } + #togglePassword { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + vertical-align: middle; + width: 50px; + } @@ -43,6 +51,9 @@
+
diff --git a/packages/api/playground/playground.js b/packages/api/playground/playground.js index e280d9a4c9..d572018423 100644 --- a/packages/api/playground/playground.js +++ b/packages/api/playground/playground.js @@ -1,4 +1,5 @@ import EmbeddedChatApi from '../src/EmbeddedChatApi'; + let messages = []; async function saveToken(token) { localStorage.setItem("ec_token", token); @@ -96,6 +97,7 @@ const callApi = async (e) => { const result = await api[fn].apply(api, params); printResult(result); } + window.addEventListener('DOMContentLoaded', () => { console.log('Ready') document.getElementById("loginWithPassword").addEventListener("click", loginWithPassword) @@ -113,8 +115,25 @@ window.addEventListener('DOMContentLoaded', () => { document.getElementById("logoutBtn").addEventListener("click", () => api.auth.logout()) document.getElementById("call-api").addEventListener("click", callApi) + const passwordField = document.getElementById('password') + const togglePassword = document.getElementById('togglePassword') + togglePassword.addEventListener('click',() => toggle(passwordField, togglePassword)) }) +let isPasswordVisible = false + +const toggle = (passwordField, togglePassword) => { + isPasswordVisible = !isPasswordVisible + + if(isPasswordVisible){ + passwordField.type = "text" + togglePassword.innerText = "Hide"; + } else { + passwordField.type = "password"; + togglePassword.innerText = "Show"; + } +} + function escapeHTML(str) { return str.replace( /[&<>'"]/g, diff --git a/packages/api/src/EmbeddedChatApi.ts b/packages/api/src/EmbeddedChatApi.ts index 574370a5b1..44cff0eaf4 100644 --- a/packages/api/src/EmbeddedChatApi.ts +++ b/packages/api/src/EmbeddedChatApi.ts @@ -121,12 +121,12 @@ export default class EmbeddedChatApi { let credentials; if (!code) { credentials = credentials = { - user: userOrEmail, + user: userOrEmail.trim(), password, }; } else { credentials = { - user: userOrEmail, + user: userOrEmail.trim(), password, code, }; @@ -552,16 +552,65 @@ export default class EmbeddedChatApi { } } + async getOlderMessages( + anonymousMode = false, + options: { + query?: object | undefined; + field?: object | undefined; + offset?: number; + } = { + query: undefined, + field: undefined, + offset: 50, + }, + isChannelPrivate = false + ) { + const roomType = isChannelPrivate ? "groups" : "channels"; + const endp = anonymousMode ? "anonymousread" : "messages"; + const query = options?.query + ? `&query=${JSON.stringify(options.query)}` + : ""; + const field = options?.field + ? `&field=${JSON.stringify(options.field)}` + : ""; + const offset = options?.offset ? options.offset : 0; + try { + const { userId, authToken } = (await this.auth.getCurrentUser()) || {}; + const messages = await fetch( + `${this.host}/api/v1/${roomType}.${endp}?roomId=${this.rid}${query}${field}&offset=${offset}`, + { + headers: { + "Content-Type": "application/json", + "X-Auth-Token": authToken, + "X-User-Id": userId, + }, + method: "GET", + } + ); + return await messages.json(); + } catch (err) { + console.log(err); + } + } + async getThreadMessages(tmid: string, isChannelPrivate = false) { - return this.getMessages( - false, - { - query: { - tmid, - }, - }, - isChannelPrivate - ); + try { + const { userId, authToken } = (await this.auth.getCurrentUser()) || {}; + const messages = await fetch( + `${this.host}/api/v1/chat.getThreadMessages?roomId=${this.rid}&tmid=${tmid}`, + { + headers: { + "Content-Type": "application/json", + "X-Auth-Token": authToken, + "X-User-Id": userId, + }, + method: "GET", + } + ); + return await messages.json(); + } catch (err) { + console.log(err); + } } async getChannelRoles(isChannelPrivate = false) { @@ -605,6 +654,41 @@ export default class EmbeddedChatApi { } } + async getUserRoles() { + try { + const { userId, authToken } = (await this.auth.getCurrentUser()) || {}; + const response = await fetch( + `${this.host}/api/v1/method.call/getUserRoles`, + { + body: JSON.stringify({ + message: JSON.stringify({ + msg: "method", + id: null, + method: "getUserRoles", + params: [], + }), + }), + headers: { + "Content-Type": "application/json", + "X-Auth-Token": authToken, + "X-User-Id": userId, + }, + method: "POST", + } + ); + + const result = await response.json(); + + if (result.success && result.message) { + const parsedMessage = JSON.parse(result.message); + return parsedMessage; + } + return null; + } catch (err) { + console.error(err); + } + } + async sendTypingStatus(username: string, typing: boolean) { try { this.rcClient.methodCall( @@ -689,21 +773,22 @@ export default class EmbeddedChatApi { } } - async getAllFiles(isChannelPrivate = false) { + async getAllFiles(isChannelPrivate = false, typeGroup: string) { const roomType = isChannelPrivate ? "groups" : "channels"; try { const { userId, authToken } = (await this.auth.getCurrentUser()) || {}; - const response = await fetch( - `${this.host}/api/v1/${roomType}.files?roomId=${this.rid}`, - { - headers: { - "Content-Type": "application/json", - "X-Auth-Token": authToken, - "X-User-Id": userId, - }, - method: "GET", - } - ); + const url = + typeGroup === "" + ? `${this.host}/api/v1/${roomType}.files?roomId=${this.rid}` + : `${this.host}/api/v1/${roomType}.files?roomId=${this.rid}&typeGroup=${typeGroup}`; + const response = await fetch(url, { + headers: { + "Content-Type": "application/json", + "X-Auth-Token": authToken, + "X-User-Id": userId, + }, + method: "GET", + }); return await response.json(); } catch (err) { console.error(err); @@ -714,7 +799,7 @@ export default class EmbeddedChatApi { try { const { userId, authToken } = (await this.auth.getCurrentUser()) || {}; const response = await fetch( - `${this.host}/api/v1/channels.images?roomId=${this.rid}`, + `${this.host}/api/v1/rooms.images?roomId=${this.rid}`, { headers: { "Content-Type": "application/json", @@ -1124,4 +1209,21 @@ export default class EmbeddedChatApi { const data = response.json(); return data; } + + async userData(username: string) { + const { userId, authToken } = (await this.auth.getCurrentUser()) || {}; + const response = await fetch( + `${this.host}/api/v1/users.info?username=${username}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-Auth-Token": authToken, + "X-User-Id": userId, + }, + } + ); + const data = response.json(); + return data; + } } diff --git a/packages/docs/docusaurus.config.js b/packages/docs/docusaurus.config.js index f9073bfa8e..1201dae919 100644 --- a/packages/docs/docusaurus.config.js +++ b/packages/docs/docusaurus.config.js @@ -17,7 +17,7 @@ const config = { url: "https://rocketchat.github.io/", // Set the // pathname under which your site is served // For GitHub pages deployment, it is often '//' - baseUrl: "/EmbeddedChat/docs/", + baseUrl: process.env.DOCS_BASE_URL || "/EmbeddedChat/docs/", // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. diff --git a/packages/layout_editor/src/store/chatInputItemsStore.js b/packages/layout_editor/src/store/chatInputItemsStore.js index 1a4b948bfa..7d140d26f0 100644 --- a/packages/layout_editor/src/store/chatInputItemsStore.js +++ b/packages/layout_editor/src/store/chatInputItemsStore.js @@ -1,7 +1,7 @@ import { create } from 'zustand'; const useChatInputItemsStore = create((set) => ({ - surfaceItems: ['emoji', 'formatter', 'audio', 'video', 'file'], + surfaceItems: ['emoji', 'formatter', 'link', 'audio', 'video', 'file'], formatters: ['bold', 'italic', 'strike', 'code', 'multiline'], setSurfaceItems: (items) => set({ surfaceItems: items }), setFormatters: (items) => set({ formatters: items }), diff --git a/packages/layout_editor/src/views/ChatInput/ChatInputToolbar.jsx b/packages/layout_editor/src/views/ChatInput/ChatInputToolbar.jsx index f594e3a813..9d5550c718 100644 --- a/packages/layout_editor/src/views/ChatInput/ChatInputToolbar.jsx +++ b/packages/layout_editor/src/views/ChatInput/ChatInputToolbar.jsx @@ -41,6 +41,13 @@ const ChatInputToolbar = () => { iconName: 'emoji', visible: true, }, + link: { + label: 'Link', + id: 'link', + onClick: () => {}, + iconName: 'link', + visible: true, + }, audio: { label: 'Audio Message', id: 'audio', @@ -61,7 +68,7 @@ const ChatInputToolbar = () => { onClick: () => {}, iconName: 'attachment', visible: true, - }, + }, formatter: { label: 'Formatter', id: 'formatter', diff --git a/packages/layout_editor/src/views/ThemeLab/LayoutSetting.jsx b/packages/layout_editor/src/views/ThemeLab/LayoutSetting.jsx index 0eba8d6eab..60ac4c4296 100644 --- a/packages/layout_editor/src/views/ThemeLab/LayoutSetting.jsx +++ b/packages/layout_editor/src/views/ThemeLab/LayoutSetting.jsx @@ -217,6 +217,15 @@ const LayoutSetting = () => { iconName: 'emoji', visible: true, }, + link: { + label: 'Link', + id: 'link', + onClick: () => { + addInputSurfaceItem('link'); + }, + iconName: 'link', + visible: true, + }, audio: { label: 'Audio Message', id: 'audio', @@ -243,7 +252,7 @@ const LayoutSetting = () => { }, iconName: 'attachment', visible: true, - }, + }, formatter: { label: 'Formatter', id: 'formatter', diff --git a/packages/layout_editor/vite.config.ts b/packages/layout_editor/vite.config.ts index 2b9daff2d3..b6ab8acd24 100644 --- a/packages/layout_editor/vite.config.ts +++ b/packages/layout_editor/vite.config.ts @@ -11,5 +11,5 @@ export default defineConfig({ }, }), ], - base: "/EmbeddedChat/layout_editor" + base: process.env.LAYOUT_EDITOR_BASE_URL || '/EmbeddedChat/layout_editor', }); diff --git a/packages/markups/package.json b/packages/markups/package.json index 5557b50c04..ab60f023f7 100644 --- a/packages/markups/package.json +++ b/packages/markups/package.json @@ -76,6 +76,7 @@ "@emotion/react": "11.7.1", "@rollup/plugin-json": "^6.0.0", "emoji-toolkit": "^7.0.1", - "prop-types": "^15.8.1" + "prop-types": "^15.8.1", + "react-syntax-highlighter": "^15.6.1" } } diff --git a/packages/markups/src/elements/CodeBlock.js b/packages/markups/src/elements/CodeBlock.js index b8f5acf133..d2d93901ee 100644 --- a/packages/markups/src/elements/CodeBlock.js +++ b/packages/markups/src/elements/CodeBlock.js @@ -1,9 +1,13 @@ import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; -import { Box } from '@embeddedchat/ui-elements'; -import { CodeBlockStyles as styles } from './elements.styles'; +import { Box, useTheme } from '@embeddedchat/ui-elements'; +import SyntaxHighlighter from 'react-syntax-highlighter'; +import { vs, monokai } from 'react-syntax-highlighter/dist/esm/styles/hljs'; +import { CodeBlockStyles } from './elements.styles'; const CodeBlock = ({ lines }) => { + const { mode } = useTheme(); + const styles = CodeBlockStyles(); const code = useMemo( () => lines.map((line) => line.value.value).join('\n'), [lines] @@ -14,7 +18,13 @@ const CodeBlock = ({ lines }) => { ``` - {code} + + {code} + ``` @@ -23,7 +33,6 @@ const CodeBlock = ({ lines }) => { }; export default CodeBlock; - CodeBlock.propTypes = { lines: PropTypes.any, }; diff --git a/packages/markups/src/elements/CodeElement.js b/packages/markups/src/elements/CodeElement.js index 043f5a2a00..8a2483f17f 100644 --- a/packages/markups/src/elements/CodeElement.js +++ b/packages/markups/src/elements/CodeElement.js @@ -1,12 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; import PlainSpan from './PlainSpan'; +import { InlineElementsStyles } from './elements.styles'; -const CodeElement = ({ contents }) => ( - - - -); +const CodeElement = ({ contents }) => { + const styles = InlineElementsStyles(); + return ( + + + + ); +}; export default CodeElement; diff --git a/packages/markups/src/elements/elements.styles.js b/packages/markups/src/elements/elements.styles.js index aa44d77dd1..31497a6517 100644 --- a/packages/markups/src/elements/elements.styles.js +++ b/packages/markups/src/elements/elements.styles.js @@ -1,23 +1,46 @@ import { css } from '@emotion/react'; -import { useTheme } from '@embeddedchat/ui-elements'; +import { useTheme, darken } from '@embeddedchat/ui-elements'; -export const CodeBlockStyles = { - copyonly: css` - display: none; - width: 100%; - height: 0; - user-select: none; - vertical-align: baseline; - font-size: 0; - -moz-box-orient: vertical; - `, +export const InlineElementsStyles = () => { + const { theme } = useTheme(); + const styles = { + inlineElement: css` + font-weight: 600; + font-size: 0.75rem; + width: fit-content; + padding: 3px; + background-color: ${theme.colors.border}; + border-radius: 6px; + `, + }; + return styles; +}; +export const CodeBlockStyles = () => { + const { theme } = useTheme(); + const styles = { + copyonly: css` + display: none; + width: 100%; + height: 0; + user-select: none; + vertical-align: baseline; + font-size: 0; + -moz-box-orient: vertical; + `, - prestyle: css` - display: inline-block; - max-width: 100%; - overflow-x: auto; - white-space: pre-wrap; - `, + prestyle: css` + display: inline-block; + width: 100%; + overflow-x: auto; + white-space: pre-wrap; + `, + codeBlock: css` + background-color: ${darken(theme.colors.accent, 0.01)} !important; + border-radius: ${theme.radius}; + font-weight: 600; + `, + }; + return styles; }; export const ColorElementStyles = { @@ -76,6 +99,13 @@ const useMentionStyles = (contents, username) => { cursor: pointer; padding: 1.5px; border-radius: 3px; + + &:hover { + text-decoration: underline; + text-decoration-color: ${contents.value === username + ? theme.colors.destructiveForeground + : theme.colors.mutedForeground}; + } `, }; diff --git a/packages/markups/src/mentions/UserMention.js b/packages/markups/src/mentions/UserMention.js index 679ffc0d37..45615e4a1a 100644 --- a/packages/markups/src/mentions/UserMention.js +++ b/packages/markups/src/mentions/UserMention.js @@ -1,14 +1,35 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; -import { Box } from '@embeddedchat/ui-elements'; +import { Box, Tooltip } from '@embeddedchat/ui-elements'; +import { useUserStore } from '@embeddedchat/react/src/store'; +import useSetExclusiveState from '@embeddedchat/react/src/hooks/useSetExclusiveState'; +import RCContext from '@embeddedchat/react/src/context/RCInstance'; import { MarkupInteractionContext } from '../MarkupInteractionContext'; import useMentionStyles from '../elements/elements.styles'; const UserMention = ({ contents }) => { const { members, username } = useContext(MarkupInteractionContext); + const { RCInstance } = useContext(RCContext); + const setExclusiveState = useSetExclusiveState(); + const { setShowCurrentUserInfo, setCurrentUser } = useUserStore((state) => ({ + setShowCurrentUserInfo: state.setShowCurrentUserInfo, + setCurrentUser: state.setCurrentUser, + })); + + const handleUserInfo = async (uname) => { + const data = await RCInstance.userData(uname); + setCurrentUser({ + _id: data.user._id, + username: data.user.username, + name: data.user.name, + }); + setExclusiveState(setShowCurrentUserInfo); + }; const hasMember = (user) => { - if (user === 'all' || user === 'here') return true; + if (user === 'all' || user === 'here') { + return true; + } let found = false; Object.keys(members).forEach((ele) => { if (members[ele].username === user) { @@ -20,12 +41,27 @@ const UserMention = ({ contents }) => { const styles = useMentionStyles(contents, username); + const handleClick = () => { + if (!['here', 'all'].includes(contents.value)) { + handleUserInfo(contents.value); + } + }; + + const tooltipMap = { + all: 'Mentions all the room members', + here: 'Mentions online room members', + [username]: 'Mentions you', + }; + const tooltipText = tooltipMap[contents.value] || 'Mentions user'; + return ( <> {hasMember(contents.value) ? ( - - {contents.value} - + + + {contents.value} + + ) : ( `@${contents.value}` )} diff --git a/packages/react/src/hooks/useFetchChatData.js b/packages/react/src/hooks/useFetchChatData.js index 1d08f0eaf4..7f5b491252 100644 --- a/packages/react/src/hooks/useFetchChatData.js +++ b/packages/react/src/hooks/useFetchChatData.js @@ -5,6 +5,7 @@ import { useChannelStore, useMemberStore, useMessageStore, + useStarredMessageStore, } from '../store'; const useFetchChatData = (showRoles) => { @@ -12,10 +13,17 @@ const useFetchChatData = (showRoles) => { const setMemberRoles = useMemberStore((state) => state.setMemberRoles); const isChannelPrivate = useChannelStore((state) => state.isChannelPrivate); const setMessages = useMessageStore((state) => state.setMessages); + const setMessagesOffset = useMessageStore((state) => state.setMessagesOffset); const setAdmins = useMemberStore((state) => state.setAdmins); + const setStarredMessages = useStarredMessageStore( + (state) => state.setStarredMessages + ); const isUserAuthenticated = useUserStore( (state) => state.isUserAuthenticated ); + const setViewUserInfoPermissions = useUserStore( + (state) => state.setViewUserInfoPermissions + ); const getMessagesAndRoles = useCallback( async (anonymousMode) => { @@ -24,7 +32,7 @@ const useFetchChatData = (showRoles) => { return; } - const { messages } = await RCInstance.getMessages( + const { messages, count } = await RCInstance.getMessages( anonymousMode, ECOptions?.enableThreads ? { @@ -40,6 +48,7 @@ const useFetchChatData = (showRoles) => { if (messages) { setMessages(messages.filter((message) => message._hidden !== true)); + setMessagesOffset(count); } if (!isUserAuthenticated) { @@ -48,10 +57,10 @@ const useFetchChatData = (showRoles) => { if (showRoles) { const { roles } = await RCInstance.getChannelRoles(isChannelPrivate); - const fetchedAdmins = await RCInstance.getUsersInRole('admin'); - const adminUsernames = fetchedAdmins?.users?.map( - (user) => user.username - ); + const fetchedRoles = await RCInstance.getUserRoles(); + const fetchedAdmins = fetchedRoles?.result; + + const adminUsernames = fetchedAdmins?.map((user) => user.username); setAdmins(adminUsernames); const rolesObj = @@ -64,6 +73,9 @@ const useFetchChatData = (showRoles) => { setMemberRoles(rolesObj); } + + const permissions = await RCInstance.permissionInfo(); + setViewUserInfoPermissions(permissions.update[70]); } catch (e) { console.error(e); } @@ -80,7 +92,24 @@ const useFetchChatData = (showRoles) => { ] ); - return getMessagesAndRoles; + const getStarredMessages = useCallback( + async (anonymousMode) => { + if (isUserAuthenticated) { + try { + if (!isUserAuthenticated && !anonymousMode) { + return; + } + const { messages } = await RCInstance.getStarredMessages(); + setStarredMessages(messages); + } catch (e) { + console.error(e); + } + } + }, + [isUserAuthenticated, RCInstance, setStarredMessages] + ); + + return { getMessagesAndRoles, getStarredMessages }; }; export default useFetchChatData; diff --git a/packages/react/src/hooks/useRCAuth.js b/packages/react/src/hooks/useRCAuth.js index 83b013353b..c0b1d3a3b4 100644 --- a/packages/react/src/hooks/useRCAuth.js +++ b/packages/react/src/hooks/useRCAuth.js @@ -1,7 +1,12 @@ import { useContext } from 'react'; import { useToastBarDispatch } from '@embeddedchat/ui-elements'; import RCContext from '../context/RCInstance'; -import { useUserStore, totpModalStore, useLoginStore } from '../store'; +import { + useUserStore, + totpModalStore, + useLoginStore, + useMessageStore, +} from '../store'; export const useRCAuth = () => { const { RCInstance } = useContext(RCContext); @@ -20,11 +25,18 @@ export const useRCAuth = () => { ); const setPassword = useUserStore((state) => state.setPassword); const setEmailorUser = useUserStore((state) => state.setEmailorUser); + const setUserPinPermissions = useUserStore( + (state) => state.setUserPinPermissions + ); + const setEditMessagePermissions = useMessageStore( + (state) => state.setEditMessagePermissions + ); const dispatchToastMessage = useToastBarDispatch(); const handleLogin = async (userOrEmail, password, code) => { try { const res = await RCInstance.login(userOrEmail, password, code); + const permissions = await RCInstance.permissionInfo(); if (res.error === 'Unauthorized' || res.error === 403) { dispatchToastMessage({ type: 'error', @@ -56,6 +68,8 @@ export const useRCAuth = () => { setIsTotpModalOpen(false); setEmailorUser(null); setPassword(null); + setUserPinPermissions(permissions.update[150]); + setEditMessagePermissions(permissions.update[28]); dispatchToastMessage({ type: 'success', message: 'Successfully logged in', diff --git a/packages/react/src/hooks/useSetMessageList.js b/packages/react/src/hooks/useSetMessageList.js index a2b3917d59..69b4bef7cf 100644 --- a/packages/react/src/hooks/useSetMessageList.js +++ b/packages/react/src/hooks/useSetMessageList.js @@ -1,16 +1,14 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; export const useSetMessageList = (messages, shouldRender) => { const [loading, setLoading] = useState(true); - const [messageList, setMessageList] = useState([]); - useEffect(() => { - setLoading(true); - const filteredMessages = messages.filter((message) => - shouldRender(message) - ); + const messageList = useMemo( + () => messages.filter(shouldRender), + [messages, shouldRender] + ); - setMessageList(filteredMessages); + useEffect(() => { setLoading(false); }, [messages, shouldRender]); diff --git a/packages/react/src/lib/textFormat.js b/packages/react/src/lib/textFormat.js index 10ceb7f33c..a1978c0033 100644 --- a/packages/react/src/lib/textFormat.js +++ b/packages/react/src/lib/textFormat.js @@ -1,7 +1,11 @@ export const formatter = [ - { name: 'bold', pattern: '*{{text}}*' }, - { name: 'italic', pattern: '_{{text}}_' }, - { name: 'strike', pattern: '~{{text}}~' }, - { name: 'code', pattern: '`{{text}}`' }, - { name: 'multiline', pattern: '```\n{{text}}\n``` ' }, + { name: 'bold', pattern: '*{{text}}*', tooltip: 'Bold' }, + { name: 'italic', pattern: '_{{text}}_', tooltip: 'Italic' }, + { name: 'strike', pattern: '~{{text}}~', tooltip: 'Strikethrough' }, + { name: 'code', pattern: '`{{text}}`', tooltip: 'Inline code' }, + { + name: 'multiline', + pattern: '```\n{{text}}\n```', + tooltip: 'Multi-line code', + }, ]; diff --git a/packages/react/src/store/channelStore.js b/packages/react/src/store/channelStore.js index 2ba08bddb4..f3acbb914f 100644 --- a/packages/react/src/store/channelStore.js +++ b/packages/react/src/store/channelStore.js @@ -4,10 +4,12 @@ const useChannelStore = create((set) => ({ showChannelinfo: false, isChannelPrivate: false, isChannelReadOnly: false, + isRoomTeam: false, setShowChannelinfo: (showChannelinfo) => set(() => ({ showChannelinfo })), channelInfo: {}, setChannelInfo: (channelInfo) => set(() => ({ channelInfo })), setIsChannelPrivate: (isChannelPrivate) => set(() => ({ isChannelPrivate })), + setIsRoomTeam: (isRoomTeam) => set(() => ({ isRoomTeam })), setIsChannelReadOnly: (isChannelReadOnly) => set(() => ({ isChannelReadOnly })), })); diff --git a/packages/react/src/store/messageStore.js b/packages/react/src/store/messageStore.js index 012a97e1ed..a9a7265c86 100644 --- a/packages/react/src/store/messageStore.js +++ b/packages/react/src/store/messageStore.js @@ -8,7 +8,8 @@ const useMessageStore = create((set, get) => ({ threadMessages: [], filtered: false, editMessage: {}, - quoteMessage: {}, + messagesOffset: 0, + quoteMessage: [], messageToReport: NaN, showReportMessage: false, isRecordingMessage: false, @@ -16,11 +17,19 @@ const useMessageStore = create((set, get) => ({ threadMainMessage: null, headerTitle: null, setFilter: (filter) => set(() => ({ filtered: filter })), - setMessages: (messages) => - set(() => ({ - messages, - isMessageLoaded: true, - })), + setMessages: (newMessages, append = false) => + set((state) => { + const allMessages = append + ? [...state.messages, ...newMessages] + : newMessages; + const uniqueMessages = Array.from( + new Map(allMessages.map((msg) => [msg._id, msg])).values() + ); + return { + messages: uniqueMessages, + isMessageLoaded: true, + }; + }), upsertMessage: (message, enableThreads = false) => { if (message.tmid && enableThreads) { if (get().threadMainMessage?._id === message.tmid) { @@ -71,7 +80,23 @@ const useMessageStore = create((set, get) => ({ } }, setEditMessage: (editMessage) => set(() => ({ editMessage })), - setQuoteMessage: (quoteMessage) => set(() => ({ quoteMessage })), + setMessagesOffset: (newOffset) => set(() => ({ messagesOffset: newOffset })), + editMessagePermissions: {}, + setEditMessagePermissions: (editMessagePermissions) => + set((state) => ({ ...state, editMessagePermissions })), + addQuoteMessage: (quoteMessage) => + set((state) => { + const updatedQuoteMessages = state.quoteMessage.filter( + (msg) => msg._id !== quoteMessage._id + ); + return { quoteMessage: [...updatedQuoteMessages, quoteMessage] }; + }), + removeQuoteMessage: (quoteMessage) => + set((state) => ({ + quoteMessage: state.quoteMessage.filter((i) => i !== quoteMessage), + })), + + clearQuoteMessages: () => set({ quoteMessage: [] }), setMessageToReport: (messageId) => set(() => ({ messageToReport: messageId })), toggleShowReportMessage: () => { diff --git a/packages/react/src/store/starredMessageStore.js b/packages/react/src/store/starredMessageStore.js index a564df3c41..989ec8b6fb 100644 --- a/packages/react/src/store/starredMessageStore.js +++ b/packages/react/src/store/starredMessageStore.js @@ -3,6 +3,8 @@ import { create } from 'zustand'; const useStarredMessageStore = create((set) => ({ showStarred: false, setShowStarred: (showStarred) => set(() => ({ showStarred })), + starredMessages: [], + setStarredMessages: (messages) => set(() => ({ starredMessages: messages })), })); export default useStarredMessageStore; diff --git a/packages/react/src/store/userStore.js b/packages/react/src/store/userStore.js index c4128cf468..a19fef4714 100644 --- a/packages/react/src/store/userStore.js +++ b/packages/react/src/store/userStore.js @@ -27,8 +27,14 @@ const useUserStore = create((set) => ({ setPassword: (password) => set(() => ({ password })), emailoruser: null, setEmailorUser: (emailoruser) => set(() => ({ emailoruser })), - roles: {}, + roles: [], setRoles: (roles) => set((state) => ({ ...state, roles })), + userPinPermissions: {}, + setUserPinPermissions: (userPinPermissions) => + set((state) => ({ ...state, userPinPermissions })), + viewUserInfoPermissions: {}, + setViewUserInfoPermissions: (viewUserInfoPermissions) => + set((state) => ({ ...state, viewUserInfoPermissions })), showCurrentUserInfo: false, setShowCurrentUserInfo: (showCurrentUserInfo) => set(() => ({ showCurrentUserInfo })), diff --git a/packages/react/src/views/AttachmentHandler/Attachment.js b/packages/react/src/views/AttachmentHandler/Attachment.js index bc07a740c1..ec752e9bcd 100644 --- a/packages/react/src/views/AttachmentHandler/Attachment.js +++ b/packages/react/src/views/AttachmentHandler/Attachment.js @@ -7,13 +7,19 @@ import AudioAttachment from './AudioAttachment'; import VideoAttachment from './VideoAttachment'; import TextAttachment from './TextAttachment'; -const Attachment = ({ attachment, host, type, variantStyles = {} }) => { +const Attachment = ({ attachment, host, type, variantStyles = {}, msg }) => { + const author = { + authorIcon: attachment?.author_icon, + authorName: attachment?.author_name, + }; if (attachment && attachment.audio_url) { return ( ); } @@ -22,7 +28,9 @@ const Attachment = ({ attachment, host, type, variantStyles = {} }) => { ); } @@ -31,7 +39,9 @@ const Attachment = ({ attachment, host, type, variantStyles = {} }) => { ); } @@ -40,10 +50,56 @@ const Attachment = ({ attachment, host, type, variantStyles = {} }) => { ); } + if ( + attachment.attachments && + Array.isArray(attachment.attachments) && + attachment.attachments[0]?.image_url + ) { + return ( + + ); + } + if ( + attachment.attachments && + Array.isArray(attachment.attachments) && + attachment.attachments[0]?.audio_url + ) { + return ( + + ); + } + if ( + attachment.attachments && + Array.isArray(attachment.attachments) && + attachment.attachments[0]?.video_url + ) { + return ( + + ); + } return ( { +const AttachmentMetadata = ({ + attachment, + url, + variantStyles = {}, + msg, + onExpandCollapseClick, + isExpanded, +}) => { const handleDownload = async () => { try { const response = await fetch(url); @@ -32,15 +40,25 @@ const AttachmentMetadata = ({ attachment, url, variantStyles = {} }) => { variantStyles.attachmentMetaContainer, ]} > -

- {attachment.description} -

+ {msg ? ( + + ) : ( + attachment.description + )} +
{ `} >

{attachment.title}

+ { +const Attachments = ({ attachments, type, variantStyles = {}, msg }) => { const { RCInstance } = useContext(RCContext); let host = RCInstance.getHost(); host = host.replace(/\/$/, ''); @@ -15,6 +15,7 @@ const Attachments = ({ attachments, type, variantStyles = {} }) => { host={host} variantStyles={variantStyles} type={type} + msg={msg} /> )); }; diff --git a/packages/react/src/views/AttachmentHandler/AudioAttachment.js b/packages/react/src/views/AttachmentHandler/AudioAttachment.js index ce84815828..5caf16e760 100644 --- a/packages/react/src/views/AttachmentHandler/AudioAttachment.js +++ b/packages/react/src/views/AttachmentHandler/AudioAttachment.js @@ -1,18 +1,151 @@ -import React from 'react'; +import React, { useState, useContext } from 'react'; import PropTypes from 'prop-types'; -import { Box } from '@embeddedchat/ui-elements'; +import { css } from '@emotion/react'; +import { Box, Avatar, useTheme } from '@embeddedchat/ui-elements'; import AttachmentMetadata from './AttachmentMetadata'; +import RCContext from '../../context/RCInstance'; -const AudioAttachment = ({ attachment, host, variantStyles }) => ( - - - -); +const AudioAttachment = ({ + attachment, + host, + type, + author, + variantStyles, + msg, +}) => { + const { RCInstance } = useContext(RCContext); + const { theme } = useTheme(); + const getUserAvatarUrl = (icon) => { + const instanceHost = RCInstance.getHost(); + const URL = `${instanceHost}${icon}`; + return URL; + }; + const { authorIcon, authorName } = author; + + const [isExpanded, setIsExpanded] = useState(true); + const toggleExpanded = () => { + setIsExpanded((prevState) => !prevState); + }; + + return ( + + + {type === 'file' ? ( + <> + + + @{authorName} + + + ) : ( + '' + )} + + {isExpanded && ( + + + ); +}; export default AudioAttachment; diff --git a/packages/react/src/views/AttachmentHandler/ImageAttachment.js b/packages/react/src/views/AttachmentHandler/ImageAttachment.js index 8937782569..5f95829df0 100644 --- a/packages/react/src/views/AttachmentHandler/ImageAttachment.js +++ b/packages/react/src/views/AttachmentHandler/ImageAttachment.js @@ -1,41 +1,173 @@ -import React, { useState } from 'react'; +import React, { useState, useContext } from 'react'; import { css } from '@emotion/react'; import PropTypes from 'prop-types'; -import { Box } from '@embeddedchat/ui-elements'; +import { Box, Avatar, useTheme } from '@embeddedchat/ui-elements'; import AttachmentMetadata from './AttachmentMetadata'; import ImageGallery from '../ImageGallery/ImageGallery'; +import RCContext from '../../context/RCInstance'; -const ImageAttachment = ({ attachment, host, variantStyles = {} }) => { +const ImageAttachment = ({ + attachment, + host, + type, + author, + variantStyles = {}, + msg, +}) => { + const { RCInstance } = useContext(RCContext); const [showGallery, setShowGallery] = useState(false); + const getUserAvatarUrl = (icon) => { + const instanceHost = RCInstance.getHost(); + const URL = `${instanceHost}${icon}`; + return URL; + }; const extractIdFromUrl = (url) => { const match = url.match(/\/file-upload\/(.*?)\//); return match ? match[1] : null; }; + const { theme } = useTheme(); + + const { authorIcon, authorName } = author; + + const [isExpanded, setIsExpanded] = useState(true); + const toggleExpanded = () => { + setIsExpanded((prevState) => !prevState); + }; + return ( - setShowGallery(true)} - css={css` - cursor: pointer; - border-radius: inherit; - line-height: 0; - `} + css={[ + css` + cursor: pointer; + border-radius: inherit; + line-height: 0; + padding: 0.5rem; + `, + (type ? variantStyles.pinnedContainer : '') || + css` + ${type === 'file' + ? `border: 2px solid ${theme.colors.border};` + : ''} + `, + ]} > - + + + @{authorName} + + + ) : ( + '' + )} + + {isExpanded && ( + setShowGallery(true)}> + + + )} + {attachment.attachments && + attachment.attachments.map((nestedAttachment, index) => ( + + setShowGallery(true)} + css={[ + css` + cursor: pointer; + border-radius: inherit; + line-height: 0; + padding: 0.5rem; + `, + (nestedAttachment.attachments[0].type + ? variantStyles.pinnedContainer + : variantStyles.quoteContainer) || + css` + ${nestedAttachment.attachments[0].type === 'file' + ? `border: 2px solid ${theme.colors.border};` + : ''} + `, + ]} + > + {nestedAttachment.type === 'file' ? ( + <> + + + @{nestedAttachment.author_name} + + + ) : ( + '' + )} + + + + {showGallery && ( + + )} + + ))} {showGallery && ( { const { RCInstance } = useContext(RCContext); @@ -12,11 +13,6 @@ const TextAttachment = ({ attachment, type, variantStyles = {} }) => { return URL; }; - let attachmentText = attachment?.text; - if (attachmentText.includes(')')) { - attachmentText = attachmentText.split(')')[1] || ''; - } - const { theme } = useTheme(); return ( @@ -67,7 +63,93 @@ const TextAttachment = ({ attachment, type, variantStyles = {} }) => { white-space: pre-line; `} > - {attachmentText} + {attachment?.text ? ( + attachment.text[0] === '[' ? ( + attachment.text.match(/\n(.*)/)?.[1] || '' + ) : ( + + ) + ) : ( + '' + )} + {attachment?.attachments && + attachment.attachments.map((nestedAttachment, index) => ( + + + {nestedAttachment?.author_name && ( + <> + + @{nestedAttachment?.author_name} + + )} + + + {nestedAttachment?.text ? ( + nestedAttachment.text[0] === '[' ? ( + nestedAttachment.text.match(/\n(.*)/)?.[1] || '' + ) : ( + + ) + ) : ( + '' + )} + + + ))}
); diff --git a/packages/react/src/views/AttachmentHandler/VideoAttachment.js b/packages/react/src/views/AttachmentHandler/VideoAttachment.js index 0472d34c21..5c29bc507b 100644 --- a/packages/react/src/views/AttachmentHandler/VideoAttachment.js +++ b/packages/react/src/views/AttachmentHandler/VideoAttachment.js @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useState, useContext } from 'react'; import PropTypes from 'prop-types'; import { css } from '@emotion/react'; -import { Box } from '@embeddedchat/ui-elements'; +import { Box, Avatar, useTheme } from '@embeddedchat/ui-elements'; import AttachmentMetadata from './AttachmentMetadata'; +import RCContext from '../../context/RCInstance'; const userAgentMIMETypeFallback = (type) => { const userAgent = navigator.userAgent.toLocaleLowerCase(); @@ -14,35 +15,165 @@ const userAgentMIMETypeFallback = (type) => { return type; }; -const VideoAttachment = ({ attachment, host, variantStyles = {} }) => ( - - - - + {isExpanded && ( + + )} + {attachment.attachments && + attachment.attachments.map((nestedAttachment, index) => ( + + + {nestedAttachment.type === 'file' ? ( + <> + + + @{authorName} + + + ) : ( + '' + )} + + + + + ))} + - -); + ); +}; export default VideoAttachment; diff --git a/packages/react/src/views/AttachmentPreview/AttachmentPreview.js b/packages/react/src/views/AttachmentPreview/AttachmentPreview.js index 236c3ec1ba..2c007f1a71 100644 --- a/packages/react/src/views/AttachmentPreview/AttachmentPreview.js +++ b/packages/react/src/views/AttachmentPreview/AttachmentPreview.js @@ -1,11 +1,15 @@ -import React, { useContext, useState } from 'react'; +import React, { useContext, useState, useRef } from 'react'; import { css } from '@emotion/react'; import { Box, Icon, Button, Input, Modal } from '@embeddedchat/ui-elements'; import useAttachmentWindowStore from '../../store/attachmentwindow'; import CheckPreviewType from './CheckPreviewType'; import RCContext from '../../context/RCInstance'; -import { useMessageStore } from '../../store'; +import { useMessageStore, useMemberStore } from '../../store'; import getAttachmentPreviewStyles from './AttachmentPreview.styles'; +import { parseEmoji } from '../../lib/emoji'; +import MembersList from '../Mentions/MembersList'; +import TypingUsers from '../TypingUsers/TypingUsers'; +import useSearchMentionUser from '../../hooks/useSearchMentionUser'; const AttachmentPreview = () => { const { RCInstance, ECOptions } = useContext(RCContext); @@ -15,17 +19,36 @@ const AttachmentPreview = () => { const data = useAttachmentWindowStore((state) => state.data); const setData = useAttachmentWindowStore((state) => state.setData); const [isPending, setIsPending] = useState(false); + const messageRef = useRef(null); + const [showMembersList, setShowMembersList] = useState(false); + const [filteredMembers, setFilteredMembers] = useState([]); + const [mentionIndex, setMentionIndex] = useState(-1); + const [startReadMentionUser, setStartReadMentionUser] = useState(false); const [fileName, setFileName] = useState(data?.name); - const [fileDescription, setFileDescription] = useState(''); const threadId = useMessageStore((state) => state.threadMainMessage?._id); const handleFileName = (e) => { setFileName(e.target.value); }; + const { members } = useMemberStore((state) => ({ + members: state.members, + })); + + const searchMentionUser = useSearchMentionUser( + members, + startReadMentionUser, + setStartReadMentionUser, + setFilteredMembers, + setMentionIndex, + setShowMembersList + ); + const handleFileDescription = (e) => { - setFileDescription(e.target.value); + const description = e.target.value; + messageRef.current.value = parseEmoji(description); + searchMentionUser(description); }; const submit = async () => { @@ -33,12 +56,14 @@ const AttachmentPreview = () => { await RCInstance.sendAttachment( data, fileName, - fileDescription, + messageRef.current.value, ECOptions?.enableThreads ? threadId : undefined ); toggle(); setData(null); - setIsPending(false); + if (isPending) { + setIsPending(false); + } }; return ( @@ -88,6 +113,7 @@ const AttachmentPreview = () => { css={styles.input} placeholder="name" /> + @@ -100,14 +126,32 @@ const AttachmentPreview = () => { > File description - { - handleFileDescription(e); - }} - value={fileDescription} - css={styles.input} - placeholder="Description" - /> + + + {showMembersList && ( + + )} + + { + handleFileDescription(e); + }} + css={styles.input} + placeholder="Description" + ref={messageRef} + /> + diff --git a/packages/react/src/views/AttachmentPreview/AttachmentPreview.styles.js b/packages/react/src/views/AttachmentPreview/AttachmentPreview.styles.js index 44729be847..45f8cf455a 100644 --- a/packages/react/src/views/AttachmentPreview/AttachmentPreview.styles.js +++ b/packages/react/src/views/AttachmentPreview/AttachmentPreview.styles.js @@ -11,7 +11,7 @@ const getAttachmentPreviewStyles = () => { `, input: css` - width: 95.5%; + width: 100%; `, modalContent: css` @@ -19,6 +19,22 @@ const getAttachmentPreviewStyles = () => { overflow-x: hidden; max-height: 350px; `, + + fileDescription: css` + width: 100%; + position: relative; + z-index: 1300; + `, + + mentionListContainer: css` + position: absolute; + top: -100px; + width: 100%; + max-height: 100px; + overflow-y: auto; + background: white; + z-index: 1400; + `, }; return styles; diff --git a/packages/react/src/views/ChatBody/ChatBody.js b/packages/react/src/views/ChatBody/ChatBody.js index e5a6bd1a33..8d7c3ac978 100644 --- a/packages/react/src/views/ChatBody/ChatBody.js +++ b/packages/react/src/views/ChatBody/ChatBody.js @@ -1,11 +1,19 @@ /* eslint-disable no-shadow */ -import React, { useCallback, useContext, useEffect, useState } from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useState, + useRef, +} from 'react'; import PropTypes from 'prop-types'; import { css } from '@emotion/react'; import { Box, Throbber, useComponentOverrides, + Modal, + useTheme, } from '@embeddedchat/ui-elements'; import RCContext from '../../context/RCInstance'; import { @@ -33,22 +41,29 @@ const ChatBody = ({ scrollToBottom, }) => { const { classNames, styleOverrides } = useComponentOverrides('ChatBody'); - - const styles = getChatbodyStyles(); + const { theme, mode } = useTheme(); + const styles = getChatbodyStyles(theme, mode); const [scrollPosition, setScrollPosition] = useState(0); const [popupVisible, setPopupVisible] = useState(false); const [, setIsUserScrolledUp] = useState(false); const [otherUserMessage, setOtherUserMessage] = useState(false); - + const [isOverflowing, setIsOverflowing] = useState(false); const { RCInstance, ECOptions } = useContext(RCContext); + const showAnnouncement = ECOptions?.showAnnouncement; const messages = useMessageStore((state) => state.messages); + const offset = useMessageStore((state) => state.messagesOffset); + const setMessagesOffset = useMessageStore((state) => state.setMessagesOffset); const threadMessages = useMessageStore((state) => state.threadMessages); - + const [isModalOpen, setModalOpen] = useState(false); const setThreadMessages = useMessageStore((state) => state.setThreadMessages); const upsertMessage = useMessageStore((state) => state.upsertMessage); + const [loadingOlderMessages, setLoadingOlderMessages] = useState(false); + const [hasMoreMessages, setHasMoreMessages] = useState(true); const removeMessage = useMessageStore((state) => state.removeMessage); const isChannelPrivate = useChannelStore((state) => state.isChannelPrivate); + const channelInfo = useChannelStore((state) => state.channelInfo); const isLoginIn = useLoginStore((state) => state.isLoginIn); + const setMessages = useMessageStore((state) => state.setMessages); const [isThreadOpen, threadMainMessage] = useMessageStore((state) => [ state.isThreadOpen, @@ -69,7 +84,7 @@ const ChatBody = ({ const username = useUserStore((state) => state.username); - const getMessagesAndRoles = useFetchChatData(showRoles); + const { getMessagesAndRoles } = useFetchChatData(showRoles); const getThreadMessages = useCallback(async () => { if (isUserAuthenticated && threadMainMessage?._id) { @@ -81,7 +96,7 @@ const ChatBody = ({ threadMainMessage._id, isChannelPrivate ); - setThreadMessages(messages); + setThreadMessages(messages.reverse()); } catch (e) { console.error(e); } @@ -143,6 +158,7 @@ const ChatBody = ({ RCInstance.auth.onAuthChange((user) => { if (user) { getMessagesAndRoles(); + setHasMoreMessages(true); } else { getMessagesAndRoles(anonymousMode); } @@ -156,13 +172,57 @@ const ChatBody = ({ setPopupVisible(false); }; - const handleScroll = useCallback(() => { + const handleScroll = useCallback(async () => { if (messageListRef && messageListRef.current) { setScrollPosition(messageListRef.current.scrollTop); setIsUserScrolledUp( messageListRef.current.scrollTop + messageListRef.current.clientHeight < messageListRef.current.scrollHeight ); + + if ( + messageListRef.current.scrollTop === 0 && + !loadingOlderMessages && + hasMoreMessages + ) { + setLoadingOlderMessages(true); + + try { + const olderMessages = await RCInstance.getOlderMessages( + anonymousMode, + ECOptions?.enableThreads + ? { + query: { + tmid: { + $exists: false, + }, + }, + offset, + } + : undefined, + anonymousMode ? false : isChannelPrivate + ); + const messageList = messageListRef.current; + if (olderMessages?.messages?.length) { + const previousScrollHeight = messageList.scrollHeight; + + setMessages(olderMessages.messages, true); + setMessagesOffset(offset + olderMessages.messages.length); + + requestAnimationFrame(() => { + const newScrollHeight = messageList.scrollHeight; + messageList.scrollTop = newScrollHeight - previousScrollHeight; + }); + } else { + setHasMoreMessages(false); + } + } catch (error) { + console.error('Error fetching older messages:', error); + setHasMoreMessages(false); + } finally { + setLoadingOlderMessages(false); + } + } } const isAtBottom = messageListRef?.current?.scrollTop === 0; @@ -173,6 +233,15 @@ const ChatBody = ({ } }, [ messageListRef, + offset, + setMessagesOffset, + setMessages, + anonymousMode, + hasMoreMessages, + RCInstance, + isChannelPrivate, + ECOptions?.enableThreads, + loadingOlderMessages, setScrollPosition, setIsUserScrolledUp, setPopupVisible, @@ -182,7 +251,30 @@ const ChatBody = ({ const showNewMessagesPopup = () => { setPopupVisible(true); }; + const announcementRef = useRef(null); + const toggleModal = () => { + setModalOpen(!isModalOpen); + }; + + const checkOverflow = () => { + if (announcementRef.current) { + setIsOverflowing( + announcementRef.current.scrollWidth > + announcementRef.current.clientWidth + ); + } + }; + + useEffect(() => { + if (messageListRef.current) { + messageListRef.current.scrollTop = messageListRef.current.scrollHeight; + } + }, [messages]); + + useEffect(() => { + checkOverflow(); + }, [channelInfo.announcement, showAnnouncement]); useEffect(() => { const currentRef = messageListRef.current; currentRef.addEventListener('scroll', handleScroll); @@ -204,6 +296,44 @@ const ChatBody = ({ return ( <> + {channelInfo.announcement && showAnnouncement && ( + + + {channelInfo.announcement} + + + )} + {isModalOpen && ( + + + Announcement + + + + {channelInfo.announcement} + + + )} ) : ( - + )} diff --git a/packages/react/src/views/ChatBody/ChatBody.styles.js b/packages/react/src/views/ChatBody/ChatBody.styles.js index 36fc549b5f..20deecd1f5 100644 --- a/packages/react/src/views/ChatBody/ChatBody.styles.js +++ b/packages/react/src/views/ChatBody/ChatBody.styles.js @@ -1,6 +1,7 @@ import { css } from '@emotion/react'; +import { darken, lighten } from '@embeddedchat/ui-elements'; -export const getChatbodyStyles = () => { +export const getChatbodyStyles = (theme, mode) => { const styles = { chatbodyContainer: css` flex: 1; @@ -8,12 +9,29 @@ export const getChatbodyStyles = () => { overflow: auto; overflow-x: hidden; display: flex; - flex-direction: column-reverse; - max-height: 600px; + flex-direction: column; + max-height: 100%; position: relative; padding-top: 70px; margin-top: 0.25rem; `, + announcementStyles: css` + display: flex; + justify-content: center; + padding: 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background-color: ${mode === 'light' + ? lighten(theme.colors.info, 0.78) + : darken(theme.colors.primary, 0.7)}; + `, + announcementTextBox: css` + max-width: 80%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `, }; return styles; diff --git a/packages/react/src/views/ChatHeader/ChatHeader.js b/packages/react/src/views/ChatHeader/ChatHeader.js index d9b6b8461b..0971ce7c77 100644 --- a/packages/react/src/views/ChatHeader/ChatHeader.js +++ b/packages/react/src/views/ChatHeader/ChatHeader.js @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo } from 'react'; +import { css } from '@emotion/react'; import PropTypes from 'prop-types'; import { Box, @@ -8,6 +9,7 @@ import { useToastBarDispatch, useComponentOverrides, useTheme, + Avatar, } from '@embeddedchat/ui-elements'; import { useRCContext } from '../../context/RCInstance'; import { @@ -21,6 +23,7 @@ import { usePinnedMessageStore, useStarredMessageStore, useFileStore, + useSidebarStore, } from '../../store'; import { DynamicHeader } from '../DynamicHeader'; import useFetchChatData from '../../hooks/useFetchChatData'; @@ -70,6 +73,8 @@ const ChatHeader = ({ const setIsChannelPrivate = useChannelStore( (state) => state.setIsChannelPrivate ); + const isRoomTeam = useChannelStore((state) => state.isRoomTeam); + const setIsRoomTeam = useChannelStore((state) => state.setIsRoomTeam); const setIsChannelReadOnly = useChannelStore( (state) => state.setIsChannelReadOnly ); @@ -84,17 +89,23 @@ const ChatHeader = ({ const setIsUserAuthenticated = useUserStore( (state) => state.setIsUserAuthenticated ); - + const setShowSidebar = useSidebarStore((state) => state.setShowSidebar); const dispatchToastMessage = useToastBarDispatch(); - const getMessagesAndRoles = useFetchChatData(showRoles); + const { getMessagesAndRoles } = useFetchChatData(showRoles); const setMessageLimit = useSettingsStore((state) => state.setMessageLimit); - + const setMessages = useMessageStore((state) => state.setMessages); const avatarUrl = useUserStore((state) => state.avatarUrl); + const setUserAvatarUrl = useUserStore((state) => state.setUserAvatarUrl); const headerTitle = useMessageStore((state) => state.headerTitle); const filtered = useMessageStore((state) => state.filtered); const setFilter = useMessageStore((state) => state.setFilter); - const threadTitle = useMessageStore((state) => state.threadMainMessage?.msg); + const isThreadOpen = useMessageStore((state) => state.isThreadOpen); + const threadMainMessage = useMessageStore((state) => state.threadMainMessage); + const threadTitle = + threadMainMessage?.msg || + (threadMainMessage?.file ? threadMainMessage.file.name : ''); + const closeThread = useMessageStore((state) => state.closeThread); const setShowMembers = useMemberStore((state) => state.setShowMembers); @@ -108,7 +119,10 @@ const ChatHeader = ({ ); const setShowAllFiles = useFileStore((state) => state.setShowAllFiles); const setShowMentions = useMentionsStore((state) => state.setShowMentions); - + const getChannelAvatarURL = (channelname) => { + const host = RCInstance.getHost(); + return `${host}/avatar/${channelname}`; + }; const handleGoBack = async () => { if (isUserAuthenticated) { getMessagesAndRoles(); @@ -123,6 +137,11 @@ const ChatHeader = ({ const handleLogout = useCallback(async () => { try { await RCInstance.logout(); + setMessages([]); + setChannelInfo({}); + setShowSidebar(false); + setUserAvatarUrl(null); + useMessageStore.setState({ isMessageLoaded: false }); } catch (e) { console.error(e); } finally { @@ -160,6 +179,7 @@ const ChatHeader = ({ if (res.success) { setChannelInfo(res.room); if (res.room.t === 'p') setIsChannelPrivate(true); + if (res.room?.teamMain) setIsRoomTeam(true); if (res.room.ro) { setIsChannelReadOnly(true); setMessageAllowed(); @@ -336,7 +356,6 @@ const ChatHeader = ({ > - {isUserAuthenticated ? ( <> @@ -344,17 +363,50 @@ const ChatHeader = ({ level={3} className="ec-chat-header--channelName" css={styles.clearSpacing} + style={{ + display: 'flex', + alignItems: 'center', + gap: '0.2rem', + }} > - {channelInfo.name || channelName || 'channelName'} + + + setExclusiveState(setShowChannelinfo)} + > + +
+ {channelInfo.name || channelName || 'channelName'} +
+
+ {fullScreen && ( + + {channelInfo.topic || ''} + + )} +
- {fullScreen && ( -

- {channelInfo.description || ''} -

- )} ) : ( { margin: 0; padding: 0; `, - chatHeaderChild: css` ${rowCentreAlign} padding: 0 0.75rem; @@ -43,6 +42,16 @@ const getChatHeaderStyles = ({ theme, mode }) => { position:relative; gap: 0.5rem; `, + channelName: css` + display: flex; + align-items: center; + gap: 0.1rem; + cursor: pointer; + `, + channelTopic: css` + opacity: 0.8rem; + font-size: 1rem; + `, }; return styles; }; diff --git a/packages/react/src/views/ChatInput/AudioMessageRecorder.js b/packages/react/src/views/ChatInput/AudioMessageRecorder.js index caef86e700..9ff497a30f 100644 --- a/packages/react/src/views/ChatInput/AudioMessageRecorder.js +++ b/packages/react/src/views/ChatInput/AudioMessageRecorder.js @@ -1,17 +1,17 @@ -import React, { - useState, - useEffect, - useCallback, - useContext, - useRef, -} from 'react'; -import { Box, Icon, ActionButton, useTheme } from '@embeddedchat/ui-elements'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { + Box, + Icon, + ActionButton, + Tooltip, + useTheme, +} from '@embeddedchat/ui-elements'; import { useMediaRecorder } from '../../hooks/useMediaRecorder'; -import RCContext from '../../context/RCInstance'; import useMessageStore from '../../store/messageStore'; import { getCommonRecorderStyles } from './ChatInput.styles'; +import useAttachmentWindowStore from '../../store/attachmentwindow'; -const AudioMessageRecorder = () => { +const AudioMessageRecorder = ({ disabled }) => { const videoRef = useRef(null); const { theme } = useTheme(); const styles = getCommonRecorderStyles(theme); @@ -19,13 +19,17 @@ const AudioMessageRecorder = () => { (state) => state.toogleRecordingMessage ); - const { RCInstance, ECOptions } = useContext(RCContext); + const { toggle, setData } = useAttachmentWindowStore((state) => ({ + toggle: state.toggle, + setData: state.setData, + })); + const [state, setRecordState] = useState('idle'); const [time, setTime] = useState('00:00'); const [recordingInterval, setRecordingInterval] = useState(null); const [file, setFile] = useState(null); const [isRecorded, setIsRecorded] = useState(false); - const threadId = useMessageStore((_state) => _state.threadMainMessage?._id); + const onStop = (audioChunks) => { const audioBlob = new Blob(audioChunks, { type: 'audio/mpeg' }); const fileName = 'Audio record.mp3'; @@ -49,6 +53,7 @@ const AudioMessageRecorder = () => { }; const handleRecordButtonClick = () => { + if (disabled) return; setRecordState('recording'); try { start(); @@ -120,16 +125,9 @@ const AudioMessageRecorder = () => { }, [handleMount]); useEffect(() => { - const sendRecording = async () => { - await RCInstance.sendAttachment( - file, - undefined, - undefined, - ECOptions.enableThreads ? threadId : undefined - ); - }; if (isRecorded && file) { - sendRecording(); + toggle(); + setData(file); setIsRecorded(false); } if (file) { @@ -139,9 +137,16 @@ const AudioMessageRecorder = () => { if (state === 'idle') { return ( - - - + + + + + ); } @@ -149,18 +154,22 @@ const AudioMessageRecorder = () => { {state === 'recording' && ( <> - - - + + + + + {time} - - - + + + + + )} diff --git a/packages/react/src/views/ChatInput/ChatInput.js b/packages/react/src/views/ChatInput/ChatInput.js index 99c1c8b4f7..33f40d7af3 100644 --- a/packages/react/src/views/ChatInput/ChatInput.js +++ b/packages/react/src/views/ChatInput/ChatInput.js @@ -25,7 +25,6 @@ import useAttachmentWindowStore from '../../store/attachmentwindow'; import MembersList from '../Mentions/MembersList'; import { TypingUsers } from '../TypingUsers'; import createPendingMessage from '../../lib/createPendingMessage'; -import { parseEmoji } from '../../lib/emoji'; import { CommandsList } from '../CommandList'; import useSettingsStore from '../../store/settingsStore'; import ChannelState from '../ChannelState/ChannelState'; @@ -34,6 +33,7 @@ import { getChatInputStyles } from './ChatInput.styles'; import useShowCommands from '../../hooks/useShowCommands'; import useSearchMentionUser from '../../hooks/useSearchMentionUser'; import formatSelection from '../../lib/formatSelection'; +import { parseEmoji } from '../../lib/emoji'; const ChatInput = ({ scrollToBottom }) => { const { styleOverrides, classNames } = useComponentOverrides('ChatInput'); @@ -74,10 +74,13 @@ const ChatInput = ({ scrollToBottom }) => { name: state.name, })); - const { isChannelPrivate, isChannelReadOnly } = useChannelStore((state) => ({ - isChannelPrivate: state.isChannelPrivate, - isChannelReadOnly: state.isChannelReadOnly, - })); + const { isChannelPrivate, isChannelReadOnly, channelInfo } = useChannelStore( + (state) => ({ + isChannelPrivate: state.isChannelPrivate, + isChannelReadOnly: state.isChannelReadOnly, + channelInfo: state.channelInfo, + }) + ); const { members, setMembersHandler } = useMemberStore((state) => ({ members: state.members, @@ -90,20 +93,20 @@ const ChatInput = ({ scrollToBottom }) => { editMessage, setEditMessage, quoteMessage, - setQuoteMessage, isRecordingMessage, upsertMessage, replaceMessage, + clearQuoteMessages, threadId, } = useMessageStore((state) => ({ editMessage: state.editMessage, setEditMessage: state.setEditMessage, quoteMessage: state.quoteMessage, - setQuoteMessage: state.setQuoteMessage, isRecordingMessage: state.isRecordingMessage, upsertMessage: state.upsertMessage, replaceMessage: state.replaceMessage, threadId: state.threadMainMessage?._id, + clearQuoteMessages: state.clearQuoteMessages, })); const setIsLoginModalOpen = useLoginStore( @@ -168,7 +171,15 @@ const ChatInput = ({ scrollToBottom }) => { }; const handleNewLine = (e, addLine = true) => { - if (addLine) messageRef.current.value += '\n'; + if (addLine) { + const { selectionStart, selectionEnd, value } = messageRef.current; + messageRef.current.value = `${value.substring( + 0, + selectionStart + )}\n${value.substring(selectionEnd)}`; + messageRef.current.selectionStart = messageRef.current.selectionEnd; + messageRef.current.selectionEnd = selectionStart + 1; + } e.target.style.height = 'auto'; if (e.target.scrollHeight <= 150) { @@ -255,14 +266,31 @@ const ChatInput = ({ scrollToBottom }) => { messageRef.current.value = ''; setDisableButton(true); - const { msg, attachments, _id } = quoteMessage; let pendingMessage = ''; - - if (msg || attachments) { - setQuoteMessage({}); - const msgLink = await getMessageLink(_id); + let quotedMessages = ''; + + if (quoteMessage.length > 0) { + // for (const quote of quoteMessage) { + // const { msg, attachments, _id } = quote; + // if (msg || attachments) { + // const msgLink = await getMessageLink(_id); + // quotedMessages += `[ ](${msgLink})`; + // } + // } + + const quoteArray = await Promise.all( + quoteMessage.map(async (quote) => { + const { msg, attachments, _id } = quote; + if (msg || attachments) { + const msgLink = await getMessageLink(_id); + quotedMessages += `[ ](${msgLink})`; + } + return quotedMessages; + }) + ); + quotedMessages = quoteArray.join(''); pendingMessage = createPendingMessage( - `[ ](${msgLink})\n ${message}`, + `${quotedMessages}\n${message}`, userInfo ); } else { @@ -283,10 +311,9 @@ const ChatInput = ({ scrollToBottom }) => { ECOptions.enableThreads ? threadId : undefined ); - if (!res.success) { - handleSendError('Error sending message, login again'); - } else { - replaceMessage(pendingMessage._id, res.message); + if (res.success) { + clearQuoteMessages(); + replaceMessage(pendingMessage, res.message); } }; @@ -362,14 +389,16 @@ const ChatInput = ({ scrollToBottom }) => { setData(event.target.files[0]); }; - const onTextChange = (e) => { + const onTextChange = (e, val) => { sendTypingStart(); - const message = e.target.value; + const message = val || e.target.value; messageRef.current.value = parseEmoji(message); setDisableButton(!messageRef.current.value.length); - handleNewLine(e, false); - searchMentionUser(message); - showCommands(e); + if (e !== null) { + handleNewLine(e, false); + searchMentionUser(message); + showCommands(e); + } }; const handleFocus = () => { @@ -423,10 +452,14 @@ const ChatInput = ({ scrollToBottom }) => { return ( - - {(quoteMessage.msg || quoteMessage.attachments) && ( - - )} + +
+ {quoteMessage && + quoteMessage.length > 0 && + quoteMessage.map((message, index) => ( + + ))} +
{editMessage.msg || editMessage.attachments || isChannelReadOnly ? ( { } /> ) : null} - - {showMembersList && ( - - )} + + {showMembersList && ( + + )} + {showCommandList && ( { disabled={!isUserAuthenticated || !canSendMsg || isRecordingMessage} placeholder={ isUserAuthenticated && canSendMsg - ? 'Message' + ? `Message #${channelInfo.name}` : isUserAuthenticated ? 'This room is read only' : 'Sign in to chat' @@ -528,6 +566,7 @@ const ChatInput = ({ scrollToBottom }) => { )}
diff --git a/packages/react/src/views/ChatInput/ChatInput.styles.js b/packages/react/src/views/ChatInput/ChatInput.styles.js index 73e91eb971..21e7a24736 100644 --- a/packages/react/src/views/ChatInput/ChatInput.styles.js +++ b/packages/react/src/views/ChatInput/ChatInput.styles.js @@ -22,6 +22,9 @@ export const getChatInputStyles = (theme) => { justify-content: center; flex-direction: row; padding: 0.5rem; + @media (max-width: 383px) { + min-height: 100px; + } `, iconCursor: css` @@ -51,6 +54,13 @@ export const getChatInputStyles = (theme) => { &::placeholder { padding-left: 5px; } + @media (max-width: 383px) { + font-size: 18px; + } + `, + quoteContainer: css` + max-height: 300px; + overflow: scroll; `, }; @@ -68,9 +78,12 @@ export const getChatInputFormattingToolbarStyles = ({ theme, mode }) => { : lighten(theme.colors.background, 1)}; display: flex; position: relative; - flex-direction: row; - gap: 0.375rem; + gap: 0.1rem; border-radius: 0 0 ${theme.radius} ${theme.radius}; + @media (max-width: 383px) { + display: grid; + grid-template-columns: repeat(5, 0.2fr); + } `, }; return styles; @@ -99,6 +112,41 @@ export const getCommonRecorderStyles = (theme) => { display: flex; margin: auto; `, + modal: { + '@media(max-width: 768px)': { + height: '100%', + width: '100%', + maxHeight: '100%', + maxWidth: '100%', + }, + }, + }; + + return styles; +}; + +export const getInsertLinkModalStyles = (theme) => { + const styles = { + inputWithFormattingBox: css` + border: 1px solid ${theme.colors.border}; + border-radius: ${theme.radius}; + margin: 0.5rem 1rem; + &.focused { + border: ${`1.5px solid ${theme.colors.ring}`}; + } + `, + modalHeader: css` + padding: 0 0.5rem; + `, + modalContent: css` + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 1rem 0; + `, + modalFooter: css` + padding: 0.75rem 1rem; + `, }; return styles; diff --git a/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js b/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js index 634b2255af..523b9ef976 100644 --- a/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js +++ b/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js @@ -15,12 +15,14 @@ import AudioMessageRecorder from './AudioMessageRecorder'; import VideoMessageRecorder from './VideoMessageRecoder'; import { getChatInputFormattingToolbarStyles } from './ChatInput.styles'; import formatSelection from '../../lib/formatSelection'; +import InsertLinkToolBox from './InsertLinkToolBox'; const ChatInputFormattingToolbar = ({ messageRef, inputRef, + triggerButton, optionConfig = { - surfaceItems: ['emoji', 'formatter', 'audio', 'video', 'file'], + surfaceItems: ['emoji', 'formatter', 'link', 'audio', 'video', 'file'], formatters: ['bold', 'italic', 'strike', 'code', 'multiline'], }, }) => { @@ -39,6 +41,7 @@ const ChatInputFormattingToolbar = ({ ); const [isEmojiOpen, setEmojiOpen] = useState(false); + const [isInsertLinkOpen, setInsertLinkOpen] = useState(false); const handleClickToOpenFiles = () => { inputRef.current.click(); @@ -46,7 +49,27 @@ const ChatInputFormattingToolbar = ({ const handleEmojiClick = (emojiEvent) => { const [emoji] = emojiEvent.names; - messageRef.current.value += ` :${emoji.replace(/[\s-]+/g, '_')}: `; + const message = `${messageRef.current.value} :${emoji.replace( + /[\s-]+/g, + '_' + )}: `; + triggerButton?.(null, message); + }; + + const handleAddLink = (linkText, linkUrl) => { + if (!linkText || !linkUrl) { + setInsertLinkOpen(false); + return; + } + + const start = messageRef.current.selectionStart; + const end = messageRef.current.selectionEnd; + const msg = messageRef.current.value; + const hyperlink = `[${linkText}](${linkUrl})`; + const message = msg.slice(0, start) + hyperlink + msg.slice(end); + + triggerButton?.(null, message); + setInsertLinkOpen(false); }; const chatToolMap = { @@ -57,6 +80,7 @@ const ChatInputFormattingToolbar = ({ ghost disabled={isRecordingMessage} onClick={() => { + if (isRecordingMessage) return; setEmojiOpen(true); }} > @@ -64,37 +88,51 @@ const ChatInputFormattingToolbar = ({ ), - audio: ( - - - - ), - video: ( - - - - ), + audio: , + video: , file: ( { + if (isRecordingMessage) return; + handleClickToOpenFiles(); + }} > ), + link: ( + + { + setInsertLinkOpen(true); + }} + > + + + + ), formatter: formatters .map((name) => formatter.find((item) => item.name === name)) .map((item) => ( - + { + if (isRecordingMessage) return; formatSelection(messageRef, item.pattern); }} > @@ -131,6 +169,14 @@ const ChatInputFormattingToolbar = ({ `} /> )} + + {isInsertLinkOpen && ( + setInsertLinkOpen(false)} + /> + )}
); }; diff --git a/packages/react/src/views/ChatInput/InsertLinkToolBox.js b/packages/react/src/views/ChatInput/InsertLinkToolBox.js new file mode 100644 index 0000000000..49e8e0d4ad --- /dev/null +++ b/packages/react/src/views/ChatInput/InsertLinkToolBox.js @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { Modal, Input, Button, useTheme } from '@embeddedchat/ui-elements'; +import { getInsertLinkModalStyles } from './ChatInput.styles'; + +const InsertLinkToolBox = ({ + handleAddLink, + selectedText, + onClose = () => {}, +}) => { + const { theme } = useTheme(); + const styles = getInsertLinkModalStyles(theme); + const [linkText, setLinkText] = useState(selectedText || 'Text'); + const [linkUrl, setLinkUrl] = useState(null); + + const handleLinkTextOnChange = (e) => { + setLinkText(e.target.value); + }; + const handleLinkUrlOnChange = (e) => { + setLinkUrl(e.target.value); + }; + + return ( + + + Add link + + + + + + + + + + + + ); +}; + +export default InsertLinkToolBox; diff --git a/packages/react/src/views/ChatInput/VideoMessageRecoder.js b/packages/react/src/views/ChatInput/VideoMessageRecoder.js index 4d5b00810d..eeb8c394e0 100644 --- a/packages/react/src/views/ChatInput/VideoMessageRecoder.js +++ b/packages/react/src/views/ChatInput/VideoMessageRecoder.js @@ -1,24 +1,19 @@ -import React, { - useState, - useEffect, - useCallback, - useContext, - useRef, -} from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { css } from '@emotion/react'; import { Box, Icon, ActionButton, + Tooltip, Modal, useTheme, } from '@embeddedchat/ui-elements'; import { useMediaRecorder } from '../../hooks/useMediaRecorder'; -import RCContext from '../../context/RCInstance'; import useMessageStore from '../../store/messageStore'; import { getCommonRecorderStyles } from './ChatInput.styles'; +import useAttachmentWindowStore from '../../store/attachmentwindow'; -const VideoMessageRecorder = () => { +const VideoMessageRecorder = ({ disabled }) => { const videoRef = useRef(null); const { theme } = useTheme(); const styles = getCommonRecorderStyles(theme); @@ -27,13 +22,16 @@ const VideoMessageRecorder = () => { (state) => state.toogleRecordingMessage ); - const { RCInstance, ECOptions } = useContext(RCContext); + const { toggle, setData } = useAttachmentWindowStore((state) => ({ + toggle: state.toggle, + setData: state.setData, + })); + const [state, setRecordState] = useState('idle'); const [time, setTime] = useState('00:00'); const [recordingInterval, setRecordingInterval] = useState(null); const [file, setFile] = useState(null); const [isRecorded, setIsRecorded] = useState(false); - const threadId = useMessageStore((_state) => _state.threadMainMessage?._id); const onStop = (videoChunks) => { const videoBlob = new Blob(videoChunks, { type: 'video/mp4' }); @@ -58,6 +56,7 @@ const VideoMessageRecorder = () => { }; const handleRecordButtonClick = () => { + if (disabled) return; setRecordState('recording'); try { start(videoRef.current); @@ -135,16 +134,9 @@ const VideoMessageRecorder = () => { }, [handleMount]); useEffect(() => { - const sendRecording = async () => { - await RCInstance.sendAttachment( - file, - undefined, - undefined, - ECOptions.enableThreads ? threadId : undefined - ); - }; if (isRecorded && file) { - sendRecording(); + toggle(); + setData(file); setIsRecorded(false); } if (file) { @@ -155,9 +147,16 @@ const VideoMessageRecorder = () => { return ( <> {state === 'idle' && ( - - - + + + + + )} {state === 'recording' && ( @@ -168,10 +167,7 @@ const VideoMessageRecorder = () => { diff --git a/packages/react/src/views/ChatLayout/ChatLayout.js b/packages/react/src/views/ChatLayout/ChatLayout.js index 6243dc0cc8..60c935a360 100644 --- a/packages/react/src/views/ChatLayout/ChatLayout.js +++ b/packages/react/src/views/ChatLayout/ChatLayout.js @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useEffect, useRef, useCallback, useState } from 'react'; import { Box, useComponentOverrides } from '@embeddedchat/ui-elements'; import styles from './ChatLayout.styles'; import { @@ -36,9 +36,15 @@ import useUiKitStore from '../../store/uiKitStore'; const ChatLayout = () => { const messageListRef = useRef(null); const { classNames, styleOverrides } = useComponentOverrides('ChatBody'); - const { ECOptions } = useRCContext(); + const { RCInstance, ECOptions } = useRCContext(); const anonymousMode = ECOptions?.anonymousMode; - const showRoles = ECOptions?.anonymousMode; + const showRoles = ECOptions?.showRoles; + const setStarredMessages = useStarredMessageStore( + (state) => state.setStarredMessages + ); + const starredMessages = useStarredMessageStore( + (state) => state.starredMessages + ); const showSidebar = useSidebarStore((state) => state.showSidebar); const showMentions = useMentionsStore((state) => state.showMentions); const showAllFiles = useFileStore((state) => state.showAllFiles); @@ -57,6 +63,9 @@ const ChatLayout = () => { const attachmentWindowOpen = useAttachmentWindowStore( (state) => state.attachmentWindowOpen ); + const isUserAuthenticated = useUserStore( + (state) => state.isUserAuthenticated + ); const { data, handleDrag, handleDragDrop } = useDropBox(); const { uiKitContextualBarOpen, uiKitContextualBarData } = useUiKitStore( (state) => ({ @@ -72,7 +81,22 @@ const ChatLayout = () => { }); } }; - + const getStarredMessages = useCallback(async () => { + if (isUserAuthenticated) { + try { + if (!isUserAuthenticated && !anonymousMode) { + return; + } + const { messages } = await RCInstance.getStarredMessages(); + setStarredMessages(messages); + } catch (e) { + console.error(e); + } + } + }, [isUserAuthenticated, anonymousMode, RCInstance]); + useEffect(() => { + getStarredMessages(); + }, [showSidebar]); return ( { toastBarPosition = 'bottom right', showRoles = false, showAvatar = true, + showAnnouncement = true, showUsername = false, showName = true, enableThreads = false, @@ -83,7 +84,13 @@ const EmbeddedChat = (props) => { })); const setIsLoginIn = useLoginStore((state) => state.setIsLoginIn); + const setUserPinPermissions = useUserStore( + (state) => state.setUserPinPermissions + ); + const setEditMessagePermissions = useMessageStore( + (state) => state.setEditMessagePermissions + ); if (isClosable && !setClosableState) { throw Error( 'Please provide a setClosableState to props when isClosable = true' @@ -125,6 +132,9 @@ const EmbeddedChat = (props) => { setIsLoginIn(true); try { await RCInstance.autoLogin(auth); + const permissions = await RCInstance.permissionInfo(); + setUserPinPermissions(permissions.update[150]); + setEditMessagePermissions(permissions.update[28]); } catch (error) { console.error(error); } finally { @@ -195,6 +205,7 @@ const EmbeddedChat = (props) => { showName, showRoles, showAvatar, + showAnnouncement, showUsername, hideHeader, anonymousMode, @@ -210,6 +221,7 @@ const EmbeddedChat = (props) => { showName, showRoles, showAvatar, + showAnnouncement, showUsername, hideHeader, anonymousMode, @@ -272,6 +284,7 @@ EmbeddedChat.propTypes = { toastBarPosition: PropTypes.string, showRoles: PropTypes.bool, showAvatar: PropTypes.bool, + showAnnouncement: PropTypes.bool, enableThreads: PropTypes.bool, theme: PropTypes.object, auth: PropTypes.oneOfType([ diff --git a/packages/react/src/views/EmojiPicker/EmojiPicker.js b/packages/react/src/views/EmojiPicker/EmojiPicker.js index e8e2926675..bc56649fdf 100644 --- a/packages/react/src/views/EmojiPicker/EmojiPicker.js +++ b/packages/react/src/views/EmojiPicker/EmojiPicker.js @@ -32,7 +32,7 @@ const CustomEmojiPicker = ({ height="auto" width="auto" > - + { const { RCInstance } = useRCContext(); const messages = useMessageStore((state) => state.messages); + const theme = useTheme(); + const { mode } = theme; + const messageStyles = styles.message; + + const hoverStyle = { + '&:hover': { + backgroundColor: + mode === 'light' + ? darken(theme.theme.colors.background, 0.03) + : lighten(theme.theme.colors.background, 1), + }, + }; + const [fileToDelete, setFileToDelete] = useState({}); const downloadFile = useCallback((url, title) => { @@ -69,7 +85,7 @@ const FileMessage = ({ fileMessage }) => { diff --git a/packages/react/src/views/GlobalStyles.js b/packages/react/src/views/GlobalStyles.js index b26977e635..ca4d72fa97 100644 --- a/packages/react/src/views/GlobalStyles.js +++ b/packages/react/src/views/GlobalStyles.js @@ -8,7 +8,6 @@ const getGlobalStyles = (theme) => css` margin: 0; padding: 0; } - .ec-embedded-chat body { font-family: ${theme.typography.default.fontFamily}; font-size: ${theme.typography.default.fontSize}px; @@ -31,11 +30,23 @@ const getGlobalStyles = (theme) => css` .ec-embedded-chat ::-webkit-scrollbar-thumb:hover { background: ${theme.colors.primary}; + cursor: pointer; } .ec-embedded-chat ::-webkit-scrollbar-button { display: none; } + @media (max-width: 780px) { + .ec-sidebar { + position: absolute; + width: 100% !important; + height: calc(100% - 56.39px) !important; + min-width: 250px !important; + left: 0; + bottom: 0; + background: ${theme.colors.background}!important; + } + } `; const GlobalStyles = () => { diff --git a/packages/react/src/views/Markdown/Markdown.js b/packages/react/src/views/Markdown/Markdown.js index d9ee6b2ad2..1eae8326f8 100644 --- a/packages/react/src/views/Markdown/Markdown.js +++ b/packages/react/src/views/Markdown/Markdown.js @@ -2,11 +2,11 @@ import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { css } from '@emotion/react'; import { Box } from '@embeddedchat/ui-elements'; -import { Markup, MarkupInteractionContext } from '@embeddedchat/markups'; +import { Markup, MarkupInteractionContext } from '@embeddedchat/markups/src'; import EmojiReaction from '../EmojiReaction/EmojiReaction'; import { useMemberStore, useUserStore } from '../../store'; -const Markdown = ({ body, isReaction = false }) => { +const Markdown = ({ body, md, isReaction = false }) => { const members = useMemberStore((state) => state.members); const username = useUserStore((state) => state.username); const value = useMemo(() => ({ members, username }), [members, username]); @@ -23,12 +23,12 @@ const Markdown = ({ body, isReaction = false }) => { ); } - if (!body || !body.md) return <>; + if (!body || !md) return <>; return ( - + ); diff --git a/packages/react/src/views/Mentions/MembersList.styles.js b/packages/react/src/views/Mentions/MembersList.styles.js index e288cae04d..03bc8f4018 100644 --- a/packages/react/src/views/Mentions/MembersList.styles.js +++ b/packages/react/src/views/Mentions/MembersList.styles.js @@ -3,7 +3,7 @@ import { css } from '@emotion/react'; const getMemberListStyles = (theme) => { const styles = { main: css` - margin: 0.2rem 2rem; + margin: 0.2rem 0rem; display: block; overflow: auto; max-height: 10rem; diff --git a/packages/react/src/views/Message/BubbleVariant/Bubble.styles.js b/packages/react/src/views/Message/BubbleVariant/Bubble.styles.js index 42f978f9eb..5f87c0b5e4 100644 --- a/packages/react/src/views/Message/BubbleVariant/Bubble.styles.js +++ b/packages/react/src/views/Message/BubbleVariant/Bubble.styles.js @@ -94,7 +94,7 @@ export const getBubbleStyles = (theme) => { overflow: hidden; `, pinnedContainer: css` - max-width: 80%; + max-width: 100%; `, quoteContainer: css` @@ -112,7 +112,7 @@ export const getBubbleStyles = (theme) => { `, attachmentMetaContainer: css` - padding: 2.5% 2.5% 0; + padding: 2.5% 0 0; `, emojiPickerStyles: css` @@ -172,7 +172,7 @@ export const getBubbleStylesMe = (theme) => { pinnedContainerMe: css` border-inline-start: none; - border-inline-end: 3px solid ${theme.colors.border}; + border-inline-end: none; `, textUserInfoMe: css` diff --git a/packages/react/src/views/Message/Message.js b/packages/react/src/views/Message/Message.js index 8ce3d8bf5c..dd8cf8d09d 100644 --- a/packages/react/src/views/Message/Message.js +++ b/packages/react/src/views/Message/Message.js @@ -7,11 +7,13 @@ import { useComponentOverrides, appendClassNames, useTheme, + lighten, + darken, } from '@embeddedchat/ui-elements'; import { Attachments } from '../AttachmentHandler'; import { Markdown } from '../Markdown'; import MessageHeader from './MessageHeader'; -import { useMessageStore, useUserStore } from '../../store'; +import { useMessageStore, useUserStore, useSidebarStore } from '../../store'; import RCContext from '../../context/RCInstance'; import { MessageBody } from './MessageBody'; import { MessageReactions } from './MessageReactions'; @@ -24,6 +26,7 @@ import { LinkPreview } from '../LinkPreview'; import { getMessageStyles } from './Message.styles'; import useBubbleStyles from './BubbleVariant/useBubbleStyles'; import UiKitMessageBlock from './uiKit/UiKitMessageBlock'; +import useFetchChatData from '../../hooks/useFetchChatData'; const Message = ({ message, @@ -48,15 +51,22 @@ const Message = ({ const { RCInstance, ECOptions } = useContext(RCContext); showAvatar = ECOptions?.showAvatar && showAvatar; - + const { showSidebar, setShowSidebar } = useSidebarStore(); const authenticatedUserId = useUserStore((state) => state.userId); const authenticatedUserUsername = useUserStore((state) => state.username); + const userRoles = useUserStore((state) => state.roles); + const pinPermissions = useUserStore( + (state) => state.userPinPermissions.roles + ); + const editMessagePermissions = useMessageStore( + (state) => state.editMessagePermissions.roles + ); const [setMessageToReport, toggleShowReportMessage] = useMessageStore( (state) => [state.setMessageToReport, state.toggleShowReportMessage] ); - const setQuoteMessage = useMessageStore((state) => state.setQuoteMessage); + const addQuoteMessage = useMessageStore((state) => state.addQuoteMessage); const openThread = useMessageStore((state) => state.openThread); - + const { getStarredMessages } = useFetchChatData(); const dispatchToastMessage = useToastBarDispatch(); const { editMessage, setEditMessage } = useMessageStore((state) => ({ editMessage: state.editMessage, @@ -64,9 +74,26 @@ const Message = ({ })); const isMe = message.u._id === authenticatedUserId; + const theme = useTheme(); + const { mode } = useTheme(); const styles = getMessageStyles(theme); + const hasType = Boolean(message.t); + + const hoverStyle = hasType + ? {} + : { + '&:hover': { + backgroundColor: + mode === 'light' + ? darken(theme.theme.colors.background, 0.03) + : lighten(theme.theme.colors.background, 1), + }, + }; + const bubbleStyles = useBubbleStyles(isMe); + const pinRoles = new Set(pinPermissions); + const editMessageRoles = new Set(editMessagePermissions); const variantStyles = !isInSidebar && variantOverrides === 'bubble' ? bubbleStyles : {}; @@ -87,14 +114,17 @@ const Message = ({ message: 'Message unstarred', }); } + getStarredMessages(); }; const handlePinMessage = async (msg) => { const isPinned = msg.pinned; + msg.pinned = !isPinned; const pinOrUnpin = isPinned ? await RCInstance.unpinMessage(msg._id) : await RCInstance.pinMessage(msg._id); if (pinOrUnpin.error) { + msg.pinned = isPinned; dispatchToastMessage({ type: 'error', message: 'Error pinning message', @@ -107,6 +137,49 @@ const Message = ({ } }; + const handleCopyMessage = async (msg) => { + const textToCopy = + msg.msg || + (msg.attachments && msg.attachments[0] + ? msg.attachments[0].description || msg.attachments[0].title + : ''); + + try { + await navigator.clipboard.writeText(textToCopy); + dispatchToastMessage({ + type: 'success', + message: 'Message copied successfully', + }); + } catch (error) { + dispatchToastMessage({ + type: 'error', + message: 'Error in copying message', + }); + } + }; + + const getMessageLink = async (id) => { + const host = await RCInstance.getHost(); + const res = await RCInstance.channelInfo(); + return `${host}/channel/${res.room.name}/?msg=${id}`; + }; + + const handleCopyMessageLink = async (msg) => { + try { + const messageLink = await getMessageLink(msg._id); + await navigator.clipboard.writeText(messageLink); + dispatchToastMessage({ + type: 'success', + message: 'Message link copied successfully', + }); + } catch (err) { + dispatchToastMessage({ + type: 'error', + message: 'Error in copying message link', + }); + } + }; + const handleDeleteMessage = async (msg) => { const res = await RCInstance.deleteMessage(msg._id); @@ -130,6 +203,7 @@ const Message = ({ const handleOpenThread = (msg) => async () => { openThread(msg); + setShowSidebar(false); }; const isStarred = message.starred?.find((u) => u._id === authenticatedUserId); @@ -138,10 +212,16 @@ const Message = ({ return ( <> + {newDay && ( + + {format(new Date(message.ts), 'MMMM d, yyyy')} + + )} )} - + {shouldShowHeader && ( {message.attachments && message.attachments.length > 0 ? ( <> - + ) : ( - + )} {message.blocks && ( @@ -200,6 +288,11 @@ const Message = ({ message={message} isEditing={editMessage._id === message._id} authenticatedUserId={authenticatedUserId} + userRoles={userRoles} + pinRoles={pinRoles} + editMessageRoles={editMessageRoles} + handleCopyMessage={handleCopyMessage} + handleCopyMessageLink={handleCopyMessageLink} handleOpenThread={handleOpenThread} handleDeleteMessage={handleDeleteMessage} handleStarMessage={handleStarMessage} @@ -211,7 +304,7 @@ const Message = ({ setEditMessage(message); } }} - handleQuoteMessage={() => setQuoteMessage(message)} + handleQuoteMessage={() => addQuoteMessage(message)} handleEmojiClick={handleEmojiClick} handlerReportMessage={() => { setMessageToReport(message._id); @@ -267,11 +360,6 @@ const Message = ({ ) : null} - {newDay && ( - - {format(new Date(message.ts), 'MMMM d, yyyy')} - - )} ); }; diff --git a/packages/react/src/views/Message/Message.styles.js b/packages/react/src/views/Message/Message.styles.js index b6b978fb4c..aa0de86c70 100644 --- a/packages/react/src/views/Message/Message.styles.js +++ b/packages/react/src/views/Message/Message.styles.js @@ -1,7 +1,6 @@ import { css } from '@emotion/react'; -import { lighten, darken } from '@embeddedchat/ui-elements'; -export const getMessageStyles = ({ theme, mode }) => { +export const getMessageStyles = ({ theme }) => { const styles = { main: css` display: flex; @@ -12,11 +11,8 @@ export const getMessageStyles = ({ theme, mode }) => { padding-left: 2.25rem; padding-right: 2.25rem; color: ${theme.colors.foreground}; - - &:hover { - background-color: ${mode === 'light' - ? darken(theme.colors.background, 0.03) - : lighten(theme.colors.background, 1)}; + @media (max-width: 768px) { + padding-left: 0.8rem; } `, messageEditing: css` @@ -81,6 +77,9 @@ export const getMessageDividerStyles = (theme) => { margin-bottom: 0.75rem; padding-left: 1.25rem; padding-right: 1.25rem; + @media (max-width: 780px) { + z-index: 1; + } `, dividerContent: css` diff --git a/packages/react/src/views/Message/MessageAvatarContainer.js b/packages/react/src/views/Message/MessageAvatarContainer.js index 7e7a8116ab..016bbb0b06 100644 --- a/packages/react/src/views/Message/MessageAvatarContainer.js +++ b/packages/react/src/views/Message/MessageAvatarContainer.js @@ -43,7 +43,15 @@ const MessageAvatarContainer = ({ ) : null} diff --git a/packages/react/src/views/Message/MessageHeader.js b/packages/react/src/views/Message/MessageHeader.js index 2d712556ee..eaaaa54cd0 100644 --- a/packages/react/src/views/Message/MessageHeader.js +++ b/packages/react/src/views/Message/MessageHeader.js @@ -68,6 +68,32 @@ const MessageHeader = ({ return 'unarchived room'; case 'room-allowed-reacting': return 'allowed reactions'; + case 'room_changed_avatar': + return `changed room avatar`; + case 'room_changed_announcement': + return `changed room announcement to: ${ + message?.msg && message.msg.length > 0 ? message.msg : '(none)' + }`; + case 'room_changed_description': + return `changed room description to: ${ + message?.msg && message.msg.length > 0 ? message.msg : '(none)' + }`; + case 'room_changed_topic': + return `changed room topic to: ${ + message?.msg && message.msg.length > 0 ? message.msg : '(none)' + }`; + case 'r': + return `changed room name to ${ + message?.msg && message.msg.length > 0 ? message.msg : '(none)' + }`; + case 'user-converted-to-team': + return `converted #${ + message?.msg && message.msg.length > 0 ? message.msg : '(none)' + } to team`; + case 'user-converted-to-channel': + return `converted #${ + message?.msg && message.msg.length > 0 ? message.msg : '(none)' + } to channel`; default: return ''; } @@ -115,7 +141,7 @@ const MessageHeader = ({ css={styles.userRole} className={appendClassNames('ec-message-user-role')} > - admin + Admin )} @@ -126,7 +152,7 @@ const MessageHeader = ({ css={styles.userRole} className={appendClassNames('ec-message-user-role')} > - {role} + {role.charAt(0).toUpperCase() + role.slice(1)} ))} diff --git a/packages/react/src/views/Message/MessageToolbox.js b/packages/react/src/views/Message/MessageToolbox.js index 248dd35586..0208ff663d 100644 --- a/packages/react/src/views/Message/MessageToolbox.js +++ b/packages/react/src/views/Message/MessageToolbox.js @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useContext, useMemo } from 'react'; import { Box, Modal, @@ -9,10 +9,12 @@ import { appendClassNames, useTheme, } from '@embeddedchat/ui-elements'; +import RCContext from '../../context/RCInstance'; import { EmojiPicker } from '../EmojiPicker'; -import { parseEmoji } from '../../lib/emoji'; import { getMessageToolboxStyles } from './Message.styles'; import SurfaceMenu from '../SurfaceMenu/SurfaceMenu'; +import { Markdown } from '../Markdown'; +import Attachment from '../AttachmentHandler/Attachment'; export const MessageToolbox = ({ className = '', @@ -21,12 +23,17 @@ export const MessageToolbox = ({ style = {}, isThreadMessage = false, authenticatedUserId, + userRoles, + pinRoles, + editMessageRoles, handleOpenThread, handleEmojiClick, handlePinMessage, handleStarMessage, handleDeleteMessage, handlerReportMessage, + handleCopyMessage, + handleCopyMessageLink, handleEditMessage, handleQuoteMessage, isEditing = false, @@ -36,6 +43,8 @@ export const MessageToolbox = ({ 'reply', 'quote', 'star', + 'copy', + 'link', 'pin', 'edit', 'delete', @@ -52,6 +61,8 @@ export const MessageToolbox = ({ className, style ); + const { RCInstance } = useContext(RCContext); + const instanceHost = RCInstance.getHost(); const { theme } = useTheme(); const styles = getMessageToolboxStyles(theme); const surfaceItems = @@ -67,6 +78,14 @@ export const MessageToolbox = ({ setShowDeleteModal(false); }; + const isAllowedToPin = userRoles.some((role) => pinRoles.has(role)); + + const isAllowedToEditMessage = userRoles.some((role) => + editMessageRoles.has(role) + ) + ? true + : message.u._id === authenticatedUserId; + const options = useMemo( () => ({ reply: { @@ -110,17 +129,31 @@ export const MessageToolbox = ({ id: 'pin', onClick: () => handlePinMessage(message), iconName: message.pinned ? 'pin-filled' : 'pin', - visible: !isThreadMessage, + visible: isAllowedToPin, }, edit: { label: 'Edit', id: 'edit', onClick: () => handleEditMessage(message), iconName: 'edit', - visible: message.u._id === authenticatedUserId, + visible: isAllowedToEditMessage, color: isEditing ? 'secondary' : 'default', ghost: !isEditing, }, + copy: { + label: 'Copy message', + id: 'copy', + onClick: () => handleCopyMessage(message), + iconName: 'copy', + visible: true, + }, + link: { + label: 'Copy link', + id: 'link', + onClick: () => handleCopyMessageLink(message), + iconName: 'link', + visible: true, + }, delete: { label: 'Delete', id: 'delete', @@ -149,6 +182,8 @@ export const MessageToolbox = ({ handlePinMessage, handleEditMessage, handlerReportMessage, + handleCopyMessage, + isAllowedToPin, ] ); @@ -234,13 +269,61 @@ export const MessageToolbox = ({ - {parseEmoji(message.msg)} + {message.file ? ( + message.file.type.startsWith('image/') ? ( +
+ {message.file.name} +
{`${message.file.name} (${( + message.file.size / 1024 + ).toFixed(2)} kB)`}
+
+ ) : message.file.type.startsWith('video/') ? ( + + ) : message.file.type.startsWith('audio/') ? ( + + ) : ( + + ) + ) : ( + + )} + {message.attachments && + message.attachments.length > 0 && + message.msg && + message.msg[0] === '[' && + message.attachments.map((attachment, index) => ( + + ))}
)} + + setSearchTerm(e.target.value)} + placeholder="Search members" + /> + + + + {filteredMembers.length > 0 ? ( + filteredMembers.map((member) => ( + <> + + + )) + ) : ( + No members found + )} + )}
@@ -90,6 +126,7 @@ const RoomMembers = ({ members }) => { ); }; + export default RoomMembers; RoomMembers.propTypes = { diff --git a/packages/react/src/views/RoomMembers/RoomMemberItem.js b/packages/react/src/views/RoomMembers/RoomMemberItem.js index 336dc7ae95..f5ff504035 100644 --- a/packages/react/src/views/RoomMembers/RoomMemberItem.js +++ b/packages/react/src/views/RoomMembers/RoomMemberItem.js @@ -1,14 +1,19 @@ import React, { useContext, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { css } from '@emotion/react'; -import { Box, Icon, Avatar } from '@embeddedchat/ui-elements'; +import { Box, Icon, Avatar, useTheme } from '@embeddedchat/ui-elements'; import RCContext from '../../context/RCInstance'; -import { RoomMemberItemStyles as styles } from './RoomMembers.styles'; +import { RoomMemberItemStyles } from './RoomMembers.styles'; +import useSetExclusiveState from '../../hooks/useSetExclusiveState'; +import { useUserStore } from '../../store'; const RoomMemberItem = ({ user, host }) => { const { RCInstance } = useContext(RCContext); const [userStatus, setUserStatus] = useState(''); const avatarUrl = new URL(`avatar/${user.username}`, host).toString(); + const { theme } = useTheme(); + const { mode } = useTheme(); + const styles = RoomMemberItemStyles(theme, mode); useEffect(() => { const getStatus = async () => { @@ -18,15 +23,28 @@ const RoomMemberItem = ({ user, host }) => { setUserStatus(res.status); } } catch (err) { - console.error('Error fetching user status', err); + console.error('Error fetching user status:', err); } }; getStatus(); }, [RCInstance]); + const setExclusiveState = useSetExclusiveState(); + const { setShowCurrentUserInfo, setCurrentUser } = useUserStore((state) => ({ + setShowCurrentUserInfo: state.setShowCurrentUserInfo, + setCurrentUser: state.setCurrentUser, + })); + const handleShowUserInfo = () => { + setExclusiveState(setShowCurrentUserInfo); + setCurrentUser(user); + }; return ( - + { {userStatus && ( )} - {user.username} + + {user.name} ({user.username}) + ); diff --git a/packages/react/src/views/RoomMembers/RoomMembers.styles.js b/packages/react/src/views/RoomMembers/RoomMembers.styles.js index 68d3307f5a..415940c2f4 100644 --- a/packages/react/src/views/RoomMembers/RoomMembers.styles.js +++ b/packages/react/src/views/RoomMembers/RoomMembers.styles.js @@ -1,34 +1,78 @@ import { css } from '@emotion/react'; +import { lighten, darken } from '@embeddedchat/ui-elements'; -export const getRoomMemberStyles = () => { +export const getRoomMemberStyles = (theme) => { const styles = { container: css` display: flex; flex-direction: column; - overflow: auto; + height: 100%; width: 100%; - justify-content: center; padding: 0 1rem 1rem; + box-sizing: border-box; + `, + searchContainer: css` + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid ${theme.colors.border}; + padding: 0 0.5rem; + border-radius: ${theme.radius}; + position: relative; + margin-top: 1rem; + `, + textInput: css` + flex: 1; + border: none; + padding: none; + font-size: 1rem; + &:focus { + outline: none; + } + `, + searchIcon: css` + padding-left: 0.5rem; + font-size: 1.25rem; + color: ${theme.colors.icon}; + `, + memberList: css` + flex: 1; + overflow-y: auto; + margin-top: 1rem; + `, + noMembers: css` + text-align: center; + color: ${theme.colors.textSecondary}; + margin-top: 1rem; `, }; return styles; }; -export const RoomMemberItemStyles = { - container: css` - width: 100%; - padding-bottom: 8px; - padding-top: 8px; - display: flex; - align-items: center; - `, +export const RoomMemberItemStyles = (theme, mode) => { + const styles = { + container: css` + width: 100%; + padding-bottom: 8px; + padding-top: 8px; + display: flex; + align-items: center; - icon: css` - padding: 0.125em; - margin-right: 0.5rem; - align-self: center; - `, + &:hover { + background-color: ${mode === 'light' + ? darken(theme.colors.background, 0.03) + : lighten(theme.colors.background, 1)}; + } + `, + + icon: css` + padding: 0.125em; + margin-right: 0.5rem; + align-self: center; + `, + }; + return styles; }; export const InviteMemberStyles = { diff --git a/packages/react/src/views/UserInformation/UserInformation.js b/packages/react/src/views/UserInformation/UserInformation.js index d745ee9b7f..7937f2ab5e 100644 --- a/packages/react/src/views/UserInformation/UserInformation.js +++ b/packages/react/src/views/UserInformation/UserInformation.js @@ -28,9 +28,15 @@ const UserInformation = () => { const [currentUserInfo, setCurrentUserInfo] = useState({}); const [isUserInfoFetched, setIsUserInfoFetched] = useState(false); const currentUser = useUserStore((state) => state.currentUser); - const authenticatedUserRoles = useUserStore((state) => state.roles); + const currentUserRoles = useUserStore((state) => state.roles); + const viewUserFullInfoRoles = useUserStore( + (state) => state.viewUserInfoPermissions.roles + ); const authenticatedUserId = useUserStore((state) => state.userId); - const isAdmin = authenticatedUserRoles?.includes('admin'); + const viewInfoRoles = new Set(viewUserFullInfoRoles); + const isAllowedToViewFullInfo = currentUserRoles.some((role) => + viewInfoRoles.has(role) + ); const getUserAvatarUrl = (username) => { const host = RCInstance.getHost(); return `${host}/avatar/${username}`; @@ -39,7 +45,7 @@ const UserInformation = () => { useEffect(() => { const getCurrentUserInfo = async () => { try { - const res = await RCInstance.userInfo(currentUser._id); + const res = await RCInstance.userData(currentUser.username); if (res?.user) { setCurrentUserInfo(res.user); setIsUserInfoFetched(true); @@ -59,6 +65,10 @@ const UserInformation = () => { title="User Info" iconName="user" onClose={() => setExclusiveState(null)} + style={{ + width: '400px', + zIndex: window.innerWidth <= 780 ? 1 : null, + }} {...(viewType === 'Popup' ? { isPopupHeader: true, @@ -92,6 +102,24 @@ const UserInformation = () => { /> {currentUserInfo?.username}
+ {currentUserInfo?.statusText && ( + + {currentUserInfo?.statusText} + + )} + {currentUserInfo?.nickname && ( + + )} {currentUserInfo?.roles?.length && ( { css={styles.userRole} className={appendClassNames('ec-message-user-role')} > - {role === 'admin' ? 'admin' : role} + {role === 'admin' + ? 'Admin' + : role === 'user' + ? 'user' + : role.charAt(0).toUpperCase() + role.slice(1)}
))}
} - isAdmin={isAdmin} + isAdmin={isAllowedToViewFullInfo} authenticatedUserId={authenticatedUserId} currentUserInfo={currentUserInfo} /> @@ -117,24 +149,37 @@ const UserInformation = () => { + {currentUserInfo?.bio && ( + + )} ( @@ -150,14 +195,14 @@ const UserInformation = () => {
))} - isAdmin={isAdmin} + isAdmin={isAllowedToViewFullInfo} authenticatedUserId={authenticatedUserId} currentUserInfo={currentUserInfo} /> diff --git a/packages/ui-elements/src/components/Icon/icons/HashLock.js b/packages/ui-elements/src/components/Icon/icons/HashLock.js new file mode 100644 index 0000000000..d44f815849 --- /dev/null +++ b/packages/ui-elements/src/components/Icon/icons/HashLock.js @@ -0,0 +1,16 @@ +import React from 'react'; + +const HashLock = (props) => ( + + + + +); + +export default HashLock; diff --git a/packages/ui-elements/src/components/Icon/icons/Lock.js b/packages/ui-elements/src/components/Icon/icons/Lock.js new file mode 100644 index 0000000000..00c234f3a5 --- /dev/null +++ b/packages/ui-elements/src/components/Icon/icons/Lock.js @@ -0,0 +1,15 @@ +import React from 'react'; + +const Lock = (props) => ( + + + +); + +export default Lock; diff --git a/packages/ui-elements/src/components/Icon/icons/Team.js b/packages/ui-elements/src/components/Icon/icons/Team.js new file mode 100644 index 0000000000..4602e9703c --- /dev/null +++ b/packages/ui-elements/src/components/Icon/icons/Team.js @@ -0,0 +1,15 @@ +import React from 'react'; + +const Team = (props) => ( + + + +); + +export default Team; diff --git a/packages/ui-elements/src/components/Icon/icons/index.js b/packages/ui-elements/src/components/Icon/icons/index.js index 77cd6a4511..234251edab 100644 --- a/packages/ui-elements/src/components/Icon/icons/index.js +++ b/packages/ui-elements/src/components/Icon/icons/index.js @@ -4,6 +4,8 @@ import Star from './Star'; import Pin from './Pin'; import ReplyDirectly from './ReplyDirectly'; import Hash from './Hash'; +import HashLock from './HashLock'; +import Lock from './Lock'; import Computer from './Computer'; import Cross from './Cross'; import Mic from './Mic'; @@ -61,6 +63,7 @@ import Arc from './Arc'; import Avatar from './Avatar'; import FormatText from './FormatText'; import Cog from './Cog'; +import Team from './Team'; const icons = { file: File, @@ -69,9 +72,12 @@ const icons = { pin: Pin, 'reply-directly': ReplyDirectly, hash: Hash, + hash_lock: HashLock, + lock: Lock, computer: Computer, cross: Cross, copy: Copy, + team: Team, mic: Mic, 'video-recorder': VideoRecorder, 'disabled-recorder': DisabledRecorder, diff --git a/packages/ui-elements/src/components/Sidebar/Sidebar.js b/packages/ui-elements/src/components/Sidebar/Sidebar.js index 8c8ec70928..cd9e7a1f73 100644 --- a/packages/ui-elements/src/components/Sidebar/Sidebar.js +++ b/packages/ui-elements/src/components/Sidebar/Sidebar.js @@ -11,6 +11,7 @@ const Sidebar = ({ iconName, onClose, children, + filterProps = {}, searchProps = {}, footer, style = {}, @@ -26,7 +27,9 @@ const Sidebar = ({ style={{ ...style, ...styleOverrides }} > - {children} + + {children} + {footer && {footer}}
); diff --git a/packages/ui-elements/src/components/Sidebar/Sidebar.styles.js b/packages/ui-elements/src/components/Sidebar/Sidebar.styles.js index 4c85b5b0f0..f6843a9105 100644 --- a/packages/ui-elements/src/components/Sidebar/Sidebar.styles.js +++ b/packages/ui-elements/src/components/Sidebar/Sidebar.styles.js @@ -34,11 +34,16 @@ export const getSidebarContentStyles = (theme) => { padding: 0 0.5rem; border-radius: ${theme.radius}; position: relative; - margin: 0 1rem 1rem; &.focused { outline: 1px solid ${theme.colors.ring}; } `, + filesHeader: css` + display: flex; + align-items: center; + justify-content: space-between; + margin: 1px 1rem 0; + `, textInput: css` border: none; diff --git a/packages/ui-elements/src/components/Sidebar/SidebarContent.js b/packages/ui-elements/src/components/Sidebar/SidebarContent.js index 7e8756f87a..cbe19088b7 100644 --- a/packages/ui-elements/src/components/Sidebar/SidebarContent.js +++ b/packages/ui-elements/src/components/Sidebar/SidebarContent.js @@ -4,13 +4,20 @@ import { Icon } from '../Icon'; import { Input } from '../Input'; import { getSidebarContentStyles } from './Sidebar.styles'; import { useTheme } from '../../hooks'; +import { StaticSelect } from '../StaticSelect'; -const SidebarContent = ({ children, searchProps = {}, style }) => { +const SidebarContent = ({ + children, + searchProps = {}, + style, + filterProps = {}, +}) => { const { isSearch = false, handleInputChange, placeholder, } = searchProps || {}; + const { isFile, options, value, handleFilterSelect } = filterProps || {}; const searchContainerRef = useRef(null); const { theme } = useTheme(); const styles = getSidebarContentStyles(theme); @@ -29,25 +36,42 @@ const SidebarContent = ({ children, searchProps = {}, style }) => { return ( - {isSearch && ( - - - - - )} + + {isSearch && ( + + + + + )} + {isFile && ( + + + + )} + {children} ); diff --git a/packages/ui-elements/src/components/StaticSelect/StaticSelect.js b/packages/ui-elements/src/components/StaticSelect/StaticSelect.js index d3123dfbac..b64e40b0f2 100644 --- a/packages/ui-elements/src/components/StaticSelect/StaticSelect.js +++ b/packages/ui-elements/src/components/StaticSelect/StaticSelect.js @@ -12,6 +12,7 @@ const StaticSelect = ({ style = {}, options = [], placeholder = '', + isFile, value, onSelect, disabled = false, @@ -22,9 +23,18 @@ const StaticSelect = ({ const styles = getStaticSelectStyles(theme); const [isOpen, setIsOpen] = useState(false); - const [internalValue, setInternalValue] = useState(''); + const [internalValue, setInternalValue] = useState(value || ''); + const [selectedOption, setSelectedOption] = useState(null); const staticSelectRef = useRef(null); + useEffect(() => { + setInternalValue(value || ''); + const option = options.find((opt) => opt.value === value); + if (option) { + setSelectedOption(option); + } + }, [value, options]); + const toggleDropdown = () => { if (!disabled) { setIsOpen(!isOpen); @@ -32,17 +42,15 @@ const StaticSelect = ({ }; const handleSelect = (optionValue) => { + const selectedOpt = options.find((opt) => opt.value === optionValue); setInternalValue(optionValue); + setSelectedOption(selectedOpt); setIsOpen(false); if (onSelect) { onSelect(optionValue); } }; - useEffect(() => { - setInternalValue(value || ''); - }, [value]); - useEffect(() => { const handleClickOutside = (event) => { if ( @@ -61,6 +69,8 @@ const StaticSelect = ({ }; }, [isOpen]); + const displayValue = selectedOption?.label || placeholder; + return ( - {!isOpen && internalValue - ? options.find((option) => option.value === internalValue)?.label - : placeholder} + {displayValue} - {isOpen && ( + {isOpen && !isFile && ( )} + {isOpen && isFile && ( + + + + )}
); }; diff --git a/packages/ui-elements/src/components/StaticSelect/StaticSelect.styles.js b/packages/ui-elements/src/components/StaticSelect/StaticSelect.styles.js index 3374417f64..e42215564b 100644 --- a/packages/ui-elements/src/components/StaticSelect/StaticSelect.styles.js +++ b/packages/ui-elements/src/components/StaticSelect/StaticSelect.styles.js @@ -38,6 +38,15 @@ const getStaticSelectStyles = (theme) => { cursor: not-allowed !important; color: ${theme.colors.mutedForeground}; `, + + fileTypeSelect: css` + position: absolute; + z-index: 10; + top: 100%; + left: 0; + width: 100%; + background-color: white; + `, }; return styles; diff --git a/packages/ui-elements/tools/icons-generator.js b/packages/ui-elements/tools/icons-generator.js index 7dc17fc93b..d12396c2b1 100644 --- a/packages/ui-elements/tools/icons-generator.js +++ b/packages/ui-elements/tools/icons-generator.js @@ -8,9 +8,12 @@ const iconsList = [ 'star', 'reply-directly', 'hash', + 'hash_lock', + 'lock', 'computer', 'cross', 'mic', + 'team', 'circle-cross', 'circle-check', 'send', @@ -64,7 +67,7 @@ const camelCase = (name) => const codeModifier = (code) => { let newCode = code.replace(/class=/g, 'className='); const openingTag = newCode.match(//g)[0]; - const newOpeningTag = openingTag.replace('>', ' {...props}>'); + const newOpeningTag = openingTag.replace(/>/g, ' {...props}>'); newCode = newCode.replace(openingTag, newOpeningTag); return newCode; }; diff --git a/yarn.lock b/yarn.lock index 37796a83ad..d3dec11bea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2366,6 +2366,7 @@ __metadata: prop-types: ^15.8.1 react: ^17.0.2 react-dom: ^17.0.2 + react-syntax-highlighter: ^15.6.1 rimraf: ^5.0.1 rollup: ^2.70.1 rollup-plugin-analyzer: ^4.0.0 @@ -18696,6 +18697,13 @@ __metadata: languageName: node linkType: hard +"highlightjs-vue@npm:^1.0.0": + version: 1.0.0 + resolution: "highlightjs-vue@npm:1.0.0" + checksum: 895f2dd22c93a441aca7df8d21f18c00697537675af18832e50810a071715f79e45eda677e6244855f325234c6a06f7bd76f8f20bd602040fc350c80ac7725e4 + languageName: node + linkType: hard + "hmac-drbg@npm:^1.0.1": version: 1.0.1 resolution: "hmac-drbg@npm:1.0.1" @@ -27215,6 +27223,22 @@ __metadata: languageName: node linkType: hard +"react-syntax-highlighter@npm:^15.6.1": + version: 15.6.1 + resolution: "react-syntax-highlighter@npm:15.6.1" + dependencies: + "@babel/runtime": ^7.3.1 + highlight.js: ^10.4.1 + highlightjs-vue: ^1.0.0 + lowlight: ^1.17.0 + prismjs: ^1.27.0 + refractor: ^3.6.0 + peerDependencies: + react: ">= 0.14.0" + checksum: 417b6f1f2e0c1e00dcc12d34da457b94c7419345306a951d0a8d2d031a0c964179d6b700137870ad1397572cbc3a4454e94de7bbef914a81674edae2098f02dc + languageName: node + linkType: hard + "react@npm:18.2.0, react@npm:^18.2.0": version: 18.2.0 resolution: "react@npm:18.2.0"