diff --git a/.npmrc b/.npmrc index ae643592e..bd3327ab5 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -//registry.npmjs.org/:_authToken=${NPM_TOKEN} +//registry.npmjs.org/:_authToken=${NPM_TOKEN} \ No newline at end of file diff --git a/.storybook/main.ts b/.storybook/main.ts index af637becd..29eb40d98 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -13,6 +13,7 @@ const config: StorybookConfig = { '@storybook/addon-a11y', ], framework: '@storybook/react-vite', + staticDirs: ['../static'], }; export default config; diff --git a/CHANGELOG.md b/CHANGELOG.md index 9df8d2bff..6725f071f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## 4.0.0-beta.5 (2024-05-28) + ## 4.0.0-beta.3 (2024-04-26) ### Bug Fixes diff --git a/amity-uikit.config.json b/amity-uikit.config.json index f25286441..ce9f67eda 100644 --- a/amity-uikit.config.json +++ b/amity-uikit.config.json @@ -27,6 +27,13 @@ } }, "excludes": [], + "message_reactions": [ + { "name": "heart", "image": "message_reaction_heart.png" }, + { "name": "like", "image": "message_reaction_like.png" }, + { "name": "fire", "image": "message_reaction_fire.png" }, + { "name": "grinning", "image": "message_reaction_grinning.png" }, + { "name": "sad", "image": "message_reaction_sad.png" } + ], "customizations": { "select_target_page/*/*": { "theme": {}, @@ -200,6 +207,9 @@ "live_chat/message_composer/*": { "message_limit": 200, "placeholder_text": "Write a message" + }, + "live_chat_page/message_list/message_quick_reaction": { + "reaction": "heart" } } } diff --git a/package.json b/package.json index 5c98bbc6e..0b05b14e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@amityco/ui-kit-open-source", - "version": "4.0.0-beta.3", + "version": "4.0.0-beta.5", "engines": { "node": ">=16", "pnpm": ">=8" @@ -39,7 +39,7 @@ "react-dom": ">=17.0.2" }, "devDependencies": { - "@amityco/ts-sdk": "^6.23.0", + "@amityco/ts-sdk": "^6.25.1", "@storybook/addon-a11y": "^7.6.7", "@storybook/addon-actions": "^7.6.7", "@storybook/addon-backgrounds": "^7.6.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 295db7885..3ebcec89f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,8 +104,8 @@ dependencies: devDependencies: '@amityco/ts-sdk': - specifier: ^6.23.0 - version: 6.23.0 + specifier: ^6.25.1 + version: 6.25.1 '@storybook/addon-a11y': specifier: ^7.6.7 version: 7.6.18 @@ -240,7 +240,7 @@ devDependencies: version: 7.1.1(webpack@5.91.0) ts-jest: specifier: ^29.1.1 - version: 29.1.2(@babel/core@7.24.4)(esbuild@0.18.20)(jest@29.7.0)(typescript@4.9.5) + version: 29.1.2(@babel/core@7.24.4)(esbuild@0.19.12)(jest@29.7.0)(typescript@4.9.5) tsup: specifier: ^7.3.0 version: 7.3.0(postcss@8.4.38)(typescript@4.9.5) @@ -263,8 +263,8 @@ packages: resolution: {integrity: sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==} dev: true - /@amityco/ts-sdk@6.23.0: - resolution: {integrity: sha512-0wLdz4h8hojNcM7WFpgk/sGCbe3ir+lCHu9+yxmSckMoK18/vOq7jSZNHCkElM99smqtU8R5+EoLurQTSW45Gw==} + /@amityco/ts-sdk@6.25.1: + resolution: {integrity: sha512-Xm0BhctI1bMw2IDtpd2weY+BIF5bVhu0CXsG6qkZMVNx3whGteh24YDD3J6ZM8h29RaZp3VXb0yHGZLh6InKAw==} engines: {node: '>=12', npm: '>=6'} dependencies: agentkeepalive: 4.5.0 @@ -7202,7 +7202,7 @@ packages: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.91.0(esbuild@0.18.20) + webpack: 5.91.0(esbuild@0.19.12) dev: true /file-system-cache@2.3.0: @@ -11551,7 +11551,7 @@ packages: dependencies: file-loader: 6.2.0(webpack@5.91.0) loader-utils: 2.0.4 - webpack: 5.91.0(esbuild@0.18.20) + webpack: 5.91.0(esbuild@0.19.12) dev: true /synchronous-promise@2.0.17: @@ -11624,7 +11624,7 @@ packages: unique-string: 2.0.0 dev: true - /terser-webpack-plugin@5.3.10(esbuild@0.18.20)(webpack@5.91.0): + /terser-webpack-plugin@5.3.10(esbuild@0.19.12)(webpack@5.91.0): resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} engines: {node: '>= 10.13.0'} peerDependencies: @@ -11641,12 +11641,12 @@ packages: optional: true dependencies: '@jridgewell/trace-mapping': 0.3.25 - esbuild: 0.18.20 + esbuild: 0.19.12 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.30.4 - webpack: 5.91.0(esbuild@0.18.20) + webpack: 5.91.0(esbuild@0.19.12) dev: true /terser@5.30.4: @@ -11797,7 +11797,7 @@ packages: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true - /ts-jest@29.1.2(@babel/core@7.24.4)(esbuild@0.18.20)(jest@29.7.0)(typescript@4.9.5): + /ts-jest@29.1.2(@babel/core@7.24.4)(esbuild@0.19.12)(jest@29.7.0)(typescript@4.9.5): resolution: {integrity: sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==} engines: {node: ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true @@ -11820,7 +11820,7 @@ packages: dependencies: '@babel/core': 7.24.4 bs-logger: 0.2.6 - esbuild: 0.18.20 + esbuild: 0.19.12 fast-json-stable-stringify: 2.1.0 jest: 29.7.0 jest-util: 29.7.0 @@ -12394,7 +12394,7 @@ packages: resolution: {integrity: sha512-poXpCylU7ExuvZK8z+On3kX+S8o/2dQ/SVYueKA0D4WEMXROXgY8Ez50/bQEUmvoSMMrWcrJqCHuhAbsiwg7Dg==} dev: true - /webpack@5.91.0(esbuild@0.18.20): + /webpack@5.91.0(esbuild@0.19.12): resolution: {integrity: sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==} engines: {node: '>=10.13.0'} hasBin: true @@ -12425,7 +12425,7 @@ packages: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(esbuild@0.18.20)(webpack@5.91.0) + terser-webpack-plugin: 5.3.10(esbuild@0.19.12)(webpack@5.91.0) watchpack: 2.4.1 webpack-sources: 3.2.3 transitivePeerDependencies: diff --git a/readme.md b/readme.md new file mode 100644 index 000000000..ef335f14c --- /dev/null +++ b/readme.md @@ -0,0 +1,136 @@ +# Amity UI-Kit for Web (Open-Source) + +## Prerequisites + +Before getting started, ensure that you have the following prerequisites installed on your system: + +- [Node.js](https://nodejs.org/) LTS version (currently version 20) +- [pnpm](https://pnpm.io/) version 8 + +## How to install PNPM (Optional) + +``` +corepack enable pnpm +``` + +Ref: https://pnpm.io/installation#using-corepack + +## Running Storybook (Optional) + +To run Storybook and view the UI components in isolation, follow these steps: + +1. Clone the Amity UI-Kit repository: + + ``` + git clone https://github.com/AmityCo/Amity-Social-Cloud-UIKit-Web-OpenSource.git + ``` + +2. Navigate to the cloned repository's directory: + + ``` + cd Amity-Social-Cloud-UIKit-Web-OpenSource + ``` + +3. Install the dependencies using pnpm: + + ``` + pnpm install + ``` + +4. Create a `.env` file at the root of the project with the following content: + + ``` + STORYBOOK_API_REGION= + STORYBOOK_API_KEY= + ``` + + Replace `` and `` with your actual credentials. + +5. Run Storybook: + + ``` + pnpm run storybook + ``` + +6. Open your browser and navigate to `http://localhost:6006` to view the Storybook interface. + +## Installation + +To install the Amity UI-Kit together with another project, follow these steps: + +1. Clone the repository using the following command: + + ``` + git clone https://github.com/AmityCo/Amity-Social-Cloud-UIKit-Web-OpenSource.git + ``` + +2. Navigate to the cloned repository's directory: + + ``` + cd ./Amity-Social-Cloud-UIKit-Web-OpenSource + ``` + +3. Install the dependencies using pnpm: + + ``` + pnpm install + ``` + +4. Build the project: + + ``` + pnpm run build + ``` + +5. Pack the project + + ``` + pnpm pack + ``` + +6. Navigate to your application's directory: + + ``` + cd + ``` + +7. Install the Amity UI-Kit to your application using one of the following package managers: + - NPM: + ``` + npm i file:/ --save + ``` + - Yarn (Classic): + ``` + yarn add file:/ + ``` + - PNPM: + ``` + pnpm i file:/ + ``` + +## Documentation + +For detailed information and guidance on using the Amity UI-Kit, please refer to our comprehensive online documentation available at [https://docs.amity.co](https://docs.amity.co). + +If you require further assistance or have any questions, please don't hesitate to contact our dedicated UI-Kit support team at **developers@amity.co**. We are here to help you make the most of the Amity UI-Kit. + +## Contributing + +We welcome contributions from the community to help improve and enhance the Amity UI-Kit. If you are interested in contributing to this project, please review our [contributing guide](https://github.com/AmityCo/Amity-Social-Cloud-UIKit-Web-OpenSource/blob/develop/contributing.md) for guidelines and best practices. + +Thank you for choosing the Amity UI-Kit for your web development needs! + +### FAQ + +Q: I tried to run `pnpm build` and it throws a types error. +A: Try to structure your project to be like this: + +``` +- your_app + - src +- Amity-Social-Cloud-UIKit-Web-OpenSource + - src +``` + +Q: The modifications I made to the code do not appear to be applied. +A: Please attempt to execute `npm cache clean` or `npm cache clean --force` to resolve this issue. diff --git a/src/i18n/en.json b/src/i18n/en.json index 2e77c1e71..fc61f27f0 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -386,6 +386,8 @@ "storyDraft.button.shareStory": "Share story", + "storyTab.title": "Story", + "select.chatType.item": "{answerType} type", "chatComposer.label.channelId": "Channel ID", "chatComposer.label.displayName": "Display Name", @@ -435,5 +437,9 @@ "livechat.error.tooLongMessage.description": "Your message is too long. Please shorten your message and try again.", "livechat.error.sendingMessage.error": "Your message wasn't sent. Please try again.", "livechat.error.sendingMessage.blockedWord": "Your message wasn't sent as it contains a blocked word.", - "livechat.error.sendingMessage.notAllowLink": "Your message wasn’t sent as it contained a link that’s not allowed." + "livechat.error.sendingMessage.notAllowLink": "Your message wasn’t sent as it contained a link that’s not allowed.", + "livechat.reaction.errorOnload": "Unable to load reactions", + "livechat.reaction.emptyState": "No reactions yet", + "livechat.reaction.emptyState.description": "Be the first to react to this message!", + "livechat.reaction.label.removeReaction": "Click to remove reaction" } diff --git a/src/index.ts b/src/index.ts index 5927767c4..835c8c3ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,8 +35,16 @@ export { AmityLiveChatMessageComposeBar, } from '~/v4/chat/components'; +export { MessageReactionPreview as AmityLiveChatMessageReactionPreview } from '~/v4/chat/components/MessageReactionPreview'; +export { MessageReactionPicker as AmityLiveChatMessageReactionPicker } from '~/v4/chat/components/MessageReactionPicker'; +export { MessageQuickReaction as AmityLiveChatMessageQuickReaction } from '~/v4/chat/components/MessageQuickReaction'; +export { ReactionList as AmityReactionList } from '~/v4/social/components/ReactionList'; + import type { AmityMessageActionType } from '~/v4/chat/components'; +import type { ReactionListProps } from '~/v4/social/components/ReactionList'; + export type { AmityMessageActionType }; +export type { ReactionListProps as AmityReactionListProps }; export { AmityLiveChatPage } from '~/v4/chat/pages'; diff --git a/src/social/components/StoryDraft/styles.tsx b/src/social/components/StoryDraft/styles.tsx deleted file mode 100644 index cede55cd7..000000000 --- a/src/social/components/StoryDraft/styles.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import styled from 'styled-components'; - -import { VideoPreview } from '~/core/components/Uploaders/Video/styles'; -import { ArrowLeftCircle2, ArrowRightIcon, ExpandIcon, LinkIcon } from '~/icons'; - -export const BackIcon = styled(ArrowLeftCircle2)` - cursor: pointer; - color: #ffffff; -`; - -export const ExpandStoryIcon = styled(ExpandIcon)` - width: 100%; - height: 100%; - color: #ffffff; -`; - -export const StoryLinkIcon = styled(LinkIcon)` - width: 100%; - height: 100%; - color: #ffffff; -`; - -export const ShareStoryIcon = styled(ArrowRightIcon)` - color: #292b32; -`; - -export const StoryDraftContainer = styled.div` - position: relative; - display: flex; - width: 23.438rem; - height: 40.875rem; - flex-direction: column; - justify-content: center; - align-items: center; -`; - -export const StoryDraftHeader = styled.div` - position: absolute; - top: 0; - padding: 1rem; - display: flex; - justify-content: space-between; - width: 100%; - z-index: 1; -`; - -export const IconButton = styled.button` - width: 2rem; - height: 2rem; - border-radius: 50%; - background-color: rgba(0, 0, 0, 0.5); - border: none; - cursor: pointer; -`; - -export const ActionsContainer = styled.div` - display: flex; - gap: 0.75rem; -`; - -export const DraftImageContainer = styled.div<{ colors: { hex: string }[] }>` - width: 100%; - height: 100%; - position: relative; - border-radius: 0.75rem; - overflow: hidden; - background: linear-gradient( - 180deg, - ${(props) => props.colors?.[0]?.hex || '#000'} 0%, - ${(props) => props.colors?.[props?.colors?.length - 1]?.hex || '#000'} 100% - ); -`; - -export const DraftImage = styled.img<{ imageMode: 'fit' | 'fill'; colors: { hex: string }[] }>` - width: 100%; - height: 100%; - object-fit: ${(props) => (props?.imageMode === 'fit' ? 'contain' : 'cover')}; -`; - -export const StoryDraftFooter = styled.div` - width: 100%; - position: absolute; - bottom: -50px; - background-color: #000; - display: flex; - justify-content: flex-end; - padding: 0.75rem; - overflow: hidden; -`; - -export const ShareStoryButton = styled.button` - display: inline-flex; - height: 2.5rem; - padding: 0.375rem 0.5rem 0.25rem 0.25rem; - align-items: center; - justify-content: center; - flex-shrink: 0; - border-radius: 1.5rem; - background-color: #fff; - border: none; - color: #292b32; - font-size: 0.938rem; - font-style: normal; - font-weight: 600; - line-height: 1.25rem; - letter-spacing: -0.24px; - cursor: pointer; - gap: 0.5rem; -`; - -export const ShareIcon = styled.img` - width: 2rem; - height: 2rem; - border-radius: 50%; -`; - -export const ShareText = styled.span``; - -export const StoryVideoPreview = styled(VideoPreview)` - background-color: #000; -`; diff --git a/src/utils.ts b/src/utils.ts index 6357a8e4b..8dd500000 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -22,12 +22,6 @@ export const checkStoryPermission = ( return false; } - const userPermission = client.hasPermission(Permissions.ManageStoryPermission).currentUser(); - - if (userPermission) { - return true; - } - if (communityId) { const communityPermission = client .hasPermission(Permissions.ManageStoryPermission) diff --git a/src/v4/chat/components/AmityLiveChatMessageList/index.tsx b/src/v4/chat/components/AmityLiveChatMessageList/index.tsx index 329b18e2f..34cbb6e9f 100644 --- a/src/v4/chat/components/AmityLiveChatMessageList/index.tsx +++ b/src/v4/chat/components/AmityLiveChatMessageList/index.tsx @@ -16,14 +16,18 @@ import { useLiveChatNotifications } from '~/v4/chat/providers/LiveChatNotificati import { useCopyMessage } from '~/v4/core/hooks'; interface AmityLiveChatMessageListProps { + pageId?: string; channel: Amity.Channel; replyMessage: (message: Amity.Message<'text'>) => void; } export const AmityLiveChatMessageList = ({ + pageId, channel, replyMessage, }: AmityLiveChatMessageListProps) => { + const componentId = 'message_list'; + const sdk = useSDK(); const containerRef = React.useRef(null); const { formatMessage } = useIntl(); @@ -132,6 +136,8 @@ export const AmityLiveChatMessageList = ({ } key={message.messageId} containerRef={containerRef} + pageId={pageId} + componentId={componentId} /> ); @@ -169,6 +175,8 @@ export const AmityLiveChatMessageList = ({ }} key={message.messageId} containerRef={containerRef} + pageId={pageId} + componentId={componentId} /> ); })} diff --git a/src/v4/chat/components/AmityLiveChatMessageReceiverView/index.tsx b/src/v4/chat/components/AmityLiveChatMessageReceiverView/index.tsx index 4419aa1bd..74859a42a 100644 --- a/src/v4/chat/components/AmityLiveChatMessageReceiverView/index.tsx +++ b/src/v4/chat/components/AmityLiveChatMessageReceiverView/index.tsx @@ -5,12 +5,16 @@ import LiveChatMessageContent from '../LiveChatMessageContent'; import { AmityMessageActionType } from '../LiveChatMessageContent/MessageAction'; interface AmityLiveChatMessageReceiverViewProps { + pageId?: string; + componentId?: string; message: Amity.Message; containerRef: React.RefObject; action: AmityMessageActionType; } export const AmityLiveChatMessageReceiverView = ({ + pageId = '*', + componentId = '*', message, containerRef, action, @@ -20,6 +24,8 @@ export const AmityLiveChatMessageReceiverView = ({ return ( } userDisplayName={user?.displayName} avatarUrl={avatarFileUrl} diff --git a/src/v4/chat/components/AmityLiveChatMessageSenderView/index.tsx b/src/v4/chat/components/AmityLiveChatMessageSenderView/index.tsx index 5478e4ac4..1b5f8f870 100644 --- a/src/v4/chat/components/AmityLiveChatMessageSenderView/index.tsx +++ b/src/v4/chat/components/AmityLiveChatMessageSenderView/index.tsx @@ -5,12 +5,16 @@ import LiveChatMessageContent from '../LiveChatMessageContent'; import { AmityMessageActionType } from '../LiveChatMessageContent/MessageAction'; interface AmityLiveChatMessageSenderViewProps { + pageId?: string; + componentId?: string; message: Amity.Message; containerRef: React.RefObject; action?: AmityMessageActionType; } export const AmityLiveChatMessageSenderView = ({ + pageId = '*', + componentId = '*', message, containerRef, action, @@ -25,6 +29,8 @@ export const AmityLiveChatMessageSenderView = ({ avatarUrl={avatarFileUrl} containerRef={containerRef} action={action} + pageId={pageId} + componentId={componentId} /> ); }; diff --git a/src/v4/chat/components/LiveChatMessageContent/MessageAction/index.tsx b/src/v4/chat/components/LiveChatMessageContent/MessageAction/index.tsx index 5f96e94b0..38bfc9136 100644 --- a/src/v4/chat/components/LiveChatMessageContent/MessageAction/index.tsx +++ b/src/v4/chat/components/LiveChatMessageContent/MessageAction/index.tsx @@ -104,7 +104,7 @@ const MessageAction = ({ )} */} - {isModerator && !isOwner && ( + {(isModerator || !isOwner) && (
; +}) => { + const [isReactionPickerOpen, setIsReactionPickerOpen] = useState(false); + + const [isHoveredQuickReaction, setIsHoveredQuickReaction] = useState(false); + const [isHoveredReactionPicker, setIsHoveredReactionPicker] = useState(false); + + const [transform, setTransform] = React.useState(); + const ref = useRef(null); + + const isHoveredQuickReactionRef = useRef(isHoveredQuickReaction); + + useEffect(() => { + isHoveredQuickReactionRef.current = isHoveredQuickReaction; + }, [isHoveredQuickReaction]); + + const onOpenPicker = useCallback(() => { + if (!isHoveredQuickReactionRef.current) return; + + const timeoutId = setTimeout(() => { + if (isHoveredQuickReactionRef.current) { + setIsReactionPickerOpen(true); + } + }, 500); + + return () => clearTimeout(timeoutId); + }, []); + + const isHoveredReactionPickerRef = useRef(isHoveredReactionPicker); + + useEffect(() => { + isHoveredReactionPickerRef.current = isHoveredReactionPicker; + }, [isHoveredReactionPicker]); + + const onClosePicker = useCallback(() => { + setTimeout(() => { + if (!isHoveredReactionPickerRef.current) { + setIsReactionPickerOpen(false); + } + }, 1000); + }, []); + + const handleClickOutside = useCallback((event) => { + if (ref.current && !ref.current.contains(event.target)) { + setIsReactionPickerOpen(false); + } + }, []); + + const calculateTransform = () => { + if (!ref.current || !containerRef?.current) return 'translate(-50%, 0px)'; + + const parentRect = containerRef.current.getBoundingClientRect(); + const pickerRect = ref.current.getBoundingClientRect(); + + if (!parentRect || !pickerRect) return 'translate(-50%, 0px)'; + + const padding = convertRemToPx( + Number(getCssVariableValue('--asc-spacing-s1').replace('rem', '')), + ); + + const overflowRight = pickerRect.right - parentRect.right; + const overflowLeft = parentRect.left - pickerRect.left; + + if (overflowRight > 0) { + // If overflowing to the right, adjust the transform + return `translate(calc(-50% - ${overflowRight}px), 0px)`; + } else if (overflowLeft > 0) { + // If overflowing to the left, adjust the transform + return `translate(calc(-50% + ${overflowLeft}px), 0px)`; + } else { + // If not overflowing, keep the original transform + return 'translate(-50%, 0px)'; + } + }; + + const onMouseDown = (event: any) => { + handleClickOutside(event); + }; + + const onSelectReaction = () => { + setIsHoveredQuickReaction(false); + setIsHoveredReactionPicker(false); + setIsReactionPickerOpen(false); + }; + + useEffect(() => { + + document.addEventListener('mousedown', onMouseDown); + document.addEventListener('touchstart', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside); + }; + }, []); + + useEffect(() => { + if (isHoveredQuickReaction) { + !isReactionPickerOpen && onOpenPicker(); + } else { + isReactionPickerOpen && onClosePicker(); + } + }, [isHoveredQuickReaction, isReactionPickerOpen]); + + return ( +
+ {isReactionPickerOpen && ( +
setIsHoveredReactionPicker(true)} + onMouseLeave={() => { + setIsHoveredReactionPicker(false); + }} + > + +
+ )} + +
{ + setIsHoveredQuickReaction(true); + }} + onMouseLeave={() => { + setIsHoveredQuickReaction(false); + }} + > + +
+
+ ); +}; diff --git a/src/v4/chat/components/LiveChatMessageContent/MessageReaction/styles.module.css b/src/v4/chat/components/LiveChatMessageContent/MessageReaction/styles.module.css new file mode 100644 index 000000000..6b1de54dc --- /dev/null +++ b/src/v4/chat/components/LiveChatMessageContent/MessageReaction/styles.module.css @@ -0,0 +1,15 @@ +.reactionContainer { + position: relative; +} + +.reactionPickerWrap { + position: absolute; + bottom: 1.5rem; + left: 0.625rem; + transform: translate(-50%, 0px); +} + +.reactionButton { + width: 1.25rem; + height: 1.25rem; +} diff --git a/src/v4/chat/components/LiveChatMessageContent/index.tsx b/src/v4/chat/components/LiveChatMessageContent/index.tsx index 40aa71f56..01bed88fc 100644 --- a/src/v4/chat/components/LiveChatMessageContent/index.tsx +++ b/src/v4/chat/components/LiveChatMessageContent/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import styles from './styles.module.css'; import { Typography } from '~/v4/core/components'; import MessageAction, { AmityMessageActionType } from './MessageAction'; @@ -9,8 +9,14 @@ import useSDK from '~/core/hooks/useSDK'; import MessageBubble from './MessageBubble'; import useChannelPermission from '~/v4/chat/hooks/useChannelPermission'; import Flag from '~/v4/icons/Flag'; +import { MessageReaction } from './MessageReaction'; +import { MessageReactionPreview } from '~/v4/chat/components/MessageReactionPreview'; +import Sheet from 'react-modal-sheet'; +import { ReactionList } from '~/v4/social/components'; interface MessageItemProps { + pageId?: string; + componentId?: string; message: Amity.Message<'text'>; userDisplayName?: string; avatarUrl?: string; @@ -19,6 +25,8 @@ interface MessageItemProps { } const LiveChatMessageContent = ({ + pageId = '*', + componentId = '*', message, avatarUrl, userDisplayName, @@ -29,41 +37,76 @@ const LiveChatMessageContent = ({ const sdk = useSDK(); const isOwner = message.creatorId === sdk.currentUserId; const { isModerator } = useChannelPermission(message.channelId); + const [openReactionPanel, setOpenReactionPanel] = useState(undefined); return ( - -
- {message.isDeleted ? ( -
- -
- - {formatMessage({ - id: 'livechat.deleted.message', - })} - + <> + +
+ {message.isDeleted ? ( +
+ +
+ + {formatMessage({ + id: 'livechat.deleted.message', + })} + +
-
- ) : ( -
- - {action && ( - + +
+ setOpenReactionPanel(message)} + /> +
+ {action && ( + 0} + /> + )} + 0} + pageId={pageId} + componentId={componentId} /> - )} - {message.flagCount > 0 && } -
- + {message.flagCount > 0 && } +
+ +
-
- )} -
- + )} +
+ + + {openReactionPanel && ( + setOpenReactionPanel(undefined)} + className={styles.reactionListSheet} + > + + + + + + + + + )} + ); }; diff --git a/src/v4/chat/components/LiveChatMessageContent/styles.module.css b/src/v4/chat/components/LiveChatMessageContent/styles.module.css index 26529261c..2b92e5f8e 100644 --- a/src/v4/chat/components/LiveChatMessageContent/styles.module.css +++ b/src/v4/chat/components/LiveChatMessageContent/styles.module.css @@ -4,12 +4,25 @@ gap: var(--asc-spacing-s1); } +.messageItemContainerInner { + position: relative; +} + +.messageItemContainerInner[data-reactions='true'] { + margin-bottom: 1.313rem; +} + .messageBubbleWrap { display: flex; align-items: flex-end; gap: var(--asc-spacing-xxs3); } +.messageReaction { + position: absolute; + bottom: -1.313rem; +} + .messageDeletedBubble { display: flex; align-items: center; @@ -19,8 +32,8 @@ gap: var(--asc-spacing-xxs2); border-radius: var(--asc-border-radius-lg); color: var( - --live-chat-message-list-asc-color-base-inverse, - var(--live-chat-asc-color-base-inverse, var(--asc-color-base-inverse)) + --live-chat-message-list-asc-color-base-inverse, + var(--live-chat-asc-color-base-inverse, var(--asc-color-base-inverse)) ); } @@ -34,8 +47,8 @@ height: 1rem; width: 1rem; fill: var( - --live-chat-message-list-asc-color-base-inverse, - var(--live-chat-asc-color-base-inverse, var(--asc-color-base-inverse)) + --live-chat-message-list-asc-color-base-inverse, + var(--live-chat-asc-color-base-inverse, var(--asc-color-base-inverse)) ); } @@ -44,11 +57,6 @@ height: 1.25rem; } -.reactionIcon { - width: 1.25rem; - height: 1.25rem; -} - .flagIcon { height: 1rem; } @@ -56,8 +64,8 @@ .timestamp { font-family: var(--asc-text-global-font-family); color: var( - --live-chat-message-list-asc-color-base-shade2, - var(--live-chat-asc-color-base-shade2, var(--asc-color-base-shade2)) + --live-chat-message-list-asc-color-base-shade2, + var(--live-chat-asc-color-base-shade2, var(--asc-color-base-shade2)) ); margin-bottom: var(--asc-spacing-s1); @@ -65,3 +73,23 @@ font-size: 0.5rem; line-height: 0.75rem; } + +.reactionListSheet { + z-index: 1001 !important; + max-height: 85%; + top: 15% !important; + + @media (min-width: 768px) { + width: 375px !important; + } +} + +.reactionListContainer { + height: 100% !important; + background-color: var(--asc-color-base-background) !important; +} + +.reactionListBackdrop { + background-color: var(--asc-color-black) !important; + opacity: 0.5 !important; +} \ No newline at end of file diff --git a/src/v4/chat/components/MessageQuickReaction/index.tsx b/src/v4/chat/components/MessageQuickReaction/index.tsx new file mode 100644 index 000000000..33a350d58 --- /dev/null +++ b/src/v4/chat/components/MessageQuickReaction/index.tsx @@ -0,0 +1,44 @@ +import React, { useCallback } from 'react'; +import { useCustomization } from '~/v4/core/providers/CustomizationProvider'; +import { useCustomReaction } from '~/v4/core/providers/CustomReactionProvider'; +import { QuickReactionIcon } from '~/v4/icons/QuickReactionIcon'; +import { selectMessageReaction } from '~/v4/utils/selectMessageReaction'; +import styles from './styles.module.css'; + +interface MessageQuickReactionProps { + pageId?: string; + componentId?: string; + message: Amity.Message; + onSelectReaction?: () => void; +} + +export const MessageQuickReaction = ({ + pageId = '*', + componentId = '*', + message, + onSelectReaction, +}: MessageQuickReactionProps) => { + const elementId = 'message_quick_reaction'; + const { config: reactionConfig } = useCustomReaction(); + const { getConfig } = useCustomization(); + + const elementConfig = getConfig(`${pageId}/${componentId}/${elementId}`); + + const onClickQuickReaction = useCallback(() => { + if ( + reactionConfig && + elementConfig.reaction && + reactionConfig.find((config) => config.name === elementConfig.reaction) + ) { + selectMessageReaction({ reactionName: elementConfig.reaction, message }); + } + + onSelectReaction && onSelectReaction(); + }, [reactionConfig, elementConfig, message]); + + return ( +
+ +
+ ); +}; diff --git a/src/v4/chat/components/MessageQuickReaction/styles.module.css b/src/v4/chat/components/MessageQuickReaction/styles.module.css new file mode 100644 index 000000000..ce96cb230 --- /dev/null +++ b/src/v4/chat/components/MessageQuickReaction/styles.module.css @@ -0,0 +1,18 @@ +.quickReactionIcon { + width: 1.25rem; + height: 1.25rem; + color: var(--asc-color-base-shade2); +} + +.quickReactionIconContainer { + display: flex; +} + +.quickReactionIconContainer:hover { + cursor: pointer; + background-color: var( + --live-chat-message-list-asc-color-secondary-shade4, + var(--live-chat-asc-color-secondary-shade4, var(--asc-color-secondary-shade4)) + ); + border-radius: var(--asc-border-radius-sm); +} diff --git a/src/v4/chat/components/MessageReactionPicker/index.tsx b/src/v4/chat/components/MessageReactionPicker/index.tsx new file mode 100644 index 000000000..6f2feb347 --- /dev/null +++ b/src/v4/chat/components/MessageReactionPicker/index.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { AmityReactionType, useCustomReaction } from '~/v4/core/providers/CustomReactionProvider'; +import { selectMessageReaction } from '~/v4/utils/selectMessageReaction'; +import styles from './styles.module.css'; + +export const MessageReactionPicker = ({ + message, + onSelectReaction, +}: { + message: Amity.Message; + onSelectReaction: (reactionName: string) => void; +}) => { + const { config } = useCustomReaction(); + + const onClickReaction = (reactionName: AmityReactionType['name']) => { + selectMessageReaction({ reactionName, message }); + }; + + if (!config) return null; + + return ( +
+ {config.map((reaction) => { + return ( + {reaction.name} { + onClickReaction(reaction.name); + onSelectReaction && onSelectReaction(reaction.name); + }} + /> + ); + })} +
+ ); +}; diff --git a/src/v4/chat/components/MessageReactionPicker/livechatMessageReactionPicker.stories.tsx b/src/v4/chat/components/MessageReactionPicker/livechatMessageReactionPicker.stories.tsx new file mode 100644 index 000000000..3e4a42805 --- /dev/null +++ b/src/v4/chat/components/MessageReactionPicker/livechatMessageReactionPicker.stories.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { MessageReactionPicker } from '.'; + +export default { + title: 'V4/MessageReactionPicker', +}; + +const message = { + reactions: { + like: 1, + angry: 2, + haha: 3, + wow: 4, + sad: 5, + heart: 6, + }, + reactionsCount: 21, + myReactions: ['grinning'], +}; + +export const liveChatMessageReactionPicker = { + render: () => ( +
+
+ {}} /> +
+
+ ), + name: 'MessageReactionPicker', +}; diff --git a/src/v4/chat/components/MessageReactionPicker/styles.module.css b/src/v4/chat/components/MessageReactionPicker/styles.module.css new file mode 100644 index 000000000..f74b32273 --- /dev/null +++ b/src/v4/chat/components/MessageReactionPicker/styles.module.css @@ -0,0 +1,31 @@ +.reactionPickerContainer { + display: flex; + cursor: pointer; + background-color: var(--asc-color-base-shade4); + border-radius: var(--asc-border-radius-full); + gap: var(--asc-spacing-xxs3); + width: max-content; + padding: 0.313rem 0.438rem; +} + +.reactionButton { + width: 2rem; + height: 2rem; + border: 0.313rem var(--asc-color-base-shade4) solid; + border-radius: var(--asc-border-radius-full); +} + +.reactionButton[data-active='true'] { + border-radius: var(--asc-border-radius-full); + border: 0.313rem var(--asc-color-base-shade1) solid; +} + +.reactionButton[data-active='true']:hover { + border-radius: var(--asc-border-radius-full); + border: 0.313rem var(--asc-color-base-shade1) solid; +} + +.reactionButton:hover { + border-radius: var(--asc-border-radius-full); + border: 0.313rem var(--asc-color-secondary-shade3) solid; +} diff --git a/src/v4/chat/components/MessageReactionPreview/index.tsx b/src/v4/chat/components/MessageReactionPreview/index.tsx new file mode 100644 index 000000000..fc5d22f88 --- /dev/null +++ b/src/v4/chat/components/MessageReactionPreview/index.tsx @@ -0,0 +1,61 @@ +import React, { useMemo } from 'react'; +import styles from './styles.module.css'; +import { useCustomReaction } from '~/v4/core/providers/CustomReactionProvider'; +import FallbackReaction from '~/v4/icons/FallbackReaction'; +import { abbreviateCount } from '~/v4/utils/abbreviateCount'; + +export const MessageReactionPreview = ({ + message, + onClick, +}: { + message: Amity.Message; + onClick?: () => void; +}) => { + const { config: reactionConfig } = useCustomReaction(); + // find the top 3 reactions + const topReactions = useMemo( + () => + Object.entries(message.reactions) + .sort((a, b) => b[1] - a[1]) // sort by value in descending order + // remove reaction that has zero value + .filter((reaction) => reaction[1] > 0) + .slice(0, 3) + .sort((a, b) => a[1] - b[1]), + [message.reactions], + ); + + if (!message.reactionsCount) return null; + + return ( +
+
+ {topReactions.map((reaction) => { + const reactionMapConfig = reactionConfig.find((config) => config.name === reaction[0]); + return ( + <> + {reactionMapConfig ? ( + {reactionMapConfig.name} + ) : ( + + )} + + ); + })} +
+
{abbreviateCount(message.reactionsCount)}
+
+ ); +}; diff --git a/src/v4/chat/components/MessageReactionPreview/livechatMessageReactionPreview.stories.tsx b/src/v4/chat/components/MessageReactionPreview/livechatMessageReactionPreview.stories.tsx new file mode 100644 index 000000000..e0efcf5b4 --- /dev/null +++ b/src/v4/chat/components/MessageReactionPreview/livechatMessageReactionPreview.stories.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { MessageReactionPreview } from './'; + +export default { + title: 'V4/MessageReactionPreview', +}; + +const message = { + reactions: { + like: 1, + angry: 2, + haha: 3, + wow: 4, + sad: 5, + heart: 6, + }, + reactionsCount: 21, + myReactions: ['grinning'], +}; + +export const liveChatMessageReactionPreview = { + render: () => ( +
+
+ +
+
+ ), + name: 'MessageReactionPreview', +}; diff --git a/src/v4/chat/components/MessageReactionPreview/styles.module.css b/src/v4/chat/components/MessageReactionPreview/styles.module.css new file mode 100644 index 000000000..b8fd6addb --- /dev/null +++ b/src/v4/chat/components/MessageReactionPreview/styles.module.css @@ -0,0 +1,50 @@ +.reactionPreviewContainer { + display: flex; + flex-direction: row; + align-items: center; + background-color: var(--asc-color-base-shade3); + border: 1px solid var(--asc-color-secondary-shade4); + padding: var(--asc-spacing-xxs2) var(--asc-spacing-xxs3); + border-radius: var(--asc-border-radius-full); +} + +.reactionPreviewContainer[data-myreaction='true'] { + background-color: var(--asc-color-primary-default); + border: none; +} + +.reactionIconContainer { + display: flex; + flex-direction: row-reverse; + align-items: center; +} + +.reactionIconContainer > :not(:first-child) { + margin-right: calc(var(--asc-spacing-s1) * -1); +} + +.reactionIconContainer > :first-child { + transform: translateX(0rem); +} + +.reactionIconContainer > :nth-child(2) { + transform: translateX(0rem); +} + +.reactionIconContainer > :last-child { + transform: translateX(0rem); +} + +.reactionIcon { + width: 1.25rem; + height: 1.25rem; +} + +.reactionCount { + margin-left: var(--asc-spacing-xxs1); + color: var(--asc-color-base-inverse); +} + +.fallbackIcon { + color: var(--asc-color-base-shade1); +} diff --git a/src/v4/chat/components/index.ts b/src/v4/chat/components/index.ts index 777c787b3..9b139945e 100644 --- a/src/v4/chat/components/index.ts +++ b/src/v4/chat/components/index.ts @@ -4,5 +4,9 @@ export { AmityLiveChatMessageSenderView } from './AmityLiveChatMessageSenderView export { AmityLiveChatMessageList } from './AmityLiveChatMessageList'; export { AmityLiveChatMessageComposeBar } from './AmityLiveChatMessageComposeBar'; +export { MessageReactionPicker } from './MessageReactionPicker'; +export { MessageQuickReaction } from './MessageQuickReaction'; +export { MessageReactionPreview } from './MessageReactionPreview'; + import type { AmityMessageActionType } from './LiveChatMessageContent/MessageAction'; export type { AmityMessageActionType }; diff --git a/src/v4/chat/hooks/useReaction.ts b/src/v4/chat/hooks/useReaction.ts new file mode 100644 index 000000000..168aabfd9 --- /dev/null +++ b/src/v4/chat/hooks/useReaction.ts @@ -0,0 +1,18 @@ +import { ReactionRepository } from '@amityco/ts-sdk'; + +const useReaction = (referenceType: Amity.ReactableType, referenceId: string) => { + const addReaction = async (reaction: string) => { + await ReactionRepository.addReaction(referenceType, referenceId, reaction); + }; + + const removeReaction = async (reaction: string) => { + await ReactionRepository.removeReaction(referenceType, referenceId, reaction); + }; + + return { + addReaction, + removeReaction, + }; +}; + +export default useReaction; diff --git a/src/v4/chat/hooks/useReactionByReference.ts b/src/v4/chat/hooks/useReactionByReference.ts new file mode 100644 index 000000000..0b8576309 --- /dev/null +++ b/src/v4/chat/hooks/useReactionByReference.ts @@ -0,0 +1,47 @@ +import { + CommentRepository, + MessageRepository, + PostRepository, + StoryRepository, +} from '@amityco/ts-sdk'; +import { useEffect, useState } from 'react'; + +const useReactionByReference = (referenceType: Amity.ReactableType, referenceId: string) => { + const [reactionCount, setReactionCount] = useState(0); + const [reactions, setReactions] = useState>({}); + const [myReaction, setMyReaction] = useState(null); + + const updateReaction = ({ + data, + loading, + error, + }: Amity.LiveObject) => { + if (loading || error) return; + + setReactionCount(data.reactionsCount); + setReactions(data.reactions); + setMyReaction(data.myReaction); + }; + + useEffect(() => { + if (referenceType === 'message') { + MessageRepository.getMessage(referenceId, updateReaction); + } else if (referenceType === 'story') { + StoryRepository.getStoryByStoryId(referenceId, updateReaction); + } else if (referenceType === 'comment') { + CommentRepository.getComment(referenceId, updateReaction); + } else if (referenceType === 'post') { + PostRepository.getPost(referenceId, updateReaction); + } else { + throw new Error('Unsupported reference type'); + } + }, [referenceId, referenceType]); + + return { + reactions, + reactionCount, + myReaction, + }; +}; + +export default useReactionByReference; diff --git a/src/v4/chat/pages/AmityLiveChatPage/ChatContainer/ChatReadyState.tsx b/src/v4/chat/pages/AmityLiveChatPage/ChatContainer/ChatReadyState.tsx index 84eef0373..58c46e592 100644 --- a/src/v4/chat/pages/AmityLiveChatPage/ChatContainer/ChatReadyState.tsx +++ b/src/v4/chat/pages/AmityLiveChatPage/ChatContainer/ChatReadyState.tsx @@ -16,7 +16,7 @@ import useSearchChannelUser from '~/v4/chat/hooks/collections/useSearchChannelUs import useSDK from '~/core/hooks/useSDK'; import useChannelPermission from '~/v4/chat/hooks/useChannelPermission'; -const ChatReadyState = ({ channel }: { channel: Amity.Channel }) => { +const ChatReadyState = ({ pageId = '*', channel }: { pageId?: string; channel: Amity.Channel }) => { const isOnline = useConnectionStates(); const { isModerator } = useChannelPermission(channel.channelId); @@ -59,7 +59,7 @@ const ChatReadyState = ({ channel }: { channel: Amity.Channel }) => { return ( <> - + {isOnline && ( <> diff --git a/src/v4/chat/pages/AmityLiveChatPage/ChatContainer/index.tsx b/src/v4/chat/pages/AmityLiveChatPage/ChatContainer/index.tsx index ee5b4208f..aa1b8c735 100644 --- a/src/v4/chat/pages/AmityLiveChatPage/ChatContainer/index.tsx +++ b/src/v4/chat/pages/AmityLiveChatPage/ChatContainer/index.tsx @@ -2,9 +2,15 @@ import React from 'react'; import ChatLoadingState from './ChatLoadingState'; import ChatReadyState from './ChatReadyState'; -const ChatContainer = ({ channel }: { channel: Amity.Channel | null }) => { +const ChatContainer = ({ + pageId = '*', + channel, +}: { + pageId?: string; + channel: Amity.Channel | null; +}) => { if (!channel) return ; - return ; + return ; }; export default ChatContainer; diff --git a/src/v4/chat/pages/AmityLiveChatPage/ChatContainer/styles.module.css b/src/v4/chat/pages/AmityLiveChatPage/ChatContainer/styles.module.css index e5884d283..6be928595 100644 --- a/src/v4/chat/pages/AmityLiveChatPage/ChatContainer/styles.module.css +++ b/src/v4/chat/pages/AmityLiveChatPage/ChatContainer/styles.module.css @@ -37,8 +37,8 @@ border-radius: var(--asc-border-radius-sm); border: var(--asc-border-radius-none); background-color: var( - --live-chat-asc-color-secondary-default, - var(--asc-color-secondary-default) + --live-chat-asc-color-secondary-default, + var(--asc-color-secondary-default) ); color: var(--live-chat-asc-color-base-inverse, var(--asc-color-base-inverse)); display: flex; @@ -133,4 +133,4 @@ gap: var(--asc-spacing-s2); padding: var(--asc-spacing-m1); color: var(--live-chat-asc-color-base-shade2, var(--asc-color-base-shade2)); -} +} \ No newline at end of file diff --git a/src/v4/chat/pages/AmityLiveChatPage/index.tsx b/src/v4/chat/pages/AmityLiveChatPage/index.tsx index b9c9dcca2..18321dd20 100644 --- a/src/v4/chat/pages/AmityLiveChatPage/index.tsx +++ b/src/v4/chat/pages/AmityLiveChatPage/index.tsx @@ -12,6 +12,7 @@ interface AmityLiveChatPageProps { export const AmityLiveChatPage = ({ channelId }: AmityLiveChatPageProps) => { const channel = useChannel(channelId); const ref = useRef(null); + const pageId = 'live_chat_page'; return ( @@ -19,7 +20,7 @@ export const AmityLiveChatPage = ({ channelId }: AmityLiveChatPageProps) => {
- +
); diff --git a/src/v4/core/components/Avatar/Avatar.tsx b/src/v4/core/components/Avatar/Avatar.tsx index 93b513a3c..12a061429 100644 --- a/src/v4/core/components/Avatar/Avatar.tsx +++ b/src/v4/core/components/Avatar/Avatar.tsx @@ -19,6 +19,7 @@ export const Avatar = ({ onClick, loading, size = 'medium', + backgroundImage, ...props }: AvatarProps) => { const [visible, setVisible] = useState(false); diff --git a/src/v4/core/components/Typography/Typography.tsx b/src/v4/core/components/Typography/Typography.tsx index fbc295f91..7eff4074b 100644 --- a/src/v4/core/components/Typography/Typography.tsx +++ b/src/v4/core/components/Typography/Typography.tsx @@ -15,61 +15,86 @@ const Typography: React.FC & { BodyBold: React.FC; Caption: React.FC; CaptionBold: React.FC; -} = ({ children, className = '' }) => { - return
{children}
; +} = ({ children, className = '', ...props }) => { + return ( +
+ {children} +
+ ); }; -Typography.Heading = ({ children, className = '' }) => { +Typography.Heading = ({ children, className = '', ...props }) => { return ( -

+

{children}

); }; -Typography.Title = ({ children, className = '' }) => { +Typography.Title = ({ children, className = '', ...props }) => { return ( -

+

{children}

); }; -Typography.Subtitle = ({ children, className = '' }) => { +Typography.Subtitle = ({ children, className = '', ...props }) => { return ( -

+

{children}

); }; -Typography.Body = ({ children, className = '' }) => { +Typography.Body = ({ children, className = '', ...props }) => { return ( -

+

{children}

); }; -Typography.BodyBold = ({ children, className = '' }) => { +Typography.BodyBold = ({ children, className = '', ...props }) => { return ( -

+

{children}

); }; -Typography.Caption = ({ children, className = '' }) => { +Typography.Caption = ({ children, className = '', ...props }) => { return ( -

+

{children}

); }; -Typography.CaptionBold = ({ children, className = '' }) => { +Typography.CaptionBold = ({ children, className = '', ...props }) => { return ( -

+

{children}

); diff --git a/src/v4/core/providers/AmityUIKitProvider.tsx b/src/v4/core/providers/AmityUIKitProvider.tsx index d7f7b4a97..7e8188e2b 100644 --- a/src/v4/core/providers/AmityUIKitProvider.tsx +++ b/src/v4/core/providers/AmityUIKitProvider.tsx @@ -30,6 +30,7 @@ import { ConfirmProvider } from '~/v4/core/providers/ConfirmProvider'; import { ConfirmProvider as LegacyConfirmProvider } from '~/core/providers/ConfirmProvider'; import { NotificationProvider } from '~/v4/core/providers/NotificationProvider'; import { NotificationProvider as LegacyNotificationProvider } from '~/core/providers/NotificationProvider'; +import { CustomReactionProvider } from './CustomReactionProvider'; export type AmityUIKitConfig = Config; @@ -138,38 +139,40 @@ const AmityUIKitProvider: React.FC = ({ - - - - - - - - - - - - {children} - - - - - - - - - - - - - - - + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + + diff --git a/src/v4/core/providers/CustomReactionProvider.tsx b/src/v4/core/providers/CustomReactionProvider.tsx new file mode 100644 index 000000000..7c18eb89b --- /dev/null +++ b/src/v4/core/providers/CustomReactionProvider.tsx @@ -0,0 +1,32 @@ +import React, { createContext, useContext } from 'react'; +import { useCustomization } from './CustomizationProvider'; + +export type AmityReactionType = { + name: string; + image: string; +}; + +const CustomReactionContext = createContext([]); + +export const useCustomReaction = () => { + const config = useContext(CustomReactionContext); + return { config }; +}; + +export const CustomReactionProvider: React.FC = ({ children }) => { + const { config } = useCustomization(); + const [reactions, setReactions] = React.useState([]); + + React.useEffect(() => { + if (!config) return; + + const reactionConfig = config?.message_reactions; + if (!reactionConfig) return; + + setReactions(reactionConfig); + }, [config]); + + return ( + {children} + ); +}; diff --git a/src/v4/core/providers/CustomizationProvider.tsx b/src/v4/core/providers/CustomizationProvider.tsx index 52a385c4e..fde68dca0 100644 --- a/src/v4/core/providers/CustomizationProvider.tsx +++ b/src/v4/core/providers/CustomizationProvider.tsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; +import { AmityReactionType } from './CustomReactionProvider'; interface CustomizationContextValue { config: Config | null; @@ -41,6 +42,7 @@ export interface Config { dark?: Theme['dark']; }; excludes?: string[]; + message_reactions?: AmityReactionType[]; customizations?: { 'select_target_page/*/*'?: { theme?: { diff --git a/src/v4/core/providers/ThemeProvider.tsx b/src/v4/core/providers/ThemeProvider.tsx index 472898a5c..928d15037 100644 --- a/src/v4/core/providers/ThemeProvider.tsx +++ b/src/v4/core/providers/ThemeProvider.tsx @@ -91,7 +91,7 @@ const generateComponentPalette = (config: Config, currentTheme: 'light' | 'dark' configurable.componentIds.forEach((componentId) => { const componentConfig = (config.customizations as { [key: string]: { theme: Theme } })?.[ `${configurable.pageId}/${componentId}/*` - ].theme; + ]?.theme; if (componentConfig) { const themeToGenerate = currentTheme === 'light' ? componentConfig.light : componentConfig.dark; diff --git a/src/v4/helpers/utils.ts b/src/v4/helpers/utils.ts index 0c7e1c4d0..9afa54300 100644 --- a/src/v4/helpers/utils.ts +++ b/src/v4/helpers/utils.ts @@ -164,3 +164,11 @@ export function parseMentionsMarkup( export function isNonNullable(value: TValue | undefined | null): value is TValue { return value != null; } + +export function getCssVariableValue(variable: string) { + return getComputedStyle(document.documentElement).getPropertyValue(variable); +} + +export function convertRemToPx(rem: number) { + return rem * parseFloat(getComputedStyle(document.documentElement).fontSize); +} diff --git a/src/v4/icons/FallbackReaction.tsx b/src/v4/icons/FallbackReaction.tsx new file mode 100644 index 000000000..4aca57bcd --- /dev/null +++ b/src/v4/icons/FallbackReaction.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +interface FallbackIconProps extends React.SVGProps { + backgroundColor?: string; +} + +const FallbackReaction = ({ backgroundColor, ...props }: FallbackIconProps) => ( + + + + +); + +export default FallbackReaction; diff --git a/src/v4/icons/QuickReactionIcon.tsx b/src/v4/icons/QuickReactionIcon.tsx new file mode 100644 index 000000000..7fd55731f --- /dev/null +++ b/src/v4/icons/QuickReactionIcon.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +export const QuickReactionIcon = (props: React.SVGProps) => { + return ( + + + + + ); +}; diff --git a/src/v4/icons/ReactionListSkeleton.tsx b/src/v4/icons/ReactionListSkeleton.tsx new file mode 100644 index 000000000..301439259 --- /dev/null +++ b/src/v4/icons/ReactionListSkeleton.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +const Svg = (props: React.SVGProps) => ( + + + + +); + +export default Svg; diff --git a/src/v4/icons/SmilePlus.tsx b/src/v4/icons/SmilePlus.tsx new file mode 100644 index 000000000..d09da778b --- /dev/null +++ b/src/v4/icons/SmilePlus.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +const Svg = (props: React.SVGProps) => ( + + + +); + +export default Svg; diff --git a/src/v4/social/components/HyperLinkConfig/HyperLinkConfig.tsx b/src/v4/social/components/HyperLinkConfig/HyperLinkConfig.tsx index c205913be..26b7f13d2 100644 --- a/src/v4/social/components/HyperLinkConfig/HyperLinkConfig.tsx +++ b/src/v4/social/components/HyperLinkConfig/HyperLinkConfig.tsx @@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import clsx from 'clsx'; + import useSDK from '~/core/hooks/useSDK'; import { BottomSheet, Typography } from '~/v4/core/components'; import { useCustomization } from '~/v4/core/providers/CustomizationProvider'; @@ -126,28 +127,27 @@ export const HyperLinkConfig = ({ )}
-
+
+
-
- -
- - - +
+ + + - {errors?.url && {errors?.url?.message}} @@ -155,11 +155,11 @@ export const HyperLinkConfig = ({
- - -
+ + +
{watch('customText')?.length || 0} / {MAX_LENGTH}
@@ -170,6 +170,7 @@ export const HyperLinkConfig = ({ })} className={clsx(styles.input, errors?.customText && styles.hasError)} {...register('customText')} + maxLength={MAX_LENGTH} /> {errors?.customText && ( {errors?.customText?.message} @@ -183,13 +184,13 @@ export const HyperLinkConfig = ({ {isHaveHyperLink && (
+ variant="secondary" + onClick={discardHyperlink} + className={clsx(styles.removeLinkButton)} + > + + {formatMessage({ id: 'storyCreation.hyperlink.form.removeButton' })} +
)} diff --git a/src/v4/social/components/ReactionList/ReactionIcon.tsx b/src/v4/social/components/ReactionList/ReactionIcon.tsx new file mode 100644 index 000000000..b1359a7aa --- /dev/null +++ b/src/v4/social/components/ReactionList/ReactionIcon.tsx @@ -0,0 +1,20 @@ +import { AmityReactionType } from '~/v4/core/providers/CustomReactionProvider'; +import React from 'react'; + +export const ReactionIcon = ({ + reactionConfigItem, + className, +}: { + reactionConfigItem: AmityReactionType; + className: string; +}) => { + return ( + {reactionConfigItem.name} + ); +}; diff --git a/src/v4/social/components/ReactionList/ReactionList.module.css b/src/v4/social/components/ReactionList/ReactionList.module.css index 484874fd7..04c531acc 100644 --- a/src/v4/social/components/ReactionList/ReactionList.module.css +++ b/src/v4/social/components/ReactionList/ReactionList.module.css @@ -1,72 +1,154 @@ .reactionListContainer { display: flex; flex-direction: column; - gap: var(--asc-spacing-m); + gap: var(--asc-spacing-m1); + height: 100%; } -.tabList { +.tabListContainer { display: flex; - justify-content: flex-start; align-items: center; - border-bottom: 1px solid var(--asc-color-neutral-shade2); - margin-bottom: 1rem; + overflow-x: auto; + position: relative; + width: 100%; + scrollbar-width: none; /* For Firefox */ + -ms-overflow-style: none; /* For Internet Explorer and Edge */ +} + +.tabListContainer::-webkit-scrollbar { + display: none; /* For Chrome, Safari, and Opera */ +} + +.tabList { + display: flex; + gap: var(--asc-spacing-s1); + border-bottom: 1px solid var(--asc-color-base-shade4); + width: 100%; + min-width: max-content; } .tabItem { cursor: pointer; - padding: 0.5rem 1rem; - position: relative; - transition: color 0.3s ease; + padding: var(--asc-spacing-xxs2) var(--asc-spacing-s1); + background: var(--asc-color-base-background); + color: var(--asc-color-base-shade6); + padding-bottom: var(--asc-spacing-s1); + border-bottom: transparent; } -.tabItem::after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; +.tabItem[data-active='true'] { + color: var(--asc-color-primary-default); + border-bottom: 1px solid var(--asc-color-primary-default); +} + +.reactionEmoji { + display: flex; + align-items: center; + gap: var(--asc-spacing-s1); +} + +.userList { + display: flex; + gap: var(--asc-spacing-s1); + width: 100%; + flex-direction: column; +} + +.userItem { + display: flex; + align-items: center; + gap: var(--asc-spacing-s1); + background: var(--asc-color-base-background); + border-radius: var(--asc-border-radius-sm); width: 100%; - height: 2px; - background-color: transparent; - transition: background-color 0.3s ease; + border-bottom: 1px solid var(--asc-color-base-shade4); } -.tabItem.active { - color: var(--asc-color-primary-default); +.userDetailsContainer { + display: flex; + color: var(--asc-color-base-default); + padding: var(--asc-spacing-s1); + justify-content: space-between; + width: 100%; } -.tabItem.active::after { - background-color: var(--asc-color-primary-default); +.userDetailsProfile { + display: flex; + gap: var(--asc-spacing-s2); + align-items: center; } -.reactionEmoji { +.userDetailsReaction { display: flex; align-items: center; - gap: 0.5rem; } -.tabCount { - color: var(--asc-color-base-shade2); - transition: color 0.3s ease; +.reactionItem { + width: 1.25rem; } -.tabItem.active .tabCount { - color: var(--asc-color-primary-default); +.reactionIcon { + width: 1.5rem; } -.userList { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 1rem; +.reactionCustomStateContainer { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + color: var(--asc-color-base-shade2) } -.userItem { +.reactionCustomStateContainer.loadingState { + flex-direction: column; + justify-content: start; + align-items: flex-start; + animation: skeletonPulse 1.5s ease-in-out infinite; +} + +.reactionState { display: flex; + flex-direction: column; align-items: center; - gap: 0.5rem; + gap: var(--asc-spacing-s2); } -.userDetailsContainer { +.reactionState2Line { display: flex; + flex-direction: column; align-items: center; - gap: 0.5rem; + gap: var(--asc-spacing-xxs2); +} + +.removeBtn { + cursor: pointer; + color: var(--asc-color-base-shade1); +} + +@keyframes skeletonPulse { + 0% { + opacity: 0.6; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.6; + } } + +.infiniteScrollContainer { + display: flex; + flex-grow: 1; + overflow: auto; + width: 100%; + + > div { + width: 100%; + } +} + +.reactionPanel { + height: 100%; +} \ No newline at end of file diff --git a/src/v4/social/components/ReactionList/ReactionList.tsx b/src/v4/social/components/ReactionList/ReactionList.tsx index af25bb11f..66b4afbbd 100644 --- a/src/v4/social/components/ReactionList/ReactionList.tsx +++ b/src/v4/social/components/ReactionList/ReactionList.tsx @@ -1,93 +1,197 @@ -import React, { Fragment, useState } from 'react'; -import { FireIcon, HeartIcon, LikedIcon } from '~/icons'; +import React, { useMemo, useRef, useState } from 'react'; import styles from './ReactionList.module.css'; import { useReactionsCollection } from '~/v4/social/hooks/collections/useReactionsCollection'; -import { Avatar, Typography } from '~/v4/core/components'; +import { Typography } from '~/v4/core/components'; +import { useCustomReaction } from '~/v4/core/providers/CustomReactionProvider'; +import { abbreviateCount } from '~/v4/utils/abbreviateCount'; +import { ReactionIcon } from '~/v4/social/components/ReactionList/ReactionIcon'; +import { ReactionListPanel } from '~/v4/social/components/ReactionList/ReactionListPanel'; +import { ReactionListError } from '~/v4/social/components/ReactionList/ReactionListError'; +import { ReactionListEmptyState } from '~/v4/social/components/ReactionList/ReactionListEmptyState'; +import { ReactionListLoadingState } from '~/v4/social/components/ReactionList/ReactionListLoadingState'; +import useReaction from '~/v4/chat/hooks/useReaction'; +import useReactionByReference from '~/v4/chat/hooks/useReactionByReference'; +import FallbackReaction from '~/v4/icons/FallbackReaction'; interface ReactionListProps { + pageId: string; referenceId: string; referenceType: Amity.ReactableType; } -type ReactionType = 'like' | 'love' | 'fire'; -type ReactionTabType = ReactionType | 'All'; +const UNKNOWN_TAB = 'unknown'; + +const filterReactionsByTab = ( + reactions: Amity.Reactor[], + activeTab: string, + allConfigReactions: string[], +) => { + if (activeTab === 'All') return reactions; + if (activeTab === UNKNOWN_TAB) { + return reactions.filter((reaction) => !allConfigReactions.includes(reaction.reactionName)); + } + return reactions.filter((reaction) => reaction.reactionName === activeTab.toLowerCase()); +}; + +const RenderCondition = ({ + filteredReactions, + hasMore, + loadMore, + isLoading, + removeReaction, + error, + currentRef, +}: { + filteredReactions: Amity.Reactor[]; + isLoading: boolean; + hasMore: boolean; + loadMore: () => void; + removeReaction: (reaction: string) => Promise; + error: Error | null; + currentRef: HTMLDivElement | null; +}) => { + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (filteredReactions.length === 0) { + if (isLoading) { + return ; + } + + return ; + } + + return ( + + ); +}; + +export const ReactionList = ({ pageId = '*', referenceId, referenceType }: ReactionListProps) => { + const componentId = 'reaction_list'; + const { reactions, error, isLoading, hasMore, loadMore } = useReactionsCollection({ + referenceId, + referenceType, + limit: 25, + }); + + const { reactions: allReacted, reactionCount } = useReactionByReference( + referenceType, + referenceId, + ); -export const ReactionList = ({ referenceId, referenceType }: ReactionListProps) => { - const { reactions } = useReactionsCollection({ referenceId, referenceType }); const [activeTab, setActiveTab] = useState('All'); + const containerRef = useRef(null); + const { config } = useCustomReaction(); + const { removeReaction } = useReaction(referenceType, referenceId); - const handleTabClick = (tab: ReactionTabType) => { + const handleTabClick = (tab: string) => { setActiveTab(tab); }; - const filteredReactions = - activeTab === 'All' - ? reactions - : reactions.filter((reaction) => reaction.reactionName === activeTab.toLowerCase()); + if (reactions == null || !config) return null; - if (reactions == null) return null; + const allConfigReactions = useMemo( + () => config.map((reactionConfigItem) => reactionConfigItem.name), + [config], + ); + + const unknownReaction = useMemo( + () => + Object.keys(allReacted).filter((reaction) => { + return !allConfigReactions.includes(reaction); + }), + [allReacted], + ); + + const totalUnknownReactionCount = useMemo( + () => unknownReaction.reduce((acc, curr) => acc + allReacted[curr], 0), + [allReacted], + ); + + const filteredReactions = useMemo( + () => filterReactionsByTab(reactions, activeTab, allConfigReactions), + [reactions, activeTab], + ); return ( -
-
-
handleTabClick('All')} - > - - - All {reactions.length} - - -
- {(['like', 'love', 'fire'] as ReactionType[]).map((reactionType) => { - const count = reactions.filter( - (reaction) => reaction.reactionName === reactionType, - ).length; - return ( -
handleTabClick(reactionType)} - > - {reactionType === 'like' && ( - - - {count} - - - )} - {reactionType === 'love' && ( - - - {count} - - - )} - {reactionType === 'fire' && ( - +
+
+
+
handleTabClick('All')} + > + + All {abbreviateCount(reactionCount)} + +
+ + {config.map((reactionConfigItem) => { + const { name: reactionType, image } = reactionConfigItem; + + if (!allReacted[reactionType]) return null; + + return ( +
handleTabClick(reactionType)} + > + - {count} + + {abbreviateCount(allReacted[reactionType])} - - )} + +
+ ); + })} + + {unknownReaction.length > 0 && ( +
handleTabClick(UNKNOWN_TAB)} + > + + + + {abbreviateCount(totalUnknownReactionCount)} + +
- ); - })} + )} +
-
- {filteredReactions.map((reaction) => { - return ( - -
-
- - {reaction.user?.displayName} -
-
-
- ); - })} + +
+
); diff --git a/src/v4/social/components/ReactionList/ReactionListEmptyState.tsx b/src/v4/social/components/ReactionList/ReactionListEmptyState.tsx new file mode 100644 index 000000000..a238af1aa --- /dev/null +++ b/src/v4/social/components/ReactionList/ReactionListEmptyState.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import styles from './ReactionList.module.css'; +import { Typography } from '~/v4/core/components'; +import SmilePlus from '~/v4/icons/SmilePlus'; + +export const ReactionListEmptyState = () => { + return ( +
+
+ +
+ + + + + + +
+
+
+ ); +}; diff --git a/src/v4/social/components/ReactionList/ReactionListError.tsx b/src/v4/social/components/ReactionList/ReactionListError.tsx new file mode 100644 index 000000000..7510eef0d --- /dev/null +++ b/src/v4/social/components/ReactionList/ReactionListError.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import styles from './ReactionList.module.css'; +import { Typography } from '~/v4/core/components'; +import Redo from '~/v4/icons/Redo'; + +export const ReactionListError = () => { + return ( +
+
+ + + + +
+
+ ); +}; diff --git a/src/v4/social/components/ReactionList/ReactionListLoadingState.tsx b/src/v4/social/components/ReactionList/ReactionListLoadingState.tsx new file mode 100644 index 000000000..57fd91356 --- /dev/null +++ b/src/v4/social/components/ReactionList/ReactionListLoadingState.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import styles from './ReactionList.module.css'; +import ReactionListSkeleton from '~/v4/icons/ReactionListSkeleton'; +import clsx from 'clsx'; + +export const ReactionListLoadingState = () => { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ ); +}; diff --git a/src/v4/social/components/ReactionList/ReactionListPanel.tsx b/src/v4/social/components/ReactionList/ReactionListPanel.tsx new file mode 100644 index 000000000..6bfbe4f99 --- /dev/null +++ b/src/v4/social/components/ReactionList/ReactionListPanel.tsx @@ -0,0 +1,92 @@ +import styles from './ReactionList.module.css'; +import React, { Fragment, useMemo } from 'react'; +import { Avatar, Typography } from '~/v4/core/components'; +import FallbackReaction from '~/v4/icons/FallbackReaction'; +import { ReactionIcon } from '~/v4/social/components/ReactionList/ReactionIcon'; +import { useCustomReaction } from '~/v4/core/providers/CustomReactionProvider'; +import useSDK from '~/core/hooks/useSDK'; +import { FormattedMessage } from 'react-intl'; +import InfiniteScroll from 'react-infinite-scroll-component'; + +export const ReactionListPanel = ({ + filteredReactions, + removeReaction, + hasMore, + loadMore, + isLoading, + currentRef, +}: { + filteredReactions: Amity.Reactor[]; + removeReaction: (reaction: string) => Promise; + hasMore: boolean; + loadMore: () => void; + isLoading: boolean; + currentRef: HTMLDivElement | null; +}) => { + const { currentUserId } = useSDK(); + const { config } = useCustomReaction(); + const reactionList = useMemo(() => config.map(({ name }) => name), [config]); + + if (!currentRef || !filteredReactions) return null; + + return ( +
+ Loading... : null} + dataLength={filteredReactions.length || 0} + style={{ display: 'flex', width: '100%' }} + height={currentRef.clientHeight} + > +
+ {filteredReactions.map((reaction) => { + return ( + +
+
+
+ + + {reaction.user?.displayName} + {currentUserId === reaction.user?.userId && ( + <> +
+
removeReaction(reaction.reactionName)}> + + + +
+ + )} +
+
+ +
+ {reactionList.includes(reaction.reactionName) ? ( + name === reaction.reactionName)! + } + className={styles.reactionIcon} + /> + ) : ( + + )} +
+
+
+
+ ); + })} +
+
+
+ ); +}; diff --git a/src/v4/social/components/ReactionList/styles.module.css b/src/v4/social/components/ReactionList/styles.module.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/v4/social/components/StoryTab/StoryTabCommunity.tsx b/src/v4/social/components/StoryTab/StoryTabCommunity.tsx index d804de851..0e23dccfb 100644 --- a/src/v4/social/components/StoryTab/StoryTabCommunity.tsx +++ b/src/v4/social/components/StoryTab/StoryTabCommunity.tsx @@ -19,6 +19,9 @@ import { StoryTitle, StoryWrapper, } from './styles'; +import { isAdmin, isModerator } from '~/helpers/permissions'; +import { FormattedMessage } from 'react-intl'; +import useUser from '~/core/hooks/useUser'; interface StoryTabCommunityFeedProps { communityId: string; @@ -53,8 +56,13 @@ export const StoryTabCommunityFeed: React.FC = ({ co onClickStory(communityId, 'communityFeed'); }; - const { client } = useSDK(); - const hasStoryPermission = checkStoryPermission(client, communityId); + const { currentUserId, client } = useSDK(); + const user = useUser(currentUserId); + const isGlobalAdmin = isAdmin(user?.roles); + const isCommunityModerator = isModerator(user?.roles); + const hasStoryPermission = + isGlobalAdmin || isCommunityModerator || checkStoryPermission(client, communityId); + const hasStoryRing = stories?.length > 0; const hasUnSeen = stories.some((story) => !story?.isSeen); const uploading = stories.some((story) => story?.syncState === 'syncing'); @@ -92,7 +100,9 @@ export const StoryTabCommunityFeed: React.FC = ({ co {isErrored && } - Story + + + ); diff --git a/src/v4/social/components/ViewStoryPage/index.tsx b/src/v4/social/components/ViewStoryPage/index.tsx index ba3ed181c..3049611e3 100644 --- a/src/v4/social/components/ViewStoryPage/index.tsx +++ b/src/v4/social/components/ViewStoryPage/index.tsx @@ -18,7 +18,7 @@ import { useCustomization } from '~/v4/core/providers/CustomizationProvider'; import { CreateStoryButton } from '../../elements'; import { renderers } from '../../internal-components/StoryViewer/Renderers'; -import { checkStoryPermission } from '~/utils'; + import { AmityDraftStoryPage } from '../../pages'; import { useStoryContext } from '../../providers/StoryProvider'; import { useConfirmContext } from '~/v4/core/providers/ConfirmProvider'; @@ -78,10 +78,7 @@ const StoryViewer = ({ pageId, targetId, duration = 5000, onClose }: StoryViewer const { file, setFile } = useStoryContext(); const [colors, setColors] = useState([]); - const { client } = useSDK(); - const isStoryCreator = stories[currentIndex]?.creator?.userId === currentUserId; - const haveStoryPermission = checkStoryPermission(client, targetId); const confirmDeleteStory = (storyId: string) => { const isLastStory = currentIndex === 0; diff --git a/src/v4/social/elements/HyperLink/HyperLink.module.css b/src/v4/social/elements/HyperLink/HyperLink.module.css index 2698895eb..e16c56d0e 100644 --- a/src/v4/social/elements/HyperLink/HyperLink.module.css +++ b/src/v4/social/elements/HyperLink/HyperLink.module.css @@ -1,7 +1,7 @@ .hyperlink { border: 1px solid var(--asc-color-base-shade4); color: var(--asc-color-secondary-default); - background: rgba(255, 255, 255, 0.2); + background: #ffffffcc; display: inline-flex; align-items: center; padding: var(--asc-spacing-s2) var(--asc-spacing-m1) var(--asc-spacing-s2) var(--asc-spacing-s2); @@ -12,10 +12,28 @@ font-weight: 600; line-height: 1.25rem; letter-spacing: -0.015rem; + max-width: 200px; + border: var(--asc-color-base-shade4); + text-decoration: none; } .hyperlinkIcon { color: var(--asc-color-primary-default); width: 1.5rem; height: 1.5rem; + flex-shrink: 0; +} + +.hyperlinkText { + display: flex; + align-items: center; + max-width: calc(100% - 2rem); + overflow: hidden; + color: var(--asc-color-secondary-shade4); +} + +.text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } diff --git a/src/v4/social/elements/HyperLink/HyperLink.tsx b/src/v4/social/elements/HyperLink/HyperLink.tsx index e107f63ba..e4dff3e56 100644 --- a/src/v4/social/elements/HyperLink/HyperLink.tsx +++ b/src/v4/social/elements/HyperLink/HyperLink.tsx @@ -10,7 +10,9 @@ export const HyperLink: React.FC = ({ href, children, ...rest } return ( - {children} +
+ {children} +
); }; diff --git a/src/v4/social/elements/ShareStoryButton/ShareStoryButton.tsx b/src/v4/social/elements/ShareStoryButton/ShareStoryButton.tsx index 949665925..3b66ac7b8 100644 --- a/src/v4/social/elements/ShareStoryButton/ShareStoryButton.tsx +++ b/src/v4/social/elements/ShareStoryButton/ShareStoryButton.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import Avatar from '~/core/components/Avatar'; import { useIntl } from 'react-intl'; import { isValidHttpUrl } from '~/utils'; import { useCustomization } from '~/v4/core/providers/CustomizationProvider'; import { Icon } from '~/v4/core/components/Icon'; +import { backgroundImage as communityBackgroundImage } from '~/icons/Community'; import styles from './ShareStoryButton.module.css'; +import { Avatar } from '~/v4/core/components'; interface ShareButtonProps { onClick: () => void; @@ -41,7 +42,12 @@ export const ShareStoryButton = ({ onClick={onClick} > {!elementConfig?.hide_avatar && ( - + )} {formatMessage({ id: 'storyDraft.button.shareStory' })} {isRemoteImage ? ( diff --git a/src/v4/social/internal-components/Comment/index.tsx b/src/v4/social/internal-components/Comment/index.tsx index b94f55cd5..4f12418fe 100644 --- a/src/v4/social/internal-components/Comment/index.tsx +++ b/src/v4/social/internal-components/Comment/index.tsx @@ -12,6 +12,7 @@ import { Metadata, parseMentionsMarkup, } from '~/v4/helpers/utils'; + import useSDK from '~/core/hooks/useSDK'; import useUser from '~/core/hooks/useUser'; import { CommentRepository, ReactionRepository } from '@amityco/ts-sdk'; diff --git a/src/v4/social/internal-components/StoryViewer/Renderers/Image.tsx b/src/v4/social/internal-components/StoryViewer/Renderers/Image.tsx index 7c7c314f3..55612a5cd 100644 --- a/src/v4/social/internal-components/StoryViewer/Renderers/Image.tsx +++ b/src/v4/social/internal-components/StoryViewer/Renderers/Image.tsx @@ -25,6 +25,7 @@ import useUser from '~/core/hooks/useUser'; import { BottomSheet } from '~/v4/core/components/BottomSheet'; import { Typography } from '~/v4/core/components'; import { Button } from '~/v4/core/components/Button'; +import { isAdmin, isModerator } from '~/helpers/permissions'; export const renderer: CustomRenderer = ({ story, action, config }) => { const { formatMessage } = useIntl(); @@ -75,7 +76,10 @@ export const renderer: CustomRenderer = ({ story, action, config }) => { const isOfficial = community?.isOfficial || false; const isCreator = creator?.userId === user?.userId; - const haveStoryPermission = checkStoryPermission(client, community?.communityId); + const isGlobalAdmin = isAdmin(user?.roles); + const isCommunityModerator = isModerator(user?.roles); + const haveStoryPermission = + isGlobalAdmin || isCommunityModerator || checkStoryPermission(client, community?.communityId); const computedStyles = { ...rendererStyles.storyContent, @@ -263,7 +267,10 @@ export const renderer: CustomRenderer = ({ story, action, config }) => { onClick={() => story.analytics.markLinkAsClicked()} > - {story.items?.[0]?.data?.customText || story.items?.[0].data.url} + + {story.items[0]?.data?.customText || + story.items[0].data.url.replace(/^https?:\/\//, '')} +
diff --git a/src/v4/social/internal-components/StoryViewer/Renderers/Video.tsx b/src/v4/social/internal-components/StoryViewer/Renderers/Video.tsx index b3efb0468..f2f7bafa7 100644 --- a/src/v4/social/internal-components/StoryViewer/Renderers/Video.tsx +++ b/src/v4/social/internal-components/StoryViewer/Renderers/Video.tsx @@ -4,7 +4,7 @@ import { Tester } from 'react-insta-stories/dist/interfaces'; import useImage from '~/core/hooks/useImage'; import { checkStoryPermission, formatTimeAgo } from '~/utils'; import { useNavigation } from '~/social/providers/NavigationProvider'; -import { FormattedMessage, useIntl } from 'react-intl'; +import { useIntl } from 'react-intl'; import useSDK from '~/core/hooks/useSDK'; @@ -24,6 +24,7 @@ import { motion, PanInfo, useAnimationControls } from 'framer-motion'; import rendererStyles from './Renderers.module.css'; import useUser from '~/core/hooks/useUser'; +import { isAdmin, isModerator } from '~/helpers/permissions'; export const renderer: CustomRenderer = ({ story, action, config, messageHandler }) => { const { formatMessage } = useIntl(); @@ -61,8 +62,6 @@ export const renderer: CustomRenderer = ({ story, action, config, messageHandler imageSize: 'small', }); - const isOfficial = community?.isOfficial || false; - const heading =
{community?.displayName}
; const subheading = createdAt && creator?.displayName ? ( @@ -74,8 +73,12 @@ export const renderer: CustomRenderer = ({ story, action, config, messageHandler '' ); - const haveStoryPermission = checkStoryPermission(client, community?.communityId); + const isOfficial = community?.isOfficial || false; const isCreator = creator?.userId === user?.userId; + const isGlobalAdmin = isAdmin(user?.roles); + const isCommunityModerator = isModerator(user?.roles); + const haveStoryPermission = + isGlobalAdmin || isCommunityModerator || checkStoryPermission(client, community?.communityId); const computedStyles = { ...storyContentStyles, @@ -235,7 +238,7 @@ export const renderer: CustomRenderer = ({ story, action, config, messageHandler data-qa-anchor="video_view" ref={vid} style={computedStyles} - src={story?.url || undefined} + src={story?.videoData?.fileUrl || story?.videoData?.videoUrl?.original} controls={false} onLoadedData={videoLoaded} playsInline @@ -282,11 +285,11 @@ export const renderer: CustomRenderer = ({ story, action, config, messageHandler detent="full-height" > {story.items?.[0]?.data?.url && ( @@ -302,7 +305,10 @@ export const renderer: CustomRenderer = ({ story, action, config, messageHandler onClick={() => story.analytics.markLinkAsClicked()} > - {story.items?.[0].data?.customText || story.items?.[0].data.url} + + {story.items[0]?.data?.customText || + story.items[0].data.url.replace(/^https?:\/\//, '')} +
diff --git a/src/v4/social/internal-components/StoryViewer/Renderers/Wrappers/Header/Header.module.css b/src/v4/social/internal-components/StoryViewer/Renderers/Wrappers/Header/Header.module.css index 8b33bbb21..8294adf3d 100644 --- a/src/v4/social/internal-components/StoryViewer/Renderers/Wrappers/Header/Header.module.css +++ b/src/v4/social/internal-components/StoryViewer/Renderers/Wrappers/Header/Header.module.css @@ -1,219 +1,219 @@ .iconButton { - position: absolute; - width: 2rem; - height: 2rem; - background-color: rgba(var(--asc-color-black), 0.5); - border-radius: 50%; - border: none; - top: 6rem; - left: 1.25rem; - z-index: 9999; - cursor: pointer; -} - -.rendererContainer { - position: relative; - width: 100%; - height: 100%; -} - -.storyVideo { - width: 100%; - height: 100%; - object-fit: contain; -} - -.muteCircleIcon { - width: 100%; - height: 100%; -} - -.unmuteCircleIcon { - width: 100%; - height: 100%; -} - -.loadingOverlay { - position: absolute; - left: 0; - top: 0; - background: rgba(var(--asc-color-black), 0.9); - z-index: 9; - display: flex; - justify-content: center; - align-items: center; - color: var(--asc-color-base-shade3); -} - -.storyImage { - width: auto; - max-width: 100%; - max-height: 100%; - margin: auto; -} - -.playStoryButton { - color: var(--asc-color-white); - cursor: pointer; -} - -.pauseStoryButton { - color: var(--asc-color-white); - cursor: pointer; -} - -.closeButton { - color: var(--asc-color-white); - width: 1.25rem; - height: 1.25rem; - cursor: pointer; -} - -.verifiedBadge { - fill: var(--asc-color-white); -} - -.dotsButton { - width: 1.5rem; - height: 1.5rem; - cursor: pointer; - color: var(--asc-color-white); -} - -.viewStoryInfoContainer { - display: flex; - flex-direction: column; - justify-content: flex-start; - width: 100%; -} - -.viewStoryCompostBarContainer { - width: 100%; - display: flex; - position: absolute; - justify-content: space-between; - align-items: center; - height: 3.5rem; - padding: 0.75rem; - background-color: var(--asc-color-black); - bottom: 0; -} - -.viewStoryCompostBarViewIconContainer { - display: flex; - align-items: center; - justify-content: space-between; - color: var(--asc-color-white); - gap: 0.25rem; -} - -.viewStoryCompostBarEngagementContainer { - display: flex; - align-items: center; - justify-content: space-between; - color: var(--asc-color-white); - gap: 0.75rem; -} - -.viewStoryCompostBarEngagementIconContainer { - display: flex; - align-items: center; - justify-content: space-between; - color: var(--asc-color-white); - gap: 0.25rem; - border-radius: 50%; - padding: 0.5rem 0.625rem; - background-color: var(--asc-color-secondary-default); -} - -.storyContent { - flex: 1; -} - -.header { - height: 5rem; - padding: 0.75rem 1rem 0.625rem 1rem; -} - -.viewStoryContainer { - position: relative; - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - background-color: var(--asc-color-black); -} - -.viewStoryHeaderContainer { - z-index: 99999; - position: absolute; - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - padding: 1.5rem 1rem 0.625rem 1rem; - gap: 0.5rem; -} - -.avatarContainer { - position: relative; - width: 2.5rem; - height: 2.5rem; - border-radius: 50%; - flex-shrink: 0; -} - -.addStoryButton { - position: absolute; - bottom: 0; - right: 0; -} - -.addStoryButton:hover { - cursor: pointer; -} - -.viewStoryHeaderListActionsContainer { - display: flex; - gap: 1.25rem; - justify-content: flex-end; - align-items: center; -} - -.viewStoryHeadingInfoContainer { - display: inline-flex; - justify-content: space-between; - gap: 0.75rem; - align-items: center; -} - -.viewStoryHeading { - cursor: pointer; - display: flex; - gap: 0.25rem; - color: var(--asc-color-white); - font-size: var(--asc-text-font-size-sm); - font-style: normal; - font-weight: var(--asc-text-font-weight-bold); - line-height: var(--asc-line-height-md); - letter-spacing: -0.24px; - margin-right: 0.25rem; - align-items: center; -} - -.viewStoryHeadingTitle { - width: auto; - max-width: 11.688rem; -} - -.viewStorySubHeading { - display: inline-flex; - gap: 0.25rem; - margin-bottom: 0.25rem; - color: var(--asc-color-white); - font-size: var(--asc-text-font-size-xs); - font-style: normal; - font-weight: var(--asc-text-font-weight-normal); - line-height: var(--asc-line-height-md); - letter-spacing: -0.1px; -} \ No newline at end of file + position: absolute; + width: 2rem; + height: 2rem; + background-color: rgba(var(--asc-color-black), 0.5); + border-radius: 50%; + border: none; + top: 6rem; + left: 1.25rem; + z-index: 9999; + cursor: pointer; + } + + .rendererContainer { + position: relative; + width: 100%; + height: 100%; + } + + .storyVideo { + width: 100%; + height: 100%; + object-fit: contain; + } + + .muteCircleIcon { + width: 100%; + height: 100%; + } + + .unmuteCircleIcon { + width: 100%; + height: 100%; + } + + .loadingOverlay { + position: absolute; + left: 0; + top: 0; + background: rgba(var(--asc-color-black), 0.9); + z-index: 9; + display: flex; + justify-content: center; + align-items: center; + color: var(--asc-color-base-shade3); + } + + .storyImage { + width: auto; + max-width: 100%; + max-height: 100%; + margin: auto; + } + + .playStoryButton { + color: var(--asc-color-white); + cursor: pointer; + } + + .pauseStoryButton { + color: var(--asc-color-white); + cursor: pointer; + } + + .closeButton { + color: var(--asc-color-white); + width: 1.25rem; + height: 1.25rem; + cursor: pointer; + } + + .verifiedBadge { + fill: var(--asc-color-white); + } + + .dotsButton { + width: 1.5rem; + height: 1.5rem; + cursor: pointer; + color: var(--asc-color-white); + } + + .viewStoryInfoContainer { + display: flex; + flex-direction: column; + justify-content: flex-start; + width: 100%; + } + + .viewStoryCompostBarContainer { + width: 100%; + display: flex; + position: absolute; + justify-content: space-between; + align-items: center; + height: 3.5rem; + padding: 0.75rem; + background-color: var(--asc-color-black); + bottom: 0; + } + + .viewStoryCompostBarViewIconContainer { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--asc-color-white); + gap: 0.25rem; + } + + .viewStoryCompostBarEngagementContainer { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--asc-color-white); + gap: 0.75rem; + } + + .viewStoryCompostBarEngagementIconContainer { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--asc-color-white); + gap: 0.25rem; + border-radius: 50%; + padding: 0.5rem 0.625rem; + background-color: var(--asc-color-secondary-default); + } + + .storyContent { + flex: 1; + } + + .header { + height: 5rem; + padding: 0.75rem 1rem 0.625rem 1rem; + } + + .viewStoryContainer { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background-color: var(--asc-color-black); + } + + .viewStoryHeaderContainer { + z-index: 99999; + position: absolute; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 1rem 0.625rem 1rem; + gap: 0.5rem; + } + + .avatarContainer { + position: relative; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + flex-shrink: 0; + } + + .addStoryButton { + position: absolute; + bottom: 0; + right: 0; + } + + .addStoryButton:hover { + cursor: pointer; + } + + .viewStoryHeaderListActionsContainer { + display: flex; + gap: 1.25rem; + justify-content: flex-end; + align-items: center; + } + + .viewStoryHeadingInfoContainer { + display: inline-flex; + justify-content: space-between; + gap: 0.75rem; + align-items: center; + } + + .viewStoryHeading { + cursor: pointer; + display: flex; + gap: 0.25rem; + color: var(--asc-color-white); + font-size: var(--asc-text-font-size-sm); + font-style: normal; + font-weight: var(--asc-text-font-weight-bold); + line-height: var(--asc-line-height-md); + letter-spacing: -0.24px; + margin-right: 0.25rem; + align-items: center; + } + + .viewStoryHeadingTitle { + width: auto; + max-width: 11.688rem; + } + + .viewStorySubHeading { + display: inline-flex; + gap: 0.25rem; + margin-bottom: 0.25rem; + color: var(--asc-color-white); + font-size: var(--asc-text-font-size-xs); + font-style: normal; + font-weight: var(--asc-text-font-weight-normal); + line-height: var(--asc-line-height-md); + letter-spacing: -0.1px; + } \ No newline at end of file diff --git a/src/v4/social/pages/DraftsPage/DraftsPage.tsx b/src/v4/social/pages/DraftsPage/DraftsPage.tsx index 6e1f562fa..70163b802 100644 --- a/src/v4/social/pages/DraftsPage/DraftsPage.tsx +++ b/src/v4/social/pages/DraftsPage/DraftsPage.tsx @@ -2,9 +2,6 @@ import React, { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; import { extractColors } from 'extract-colors'; import { readFileAsync } from '~/helpers'; -import useUser from '~/core/hooks/useUser'; -import useSDK from '~/core/hooks/useSDK'; -import useImage from '~/core/hooks/useImage'; import styles from './DraftsPage.module.css'; import { SubmitHandler } from 'react-hook-form'; @@ -27,6 +24,7 @@ import { useNotifications } from '~/v4/core/providers/NotificationProvider'; import { useNavigation } from '~/social/providers/NavigationProvider'; import { PageTypes } from '~/social/constants'; import { BaseVideoPreview } from '../../internal-components/VideoPreview'; +import { useCommunityInfo } from '~/social/components/CommunityInfo/hooks'; type AmityStoryMediaType = { type: 'image'; url: string } | { type: 'video'; url: string }; @@ -60,9 +58,7 @@ const AmityDraftStoryPage = ({ targetId, targetType, mediaType }: AmityDraftStor setIsHyperLinkBottomSheetOpen(false); }; - const { currentUserId } = useSDK(); - const user = useUser(currentUserId); - const creatorAvatar = useImage({ imageSize: 'small', fileId: user?.avatarFileId }); + const community = useCommunityInfo(targetId); const { formatMessage } = useIntl(); @@ -263,9 +259,7 @@ const AmityDraftStoryPage = ({ targetId, targetType, mediaType }: AmityDraftStor target="_blank" rel="noopener noreferrer" > - - {hyperLink[0]?.data?.customText || hyperLink[0].data.url} - + {hyperLink[0]?.data?.customText || hyperLink[0].data.url.replace(/^https?:\/\//, '')}
)} @@ -286,7 +280,7 @@ const AmityDraftStoryPage = ({ targetId, targetType, mediaType }: AmityDraftStor onClick={() => onCreateStory(file, imageMode, {}, hyperLink.length > 0 ? hyperLink : []) } - avatar={creatorAvatar} + avatar={community.avatarFileUrl} />
diff --git a/src/v4/social/pages/StoryPage/CommunityFeedStory.tsx b/src/v4/social/pages/StoryPage/CommunityFeedStory.tsx index f5addb5f4..159309e4f 100644 --- a/src/v4/social/pages/StoryPage/CommunityFeedStory.tsx +++ b/src/v4/social/pages/StoryPage/CommunityFeedStory.tsx @@ -81,7 +81,7 @@ export const CommunityFeedStory = ({ communityId }: CommunityFeedStoryProps) => const [colors, setColors] = useState([]); const isStoryCreator = stories[currentIndex]?.creator?.userId === currentUserId; - const haveStoryPermission = checkStoryPermission(client, communityId); + const isModerator = checkStoryPermission(client, communityId); const confirmDeleteStory = (storyId: string) => { const isLastStory = currentIndex === 0; @@ -172,7 +172,7 @@ export const CommunityFeedStory = ({ communityId }: CommunityFeedStoryProps) => url, type: isImage ? 'image' : 'video', actions: [ - isStoryCreator || haveStoryPermission + isStoryCreator || isModerator ? { name: 'delete', action: () => deleteStory(story?.storyId as string), diff --git a/src/v4/social/pages/StoryPage/GlobalFeedStory.tsx b/src/v4/social/pages/StoryPage/GlobalFeedStory.tsx index 825226238..f5109aa64 100644 --- a/src/v4/social/pages/StoryPage/GlobalFeedStory.tsx +++ b/src/v4/social/pages/StoryPage/GlobalFeedStory.tsx @@ -80,7 +80,7 @@ export const GlobalFeedStory: React.FC = () => { const [colors, setColors] = useState([]); const isStoryCreator = stories[currentIndex]?.creator?.userId === currentUserId; - const haveStoryPermission = checkStoryPermission(client, stories[currentIndex]?.targetId); + const isModerator = checkStoryPermission(client, stories[currentIndex]?.targetId); const confirmDeleteStory = (storyId: string) => { const isLastStory = currentIndex === 0; @@ -173,7 +173,7 @@ export const GlobalFeedStory: React.FC = () => { url, type: isImage ? 'image' : 'video', actions: [ - isStoryCreator || haveStoryPermission + isStoryCreator || isModerator ? { name: 'delete', action: () => deleteStory(story?.storyId as string), diff --git a/src/v4/utils/abbreviateCount.ts b/src/v4/utils/abbreviateCount.ts new file mode 100644 index 000000000..118a6615c --- /dev/null +++ b/src/v4/utils/abbreviateCount.ts @@ -0,0 +1,9 @@ +export const abbreviateCount = (count: number) => { + if (count >= 1000000) { + return (count / 1000000).toFixed(1) + 'M'; + } else if (count >= 1000) { + return (count / 1000).toFixed(1) + 'K'; + } else { + return count; + } +}; diff --git a/src/v4/utils/selectMessageReaction.ts b/src/v4/utils/selectMessageReaction.ts new file mode 100644 index 000000000..c35d4af04 --- /dev/null +++ b/src/v4/utils/selectMessageReaction.ts @@ -0,0 +1,23 @@ +import { ReactionRepository } from '@amityco/ts-sdk'; +import { AmityReactionType } from '~/v4/core/providers/CustomReactionProvider'; + +export const selectMessageReaction = async ({ + reactionName, + message, +}: { + reactionName: AmityReactionType['name']; + message: Amity.Message; +}) => { + const myReactions = message.myReactions || []; + + if (myReactions.includes(reactionName)) { + await ReactionRepository.removeReaction('message', message.messageId, reactionName); + return; + } + + if (myReactions.length > 0) { + await ReactionRepository.removeReaction('message', message.messageId, myReactions[0]); + } + + await ReactionRepository.addReaction('message', message.messageId, reactionName); +}; diff --git a/static/message_reaction_fire.png b/static/message_reaction_fire.png new file mode 100644 index 000000000..143e7c0d9 Binary files /dev/null and b/static/message_reaction_fire.png differ diff --git a/static/message_reaction_grinning.png b/static/message_reaction_grinning.png new file mode 100644 index 000000000..df2871542 Binary files /dev/null and b/static/message_reaction_grinning.png differ diff --git a/static/message_reaction_heart.png b/static/message_reaction_heart.png new file mode 100644 index 000000000..4ded5eb34 Binary files /dev/null and b/static/message_reaction_heart.png differ diff --git a/static/message_reaction_like.png b/static/message_reaction_like.png new file mode 100644 index 000000000..6fc08d1c7 Binary files /dev/null and b/static/message_reaction_like.png differ diff --git a/static/message_reaction_sad.png b/static/message_reaction_sad.png new file mode 100644 index 000000000..9e07b821b Binary files /dev/null and b/static/message_reaction_sad.png differ