diff --git a/.github/workflows/check_do_not_merge.yaml b/.github/workflows/check_do_not_merge.yaml index 0f1d237b4..0a29154ec 100644 --- a/.github/workflows/check_do_not_merge.yaml +++ b/.github/workflows/check_do_not_merge.yaml @@ -12,10 +12,16 @@ on: jobs: ok-to-merge: - if: contains(github.event.pull_request.labels.*.name, 'DO NOT MERGE') == false runs-on: ubuntu-latest steps: + - name: This PR is labeled with do not merge + if: contains(github.event.pull_request.labels.*.name, 'DO NOT MERGE') == true + run: | + echo "This PR cannot be merged" + exit 1 + - name: This PR is not labeled with do not merge + if: contains(github.event.pull_request.labels.*.name, 'DO NOT MERGE') == false run: | echo "This PR can be merged" exit 0 diff --git a/.storybook/main.ts b/.storybook/main.ts index 29eb40d98..b1d8c4716 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,3 +1,6 @@ +import path from 'path'; +import { readdirSync } from 'fs'; +import type { InlineConfig, Plugin } from 'vite'; import type { StorybookConfig } from '@storybook/react-vite'; const config: StorybookConfig = { @@ -9,11 +12,80 @@ const config: StorybookConfig = { '@storybook/addon-controls', '@storybook/addon-viewport', '@storybook/addon-toolbars', - '@storybook/addon-a11y', ], framework: '@storybook/react-vite', staticDirs: ['../static'], + viteFinal: async (config) => { + config.plugins?.push( + assetPlugin(config, { + markup: `[ICONS]`, + exclude: [/.*stories.*/], + assetDir: 'src/v4/icons', + storyFileName: 'icons.stories.tsx', + }), + ); + return config; + }, }; export default config; + +const assetPlugin: ( + config: InlineConfig, + options: { + assetDir: string; + storyFileName: string; + exclude?: Array; + markup: string | RegExp; + }, +) => Plugin = (config, { storyFileName, assetDir, exclude, markup }) => { + return { + enforce: 'pre', + name: 'vite-plugin-v4-icons', + transform(code, id) { + const rootDir = config.root!; + + if (id.includes(storyFileName)) { + let icons = '', + imports = ''; + readdirSync(path.join(rootDir, assetDir)).forEach((file) => { + if (file.match(/.*\.(tsx)/) && exclude?.every((ex) => !file.match(ex))) { + const fileName = file.replace(/.tsx/, ''); + const source = { + relativePath: path.join(assetDir.replace(/.*src\//, ''), fileName), + path: path.join(rootDir, assetDir, file), + }; + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const exportedAssets = require(source.path!); + const entries = Object.entries(exportedAssets); + + entries.map(([key, _]) => { + const componentName = key === 'default' ? fileName : key; + imports += + key == 'default' + ? `import ${fileName} from "src/${source?.relativePath}";\n` + : `import {${key}} from "src/${source?.relativePath}";\n`; + icons += ` + + `; + }); + } + }); + + code = imports + code.replace(markup, icons); + } + return code; + }, + }; +}; diff --git a/CHANGELOG.md b/CHANGELOG.md index cc98e2c56..25e9f0917 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. +### 3.10.3 (2024-10-18) + ### 3.10.2 (2024-09-12) diff --git a/package.json b/package.json index 2243330d9..4582fd7aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@amityco/ui-kit-open-source", - "version": "3.10.2", + "version": "3.10.3", "engines": { "node": ">=16", "pnpm": ">=8" diff --git a/src/core/components/Modal/styles.tsx b/src/core/components/Modal/styles.tsx index 98f3543ec..3e4fed3f3 100644 --- a/src/core/components/Modal/styles.tsx +++ b/src/core/components/Modal/styles.tsx @@ -2,7 +2,7 @@ import React, { ReactNode } from 'react'; import styled, { css } from 'styled-components'; import { Close } from '~/icons'; -export const CloseIcon = styled(Close).attrs<{ icon?: ReactNode }>({ width: 18, height: 18 })` +export const CloseIcon = styled(Close).attrs<{ icon?: ReactNode }>({ width: 20, height: 20 })` padding: 0 6px; cursor: pointer; margin-left: auto; diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index 9165c828f..c74152baa 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -6,8 +6,14 @@ import { } from '@amityco/ts-sdk'; import isEmpty from 'lodash/isEmpty'; -export type Mentioned = { userId: string; length: number; index: number; type: string }; -export type Mentionees = Parameters[1]['mentionees']; +export type Mentioned = { + userId: string; + length: number; + index: number; + type: string; + displayName: string; +}; +export type Mentionees = Amity.UserMention[]; export type Metadata = { mentioned?: Mentioned[]; }; @@ -116,6 +122,7 @@ export function extractMetadata( length: displayName.length - AT_SIGN_LENGTH, type: 'user', userId: id, + displayName, })), ]; @@ -160,3 +167,26 @@ export function parseMentionsMarkup( export function isNonNullable(value: TValue | undefined | null): value is TValue { return value != null; } + +export function reconstructMentions( + metadata?: Metadata, + mentionees?: Mentionees, +): { plainTextIndex: number; id: string; display: string }[] { + if (!metadata?.mentioned || mentionees?.length === 0) { + return []; + } + + const userIds = mentionees?.find((mentionee) => mentionee.type === 'user')?.userIds || []; + + return metadata?.mentioned?.map((mention, index) => { + const id = userIds[index]; + const displayName = mention.displayName; + const display = '@' + (displayName ?? id); + + return { + plainTextIndex: mention.index, + id, + display, + }; + }); +} diff --git a/src/social/components/CommunityForm/EditCommunityForm.tsx b/src/social/components/CommunityForm/EditCommunityForm.tsx index 177d333bc..5030fc628 100644 --- a/src/social/components/CommunityForm/EditCommunityForm.tsx +++ b/src/social/components/CommunityForm/EditCommunityForm.tsx @@ -96,6 +96,14 @@ const EditCommunityForm = ({ await onSubmit?.({ ...data, avatarFileId: data.avatarFileId || undefined }); notification.success({ content: }); + } catch (error) { + console.log('error', error); + if (error instanceof Error) { + if (error.message.indexOf(':') > -1) { + const [, errorMessage] = error.message.split(':'); + notification.error({ content: errorMessage }); + } + } } finally { setSubmitting(false); } diff --git a/src/social/components/SideSectionMyCommunity/index.tsx b/src/social/components/SideSectionMyCommunity/index.tsx index a8ebdeb54..2d975c4c7 100644 --- a/src/social/components/SideSectionMyCommunity/index.tsx +++ b/src/social/components/SideSectionMyCommunity/index.tsx @@ -23,7 +23,6 @@ const SideSectionMyCommunity = ({ className, activeCommunity }: SideSectionMyCom const open = () => setIsOpen(true); const close = (communityId?: string) => { - console.log('communityId', communityId); setIsOpen(false); communityId && onCommunityCreated(communityId); }; diff --git a/src/social/components/UserHeader/UIUserHeader.tsx b/src/social/components/UserHeader/UIUserHeader.tsx index 34cefd118..adb43bf92 100644 --- a/src/social/components/UserHeader/UIUserHeader.tsx +++ b/src/social/components/UserHeader/UIUserHeader.tsx @@ -9,6 +9,7 @@ import { UserHeaderTitle, } from './styles'; import { useCustomComponent } from '~/core/providers/CustomComponentsProvider'; +import { BrandBadge } from '~/v4/social/internal-components/BrandBadge/BrandBadge'; interface UIUserHeaderProps { userId?: string | null; @@ -16,6 +17,7 @@ interface UIUserHeaderProps { avatarFileUrl?: string | null; children?: ReactNode; isBanned?: boolean; + isBrand?: boolean; onClick?: (userId: string) => void; } @@ -26,6 +28,7 @@ const UIUserHeader = ({ children, onClick, isBanned, + isBrand, }: UIUserHeaderProps) => { const onClickUser = () => userId && onClick?.(userId); return ( @@ -36,7 +39,7 @@ const UIUserHeader = ({ onClick={onClickUser} /> -
{displayName}
{isBanned && } +
{displayName}
{isBanned && } {isBrand && }
{children && {children}} diff --git a/src/social/components/UserHeader/index.tsx b/src/social/components/UserHeader/index.tsx index d25230fa5..5392d58f3 100644 --- a/src/social/components/UserHeader/index.tsx +++ b/src/social/components/UserHeader/index.tsx @@ -22,6 +22,7 @@ const UserHeader = ({ userId, children, onClick, isBanned = false }: UserHeaderP displayName={user?.displayName} avatarFileUrl={avatarFileUrl} isBanned={isBanned} + isBrand={user?.isBrand} onClick={onClick} > {children} diff --git a/src/social/components/UserHeader/styles.tsx b/src/social/components/UserHeader/styles.tsx index 7ea4a01f7..067e0cb14 100644 --- a/src/social/components/UserHeader/styles.tsx +++ b/src/social/components/UserHeader/styles.tsx @@ -27,6 +27,7 @@ export const UserHeaderTitle = styled.div` display: flex; min-width: 0; align-items: center; + gap: 8px; > div { text-overflow: ellipsis; diff --git a/src/social/components/post/Editor/index.tsx b/src/social/components/post/Editor/index.tsx index 3e85556c2..e02cd6b53 100644 --- a/src/social/components/post/Editor/index.tsx +++ b/src/social/components/post/Editor/index.tsx @@ -6,20 +6,19 @@ import { PostEditorContainer, Footer, ContentContainer, PostButton } from './sty import { usePostEditor } from './usePostEditor'; interface PostEditorProps { - postId?: string; + post: Amity.Post; onSave: () => void; className?: string; placeholder?: string; } const PostEditor = ({ - postId, + post, placeholder = "What's going on...", className, onSave, }: PostEditorProps) => { const { - post, markup, onChange, queryMentionees, @@ -30,7 +29,7 @@ const PostEditor = ({ isEmpty, handleSave, } = usePostEditor({ - postId, + post, onSave, }); diff --git a/src/social/components/post/Editor/usePostEditor.ts b/src/social/components/post/Editor/usePostEditor.ts index 7bd75a153..af05de098 100644 --- a/src/social/components/post/Editor/usePostEditor.ts +++ b/src/social/components/post/Editor/usePostEditor.ts @@ -1,12 +1,11 @@ import { PostRepository } from '@amityco/ts-sdk'; import { useMemo, useState } from 'react'; -import { parseMentionsMarkup } from '~/helpers/utils'; +import { parseMentionsMarkup, reconstructMentions } from '~/helpers/utils'; import usePost from '~/social/hooks/usePost'; import usePostByIds from '~/social/hooks/usePostByIds'; import useSocialMention from '~/social/hooks/useSocialMention'; -export const usePostEditor = ({ postId, onSave }: { postId?: string; onSave: () => void }) => { - const post = usePost(postId); +export const usePostEditor = ({ post, onSave }: { post: Amity.Post; onSave: () => void }) => { const initialChildrenPosts = usePostByIds(post?.children); const { text, markup, mentions, mentionees, metadata, clearAll, onChange, queryMentionees } = useSocialMention({ @@ -18,6 +17,7 @@ export const usePostEditor = ({ postId, onSave }: { postId?: string; onSave: () typeof post?.data === 'string' ? post?.data : (post?.data as Amity.ContentDataText)?.text, post?.metadata, ), + remoteMentions: reconstructMentions(post?.metadata, post?.mentionees), }); // List of the children posts removed - these will be deleted on save. diff --git a/src/social/components/post/Post/DefaultPostRenderer.tsx b/src/social/components/post/Post/DefaultPostRenderer.tsx index 3b29c0093..71619d650 100644 --- a/src/social/components/post/Post/DefaultPostRenderer.tsx +++ b/src/social/components/post/Post/DefaultPostRenderer.tsx @@ -320,13 +320,13 @@ const DefaultPostRenderer = (props: DefaultPostRendererProps) => { )} - {isEditing && ( + {isEditing && post && ( - + )} diff --git a/src/social/hooks/useSocialMention.ts b/src/social/hooks/useSocialMention.ts index c107a6f4f..25f2fe3fb 100644 --- a/src/social/hooks/useSocialMention.ts +++ b/src/social/hooks/useSocialMention.ts @@ -8,6 +8,7 @@ interface UseSocialMentionProps { targetType?: 'user' | 'community' | string; remoteText?: string; remoteMarkup?: string; + remoteMentions?: { plainTextIndex: number; id: string; display: string }[]; } export type QueryMentioneesFnType = (query?: string) => Promise< @@ -24,15 +25,15 @@ const useSocialMention = ({ targetType, remoteText, remoteMarkup, + remoteMentions = [], }: UseSocialMentionProps) => { const isCommunityFeed = targetType === 'community'; const community = useCommunity(targetId); const [text, setText] = useState(remoteText ?? ''); const [markup, setMarkup] = useState(remoteMarkup ?? remoteText); - const [mentions, setMentions] = useState< - { plainTextIndex: number; id: string; display: string }[] - >([]); + const [mentions, setMentions] = + useState<{ plainTextIndex: number; id: string; display: string }[]>(remoteMentions); useEffect(() => { setText(remoteText || ''); diff --git a/src/social/pages/CommunityEdit/index.tsx b/src/social/pages/CommunityEdit/index.tsx index 98733bd07..a2e3e21db 100644 --- a/src/social/pages/CommunityEdit/index.tsx +++ b/src/social/pages/CommunityEdit/index.tsx @@ -33,11 +33,11 @@ const CommunityEditPage = ({ useEffect(() => setActiveTab(tab), [tab]); - const { onClickCommunity } = useNavigation(); + const { onBack } = useNavigation(); const community = useCommunity(communityId); const avatarFileUrl = useImage({ fileId: community?.avatarFileId, imageSize: 'medium' }); - const handleReturnToCommunity = () => communityId && onClickCommunity(communityId); + const handleReturnToCommunity = () => communityId && onBack(); const handleEditCommunity = async ( data: Parameters[1], diff --git a/src/v4/chat/components/MessageComposer/MessageComposer.tsx b/src/v4/chat/components/MessageComposer/MessageComposer.tsx index 9d92de65b..2bad850a4 100644 --- a/src/v4/chat/components/MessageComposer/MessageComposer.tsx +++ b/src/v4/chat/components/MessageComposer/MessageComposer.tsx @@ -23,7 +23,7 @@ import { MentionPlugin } from '~/v4/social/internal-components/Lexical/plugins/M import { useMutation } from '@tanstack/react-query'; import { - editorStateToText, + editorToText, getEditorConfig, MentionData, } from '~/v4/social/internal-components/Lexical/utils'; @@ -148,7 +148,7 @@ export const MessageComposer = ({ if (!channel) return; if (!editorRef.current) return; - const { mentioned, mentionees, text } = editorStateToText(editorRef.current); + const { mentioned, mentionees, text } = editorToText(editorRef.current); if (text?.trim().length === 0) return; diff --git a/src/v4/core/components/Avatar/Avatar.tsx b/src/v4/core/components/Avatar/Avatar.tsx index 6c49890aa..03bd71083 100644 --- a/src/v4/core/components/Avatar/Avatar.tsx +++ b/src/v4/core/components/Avatar/Avatar.tsx @@ -1,15 +1,32 @@ import React from 'react'; import styles from './Avatar.module.css'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; export interface AvatarProps { + pageId?: string; + componentId?: string; avatarUrl?: string | null; defaultImage: React.ReactNode; onClick?: () => void; } -export const Avatar = ({ avatarUrl, defaultImage, onClick }: AvatarProps) => { +export const Avatar = ({ + pageId = '*', + componentId = '*', + avatarUrl, + defaultImage, + onClick, +}: AvatarProps) => { + const elementId = 'avatar'; + + const { accessibilityId } = useAmityElement({ pageId, componentId, elementId }); return ( -
+
{avatarUrl ? ( // TODO: add handler if cannot fetch the url Avatar diff --git a/src/v4/core/components/ConfirmModal/index.tsx b/src/v4/core/components/ConfirmModal/index.tsx index 0d39ae041..d3e0fff97 100644 --- a/src/v4/core/components/ConfirmModal/index.tsx +++ b/src/v4/core/components/ConfirmModal/index.tsx @@ -18,7 +18,6 @@ interface ConfirmProps extends ConfirmType { const Confirm = ({ pageId = '*', componentId = '*', - elementId = '*', className, title, content, @@ -28,6 +27,7 @@ const Confirm = ({ onCancel, type = 'confirm', }: ConfirmProps) => { + const elementId = 'confirm_modal'; const { accessibilityId, themeStyles } = useAmityElement({ pageId, componentId, elementId }); return ( diff --git a/src/v4/core/components/Modal/index.tsx b/src/v4/core/components/Modal/index.tsx index 925f356e9..c7515a985 100644 --- a/src/v4/core/components/Modal/index.tsx +++ b/src/v4/core/components/Modal/index.tsx @@ -45,10 +45,25 @@ const Modal = ({ ref={modalRef} tabIndex={0} > - {onCancel && } - {title &&
{title}
} + {onCancel && ( + + )} + {title && ( +
+ {title} +
+ )} -
{children}
+
+ {children} +
{footer &&
{footer}
}
diff --git a/src/v4/core/components/Typography/Typography.tsx b/src/v4/core/components/Typography/Typography.tsx index 60e8e18f0..80240d72c 100644 --- a/src/v4/core/components/Typography/Typography.tsx +++ b/src/v4/core/components/Typography/Typography.tsx @@ -2,110 +2,160 @@ import React from 'react'; import clsx from 'clsx'; import typography from '~/v4/styles/typography.module.css'; -interface TypographyProps { - children: React.ReactNode; - className?: string; - style?: React.CSSProperties; -} - -const Typography: React.FC & { - Heading: React.FC; - Title: React.FC; - Subtitle: React.FC; - Body: React.FC; - BodyBold: React.FC; - Caption: React.FC; - CaptionBold: React.FC; -} = ({ children, className = '', style, ...rest }) => { - return ( -
- {children} -
- ); -}; +type TypographyRenderer = ({ typoClassName }: { typoClassName: string }) => JSX.Element; -Typography.Heading = ({ children, className = '', style, ...rest }) => { - return ( -

- {children} -

- ); -}; +type TypographyProps = + | { + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; + } + | { renderer: TypographyRenderer }; -Typography.Title = ({ children, className = '', style, ...rest }) => { - return ( -

- {children} -

- ); +const isRendererProp = (props: TypographyProps): props is { renderer: TypographyRenderer } => { + return (props as { renderer: TypographyRenderer }).renderer !== undefined; }; -Typography.Subtitle = ({ children, className = '', style, ...rest }) => { - return ( -

- {children} -

- ); -}; +export const Typography = { + Heading: (props: TypographyProps) => { + if (isRendererProp(props)) { + return props.renderer({ + typoClassName: clsx(typography['typography'], typography['typography-headings']), + }); + } -Typography.Body = ({ children, className = '', style, ...rest }) => { - return ( - - {children} - - ); -}; + const { children, className, style, ...rest } = props; -Typography.BodyBold = ({ children, className = '', style, ...rest }) => { - return ( - - {children} - - ); -}; + return ( +

+ {children} +

+ ); + }, -Typography.Caption = ({ children, className = '', style, ...rest }) => { - return ( - - {children} - - ); -}; + Title: (props: TypographyProps) => { + if (isRendererProp(props)) { + return props.renderer({ + typoClassName: clsx(typography['typography'], typography['typography-titles']), + }); + } + + const { children, className, style, ...rest } = props; + + return ( +

+ {children} +

+ ); + }, + + Subtitle: (props: TypographyProps) => { + if (isRendererProp(props)) { + return props.renderer({ + typoClassName: clsx(typography['typography'], typography['typography-sub-title']), + }); + } + + const { children, className, style, ...rest } = props; + + return ( +

+ {children} +

+ ); + }, + + Body: (props: TypographyProps) => { + if (isRendererProp(props)) { + return props.renderer({ + typoClassName: clsx(typography['typography'], typography['typography-body']), + }); + } + + const { children, className, style, ...rest } = props; + + return ( + + {children} + + ); + }, + + BodyBold: (props: TypographyProps) => { + if (isRendererProp(props)) { + return props.renderer({ + typoClassName: clsx(typography['typography'], typography['typography-body-bold']), + }); + } + + const { children, className, style, ...rest } = props; + + return ( + + {children} + + ); + }, + + Caption: (props: TypographyProps) => { + if (isRendererProp(props)) { + return props.renderer({ + typoClassName: clsx(typography['typography'], typography['typography-caption']), + }); + } + + const { children, className, style, ...rest } = props; + + return ( + + {children} + + ); + }, + + CaptionBold: (props: TypographyProps) => { + if (isRendererProp(props)) { + return props.renderer({ + typoClassName: clsx(typography['typography'], typography['typography-caption-bold']), + }); + } + + const { children, className, style, ...rest } = props; -Typography.CaptionBold = ({ children, className = '', style, ...rest }) => { - return ( - - {children} - - ); + return ( + + {children} + + ); + }, }; export default Typography; diff --git a/src/v4/core/hooks/collections/useCategoriesCollection.ts b/src/v4/core/hooks/collections/useCategoriesCollection.ts new file mode 100644 index 000000000..c5b2de313 --- /dev/null +++ b/src/v4/core/hooks/collections/useCategoriesCollection.ts @@ -0,0 +1,22 @@ +import { CategoryRepository } from '@amityco/ts-sdk'; + +import useLiveCollection from '~/v4/core/hooks/useLiveCollection'; + +export default function useCategoriesCollection({ + query, + enabled, +}: { + query: Amity.CategoryLiveCollection; + enabled?: boolean; +}) { + const { items, ...rest } = useLiveCollection({ + fetcher: CategoryRepository.getCategories, + params: query, + shouldCall: enabled, + }); + + return { + categories: items, + ...rest, + }; +} diff --git a/src/v4/core/hooks/collections/useRecommendedCommunitiesCollection.ts b/src/v4/core/hooks/collections/useRecommendedCommunitiesCollection.ts new file mode 100644 index 000000000..eb74e336e --- /dev/null +++ b/src/v4/core/hooks/collections/useRecommendedCommunitiesCollection.ts @@ -0,0 +1,21 @@ +import { CommunityRepository } from '@amityco/ts-sdk'; +import useLiveCollection from '~/v4/core/hooks/useLiveCollection'; + +export function useRecommendedCommunitiesCollection({ + params, + enabled = true, +}: { + params: Parameters[0]; + enabled?: boolean; +}) { + const { items, ...rest } = useLiveCollection({ + fetcher: CommunityRepository.getRecommendedCommunities, + params, + shouldCall: enabled, + }); + + return { + recommendedCommunities: items, + ...rest, + }; +} diff --git a/src/v4/core/hooks/collections/useTrendingCommunitiesCollection.ts b/src/v4/core/hooks/collections/useTrendingCommunitiesCollection.ts new file mode 100644 index 000000000..114d6ecdf --- /dev/null +++ b/src/v4/core/hooks/collections/useTrendingCommunitiesCollection.ts @@ -0,0 +1,24 @@ +import { CommunityRepository } from '@amityco/ts-sdk'; +import useLiveCollection from '~/v4/core/hooks/useLiveCollection'; + +export function useTrendingCommunitiesCollection({ + params, + enabled, +}: { + params?: Parameters[0]; + enabled?: boolean; +}) { + const { items, ...rest } = useLiveCollection({ + fetcher: CommunityRepository.getTrendingCommunities, + params: { + ...params, + limit: params?.limit || 10, + }, + shouldCall: enabled, + }); + + return { + trendingCommunities: items, + ...rest, + }; +} diff --git a/src/v4/core/hooks/objects/useUser.ts b/src/v4/core/hooks/objects/useUser.ts index 204cb3b78..9ba519902 100644 --- a/src/v4/core/hooks/objects/useUser.ts +++ b/src/v4/core/hooks/objects/useUser.ts @@ -2,11 +2,17 @@ import { UserRepository } from '@amityco/ts-sdk'; import useLiveObject from '~/v4/core/hooks/useLiveObject'; -export const useUser = (userId?: string | null) => { +export const useUser = ({ + userId, + shouldCall = true, +}: { + userId?: string | null; + shouldCall?: boolean; +}) => { const { item, ...rest } = useLiveObject({ fetcher: UserRepository.getUser, params: userId, - shouldCall: !!userId, + shouldCall: !!userId && shouldCall, }); return { diff --git a/src/v4/core/hooks/useCategory.ts b/src/v4/core/hooks/useCategory.ts new file mode 100644 index 000000000..e63f41ffa --- /dev/null +++ b/src/v4/core/hooks/useCategory.ts @@ -0,0 +1,22 @@ +import { CategoryRepository } from '@amityco/ts-sdk'; + +import { useEffect, useState } from 'react'; + +export const useCategory = ({ + categoryId, +}: { + categoryId?: Amity.Category['categoryId'] | null; +}) => { + const [category, setCategory] = useState(null); + + useEffect(() => { + async function run() { + if (categoryId == null) return; + const category = await CategoryRepository.getCategory(categoryId); + setCategory(category.data); + } + run(); + }, [categoryId]); + + return category; +}; diff --git a/src/v4/core/natives/ClickableArea/ClickableArea.module.css b/src/v4/core/natives/ClickableArea/ClickableArea.module.css new file mode 100644 index 000000000..d4ebb84b3 --- /dev/null +++ b/src/v4/core/natives/ClickableArea/ClickableArea.module.css @@ -0,0 +1,5 @@ +.clickableArea { + box-sizing: border-box; + cursor: pointer; + outline: none; +} diff --git a/src/v4/core/natives/ClickableArea/ClickableArea.ts b/src/v4/core/natives/ClickableArea/ClickableArea.ts new file mode 100644 index 000000000..fd098a8f1 --- /dev/null +++ b/src/v4/core/natives/ClickableArea/ClickableArea.ts @@ -0,0 +1,41 @@ +import clsx from 'clsx'; +import React, { ReactNode, useRef, createElement, DOMAttributes } from 'react'; +import { useButton, AriaButtonOptions } from 'react-aria'; +import styles from './ClickableArea.module.css'; + +export type ClickableAreaProps = Omit< + AriaButtonOptions, + 'elementType' +> & { + elementType: 'span' | 'div'; + key?: string; + children: ReactNode; + className?: string; + style?: React.CSSProperties; +}; + +export function ClickableArea({ + key, + className, + style, + ...props +}: ClickableAreaProps): React.DetailedReactHTMLElement< + { + className: string | undefined; + ref: React.MutableRefObject; + key: string | undefined; + } & DOMAttributes, + HTMLButtonElement +> { + const { children } = props; + const ref = useRef(null); + const { buttonProps } = useButton(props, ref); + + const element = createElement( + props.elementType, + { ...buttonProps, className: clsx(styles.clickableArea, className), ref, key, style }, + children, + ); + + return element; +} diff --git a/src/v4/core/natives/ClickableArea/index.ts b/src/v4/core/natives/ClickableArea/index.ts new file mode 100644 index 000000000..24148527b --- /dev/null +++ b/src/v4/core/natives/ClickableArea/index.ts @@ -0,0 +1,5 @@ +export { ClickableArea } from './ClickableArea'; + +import type { ClickableAreaProps } from './ClickableArea'; + +export type { ClickableAreaProps }; diff --git a/src/v4/core/natives/Img/Img.tsx b/src/v4/core/natives/Img/Img.tsx new file mode 100644 index 000000000..34da055f3 --- /dev/null +++ b/src/v4/core/natives/Img/Img.tsx @@ -0,0 +1,22 @@ +import React, { useState } from 'react'; + +interface ImgProps extends React.ImgHTMLAttributes { + fallBackRenderer?: () => JSX.Element | null; +} + +export const Img = ({ fallBackRenderer, src, ...props }: ImgProps) => { + const [isError, setIsError] = useState(false); + + const handleError = (event: React.SyntheticEvent) => { + setIsError(true); + }; + + if (isError || src == undefined) { + if (fallBackRenderer) { + return fallBackRenderer(); + } + return null; + } + + return ; +}; diff --git a/src/v4/core/natives/Img/index.tsx b/src/v4/core/natives/Img/index.tsx new file mode 100644 index 000000000..ce8755777 --- /dev/null +++ b/src/v4/core/natives/Img/index.tsx @@ -0,0 +1 @@ +export { Img } from './Img'; diff --git a/src/v4/core/providers/AmityUIKitProvider.tsx b/src/v4/core/providers/AmityUIKitProvider.tsx index 4fb254718..1f71cb3c6 100644 --- a/src/v4/core/providers/AmityUIKitProvider.tsx +++ b/src/v4/core/providers/AmityUIKitProvider.tsx @@ -1,4 +1,3 @@ -import '~/core/providers/UiKitProvider/inter.css'; import './index.css'; import '~/v4/styles/global.css'; @@ -30,7 +29,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AmityUIKitManager } from '~/v4/core/AmityUIKitManager'; import { ConfirmProvider } from '~/v4/core/providers/ConfirmProvider'; import { ConfirmProvider as LegacyConfirmProvider } from '~/core/providers/ConfirmProvider'; -import { NotificationProvider } from '~/v4/core/providers/NotificationProvider'; +import { NotificationProvider, useNotifications } from '~/v4/core/providers/NotificationProvider'; import { DrawerProvider } from '~/v4/core/providers/DrawerProvider'; import { NotificationProvider as LegacyNotificationProvider } from '~/core/providers/NotificationProvider'; import { CustomReactionProvider } from './CustomReactionProvider'; @@ -38,40 +37,7 @@ import { AdEngineProvider } from './AdEngineProvider'; import { AdEngine } from '~/v4/core/AdEngine'; import { GlobalFeedProvider } from '~/v4/social/providers/GlobalFeedProvider'; -export type AmityUIKitConfig = Config; - -interface AmityUIKitProviderProps { - apiKey: string; - apiRegion: string; - apiEndpoint?: { - http?: string; - mqtt?: string; - }; - userId: string; - displayName: string; - postRendererConfig?: any; - theme?: Record; - children?: React.ReactNode; - socialCommunityCreationButtonVisible?: boolean; - actionHandlers?: { - onChangePage?: (data: { type: string; [x: string]: string | boolean }) => void; - onClickCategory?: (categoryId: string) => void; - onClickCommunity?: (communityId: string) => void; - onClickUser?: (userId: string) => void; - onCommunityCreated?: (communityId: string) => void; - onEditCommunity?: (communityId: string, options?: { tab?: string }) => void; - onEditUser?: (userId: string) => void; - onMessageUser?: (userId: string) => void; - }; - pageBehavior?: PageBehavior; - onConnectionStatusChange?: (state: Amity.SessionStates) => void; - onConnected?: () => void; - onDisconnected?: () => void; - getAuthToken?: () => Promise; - configs?: AmityUIKitConfig; -} - -const AmityUIKitProvider: React.FC = ({ +const InternalComponent = ({ apiKey, apiRegion, apiEndpoint, @@ -86,11 +52,13 @@ const AmityUIKitProvider: React.FC = ({ onDisconnected, getAuthToken, configs, -}) => { +}: AmityUIKitProviderProps) => { const queryClient = new QueryClient(); const [client, setClient] = useState(null); const currentUser = useUser(userId); + const { error } = useNotifications(); + const sdkContextValue = useMemo( () => ({ client, @@ -137,8 +105,13 @@ const AmityUIKitProvider: React.FC = ({ const newClient = AmityUIKitManager.getClient(); setClient(newClient); - } catch (error) { - console.error('Error setting up AmityUIKitManager:', error); + } catch (_error) { + console.error('Error setting up AmityUIKitManager:', _error); + if (_error instanceof Error) { + error({ + content: _error.message, + }); + } } }; @@ -150,59 +123,97 @@ const AmityUIKitProvider: React.FC = ({ return (
- - - - - - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + {children} + + + + + + + + + + + + +
); }; +export type AmityUIKitConfig = Config; + +interface AmityUIKitProviderProps { + apiKey: string; + apiRegion: string; + apiEndpoint?: { + http?: string; + mqtt?: string; + }; + userId: string; + displayName?: string; + postRendererConfig?: any; + theme?: Record; + children?: React.ReactNode; + socialCommunityCreationButtonVisible?: boolean; + actionHandlers?: { + onChangePage?: (data: { type: string; [x: string]: string | boolean }) => void; + onClickCategory?: (categoryId: string) => void; + onClickCommunity?: (communityId: string) => void; + onClickUser?: (userId: string) => void; + onCommunityCreated?: (communityId: string) => void; + onEditCommunity?: (communityId: string, options?: { tab?: string }) => void; + onEditUser?: (userId: string) => void; + onMessageUser?: (userId: string) => void; + }; + pageBehavior?: PageBehavior; + onConnectionStatusChange?: (state: Amity.SessionStates) => void; + onConnected?: () => void; + onDisconnected?: () => void; + getAuthToken?: () => Promise; + configs?: AmityUIKitConfig; +} + +const AmityUIKitProvider: React.FC = (props) => { + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + export default AmityUIKitProvider; diff --git a/src/v4/core/providers/CustomizationProvider.tsx b/src/v4/core/providers/CustomizationProvider.tsx index 2c59cf297..376b51bae 100644 --- a/src/v4/core/providers/CustomizationProvider.tsx +++ b/src/v4/core/providers/CustomizationProvider.tsx @@ -534,6 +534,48 @@ export const defaultConfig: DefaultConfig = { image: 'value', }, 'community_profile_page/post_content/*': {}, + 'social_home_page/explore_community_categories/*': {}, + 'social_home_page/recommended_communities/*': {}, + 'social_home_page/*/explore_empty_image': { + image: 'value', + }, + 'social_home_page/explore_empty/title': { + text: 'Your explore is empty', + }, + 'social_home_page/explore_empty/description': { + text: 'Find community or create your own', + }, + 'social_home_page/explore_empty/explore_create_community': { + text: 'Create community', + }, + 'social_home_page/explore_community_empty/title': { + text: 'No community yet', + }, + 'social_home_page/explore_community_empty/description': { + text: `Let's create your own communities`, + }, + 'social_home_page/explore_community_empty/explore_create_community': { + text: 'Create community', + }, + 'social_home_page/*/explore_trending_title': { + text: 'Trending now', + }, + 'social_home_page/*/explore_recommended_title': { + text: 'Recommended for you', + }, + 'social_home_page/trending_communities/*': {}, + 'all_categories_page/*/*': {}, + 'communities_by_category_page/*/*': {}, + '*/*/community_join_button': { + text: 'Join', + }, + '*/*/community_joined_button': { + text: 'Joined', + }, + 'communities_by_category_page/*/community_empty_image': {}, + 'communities_by_category_page/*/community_empty_title': { + text: 'No community yet', + }, }, }; diff --git a/src/v4/core/providers/NavigationProvider.tsx b/src/v4/core/providers/NavigationProvider.tsx index 3608cdcfd..f2e9333ef 100644 --- a/src/v4/core/providers/NavigationProvider.tsx +++ b/src/v4/core/providers/NavigationProvider.tsx @@ -23,6 +23,9 @@ export enum PageTypes { PostComposerPage = 'PostComposerPage', MyCommunitiesSearchPage = 'MyCommunitiesSearchPage', StoryTargetSelectionPage = 'StoryTargetSelectionPage', + AllCategoriesPage = 'AllCategoriesPage', + CommunitiesByCategoryPage = 'CommunitiesByCategoryPage', + CommunityCreatePage = 'CommunityCreatePage', } type Page = @@ -103,6 +106,18 @@ type Page = } | { type: PageTypes.StoryTargetSelectionPage; + } + | { + type: PageTypes.AllCategoriesPage; + } + | { + type: PageTypes.CommunitiesByCategoryPage; + context: { + categoryId: string; + }; + } + | { + type: PageTypes.CommunityCreatePage; }; type ContextValue = { @@ -123,6 +138,7 @@ type ContextValue = { goToMyCommunitiesSearchPage: () => void; goToSelectPostTargetPage: () => void; goToStoryTargetSelectionPage: () => void; + goToCommunityCreatePage: () => void; goToDraftStoryPage: ( targetId: string, targetType: string, @@ -161,6 +177,8 @@ type ContextValue = { storyType: 'communityFeed' | 'globalFeed'; }) => void; goToSocialHomePage: () => void; + goToAllCategoriesPage: () => void; + goToCommunitiesByCategoryPage: (context: { categoryId: string }) => void; //V3 functions onClickStory: ( storyId: string, @@ -196,10 +214,13 @@ let defaultValue: ContextValue = { goToSocialGlobalSearchPage: (tab?: string) => {}, goToSelectPostTargetPage: () => {}, goToStoryTargetSelectionPage: () => {}, + goToCommunityCreatePage: () => {}, goToPostComposerPage: () => {}, goToStoryCreationPage: () => {}, goToSocialHomePage: () => {}, goToMyCommunitiesSearchPage: () => {}, + goToAllCategoriesPage: () => {}, + goToCommunitiesByCategoryPage: (context: { categoryId: string }) => {}, setNavigationBlocker: () => {}, onBack: () => {}, //V3 functions @@ -237,6 +258,7 @@ if (process.env.NODE_ENV !== 'production') { goToSocialGlobalSearchPage: (tab) => console.log(`NavigationContext goToSocialGlobalSearchPage(${tab})`), goToSelectPostTargetPage: () => console.log('NavigationContext goToTargetPage()'), + goToCommunityCreatePage: () => console.log('NavigationContext goToCommunityCreatePage()'), goToStoryTargetSelectionPage: () => console.log('NavigationContext goToStoryTargetSelectionPage()'), goToDraftStoryPage: (data) => console.log(`NavigationContext goToDraftStoryPage()`), @@ -245,6 +267,9 @@ if (process.env.NODE_ENV !== 'production') { goToSocialHomePage: () => console.log('NavigationContext goToSocialHomePage()'), goToMyCommunitiesSearchPage: () => console.log('NavigationContext goToMyCommunitiesSearchPage()'), + goToAllCategoriesPage: () => console.log('NavigationContext goToAllCategoriesPage()'), + goToCommunitiesByCategoryPage: (context) => + console.log(`NavigationContext goToCommunitiesByCategoryPage(${context})`), //V3 functions onClickStory: (storyId, storyType, targetIds) => @@ -289,7 +314,10 @@ interface NavigationProviderProps { mediaType: AmityStoryMediaType, storyType: 'communityFeed' | 'globalFeed', ) => void; + goToAllCategoriesPage?: () => void; + goToCommunitiesByCategoryPage?: (context: { categoryId: string }) => void; onCommunityCreated?: (communityId: string) => void; + goToCommunityCreatePage?: () => void; onEditCommunity?: (communityId: string, options?: { tab?: string }) => void; onEditUser?: (userId: string) => void; onMessageUser?: (userId: string) => void; @@ -541,6 +569,15 @@ export default function NavigationProvider({ [onChangePage, pushPage], ); + const goToCommunityCreatePage = useCallback(() => { + const next = { + type: PageTypes.CommunityCreatePage, + context: {}, + }; + + pushPage(next); + }, [onChangePage, pushPage]); + const goToSocialGlobalSearchPage = useCallback( (tab?: string) => { const next = { @@ -640,6 +677,27 @@ export default function NavigationProvider({ pushPage(next); }, [onChangePage, pushPage]); + const goToAllCategoriesPage = useCallback(() => { + const next = { + type: PageTypes.AllCategoriesPage, + context: {}, + }; + + pushPage(next); + }, [onChangePage, pushPage]); + + const goToCommunitiesByCategoryPage = useCallback( + (context) => { + const next = { + type: PageTypes.CommunitiesByCategoryPage, + context, + }; + + pushPage(next); + }, + [onChangePage, pushPage], + ); + const handleClickStory = useCallback( (targetId, storyType, targetIds) => { const next = { @@ -683,6 +741,9 @@ export default function NavigationProvider({ goToPostComposerPage, goToSocialHomePage, goToMyCommunitiesSearchPage, + goToAllCategoriesPage, + goToCommunitiesByCategoryPage, + goToCommunityCreatePage, setNavigationBlocker, onClickStory: handleClickStory, }} diff --git a/src/v4/helpers/utils.ts b/src/v4/helpers/utils.ts index a0eea90c0..0a0b19aab 100644 --- a/src/v4/helpers/utils.ts +++ b/src/v4/helpers/utils.ts @@ -1,7 +1,13 @@ import { CommunityPostSettings } from '@amityco/ts-sdk'; import isEmpty from 'lodash/isEmpty'; -export type Mentioned = { userId?: string; length: number; index: number; type: string }; +export type Mentioned = { + userId?: string; + length: number; + index: number; + type: string; + displayName?: string; +}; export type Mentionees = (Amity.UserMention | Amity.ChannelMention)[]; export type Metadata = { mentioned?: Mentioned[]; diff --git a/src/v4/icons/Brand.tsx b/src/v4/icons/Brand.tsx new file mode 100644 index 000000000..8688bb408 --- /dev/null +++ b/src/v4/icons/Brand.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +const Brand = (props: React.SVGProps) => ( + + + + + + + + + + +); + +export default Brand; diff --git a/src/v4/icons/ChevronRight.tsx b/src/v4/icons/ChevronRight.tsx new file mode 100644 index 000000000..92103504c --- /dev/null +++ b/src/v4/icons/ChevronRight.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +const ChevronRight = (props: React.SVGProps) => ( + + + +); + +export default ChevronRight; diff --git a/src/v4/icons/People.tsx b/src/v4/icons/People.tsx new file mode 100644 index 000000000..b631b3db7 --- /dev/null +++ b/src/v4/icons/People.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +export const People = (props: React.SVGProps) => { + return ( + + + + + + + + + + ); +}; diff --git a/src/v4/icons/PinBadge.tsx b/src/v4/icons/PinBadge.tsx index 836bb3346..b2b989dc0 100644 --- a/src/v4/icons/PinBadge.tsx +++ b/src/v4/icons/PinBadge.tsx @@ -11,7 +11,7 @@ export const PinBadgeIcon = (props: React.SVGProps) => { {...props} > diff --git a/src/v4/icons/icons.stories.tsx b/src/v4/icons/icons.stories.tsx new file mode 100644 index 000000000..8ab6bd933 --- /dev/null +++ b/src/v4/icons/icons.stories.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import styles from './icons.style.module.css'; + +export default { + title: 'V4/Icons', +}; + +export const Default = { + render: () => { + return ( +
{ + if (e.target instanceof HTMLButtonElement) { + const iconName = e.target.getAttribute('data-name'); + if (iconName) { + navigator.clipboard.writeText(iconName).then(() => { + alert(`${iconName} copied.`); + }); + } + } + }} + > + [ICONS] +
+ ); + }, +}; diff --git a/src/v4/icons/icons.style.module.css b/src/v4/icons/icons.style.module.css new file mode 100644 index 000000000..cb2cbb705 --- /dev/null +++ b/src/v4/icons/icons.style.module.css @@ -0,0 +1,26 @@ +.container { + gap: 2rem; + width: 100%; + padding: 3rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + background: var(--asc-color-background-default); +} + +.container button { + width: 100%; + padding: 1rem; + display: flex; + cursor: pointer; + align-items: center; + border-radius: 0.5rem; + flex-direction: column; + justify-content: center; + color: var(--asc-color-base-default); + background: var(--asc-color-secondary-shade5); + border: 1px solid var(--asc-color-secondary-shade4); +} + +.container button svg { + fill: var(--asc-color-base-default); +} diff --git a/src/v4/social/components/Comment/Comment.module.css b/src/v4/social/components/Comment/Comment.module.css index ab93aa4e2..a18170589 100644 --- a/src/v4/social/components/Comment/Comment.module.css +++ b/src/v4/social/components/Comment/Comment.module.css @@ -137,7 +137,18 @@ width: 100%; } +.postComment__edit__mentionContainer { + width: 100%; + position: absolute; + top: 0; + left: 0; + transform: translateY(-100%); + max-height: 8rem; + overflow: scroll; +} + .postComment__edit__input { + position: relative; display: flex; height: 7.5rem; padding: 0.75rem; diff --git a/src/v4/social/components/Comment/Comment.tsx b/src/v4/social/components/Comment/Comment.tsx index deca3ba46..2b469b1b3 100644 --- a/src/v4/social/components/Comment/Comment.tsx +++ b/src/v4/social/components/Comment/Comment.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Typography, BottomSheet } from '~/v4/core/components'; import { ModeratorBadge } from '~/v4/social/elements/ModeratorBadge'; import { Timestamp } from '~/v4/social/elements/Timestamp'; @@ -21,6 +21,7 @@ import { CreateCommentParams } from '~/v4/social/components/CommentComposer/Comm import useCommentSubscription from '~/v4/core/hooks/subscriptions/useCommentSubscription'; import { TextWithMention } from '~/v4/social/internal-components/TextWithMention/TextWithMention'; import millify from 'millify'; +import useCommunityPostPermission from '~/v4/social/hooks/useCommunityPostPermission'; const EllipsisH = ({ ...props }: React.SVGProps) => ( void; shouldAllowInteraction?: boolean; } @@ -82,11 +83,10 @@ export const Comment = ({ onClickReply, shouldAllowInteraction = true, }: CommentProps) => { - const { accessibilityId, config, defaultConfig, isExcluded, uiReference, themeStyles } = - useAmityComponent({ - pageId, - componentId, - }); + const { accessibilityId, isExcluded, themeStyles } = useAmityComponent({ + pageId, + componentId, + }); const { confirm } = useConfirmContext(); @@ -94,9 +94,15 @@ export const Comment = ({ const [hasClickLoadMore, setHasClickLoadMore] = useState(false); const [isEditing, setIsEditing] = useState(false); const [commentData, setCommentData] = useState(); + const mentionRef = useRef(null); const [isShowMore, setIsShowMore] = useState(false); + const { isModerator: isModeratorUser } = useCommunityPostPermission({ + community, + userId: comment.creator?.userId, + }); + const toggleBottomSheet = () => setBottomSheetOpen((prev) => !prev); const isLiked = (comment.myReactions || []).some((reaction) => reaction === 'like'); @@ -161,11 +167,12 @@ export const Comment = ({ ) : isEditing ? (
- +
+
{ - setCommentData(value); + onChange={(value) => { + setCommentData({ + data: { + text: value.text, + }, + mentionees: value.mentionees as Amity.UserMention[], + metadata: { + mentioned: value.mentioned, + }, + }); }} maxLines={5} - mentionOffsetBottom={215} + mentionContainer={mentionRef?.current} />
@@ -204,16 +219,21 @@ export const Comment = ({
) : (
- +
- + {comment.creator?.displayName} - + {isModeratorUser && } - {comment.createdAt !== comment.editedAt && ' (edited)'} + + {comment.createdAt !== comment.editedAt && ' (edited)'} +
@@ -239,7 +261,10 @@ export const Comment = ({ {isLiked ? 'Liked' : 'Like'}
-
onClickReply(comment)}> +
onClickReply(comment)} + > Reply @@ -261,6 +286,7 @@ export const Comment = ({ {replyAmount > 0 && !hasClickLoadMore && (
setHasClickLoadMore(true)} > @@ -273,7 +299,9 @@ export const Comment = ({ {hasClickLoadMore && ( { const userId = useSDK().currentUserId; - const { user } = useUser(userId); + const { user } = useUser({ userId }); const avatarUrl = useImage({ fileId: user?.avatar?.fileId, imageSize: 'small' }); const editorRef = useRef(null); - const composerRef = useRef(null); const composerInputRef = useRef(null); + const componentId = 'comment_composer_bar'; + const mentionContainerRef = useRef(null); const [composerHeight, setComposerHeight] = useState(0); - const [mentionOffsetBottom, setMentionOffsetBottom] = useState(0); const [textValue, setTextValue] = useState({ data: { @@ -72,10 +74,6 @@ export const CommentComposer = ({ metadata: {}, }); - const onChange = (val: any) => { - setTextValue(val); - }; - const { mutateAsync } = useMutation({ mutationFn: async ({ params }: { params: CreateCommentParams }) => { const parentId = replyTo ? replyTo.commentId : undefined; @@ -100,21 +98,6 @@ export const CommentComposer = ({ }, }); - useEffect(() => { - if (composerInputRef.current) { - // NOTE: Cannot use ref to get padding of the container and inside input - const containerPaddingBottom = 8; - const inputPaddingBottom = 10; - setMentionOffsetBottom( - composerInputRef.current.offsetHeight - inputPaddingBottom + containerPaddingBottom, - ); - } - - if (composerRef.current) { - setComposerHeight(composerRef.current.offsetHeight); - } - }, []); - if (!shouldAllowCreation) { return (
@@ -125,50 +108,78 @@ export const CommentComposer = ({ } return ( -
-
- } /> -
-
- +
+
+
+ {replyTo && ( +
+
+ Replying to + + {replyTo?.userId} + +
+ +
+ )}
- - {replyTo && ( +
+
+ } + /> +
-
- Replying to - - {replyTo?.userId} - -
- { + setTextValue({ + data: { + text: text, + }, + mentionees: mentionees, + metadata: { + mentioned: mentioned, + }, + }); + }} + targetType={referenceType} + targetId={referenceId} + value={textValue} + placehoder="Say something nice..." + communityId={community?.communityId} />
- )} + +
); }; diff --git a/src/v4/social/components/CommentComposer/CommentInput.module.css b/src/v4/social/components/CommentComposer/CommentInput.module.css index daae1b98a..de3572627 100644 --- a/src/v4/social/components/CommentComposer/CommentInput.module.css +++ b/src/v4/social/components/CommentComposer/CommentInput.module.css @@ -11,31 +11,22 @@ height: 100%; width: 100%; color: var(--asc-color-base-default); -} - -.editorParagraph span { - color: var(--asc-color-base-default); + line-height: var(--asc-line-height-md); + font-size: var(--asc-text-font-size-md); + margin: 0; } .editorContainer { width: 100%; height: 100%; color: var(--asc-color-base-default); - max-height: calc(var(--asc-line-height-md) * var(--var-max-lines) + (0.62rem * 2)); - - /* padding: 0.62rem 1rem; */ + max-height: calc(var(--asc-line-height-md) * var(--asc-max-lines) + (0.62rem * 2)); position: relative; - - p { - line-height: var(--asc-line-height-md); - font-size: var(--asc-text-font-size-md); - margin: 0; - } } .editorEditableContent { height: max-content; - max-height: calc(var(--asc-line-height-md) * var(--var-max-lines)); + max-height: calc(var(--asc-line-height-md) * var(--asc-max-lines)); overflow-y: scroll; } @@ -43,3 +34,14 @@ border: none; outline: none; } + +.editorLink { + color: var(--asc-color-primary-shade1); + text-decoration: none; +} + +.commentInput__mentionInterceptor { + width: 100%; + height: 1px; + background-color: var(--asc-color-background-default); +} diff --git a/src/v4/social/components/CommentComposer/CommentInput.tsx b/src/v4/social/components/CommentComposer/CommentInput.tsx index 2fc479b3f..49041c5b3 100644 --- a/src/v4/social/components/CommentComposer/CommentInput.tsx +++ b/src/v4/social/components/CommentComposer/CommentInput.tsx @@ -1,267 +1,224 @@ -import React, { forwardRef, MutableRefObject, useImperativeHandle } from 'react'; +import React, { forwardRef, MutableRefObject, useImperativeHandle, useMemo, useState } from 'react'; +import ReactDOM from 'react-dom'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { EditorRefPlugin } from '@lexical/react/LexicalEditorRefPlugin'; -import { - $getRoot, - LexicalEditor, - SerializedLexicalNode, - SerializedTextNode, - SerializedRootNode, - SerializedParagraphNode, -} from 'lexical'; +import { $getRoot, LexicalEditor, Klass, LexicalNode, COMMAND_PRIORITY_HIGH } from 'lexical'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'; -import { - MentionNode, - SerializedMentionNode, -} from '~/v4/social/internal-components/MentionTextInput/MentionNodes'; import styles from './CommentInput.module.css'; -import { CommentMentionInput } from '~/v4/social/components/CommentMentionInput'; -import { useMentionUsers } from '~/v4/social/hooks/useMentionUser'; import { CreateCommentParams } from '~/v4/social/components/CommentComposer/CommentComposer'; +import { + editorToText, + getEditorConfig, + MentionData, + textToEditorState, +} from '~/v4/social/internal-components/Lexical/utils'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import { AutoLinkNode, LinkNode } from '@lexical/link'; +import { + $createMentionNode, + MentionNode, +} from '~/v4/social/internal-components/Lexical/nodes/MentionNode'; import { Mentioned, Mentionees } from '~/v4/helpers/utils'; - -const theme = { - ltr: 'ltr', - rtl: 'rtl', - placeholder: styles.editorPlaceholder, - paragraph: styles.editorParagraph, -}; - -const editorConfig = { - namespace: 'CommentInput', - theme: theme, - onError(error: Error) { - throw error; - }, - nodes: [MentionNode], -}; - -interface EditorStateJson extends SerializedLexicalNode { - children: []; -} +import { AutoLinkPlugin } from '~/v4/social/internal-components/Lexical/plugins/AutoLinkPlugin'; +import { LinkPlugin } from '~/v4/social/internal-components/Lexical/plugins/LinkPlugin'; +import { MentionPlugin } from '~/v4/social/internal-components/Lexical/plugins/MentionPlugin'; +import { useUserQueryByDisplayName } from '~/v4/core/hooks/collections/useUsersCollection'; +import { useMemberQueryByDisplayName } from '~/v4/social/hooks/useMemberQueryByDisplayName'; +import useCommunity from '~/v4/core/hooks/collections/useCommunity'; +import { MentionItem } from '~/v4/social/internal-components/Lexical/MentionItem'; +import useIntersectionObserver from '~/v4/core/hooks/useIntersectionObserver'; interface CommentInputProps { - community?: Amity.Community | null; + pageId?: string; + componentId?: string; + communityId?: string; value?: CreateCommentParams; - mentionOffsetBottom?: number; maxLines?: number; placehoder?: string; targetType?: string; targetId?: string; ref: MutableRefObject; - onChange: (data: CreateCommentParams) => void; + mentionContainer?: HTMLElement | null; + onChange: (data: { mentioned: Mentioned[]; mentionees: Mentionees; text: string }) => void; } export interface CommentInputRef { clearEditorState: () => void; } -function editorStateToText(editor: LexicalEditor): CreateCommentParams { - const editorStateTextString: string[] = []; - const paragraphs = editor.getEditorState().toJSON().root.children as EditorStateJson[]; - - const mentioned: Mentioned[] = []; - const mentionees: { - type: Amity.Mention['type']; - userIds: string[]; - }[] = []; - let runningIndex = 0; - - paragraphs.forEach((paragraph) => { - const children = paragraph.children; - const paragraphText: string[] = []; - - children.forEach((child: { type: string; text: string; userId: string }) => { - if (child.text) { - paragraphText.push(child.text); - } - if (child.type === 'mention') { - mentioned.push({ - index: runningIndex, - length: child.text.length, - type: 'user', - userId: child.userId, - }); - - mentionees.push({ type: 'user', userIds: [child.userId] }); - } - runningIndex += child.text.length; - }); - runningIndex += 1; - editorStateTextString.push(paragraphText.join('')); +const useSuggestions = (communityId?: string | null) => { + const [queryString, setQueryString] = useState(null); + + const { community, isLoading: isCommunityLoading } = useCommunity({ communityId }); + + const isSearchCommunityMembers = useMemo( + () => !!communityId && !isCommunityLoading && !community?.isPublic, + [communityId, isCommunityLoading, community], + ); + + const { + members, + hasMore: hasMoreMember, + isLoading: isLoadingMember, + loadMore: loadMoreMember, + } = useMemberQueryByDisplayName({ + communityId: communityId || '', + displayName: queryString || '', + limit: 10, + enabled: isSearchCommunityMembers, + }); + const { + users, + hasMore: hasMoreUser, + loadMore: loadMoreUser, + isLoading: isLoadingUser, + } = useUserQueryByDisplayName({ + displayName: queryString || '', + limit: 10, + enabled: !isSearchCommunityMembers, }); - return { - data: { text: editorStateTextString.join('\n') }, - mentionees, - metadata: { - mentioned, - }, + const onQueryChange = (newQuery: string | null) => { + setQueryString(newQuery); }; -} -function createRootNode(): SerializedRootNode { - return { - children: [], - direction: 'ltr', - format: '', - indent: 0, - type: 'root', - version: 1, - }; -} + const suggestions = useMemo(() => { + if (!!communityId && isCommunityLoading) return []; -function createParagraphNode(): SerializedParagraphNode { - return { - children: [], - direction: 'ltr', - format: '', - indent: 0, - type: 'paragraph', - version: 1, - textFormat: 0, - }; -} - -function createSerializeTextNode(text: string): SerializedTextNode { - return { - detail: 0, - format: 0, - mode: 'normal', - style: '', - text, - type: 'text', - version: 1, - }; -} + if (isSearchCommunityMembers) { + return members.map(({ user, userId }) => ({ + userId: user?.userId || userId, + displayName: user?.displayName, + })); + } -function createSerializeMentionNode(mention: Mentioned): SerializedMentionNode { - return { - detail: 0, - format: 0, - mode: 'normal', - style: '', - text: ('@' + mention.userId) as string, - type: 'mention', - version: 1, - mentionName: mention.userId as string, - displayName: mention.userId as string, - userId: mention.userId as string, - userInternalId: mention.userId as string, - userPublicId: mention.userId as string, - }; -} + return users.map(({ displayName, userId }) => ({ + userId: userId, + displayName: displayName, + })); + }, [users, members, isSearchCommunityMembers, isCommunityLoading]); -export function TextToEditorState(value: { - data: { text: string }; - metadata?: { - mentioned?: Mentioned[]; + const hasMore = useMemo(() => { + if (isSearchCommunityMembers) { + return hasMoreMember; + } else { + return hasMoreUser; + } + }, [isSearchCommunityMembers, hasMoreMember, hasMoreUser]); + + const loadMore = () => { + if (isLoading || !hasMore) return; + if (isSearchCommunityMembers) { + loadMoreMember(); + } else { + loadMoreUser(); + } }; - mentionees?: Mentionees; -}) { - const rootNode = createRootNode(); - - const textArray = value.data.text.split('\n'); - - const mentions = value.metadata?.mentioned; - - let start = 0; - let stop = -1; - let mentionRunningIndex = 0; - - for (let i = 0; i < textArray.length; i++) { - start = stop + 1; - stop = start + textArray[i].length; - - const paragraph = createParagraphNode(); - if (Array.isArray(mentions) && mentions?.length > 0) { - let runningIndex = 0; - - while (runningIndex < textArray[i].length) { - if (mentionRunningIndex >= mentions.length) { - paragraph.children.push(createSerializeTextNode(textArray[i].slice(runningIndex))); - runningIndex = textArray[i].length; - break; - } + const isLoading = useMemo(() => { + if (isSearchCommunityMembers) { + return isLoadingMember; + } else { + return isLoadingUser; + } + }, [isLoadingMember, isLoadingUser, isSearchCommunityMembers]); - if (mentions[mentionRunningIndex].index >= stop) { - paragraph.children.push(createSerializeTextNode(textArray[i])); - runningIndex = textArray[i].length; - } else { - const text = textArray[i].slice( - runningIndex, - runningIndex + mentions[mentionRunningIndex]?.index - start, - ); + return { suggestions, queryString, onQueryChange, loadMore, hasMore, isLoading }; +}; - if (text) { - paragraph.children.push(createSerializeTextNode(text)); - } +const nodes = [AutoLinkNode, LinkNode, MentionNode] as Array>; - paragraph.children.push(createSerializeMentionNode(mentions[mentionRunningIndex])); +export const CommentInput = forwardRef( + ( + { + pageId = '*', + componentId = '*', + communityId, + value, + onChange, + maxLines = 10, + placehoder, + mentionContainer, + }, + ref, + ) => { + const [intersectionNode, setIntersectionNode] = useState(null); + const elementId = 'comment_input'; + const { themeStyles, uiReference, config, accessibilityId } = useAmityElement({ + pageId, + componentId, + elementId, + }); - runningIndex += - mentions[mentionRunningIndex].index + mentions[mentionRunningIndex].length - start; + const { onQueryChange, suggestions, isLoading, loadMore, hasMore } = + useSuggestions(communityId); - mentionRunningIndex++; + useIntersectionObserver({ + onIntersect: () => { + if (isLoading === false) { + loadMore(); } - } - } - - if (!mentions || mentions?.length === 0) { - const textNode = createSerializeTextNode(textArray[i]); - paragraph.children.push(textNode); - } - - rootNode.children.push(paragraph); - } - - return { root: rootNode }; -} + }, + node: intersectionNode, + options: { + threshold: 0.7, + }, + }); -export const CommentInput = forwardRef( - ({ community, mentionOffsetBottom = 0, value, onChange, maxLines = 10, placehoder }, ref) => { const editorRef = React.useRef(null); - const [queryMentionUser, setQueryMentionUser] = React.useState(null); - - const { mentionUsers } = useMentionUsers({ - displayName: queryMentionUser || '', - community, - }); const clearEditorState = () => { editorRef.current?.update(() => { const root = $getRoot(); root.clear(); }); + setTimeout(() => { + editorRef.current?.blur(); + }, 500); }; useImperativeHandle(ref, () => ({ clearEditorState, })); + const editorConfig = getEditorConfig({ + namespace: uiReference, + theme: { + // root: styles.editorRoot, + placeholder: styles.editorPlaceholder, + paragraph: styles.editorParagraph, + link: styles.editorLink, + }, + nodes, + }); + return (
} + contentEditable={ + + } placeholder={ placehoder ?
{placehoder}
: null } @@ -269,16 +226,60 @@ export const CommentInput = forwardRef( /> { - onChange(editorStateToText(editor)); + onChange(editorToText(editor)); }} /> + + - setQueryMentionUser(query)} - offsetBottom={mentionOffsetBottom} + > + suggestions={suggestions} + getSuggestionId={(suggestion) => suggestion.userId} + onQueryChange={onQueryChange} + $createNode={(data) => + $createMentionNode({ + text: `@${data?.displayName || ''}`, + data, + }) + } + menuRenderFn={( + _, + { options, selectedIndex, setHighlightedIndex, selectOptionAndCleanUp }, + ) => { + if (!mentionContainer || options.length === 0) { + return null; + } + return ReactDOM.createPortal( + <> + {options.map((option, i: number) => { + return ( + { + setHighlightedIndex(i); + selectOptionAndCleanUp(option); + }} + onMouseEnter={() => { + setHighlightedIndex(i); + }} + key={option.key} + option={option} + /> + ); + })} + {hasMore && ( +
setIntersectionNode(node)} + className={styles.commentInput__mentionInterceptor} + /> + )} + , + mentionContainer, + ); + }} + commandPriority={COMMAND_PRIORITY_HIGH} />
diff --git a/src/v4/social/components/CommentList/CommentList.tsx b/src/v4/social/components/CommentList/CommentList.tsx index 92a1976c8..0dedda793 100644 --- a/src/v4/social/components/CommentList/CommentList.tsx +++ b/src/v4/social/components/CommentList/CommentList.tsx @@ -3,17 +3,13 @@ import React, { useRef, useState } from 'react'; import { Comment } from '~/v4/social/components/Comment/Comment'; import useIntersectionObserver from '~/v4/core/hooks/useIntersectionObserver'; import { useAmityComponent } from '~/v4/core/hooks/uikit'; -import useUserSubscription from '~/v4/core/hooks/subscriptions/useUserSubscription'; import { CommentRepository, SubscriptionLevels } from '@amityco/ts-sdk'; -import useCommunitySubscription from '~/v4/core/hooks/subscriptions/useCommunitySubscription'; import { usePaginator } from '~/v4/core/hooks/usePaginator'; import { CommentAd } from '~/v4/social/internal-components/CommentAd/CommentAd'; import { CommentSkeleton } from '~/v4/social/components/Comment/CommentSkeleton'; import styles from './CommentList.module.css'; import useCommunityStoriesSubscription from '~/v4/social/hooks/useCommunityStoriesSubscription'; import { Typography } from '~/v4/core/components'; -import useStory from '~/v4/social/hooks/useStory'; -import usePost from '~/v4/core/hooks/objects/usePost'; import usePostSubscription from '~/v4/core/hooks/subscriptions/usePostSubscription'; type CommentListProps = { @@ -23,7 +19,7 @@ type CommentListProps = { onClickReply: (comment: Amity.Comment) => void; limit?: number; includeDeleted?: boolean; - community?: Amity.Community; + community?: Amity.Community | null; shouldAllowInteraction?: boolean; }; @@ -65,13 +61,6 @@ export const CommentList = ({ shouldCall: true, }); - const { story } = useStory({ - storyId: referenceId, - shouldCall: referenceType === 'story', - }); - - const { post } = usePost(referenceType === 'post' ? referenceId : undefined); - useIntersectionObserver({ node: intersectionNode, options: { @@ -97,7 +86,7 @@ export const CommentList = ({ shouldSubscribe: referenceType === 'story' && !!referenceId, }); - if (items.length === 0) { + if (!isLoading && items.length === 0) { return (
No comments yet @@ -117,13 +106,12 @@ export const CommentList = ({ ) : ( onClickReply?.(comment)} componentId={componentId} community={community} - // targetId={post?.targetId || story?.targetId} - // targetType={post?.targetType || story?.targetType} shouldAllowInteraction={shouldAllowInteraction} /> ); diff --git a/src/v4/social/components/CommentMentionInput/MentionUser.module.css b/src/v4/social/components/CommentMentionInput/MentionUser.module.css index 2167f73ff..e4e50ff66 100644 --- a/src/v4/social/components/CommentMentionInput/MentionUser.module.css +++ b/src/v4/social/components/CommentMentionInput/MentionUser.module.css @@ -16,6 +16,14 @@ height: 2.5rem; } +.communityMember__rightPane { + display: grid; + grid-template-columns: auto 1.5rem; + justify-content: center; + align-items: center; + gap: 0.5rem; +} + .communityMember__displayName { margin-left: 0.5rem; font-size: 1rem; @@ -25,3 +33,8 @@ overflow: hidden; color: var(--asc-color-base-default); } + +.communityMember__brandIcon { + width: 1.5rem; + height: 1.5rem; +} diff --git a/src/v4/social/components/CommentMentionInput/MentionUser.tsx b/src/v4/social/components/CommentMentionInput/MentionUser.tsx index 2fb5d5b10..7a25b2d56 100644 --- a/src/v4/social/components/CommentMentionInput/MentionUser.tsx +++ b/src/v4/social/components/CommentMentionInput/MentionUser.tsx @@ -2,6 +2,7 @@ import React from 'react'; import styles from './MentionUser.module.css'; import { UserAvatar } from '~/v4/social/internal-components/UserAvatar/UserAvatar'; import { MentionTypeaheadOption } from './CommentMentionInput'; +import { BrandBadge } from '~/v4/social/internal-components/BrandBadge'; interface MentionUserProps { isSelected: boolean; @@ -34,7 +35,12 @@ export function MentionUser({ isSelected, onClick, onMouseEnter, option }: Menti userId={option.user.avatarFileId} />
-

{option.user.displayName}

+
+

{option.user.displayName}

+ {option.user.isBrand ? ( + + ) : null} +
); diff --git a/src/v4/social/components/CommentOptions/CommentOptions.tsx b/src/v4/social/components/CommentOptions/CommentOptions.tsx index fa1f62cb3..d2af72c7f 100644 --- a/src/v4/social/components/CommentOptions/CommentOptions.tsx +++ b/src/v4/social/components/CommentOptions/CommentOptions.tsx @@ -9,6 +9,8 @@ import { FlagIcon, PenIcon, TrashIcon } from '~/v4/social/icons'; import styles from './CommentOptions.module.css'; interface CommentOptionsProps { + pageId?: string; + componentId?: string; comment: Amity.Comment; handleEditComment: () => void; handleDeleteComment: () => void; @@ -16,6 +18,8 @@ interface CommentOptionsProps { } export const CommentOptions = ({ + pageId = '*', + componentId = '*', comment, handleEditComment, handleDeleteComment, @@ -55,6 +59,7 @@ export const CommentOptions = ({ name: 'Edit comment', action: handleEditComment, icon: , + accessibilityId: 'edit_comment', } : null, canReport @@ -62,6 +67,7 @@ export const CommentOptions = ({ name: isFlaggedByMe ? 'Unreport comment' : 'Report comment', action: handleReportComment, icon: , + accessibilityId: 'report_comment', } : null, canDelete @@ -69,6 +75,7 @@ export const CommentOptions = ({ name: 'Delete comment', action: handleDeleteComment, icon: , + accessibilityId: 'delete_comment', } : null, ].filter(isNonNullable); @@ -77,6 +84,7 @@ export const CommentOptions = ({ <> {options.map((option, index) => (
{ diff --git a/src/v4/social/components/CommentTray/CommentTray.tsx b/src/v4/social/components/CommentTray/CommentTray.tsx index bfde921ad..a8cc0e414 100644 --- a/src/v4/social/components/CommentTray/CommentTray.tsx +++ b/src/v4/social/components/CommentTray/CommentTray.tsx @@ -46,6 +46,7 @@ export const CommentTray = ({ >
{ +export const CommunityFeedPostContentSkeleton = () => { return (
@@ -59,12 +59,11 @@ export const CommunityFeed = ({ pageId = '*', communityId }: CommunityFeedProps) }); const { - pinnedPost: announcementPosts, - isLoading: isLoadingAnnouncementPosts, + pinnedPost: allPinnedPost, + isLoading: isLoadingAllPinnedPosts, refresh, } = usePinnedPostsCollection({ communityId, - placement: 'announcement', }); const { AmityCommunityProfilePageBehavior } = usePageBehavior(); @@ -73,6 +72,32 @@ export const CommunityFeed = ({ pageId = '*', communityId }: CommunityFeedProps) const observerTarget = useRef(null); + const announcementPosts = allPinnedPost.filter((item) => item.placement === 'announcement'); + + const pinnedPosts = allPinnedPost.filter( + (item) => + item.placement === 'default' && + !announcementPosts.map((item) => item.post.postId).includes(item.post.postId), + ); + + const isAnnouncePostWasPinned = allPinnedPost.some( + (item) => + item.placement === 'default' && + announcementPosts.map((item) => item.post.postId).includes(item.post.postId), + ); + + const filteredPosts = posts.filter( + (post) => + !announcementPosts.some((announcementPost) => announcementPost.post.postId === post.postId), + ); + + filteredPosts.forEach((post) => { + const matchedPinnedPost = pinnedPosts.find((pinned) => pinned.post.postId === post.postId); + if (matchedPinnedPost) { + post.placement = matchedPinnedPost.placement; + } + }); + useEffect(() => { const observer = new IntersectionObserver( async (entries) => { @@ -101,46 +126,51 @@ export const CommunityFeed = ({ pageId = '*', communityId }: CommunityFeedProps) if (isExcluded) return null; const renderPublicCommunityFeed = () => { - //TODO : Change any type to be Amity.PinnedPost after SDK deploy to PROD - const filteredPosts = posts.filter( - (post) => - !announcementPosts.some( - (announcementPost: any) => announcementPost.post.postId === post.postId, - ), - ); - return ( <> - {isLoading - ? Array.from({ length: 2 }).map((_, index) => ( - - )) - : filteredPosts && - filteredPosts.map((post) => ( - - ))} + /> + + ))} + {isLoading && + Array.from({ length: 2 }).map((_, index) => ( + + ))} {posts?.length === 0 && !isLoading && (
@@ -153,7 +183,7 @@ export const CommunityFeed = ({ pageId = '*', communityId }: CommunityFeedProps) }; const renderAnnouncementPost = () => { - return isLoadingAnnouncementPosts ? ( + return isLoadingAllPinnedPosts ? ( ) : ( announcementPosts && @@ -164,15 +194,22 @@ export const CommunityFeed = ({ pageId = '*', communityId }: CommunityFeedProps) AmityCommunityProfilePageBehavior?.goToPostDetailPage?.({ postId: post.postId, hideTarget: true, - category: AmityPostCategory.ANNOUNCEMENT, + category: isAnnouncePostWasPinned + ? AmityPostCategory.PIN_AND_ANNOUNCEMENT + : AmityPostCategory.ANNOUNCEMENT, }); }} className={styles.communityFeed__announcePost} > diff --git a/src/v4/social/components/CommunityHeader/CommunityHeader.module.css b/src/v4/social/components/CommunityHeader/CommunityHeader.module.css index 13066334e..babad5e7c 100644 --- a/src/v4/social/components/CommunityHeader/CommunityHeader.module.css +++ b/src/v4/social/components/CommunityHeader/CommunityHeader.module.css @@ -139,3 +139,14 @@ background-color: var(--asc-color-base-shade4); margin-bottom: 0.25rem; } + +.communityProfile__communityCategories { + overflow-x: scroll; + width: 100%; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.communityProfile__communityCategories::-webkit-scrollbar { + display: none; +} diff --git a/src/v4/social/components/CommunityHeader/CommunityHeader.tsx b/src/v4/social/components/CommunityHeader/CommunityHeader.tsx index ae034c8bf..69fc03ae2 100644 --- a/src/v4/social/components/CommunityHeader/CommunityHeader.tsx +++ b/src/v4/social/components/CommunityHeader/CommunityHeader.tsx @@ -11,8 +11,8 @@ import { useNavigation } from '~/v4/core/providers/NavigationProvider'; import { CommunityVerifyBadge } from '~/v4/social/elements/CommunityVerifyBadge'; import { CommunityDescription } from '~/v4/social/elements/CommunityDescription'; import { CommunityName } from '~/v4/social/elements/CommunityName'; -import { CommunityCategory } from '~/v4/social/elements/CommunityCategory'; import { CommunityInfo } from '~/v4/social/elements/CommunityInfo'; +import { CommunityCategories } from '~/v4/social/internal-components/CommunityCategories/CommunityCategories'; import Lock from '~/v4/icons/Lock'; interface CommunityProfileHeaderProps { @@ -24,6 +24,7 @@ export const CommunityHeader: React.FC = ({ pageId = '*', community, }) => { + const componentId = 'community_header'; const { onBack, onEditCommunity } = useNavigation(); const { activeTab, setActiveTab } = useCommunityTabContext(); @@ -46,6 +47,7 @@ export const CommunityHeader: React.FC = ({
onEditCommunity(community.communityId)} @@ -53,18 +55,36 @@ export const CommunityHeader: React.FC = ({
{!community.isPublic && } - + {community.isOfficial && }
- +
+ +
- +
- +
onEditCommunity(community.communityId, 'MEMBERS')} @@ -73,15 +93,19 @@ export const CommunityHeader: React.FC = ({ {!community.isJoined && community.isPublic && (
- +
)}
- +
{/* {isShowPendingPost && (
- +
)} */} diff --git a/src/v4/social/components/CommunityPin/CommunityPin.module.css b/src/v4/social/components/CommunityPin/CommunityPin.module.css index e3dc19f10..28ec04810 100644 --- a/src/v4/social/components/CommunityPin/CommunityPin.module.css +++ b/src/v4/social/components/CommunityPin/CommunityPin.module.css @@ -3,3 +3,8 @@ flex-direction: column; gap: 0.5rem; } + +.communityPin__feed { + padding-bottom: 0.75rem; + background-color: var(--asc-color-base-shade4); +} diff --git a/src/v4/social/components/CommunityPin/CommunityPin.tsx b/src/v4/social/components/CommunityPin/CommunityPin.tsx index 56fe2b157..07c5b7568 100644 --- a/src/v4/social/components/CommunityPin/CommunityPin.tsx +++ b/src/v4/social/components/CommunityPin/CommunityPin.tsx @@ -1,9 +1,20 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useAmityComponent } from '~/v4/core/hooks/uikit'; import { EmptyPinnedPost } from '~/v4/social/components/EmptyPinnedPost'; import styles from './CommunityPin.module.css'; import useCommunity from '~/v4/core/hooks/collections/useCommunity'; import LockPrivateContent from '~/v4/social/internal-components/LockPrivateContent'; +import usePinnedPostsCollection from '~/v4/social/hooks/collections/usePinnedPostCollection'; +import { CommunityFeedPostContentSkeleton } from '~/v4/social/components/CommunityFeed/CommunityFeed'; +import { Button } from '~/v4/core/natives/Button'; +import { usePageBehavior } from '~/v4/core/providers/PageBehaviorProvider'; +import useCommunitySubscription from '~/v4/core/hooks/subscriptions/useCommunitySubscription'; +import { SubscriptionLevels } from '@amityco/ts-sdk'; +import { + AmityPostCategory, + AmityPostContentComponentStyle, + PostContent, +} from '~/v4/social/components/PostContent/PostContent'; interface CommunityPinProps { pageId?: string; @@ -20,11 +31,112 @@ export const CommunityPin = ({ pageId = '*', communityId }: CommunityPinProps) = if (isExcluded) return null; const { community } = useCommunity({ communityId, shouldCall: !!communityId }); + const { AmityCommunityProfilePageBehavior } = usePageBehavior(); + + useCommunitySubscription({ communityId, level: SubscriptionLevels.POST }); + const { pinnedPost, isLoading, refresh } = usePinnedPostsCollection({ + communityId, + }); const isMemberPrivateCommunity = community?.isJoined && !community?.isPublic; - //TODO : Integrate with the new pinned post API - //TODO : Fix condition to show empty pinned post and lock private content + useEffect(() => { + refresh(); + }, []); + + const announcementPosts = pinnedPost.filter( + (post) => + post.placement === 'announcement' && + pinnedPost.some( + (pinned) => pinned.placement === 'default' && pinned.referenceId === post.referenceId, + ), + ); + const pinnedPostsWithFilterOutAnnouncePost = pinnedPost + .filter((post) => post.placement === 'default') + .filter( + (post) => + !announcementPosts.some((announcement) => announcement.referenceId === post.referenceId), + ); + + const pinnedPosts = pinnedPost.filter((post) => post.placement === 'default'); + + const renderAnnouncementPost = () => { + return isLoading ? ( + + ) : ( + announcementPosts && + announcementPosts.map(({ post }: Amity.Post) => { + return ( + + ); + }) + ); + }; + + const renderPinnedPost = () => { + const pinnedPostsFilter = + announcementPosts.length > 0 ? pinnedPostsWithFilterOutAnnouncePost : pinnedPosts; + + return isLoading ? ( + + ) : ( + pinnedPostsFilter.map(({ post }: Amity.Post) => { + return ( + + ); + }) + ); + }; return (
{isMemberPrivateCommunity || community?.isPublic ? ( - + pinnedPost.length > 0 ? ( + <> + {renderAnnouncementPost()} + {renderPinnedPost()} + + ) : ( + + ) ) : ( )} diff --git a/src/v4/social/components/CommunitySearchResult/CommunitySearchResult.module.css b/src/v4/social/components/CommunitySearchResult/CommunitySearchResult.module.css index d299fa3e1..428e79608 100644 --- a/src/v4/social/components/CommunitySearchResult/CommunitySearchResult.module.css +++ b/src/v4/social/components/CommunitySearchResult/CommunitySearchResult.module.css @@ -5,7 +5,6 @@ align-items: start; width: 100%; background-color: var(--asc-color-background-default); - overflow-x: hidden; } .communitySearchResult__communityItem { diff --git a/src/v4/social/components/CommunitySearchResult/CommunitySearchResult.tsx b/src/v4/social/components/CommunitySearchResult/CommunitySearchResult.tsx index 04936b802..e45f3b5c1 100644 --- a/src/v4/social/components/CommunitySearchResult/CommunitySearchResult.tsx +++ b/src/v4/social/components/CommunitySearchResult/CommunitySearchResult.tsx @@ -1,14 +1,18 @@ -import React, { useRef, useState } from 'react'; +import React, { useState } from 'react'; import styles from './CommunitySearchResult.module.css'; import useIntersectionObserver from '~/v4/core/hooks/useIntersectionObserver'; import { useAmityComponent } from '~/v4/core/hooks/uikit'; -import { CommunityItem } from './CommunityItem'; -import { CommunityItemSkeleton } from './CommunityItemSkeleton'; +import { CommunityRowItem } from '~/v4/social/internal-components/CommunityRowItem'; +import { CommunityRowItemSkeleton } from '~/v4/social/internal-components/CommunityRowItem/CommunityRowItemSkeleton'; +import { useNavigation } from '~/v4/core/providers/NavigationProvider'; +import { CommunityRowItemDivider } from '~/v4/social/internal-components/CommunityRowItem/CommunityRowItemDivider'; +import { useCommunityActions } from '~/v4/social/hooks/useCommunityActions'; interface CommunitySearchResultProps { pageId?: string; communityCollection: Amity.Community[]; isLoading: boolean; + showJoinButton?: boolean; onLoadMore: () => void; } @@ -17,30 +21,50 @@ export const CommunitySearchResult = ({ communityCollection = [], isLoading, onLoadMore, + showJoinButton = false, }: CommunitySearchResultProps) => { const componentId = 'community_search_result'; - const { themeStyles } = useAmityComponent({ + const { themeStyles, accessibilityId } = useAmityComponent({ pageId, componentId, }); + const { goToCommunityProfilePage, goToCommunitiesByCategoryPage } = useNavigation(); + const [intersectionNode, setIntersectionNode] = useState(null); useIntersectionObserver({ onIntersect: () => onLoadMore(), node: intersectionNode }); + const { joinCommunity, leaveCommunity } = useCommunityActions(); + return ( -
- {communityCollection.map((community: Amity.Community) => ( - +
+ {communityCollection.map((community) => ( + + + goToCommunityProfilePage(communityId)} + onCategoryClick={(categoryId) => goToCommunitiesByCategoryPage({ categoryId })} + onJoinButtonClick={(communityId) => joinCommunity(communityId)} + onLeaveButtonClick={(communityId) => leaveCommunity(communityId)} + showJoinButton={showJoinButton} + maxCategoriesLength={5} + /> + ))} {isLoading ? Array.from({ length: 5 }).map((_, index) => ( - + + + + )) : null}
setIntersectionNode(node)} /> diff --git a/src/v4/social/components/EmptyNewsFeed/EmptyNewsFeed.tsx b/src/v4/social/components/EmptyNewsFeed/EmptyNewsFeed.tsx index 35407f4cf..def6c3e33 100644 --- a/src/v4/social/components/EmptyNewsFeed/EmptyNewsFeed.tsx +++ b/src/v4/social/components/EmptyNewsFeed/EmptyNewsFeed.tsx @@ -8,6 +8,7 @@ import { CreateCommunityButton } from '~/v4/social/elements/CreateCommunityButto import styles from './EmptyNewsFeed.module.css'; import { useAmityComponent } from '~/v4/core/hooks/uikit'; +import { useNavigation } from '~/v4/core/providers/NavigationProvider'; interface EmptyNewsfeedProps { pageId?: string; @@ -15,23 +16,29 @@ interface EmptyNewsfeedProps { export function EmptyNewsfeed({ pageId = '*' }: EmptyNewsfeedProps) { const componentId = 'empty_newsfeed'; - const { accessibilityId, config, defaultConfig, isExcluded, uiReference, themeStyles } = - useAmityComponent({ - pageId, - componentId, - }); + + const { goToCommunityCreatePage } = useNavigation(); + + const { isExcluded, themeStyles, accessibilityId } = useAmityComponent({ + pageId, + componentId, + }); if (isExcluded) return null; return ( -
+
<Description pageId={pageId} componentId={componentId} /> </div> <ExploreCommunitiesButton pageId={pageId} componentId={componentId} /> - <CreateCommunityButton pageId={pageId} componentId={componentId} onClick={() => {}} /> + <CreateCommunityButton + pageId={pageId} + componentId={componentId} + onClick={goToCommunityCreatePage} + /> </div> ); } diff --git a/src/v4/social/components/ExploreCommunityCategories/ExploreCommunityCategories.module.css b/src/v4/social/components/ExploreCommunityCategories/ExploreCommunityCategories.module.css new file mode 100644 index 000000000..57bdec485 --- /dev/null +++ b/src/v4/social/components/ExploreCommunityCategories/ExploreCommunityCategories.module.css @@ -0,0 +1,44 @@ +.exploreCommunityCategories { + display: inline-flex; + align-items: center; + gap: 0.5rem; + width: 100%; + overflow-x: scroll; + -ms-overflow-style: none; + scrollbar-width: none; +} + +.exploreCommunityCategories::-webkit-scrollbar { + display: none; +} + +.exploreCommunityCategories__seeMore { + display: flex; + align-items: center; + gap: 0.5rem; + border-radius: 1.125rem; + padding: 0 0.75rem; + border: 1px solid var(--asc-color-base-shade4); + background-color: var(--asc-color-background-default); + height: 2.25rem; + flex-wrap: nowrap; + color: var(--asc-color-base-default); + text-wrap: nowrap; +} + +.exploreCommunityCategories__seeMoreIcon { + width: 1rem; + height: 1rem; + fill: var(--asc-color-base-default); +} + +.exploreCommunityCategories__seeMore:hover { + background-color: var(--asc-color-background-shade1); +} + +.exploreCommunityCategories__categoryChip { + display: flex; + justify-content: center; + align-items: center; + height: 2.25rem; +} diff --git a/src/v4/social/components/ExploreCommunityCategories/ExploreCommunityCategories.stories.tsx b/src/v4/social/components/ExploreCommunityCategories/ExploreCommunityCategories.stories.tsx new file mode 100644 index 000000000..b0b822f31 --- /dev/null +++ b/src/v4/social/components/ExploreCommunityCategories/ExploreCommunityCategories.stories.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import { ExploreCommunityCategories } from './ExploreCommunityCategories'; + +export default { + title: 'v4-social/components/ExploreCommunityCategories', +}; + +export const ExploreCommunityCategoriesStory = { + render: () => { + return <ExploreCommunityCategories />; + }, + + name: 'ExploreCommunityCategories', +}; diff --git a/src/v4/social/components/ExploreCommunityCategories/ExploreCommunityCategories.tsx b/src/v4/social/components/ExploreCommunityCategories/ExploreCommunityCategories.tsx new file mode 100644 index 000000000..a28a0b577 --- /dev/null +++ b/src/v4/social/components/ExploreCommunityCategories/ExploreCommunityCategories.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useRef } from 'react'; +import useIntersectionObserver from '~/v4/core/hooks/useIntersectionObserver'; + +import { useAmityComponent } from '~/v4/core/hooks/uikit'; +import { CategoryChip } from '~/v4/social/elements/CategoryChip/CategoryChip'; +import { Typography } from '~/v4/core/components'; +import { useNavigation } from '~/v4/core/providers/NavigationProvider'; +import { Button } from '~/v4/core/natives/Button/Button'; +import clsx from 'clsx'; +import { useExplore } from '~/v4/social/providers/ExploreProvider'; +import { CategoryChipSkeleton } from '~/v4/social/elements/CategoryChip/CategoryChipSkeleton'; + +import styles from './ExploreCommunityCategories.module.css'; +import ChevronRight from '~/v4/icons/ChevronRight'; + +interface ExploreCommunityCategoriesProps { + pageId?: string; +} + +export const ExploreCommunityCategories = ({ pageId = '*' }: ExploreCommunityCategoriesProps) => { + const componentId = 'explore_community_categories'; + const { accessibilityId, themeStyles } = useAmityComponent({ + pageId, + componentId, + }); + + const { goToAllCategoriesPage, goToCommunitiesByCategoryPage } = useNavigation(); + + const { categories, isLoading, fetchCommunityCategories } = useExplore(); + + useEffect(() => { + fetchCommunityCategories(); + }, []); + + const intersectionRef = useRef<HTMLDivElement>(null); + + useIntersectionObserver({ + node: intersectionRef.current, + onIntersect: () => {}, + }); + + if (isLoading) { + return ( + <div + className={styles.exploreCommunityCategories} + style={themeStyles} + data-qa-anchor={accessibilityId} + > + {Array.from({ length: 5 }).map((_, index) => ( + <CategoryChipSkeleton key={index} /> + ))} + </div> + ); + } + + return ( + <div + className={styles.exploreCommunityCategories} + style={themeStyles} + data-qa-anchor={accessibilityId} + > + {categories.map((category) => ( + <div className={styles.exploreCommunityCategories__categoryChip} key={category.categoryId}> + <CategoryChip + pageId={pageId} + category={category} + onClick={(categoryId) => goToCommunitiesByCategoryPage({ categoryId })} + /> + </div> + ))} + + {categories.length >= 5 ? ( + <Typography.BodyBold + renderer={({ typoClassName }) => ( + <Button + className={clsx(typoClassName, styles.exploreCommunityCategories__seeMore)} + onPress={() => goToAllCategoriesPage()} + > + <div>See more</div> + <ChevronRight className={styles.exploreCommunityCategories__seeMoreIcon} /> + </Button> + )} + /> + ) : null} + </div> + ); +}; diff --git a/src/v4/social/components/ExploreCommunityCategories/index.tsx b/src/v4/social/components/ExploreCommunityCategories/index.tsx new file mode 100644 index 000000000..97e51fefc --- /dev/null +++ b/src/v4/social/components/ExploreCommunityCategories/index.tsx @@ -0,0 +1 @@ +export { ExploreCommunityCategories } from './ExploreCommunityCategories'; diff --git a/src/v4/social/components/ExploreCommunityEmpty/ExploreCommunityEmpty.module.css b/src/v4/social/components/ExploreCommunityEmpty/ExploreCommunityEmpty.module.css new file mode 100644 index 000000000..5851eb613 --- /dev/null +++ b/src/v4/social/components/ExploreCommunityEmpty/ExploreCommunityEmpty.module.css @@ -0,0 +1,15 @@ +.exploreCommunityEmpty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background-color: var(--asc-color-background-default); + gap: 1rem; +} + +.exploreCommunityEmpty__text { + padding-bottom: 1.0625rem; + color: var(--asc-color-base-shade3); +} diff --git a/src/v4/social/components/ExploreCommunityEmpty/ExploreCommunityEmpty.tsx b/src/v4/social/components/ExploreCommunityEmpty/ExploreCommunityEmpty.tsx new file mode 100644 index 000000000..11e8aecf3 --- /dev/null +++ b/src/v4/social/components/ExploreCommunityEmpty/ExploreCommunityEmpty.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useAmityComponent } from '~/v4/core/hooks/uikit'; +import { useNavigation } from '~/v4/core/providers/NavigationProvider'; +import { Description } from '~/v4/social/elements/Description/Description'; +import { ExploreCreateCommunity } from '~/v4/social/elements/ExploreCreateCommunity/ExploreCreateCommunity'; +import { ExploreEmptyImage } from '~/v4/social/elements/ExploreEmptyImage'; +import { Title } from '~/v4/social/elements/Title/Title'; + +import styles from './ExploreCommunityEmpty.module.css'; + +interface ExploreCommunityEmptyProps { + pageId?: string; +} + +export function ExploreCommunityEmpty({ pageId = '*' }: ExploreCommunityEmptyProps) { + const componentId = 'explore_community_empty'; + + const { goToCommunityCreatePage } = useNavigation(); + + const { themeStyles, accessibilityId } = useAmityComponent({ + componentId, + pageId, + }); + + return ( + <div + className={styles.exploreCommunityEmpty} + style={themeStyles} + data-qa-anchor={accessibilityId} + > + <ExploreEmptyImage pageId={pageId} componentId={componentId} /> + <div className={styles.exploreCommunityEmpty__text}> + <Title pageId={pageId} componentId={componentId} /> + <Description pageId={pageId} componentId={componentId} /> + </div> + <ExploreCreateCommunity + pageId={pageId} + componentId={componentId} + onClick={goToCommunityCreatePage} + /> + </div> + ); +} diff --git a/src/v4/social/components/ExploreCommunityEmpty/index.ts b/src/v4/social/components/ExploreCommunityEmpty/index.ts new file mode 100644 index 000000000..ca97d0b66 --- /dev/null +++ b/src/v4/social/components/ExploreCommunityEmpty/index.ts @@ -0,0 +1 @@ +export { ExploreCommunityEmpty } from './ExploreCommunityEmpty'; diff --git a/src/v4/social/components/ExploreEmpty/ExploreEmpty.module.css b/src/v4/social/components/ExploreEmpty/ExploreEmpty.module.css new file mode 100644 index 000000000..ce355cf9c --- /dev/null +++ b/src/v4/social/components/ExploreEmpty/ExploreEmpty.module.css @@ -0,0 +1,15 @@ +.exploreEmpty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background-color: var(--asc-color-background-default); + gap: 1rem; +} + +.exploreEmpty__text { + padding-bottom: 1.0625rem; + color: var(--asc-color-base-shade3); +} diff --git a/src/v4/social/components/ExploreEmpty/ExploreEmpty.tsx b/src/v4/social/components/ExploreEmpty/ExploreEmpty.tsx new file mode 100644 index 000000000..6a61bea6d --- /dev/null +++ b/src/v4/social/components/ExploreEmpty/ExploreEmpty.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useAmityComponent } from '~/v4/core/hooks/uikit'; +import { useNavigation } from '~/v4/core/providers/NavigationProvider'; +import { Description } from '~/v4/social/elements/Description/Description'; +import { ExploreCreateCommunity } from '~/v4/social/elements/ExploreCreateCommunity/ExploreCreateCommunity'; +import { ExploreEmptyImage } from '~/v4/social/elements/ExploreEmptyImage/ExploreEmptyImage'; +import { Title } from '~/v4/social/elements/Title/Title'; + +import styles from './ExploreEmpty.module.css'; + +interface ExploreEmptyProps { + pageId?: string; +} + +export function ExploreEmpty({ pageId = '*' }: ExploreEmptyProps) { + const componentId = 'explore_empty'; + + const { goToCommunityCreatePage } = useNavigation(); + + const { themeStyles, accessibilityId } = useAmityComponent({ + componentId, + pageId, + }); + + return ( + <div className={styles.exploreEmpty} style={themeStyles} data-qa-anchor={accessibilityId}> + <ExploreEmptyImage pageId={pageId} componentId={componentId} /> + <div className={styles.exploreEmpty__text}> + <Title pageId={pageId} componentId={componentId} /> + <Description pageId={pageId} componentId={componentId} /> + </div> + <ExploreCreateCommunity + pageId={pageId} + componentId={componentId} + onClick={goToCommunityCreatePage} + /> + </div> + ); +} diff --git a/src/v4/social/components/ExploreEmpty/index.ts b/src/v4/social/components/ExploreEmpty/index.ts new file mode 100644 index 000000000..772ca67a8 --- /dev/null +++ b/src/v4/social/components/ExploreEmpty/index.ts @@ -0,0 +1 @@ +export { ExploreEmpty } from './ExploreEmpty'; diff --git a/src/v4/social/components/GlobalFeed/GlobalFeed.module.css b/src/v4/social/components/GlobalFeed/GlobalFeed.module.css index ddaa89ca6..f7fa0d357 100644 --- a/src/v4/social/components/GlobalFeed/GlobalFeed.module.css +++ b/src/v4/social/components/GlobalFeed/GlobalFeed.module.css @@ -45,6 +45,10 @@ width: 100%; } +.global_feed__divider + .global_feed__divider { + display: none; +} + .global_feed__post__divider { width: 100%; background-color: var(--asc-color-base-shade4); diff --git a/src/v4/social/components/GlobalFeed/GlobalFeed.tsx b/src/v4/social/components/GlobalFeed/GlobalFeed.tsx index c6ccaf817..5df69d96b 100644 --- a/src/v4/social/components/GlobalFeed/GlobalFeed.tsx +++ b/src/v4/social/components/GlobalFeed/GlobalFeed.tsx @@ -1,17 +1,16 @@ -import React, { useRef, useState } from 'react'; +import React, { useState } from 'react'; import { PostContent, PostContentSkeleton } from '~/v4/social/components/PostContent'; import { EmptyNewsfeed } from '~/v4/social/components/EmptyNewsFeed/EmptyNewsFeed'; import useIntersectionObserver from '~/v4/core/hooks/useIntersectionObserver'; import { usePageBehavior } from '~/v4/core/providers/PageBehaviorProvider'; - -import styles from './GlobalFeed.module.css'; import { useAmityComponent } from '~/v4/core/hooks/uikit'; import { PostAd } from '~/v4/social/internal-components/PostAd/PostAd'; -import { Button } from '~/v4/core/natives/Button'; import { AmityPostCategory, AmityPostContentComponentStyle, } from '~/v4/social/components/PostContent/PostContent'; +import { ClickableArea } from '~/v4/core/natives/ClickableArea'; +import styles from './GlobalFeed.module.css'; interface GlobalFeedProps { pageId?: string; @@ -68,12 +67,13 @@ export const GlobalFeed = ({ return ( <div className={styles.global_feed} style={themeStyles} data-qa-anchor={accessibilityId}> {items.map((item, index) => ( - <div key={getItemKey(item, items[Math.max(0, index - 1)])}> + <React.Fragment key={getItemKey(item, items[Math.max(0, index - 1)])}> {index !== 0 ? <div className={styles.global_feed__divider} /> : null} {isAmityAd(item) ? ( <PostAd ad={item} /> ) : ( - <Button + <ClickableArea + elementType="div" className={styles.global_feed__postContainer} onPress={() => AmityGlobalFeedComponentBehavior?.goToPostDetailPage?.({ postId: item.postId }) @@ -89,17 +89,18 @@ export const GlobalFeed = ({ }} onPostDeleted={onPostDeleted} /> - </Button> + </ClickableArea> )} - </div> + </React.Fragment> ))} + {items.length > 0 ? <div className={styles.global_feed__divider} /> : null} {isLoading ? Array.from({ length: 2 }).map((_, index) => ( <div key={index}> - <div className={styles.global_feed__divider} /> <div className={styles.global_feed__postSkeletonContainer}> <PostContentSkeleton /> </div> + {index !== 1 ? <div className={styles.global_feed__divider} /> : null} </div> )) : null} diff --git a/src/v4/social/components/Newsfeed/Newsfeed.module.css b/src/v4/social/components/Newsfeed/Newsfeed.module.css index 215bf7297..ac85223eb 100644 --- a/src/v4/social/components/Newsfeed/Newsfeed.module.css +++ b/src/v4/social/components/Newsfeed/Newsfeed.module.css @@ -47,6 +47,10 @@ width: 100%; } +.newsfeed__divider + .newsfeed__divider { + display: none; +} + .newsfeed__post__divider { width: 100%; background-color: var(--asc-color-base-shade4); diff --git a/src/v4/social/components/Newsfeed/Newsfeed.tsx b/src/v4/social/components/Newsfeed/Newsfeed.tsx index 741aa2244..b1a4de7bd 100644 --- a/src/v4/social/components/Newsfeed/Newsfeed.tsx +++ b/src/v4/social/components/Newsfeed/Newsfeed.tsx @@ -6,40 +6,7 @@ import { GlobalFeed } from '~/v4/social/components/GlobalFeed'; import styles from './Newsfeed.module.css'; import { useAmityComponent } from '~/v4/core/hooks/uikit'; import { useGlobalFeedContext } from '~/v4/social/providers/GlobalFeedProvider'; - -const Spinner = (props: React.SVGProps<SVGSVGElement>) => { - return ( - <div className={styles.newsfeed__pullToRefresh__spinner}> - <svg - xmlns="http://www.w3.org/2000/svg" - width="21" - height="20" - viewBox="0 0 21 20" - fill="none" - > - <path - fillRule="evenodd" - clipRule="evenodd" - d="M11.1122 5C11.1122 5.39449 10.7924 5.71429 10.3979 5.71429C10.0035 5.71429 9.68366 5.39449 9.68366 5V0.714286C9.68366 0.319797 10.0035 0 10.3979 0C10.7924 0 11.1122 0.319797 11.1122 0.714286V5ZM8.25509 6.28846C8.59673 6.09122 8.71378 5.65437 8.51654 5.31273L6.37368 1.60119C6.17644 1.25955 5.73959 1.1425 5.39795 1.33975C5.05631 1.53699 4.93926 1.97384 5.1365 2.31548L7.27936 6.02702C7.4766 6.36865 7.91346 6.48571 8.25509 6.28846ZM6.42496 6.88141C6.7666 7.07865 6.88366 7.51551 6.68641 7.85714C6.48917 8.19878 6.05232 8.31583 5.71068 8.11859L1.99914 5.97573C1.6575 5.77849 1.54045 5.34164 1.7377 5C1.93494 4.65836 2.37179 4.54131 2.71343 4.73855L6.42496 6.88141ZM6.11224 10C6.11224 9.60551 5.79244 9.28571 5.39795 9.28571H1.11223C0.717746 9.28571 0.397949 9.60551 0.397949 10C0.397949 10.3945 0.717746 10.7143 1.11224 10.7143H5.39795C5.79244 10.7143 6.11224 10.3945 6.11224 10ZM5.71068 11.8814C6.05232 11.6842 6.48917 11.8012 6.68641 12.1429C6.88366 12.4845 6.7666 12.9213 6.42497 13.1186L2.71343 15.2614C2.37179 15.4587 1.93494 15.3416 1.7377 15C1.54045 14.6584 1.6575 14.2215 1.99914 14.0243L5.71068 11.8814ZM8.25509 13.7115C7.91345 13.5143 7.4766 13.6313 7.27936 13.973L5.1365 17.6845C4.93926 18.0262 5.05631 18.463 5.39795 18.6603C5.73959 18.8575 6.17644 18.7404 6.37368 18.3988L8.51654 14.6873C8.71378 14.3456 8.59673 13.9088 8.25509 13.7115ZM10.3979 14.2857C10.0035 14.2857 9.68366 14.6055 9.68366 15V19.2857C9.68366 19.6802 10.0035 20 10.3979 20C10.7924 20 11.1122 19.6802 11.1122 19.2857V15C11.1122 14.6055 10.7924 14.2857 10.3979 14.2857ZM12.5408 6.28846C12.8824 6.48571 13.3193 6.36865 13.5165 6.02702L15.6594 2.31548C15.8566 1.97384 15.7396 1.53699 15.3979 1.33975C15.0563 1.1425 14.6195 1.25956 14.4222 1.60119L12.2794 5.31273C12.0821 5.65437 12.1992 6.09122 12.5408 6.28846ZM15.0852 8.11859C14.7436 8.31583 14.3067 8.19878 14.1095 7.85714C13.9122 7.51551 14.0293 7.07866 14.3709 6.88141L18.0825 4.73855C18.4241 4.54131 18.861 4.65836 19.0582 5C19.2554 5.34164 19.1384 5.77849 18.7968 5.97573L15.0852 8.11859ZM14.6837 10C14.6837 10.3945 15.0035 10.7143 15.3979 10.7143H19.6837C20.0782 10.7143 20.3979 10.3945 20.3979 10C20.3979 9.60551 20.0782 9.28571 19.6837 9.28571H15.3979C15.0035 9.28571 14.6837 9.60551 14.6837 10ZM14.3709 13.1186C14.0293 12.9213 13.9122 12.4845 14.1095 12.1429C14.3067 11.8012 14.7436 11.6842 15.0852 11.8814L18.7968 14.0243C19.1384 14.2215 19.2554 14.6584 19.0582 15C18.861 15.3416 18.4241 15.4587 18.0825 15.2614L14.3709 13.1186ZM12.5408 13.7115C12.1992 13.9088 12.0821 14.3456 12.2794 14.6873L14.4222 18.3988C14.6195 18.7404 15.0563 18.8575 15.3979 18.6603C15.7396 18.463 15.8566 18.0262 15.6594 17.6845L13.5165 13.973C13.3193 13.6313 12.8824 13.5143 12.5408 13.7115Z" - fill="url(#paint0_angular_1709_10374)" - /> - <defs> - <radialGradient - id="paint0_angular_1709_10374" - cx="0" - cy="0" - r="1" - gradientUnits="userSpaceOnUse" - gradientTransform="translate(10.3979 10) scale(10)" - > - <stop offset="0.669733" stopColor="#595F67" /> - <stop offset="0.716307" stopColor="#262626" stopOpacity="0.01" /> - </radialGradient> - </defs> - </svg> - </div> - ); -}; +import { RefreshSpinner } from '~/v4/icons/RefreshSpinner'; interface NewsfeedProps { pageId?: string; @@ -103,7 +70,7 @@ export const Newsfeed = ({ pageId = '*' }: NewsfeedProps) => { } className={styles.newsfeed__pullToRefresh} > - <Spinner /> + <RefreshSpinner className={styles.newsfeed__pullToRefresh__spinner} /> </div> <div className={styles.newsfeed__divider} /> <StoryTab type="globalFeed" pageId={pageId} /> diff --git a/src/v4/social/components/PostContent/ImageContent/ImageContent.tsx b/src/v4/social/components/PostContent/ImageContent/ImageContent.tsx index 34e67c542..26002e171 100644 --- a/src/v4/social/components/PostContent/ImageContent/ImageContent.tsx +++ b/src/v4/social/components/PostContent/ImageContent/ImageContent.tsx @@ -24,12 +24,16 @@ const ImageThumbnail = ({ fileId }: { fileId: string }) => { }; const Image = ({ + pageId = '*', + componentId = '*', postId, index, imageLeftCount, postAmount, onImageClick, }: { + pageId?: string; + componentId?: string; postId: string; index: number; imageLeftCount: number; @@ -44,6 +48,7 @@ const Image = ({ return ( <Button + data-qa-anchor={`${pageId}/${componentId}/post_image`} key={imagePost.postId} className={styles.imageContent__imgContainer} onPress={() => onImageClick()} @@ -93,6 +98,8 @@ export const ImageContent = ({ > {first4Images.map((postId: string, index: number) => ( <Image + pageId={pageId} + componentId={componentId} key={postId} postId={postId} index={index} diff --git a/src/v4/social/components/PostContent/LinkPreview/LinkPreview.tsx b/src/v4/social/components/PostContent/LinkPreview/LinkPreview.tsx index 46bd6f219..1c78b0d8d 100644 --- a/src/v4/social/components/PostContent/LinkPreview/LinkPreview.tsx +++ b/src/v4/social/components/PostContent/LinkPreview/LinkPreview.tsx @@ -52,10 +52,12 @@ const usePreviewLink = ({ url }: { url: string }) => { }; interface LinkPreviewProps { + pageId?: string; + componentId?: string; url: string; } -export function LinkPreview({ url }: LinkPreviewProps) { +export function LinkPreview({ pageId = '*', componentId = '*', url }: LinkPreviewProps) { const urlObject = new URL(url); const previewData = usePreviewLink({ url }); @@ -82,7 +84,11 @@ export function LinkPreview({ url }: LinkPreviewProps) { } return ( - <Button onPress={handleClick} className={styles.linkPreview}> + <Button + data-qa-anchor={`${pageId}/${componentId}/post_preview_link`} + onPress={handleClick} + className={styles.linkPreview} + > <div className={styles.linkPreview__top}> {previewData.data?.image ? ( <object data={previewData.data.image} className={styles.linkPreview__object}> diff --git a/src/v4/social/components/PostContent/PostContent.module.css b/src/v4/social/components/PostContent/PostContent.module.css index a2afeebcb..8e7d7c1a7 100644 --- a/src/v4/social/components/PostContent/PostContent.module.css +++ b/src/v4/social/components/PostContent/PostContent.module.css @@ -5,7 +5,7 @@ .postContent__bar { display: grid; - grid-template-columns: min-content 1fr min-content; + grid-template-columns: min-content minmax(0, 1fr) min-content; align-items: center; gap: 0.5rem; } @@ -18,6 +18,10 @@ padding-bottom: 0.5rem; } +.postContent__bar__detail { + overflow: hidden; +} + .postContent__bar__information__subtitle { display: flex; align-items: center; @@ -31,6 +35,13 @@ gap: 0.25rem; } +.postContent__bar__information__subtitle__brand { + display: flex; + justify-content: center; + align-items: center; + gap: 0.25rem; +} + .postContent__bar__information__subtitle__separator { color: var(--asc-color-base-shade2); } @@ -92,13 +103,22 @@ } .postTitle { - display: flex; + display: grid; align-items: center; + grid-template-columns: minmax(0, max-content); + grid-auto-flow: column; gap: 0.25rem; } +.postTitle[data-show-target-community='true'] { + grid-template-columns: minmax(0, max-content) minmax(50%, 1fr); +} + .postTitle__text { color: var(--asc-color-base-default); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .postTitle__icon { @@ -107,6 +127,68 @@ width: 1rem; } +.postTitle__user__container { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-auto-flow: column; + gap: 0.25rem; + align-items: center; + max-width: 100%; +} + +.postTitle__user__container[data-show-brand-badge='true'][data-show-target='true'] { + grid-template-columns: minmax(0, min-content) min-content min-content; +} + +.postTitle__user__container[data-show-brand-badge='false'][data-show-target='true'], +.postTitle__user__container[data-show-brand-badge='true'][data-show-target='false'] { + grid-template-columns: minmax(0, min-content) min-content; +} + +.postTitle__brandIcon { + width: 1.25rem; + height: 1.25rem; +} + +.postTitle__community { + display: grid; + grid-template-columns: minmax(0, min-content); + grid-auto-flow: column; + gap: 0.25rem; + align-items: center; + max-width: 100%; +} + +.postTitle__community[data-show-private-badge='true'][data-show-official-badge='true'] { + grid-template-columns: min-content minmax(0, min-content) min-content; +} + +.postTitle__community[data-show-private-badge='true'][data-show-official-badge='false'] { + grid-template-columns: min-content minmax(0, min-content); +} + +.postTitle__community[data-show-private-badge='false'][data-show-official-badge='true'] { + grid-template-columns: minmax(0, min-content) min-content; +} + +.postTitle__communityText { + color: var(--asc-color-base-default); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.postTitle__community__privateIcon { + width: 0.75rem; + height: 0.75rem; +} + +.postTitle__community__verifiedIcon { + width: 1.25rem; + height: 1.25rem; +} + .postContent__comment { display: grid; } @@ -261,6 +343,12 @@ color: var(--asc-color-base-shade2); } +.postContent__wrapRightMenu { + display: flex; + align-items: center; + gap: 0.25rem; +} + @keyframes fade-in { from { opacity: 0; diff --git a/src/v4/social/components/PostContent/PostContent.tsx b/src/v4/social/components/PostContent/PostContent.tsx index 31618cb6c..e852da74d 100644 --- a/src/v4/social/components/PostContent/PostContent.tsx +++ b/src/v4/social/components/PostContent/PostContent.tsx @@ -6,7 +6,6 @@ import { ModeratorBadge } from '~/v4/social/elements/ModeratorBadge'; import { MenuButton } from '~/v4/social/elements/MenuButton'; import { ShareButton } from '~/v4/social/elements/ShareButton'; import useCommunity from '~/v4/core/hooks/collections/useCommunity'; -import { useUser } from '~/v4/core/hooks/objects/useUser'; import { Typography } from '~/v4/core/components'; import AngleRight from '~/v4/icons/AngleRight'; import { UserAvatar } from '~/v4/social/internal-components/UserAvatar'; @@ -39,6 +38,12 @@ import { PageTypes, useNavigation } from '~/v4/core/providers/NavigationProvider import dayjs from 'dayjs'; import { useVisibilitySensor } from '~/v4/social/hooks/useVisibilitySensor'; import { AnnouncementBadge } from '~/v4/social/elements/AnnouncementBadge'; +import { PinBadge } from '~/v4/social/elements/PinBadge'; +import { BrandBadge } from '~/v4/social/internal-components/BrandBadge'; +import clsx from 'clsx'; +import { Lock } from '~/icons'; +import Verified from '~/v4/icons/Verified'; +import { useUser } from '~/v4/core/hooks/objects/useUser'; export enum AmityPostContentComponentStyle { FEED = 'feed', @@ -49,112 +54,147 @@ export enum AmityPostCategory { GENERAL = 'general', ANNOUNCEMENT = 'announcement', PIN = 'pin', + PIN_AND_ANNOUNCEMENT = 'pin_and_announcement', } interface PostTitleProps { post: Amity.Post; pageId?: string; + componentId?: string; hideTarget?: boolean; } -const PostTitle = ({ pageId, post, hideTarget }: PostTitleProps) => { - const shouldCall = useMemo(() => post?.targetType === 'community', [post?.targetType]); +const PostTitle = ({ pageId, componentId, post, hideTarget }: PostTitleProps) => { + const shouldCallCommunity = useMemo(() => post?.targetType === 'community', [post?.targetType]); + const shouldCallUser = useMemo( + () => post?.targetType === 'user' && post?.postedUserId !== post?.targetId, + [post?.targetType, post?.postedUserId, post?.targetId], + ); const { community: targetCommunity } = useCommunity({ communityId: post?.targetId, - shouldCall, + shouldCall: shouldCallCommunity, }); - const { goToCommunityProfilePage } = useNavigation(); + const { user: targetUser } = useUser({ + userId: post?.targetId, + shouldCall: shouldCallUser, + }); - const { user: postedUser } = useUser(post.postedUserId); - const { onClickCommunity, onClickUser } = useNavigation(); + const { goToCommunityProfilePage, onClickUser } = useNavigation(); - if (targetCommunity) { - return ( - <div className={styles.postTitle}> - {postedUser && ( - <Button onPress={() => onClickUser(postedUser.userId)}> - <Typography.BodyBold className={styles.postTitle__text}> - {postedUser.displayName} - </Typography.BodyBold> - </Button> - )} - {targetCommunity && !hideTarget && ( - <Button onPress={() => goToCommunityProfilePage(targetCommunity.communityId)}> - <AngleRight className={styles.postTitle__icon} /> - <Typography.BodyBold className={styles.postTitle__text}> - {targetCommunity.displayName} - </Typography.BodyBold>{' '} - </Button> - )} - </div> - ); - } + const showTargetCommunity = targetCommunity && !hideTarget; + const showTargetUser = targetUser && !hideTarget; + const showBrandBadge = post.creator.isBrand; + const showPrivateBadge = targetCommunity?.isPublic === false; + const showOfficialBadge = targetCommunity?.isOfficial === true; + + const showTarget = showTargetCommunity || showTargetUser; return ( - <Button onPress={() => postedUser && onClickUser(postedUser.userId)}> - <Typography.BodyBold className={styles.postTitle__text}> - {postedUser?.displayName} - </Typography.BodyBold> - </Button> + <div className={styles.postTitle} data-show-target-community={showTargetCommunity === true}> + {post.creator && ( + <div + className={styles.postTitle__user__container} + data-show-brand-badge={showBrandBadge === true} + data-show-target={showTarget === true} + > + <Typography.BodyBold + renderer={({ typoClassName }) => ( + <Button + className={clsx(typoClassName, styles.postTitle__text)} + onPress={() => onClickUser(post.creator.userId)} + data-qa-anchor={`${pageId}/${componentId}/username`} + > + {post.creator.displayName} + </Button> + )} + /> + {showBrandBadge ? <BrandBadge className={styles.postTitle__brandIcon} /> : null} + + {showTarget ? ( + <AngleRight + data-qa-anchor={`${pageId}/${componentId}/arrow_right`} + className={styles.postTitle__icon} + /> + ) : null} + </div> + )} + {showTargetCommunity && ( + <div + className={styles.postTitle__community} + data-show-private-badge={showPrivateBadge === true} + data-show-official-badge={showOfficialBadge === true} + > + {showPrivateBadge && <Lock className={styles.postTitle__community__privateIcon} />} + <Typography.BodyBold + renderer={({ typoClassName }) => ( + <Button + data-qa-anchor={`${pageId}/${componentId}/community_name`} + className={clsx(typoClassName, styles.postTitle__communityText)} + onPress={() => { + goToCommunityProfilePage(targetCommunity.communityId); + }} + > + {targetCommunity.displayName} + </Button> + )} + /> + {showOfficialBadge && <Verified className={styles.postTitle__community__verifiedIcon} />} + </div> + )} + {showTargetUser && ( + <div + className={styles.postTitle__user__container} + data-show-brand-badge={targetUser?.isBrand === true} + data-show-target={false} + > + <Typography.BodyBold + renderer={({ typoClassName }) => ( + <Button + className={clsx(typoClassName, styles.postTitle__text)} + onPress={() => onClickUser(targetUser.userId)} + > + {targetUser.displayName} + </Button> + )} + /> + {targetUser?.isBrand === true ? ( + <BrandBadge className={styles.postTitle__brandIcon} /> + ) : null} + </div> + )} + </div> ); }; -const useMutateAddReaction = ({ - postId, - reactionByMe, -}: { - postId: string; - reactionByMe: string | null; -}) => - useMutation({ - mutationFn: async (reactionKey: string) => { - if (reactionByMe) { - try { - await ReactionRepository.removeReaction('post', postId, reactionByMe); - } catch { - console.log("Can't remove reaction."); - } - } - return ReactionRepository.addReaction('post', postId, reactionKey); - }, - }); - -const useMutateRemoveReaction = ({ - postId, - reactionsByMe, -}: { - postId: string; - reactionsByMe: string[]; -}) => - useMutation({ - mutationFn: async () => { - return Promise.all( - reactionsByMe.map((reaction) => { - try { - return ReactionRepository.removeReaction('post', postId, reaction); - } catch (e) { - console.log("Can't remove reaction."); - } - }), - ); - }, - }); - const ChildrenPostContent = ({ + pageId, + componentId, post, onImageClick, onVideoClick, }: { + pageId?: string; + componentId?: string; post: Amity.Post[]; onImageClick: (imageIndex: number) => void; onVideoClick: (videoIndex: number) => void; }) => { return ( <> - <ImageContent post={post} onImageClick={onImageClick} /> - <VideoContent post={post} onVideoClick={onVideoClick} /> + <ImageContent + pageId={pageId} + componentId={componentId} + post={post} + onImageClick={onImageClick} + /> + <VideoContent + pageId={pageId} + componentId={componentId} + post={post} + onVideoClick={onVideoClick} + /> </> ); }; @@ -181,7 +221,7 @@ export const PostContent = ({ style, }: PostContentProps) => { const componentId = 'post_content'; - const { themeStyles } = useAmityComponent({ + const { themeStyles, accessibilityId } = useAmityComponent({ pageId, componentId, }); @@ -328,17 +368,28 @@ export const PostContent = ({ }, [post, isVisible, page.type]); return ( - <div ref={elementRef} className={styles.postContent} style={themeStyles}> - {category === AmityPostCategory.ANNOUNCEMENT && ( + <div + data-qa-anchor={accessibilityId} + ref={elementRef} + className={styles.postContent} + style={themeStyles} + > + {(category === AmityPostCategory.ANNOUNCEMENT || + category === AmityPostCategory.PIN_AND_ANNOUNCEMENT) && ( <AnnouncementBadge pageId={pageId} componentId={componentId} /> )} <div className={styles.postContent__bar} data-type={style}> <div className={styles.postContent__bar__userAvatar}> - <UserAvatar userId={post?.postedUserId} /> + <UserAvatar pageId={pageId} componentId={componentId} userId={post?.postedUserId} /> </div> - <div> + <div className={styles.postContent__bar__detail}> <div> - <PostTitle post={post} hideTarget={hideTarget} /> + <PostTitle + post={post} + hideTarget={hideTarget} + pageId={pageId} + componentId={componentId} + /> </div> <div className={styles.postContent__bar__information__subtitle}> {isCommunityModerator ? ( @@ -349,42 +400,60 @@ export const PostContent = ({ ) : null} <Timestamp timestamp={post.createdAt} /> {post.createdAt !== post.editedAt && ( - <Typography.Caption className={styles.postContent__bar__information__editedTag}> + <Typography.Caption + data-qa-anchor={`${pageId}/${componentId}/post_edited_text`} + className={styles.postContent__bar__information__editedTag} + > (edited) </Typography.Caption> )} </div> </div> - {style === AmityPostContentComponentStyle.FEED ? ( - <div className={styles.postContent__bar__actionButton}> - {!hideMenu && ( - <MenuButton - pageId={pageId} - componentId={componentId} - onClick={() => - setDrawerData({ - content: ( - <PostMenu - post={post} - onCloseMenu={() => removeDrawerData()} - pageId={pageId} - componentId={componentId} - onPostDeleted={onPostDeleted} - /> - ), - }) - } - /> - )} - </div> - ) : null} + <div className={styles.postContent__wrapRightMenu}> + {(category === AmityPostCategory.PIN || + category === AmityPostCategory.PIN_AND_ANNOUNCEMENT) && ( + <PinBadge pageId={pageId} componentId={componentId} /> + )} + + {style === AmityPostContentComponentStyle.FEED ? ( + <div className={styles.postContent__bar__actionButton}> + {!hideMenu && ( + <MenuButton + pageId={pageId} + componentId={componentId} + onClick={() => + setDrawerData({ + content: ( + <PostMenu + post={post} + onCloseMenu={() => removeDrawerData()} + pageId={pageId} + componentId={componentId} + onPostDeleted={onPostDeleted} + /> + ), + }) + } + /> + )} + </div> + ) : null} + </div> </div> <div className={styles.postContent__content_and_reactions}> <div className={styles.postContent__content}> - <TextContent text={post.data.text} mentionees={post?.metadata?.mentioned} /> + <TextContent + pageId={pageId} + componentId={componentId} + text={post?.data?.text} + mentioned={post?.metadata?.mentioned} + mentionees={post?.mentioness} + /> {post.children.length > 0 ? ( <ChildrenPostContent + pageId={pageId} + componentId={componentId} post={post} onImageClick={openImageViewer} onVideoClick={openVideoViewer} @@ -426,14 +495,20 @@ export const PostContent = ({ )} </div> ) : null} - <Typography.Caption className={styles.postContent__reactionsBar__reactions__count}> + <Typography.Caption + data-qa-anchor={`${pageId}/${componentId}/like_count`} + className={styles.postContent__reactionsBar__reactions__count} + > {`${millify(post?.reactionsCount || 0)} ${ post?.reactionsCount === 1 ? 'like' : 'likes' }`} </Typography.Caption> </div> - <Typography.Caption className={styles.postContent__commentsCount}> + <Typography.Caption + data-qa-anchor={`${pageId}/${componentId}/comment_count`} + className={styles.postContent__commentsCount} + > {`${post?.commentsCount || 0} ${post?.commentsCount === 1 ? 'comment' : 'comments'}`} </Typography.Caption> </div> @@ -444,7 +519,9 @@ export const PostContent = ({ Join community to interact with all posts </Typography.Body> </> - ) : !targetCommunity?.isJoined && page.type === PageTypes.PostDetailPage ? null : ( + ) : targetCommunity && + !targetCommunity?.isJoined && + page.type === PageTypes.PostDetailPage ? null : ( <> <div className={styles.postContent__divider} /> <div className={styles.postContent__reactionBar}> diff --git a/src/v4/social/components/PostContent/TextContent/TextContent.tsx b/src/v4/social/components/PostContent/TextContent/TextContent.tsx index 0303dc108..9c0b595cc 100644 --- a/src/v4/social/components/PostContent/TextContent/TextContent.tsx +++ b/src/v4/social/components/PostContent/TextContent/TextContent.tsx @@ -1,11 +1,12 @@ import React, { useState, useMemo, ReactNode, useEffect } from 'react'; import { Linkify } from '~/v4/social/internal-components/Linkify'; -import { Mentioned, findChunks, processChunks } from '~/v4/helpers/utils'; +import { Mentioned, findChunks, processChunks, Mentionees } from '~/v4/helpers/utils'; import { Typography } from '~/v4/core/components'; import { LinkPreview } from '~/v4/social/components/PostContent/LinkPreview/LinkPreview'; import styles from './TextContent.module.css'; import * as linkify from 'linkifyjs'; +import { TextWithMention } from '~/v4/social/internal-components/TextWithMention/TextWithMention'; interface MentionHighlightTagProps { children: ReactNode; @@ -19,24 +20,20 @@ const MentionHighlightTag = ({ children }: MentionHighlightTagProps) => { const MAX_TEXT_LENGTH = 500; interface TextContentProps { + pageId?: string; + componentId?: string; text?: string; - mentionees?: Mentioned[]; + mentioned?: Mentioned[]; + mentionees?: Mentionees; } -export const TextContent = ({ text = '', mentionees }: TextContentProps) => { - const needReadMore = text.length > MAX_TEXT_LENGTH; - - const [isReadMoreClick, setIsReadMoreClick] = useState(false); - - const isShowReadMore = needReadMore && !isReadMoreClick; - - const chunks = useMemo(() => { - if (isShowReadMore) { - return processChunks(text.substring(0, MAX_TEXT_LENGTH), findChunks(mentionees)); - } - return processChunks(text, findChunks(mentionees)); - }, [mentionees, text, isShowReadMore]); - +export const TextContent = ({ + pageId = '*', + componentId = '*', + text = '', + mentionees = [], + mentioned, +}: TextContentProps) => { if (!text) { return null; } @@ -45,30 +42,18 @@ export const TextContent = ({ text = '', mentionees }: TextContentProps) => { return ( <> - <Typography.Body className={styles.postContent}> - {chunks.map((chunk) => { - const key = `${text}-${chunk.start}-${chunk.end}`; - const sub = text.substring(chunk.start, chunk.end); - if (chunk.highlight) { - const mentionee = mentionees?.find((m) => m.index === chunk.start); - if (mentionee) { - return ( - <MentionHighlightTag key={key} mentionee={mentionee}> - {sub} - </MentionHighlightTag> - ); - } - return <span key={key}>{sub}</span>; - } - return <Linkify key={key}>{sub}</Linkify>; - })} - {isShowReadMore ? ( - <span className={styles.postContent__readmore} onClick={() => setIsReadMoreClick(true)}> - ...Read more - </span> - ) : null} - </Typography.Body> - {linksFounded && linksFounded.length > 0 && <LinkPreview url={linksFounded[0].href} />} + <TextWithMention + pageId={pageId} + componentId={componentId} + data={{ text: text }} + mentionees={mentionees} + metadata={{ + mentioned, + }} + /> + {linksFounded && linksFounded.length > 0 && ( + <LinkPreview pageId={pageId} componentId={componentId} url={linksFounded[0].href} /> + )} </> ); }; diff --git a/src/v4/social/components/PostContent/VideoContent/VideoContent.tsx b/src/v4/social/components/PostContent/VideoContent/VideoContent.tsx index 5cb60fa5b..2b9006c5e 100644 --- a/src/v4/social/components/PostContent/VideoContent/VideoContent.tsx +++ b/src/v4/social/components/PostContent/VideoContent/VideoContent.tsx @@ -35,12 +35,16 @@ const VideoThumbnail = ({ fileId }: { fileId: string }) => { }; const Video = ({ + pageId = '*', + componentId = '*', postId, postAmount, videoLeftCount, index, onVideoClick, }: { + pageId?: string; + componentId?: string; postId: string; postAmount: number; videoLeftCount: number; @@ -58,6 +62,7 @@ const Video = ({ className={styles.videoContent__videoContainer} data-videos-amount={Math.min(postAmount, 4)} onPress={() => onVideoClick()} + data-qa-anchor={`${pageId}/${componentId}/post_video`} > <VideoThumbnail fileId={videoPost.data.thumbnailFileId} /> {videoLeftCount > 0 && index === postAmount - 1 && ( @@ -91,7 +96,7 @@ export const VideoContent = ({ post, onVideoClick, }: VideoContentProps) => { - const { themeStyles } = useAmityElement({ + const { themeStyles, accessibilityId } = useAmityElement({ pageId, componentId, elementId, @@ -116,6 +121,8 @@ export const VideoContent = ({ > {first4Videos.map((postId: string, index: number) => ( <Video + pageId={pageId} + componentId={componentId} key={postId} index={index} postId={postId} diff --git a/src/v4/social/components/RecommendedCommunities/RecommendedCommunities.module.css b/src/v4/social/components/RecommendedCommunities/RecommendedCommunities.module.css new file mode 100644 index 000000000..8ed6cc448 --- /dev/null +++ b/src/v4/social/components/RecommendedCommunities/RecommendedCommunities.module.css @@ -0,0 +1,91 @@ +.recommendedCommunities { + display: inline-flex; + flex-wrap: nowrap; + gap: 0.75rem; + overflow-x: scroll; + width: 100%; + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +.recommendedCommunityCard { + border-radius: var(--asc-border-radius-sm); + border: 1px solid var(--asc-color-base-shade4); + width: 16.75rem; + height: calc(7.8rem + 6rem); +} + +.recommendedCommunityCard__image { + width: 100%; + height: 7.8rem; + overflow: hidden; +} + +.recommendedCommunityCard__img { + border-radius: var(--asc-border-radius-sm) var(--asc-border-radius-sm) 0 0; +} + +.recommendedCommunityCard__content { + display: flex; + flex-direction: column; + padding: 0.62rem; + width: 100%; +} + +.recommendedCommunityCard__bottom { + display: flex; + justify-content: space-between; + flex-wrap: nowrap; + gap: 0.5rem; + width: 100%; +} + +.recommendedCommunityCard__content__left { + display: flex; + justify-content: center; + align-items: start; + flex-direction: column; + gap: 0.25rem; + flex-shrink: 1; + width: 9rem; +} + +.recommendedCommunities__contentTitle { + display: flex; + align-items: center; + gap: 0.12rem; + width: 100%; +} + +.recommendedCommunities__content__right { + display: flex; + align-items: end; + align-self: flex-end; + flex: 0 0 4.2rem; +} + +.recommendedCommunityCard__name { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recommendedCommunityCard__communityName { + display: flex; + justify-content: start; + align-items: center; + gap: 0.25rem; + width: 100%; +} + +.recommendedCommunityCard__communityName__private, +.recommendedCommunityCard__communityName__official { + width: 1.25rem; + height: 1.25rem; + display: flex; + justify-content: center; + align-items: center; + padding-top: 0.22rem; + padding-bottom: 0.28rem; +} diff --git a/src/v4/social/components/RecommendedCommunities/RecommendedCommunities.stories.tsx b/src/v4/social/components/RecommendedCommunities/RecommendedCommunities.stories.tsx new file mode 100644 index 000000000..1ce483276 --- /dev/null +++ b/src/v4/social/components/RecommendedCommunities/RecommendedCommunities.stories.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import { RecommendedCommunities } from './RecommendedCommunities'; + +export default { + title: 'v4-social/components/RecommendedCommunities', +}; + +export const RecommendedCommunitiesStory = { + render: () => { + return <RecommendedCommunities />; + }, + + name: 'RecommendedCommunities', +}; diff --git a/src/v4/social/components/RecommendedCommunities/RecommendedCommunities.tsx b/src/v4/social/components/RecommendedCommunities/RecommendedCommunities.tsx new file mode 100644 index 000000000..141e00c83 --- /dev/null +++ b/src/v4/social/components/RecommendedCommunities/RecommendedCommunities.tsx @@ -0,0 +1,178 @@ +import React, { useEffect } from 'react'; + +import styles from './RecommendedCommunities.module.css'; +import { useAmityComponent } from '~/v4/core/hooks/uikit'; +import { CommunityJoinButton } from '~/v4/social/elements/CommunityJoinButton/CommunityJoinButton'; +import { CommunityMembersCount } from '~/v4/social/elements/CommunityMembersCount/CommunityMembersCount'; +import { CommunityCategories } from '~/v4/social/internal-components/CommunityCategories/CommunityCategories'; +import { CommunityPrivateBadge } from '~/v4/social/elements/CommunityPrivateBadge/CommunityPrivateBadge'; +import { CommunityDisplayName } from '~/v4/social/elements/CommunityDisplayName/CommunityDisplayName'; +import { CommunityOfficialBadge } from '~/v4/social/elements/CommunityOfficialBadge/CommunityOfficialBadge'; +import { useNavigation } from '~/v4/core/providers/NavigationProvider'; +import { CommunityCardImage } from '~/v4/social/elements/CommunityCardImage'; +import useImage from '~/core/hooks/useImage'; +import { RecommendedCommunityCardSkeleton } from './RecommendedCommunityCardSkeleton'; +import { useExplore } from '~/v4/social/providers/ExploreProvider'; +import { CommunityJoinedButton } from '~/v4/social/elements/CommunityJoinedButton/CommunityJoinedButton'; +import { useCommunityActions } from '~/v4/social/hooks/useCommunityActions'; +import { ClickableArea } from '~/v4/core/natives/ClickableArea/ClickableArea'; + +interface RecommendedCommunityCardProps { + community: Amity.Community; + pageId: string; + componentId: string; + onClick: (communityId: string) => void; + onCategoryClick?: (categoryId: string) => void; + onJoinButtonClick: (communityId: string) => void; + onLeaveButtonClick: (communityId: string) => void; +} + +const RecommendedCommunityCard = ({ + pageId, + componentId, + community, + onClick, + onCategoryClick, + onJoinButtonClick, + onLeaveButtonClick, +}: RecommendedCommunityCardProps) => { + const avatarUrl = useImage({ + fileId: community.avatarFileId, + imageSize: 'medium', + }); + + return ( + <ClickableArea + elementType="div" + className={styles.recommendedCommunityCard} + onPress={() => onClick(community.communityId)} + > + <div className={styles.recommendedCommunityCard__image}> + <CommunityCardImage + pageId={pageId} + componentId={componentId} + imgSrc={avatarUrl} + className={styles.recommendedCommunityCard__img} + /> + </div> + <div className={styles.recommendedCommunityCard__content}> + <div className={styles.recommendedCommunities__contentTitle}> + {!community.isPublic && ( + <div className={styles.recommendedCommunityCard__communityName__private}> + <CommunityPrivateBadge pageId={pageId} componentId={componentId} /> + </div> + )} + <CommunityDisplayName pageId={pageId} componentId={componentId} community={community} /> + {community.isOfficial && ( + <div className={styles.recommendedCommunityCard__communityName__official}> + <CommunityOfficialBadge pageId={pageId} componentId={componentId} /> + </div> + )} + </div> + <div className={styles.recommendedCommunityCard__bottom}> + <div className={styles.recommendedCommunityCard__content__left}> + <CommunityCategories + pageId={pageId} + componentId={componentId} + community={community} + onClick={onCategoryClick} + truncate + maxCategoryCharacters={5} + maxCategoriesLength={2} + /> + <CommunityMembersCount + pageId={pageId} + componentId={componentId} + memberCount={community.membersCount} + /> + </div> + <div className={styles.recommendedCommunities__content__right}> + {community.isJoined ? ( + <CommunityJoinedButton + pageId={pageId} + componentId={componentId} + onClick={() => onLeaveButtonClick(community.communityId)} + /> + ) : ( + <CommunityJoinButton + pageId={pageId} + componentId={componentId} + onClick={() => onJoinButtonClick(community.communityId)} + /> + )} + </div> + </div> + </div> + </ClickableArea> + ); +}; + +interface RecommendedCommunitiesProps { + pageId?: string; +} + +export const RecommendedCommunities = ({ pageId = '*' }: RecommendedCommunitiesProps) => { + const componentId = 'recommended_communities'; + const { accessibilityId, themeStyles } = useAmityComponent({ + pageId, + componentId, + }); + + const { goToCommunitiesByCategoryPage, goToCommunityProfilePage } = useNavigation(); + + const { + recommendedCommunities, + isLoading, + fetchRecommendedCommunities, + refetchRecommendedCommunities, + } = useExplore(); + + useEffect(() => { + fetchRecommendedCommunities(); + }, []); + + const { joinCommunity, leaveCommunity } = useCommunityActions({ + onJoinSuccess: () => { + refetchRecommendedCommunities(); + }, + }); + + const handleJoinButtonClick = (communityId: string) => joinCommunity(communityId); + + const handleLeaveButtonClick = (communityId: string) => leaveCommunity(communityId); + + if (isLoading) { + return ( + <div className={styles.recommendedCommunities}> + {Array.from({ length: 4 }).map((_, index) => ( + <RecommendedCommunityCardSkeleton key={index} /> + ))} + </div> + ); + } + + if (recommendedCommunities.length === 0) { + return null; + } + + return ( + <div + style={themeStyles} + data-qa-anchor={accessibilityId} + className={styles.recommendedCommunities} + > + {recommendedCommunities.map((community) => ( + <RecommendedCommunityCard + key={community.communityId} + community={community} + pageId={pageId} + componentId={componentId} + onClick={(communityId) => goToCommunityProfilePage(communityId)} + onCategoryClick={(categoryId) => goToCommunitiesByCategoryPage({ categoryId })} + onJoinButtonClick={handleJoinButtonClick} + onLeaveButtonClick={handleLeaveButtonClick} + /> + ))} + </div> + ); +}; diff --git a/src/v4/social/components/RecommendedCommunities/RecommendedCommunityCardSkeleton.module.css b/src/v4/social/components/RecommendedCommunities/RecommendedCommunityCardSkeleton.module.css new file mode 100644 index 000000000..f46e6cee4 --- /dev/null +++ b/src/v4/social/components/RecommendedCommunities/RecommendedCommunityCardSkeleton.module.css @@ -0,0 +1,63 @@ +.recommendedCommunityCardSkeleton { + width: 17.375rem; + height: 13.3125rem; + border-radius: 0.75rem; + border: 1px solid var(--asc-color-base-shade4); + box-shadow: var(--asc-box-shadow-02); +} + +.recommendedCommunityCardSkeleton__image { + width: 17.375rem; + height: 8.1875rem; + border-radius: 0.75rem 0.75rem 0 0; + background-color: var(--asc-color-base-shade4); +} + +.recommendedCommunityCardSkeleton__content { + width: 17.375rem; + height: 5.12rem; + display: flex; + flex-direction: column; + padding: 1.06rem 1rem; +} + +.recommendedCommunityCardSkeleton__contentBar1 { + border-radius: 0.75rem; + background-color: var(--asc-color-base-shade4); + width: 5.1875rem; + height: 0.5rem; +} + +.recommendedCommunityCardSkeleton__contentBar2 { + margin-top: 1rem; + border-radius: 0.75rem; + background-color: var(--asc-color-base-shade4); + width: 8.9055rem; + height: 0.5rem; +} + +.recommendedCommunityCardSkeleton__contentBar3 { + margin-top: 0.5rem; + border-radius: 0.75rem; + background-color: var(--asc-color-base-shade4); + width: 11.25rem; + height: 0.5rem; +} + +.recommendedCommunityCardSkeleton__animation { + animation: skeleton-pulse 1.5s ease-in-out infinite; +} + +@keyframes skeleton-pulse { + 0% { + opacity: 0.6; + } + + 50% { + opacity: 1; + } + + 100% { + opacity: 0.6; + } +} diff --git a/src/v4/social/components/RecommendedCommunities/RecommendedCommunityCardSkeleton.tsx b/src/v4/social/components/RecommendedCommunities/RecommendedCommunityCardSkeleton.tsx new file mode 100644 index 000000000..54a52e0a1 --- /dev/null +++ b/src/v4/social/components/RecommendedCommunities/RecommendedCommunityCardSkeleton.tsx @@ -0,0 +1,40 @@ +import clsx from 'clsx'; +import React from 'react'; + +import styles from './RecommendedCommunityCardSkeleton.module.css'; + +export const RecommendedCommunityCardSkeleton = () => ( + <div className={styles.recommendedCommunityCardSkeleton}> + <div + className={clsx( + styles.recommendedCommunityCardSkeleton__image, + styles.recommendedCommunityCardSkeleton__animation, + )} + /> + <div + className={clsx( + styles.recommendedCommunityCardSkeleton__content, + styles.recommendedCommunityCardSkeleton__animation, + )} + > + <div + className={clsx( + styles.recommendedCommunityCardSkeleton__contentBar1, + styles.recommendedCommunityCardSkeleton__animation, + )} + /> + <div + className={clsx( + styles.recommendedCommunityCardSkeleton__contentBar2, + styles.recommendedCommunityCardSkeleton__animation, + )} + /> + <div + className={clsx( + styles.recommendedCommunityCardSkeleton__contentBar3, + styles.recommendedCommunityCardSkeleton__animation, + )} + /> + </div> + </div> +); diff --git a/src/v4/social/components/RecommendedCommunities/index.tsx b/src/v4/social/components/RecommendedCommunities/index.tsx new file mode 100644 index 000000000..5cd2e5a96 --- /dev/null +++ b/src/v4/social/components/RecommendedCommunities/index.tsx @@ -0,0 +1 @@ +export { RecommendedCommunities } from './RecommendedCommunities'; diff --git a/src/v4/social/components/ReplyComment/ReplyComment.tsx b/src/v4/social/components/ReplyComment/ReplyComment.tsx index 62aa5c0cd..b2e5ad2ac 100644 --- a/src/v4/social/components/ReplyComment/ReplyComment.tsx +++ b/src/v4/social/components/ReplyComment/ReplyComment.tsx @@ -21,6 +21,7 @@ import { CommentOptions } from '~/v4/social/components/CommentOptions/CommentOpt import { CreateCommentParams } from '~/v4/social/components/CommentComposer/CommentComposer'; import { CommentInput } from '~/v4/social/components/CommentComposer/CommentInput'; import styles from './ReplyComment.module.css'; +import useCommunityPostPermission from '~/v4/social/hooks/useCommunityPostPermission'; type ReplyCommentProps = { pageId?: string; @@ -41,6 +42,11 @@ const PostReplyComment = ({ pageId = '*', community, comment }: ReplyCommentProp const [isEditing, setIsEditing] = useState(false); const [commentData, setCommentData] = useState<CreateCommentParams>(); + const { isModerator: isModeratorUser } = useCommunityPostPermission({ + community, + userId: comment.creator?.userId, + }); + const isLiked = (comment.myReactions || []).some((reaction) => reaction === 'like'); const toggleBottomSheet = () => setBottomSheetOpen((prev) => !prev); @@ -103,11 +109,13 @@ const PostReplyComment = ({ pageId = '*', community, comment }: ReplyCommentProp </div> ) : isEditing ? ( <div className={styles.postReplyComment__edit}> - <UserAvatar userId={comment.userId} /> + <UserAvatar pageId={pageId} componentId={componentId} userId={comment.userId} /> <div className={styles.postReplyComment__edit__inputWrap}> <div className={styles.postReplyComment__edit__input}> <CommentInput - community={community} + pageId={pageId} + componentId={componentId} + communityId={community?.communityId} value={{ data: { text: (comment.data as Amity.ContentDataText).text, @@ -115,8 +123,16 @@ const PostReplyComment = ({ pageId = '*', community, comment }: ReplyCommentProp mentionees: comment.mentionees as Mentionees, metadata: comment.metadata || {}, }} - onChange={(value: CreateCommentParams) => { - setCommentData(value); + onChange={(value) => { + setCommentData({ + data: { + text: value.text, + }, + mentionees: value.mentionees as Amity.UserMention[], + metadata: { + mentioned: value.mentioned, + }, + }); }} /> </div> @@ -148,15 +164,16 @@ const PostReplyComment = ({ pageId = '*', community, comment }: ReplyCommentProp style={themeStyles} data-qa-anchor={accessibilityId} > - <UserAvatar userId={comment.userId} /> + <UserAvatar pageId={pageId} componentId={componentId} userId={comment.userId} /> <div className={styles.postReplyComment__details}> <div className={styles.postReplyComment__content}> <Typography.BodyBold className={styles.postReplyComment__content__username}> {comment.creator?.displayName} </Typography.BodyBold> - - <ModeratorBadge pageId={pageId} componentId={componentId} /> + {isModeratorUser && <ModeratorBadge pageId={pageId} componentId={componentId} />} <TextWithMention + pageId={pageId} + componentId={componentId} data={{ text: (comment.data as Amity.ContentDataText).text }} mentionees={comment.mentionees as Amity.UserMention[]} metadata={comment.metadata} @@ -170,10 +187,13 @@ const PostReplyComment = ({ pageId = '*', community, comment }: ReplyCommentProp componentId={componentId} timestamp={comment.createdAt} /> - {comment.createdAt !== comment.editedAt && ' (edited)'} + <span data-qa-anchor={`${pageId}/${componentId}/reply_comment_edited_text`}> + {comment.createdAt !== comment.editedAt && ' (edited)'} + </span> </Typography.Caption> <div onClick={handleLike}> <Typography.CaptionBold + data-qa-anchor={`${pageId}/${componentId}/reply_comment_like`} className={styles.postReplyComment__secondRow__like} data-is-liked={isLiked} > @@ -181,6 +201,7 @@ const PostReplyComment = ({ pageId = '*', community, comment }: ReplyCommentProp </Typography.CaptionBold> </div> <EllipsisH + data-qa-anchor={`${pageId}/${componentId}/reply_comment_options`} className={styles.postReplyComment__secondRow__actionButton} onClick={() => setBottomSheetOpen(true)} /> @@ -202,6 +223,8 @@ const PostReplyComment = ({ pageId = '*', community, comment }: ReplyCommentProp detent="content-height" > <CommentOptions + pageId={pageId} + componentId={componentId} comment={comment} onCloseBottomSheet={toggleBottomSheet} handleEditComment={handleEditComment} diff --git a/src/v4/social/components/ReplyCommentList/ReplyCommentList.tsx b/src/v4/social/components/ReplyCommentList/ReplyCommentList.tsx index 1ec452b73..7b4b6c4e5 100644 --- a/src/v4/social/components/ReplyCommentList/ReplyCommentList.tsx +++ b/src/v4/social/components/ReplyCommentList/ReplyCommentList.tsx @@ -7,6 +7,8 @@ import ReplyComment from '~/v4/social/components/ReplyComment/ReplyComment'; import styles from './ReplyCommentList.module.css'; interface ReplyCommentProps { + pageId?: string; + componentId?: string; community?: Amity.Community; referenceId: string; referenceType: string; @@ -14,6 +16,8 @@ interface ReplyCommentProps { } export const ReplyCommentList = ({ + pageId = '*', + componentId = '*', referenceId, referenceType, community, @@ -36,7 +40,9 @@ export const ReplyCommentList = ({ <div> {isLoading && <CommentSkeleton numberOfSkeletons={3} />} {comments.map((comment) => { - return <ReplyComment community={community} comment={comment as Amity.Comment} />; + return ( + <ReplyComment pageId={pageId} community={community} comment={comment as Amity.Comment} /> + ); })} {hasMore && ( <div diff --git a/src/v4/social/components/StoryTab/StoryTabCommunity.tsx b/src/v4/social/components/StoryTab/StoryTabCommunity.tsx index bcde21eeb..acfbd9abf 100644 --- a/src/v4/social/components/StoryTab/StoryTabCommunity.tsx +++ b/src/v4/social/components/StoryTab/StoryTabCommunity.tsx @@ -63,7 +63,7 @@ export const StoryTabCommunityFeed: React.FC<StoryTabCommunityFeedProps> = ({ const { community } = useCommunityInfo(communityId); const { currentUserId, client } = useSDK(); - const { user } = useUser(currentUserId); + const { user } = useUser({ userId: currentUserId }); const isGlobalAdmin = isAdmin(user?.roles); const hasStoryPermission = isGlobalAdmin || checkStoryPermission(client, communityId); const hasStories = stories?.length > 0; @@ -115,7 +115,12 @@ export const StoryTabCommunityFeed: React.FC<StoryTabCommunityFeedProps> = ({ {isErrored && <ErrorIcon className={clsx(styles.errorIcon)} />} </div> <Truncate lines={1}> - <div className={clsx(styles.storyTitle)}>Story</div> + <div + data-qa-anchore={`${pageId}/${componentId}/story_title`} + className={clsx(styles.storyTitle)} + > + Story + </div> </Truncate> </div> ); diff --git a/src/v4/social/components/StoryTab/StoryTabGlobalFeed.tsx b/src/v4/social/components/StoryTab/StoryTabGlobalFeed.tsx index 6e4d6be36..e055de545 100644 --- a/src/v4/social/components/StoryTab/StoryTabGlobalFeed.tsx +++ b/src/v4/social/components/StoryTab/StoryTabGlobalFeed.tsx @@ -24,7 +24,7 @@ export const StoryTabGlobalFeed = ({ pageId, componentId, }); - const { stories, isLoading, hasMore, loadMoreStories } = useGlobalStoryTargets({ + const { stories, isLoading, hasMore, loadMoreStories, refresh } = useGlobalStoryTargets({ seenState: 'smart' as Amity.StorySeenQuery, limit: STORIES_PER_PAGE, }); @@ -32,6 +32,10 @@ export const StoryTabGlobalFeed = ({ const containerRef = useRef<HTMLDivElement>(null); const observerRef = useRef<HTMLDivElement>(null); + useEffect(() => { + refresh(); + }, []); + useEffect(() => { if (!containerRef.current) { return; diff --git a/src/v4/social/components/StoryTab/StoryTabItem.module.css b/src/v4/social/components/StoryTab/StoryTabItem.module.css index 17435b850..d19b31be9 100644 --- a/src/v4/social/components/StoryTab/StoryTabItem.module.css +++ b/src/v4/social/components/StoryTab/StoryTabItem.module.css @@ -46,6 +46,8 @@ overflow: hidden; text-overflow: ellipsis; color: var(--asc-color-secondary-default); + display: grid; + grid-template-columns: repeat(2, 1fr); } .errorIcon { @@ -54,3 +56,9 @@ right: 0; cursor: pointer; } + +.lockIcon { + width: 0.875rem; + height: 0.875rem; + fill: currentcolor; +} diff --git a/src/v4/social/components/StoryTab/StoryTabItem.tsx b/src/v4/social/components/StoryTab/StoryTabItem.tsx index cb2ec12c6..9c77c6659 100644 --- a/src/v4/social/components/StoryTab/StoryTabItem.tsx +++ b/src/v4/social/components/StoryTab/StoryTabItem.tsx @@ -9,6 +9,7 @@ import useCommunity from '~/v4/core/hooks/collections/useCommunity'; import { useImage } from '~/v4/core/hooks/useImage'; import styles from './StoryTabItem.module.css'; +import Lock from '~/v4/icons/Lock'; const ErrorIcon = (props: React.SVGProps<SVGSVGElement>) => { return ( @@ -86,7 +87,10 @@ export const StoryTabItem: React.FC<StoryTabProps> = ({ isErrored={isErrored} /> - <div className={styles.avatarBackground}> + <div + data-qa-anchor={`${pageId}/${componentId}/community_avatar`} + className={styles.avatarBackground} + > {communityAvatar && ( <img className={styles.avatar} src={communityAvatar} alt={community?.displayName} /> )} @@ -95,8 +99,11 @@ export const StoryTabItem: React.FC<StoryTabProps> = ({ {community?.isOfficial && !isErrored && <Verified className={styles.verifiedIcon} />} </div> - <Typography.Caption className={clsx(styles.displayName)}> - {!community?.isPublic && <LockIcon />} + <Typography.Caption + data-qa-anchor={`${pageId}/${componentId}/community_name`} + className={clsx(styles.displayName)} + > + {!community?.isPublic && <Lock className={styles.lockIcon} />} {community?.displayName} </Typography.Caption> </div> diff --git a/src/v4/social/components/TopNavigation/TopNavigation.tsx b/src/v4/social/components/TopNavigation/TopNavigation.tsx index bfbaa5609..6d8e4d6a2 100644 --- a/src/v4/social/components/TopNavigation/TopNavigation.tsx +++ b/src/v4/social/components/TopNavigation/TopNavigation.tsx @@ -28,9 +28,8 @@ export function TopNavigation({ const handleGlobalSearchClick = () => { switch (selectedTab) { case HomePageTab.Newsfeed: - goToSocialGlobalSearchPage(); - break; case HomePageTab.Explore: + goToSocialGlobalSearchPage(); break; case HomePageTab.MyCommunities: goToMyCommunitiesSearchPage(); diff --git a/src/v4/social/components/TopSearchBar/TopSearchBar.module.css b/src/v4/social/components/TopSearchBar/TopSearchBar.module.css index d08fcbe81..ee0919c89 100644 --- a/src/v4/social/components/TopSearchBar/TopSearchBar.module.css +++ b/src/v4/social/components/TopSearchBar/TopSearchBar.module.css @@ -20,6 +20,7 @@ border: none; background-color: var(--asc-color-base-shade4); color: var(--asc-color-base-shade2); + outline: none; } .topSearchBar__searchIcon { diff --git a/src/v4/social/components/TopSearchBar/TopSearchBar.tsx b/src/v4/social/components/TopSearchBar/TopSearchBar.tsx index a4d53a4c9..e40efe6c9 100644 --- a/src/v4/social/components/TopSearchBar/TopSearchBar.tsx +++ b/src/v4/social/components/TopSearchBar/TopSearchBar.tsx @@ -6,6 +6,7 @@ import { SearchIcon } from '~/v4/social/elements/SearchIcon'; import styles from './TopSearchBar.module.css'; import { useAmityComponent } from '~/v4/core/hooks/uikit'; import { useNavigation } from '~/v4/core/providers/NavigationProvider'; +import { Input } from 'react-aria-components'; export interface TopSearchBarProps { pageId?: string; @@ -14,7 +15,7 @@ export interface TopSearchBarProps { export function TopSearchBar({ pageId = '*', search }: TopSearchBarProps) { const componentId = 'top_search_bar'; - const { config, isExcluded, themeStyles } = useAmityComponent({ + const { config, isExcluded, themeStyles, accessibilityId } = useAmityComponent({ pageId, componentId, }); @@ -38,12 +39,13 @@ export function TopSearchBar({ pageId = '*', search }: TopSearchBarProps) { defaultClassName={styles.topSearchBar__searchIcon} imgClassName={styles.topSearchBar__searchIcon_img} /> - <input + <Input className={styles.topSearchBar__textInput} type="text" value={searchValue} placeholder={config.text} onChange={(ev) => setSearchValue(ev.target.value)} + data-qa-anchor={accessibilityId} /> {searchValue != '' ? ( <ClearButton diff --git a/src/v4/social/components/TrendingCommunities/TrendingCommunities.module.css b/src/v4/social/components/TrendingCommunities/TrendingCommunities.module.css new file mode 100644 index 000000000..39340617b --- /dev/null +++ b/src/v4/social/components/TrendingCommunities/TrendingCommunities.module.css @@ -0,0 +1,6 @@ +.trendingCommunities { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} diff --git a/src/v4/social/components/TrendingCommunities/TrendingCommunities.stories.tsx b/src/v4/social/components/TrendingCommunities/TrendingCommunities.stories.tsx new file mode 100644 index 000000000..a7008b1b0 --- /dev/null +++ b/src/v4/social/components/TrendingCommunities/TrendingCommunities.stories.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import { TrendingCommunities } from './TrendingCommunities'; + +export default { + title: 'v4-social/components/TrendingCommunities', +}; + +export const RecommendedCommunitiesStory = { + render: () => { + return <TrendingCommunities />; + }, + + name: 'TrendingCommunities', +}; diff --git a/src/v4/social/components/TrendingCommunities/TrendingCommunities.tsx b/src/v4/social/components/TrendingCommunities/TrendingCommunities.tsx new file mode 100644 index 000000000..15492b86a --- /dev/null +++ b/src/v4/social/components/TrendingCommunities/TrendingCommunities.tsx @@ -0,0 +1,84 @@ +import React, { useEffect } from 'react'; + +import styles from './TrendingCommunities.module.css'; +import { useAmityComponent } from '~/v4/core/hooks/uikit'; +import { useNavigation } from '~/v4/core/providers/NavigationProvider'; +import { CommunityRowItem } from '~/v4/social/internal-components/CommunityRowItem'; +import { CommunityRowItemSkeleton } from '~/v4/social/internal-components/CommunityRowItem/CommunityRowItemSkeleton'; +import { useExplore } from '~/v4/social/providers/ExploreProvider'; +import { CommunityRowItemDivider } from '~/v4/social/internal-components/CommunityRowItem/CommunityRowItemDivider'; +import { useCommunityActions } from '~/v4/social/hooks/useCommunityActions'; + +interface TrendingCommunitiesProps { + pageId?: string; +} + +export const TrendingCommunities = ({ pageId = '*' }: TrendingCommunitiesProps) => { + const componentId = 'trending_communities'; + const { accessibilityId, themeStyles } = useAmityComponent({ + pageId, + componentId, + }); + + const { trendingCommunities, isLoading, fetchTrendingCommunities } = useExplore(); + + useEffect(() => { + fetchTrendingCommunities(); + }, []); + + const { goToCommunitiesByCategoryPage, goToCommunityProfilePage } = useNavigation(); + + const { joinCommunity, leaveCommunity } = useCommunityActions(); + + const handleJoinButtonClick = (communityId: string) => joinCommunity(communityId); + const handleLeaveButtonClick = (communityId: string) => leaveCommunity(communityId); + + if (isLoading) { + return ( + <div + style={themeStyles} + data-qa-anchor={accessibilityId} + className={styles.trendingCommunities} + > + {Array.from({ length: 2 }).map((_, index) => ( + <React.Fragment key={index}> + <CommunityRowItemDivider /> + <CommunityRowItemSkeleton /> + </React.Fragment> + ))} + </div> + ); + } + + if (trendingCommunities.length === 0) { + return null; + } + + return ( + <div + style={themeStyles} + data-qa-anchor={accessibilityId} + className={styles.trendingCommunities} + > + {trendingCommunities.map((community, index) => ( + <React.Fragment key={community.communityId}> + <CommunityRowItemDivider /> + <CommunityRowItem + community={community} + pageId={pageId} + componentId={componentId} + order={index + 1} + onClick={(communityId) => goToCommunityProfilePage(communityId)} + onCategoryClick={(categoryId) => goToCommunitiesByCategoryPage({ categoryId })} + onJoinButtonClick={handleJoinButtonClick} + onLeaveButtonClick={handleLeaveButtonClick} + showJoinButton + minCategoryCharacters={3} + maxCategoryCharacters={36} + maxCategoriesLength={2} + /> + </React.Fragment> + ))} + </div> + ); +}; diff --git a/src/v4/social/components/TrendingCommunities/index.tsx b/src/v4/social/components/TrendingCommunities/index.tsx new file mode 100644 index 000000000..e641ad6dd --- /dev/null +++ b/src/v4/social/components/TrendingCommunities/index.tsx @@ -0,0 +1 @@ +export { TrendingCommunities } from './TrendingCommunities'; diff --git a/src/v4/social/components/UserSearchResult/UserSearchItem.module.css b/src/v4/social/components/UserSearchResult/UserSearchItem.module.css index 7b55aa592..9e25df17c 100644 --- a/src/v4/social/components/UserSearchResult/UserSearchItem.module.css +++ b/src/v4/social/components/UserSearchResult/UserSearchItem.module.css @@ -18,6 +18,7 @@ justify-content: center; align-items: center; width: 100%; + gap: 0.25rem; } .userItem__avatar { @@ -25,10 +26,20 @@ height: 2.5rem; } +.userItem__brandIcon__container { + width: 1.125rem; + height: 1.125rem; +} + +.userItem__brandIcon { + width: 1.125rem; + height: 1.125rem; +} + .userItem__userName { display: flex; justify-content: start; - align-items: last baseline; + align-items: last baseline baseline; gap: 0.25rem; width: 100%; color: var(--asc-color-base-default); diff --git a/src/v4/social/components/UserSearchResult/UserSearchItem.tsx b/src/v4/social/components/UserSearchResult/UserSearchItem.tsx index a67d5c0ea..788d0a94b 100644 --- a/src/v4/social/components/UserSearchResult/UserSearchItem.tsx +++ b/src/v4/social/components/UserSearchResult/UserSearchItem.tsx @@ -4,25 +4,44 @@ import { Typography } from '~/v4/core/components'; import styles from './UserSearchItem.module.css'; import { useNavigation } from '~/v4/core/providers/NavigationProvider'; import { Button } from '~/v4/core/natives/Button'; +import { BrandBadge } from '~/v4/social/internal-components/BrandBadge'; interface UserSearchItemProps { + pageId?: string; + componentId?: string; user: Amity.User; onClick?: () => void; } -export const UserSearchItem = ({ user }: UserSearchItemProps) => { +export const UserSearchItem = ({ pageId = '*', componentId = '*', user }: UserSearchItemProps) => { const { onClickUser } = useNavigation(); return ( <Button key={user.userId} className={styles.userItem} onPress={() => onClickUser(user.userId)}> - <div className={styles.userItem__leftPane}> - <UserAvatar userId={user.userId} className={styles.userItem__avatar} /> + <div + data-qa-anchor={`${pageId}/${componentId}/search_user_avatar`} + className={styles.userItem__leftPane} + > + <UserAvatar + pageId={pageId} + componentId={componentId} + userId={user.userId} + className={styles.userItem__avatar} + /> </div> <div className={styles.userItem__rightPane}> <div className={styles.userItem__userName}> - <Typography.BodyBold className={styles.userItem__userName__text}> + <Typography.BodyBold + data-qa-anchor={`${pageId}/${componentId}/search_username`} + className={styles.userItem__userName__text} + > {user.displayName} </Typography.BodyBold> + {user.isBrand ? ( + <div className={styles.userItem__brandIcon__container}> + <BrandBadge className={styles.userItem__brandIcon} /> + </div> + ) : null} </div> </div> </Button> diff --git a/src/v4/social/components/UserSearchResult/UserSearchResult.tsx b/src/v4/social/components/UserSearchResult/UserSearchResult.tsx index f419ccb6e..d18ded607 100644 --- a/src/v4/social/components/UserSearchResult/UserSearchResult.tsx +++ b/src/v4/social/components/UserSearchResult/UserSearchResult.tsx @@ -20,20 +20,19 @@ export const UserSearchResult = ({ onLoadMore, }: UserSearchResultProps) => { const componentId = 'user_search_result'; - const { accessibilityId, config, defaultConfig, isExcluded, uiReference, themeStyles } = - useAmityComponent({ - pageId, - componentId, - }); + const { accessibilityId, themeStyles } = useAmityComponent({ + pageId, + componentId, + }); const [intersectionNode, setIntersectionNode] = useState<HTMLDivElement | null>(null); useIntersectionObserver({ onIntersect: () => onLoadMore(), node: intersectionNode }); return ( - <div className={styles.userSearchResult} style={themeStyles}> + <div className={styles.userSearchResult} style={themeStyles} data-qa-anchor={accessibilityId}> {userCollection.map((user) => ( - <UserSearchItem key={user.userId} user={user} /> + <UserSearchItem pageId={pageId} componentId={componentId} key={user.userId} user={user} /> ))} {isLoading ? Array.from({ length: 5 }).map((_, index) => ( diff --git a/src/v4/social/elements/AllCategoriesTitle/AllCategoriesTitle.module.css b/src/v4/social/elements/AllCategoriesTitle/AllCategoriesTitle.module.css new file mode 100644 index 000000000..7b5b9e6a2 --- /dev/null +++ b/src/v4/social/elements/AllCategoriesTitle/AllCategoriesTitle.module.css @@ -0,0 +1,8 @@ +.communityName__truncate { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; +} diff --git a/src/v4/social/elements/AllCategoriesTitle/AllCategoriesTitle.tsx b/src/v4/social/elements/AllCategoriesTitle/AllCategoriesTitle.tsx new file mode 100644 index 000000000..fec74b7b7 --- /dev/null +++ b/src/v4/social/elements/AllCategoriesTitle/AllCategoriesTitle.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import styles from './AllCategoriesTitle.module.css'; +import { Typography } from '~/v4/core/components'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; + +interface AllCategoriesTitleProps { + pageId?: string; + componentId?: string; +} + +export const AllCategoriesTitle = ({ + pageId = '*', + componentId = '*', +}: AllCategoriesTitleProps) => { + const elementId = 'all_categories_title'; + const { config, themeStyles, accessibilityId, isExcluded } = useAmityElement({ + pageId, + componentId, + elementId, + }); + + if (isExcluded) return null; + + return ( + <Typography.Title + data-qa-anchor={accessibilityId} + className={styles.communityName__truncate} + style={themeStyles} + > + All Categories + </Typography.Title> + ); +}; diff --git a/src/v4/social/elements/AllCategoriesTitle/index.ts b/src/v4/social/elements/AllCategoriesTitle/index.ts new file mode 100644 index 000000000..13c618184 --- /dev/null +++ b/src/v4/social/elements/AllCategoriesTitle/index.ts @@ -0,0 +1 @@ +export { AllCategoriesTitle } from './AllCategoriesTitle'; diff --git a/src/v4/social/elements/CategoryChip/CategoryChip.module.css b/src/v4/social/elements/CategoryChip/CategoryChip.module.css new file mode 100644 index 000000000..0ac244b2d --- /dev/null +++ b/src/v4/social/elements/CategoryChip/CategoryChip.module.css @@ -0,0 +1,29 @@ +.categoryChip { + display: flex; + gap: 0.5rem; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + padding: 0.25rem 0.75rem 0.25rem 0.25rem; + border-radius: 1.125rem; + border: 1px solid var(--asc-color-base-shade4); + background-color: var(--asc-color-background-default); +} + +.categoryChip:hover { + background-color: var(--asc-color-background-shade1); +} + +.categoryChip__image { + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + overflow: hidden; +} + +.categoryChip__text { + color: var(--asc-color-base-default); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/v4/social/elements/CategoryChip/CategoryChip.tsx b/src/v4/social/elements/CategoryChip/CategoryChip.tsx new file mode 100644 index 000000000..b55011291 --- /dev/null +++ b/src/v4/social/elements/CategoryChip/CategoryChip.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import styles from './CategoryChip.module.css'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import { Button } from '~/v4/core/natives/Button'; +import { Typography } from '~/v4/core/components'; +import { useImage } from '~/v4/core/hooks/useImage'; +import clsx from 'clsx'; +import { CategoryImage } from '~/v4/social/internal-components/CategoryImage/CategoryImage'; + +interface CategoryChipProps { + pageId: string; + componentId?: string; + category: Amity.Category; + onClick?: (categoryId: string) => void; +} + +export const CategoryChip: React.FC<CategoryChipProps> = ({ + pageId = '*', + componentId = '*', + category, + onClick, +}) => { + const elementId = 'category_chip'; + + const { isExcluded, accessibilityId, themeStyles } = useAmityElement({ + pageId, + componentId, + elementId, + }); + + const categoryImage = useImage({ fileId: category.avatar?.fileId }); + + if (isExcluded) return null; + + return ( + <Button + data-qa-anchor={accessibilityId} + style={themeStyles} + className={styles.categoryChip} + onPress={() => onClick?.(category.categoryId)} + > + <CategoryImage + imgSrc={categoryImage} + className={styles.categoryChip__image} + pageId={pageId} + componentId={componentId} + elementId={elementId} + /> + <Typography.BodyBold + renderer={({ typoClassName }) => ( + <span className={clsx(typoClassName, styles.categoryChip__text)}>{category.name}</span> + )} + /> + </Button> + ); +}; diff --git a/src/v4/social/elements/CategoryChip/CategoryChipSkeleton.module.css b/src/v4/social/elements/CategoryChip/CategoryChipSkeleton.module.css new file mode 100644 index 000000000..20d55276d --- /dev/null +++ b/src/v4/social/elements/CategoryChip/CategoryChipSkeleton.module.css @@ -0,0 +1,22 @@ +.exploreCategories__categoryChipSkeleton { + width: 5.625rem; + height: 2.25rem; + flex-shrink: 0; + border-radius: 2.5rem; + background: var(--asc-color-base-shade4); + animation: skeleton-pulse 1.5s ease-in-out infinite; +} + +@keyframes skeleton-pulse { + 0% { + opacity: 0.6; + } + + 50% { + opacity: 1; + } + + 100% { + opacity: 0.6; + } +} diff --git a/src/v4/social/elements/CategoryChip/CategoryChipSkeleton.tsx b/src/v4/social/elements/CategoryChip/CategoryChipSkeleton.tsx new file mode 100644 index 000000000..4a2a053dc --- /dev/null +++ b/src/v4/social/elements/CategoryChip/CategoryChipSkeleton.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +import styles from './CategoryChipSkeleton.module.css'; + +export const CategoryChipSkeleton = () => ( + <div className={styles.exploreCategories__categoryChipSkeleton} /> +); diff --git a/src/v4/social/elements/CategoryChip/index.ts b/src/v4/social/elements/CategoryChip/index.ts new file mode 100644 index 000000000..14717373b --- /dev/null +++ b/src/v4/social/elements/CategoryChip/index.ts @@ -0,0 +1 @@ +export { CategoryChip } from './CategoryChip'; diff --git a/src/v4/social/elements/CategoryTitle/CategoryTitle.module.css b/src/v4/social/elements/CategoryTitle/CategoryTitle.module.css new file mode 100644 index 000000000..1f5c12a0c --- /dev/null +++ b/src/v4/social/elements/CategoryTitle/CategoryTitle.module.css @@ -0,0 +1,5 @@ +.categoryTitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/v4/social/elements/CategoryTitle/CategoryTitle.tsx b/src/v4/social/elements/CategoryTitle/CategoryTitle.tsx new file mode 100644 index 000000000..451f7e76e --- /dev/null +++ b/src/v4/social/elements/CategoryTitle/CategoryTitle.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import styles from './CategoryTitle.module.css'; +import { Typography } from '~/v4/core/components'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; + +interface CategoryTitleProps { + pageId?: string; + componentId?: string; + categoryName: string; +} + +export const CategoryTitle = ({ + pageId = '*', + componentId = '*', + categoryName, +}: CategoryTitleProps) => { + const elementId = 'all_categories_title'; + const { config, themeStyles, accessibilityId, isExcluded } = useAmityElement({ + pageId, + componentId, + elementId, + }); + + if (isExcluded) return null; + + return ( + <Typography.Title + data-qa-anchor={accessibilityId} + className={styles.categoryTitle} + style={themeStyles} + > + {categoryName} + </Typography.Title> + ); +}; diff --git a/src/v4/social/elements/CategoryTitle/index.ts b/src/v4/social/elements/CategoryTitle/index.ts new file mode 100644 index 000000000..6ba158372 --- /dev/null +++ b/src/v4/social/elements/CategoryTitle/index.ts @@ -0,0 +1 @@ +export { CategoryTitle } from './CategoryTitle'; diff --git a/src/v4/social/elements/CommentButton/CommentButton.tsx b/src/v4/social/elements/CommentButton/CommentButton.tsx index e51885c99..33ec24920 100644 --- a/src/v4/social/elements/CommentButton/CommentButton.tsx +++ b/src/v4/social/elements/CommentButton/CommentButton.tsx @@ -48,13 +48,15 @@ export function CommentButton({ if (isExcluded) return null; return ( - <Button onPress={onPress}> + <Button onPress={onPress} data-qa-anchor={accessibilityId}> <IconComponent - data-qa-anchor={accessibilityId} defaultIcon={() => ( <div className={clsx(styles.commentButton)}> <CommentSvg className={clsx(styles.commentButton__icon, defaultIconClassName)} /> - <Typography.BodyBold className={styles.commentButton__text}> + <Typography.BodyBold + data-qa-anchor={`${pageId}/${componentId}/comment_count`} + className={styles.commentButton__text} + > {typeof commentsCount === 'number' ? commentsCount : config.text} </Typography.BodyBold> </div> diff --git a/src/v4/social/elements/CommunityAvatar/CommunityAvatar.tsx b/src/v4/social/elements/CommunityAvatar/CommunityAvatar.tsx index 6c1015b50..50112e2b3 100644 --- a/src/v4/social/elements/CommunityAvatar/CommunityAvatar.tsx +++ b/src/v4/social/elements/CommunityAvatar/CommunityAvatar.tsx @@ -2,6 +2,7 @@ import React from 'react'; import useImage from '~/core/hooks/useImage'; import { useAmityElement } from '~/v4/core/hooks/uikit'; import styles from './CommunityAvatar.module.css'; +import clsx from 'clsx'; const CommunityAvatarSvg = (props: React.SVGProps<SVGSVGElement>) => ( <svg @@ -23,6 +24,7 @@ const CommunityAvatarSvg = (props: React.SVGProps<SVGSVGElement>) => ( export interface CommunityAvatarProps { pageId?: string; + className?: string; componentId?: string; community?: Amity.Community | null; } @@ -31,6 +33,7 @@ export function CommunityAvatar({ pageId = '*', componentId = '*', community, + className, }: CommunityAvatarProps) { const elementId = 'community_avatar'; const { accessibilityId, isExcluded, themeStyles } = useAmityElement({ @@ -47,11 +50,11 @@ export function CommunityAvatar({ return ( <object - data={avatarFile} type="image/png" - className={styles.communityAvatar__image} - data-qa-anchor={accessibilityId} + data={avatarFile} style={themeStyles} + data-qa-anchor={accessibilityId} + className={clsx(styles.communityAvatar__image, className)} > <CommunityAvatarSvg /> </object> diff --git a/src/v4/social/elements/CommunityCardImage/CommunityCardImage.module.css b/src/v4/social/elements/CommunityCardImage/CommunityCardImage.module.css new file mode 100644 index 000000000..76c243d35 --- /dev/null +++ b/src/v4/social/elements/CommunityCardImage/CommunityCardImage.module.css @@ -0,0 +1,20 @@ +.communityCardImage { + width: 100%; + height: 100%; + overflow: hidden; + object-fit: cover; +} + +.communityCardImage__placeholderImage { + display: flex; + justify-content: center; + align-items: center; + background-color: var(--asc-color-base-shade3); + width: 100%; + height: 100%; +} + +.communityCardImage__placeholderImageIcon { + width: 4rem; + height: 2.364rem; +} diff --git a/src/v4/social/elements/CommunityCardImage/CommunityCardImage.tsx b/src/v4/social/elements/CommunityCardImage/CommunityCardImage.tsx new file mode 100644 index 000000000..a84f7e8ab --- /dev/null +++ b/src/v4/social/elements/CommunityCardImage/CommunityCardImage.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import styles from './CommunityCardImage.module.css'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import { Img } from '~/v4/core/natives/Img/Img'; +import { People } from '~/v4/icons/People'; +import clsx from 'clsx'; + +const PlaceholderImage = ({ className }: { className?: string }) => { + return ( + <div className={clsx(styles.communityCardImage__placeholderImage, className)}> + <People className={styles.communityCardImage__placeholderImageIcon} /> + </div> + ); +}; + +interface CommunityCardImageProps { + pageId?: string; + componentId?: string; + imgSrc?: string; + className?: string; +} + +export const CommunityCardImage: React.FC<CommunityCardImageProps> = ({ + pageId = '*', + componentId = '*', + imgSrc, + className, +}) => { + const elementId = 'community_card_image'; + + const { themeStyles, accessibilityId } = useAmityElement({ + pageId, + componentId, + elementId, + }); + + return ( + <Img + data-qa-anchor={accessibilityId} + style={themeStyles} + className={clsx(styles.communityCardImage, className)} + src={imgSrc} + fallBackRenderer={() => <PlaceholderImage className={className} />} + /> + ); +}; diff --git a/src/v4/social/elements/CommunityCardImage/index.ts b/src/v4/social/elements/CommunityCardImage/index.ts new file mode 100644 index 000000000..36eea75fd --- /dev/null +++ b/src/v4/social/elements/CommunityCardImage/index.ts @@ -0,0 +1 @@ +export { CommunityCardImage } from './CommunityCardImage'; diff --git a/src/v4/social/elements/CommunityCategory/CommunityCategory.module.css b/src/v4/social/elements/CommunityCategory/CommunityCategory.module.css index fee67d974..362aeff10 100644 --- a/src/v4/social/elements/CommunityCategory/CommunityCategory.module.css +++ b/src/v4/social/elements/CommunityCategory/CommunityCategory.module.css @@ -1,11 +1,16 @@ -.community__category { - display: flex; - align-items: center; - gap: 0.5rem; - overflow-x: scroll; - width: 100%; +.communityCategory { + padding: 0.12rem 0.5rem; + border-radius: 1.25rem; + background-color: var(--asc-color-base-shade4); + color: var(--asc-color-base-default); + line-height: 1.125rem; + font-size: 0.8125rem; + white-space: nowrap; } -.community__category::-webkit-scrollbar { - display: none; +.communityCategory[data-truncated='true'] { + text-overflow: ellipsis; + overflow: hidden; + min-width: var(--asc-community-category-min-characters, unset); + max-width: var(--asc-community-category-max-characters, unset); } diff --git a/src/v4/social/elements/CommunityCategory/CommunityCategory.tsx b/src/v4/social/elements/CommunityCategory/CommunityCategory.tsx index debf081ec..937593169 100644 --- a/src/v4/social/elements/CommunityCategory/CommunityCategory.tsx +++ b/src/v4/social/elements/CommunityCategory/CommunityCategory.tsx @@ -2,17 +2,29 @@ import React from 'react'; import styles from './CommunityCategory.module.css'; import { useAmityElement } from '~/v4/core/hooks/uikit'; import { CommunityCategoryName } from '~/v4/social/elements/CommunityCategoryName'; +import { Button } from '~/v4/core/natives/Button/Button'; +import clsx from 'clsx'; interface CommunityCategoryProps { - categories: Amity.Category[]; pageId?: string; componentId?: string; + categoryName: string; + minCharacters?: number; + maxCharacters?: number; + truncate?: boolean; + className?: string; + onClick?: () => void; } export const CommunityCategory = ({ pageId = '*', componentId = '*', - categories, + categoryName, + minCharacters, + maxCharacters, + truncate = false, + className, + onClick, }: CommunityCategoryProps) => { const elementId = 'community_category'; const { config, themeStyles, accessibilityId, isExcluded } = useAmityElement({ @@ -23,15 +35,28 @@ export const CommunityCategory = ({ if (isExcluded) return null; + const categoryNameLength = categoryName.length; + return ( - <div + <Button + style={ + { + ...themeStyles, + '--asc-community-category-min-characters': + minCharacters && categoryNameLength > minCharacters + ? `${Math.min(minCharacters, categoryName.length)}ch` + : undefined, + '--asc-community-category-max-characters': maxCharacters + ? `${maxCharacters}ch` + : undefined, + } as React.CSSProperties + } data-qa-anchor={accessibilityId} - style={themeStyles} - className={styles.community__category} + data-truncated={categoryNameLength > (minCharacters ?? 0) ? truncate : false} + className={clsx(styles.communityCategory, className)} + onPress={() => onClick?.()} > - {categories.map((category) => ( - <CommunityCategoryName categoryName={category.name} /> - ))} - </div> + {categoryName} + </Button> ); }; diff --git a/src/v4/social/elements/CommunityCategoryName/CommunityCategoryName.module.css b/src/v4/social/elements/CommunityCategoryName/CommunityCategoryName.module.css index 1986addeb..fdaead2b7 100644 --- a/src/v4/social/elements/CommunityCategoryName/CommunityCategoryName.module.css +++ b/src/v4/social/elements/CommunityCategoryName/CommunityCategoryName.module.css @@ -1,12 +1,12 @@ .communityCategoryName { - display: flex; + width: 100%; padding: 0.12rem 0.5rem; - justify-content: center; - align-items: center; border-radius: 1.25rem; background-color: var(--asc-color-base-shade4); color: var(--asc-color-base-default); line-height: 1.125rem; font-size: 0.8125rem; white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; } diff --git a/src/v4/social/elements/CommunityEmptyImage/CommunityEmptyImage.tsx b/src/v4/social/elements/CommunityEmptyImage/CommunityEmptyImage.tsx new file mode 100644 index 000000000..0b0ad497c --- /dev/null +++ b/src/v4/social/elements/CommunityEmptyImage/CommunityEmptyImage.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import { IconComponent } from '~/v4/core/IconComponent'; + +interface CommunityEmptyImageProps { + pageId?: string; + componentId?: string; +} + +export const CommunityEmptyImage = ({ + pageId = '*', + componentId = '*', +}: CommunityEmptyImageProps) => { + const elementId = 'community_empty_image'; + + const { config, accessibilityId, isExcluded, uiReference, defaultConfig } = useAmityElement({ + pageId, + componentId, + elementId, + }); + + if (isExcluded) return null; + + return ( + <IconComponent + data-qa-anchor={accessibilityId} + defaultIcon={() => ( + <svg + width="55" + height="45" + viewBox="0 0 55 45" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M8.9375 0.3125C9.78125 0.3125 10.625 1.15625 10.625 2V8.75C10.625 9.69922 9.78125 10.4375 8.9375 10.4375H2.1875C1.23828 10.4375 0.5 9.69922 0.5 8.75V2C0.5 1.15625 1.23828 0.3125 2.1875 0.3125H8.9375ZM8.9375 17.1875C9.78125 17.1875 10.625 18.0312 10.625 18.875V25.625C10.625 26.5742 9.78125 27.3125 8.9375 27.3125H2.1875C1.23828 27.3125 0.5 26.5742 0.5 25.625V18.875C0.5 18.0312 1.23828 17.1875 2.1875 17.1875H8.9375ZM8.9375 34.0625C9.78125 34.0625 10.625 34.9062 10.625 35.75V42.5C10.625 43.4492 9.78125 44.1875 8.9375 44.1875H2.1875C1.23828 44.1875 0.5 43.4492 0.5 42.5V35.75C0.5 34.9062 1.23828 34.0625 2.1875 34.0625H8.9375ZM52.8125 19.7188C53.6562 19.7188 54.5 20.5625 54.5 21.4062V23.0938C54.5 24.043 53.6562 24.7812 52.8125 24.7812H19.0625C18.1133 24.7812 17.375 24.043 17.375 23.0938V21.4062C17.375 20.5625 18.1133 19.7188 19.0625 19.7188H52.8125ZM52.8125 36.5938C53.6562 36.5938 54.5 37.4375 54.5 38.2812V39.9688C54.5 40.918 53.6562 41.6562 52.8125 41.6562H19.0625C18.1133 41.6562 17.375 40.918 17.375 39.9688V38.2812C17.375 37.4375 18.1133 36.5938 19.0625 36.5938H52.8125ZM52.8125 2.84375C53.6562 2.84375 54.5 3.6875 54.5 4.53125V6.21875C54.5 7.16797 53.6562 7.90625 52.8125 7.90625H19.0625C18.1133 7.90625 17.375 7.16797 17.375 6.21875V4.53125C17.375 3.6875 18.1133 2.84375 19.0625 2.84375H52.8125Z" + fill="#EBECEF" + /> + </svg> + )} + imgIcon={() => <img src={config.icon} alt={uiReference} />} + configIconName={config.icon} + defaultIconName={defaultConfig.icon} + /> + ); +}; diff --git a/src/v4/social/elements/CommunityEmptyImage/index.ts b/src/v4/social/elements/CommunityEmptyImage/index.ts new file mode 100644 index 000000000..02f0c424a --- /dev/null +++ b/src/v4/social/elements/CommunityEmptyImage/index.ts @@ -0,0 +1 @@ +export { CommunityEmptyImage } from './CommunityEmptyImage'; diff --git a/src/v4/social/elements/CommunityEmptyTitle/CommunityEmptyTitle.module.css b/src/v4/social/elements/CommunityEmptyTitle/CommunityEmptyTitle.module.css new file mode 100644 index 000000000..651aa5758 --- /dev/null +++ b/src/v4/social/elements/CommunityEmptyTitle/CommunityEmptyTitle.module.css @@ -0,0 +1,3 @@ +.communityEmptyTitle { + color: var(--asc-color-base-shade3); +} diff --git a/src/v4/social/elements/CommunityEmptyTitle/CommunityEmptyTitle.tsx b/src/v4/social/elements/CommunityEmptyTitle/CommunityEmptyTitle.tsx new file mode 100644 index 000000000..12635363c --- /dev/null +++ b/src/v4/social/elements/CommunityEmptyTitle/CommunityEmptyTitle.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Typography } from '~/v4/core/components/Typography'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import styles from './CommunityEmptyTitle.module.css'; + +interface CommunityEmptyTitleProps { + pageId?: string; + componentId?: string; +} + +export const CommunityEmptyTitle = ({ + pageId = '*', + componentId = '*', +}: CommunityEmptyTitleProps) => { + const elementId = 'community_empty_title'; + + const { themeStyles, config, accessibilityId, isExcluded } = useAmityElement({ + pageId, + componentId, + elementId, + }); + + if (isExcluded) { + return null; + } + + return ( + <Typography.Title + style={themeStyles} + className={styles.communityEmptyTitle} + data-qa-anchor={accessibilityId} + > + {config.text} + </Typography.Title> + ); +}; diff --git a/src/v4/social/elements/CommunityEmptyTitle/index.ts b/src/v4/social/elements/CommunityEmptyTitle/index.ts new file mode 100644 index 000000000..e5d23c4b8 --- /dev/null +++ b/src/v4/social/elements/CommunityEmptyTitle/index.ts @@ -0,0 +1 @@ +export { CommunityEmptyTitle } from './CommunityEmptyTitle'; diff --git a/src/v4/social/elements/CommunityInfo/CommunityInfo.tsx b/src/v4/social/elements/CommunityInfo/CommunityInfo.tsx index 456bf11ee..3fafeca53 100644 --- a/src/v4/social/elements/CommunityInfo/CommunityInfo.tsx +++ b/src/v4/social/elements/CommunityInfo/CommunityInfo.tsx @@ -28,7 +28,11 @@ export const CommunityInfo = ({ }); if (isExcluded) return null; return ( - <Button onPress={onClick} className={styles.communityInfo__container}> + <Button + data-qa-anchor={accessibilityId} + onPress={onClick} + className={styles.communityInfo__container} + > <div className={styles.communityInfo__wrapper}> <Typography.BodyBold className={styles.communityInfo__count}> {millify(count)} diff --git a/src/v4/social/elements/CommunityJoinButton/CommunityJoinButton.module.css b/src/v4/social/elements/CommunityJoinButton/CommunityJoinButton.module.css index da25c1d29..278e86f08 100644 --- a/src/v4/social/elements/CommunityJoinButton/CommunityJoinButton.module.css +++ b/src/v4/social/elements/CommunityJoinButton/CommunityJoinButton.module.css @@ -10,7 +10,34 @@ margin-top: 1rem; } +.communityJoinButton[data-is-joined='true'] { + background: var(--asc-color-background-transparent-white); + border: 1px solid var(--asc-color-base-shade4); +} + .joinButton { width: 1.25rem; height: 1rem; + fill: var(--asc-color-white); +} + +.checkButton { + width: 1.25rem; + height: 1rem; + fill: var(--asc-color-black); +} + +.communityJoinButton__text { + color: var(--asc-color-white); + + /* IOS / 13 Caption Bold */ + font-size: 0.8125rem; + font-style: normal; + font-weight: 600; + line-height: 1.125rem; /* 138.462% */ + letter-spacing: -0.005rem; +} + +.communityJoinButton__text[data-is-joined='true'] { + color: var(--asc-color-black); } diff --git a/src/v4/social/elements/CommunityJoinButton/CommunityJoinButton.tsx b/src/v4/social/elements/CommunityJoinButton/CommunityJoinButton.tsx index 6e775613d..a490b7aed 100644 --- a/src/v4/social/elements/CommunityJoinButton/CommunityJoinButton.tsx +++ b/src/v4/social/elements/CommunityJoinButton/CommunityJoinButton.tsx @@ -5,6 +5,7 @@ import styles from './CommunityJoinButton.module.css'; import { IconComponent } from '~/v4/core/IconComponent'; import { Plus as PlusIcon } from '~/v4/icons/Plus'; import clsx from 'clsx'; +import { Typography } from '~/v4/core/components/Typography'; interface CommunityJoinButtonProps { pageId?: string; @@ -35,7 +36,7 @@ export const CommunityJoinButton = ({ <Button data-qa-anchor={accessibilityId} style={themeStyles} - onPress={onClick} + onPress={() => onClick?.()} className={clsx(styles.communityJoinButton, className)} > <IconComponent @@ -44,7 +45,9 @@ export const CommunityJoinButton = ({ defaultIconName={defaultConfig.icon} configIconName={config.icon} /> - Join + <Typography.CaptionBold className={styles.communityJoinButton__text}> + {config.text} + </Typography.CaptionBold> </Button> ); }; diff --git a/src/v4/social/elements/CommunityJoinedButton/CommunityJoinedButton.module.css b/src/v4/social/elements/CommunityJoinedButton/CommunityJoinedButton.module.css new file mode 100644 index 000000000..8c8cfbbab --- /dev/null +++ b/src/v4/social/elements/CommunityJoinedButton/CommunityJoinedButton.module.css @@ -0,0 +1,35 @@ +.communityJoinedButton { + display: flex; + width: 100%; + background: var(--asc-color-background-transparent-white); + border: 1px solid var(--asc-color-base-shade4); + padding: 0.625rem 1rem 0.625rem 0.75rem; + justify-content: center; + align-items: center; + gap: 0.5rem; + border-radius: 0.5rem; + margin-top: 1rem; +} + +.joinedButton { + width: 1.25rem; + height: 1rem; + fill: var(--asc-color-white); +} + +.checkButton { + width: 1.25rem; + height: 1rem; + fill: var(--asc-color-black); +} + +.communityJoinedButton__text { + color: var(--asc-color-black); + + /* IOS / 13 Caption Bold */ + font-size: 0.8125rem; + font-style: normal; + font-weight: 600; + line-height: 1.125rem; /* 138.462% */ + letter-spacing: -0.005rem; +} diff --git a/src/v4/social/elements/CommunityJoinedButton/CommunityJoinedButton.tsx b/src/v4/social/elements/CommunityJoinedButton/CommunityJoinedButton.tsx new file mode 100644 index 000000000..fdb87e07f --- /dev/null +++ b/src/v4/social/elements/CommunityJoinedButton/CommunityJoinedButton.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Button } from '~/v4/core/natives/Button'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import styles from './CommunityJoinedButton.module.css'; +import { IconComponent } from '~/v4/core/IconComponent'; +import clsx from 'clsx'; +import { Typography } from '~/v4/core/components/Typography'; +import Check from '~/v4/icons/Check'; + +interface CommunityJoinedButtonProps { + pageId?: string; + componentId?: string; + onClick?: () => void; + className?: string; + defaultClassName?: string; +} + +export const CommunityJoinedButton = ({ + pageId = '*', + componentId = '*', + onClick, + className, + defaultClassName, +}: CommunityJoinedButtonProps) => { + const elementId = 'community_joined_button'; + const { config, themeStyles, accessibilityId, isExcluded, uiReference, defaultConfig } = + useAmityElement({ + pageId, + componentId, + elementId, + }); + + if (isExcluded) return null; + + return ( + <Button + data-qa-anchor={accessibilityId} + style={themeStyles} + onPress={() => onClick?.()} + className={clsx(styles.communityJoinedButton, className)} + > + <IconComponent + defaultIcon={() => <Check className={clsx(styles.checkButton, defaultClassName)} />} + imgIcon={() => <img src={config.icon} alt={uiReference} />} + defaultIconName={defaultConfig.icon} + configIconName={config.icon} + /> + + <Typography.CaptionBold className={styles.communityJoinedButton__text}> + {config.text} + </Typography.CaptionBold> + </Button> + ); +}; diff --git a/src/v4/social/elements/CommunityJoinedButton/index.ts b/src/v4/social/elements/CommunityJoinedButton/index.ts new file mode 100644 index 000000000..b5c4cd298 --- /dev/null +++ b/src/v4/social/elements/CommunityJoinedButton/index.ts @@ -0,0 +1 @@ +export { CommunityJoinedButton } from './CommunityJoinedButton'; diff --git a/src/v4/social/elements/CommunityOfficialBadge/CommunityOfficialBadge.tsx b/src/v4/social/elements/CommunityOfficialBadge/CommunityOfficialBadge.tsx index caa4b526a..c919b5363 100644 --- a/src/v4/social/elements/CommunityOfficialBadge/CommunityOfficialBadge.tsx +++ b/src/v4/social/elements/CommunityOfficialBadge/CommunityOfficialBadge.tsx @@ -7,13 +7,20 @@ import styles from './CommunityOfficialBadge.module.css'; const OfficialBadgeIconSvg = (props: React.SVGProps<SVGSVGElement>) => ( <svg xmlns="http://www.w3.org/2000/svg" - width="21" - height="20" - viewBox="0 0 21 20" + width="17" + height="16" + viewBox="0 0 17 16" fill="none" {...props} > - <path d="M9.58112 15.4777C10.2749 16.1767 10.9373 16.1715 11.6363 15.4777L12.4188 14.6953C12.4918 14.6223 12.5544 14.6014 12.6483 14.6014H13.7541C14.74 14.6014 15.2095 14.1319 15.2095 13.1461V12.0402C15.2095 11.9463 15.2355 11.8785 15.3034 11.8107L16.0858 11.023C16.7848 10.3293 16.7796 9.66681 16.0858 8.97305L15.3034 8.19061C15.2355 8.11758 15.2095 8.05499 15.2095 7.9611V6.85525C15.2095 5.86938 14.74 5.39991 13.7541 5.39991H12.6483C12.5544 5.39991 12.4866 5.37383 12.4188 5.30602L11.6363 4.52358C10.9373 3.82461 10.2749 3.82461 9.58112 4.5288L8.79868 5.30602C8.73087 5.37383 8.66306 5.39991 8.56917 5.39991H7.46332C6.47745 5.39991 6.00799 5.85894 6.00799 6.85525V7.9611C6.00799 8.05499 5.9819 8.1228 5.91409 8.19061L5.13165 8.97305C4.43268 9.66681 4.43789 10.3293 5.13165 11.023L5.91409 11.8107C5.9819 11.8785 6.00799 11.9463 6.00799 12.0402V13.1461C6.00799 14.1319 6.47745 14.6014 7.46332 14.6014H8.56917C8.66306 14.6014 8.72565 14.6223 8.79868 14.6953L9.58112 15.4777ZM9.74282 12.6714C9.56026 12.6714 9.4142 12.6036 9.29944 12.4836L7.63024 10.6162C7.54156 10.5223 7.49462 10.3919 7.49462 10.2615C7.49462 9.94849 7.71892 9.72419 8.04232 9.72419C8.21968 9.72419 8.35008 9.78157 8.46484 9.90676L9.72196 11.3099L12.2362 7.72115C12.3614 7.5438 12.497 7.47077 12.7057 7.47077C13.0291 7.47077 13.2586 7.69507 13.2586 7.99761C13.2586 8.10193 13.2169 8.23234 13.1386 8.34188L10.2123 12.4471C10.0923 12.6036 9.93583 12.6714 9.74282 12.6714Z" /> + <path + d="M9.11463 13.479C8.41557 14.1728 7.75304 14.178 7.0592 13.479L6.27668 12.6964C6.20364 12.6234 6.14104 12.6025 6.04714 12.6025H4.94117C3.95519 12.6025 3.48568 12.133 3.48568 11.147V10.0411C3.48568 9.94718 3.45959 9.87936 3.39177 9.81154L2.60925 9.0238C1.91541 8.32996 1.9102 7.66743 2.60925 6.97359L3.39177 6.19107C3.45959 6.12325 3.48568 6.05543 3.48568 5.96153V4.85556C3.48568 3.85915 3.95519 3.40007 4.94117 3.40007H6.04714C6.14104 3.40007 6.20886 3.37398 6.27668 3.30616L7.0592 2.52886C7.75304 1.82459 8.41557 1.82459 9.11463 2.52364L9.89715 3.30616C9.96497 3.37398 10.0328 3.40007 10.1267 3.40007H11.2327C12.2186 3.40007 12.6882 3.86958 12.6882 4.85556V5.96153C12.6882 6.05543 12.7142 6.11803 12.7821 6.19107L13.5646 6.97359C14.2584 7.66743 14.2636 8.32996 13.5646 9.0238L12.7821 9.81154C12.7142 9.87936 12.6882 9.94718 12.6882 10.0411V11.147C12.6882 12.133 12.2186 12.6025 11.2327 12.6025H10.1267C10.0328 12.6025 9.97019 12.6234 9.89715 12.6964L9.11463 13.479Z" + fill="#1054DE" + /> + <path + d="M6.77768 10.4846C6.89245 10.6045 7.03852 10.6724 7.22111 10.6724C7.41413 10.6724 7.57063 10.6045 7.69062 10.448L10.6173 6.3424C10.6955 6.23285 10.7372 6.10243 10.7372 5.99809C10.7372 5.69551 10.5077 5.47119 10.1843 5.47119C9.97559 5.47119 9.83995 5.54423 9.71475 5.7216L7.20024 9.31077L5.94299 7.90745C5.82822 7.78224 5.69779 7.72486 5.52042 7.72486C5.19698 7.72486 4.97266 7.94918 4.97266 8.26219C4.97266 8.39261 5.01961 8.52303 5.10829 8.61694L6.77768 10.4846Z" + fill="white" + /> </svg> ); diff --git a/src/v4/social/elements/CommunityProfileTab/CommunityProfileTab.tsx b/src/v4/social/elements/CommunityProfileTab/CommunityProfileTab.tsx index b673a1cb8..36999f7d9 100644 --- a/src/v4/social/elements/CommunityProfileTab/CommunityProfileTab.tsx +++ b/src/v4/social/elements/CommunityProfileTab/CommunityProfileTab.tsx @@ -36,6 +36,7 @@ export const CommunityProfileTab: React.FC<CommunityTabsProps> = ({ className={styles.communityTabs__container} > <Button + data-qa-anchor={`${accessibilityId}_feed`} data-is-active={activeTab === 'community_feed'} onPress={() => onTabChange('community_feed')} className={styles.communityTabs__tab} @@ -43,12 +44,29 @@ export const CommunityProfileTab: React.FC<CommunityTabsProps> = ({ <FeedIcon /> </Button> <Button + data-qa-anchor={`${accessibilityId}_pin`} data-is-active={activeTab === 'community_pin'} onPress={() => onTabChange('community_pin')} className={styles.communityTabs__tab} > <PinIcon /> </Button> + {/* <Button + data-qa-anchor={`${accessibilityId}_photo`} + data-is-active={activeTab === 'community_pin'} + onPress={() => onTabChange('community_pin')} + className={styles.communityTabs__tab} + > + <PinIcon /> + </Button> + <Button + data-qa-anchor={`${accessibilityId}_video`} + data-is-active={activeTab === 'community_pin'} + onPress={() => onTabChange('community_pin')} + className={styles.communityTabs__tab} + > + <PinIcon /> + </Button> */} </div> ); }; diff --git a/src/v4/social/elements/CommunityRowImage/CommunityRowImage.module.css b/src/v4/social/elements/CommunityRowImage/CommunityRowImage.module.css new file mode 100644 index 000000000..3fd76b476 --- /dev/null +++ b/src/v4/social/elements/CommunityRowImage/CommunityRowImage.module.css @@ -0,0 +1,7 @@ +.communityRowImage__img { + width: 100%; + height: 100%; + overflow: hidden; + object-fit: cover; + border-radius: var(--asc-border-radius-sm); +} diff --git a/src/v4/social/elements/CommunityRowImage/CommunityRowImage.tsx b/src/v4/social/elements/CommunityRowImage/CommunityRowImage.tsx new file mode 100644 index 000000000..4df2dd422 --- /dev/null +++ b/src/v4/social/elements/CommunityRowImage/CommunityRowImage.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import styles from './CommunityRowImage.module.css'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import { Img } from '~/v4/core/natives/Img/Img'; + +const PlaceholderImage = () => { + return ( + <svg xmlns="http://www.w3.org/2000/svg" width="81" height="80" viewBox="0 0 81 80" fill="none"> + <g clipPath="url(#clip0_7036_40403)"> + <rect x="0.449219" width="80" height="80" rx="4" fill="#A5A9B5" /> + <path + d="M40.449 28.8C41.9151 28.8 43.3213 29.3697 44.358 30.3837C45.3947 31.3977 45.9772 32.7729 45.9772 34.2069C45.9772 35.6409 45.3947 37.0162 44.358 38.0302C43.3213 39.0442 41.9151 39.6138 40.449 39.6138C38.9828 39.6138 37.5767 39.0442 36.5399 38.0302C35.5032 37.0162 34.9208 35.6409 34.9208 34.2069C34.9208 32.7729 35.5032 31.3977 36.5399 30.3837C37.5767 29.3697 38.9828 28.8 40.449 28.8ZM29.3926 32.6621C30.2771 32.6621 31.0984 32.8938 31.8092 33.3109C31.5722 35.52 32.2356 37.7137 33.594 39.4285C32.8042 40.9115 31.2248 41.9311 29.3926 41.9311C28.1358 41.9311 26.9306 41.4428 26.042 40.5737C25.1533 39.7045 24.6541 38.5257 24.6541 37.2966C24.6541 36.0675 25.1533 34.8887 26.042 34.0195C26.9306 33.1504 28.1358 32.6621 29.3926 32.6621ZM51.5054 32.6621C52.7621 32.6621 53.9673 33.1504 54.856 34.0195C55.7446 34.8887 56.2438 36.0675 56.2438 37.2966C56.2438 38.5257 55.7446 39.7045 54.856 40.5737C53.9673 41.4428 52.7621 41.9311 51.5054 41.9311C49.6732 41.9311 48.0937 40.9115 47.3039 39.4285C48.6623 37.7137 49.3257 35.52 49.0888 33.3109C49.7995 32.8938 50.6209 32.6621 51.5054 32.6621ZM30.1823 48.4966C30.1823 45.2988 34.7786 42.7035 40.449 42.7035C46.1193 42.7035 50.7156 45.2988 50.7156 48.4966V51.2H30.1823V48.4966ZM21.4951 51.2V48.8828C21.4951 46.7355 24.4803 44.928 28.5238 44.4028C27.5919 45.4533 27.0233 46.9054 27.0233 48.4966V51.2H21.4951ZM59.4028 51.2H53.8746V48.4966C53.8746 46.9054 53.306 45.4533 52.3741 44.4028C56.4176 44.928 59.4028 46.7355 59.4028 48.8828V51.2Z" + fill="white" + /> + <rect x="0.449219" width="80" height="80" fill="url(#paint0_linear_7036_40403)" /> + </g> + <defs> + <linearGradient + id="paint0_linear_7036_40403" + x1="40.4492" + y1="40" + x2="40.4492" + y2="80" + gradientUnits="userSpaceOnUse" + > + <stop stopOpacity="0" /> + <stop offset="1" stopOpacity="0.4" /> + </linearGradient> + <clipPath id="clip0_7036_40403"> + <rect x="0.449219" width="80" height="80" rx="4" fill="white" /> + </clipPath> + </defs> + </svg> + ); +}; + +interface CommunityRowImageProps { + pageId?: string; + componentId?: string; + imgSrc?: string; +} + +export const CommunityRowImage: React.FC<CommunityRowImageProps> = ({ + pageId = '*', + componentId = '*', + imgSrc, +}) => { + const elementId = 'community_row_image'; + + const { themeStyles } = useAmityElement({ + pageId, + componentId, + elementId, + }); + + return ( + <Img + style={themeStyles} + className={styles.communityRowImage__img} + src={imgSrc} + fallBackRenderer={() => <PlaceholderImage />} + /> + ); +}; diff --git a/src/v4/social/elements/CommunityRowImage/index.ts b/src/v4/social/elements/CommunityRowImage/index.ts new file mode 100644 index 000000000..5054022bf --- /dev/null +++ b/src/v4/social/elements/CommunityRowImage/index.ts @@ -0,0 +1 @@ +export { CommunityRowImage } from './CommunityRowImage'; diff --git a/src/v4/social/elements/CreateCommunityButton/CreateCommunityButton.tsx b/src/v4/social/elements/CreateCommunityButton/CreateCommunityButton.tsx index c6936410b..2b6791bd0 100644 --- a/src/v4/social/elements/CreateCommunityButton/CreateCommunityButton.tsx +++ b/src/v4/social/elements/CreateCommunityButton/CreateCommunityButton.tsx @@ -38,7 +38,7 @@ export function CreateCommunityButton({ configIconName={config.icon} defaultIconName={defaultConfig.icon} /> - <Typography.Body>{config.text}</Typography.Body> + <Typography.Body>Create Community</Typography.Body> </div> ); } diff --git a/src/v4/social/elements/CreateNewPostButton/CreateNewPostButton.tsx b/src/v4/social/elements/CreateNewPostButton/CreateNewPostButton.tsx index 0c2757c3a..ed28d4278 100644 --- a/src/v4/social/elements/CreateNewPostButton/CreateNewPostButton.tsx +++ b/src/v4/social/elements/CreateNewPostButton/CreateNewPostButton.tsx @@ -1,12 +1,13 @@ import React from 'react'; import { useAmityElement } from '~/v4/core/hooks/uikit'; +import { Button } from '~/v4/core/natives/Button'; import styles from './CreateNewPostButton.module.css'; interface CreateNewPostButtonProps { pageId: string; componentId?: string; isValid: boolean; - onSubmit: (e: React.FormEvent) => void; + onSubmit: () => void; } export function CreateNewPostButton({ @@ -16,7 +17,7 @@ export function CreateNewPostButton({ onSubmit, }: CreateNewPostButtonProps) { const elementId = 'create_new_post_button'; - const { config, isExcluded, themeStyles } = useAmityElement({ + const { config, isExcluded, themeStyles, accessibilityId } = useAmityElement({ pageId, componentId, elementId, @@ -24,14 +25,15 @@ export function CreateNewPostButton({ if (isExcluded) return null; return ( - <button - onSubmit={onSubmit} + <Button + onPress={onSubmit} style={themeStyles} - disabled={!isValid} + isDisabled={!isValid} className={styles.createNewPostButton} type="submit" + data-qa-anchor={accessibilityId} > {config.text} - </button> + </Button> ); } diff --git a/src/v4/social/elements/EditPostButton/EditPostButton.tsx b/src/v4/social/elements/EditPostButton/EditPostButton.tsx index cc62b1f88..32e3ea054 100644 --- a/src/v4/social/elements/EditPostButton/EditPostButton.tsx +++ b/src/v4/social/elements/EditPostButton/EditPostButton.tsx @@ -10,7 +10,7 @@ type EditPostButtonProps = ButtonProps & { export function EditPostButton({ pageId = '*', componentId = '*', ...props }: EditPostButtonProps) { const elementId = 'edit_post_button'; - const { config, isExcluded, themeStyles } = useAmityElement({ + const { config, isExcluded, themeStyles, accessibilityId } = useAmityElement({ pageId, componentId, elementId, @@ -18,7 +18,12 @@ export function EditPostButton({ pageId = '*', componentId = '*', ...props }: Ed if (isExcluded) return null; return ( - <Button style={themeStyles} className={styles.editPostButton} {...props}> + <Button + data-qa-anchor={accessibilityId} + style={themeStyles} + className={styles.editPostButton} + {...props} + > {config.text} </Button> ); diff --git a/src/v4/social/elements/EditPostTitle/EditPostTitle.tsx b/src/v4/social/elements/EditPostTitle/EditPostTitle.tsx index 732857dca..cf96204f0 100644 --- a/src/v4/social/elements/EditPostTitle/EditPostTitle.tsx +++ b/src/v4/social/elements/EditPostTitle/EditPostTitle.tsx @@ -9,7 +9,7 @@ interface EditPostTitleProps { export function EditPostTitle({ pageId = '*', componentId = '*' }: EditPostTitleProps) { const elementId = 'edit_post_title'; - const { config, isExcluded, themeStyles } = useAmityElement({ + const { config, isExcluded, themeStyles, accessibilityId } = useAmityElement({ pageId, componentId, elementId, @@ -17,7 +17,7 @@ export function EditPostTitle({ pageId = '*', componentId = '*' }: EditPostTitle if (isExcluded) return null; return ( - <div style={themeStyles} className={styles.editPostTitle}> + <div data-qa-anchor={accessibilityId} style={themeStyles} className={styles.editPostTitle}> {config.text} </div> ); diff --git a/src/v4/social/elements/ExploreCreateCommunity/ExploreCreateCommunity.module.css b/src/v4/social/elements/ExploreCreateCommunity/ExploreCreateCommunity.module.css new file mode 100644 index 000000000..e16c22e9f --- /dev/null +++ b/src/v4/social/elements/ExploreCreateCommunity/ExploreCreateCommunity.module.css @@ -0,0 +1,20 @@ +.exploreCreateCommunityButton { + display: flex; + padding: 0.625rem 1rem 0.625rem 0.75rem; + justify-content: center; + align-items: center; + gap: 0.5rem; + border-radius: 0.25rem; + background: var(--asc-color-primary-default); + cursor: pointer; +} + +.exploreCreateCommunityButton__icon { + fill: var(--asc-color-white); + height: 1.25rem; + width: 1.25rem; +} + +.exploreCreateCommunityButton__text { + color: var(--asc-color-white); +} diff --git a/src/v4/social/elements/ExploreCreateCommunity/ExploreCreateCommunity.tsx b/src/v4/social/elements/ExploreCreateCommunity/ExploreCreateCommunity.tsx new file mode 100644 index 000000000..51ba78cc0 --- /dev/null +++ b/src/v4/social/elements/ExploreCreateCommunity/ExploreCreateCommunity.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Typography } from '~/v4/core/components'; +import styles from './ExploreCreateCommunity.module.css'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import { Plus } from '~/v4/icons/Plus'; +import { Button } from '~/v4/core/natives/Button'; + +interface DescriptionProps { + pageId?: string; + componentId?: string; + onClick?: () => void; +} + +export function ExploreCreateCommunity({ + pageId = '*', + componentId = '*', + onClick, +}: DescriptionProps) { + const elementId = 'explore_create_community'; + const { accessibilityId, config, defaultConfig, isExcluded, uiReference, themeStyles } = + useAmityElement({ + pageId, + componentId, + elementId, + }); + + if (isExcluded) return null; + + return ( + <Button + className={styles.exploreCreateCommunityButton} + data-qa-anchor={accessibilityId} + style={themeStyles} + onPress={onClick} + > + <Plus className={styles.exploreCreateCommunityButton__icon} /> + <Typography.BodyBold className={styles.exploreCreateCommunityButton__text}> + {config.text} + </Typography.BodyBold> + </Button> + ); +} diff --git a/src/v4/social/elements/ExploreCreateCommunity/index.tsx b/src/v4/social/elements/ExploreCreateCommunity/index.tsx new file mode 100644 index 000000000..9a1dc9788 --- /dev/null +++ b/src/v4/social/elements/ExploreCreateCommunity/index.tsx @@ -0,0 +1 @@ +export { ExploreCreateCommunity } from './ExploreCreateCommunity'; diff --git a/src/v4/social/elements/ExploreEmptyImage/ExploreEmptyImage.tsx b/src/v4/social/elements/ExploreEmptyImage/ExploreEmptyImage.tsx new file mode 100644 index 000000000..fd5f08e9b --- /dev/null +++ b/src/v4/social/elements/ExploreEmptyImage/ExploreEmptyImage.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import { IconComponent } from '~/v4/core/IconComponent'; + +const DarkExploreEmptyImageSvg = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="161" + height="161" + viewBox="0 0 161 161" + fill="none" + > + <path + d="M130.1 24.5H34.0996C29.6813 24.5 26.0996 28.0817 26.0996 32.5V128.5C26.0996 132.918 29.6813 136.5 34.0996 136.5H130.1C134.518 136.5 138.1 132.918 138.1 128.5V32.5C138.1 28.0817 134.518 24.5 130.1 24.5Z" + fill="#292B32" + /> + <path + d="M43.7002 68.2H44.2002H151.7C152.628 68.2 153.519 68.5688 154.175 69.2251C154.831 69.8815 155.2 70.7718 155.2 71.7V91.7C155.2 92.6283 154.831 93.5185 154.175 94.1749C153.519 94.8313 152.628 95.2 151.7 95.2H43.7002C42.7719 95.2 41.8817 94.8313 41.2253 94.1749C40.5689 93.5185 40.2002 92.6283 40.2002 91.7V71.7C40.2002 70.7718 40.5689 69.8815 41.2253 69.2251C41.8817 68.5688 42.7719 68.2 43.7002 68.2Z" + fill="#292B32" + stroke="#40434E" + /> + <path + opacity="0.3" + d="M90.9002 74.1H70.1002C68.7747 74.1 67.7002 75.1745 67.7002 76.5C67.7002 77.8255 68.7747 78.9 70.1002 78.9H90.9002C92.2257 78.9 93.3002 77.8255 93.3002 76.5C93.3002 75.1745 92.2257 74.1 90.9002 74.1Z" + fill="#40434E" + /> + <path + opacity="0.15" + d="M105.3 84.5H70.1002C68.7747 84.5 67.7002 85.5745 67.7002 86.9C67.7002 88.2255 68.7747 89.3 70.1002 89.3H105.3C106.626 89.3 107.7 88.2255 107.7 86.9C107.7 85.5745 106.626 84.5 105.3 84.5Z" + fill="#40434E" + /> + <path + d="M53.6996 89.3C57.897 89.3 61.2996 85.8974 61.2996 81.7C61.2996 77.5027 57.897 74.1 53.6996 74.1C49.5022 74.1 46.0996 77.5027 46.0996 81.7C46.0996 85.8974 49.5022 89.3 53.6996 89.3Z" + fill="#40434E" + /> + <path + d="M9.2998 102.6H9.7998H117.3C118.228 102.6 119.118 102.969 119.775 103.625C120.431 104.282 120.8 105.172 120.8 106.1V126.1C120.8 127.028 120.431 127.919 119.775 128.575C119.118 129.231 118.228 129.6 117.3 129.6H9.2998C8.37155 129.6 7.48131 129.231 6.82493 128.575C6.16855 127.919 5.7998 127.028 5.7998 126.1V106.1C5.7998 105.172 6.16855 104.282 6.82493 103.625C7.48131 102.969 8.37155 102.6 9.2998 102.6Z" + fill="#292B32" + stroke="#40434E" + /> + <path + opacity="0.3" + d="M56.4998 108.5H35.6998C34.3743 108.5 33.2998 109.575 33.2998 110.9C33.2998 112.225 34.3743 113.3 35.6998 113.3H56.4998C57.8253 113.3 58.8998 112.225 58.8998 110.9C58.8998 109.575 57.8253 108.5 56.4998 108.5Z" + fill="#40434E" + /> + <path + opacity="0.15" + d="M70.8998 118.9H35.6998C34.3743 118.9 33.2998 119.975 33.2998 121.3C33.2998 122.626 34.3743 123.7 35.6998 123.7H70.8998C72.2253 123.7 73.2998 122.626 73.2998 121.3C73.2998 119.975 72.2253 118.9 70.8998 118.9Z" + fill="#40434E" + /> + <path + d="M19.3002 123.7C23.4976 123.7 26.9002 120.297 26.9002 116.1C26.9002 111.903 23.4976 108.5 19.3002 108.5C15.1028 108.5 11.7002 111.903 11.7002 116.1C11.7002 120.297 15.1028 123.7 19.3002 123.7Z" + fill="#40434E" + /> + <path + d="M9.2998 33.8H117.3C119.233 33.8 120.8 35.367 120.8 37.3V57.3C120.8 59.233 119.233 60.8 117.3 60.8H9.2998C7.36681 60.8 5.7998 59.233 5.7998 57.3V37.3C5.7998 35.367 7.36681 33.8 9.2998 33.8Z" + fill="#292B32" + stroke="#40434E" + /> + <path + opacity="0.3" + d="M54.8992 39.7H34.0992C32.7737 39.7 31.6992 40.7745 31.6992 42.1C31.6992 43.4255 32.7737 44.5 34.0992 44.5H54.8992C56.2247 44.5 57.2992 43.4255 57.2992 42.1C57.2992 40.7745 56.2247 39.7 54.8992 39.7Z" + fill="#40434E" + /> + <path + opacity="0.15" + d="M69.2992 50.1H34.0992C32.7737 50.1 31.6992 51.1745 31.6992 52.5C31.6992 53.8255 32.7737 54.9 34.0992 54.9H69.2992C70.6247 54.9 71.6992 53.8255 71.6992 52.5C71.6992 51.1745 70.6247 50.1 69.2992 50.1Z" + fill="#40434E" + /> + <path + d="M19.3002 54.9C23.4976 54.9 26.9002 51.4974 26.9002 47.3C26.9002 43.1026 23.4976 39.7 19.3002 39.7C15.1028 39.7 11.7002 43.1026 11.7002 47.3C11.7002 51.4974 15.1028 54.9 19.3002 54.9Z" + fill="#40434E" + /> + </svg> +); + +const ExploreEmptyImageSvg = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="161" + height="160" + viewBox="0 0 161 160" + fill="none" + > + <path + d="M130.452 24H34.4521C30.0339 24 26.4521 27.5817 26.4521 32V128C26.4521 132.418 30.0339 136 34.4521 136H130.452C134.87 136 138.452 132.418 138.452 128V32C138.452 27.5817 134.87 24 130.452 24Z" + fill="#EBECEF" + /> + <path + d="M44.0522 67.7H44.5522H152.052C152.981 67.7 153.871 68.0688 154.527 68.7251C155.184 69.3815 155.552 70.2718 155.552 71.2V91.2C155.552 92.1283 155.184 93.0185 154.527 93.6749C153.871 94.3313 152.981 94.7 152.052 94.7H44.0522C43.124 94.7 42.2337 94.3313 41.5774 93.6749C40.921 93.0185 40.5522 92.1283 40.5522 91.2V71.2C40.5522 70.2718 40.921 69.3815 41.5774 68.7251C42.2337 68.0688 43.124 67.7 44.0522 67.7Z" + fill="white" + stroke="#EBECEF" + /> + <path + opacity="0.3" + d="M91.2523 73.6H70.4522C69.1268 73.6 68.0522 74.6745 68.0522 76C68.0522 77.3255 69.1268 78.4 70.4522 78.4H91.2523C92.5777 78.4 93.6523 77.3255 93.6523 76C93.6523 74.6745 92.5777 73.6 91.2523 73.6Z" + fill="#A5A9B5" + /> + <path + opacity="0.15" + d="M105.652 84H70.4522C69.1268 84 68.0522 85.0745 68.0522 86.4C68.0522 87.7255 69.1268 88.8 70.4522 88.8H105.652C106.978 88.8 108.052 87.7255 108.052 86.4C108.052 85.0745 106.978 84 105.652 84Z" + fill="#A5A9B5" + /> + <path + d="M54.0521 88.8C58.2495 88.8 61.6521 85.3974 61.6521 81.2C61.6521 77.0027 58.2495 73.6 54.0521 73.6C49.8548 73.6 46.4521 77.0027 46.4521 81.2C46.4521 85.3974 49.8548 88.8 54.0521 88.8Z" + fill="#A5A9B5" + /> + <path + d="M9.65234 102.1H10.1523H117.652C118.581 102.1 119.471 102.469 120.127 103.125C120.784 103.782 121.152 104.672 121.152 105.6V125.6C121.152 126.528 120.784 127.419 120.127 128.075C119.471 128.731 118.581 129.1 117.652 129.1H9.65234C8.72409 129.1 7.83385 128.731 7.17747 128.075C6.52109 127.419 6.15234 126.528 6.15234 125.6V105.6C6.15234 104.672 6.52109 103.782 7.17747 103.125C7.83385 102.469 8.72409 102.1 9.65234 102.1Z" + fill="white" + stroke="#EBECEF" + /> + <path + opacity="0.3" + d="M56.8523 108H36.0523C34.7269 108 33.6523 109.075 33.6523 110.4C33.6523 111.725 34.7269 112.8 36.0523 112.8H56.8523C58.1778 112.8 59.2523 111.725 59.2523 110.4C59.2523 109.075 58.1778 108 56.8523 108Z" + fill="#A5A9B5" + /> + <path + opacity="0.15" + d="M71.2523 118.4H36.0523C34.7269 118.4 33.6523 119.475 33.6523 120.8C33.6523 122.126 34.7269 123.2 36.0523 123.2H71.2523C72.5778 123.2 73.6523 122.126 73.6523 120.8C73.6523 119.475 72.5778 118.4 71.2523 118.4Z" + fill="#A5A9B5" + /> + <path + d="M19.6522 123.2C23.8496 123.2 27.2522 119.797 27.2522 115.6C27.2522 111.403 23.8496 108 19.6522 108C15.4549 108 12.0522 111.403 12.0522 115.6C12.0522 119.797 15.4549 123.2 19.6522 123.2Z" + fill="#A5A9B5" + /> + <path + d="M9.65234 33.3H117.652C119.585 33.3 121.152 34.867 121.152 36.8V56.8C121.152 58.733 119.585 60.3 117.652 60.3H9.65234C7.71935 60.3 6.15234 58.733 6.15234 56.8V36.8C6.15234 34.867 7.71935 33.3 9.65234 33.3Z" + fill="white" + stroke="#EBECEF" + /> + <path + opacity="0.3" + d="M55.2522 39.2H34.4522C33.1268 39.2 32.0522 40.2745 32.0522 41.6C32.0522 42.9255 33.1268 44 34.4522 44H55.2522C56.5777 44 57.6522 42.9255 57.6522 41.6C57.6522 40.2745 56.5777 39.2 55.2522 39.2Z" + fill="#A5A9B5" + /> + <path + opacity="0.15" + d="M69.6522 49.6H34.4522C33.1268 49.6 32.0522 50.6745 32.0522 52C32.0522 53.3255 33.1268 54.4 34.4522 54.4H69.6522C70.9777 54.4 72.0522 53.3255 72.0522 52C72.0522 50.6745 70.9777 49.6 69.6522 49.6Z" + fill="#A5A9B5" + /> + <path + d="M19.6522 54.4C23.8496 54.4 27.2522 50.9974 27.2522 46.8C27.2522 42.6026 23.8496 39.2 19.6522 39.2C15.4549 39.2 12.0522 42.6026 12.0522 46.8C12.0522 50.9974 15.4549 54.4 19.6522 54.4Z" + fill="#A5A9B5" + /> + </svg> +); + +interface ExploreEmptyImageProps { + pageId?: string; + componentId?: string; +} + +export const ExploreEmptyImage = ({ pageId = '*', componentId = '*' }: ExploreEmptyImageProps) => { + const elementId = 'explore_empty_image'; + const { + currentTheme, + accessibilityId, + config, + defaultConfig, + isExcluded, + uiReference, + themeStyles, + } = useAmityElement({ + pageId, + componentId, + elementId, + }); + + if (isExcluded) return null; + + return ( + <IconComponent + defaultIconName={defaultConfig.icon} + configIconName={config.icon} + imgIcon={() => ( + <img + style={themeStyles} + src={config.icon} + alt={uiReference} + data-qa-anchor={accessibilityId} + /> + )} + defaultIcon={() => ( + <div data-qa-anchor={accessibilityId} style={themeStyles}> + {currentTheme === 'light' ? <ExploreEmptyImageSvg /> : <DarkExploreEmptyImageSvg />} + </div> + )} + /> + ); +}; diff --git a/src/v4/social/elements/ExploreEmptyImage/index.tsx b/src/v4/social/elements/ExploreEmptyImage/index.tsx new file mode 100644 index 000000000..63f270016 --- /dev/null +++ b/src/v4/social/elements/ExploreEmptyImage/index.tsx @@ -0,0 +1 @@ +export { ExploreEmptyImage } from './ExploreEmptyImage'; diff --git a/src/v4/social/elements/ExploreRecommendedTitle/ExploreRecommendedTitle.module.css b/src/v4/social/elements/ExploreRecommendedTitle/ExploreRecommendedTitle.module.css new file mode 100644 index 000000000..03aa3f7c3 --- /dev/null +++ b/src/v4/social/elements/ExploreRecommendedTitle/ExploreRecommendedTitle.module.css @@ -0,0 +1,3 @@ +.exploreRecommendedTitle { + color: var(--asc-color-base-default); +} diff --git a/src/v4/social/elements/ExploreRecommendedTitle/ExploreRecommendedTitle.tsx b/src/v4/social/elements/ExploreRecommendedTitle/ExploreRecommendedTitle.tsx new file mode 100644 index 000000000..705957d71 --- /dev/null +++ b/src/v4/social/elements/ExploreRecommendedTitle/ExploreRecommendedTitle.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Typography } from '~/v4/core/components'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import clsx from 'clsx'; +import styles from './ExploreRecommendedTitle.module.css'; + +interface TitleProps { + pageId?: string; + componentId?: string; + titleClassName?: string; +} + +export function ExploreRecommendedTitle({ + pageId = '*', + componentId = '*', + titleClassName, +}: TitleProps) { + const elementId = 'explore_recommended_title'; + const { accessibilityId, config, isExcluded, themeStyles } = useAmityElement({ + pageId, + componentId, + elementId, + }); + + if (isExcluded) return null; + + return ( + <Typography.Title + className={clsx(styles.exploreRecommendedTitle, titleClassName)} + style={themeStyles} + data-qa-anchor={accessibilityId} + > + {config.text} + </Typography.Title> + ); +} diff --git a/src/v4/social/elements/ExploreRecommendedTitle/index.tsx b/src/v4/social/elements/ExploreRecommendedTitle/index.tsx new file mode 100644 index 000000000..a80ae8654 --- /dev/null +++ b/src/v4/social/elements/ExploreRecommendedTitle/index.tsx @@ -0,0 +1 @@ +export { ExploreRecommendedTitle } from './ExploreRecommendedTitle'; diff --git a/src/v4/social/elements/ExploreTrendingTitle/ExploreTrendingTitle.module.css b/src/v4/social/elements/ExploreTrendingTitle/ExploreTrendingTitle.module.css new file mode 100644 index 000000000..ff76513bb --- /dev/null +++ b/src/v4/social/elements/ExploreTrendingTitle/ExploreTrendingTitle.module.css @@ -0,0 +1,3 @@ +.exploreTrendingTitle { + color: var(--asc-color-base-default); +} diff --git a/src/v4/social/elements/ExploreTrendingTitle/ExploreTrendingTitle.tsx b/src/v4/social/elements/ExploreTrendingTitle/ExploreTrendingTitle.tsx new file mode 100644 index 000000000..e1a939a73 --- /dev/null +++ b/src/v4/social/elements/ExploreTrendingTitle/ExploreTrendingTitle.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Typography } from '~/v4/core/components'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import clsx from 'clsx'; +import styles from './ExploreTrendingTitle.module.css'; + +interface TitleProps { + pageId?: string; + componentId?: string; + titleClassName?: string; +} + +export function ExploreTrendingTitle({ + pageId = '*', + componentId = '*', + titleClassName, +}: TitleProps) { + const elementId = 'explore_trending_title'; + const { accessibilityId, config, isExcluded, themeStyles } = useAmityElement({ + pageId, + componentId, + elementId, + }); + + if (isExcluded) return null; + + return ( + <Typography.Title + className={clsx(styles.exploreTrendingTitle, titleClassName)} + style={themeStyles} + data-qa-anchor={accessibilityId} + > + {config.text} + </Typography.Title> + ); +} diff --git a/src/v4/social/elements/ExploreTrendingTitle/index.tsx b/src/v4/social/elements/ExploreTrendingTitle/index.tsx new file mode 100644 index 000000000..c1399f1b1 --- /dev/null +++ b/src/v4/social/elements/ExploreTrendingTitle/index.tsx @@ -0,0 +1 @@ +export { ExploreTrendingTitle } from './ExploreTrendingTitle'; diff --git a/src/v4/social/elements/MyTimelineAvatar/MyTimelineAvatar.tsx b/src/v4/social/elements/MyTimelineAvatar/MyTimelineAvatar.tsx index 25a9bdd01..a671c88fc 100644 --- a/src/v4/social/elements/MyTimelineAvatar/MyTimelineAvatar.tsx +++ b/src/v4/social/elements/MyTimelineAvatar/MyTimelineAvatar.tsx @@ -24,7 +24,12 @@ export function MyTimelineAvatar({ if (isExcluded) return null; return ( <div className={styles.myTimelineAvatar} data-qa-anchor={accessibilityId}> - <UserAvatar className={styles.myTimelineAvatar__userAvatar} userId={userId} /> + <UserAvatar + pageId={pageId} + componentId={componentId} + className={styles.myTimelineAvatar__userAvatar} + userId={userId} + /> </div> ); } diff --git a/src/v4/social/elements/PinBadge/PinBadge.module.css b/src/v4/social/elements/PinBadge/PinBadge.module.css new file mode 100644 index 000000000..0e795aca8 --- /dev/null +++ b/src/v4/social/elements/PinBadge/PinBadge.module.css @@ -0,0 +1,3 @@ +.pinBadge__icon { + fill: var(--asc-color-primary-default); +} diff --git a/src/v4/social/elements/PinBadge/PinBadge.tsx b/src/v4/social/elements/PinBadge/PinBadge.tsx index c9173e407..5b150f1d8 100644 --- a/src/v4/social/elements/PinBadge/PinBadge.tsx +++ b/src/v4/social/elements/PinBadge/PinBadge.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import styles from './PinBadge.module.css'; import { useAmityElement } from '~/v4/core/hooks/uikit'; import { IconComponent } from '~/v4/core/IconComponent'; import { PinBadgeIcon } from '~/v4/icons/PinBadge'; @@ -10,19 +11,18 @@ interface PinBadgeProps { export const PinBadge = ({ pageId, componentId }: PinBadgeProps) => { const elementId = 'pin_badge'; - const { config, themeStyles, isExcluded, accessibilityId, uiReference, defaultConfig } = - useAmityElement({ - pageId, - componentId, - elementId, - }); + const { config, isExcluded, accessibilityId, uiReference, defaultConfig } = useAmityElement({ + pageId, + componentId, + elementId, + }); if (isExcluded) return null; return ( <IconComponent data-qa-anchor={accessibilityId} - defaultIcon={() => <PinBadgeIcon />} + defaultIcon={() => <PinBadgeIcon className={styles.pinBadge__icon} />} imgIcon={() => <img src={config.icon} alt={uiReference} />} defaultIconName={defaultConfig.icon} configIconName={config.icon} diff --git a/src/v4/social/elements/PostTextField/PostTextField.module.css b/src/v4/social/elements/PostTextField/PostTextField.module.css index 8be851b0b..85b5f687f 100644 --- a/src/v4/social/elements/PostTextField/PostTextField.module.css +++ b/src/v4/social/elements/PostTextField/PostTextField.module.css @@ -46,3 +46,9 @@ .editorLink:hover { text-decoration: underline; } + +.postTextField__mentionInterceptor { + width: 100%; + height: 1px; + background-color: var(--asc-color-background-default); +} diff --git a/src/v4/social/elements/PostTextField/PostTextField.tsx b/src/v4/social/elements/PostTextField/PostTextField.tsx index 4d4d8fcc8..25ee78ed0 100644 --- a/src/v4/social/elements/PostTextField/PostTextField.tsx +++ b/src/v4/social/elements/PostTextField/PostTextField.tsx @@ -17,7 +17,7 @@ import { Mentioned, Mentionees } from '~/v4/helpers/utils'; import { LinkPlugin } from '~/v4/social/internal-components/Lexical/plugins/LinkPlugin'; import { AutoLinkPlugin } from '~/v4/social/internal-components/Lexical/plugins/AutoLinkPlugin'; import { - editorStateToText, + editorToText, getEditorConfig, MentionData, textToEditorState, @@ -29,8 +29,11 @@ import { AutoLinkNode, LinkNode } from '@lexical/link'; import useIntersectionObserver from '~/v4/core/hooks/useIntersectionObserver'; import useCommunity from '~/v4/core/hooks/collections/useCommunity'; import { MentionItem } from '~/v4/social/internal-components/Lexical/MentionItem'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; interface PostTextFieldProps { + pageId?: string; + componentId?: string; communityId?: string | null; attachmentAmount?: number; mentionContainer: HTMLElement | null; @@ -73,7 +76,7 @@ const useSuggestions = (communityId?: string | null) => { } = useUserQueryByDisplayName({ displayName: queryString || '', limit: 10, - enabled: !communityId, + enabled: !isSearchCommunityMembers, }); const onQueryChange = (newQuery: string | null) => { @@ -97,16 +100,16 @@ const useSuggestions = (communityId?: string | null) => { }, [users, members, isSearchCommunityMembers, isCommunityLoading]); const hasMore = useMemo(() => { - if (communityId) { + if (isSearchCommunityMembers) { return hasMoreMember; } else { return hasMoreUser; } - }, [communityId, hasMoreMember, hasMoreUser]); + }, [isSearchCommunityMembers, hasMoreMember, hasMoreUser]); const loadMore = () => { if (isLoading || !hasMore) return; - if (communityId) { + if (isSearchCommunityMembers) { loadMoreMember(); } else { loadMoreUser(); @@ -114,12 +117,12 @@ const useSuggestions = (communityId?: string | null) => { }; const isLoading = useMemo(() => { - if (communityId) { + if (isSearchCommunityMembers) { return isLoadingMember; } else { return isLoadingUser; } - }, [isLoadingMember, isLoadingUser, communityId]); + }, [isLoadingMember, isLoadingUser, isSearchCommunityMembers]); return { suggestions, queryString, onQueryChange, loadMore, hasMore, isLoading }; }; @@ -131,8 +134,13 @@ export const PostTextField = ({ communityId, mentionContainer, dataValue, + pageId = '*', + componentId = '*', }: PostTextFieldProps) => { - const [intersectionNode, setIntersectionNode] = useState<HTMLDivElement | null>(null); + const elementId = 'post_text_field'; + const [intersectionNode, setIntersectionNode] = useState<HTMLElement | null>(null); + + const { accessibilityId } = useAmityElement({ pageId, componentId, elementId }); const editorConfig = getEditorConfig({ namespace: 'PostTextField', @@ -148,9 +156,7 @@ export const PostTextField = ({ useIntersectionObserver({ onIntersect: () => { - if (isLoading === false) { - loadMore(); - } + loadMore(); }, node: intersectionNode, options: { @@ -168,7 +174,7 @@ export const PostTextField = ({ : {}), }} > - <div className={styles.editorContainer}> + <div className={styles.editorContainer} data-qa-anchor={accessibilityId}> <RichTextPlugin contentEditable={<ContentEditable />} placeholder={<div className={styles.editorPlaceholder}>What’s going on...</div>} @@ -176,7 +182,7 @@ export const PostTextField = ({ /> <OnChangePlugin onChange={(_, editor) => { - onChange(editorStateToText(editor)); + onChange(editorToText(editor)); }} /> <HistoryPlugin /> @@ -214,11 +220,18 @@ export const PostTextField = ({ setHighlightedIndex(i); }} key={option.key} - option={option} + option={{ + ...option, + setRefElement: (element) => { + if (i === options.length - 1) { + setIntersectionNode(element); + } + option.setRefElement(element); + }, + }} /> ); })} - <div ref={(node) => setIntersectionNode(node)} style={{ height: '4px' }} /> </>, mentionContainer, ); diff --git a/src/v4/social/elements/ShareStoryButton/ShareStoryButton.module.css b/src/v4/social/elements/ShareStoryButton/ShareStoryButton.module.css index 0992c06ed..1cc8cd5f1 100644 --- a/src/v4/social/elements/ShareStoryButton/ShareStoryButton.module.css +++ b/src/v4/social/elements/ShareStoryButton/ShareStoryButton.module.css @@ -19,3 +19,8 @@ .shareStoryIcon { margin-left: var(--asc-spacing-s1); } + +.shareStoryButton__image { + width: 2rem; + height: 2rem; +} diff --git a/src/v4/social/elements/ShareStoryButton/ShareStoryButton.tsx b/src/v4/social/elements/ShareStoryButton/ShareStoryButton.tsx index c1741b270..8a43ff4c8 100644 --- a/src/v4/social/elements/ShareStoryButton/ShareStoryButton.tsx +++ b/src/v4/social/elements/ShareStoryButton/ShareStoryButton.tsx @@ -38,7 +38,7 @@ export const ShareStoryButton = ({ onClick, }: ShareButtonProps) => { const elementId = 'share_story_button'; - const { config, isExcluded } = useAmityElement({ + const { config, isExcluded, accessibilityId } = useAmityElement({ pageId, componentId, elementId, @@ -50,12 +50,17 @@ export const ShareStoryButton = ({ <button role="button" className={clsx(styles.shareStoryButton)} - data-qa-anchor="share_story_button" + data-qa-anchor={accessibilityId} onClick={onClick} data-hideAvatar={config?.hide_avatar} > {!config?.hide_avatar && ( - <CommunityAvatar pageId={pageId} componentId={componentId} community={community} /> + <CommunityAvatar + pageId={pageId} + community={community} + componentId={componentId} + className={styles.shareStoryButton__image} + /> )} <Typography.BodyBold>{config?.text || 'Share story'}</Typography.BodyBold> <ArrowRightIcon /> diff --git a/src/v4/social/hooks/collections/useGlobalStoryTargets.ts b/src/v4/social/hooks/collections/useGlobalStoryTargets.ts index cb3a4f1e5..513e488b6 100644 --- a/src/v4/social/hooks/collections/useGlobalStoryTargets.ts +++ b/src/v4/social/hooks/collections/useGlobalStoryTargets.ts @@ -4,7 +4,7 @@ import useLiveCollection from '~/v4/core/hooks/useLiveCollection'; export const useGlobalStoryTargets = ( params: Amity.LiveCollectionParams<Amity.StoryGlobalQuery>, ) => { - const { items, hasMore, loadMore, isLoading, ...rest } = useLiveCollection({ + const { items, hasMore, loadMore, isLoading, refresh, ...rest } = useLiveCollection({ fetcher: StoryRepository.getGlobalStoryTargets, params, shouldCall: true, @@ -21,6 +21,7 @@ export const useGlobalStoryTargets = ( hasMore, isLoading, loadMoreStories, + refresh, ...rest, }; }; diff --git a/src/v4/social/hooks/collections/usePinnedPostCollection.ts b/src/v4/social/hooks/collections/usePinnedPostCollection.ts index 5c125def4..0f5944db1 100644 --- a/src/v4/social/hooks/collections/usePinnedPostCollection.ts +++ b/src/v4/social/hooks/collections/usePinnedPostCollection.ts @@ -1,12 +1,9 @@ import { PostRepository } from '@amityco/ts-sdk'; import useLiveCollection from '~/v4/core/hooks/useLiveCollection'; -const QUERY_LIMIT = 10; - export default function usePinnedPostsCollection({ communityId, placement, - limit = QUERY_LIMIT, }: Partial<Parameters<typeof PostRepository.getPinnedPosts>[0]>) { const { items, ...rest } = useLiveCollection({ fetcher: PostRepository.getPinnedPosts, diff --git a/src/v4/social/hooks/useCategoriesByIds.ts b/src/v4/social/hooks/useCategoriesByIds.ts index 981e36ec7..19024d41b 100644 --- a/src/v4/social/hooks/useCategoriesByIds.ts +++ b/src/v4/social/hooks/useCategoriesByIds.ts @@ -8,9 +8,11 @@ const useCategoriesByIds = (categoryIds?: string[]) => { async function run() { if (categoryIds == null || categoryIds.length === 0) return; const categories = await Promise.all( - categoryIds.map( - async (categoryId) => (await CategoryRepository.getCategory(categoryId)).data, - ), + categoryIds.map(async (categoryId) => { + const cache = CategoryRepository.getCategory.locally(categoryId); + if (cache?.data) return cache.data; + return (await CategoryRepository.getCategory(categoryId)).data; + }), ); setCategories(categories); } @@ -19,5 +21,4 @@ const useCategoriesByIds = (categoryIds?: string[]) => { return categories; }; - export default useCategoriesByIds; diff --git a/src/v4/social/hooks/useCommunityActions.ts b/src/v4/social/hooks/useCommunityActions.ts new file mode 100644 index 000000000..e2ea00b38 --- /dev/null +++ b/src/v4/social/hooks/useCommunityActions.ts @@ -0,0 +1,57 @@ +import { CommunityRepository } from '@amityco/ts-sdk'; +import { useMutation } from '@tanstack/react-query'; +import { useNotifications } from '~/v4/core/providers/NotificationProvider'; + +export const useCommunityActions = ({ + onJoinSuccess, + onJoinError, + onLeaveSuccess, + onLeaveError, +}: { + onJoinSuccess?: () => void; + onJoinError?: (error: Error) => void; + onLeaveSuccess?: () => void; + onLeaveError?: (error: Error) => void; +} = {}): { + joinCommunity: (communityId: string) => void; + leaveCommunity: (communityId: string) => void; +} => { + const { error: errorFn, success } = useNotifications(); + + const { mutate: joinCommunity } = useMutation({ + mutationFn: (communityId: string) => CommunityRepository.joinCommunity(communityId), + onSuccess: () => { + success({ + content: 'Successfully joined the community.', + }); + onJoinSuccess?.(); + }, + onError: (error) => { + errorFn({ + content: 'Failed to join the community', + }); + onJoinError?.(error); + }, + }); + + const { mutate: leaveCommunity } = useMutation({ + mutationFn: (communityId: string) => CommunityRepository.leaveCommunity(communityId), + onSuccess: () => { + success({ + content: 'Successfully left the community', + }); + onLeaveSuccess?.(); + }, + onError: (error) => { + errorFn({ + content: 'Failed to leave the community', + }); + onLeaveError?.(error); + }, + }); + + return { + joinCommunity, + leaveCommunity, + }; +}; diff --git a/src/v4/social/hooks/useCommunityPostPermission.ts b/src/v4/social/hooks/useCommunityPostPermission.ts new file mode 100644 index 000000000..878a4b265 --- /dev/null +++ b/src/v4/social/hooks/useCommunityPostPermission.ts @@ -0,0 +1,101 @@ +import { CommunityPostSettings } from '@amityco/ts-sdk'; +import { useMemo } from 'react'; +import usePostsCollection from '~/v4/social/hooks/collections/usePostsCollection'; +import { useSDK } from '~/v4/core/hooks/useSDK'; +import useCommunityModeratorsCollection from '~/v4/social/hooks/collections/useCommunityModeratorsCollection'; +import { Permissions } from '~/v4/social/constants/permissions'; + +const useCommunityPostPermission = ({ + post, + childrenPosts = [], + community, + userId, +}: { + post?: Amity.Post | null; + childrenPosts?: Amity.Post[]; + community?: Amity.Community | null; + userId?: string; +}) => { + const { moderators } = useCommunityModeratorsCollection({ communityId: community?.communityId }); + const { client } = useSDK(); + + const { posts: reviewingPosts } = usePostsCollection({ + targetType: 'community', + targetId: community?.communityId, + feedType: 'reviewing', + }); + + const isEditable = useMemo(() => { + if ( + childrenPosts.find( + (childPost) => childPost.dataType === 'liveStream' || childPost.dataType === 'poll', + ) + ) { + return false; + } + return true; + }, [childrenPosts]); + + const moderator = moderators.find((moderator) => moderator.userId === userId); + const isMyPost = post?.postedUserId === userId; + const isPostUnderReview = useMemo(() => { + if (community?.postSetting != CommunityPostSettings.ANYONE_CAN_POST) { + return reviewingPosts.find((reviewingPost) => reviewingPost.postId === post?.postId) != null; + } + return false; + }, [community, reviewingPosts]); + + const isModerator = moderator != null; + + const permissions: { + canEdit: boolean; + canReport: boolean; + canDelete: boolean; + canReview: boolean; + } = { + canEdit: false, + canReport: false, + canDelete: false, + canReview: false, + }; + + if (isMyPost) { + if (!isPostUnderReview && isEditable) { + permissions.canEdit = true; + } + permissions.canDelete = true; + } else { + if (community != null) { + const canEdit = + client + ?.hasPermission(Permissions.EditCommunityFeedPostPermission) + .community(community.communityId) ?? false; + + permissions.canEdit = canEdit && isEditable; + + const canDelete = + client + ?.hasPermission(Permissions.DeleteCommunityFeedPostPermission) + .community(community.communityId) ?? false; + + permissions.canDelete = canDelete; + } else { + const canDelete = + client?.hasPermission(Permissions.EditUserFeedPostPermission).currentUser() ?? false; + + permissions.canDelete = canDelete; + } + + if (!isPostUnderReview) { + permissions.canReport = true; + } + } + + return { + isPostUnderReview, + isModerator, + ...permissions, + }; +}; + +export default useCommunityPostPermission; diff --git a/src/v4/social/internal-components/BrandBadge/BrandBadge.module.css b/src/v4/social/internal-components/BrandBadge/BrandBadge.module.css new file mode 100644 index 000000000..40c7d20ae --- /dev/null +++ b/src/v4/social/internal-components/BrandBadge/BrandBadge.module.css @@ -0,0 +1,32 @@ +.badge { + position: relative; + border-radius: var(--asc-border-radius-full); + padding: var(--asc-spacing-none) var(--asc-spacing-xxs3); +} + +.badge::before { + content: ''; + position: absolute; + inset: 0; + background-color: var(--asc-color-base-shade1); + opacity: 0.5; + border-radius: inherit; +} + +.badge__icon { + width: 0.75rem; + height: 0.75rem; +} + +.badge__text { + font-size: var(--asc-text-font-size-xs); + line-height: var(--asc-line-height-sm); + color: var(--asc-color-white); +} + +.badge__child { + position: relative; + display: flex; + gap: var(--asc-spacing-xxs1); + align-items: center; +} diff --git a/src/v4/social/internal-components/BrandBadge/BrandBadge.stories.tsx b/src/v4/social/internal-components/BrandBadge/BrandBadge.stories.tsx new file mode 100644 index 000000000..5cd8c80b2 --- /dev/null +++ b/src/v4/social/internal-components/BrandBadge/BrandBadge.stories.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { BrandBadge } from './BrandBadge'; + +export default { + title: 'v4-social/internal-components/BrandBadge', +}; + +export const BrandBadgeStory = { + render: () => <BrandBadge />, +}; diff --git a/src/v4/social/internal-components/BrandBadge/BrandBadge.tsx b/src/v4/social/internal-components/BrandBadge/BrandBadge.tsx new file mode 100644 index 000000000..66b1ebab4 --- /dev/null +++ b/src/v4/social/internal-components/BrandBadge/BrandBadge.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import Brand from '~/v4/icons/Brand'; + +interface BrandBadgeProps { + className?: string; +} + +export const BrandBadge = ({ className }: BrandBadgeProps) => { + return <Brand className={className} />; +}; diff --git a/src/v4/social/internal-components/BrandBadge/index.tsx b/src/v4/social/internal-components/BrandBadge/index.tsx new file mode 100644 index 000000000..068a46276 --- /dev/null +++ b/src/v4/social/internal-components/BrandBadge/index.tsx @@ -0,0 +1 @@ +export { BrandBadge } from './BrandBadge'; diff --git a/src/v4/social/internal-components/CategoryImage/CategoryImage.module.css b/src/v4/social/internal-components/CategoryImage/CategoryImage.module.css new file mode 100644 index 000000000..78db3e0e2 --- /dev/null +++ b/src/v4/social/internal-components/CategoryImage/CategoryImage.module.css @@ -0,0 +1,7 @@ +.categoryImage__placeHolderRect { + fill: var(--asc-color-primary-shade2); +} + +.categoryImage__placeHolderPath { + fill: var(--asc-color-white); +} diff --git a/src/v4/social/internal-components/CategoryImage/CategoryImage.tsx b/src/v4/social/internal-components/CategoryImage/CategoryImage.tsx new file mode 100644 index 000000000..fc1100933 --- /dev/null +++ b/src/v4/social/internal-components/CategoryImage/CategoryImage.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import styles from './CategoryImage.module.css'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import { Img } from '~/v4/core/natives/Img/Img'; + +const CategoryImagePlaceHolder = ({ className }: { className?: string }) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="28" + height="28" + viewBox="0 0 28 28" + fill="none" + className={className} + > + <rect width="28" height="28" rx="14" className={styles.categoryImage__placeHolderRect} /> + <path + d="M19.6 14.7C19.6 14.3281 19.2719 14 18.9 14H15.4C15.0063 14 14.7 14.3281 14.7 14.7V18.2C14.7 18.5938 15.0063 18.9 15.4 18.9H18.9C19.2719 18.9 19.6 18.5938 19.6 18.2V14.7ZM9.8 13.3C8.24688 13.3 7 14.5688 7 16.1C7 17.6531 8.24688 18.9 9.8 18.9C11.3313 18.9 12.6 17.6531 12.6 16.1C12.6 14.5688 11.3313 13.3 9.8 13.3ZM18.8781 11.9C19.425 11.9 19.775 11.3313 19.4906 10.85L17.4125 7.35002C17.1281 6.89065 16.45 6.89065 16.1656 7.35002L14.0875 10.85C13.8031 11.3313 14.1531 11.9 14.7 11.9H18.8781Z" + className={styles.categoryImage__placeHolderPath} + /> + </svg> +); + +interface CategoryImageProps { + pageId: string; + componentId?: string; + elementId?: string; + imgSrc?: string; + className?: string; +} + +export const CategoryImage = ({ + imgSrc, + pageId = '*', + componentId = '*', + elementId = '*', + className, +}: CategoryImageProps) => { + const { themeStyles } = useAmityElement({ + pageId, + componentId, + elementId, + }); + + return ( + <Img + style={themeStyles} + className={className} + src={imgSrc} + fallBackRenderer={() => <CategoryImagePlaceHolder className={className} />} + /> + ); +}; diff --git a/src/v4/social/internal-components/CategoryImage/index.tsx b/src/v4/social/internal-components/CategoryImage/index.tsx new file mode 100644 index 000000000..63ff08f1b --- /dev/null +++ b/src/v4/social/internal-components/CategoryImage/index.tsx @@ -0,0 +1 @@ +export { CategoryImage } from './CategoryImage'; diff --git a/src/v4/social/internal-components/Comment/index.tsx b/src/v4/social/internal-components/Comment/index.tsx index cb641b407..6ca62385f 100644 --- a/src/v4/social/internal-components/Comment/index.tsx +++ b/src/v4/social/internal-components/Comment/index.tsx @@ -6,6 +6,7 @@ import { isCommunityMember, isNonNullable, Mentioned, + Mentionees, Metadata, parseMentionsMarkup, } from '~/v4/helpers/utils'; @@ -111,18 +112,14 @@ export const Comment = ({ return toggleFlagComment(); }; - const handleEditComment = async ( - text: string, - mentionees: Amity.UserMention[], - metadata: Metadata, - ) => + const handleEditComment = async (text: string, mentionees: Mentionees, metadata: Metadata) => commentId && CommentRepository.updateComment(commentId, { data: { text, }, metadata, - mentionees, + mentionees: mentionees as Amity.UserMention[], }); const handleDeleteComment = async () => commentId && CommentRepository.deleteComment(commentId); @@ -284,6 +281,7 @@ export const Comment = ({ <div data-qa-anchor="comment">{renderedComment}</div> {comment.children.length > 0 && ( <CommentList + pageId={pageId} componentId={componentId} parentId={comment.commentId} referenceType={comment.referenceType} diff --git a/src/v4/social/internal-components/CommentAd/UICommentAd.tsx b/src/v4/social/internal-components/CommentAd/UICommentAd.tsx index eabf7afe1..2fbc4754a 100644 --- a/src/v4/social/internal-components/CommentAd/UICommentAd.tsx +++ b/src/v4/social/internal-components/CommentAd/UICommentAd.tsx @@ -6,6 +6,7 @@ import Broadcast from '~/v4/icons/Broadcast'; import InfoCircle from '~/v4/icons/InfoCircle'; import { Button } from '~/v4/core/natives/Button'; import { AdInformation } from '~/v4/social/internal-components/AdInformation/AdInformation'; +import clsx from 'clsx'; interface UICommentAdProps { ad: Amity.Ad; @@ -57,9 +58,13 @@ export const UICommentAd = ({ </div> <div className={styles.commentAd__adCard__detail}> <div className={styles.commentAd__adCard__textContainer}> - <Typography.Caption className={styles.commentAd__adCard__description}> - {ad.description} - </Typography.Caption> + <Typography.Caption + renderer={({ typoClassName }) => ( + <div className={clsx(typoClassName, styles.commentAd__adCard__description)}> + {ad.description} + </div> + )} + /> <Typography.BodyBold className={styles.commentAd__adCard__headline}> {ad.headline} </Typography.BodyBold> diff --git a/src/v4/social/internal-components/CommentComposeBar/CommentComposeBar.tsx b/src/v4/social/internal-components/CommentComposeBar/CommentComposeBar.tsx index 2803fab43..9a19fc1d2 100644 --- a/src/v4/social/internal-components/CommentComposeBar/CommentComposeBar.tsx +++ b/src/v4/social/internal-components/CommentComposeBar/CommentComposeBar.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import useUser from '~/core/hooks/useUser'; +import { useUser } from '~/v4/core/hooks/objects/useUser'; import useMention from '~/v4/chat/hooks/useMention'; import { Mentionees, Metadata } from '~/v4/helpers/utils'; @@ -36,7 +36,7 @@ export const CommentComposeBar = ({ targetType, }: CommentComposeBarProps) => { const { currentUserId } = useSDK(); - const user = useUser(currentUserId); + const { user } = useUser({ userId: currentUserId }); const avatarFileUrl = useImage({ fileId: user?.avatarFileId, imageSize: 'small' }); const { text, markup, mentions, mentionees, metadata, onChange, clearAll, queryMentionees } = useMention({ diff --git a/src/v4/social/internal-components/CommunityCategories/CommunityCategories.module.css b/src/v4/social/internal-components/CommunityCategories/CommunityCategories.module.css new file mode 100644 index 000000000..814b625a7 --- /dev/null +++ b/src/v4/social/internal-components/CommunityCategories/CommunityCategories.module.css @@ -0,0 +1,13 @@ +.communityCategories { + display: flex; + flex-wrap: nowrap; + gap: 0.25rem; +} + +.communityCategories__categoryChip { + width: unset; +} + +.communityCategories__categoryOverflow { + width: min-content; +} diff --git a/src/v4/social/internal-components/CommunityCategories/CommunityCategories.tsx b/src/v4/social/internal-components/CommunityCategories/CommunityCategories.tsx new file mode 100644 index 000000000..94db4eaa2 --- /dev/null +++ b/src/v4/social/internal-components/CommunityCategories/CommunityCategories.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import useCategoriesByIds from '~/v4/social/hooks/useCategoriesByIds'; + +import styles from './CommunityCategories.module.css'; +import { CommunityCategory } from '~/v4/social/elements/CommunityCategory/CommunityCategory'; +import clsx from 'clsx'; + +export const CommunityCategories = ({ + community, + pageId = '*', + componentId = '*', + onClick, + minCategoryCharacters, + maxCategoryCharacters, + maxCategoriesLength, + truncate = false, +}: { + community: Amity.Community; + pageId?: string; + componentId?: string; + onClick?: (categoryId: string) => void; + minCategoryCharacters?: number; + maxCategoryCharacters?: number; + maxCategoriesLength?: number; + truncate?: boolean; +}) => { + const categories = useCategoriesByIds(community.categoryIds); + + const overflowCategoriesLength = + typeof maxCategoriesLength === 'number' ? categories.length - maxCategoriesLength : 0; + + const categoriesLength = + typeof maxCategoriesLength === 'number' + ? Math.min(categories.length, maxCategoriesLength) + : categories.length; + + return ( + <div + className={styles.communityCategories} + style={ + { + '--asc-community-categories-length': + overflowCategoriesLength > 0 ? categoriesLength + 1 : categoriesLength, + } as React.CSSProperties + } + > + {categories.slice(0, categoriesLength).map((category) => ( + <CommunityCategory + key={category.categoryId} + pageId={pageId} + componentId={componentId} + categoryName={category.name} + minCharacters={minCategoryCharacters} + maxCharacters={maxCategoryCharacters} + truncate={truncate} + className={styles.communityCategories__categoryChip} + onClick={() => onClick?.(category.categoryId)} + /> + ))} + {overflowCategoriesLength > 0 && ( + <CommunityCategory + pageId={pageId} + componentId={componentId} + categoryName={`+${overflowCategoriesLength}`} + minCharacters={`+${overflowCategoriesLength}`.length} + className={clsx( + styles.communityCategories__categoryChip, + styles.communityCategories__categoryOverflow, + )} + /> + )} + </div> + ); +}; diff --git a/src/v4/social/internal-components/CommunityCategories/index.ts b/src/v4/social/internal-components/CommunityCategories/index.ts new file mode 100644 index 000000000..9941b843e --- /dev/null +++ b/src/v4/social/internal-components/CommunityCategories/index.ts @@ -0,0 +1 @@ +export { CommunityCategories } from './CommunityCategories'; diff --git a/src/v4/social/internal-components/CommunityMember/CommunityMember.module.css b/src/v4/social/internal-components/CommunityMember/CommunityMember.module.css index 9425aebbf..af4784bdc 100644 --- a/src/v4/social/internal-components/CommunityMember/CommunityMember.module.css +++ b/src/v4/social/internal-components/CommunityMember/CommunityMember.module.css @@ -18,6 +18,14 @@ height: 2.5rem; } +.communityMember__rightPane { + display: grid; + grid-template-columns: auto 1.5rem; + justify-content: center; + align-items: center; + gap: 0.5rem; +} + .communityMember__displayName { font-size: 1rem; display: block; @@ -26,3 +34,8 @@ overflow: hidden; color: var(--asc-color-base-default); } + +.communityMember__brandIcon { + width: 1.5rem; + height: 1.5rem; +} diff --git a/src/v4/social/internal-components/CommunityMember/CommunityMember.tsx b/src/v4/social/internal-components/CommunityMember/CommunityMember.tsx index c01cfe9ae..8cfe080e8 100644 --- a/src/v4/social/internal-components/CommunityMember/CommunityMember.tsx +++ b/src/v4/social/internal-components/CommunityMember/CommunityMember.tsx @@ -2,8 +2,11 @@ import React from 'react'; import styles from './CommunityMember.module.css'; import { UserAvatar } from '~/v4/social/internal-components/UserAvatar'; import { MentionTypeaheadOption } from '~/v4/social/internal-components/MentionTextInput/MentionTextInput'; +import { BrandBadge } from '~/v4/social/internal-components/BrandBadge'; interface CommunityMemberProps { + pageId?: string; + componentId?: string; isSelected: boolean; onClick: () => void; onMouseEnter: () => void; @@ -11,6 +14,8 @@ interface CommunityMemberProps { } export function CommunityMember({ + pageId = '*', + componentId = '*', isSelected, onClick, onMouseEnter, @@ -35,11 +40,18 @@ export function CommunityMember({ <div key={option.key} className={styles.communityMember__item}> <div> <UserAvatar + pageId={pageId} + componentId={componentId} className={styles.communityMember__avatar} userId={option.user.avatarFileId} /> </div> - <p className={styles.communityMember__displayName}>{option.user.displayName}</p> + <div className={styles.communityMember__rightPane}> + <p className={styles.communityMember__displayName}>{option.user.displayName}</p> + {option.user.isBrand ? ( + <BrandBadge className={styles.communityMember__brandIcon} /> + ) : null} + </div> </div> </div> ); diff --git a/src/v4/social/internal-components/CommunityRowItem/CommunityRowItem.module.css b/src/v4/social/internal-components/CommunityRowItem/CommunityRowItem.module.css new file mode 100644 index 000000000..a346e87ec --- /dev/null +++ b/src/v4/social/internal-components/CommunityRowItem/CommunityRowItem.module.css @@ -0,0 +1,93 @@ +.communityRowItem { + display: grid; + grid-template-columns: [image-start] 5rem [image-end content-start] 1fr 1fr 1fr 1fr 1fr 1fr [content-end]; + grid-template-rows: + [name-start] auto + [name-end member-start] auto + [member-end]; + gap: 0.75rem; + width: 100%; +} + +.communityRowItem[data-has-categories='true'] { + grid-template-rows: + [name-start] auto + [name-end cat-start] auto + [cat-end member-start] auto + [member-end]; +} + +.communityRowItem__image { + grid-column: image-start / image-end; + grid-row: name-start / member-end; + place-self: center; + height: 5rem; + width: 5rem; + border-radius: var(--asc-border-radius-sm); + position: relative; +} + +.communityRowItem__order { + position: absolute; + left: 0.5rem; + bottom: 0.37rem; + color: var(--asc-color-white); +} + +.communityRowItem__content { + display: grid; + grid-column: content-start / content-end; + grid-row: name-start / member-end; + grid-template-columns: subgrid [sub-a] [sub-b] [sub-c] [sub-d] [sub-e] [sub-f] [sub-g]; + grid-template-rows: subgrid; + width: 100%; + padding: 0.62rem; + justify-content: space-evenly; + gap: 0.5rem; +} + +.communityRowItem__communityName { + grid-column: sub-a / sub-g; + grid-row: name-start / name-end; + display: flex; + justify-content: start; + align-items: center; + gap: 0.25rem; + width: 100%; +} + +.communityRowItem__categories { + grid-column: sub-a / sub-g; + grid-row: cat-start / cat-end; +} + +.communityRowItem__categories[data-show-join-button='true'] { + grid-column: sub-a / sub-e; + grid-row: cat-start / cat-end; +} + +.communityRowItem__member { + grid-column: sub-a / sub-e; + grid-row: member-start / member-end; +} + +.communityRowItem__joinButton { + grid-row: name-start / member-end; + grid-column: sub-e / sub-g; + width: 4rem; + place-self: end end; +} + +.communityRowItem__joinButton[data-has-categories='true'] { + grid-row: cat-start / member-end; +} + +.communityRowItem__communityName__private { + width: 1.25rem; + height: 1.25rem; + display: flex; + justify-content: center; + align-items: center; + padding-top: 0.22rem; + padding-bottom: 0.28rem; +} diff --git a/src/v4/social/internal-components/CommunityRowItem/CommunityRowItem.tsx b/src/v4/social/internal-components/CommunityRowItem/CommunityRowItem.tsx new file mode 100644 index 000000000..9aab70ca6 --- /dev/null +++ b/src/v4/social/internal-components/CommunityRowItem/CommunityRowItem.tsx @@ -0,0 +1,142 @@ +import React from 'react'; + +import { useAmityElement } from '~/v4/core/hooks/uikit'; +import { CommunityJoinButton } from '~/v4/social/elements/CommunityJoinButton/CommunityJoinButton'; +import { CommunityMembersCount } from '~/v4/social/elements/CommunityMembersCount/CommunityMembersCount'; +import { CommunityCategories } from '~/v4/social/internal-components/CommunityCategories/CommunityCategories'; +import { CommunityPrivateBadge } from '~/v4/social/elements/CommunityPrivateBadge/CommunityPrivateBadge'; +import { CommunityDisplayName } from '~/v4/social/elements/CommunityDisplayName/CommunityDisplayName'; +import { CommunityOfficialBadge } from '~/v4/social/elements/CommunityOfficialBadge/CommunityOfficialBadge'; +import { Typography } from '~/v4/core/components'; +import { CommunityRowImage } from '~/v4/social/elements/CommunityRowImage/CommunityRowImage'; +import { useImage } from '~/v4/core/hooks/useImage'; +import { CommunityJoinedButton } from '~/v4/social/elements/CommunityJoinedButton/CommunityJoinedButton'; + +import styles from './CommunityRowItem.module.css'; +import { ClickableArea } from '~/v4/core/natives/ClickableArea/ClickableArea'; + +type CommunityRowItemProps<TShowJoinButton extends boolean | undefined> = { + community: Amity.Community; + pageId?: string; + componentId?: string; + elementId?: string; + key?: string; + order?: number; + minCategoryCharacters?: number; + maxCategoryCharacters?: number; + maxCategoriesLength?: number; + showJoinButton?: TShowJoinButton; + onClick: (communityId: string) => void; + onCategoryClick: (categoryId: string) => void; +} & (TShowJoinButton extends true + ? { + onJoinButtonClick: (communityId: string) => void; + onLeaveButtonClick: (communityId: string) => void; + } + : { + onJoinButtonClick?: undefined | null; + onLeaveButtonClick?: undefined | null; + }); + +const formatOrder = (order: number) => { + if (order < 10) { + return `0${order}`; + } + return `${order}`; +}; + +export const CommunityRowItem = <T extends boolean | undefined>({ + key, + pageId = '*', + componentId = '*', + elementId = '*', + community, + order, + showJoinButton, + minCategoryCharacters, + maxCategoryCharacters, + maxCategoriesLength, + onJoinButtonClick, + onLeaveButtonClick, + onClick, + onCategoryClick, +}: CommunityRowItemProps<T>) => { + const { themeStyles } = useAmityElement({ pageId, componentId, elementId }); + + const avatarUrl = useImage({ fileId: community.avatarFileId, imageSize: 'medium' }); + + return ( + <ClickableArea + key={key} + elementType="div" + className={styles.communityRowItem} + onPress={() => onClick(community.communityId)} + data-has-categories={community.categoryIds.length > 0} + style={themeStyles} + > + <div className={styles.communityRowItem__image}> + <CommunityRowImage pageId={pageId} componentId={componentId} imgSrc={avatarUrl} /> + {typeof order === 'number' ? ( + <Typography.BodyBold className={styles.communityRowItem__order}> + {formatOrder(order)} + </Typography.BodyBold> + ) : null} + </div> + <div className={styles.communityRowItem__content}> + <div className={styles.communityRowItem__communityName}> + {!community.isPublic && ( + <div className={styles.communityRowItem__communityName__private}> + <CommunityPrivateBadge pageId={pageId} componentId={componentId} /> + </div> + )} + <CommunityDisplayName pageId={pageId} componentId={componentId} community={community} /> + {community.isOfficial && ( + <CommunityOfficialBadge pageId={pageId} componentId={componentId} /> + )} + </div> + + <div + className={styles.communityRowItem__categories} + data-show-join-button={showJoinButton === true} + > + <CommunityCategories + pageId={pageId} + componentId={componentId} + community={community} + maxCategoriesLength={maxCategoriesLength} + onClick={onCategoryClick} + minCategoryCharacters={minCategoryCharacters} + maxCategoryCharacters={maxCategoryCharacters} + truncate + /> + </div> + + <div className={styles.communityRowItem__member}> + <CommunityMembersCount + pageId={pageId} + componentId={componentId} + memberCount={community.membersCount} + /> + </div> + {showJoinButton === true && + (community.isJoined ? ( + <CommunityJoinedButton + pageId={pageId} + componentId={componentId} + className={styles.communityRowItem__joinButton} + data-has-categories={community.categoryIds.length > 0} + onClick={() => onLeaveButtonClick?.(community.communityId)} + /> + ) : ( + <CommunityJoinButton + pageId={pageId} + componentId={componentId} + className={styles.communityRowItem__joinButton} + data-has-categories={community.categoryIds.length > 0} + onClick={() => onJoinButtonClick?.(community.communityId)} + /> + ))} + </div> + </ClickableArea> + ); +}; diff --git a/src/v4/social/internal-components/CommunityRowItem/CommunityRowItemDivider.module.css b/src/v4/social/internal-components/CommunityRowItem/CommunityRowItemDivider.module.css new file mode 100644 index 000000000..9d4cd89d1 --- /dev/null +++ b/src/v4/social/internal-components/CommunityRowItem/CommunityRowItemDivider.module.css @@ -0,0 +1,12 @@ +.communityRowItemDivider { + background-color: var(--asc-color-base-shade4); + height: 0.0625rem; + width: 100%; + margin-top: 0.75rem; + margin-bottom: 0.75rem; +} + +.communityRowItemDivider:first-child { + all: unset; + margin-top: 0.75rem; +} diff --git a/src/v4/social/internal-components/CommunityRowItem/CommunityRowItemDivider.tsx b/src/v4/social/internal-components/CommunityRowItem/CommunityRowItemDivider.tsx new file mode 100644 index 000000000..00abdbff2 --- /dev/null +++ b/src/v4/social/internal-components/CommunityRowItem/CommunityRowItemDivider.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +import styles from './CommunityRowItemDivider.module.css'; + +export const CommunityRowItemDivider = () => <div className={styles.communityRowItemDivider} />; diff --git a/src/v4/social/internal-components/CommunityRowItem/CommunityRowItemSkeleton.module.css b/src/v4/social/internal-components/CommunityRowItem/CommunityRowItemSkeleton.module.css new file mode 100644 index 000000000..2fa55fa12 --- /dev/null +++ b/src/v4/social/internal-components/CommunityRowItem/CommunityRowItemSkeleton.module.css @@ -0,0 +1,53 @@ +.communityRowItemSkeleton { + width: 100%; + height: 5rem; + border-radius: 0.75rem; + display: inline-flex; + gap: 1rem; +} + +.communityRowItemSkeleton__avatar { + width: 5rem; + height: 5rem; + border-radius: var(--asc-border-radius-sm); + background-color: var(--asc-color-base-shade4); +} + +.communityRowItemSkeleton__content { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.75rem; +} + +.communityRowItemSkeleton__contentBar1 { + border-radius: 0.75rem; + background-color: var(--asc-color-base-shade4); + width: 12.25rem; + height: 0.75rem; +} + +.communityRowItemSkeleton__contentBar2 { + border-radius: 0.75rem; + background-color: var(--asc-color-base-shade4); + width: 5.8008rem; + height: 0.625rem; +} + +.communityRowItemSkeleton__animation { + animation: skeleton-pulse 1.5s ease-in-out infinite; +} + +@keyframes skeleton-pulse { + 0% { + opacity: 0.6; + } + + 50% { + opacity: 1; + } + + 100% { + opacity: 0.6; + } +} diff --git a/src/v4/social/internal-components/CommunityRowItem/CommunityRowItemSkeleton.tsx b/src/v4/social/internal-components/CommunityRowItem/CommunityRowItemSkeleton.tsx new file mode 100644 index 000000000..40991a43b --- /dev/null +++ b/src/v4/social/internal-components/CommunityRowItem/CommunityRowItemSkeleton.tsx @@ -0,0 +1,48 @@ +import clsx from 'clsx'; +import React from 'react'; +import { useAmityElement } from '~/v4/core/hooks/uikit'; + +import styles from './CommunityRowItemSkeleton.module.css'; + +interface CommunityRowItemSkeleton { + pageId?: string; + componentId?: string; + elementId?: string; +} + +export const CommunityRowItemSkeleton = ({ + pageId = '*', + componentId = '*', + elementId = '*', +}: CommunityRowItemSkeleton) => { + const { themeStyles } = useAmityElement({ pageId, componentId, elementId }); + return ( + <div className={styles.communityRowItemSkeleton} style={themeStyles}> + <div + className={clsx( + styles.communityRowItemSkeleton__avatar, + styles.communityRowItemSkeleton__animation, + )} + /> + <div + className={clsx( + styles.communityRowItemSkeleton__content, + styles.communityRowItemSkeleton__animation, + )} + > + <div + className={clsx( + styles.communityRowItemSkeleton__contentBar1, + styles.communityRowItemSkeleton__animation, + )} + /> + <div + className={clsx( + styles.communityRowItemSkeleton__contentBar2, + styles.communityRowItemSkeleton__animation, + )} + /> + </div> + </div> + ); +}; diff --git a/src/v4/social/internal-components/CommunityRowItem/index.tsx b/src/v4/social/internal-components/CommunityRowItem/index.tsx new file mode 100644 index 000000000..05cd18738 --- /dev/null +++ b/src/v4/social/internal-components/CommunityRowItem/index.tsx @@ -0,0 +1 @@ +export { CommunityRowItem } from './CommunityRowItem'; diff --git a/src/v4/social/internal-components/CreatePost/CreatePost.tsx b/src/v4/social/internal-components/CreatePost/CreatePost.tsx index 9984902b4..fcb9d1dca 100644 --- a/src/v4/social/internal-components/CreatePost/CreatePost.tsx +++ b/src/v4/social/internal-components/CreatePost/CreatePost.tsx @@ -305,6 +305,7 @@ export function CreatePost({ community, targetType, targetId }: AmityPostCompose /> </div> <PostTextField + pageId={pageId} onChange={onChange} communityId={targetId} mentionContainer={mentionRef.current} @@ -313,6 +314,7 @@ export function CreatePost({ community, targetType, targetId }: AmityPostCompose }} /> <ImageThumbnail + pageId={pageId} files={incomingImages} uploadedFiles={postImages} uploadLoading={uploadLoading} @@ -325,6 +327,7 @@ export function CreatePost({ community, targetType, targetId }: AmityPostCompose isErrorUpload={isErrorUpload} /> <VideoThumbnail + pageId={pageId} files={incomingVideos} uploadedFiles={postVideos} uploadLoading={uploadLoading} diff --git a/src/v4/social/internal-components/EditPost/EditPost.tsx b/src/v4/social/internal-components/EditPost/EditPost.tsx index eedd0bfef..7d6353ffc 100644 --- a/src/v4/social/internal-components/EditPost/EditPost.tsx +++ b/src/v4/social/internal-components/EditPost/EditPost.tsx @@ -161,6 +161,7 @@ export function EditPost({ post }: AmityPostComposerEditOptions) { /> </div> <PostTextField + pageId={pageId} communityId={post.targetType === 'community' ? post.targetId : undefined} onChange={onChange} mentionContainer={mentionRef.current} @@ -173,7 +174,7 @@ export function EditPost({ post }: AmityPostComposerEditOptions) { }} /> - <Thumbnail postMedia={postImages} onRemove={handleRemoveThumbnailImage} /> + <Thumbnail pageId={pageId} postMedia={postImages} onRemove={handleRemoveThumbnailImage} /> <Thumbnail postMedia={postVideos} onRemove={handleRemoveThumbnailVideo} /> <div ref={mentionRef} className={styles.mentionTextInput_item} /> diff --git a/src/v4/social/internal-components/EditPost/Thumbnail.tsx b/src/v4/social/internal-components/EditPost/Thumbnail.tsx index b3370ea38..a6e29629b 100644 --- a/src/v4/social/internal-components/EditPost/Thumbnail.tsx +++ b/src/v4/social/internal-components/EditPost/Thumbnail.tsx @@ -6,6 +6,8 @@ import { PostContentType } from '@amityco/ts-sdk'; import { Button } from '~/v4/core/natives/Button'; const MediaComponent = ({ + pageId = '*', + componentId = '*', fieldId, onRemove, type, @@ -13,14 +15,24 @@ const MediaComponent = ({ fieldId: string; onRemove: () => void; type: 'image' | 'video'; + pageId?: string; + componentId?: string; }) => { const thumbnailUrl = useImage({ fileId: fieldId }); + const elementId = 'remove_thumbnail'; + if (!thumbnailUrl) return null; + return ( <> <img className={styles.thumbnail} src={thumbnailUrl} alt="thumbnail" /> - <Button type="reset" onPress={() => onRemove()} className={styles.thumbnail__closeButton}> + <Button + data-qa-anchor={`${pageId}/${componentId}/${elementId}`} + type="reset" + onPress={() => onRemove()} + className={styles.thumbnail__closeButton} + > <CloseIcon className={styles.thumbnail__closeIcon} /> </Button> {type === PostContentType.VIDEO && ( @@ -33,9 +45,11 @@ const MediaComponent = ({ }; export const Thumbnail = ({ + pageId = '*', postMedia, onRemove, }: { + pageId?: string; postMedia: Amity.Post<'image' | 'video'>[]; onRemove: (fileId: string) => void; }) => { @@ -50,6 +64,7 @@ export const Thumbnail = ({ className={styles.thumbnail__wrapper} > <MediaComponent + pageId={pageId} type={file.dataType} key={index} fieldId={ diff --git a/src/v4/social/internal-components/ImageThumbnail/ImageThumbnail.tsx b/src/v4/social/internal-components/ImageThumbnail/ImageThumbnail.tsx index 790e9e10d..f053d3998 100644 --- a/src/v4/social/internal-components/ImageThumbnail/ImageThumbnail.tsx +++ b/src/v4/social/internal-components/ImageThumbnail/ImageThumbnail.tsx @@ -6,6 +6,7 @@ import useFileUpload from '~/v4/social/hooks/useFileUpload'; import { FileRepository } from '@amityco/ts-sdk'; interface ImageThumbnailProps { + pageId?: string; files: File[]; uploadedFiles: Amity.File[]; onChange: (data: { uploaded: Array<Amity.File>; uploading: Array<File> }) => void; @@ -16,6 +17,7 @@ interface ImageThumbnailProps { } export function ImageThumbnail({ + pageId = '*', files, uploadedFiles, onChange, @@ -59,11 +61,16 @@ export function ImageThumbnail({ ) : ( <> <img + data-qa-anchor={`${pageId}/*/image_thumbnail`} className={styles.thumbnail} src={FileRepository.fileUrlWithSize((file as Amity.File)?.fileUrl, 'medium')} alt={(file as Amity.File).attributes?.name} /> - <button type="reset" onClick={() => removeFile(file)}> + <button + data-qa-anchor={`${pageId}/*/remove_thumbnail`} + type="reset" + onClick={() => removeFile(file)} + > <CloseIcon className={styles.closeIcon} /> </button> </> diff --git a/src/v4/social/internal-components/Lexical/MentionItem.module.css b/src/v4/social/internal-components/Lexical/MentionItem.module.css index a060ebb2a..fdd8d4a9c 100644 --- a/src/v4/social/internal-components/Lexical/MentionItem.module.css +++ b/src/v4/social/internal-components/Lexical/MentionItem.module.css @@ -47,3 +47,16 @@ overflow: hidden; color: var(--asc-color-base-default); } + +.userMentionItem__rightPane { + display: grid; + grid-template-columns: auto 1.5rem; + justify-content: center; + align-items: center; + gap: 0.5rem; +} + +.userMentionItem__brandIcon { + width: 1.5rem; + height: 1.5rem; +} diff --git a/src/v4/social/internal-components/Lexical/MentionItem.tsx b/src/v4/social/internal-components/Lexical/MentionItem.tsx index 98059c869..9cd28181b 100644 --- a/src/v4/social/internal-components/Lexical/MentionItem.tsx +++ b/src/v4/social/internal-components/Lexical/MentionItem.tsx @@ -4,15 +4,30 @@ import { MentionTypeaheadOption } from './plugins/MentionPlugin'; import { MentionData } from './utils'; import styles from './MentionItem.module.css'; +import { BrandBadge } from '~/v4/social/internal-components/BrandBadge'; +import { useUser } from '~/v4/core/hooks/objects/useUser'; interface MentionItemProps { + pageId?: string; + componentId?: string; isSelected: boolean; onClick: () => void; onMouseEnter: () => void; option: MentionTypeaheadOption<MentionData>; } -export function MentionItem({ option, isSelected, onClick, onMouseEnter }: MentionItemProps) { +export function MentionItem({ + pageId = '*', + componentId = '*', + option, + isSelected, + onClick, + onMouseEnter, +}: MentionItemProps) { + const { user } = useUser({ + userId: option.data.userId, + }); + return ( <li key={option.key} @@ -26,9 +41,17 @@ export function MentionItem({ option, isSelected, onClick, onMouseEnter }: Menti onClick={onClick} > <div> - <UserAvatar className={styles.userMentionItem__avatar} userId={option.data.userId} /> + <UserAvatar + pageId={pageId} + componentId={componentId} + className={styles.userMentionItem__avatar} + userId={option.data.userId} + /> + </div> + <div className={styles.userMentionItem__rightPane}> + <p className={styles.userMentionItem__displayName}>{user?.displayName}</p> + {user?.isBrand ? <BrandBadge className={styles.userMentionItem__brandIcon} /> : null} </div> - <p className={styles.userMentionItem__displayName}>{option.data.displayName}</p> </li> ); } diff --git a/src/v4/social/internal-components/Lexical/__snapshots__/utils.test.ts.snap b/src/v4/social/internal-components/Lexical/__snapshots__/utils.test.ts.snap new file mode 100644 index 000000000..12666249b --- /dev/null +++ b/src/v4/social/internal-components/Lexical/__snapshots__/utils.test.ts.snap @@ -0,0 +1,292 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`v3 should return a same object 1`] = ` +{ + "root": { + "children": [ + { + "children": [ + { + "data": { + "displayName": "Web-Test", + "userId": "Web-Test", + }, + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "@Web-Test", + "type": "mention", + "version": 1, + }, + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": " hello ", + "type": "text", + "version": 1, + }, + { + "data": { + "displayName": "FonTS", + "userId": "FonTS", + }, + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "@FonTS", + "type": "mention", + "version": 1, + }, + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": " ", + "type": "text", + "version": 1, + }, + ], + "direction": "ltr", + "format": "", + "indent": 0, + "textFormat": 0, + "textStyle": "", + "type": "paragraph", + "version": 1, + }, + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "root", + "version": 1, + }, +} +`; + +exports[`v4 should return a same object 1`] = ` +{ + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "1 ", + "type": "text", + "version": 1, + }, + { + "data": { + "displayName": " android2", + "userId": " android2", + }, + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "@ android2", + "type": "mention", + "version": 1, + }, + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": " nstaehunstah ", + "type": "text", + "version": 1, + }, + { + "data": { + "displayName": " android2", + "userId": " android2", + }, + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "@ android2", + "type": "mention", + "version": 1, + }, + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": " ", + "type": "text", + "version": 1, + }, + { + "data": { + "displayName": "Web-Test", + "userId": "Web-Test", + }, + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "@Web-Test", + "type": "mention", + "version": 1, + }, + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": " aenotuhnasetouhnsateohunsatheunst sntaehuns", + "type": "text", + "version": 1, + }, + ], + "direction": "ltr", + "format": "", + "indent": 0, + "textFormat": 0, + "textStyle": "", + "type": "paragraph", + "version": 1, + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "STnseatu ", + "type": "text", + "version": 1, + }, + { + "data": { + "displayName": " android3", + "userId": " android3", + }, + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "@ android3", + "type": "mention", + "version": 1, + }, + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": " ", + "type": "text", + "version": 1, + }, + ], + "direction": "ltr", + "format": "", + "indent": 0, + "textFormat": 0, + "textStyle": "", + "type": "paragraph", + "version": 1, + }, + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "root", + "version": 1, + }, +} +`; + +exports[`v4 should return a same object 2`] = ` +{ + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "www.google.com", + "type": "text", + "version": 1, + }, + ], + "direction": "ltr", + "format": "", + "indent": 0, + "rel": null, + "target": null, + "title": null, + "type": "link", + "url": "http://www.google.com", + "version": 1, + }, + ], + "direction": "ltr", + "format": "", + "indent": 0, + "textFormat": 0, + "textStyle": "", + "type": "paragraph", + "version": 1, + }, + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "www.youtube.com", + "type": "text", + "version": 1, + }, + ], + "direction": "ltr", + "format": "", + "indent": 0, + "rel": null, + "target": null, + "title": null, + "type": "link", + "url": "http://www.youtube.com", + "version": 1, + }, + ], + "direction": "ltr", + "format": "", + "indent": 0, + "textFormat": 0, + "textStyle": "", + "type": "paragraph", + "version": 1, + }, + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "root", + "version": 1, + }, +} +`; diff --git a/src/v4/social/internal-components/Lexical/nodes/MentionNode.ts b/src/v4/social/internal-components/Lexical/nodes/MentionNode.ts index f4366ea54..dbf919198 100644 --- a/src/v4/social/internal-components/Lexical/nodes/MentionNode.ts +++ b/src/v4/social/internal-components/Lexical/nodes/MentionNode.ts @@ -89,6 +89,7 @@ export class MentionNode<T> extends TextNode { createDOM(config: EditorConfig): HTMLElement { const dom = super.createDOM(config); dom.className = styles.mention; //create css + dom.setAttribute('data-qa-anchor', 'mention-preview'); return dom; } diff --git a/src/v4/social/internal-components/Lexical/utils.test.ts b/src/v4/social/internal-components/Lexical/utils.test.ts new file mode 100644 index 000000000..3509ff1ec --- /dev/null +++ b/src/v4/social/internal-components/Lexical/utils.test.ts @@ -0,0 +1,97 @@ +import { Metadata, Mentionees } from '~/v4/helpers/utils'; +import { editorStateToText, textToEditorState } from './utils'; + +type Input = { + data: { + text: string; + }; + metadata: Metadata; + mentionees: Mentionees; +}; + +const inputsV3: Input[] = [ + { + data: { + text: '@Web-Test hello @FonTS ', + }, + metadata: { + mentioned: [ + { + index: 0, + length: 8, + type: 'user', + userId: 'Web-Test', + }, + { + index: 16, + length: 5, + type: 'user', + userId: 'FonTS', + }, + ], + }, + mentionees: [ + { + type: 'user', + userIds: ['Web-Test', 'FonTS'], + }, + ], + }, +]; + +const inputsV4: Input[] = [ + { + data: { + text: '1 @ android2 nstaehunstah @ android2 @Web-Test aenotuhnasetouhnsateohunsatheunst sntaehuns\nSTnseatu @ android3 ', + }, + metadata: { + mentioned: [ + { index: 2, length: 9, type: 'user', userId: ' android2', displayName: ' android2' }, + { index: 26, length: 9, type: 'user', userId: ' android2', displayName: ' android2' }, + { index: 37, length: 8, type: 'user', userId: 'Web-Test', displayName: 'Web-Test' }, + { index: 100, length: 9, type: 'user', userId: ' android3', displayName: ' android3' }, + ], + }, + mentionees: [{ type: 'user', userIds: [' android2', ' android2', 'Web-Test', ' android3'] }], + }, + { + data: { + text: 'www.google.com\nwww.youtube.com', + }, + metadata: { + mentioned: [], + }, + mentionees: [], + }, +]; + +describe('v3', () => { + test.each(inputsV3)('should return a same object', (input) => { + const editorState = textToEditorState(input); + + expect(editorState).toMatchSnapshot(); + + const actual = editorStateToText(editorState); + expect(actual.text).toEqual(input.data.text); + const expectedMentioned = (input.metadata.mentioned || []).map((m) => ({ + ...m, + displayName: m.userId, + })); + expect(actual.mentioned).toEqual(expectedMentioned); + expect(actual.mentionees).toEqual(input.mentionees); + }); +}); + +describe('v4', () => { + test.each(inputsV4)('should return a same object', (input) => { + const editorState = textToEditorState(input); + + expect(editorState).toMatchSnapshot(); + + const actual = editorStateToText(editorState); + expect(actual.text).toEqual(input.data.text); + const expectedMentioned = actual.mentioned.map(({ ...m }) => m); + expect(expectedMentioned).toEqual(input.metadata.mentioned); + expect(actual.mentionees).toEqual(input.mentionees); + }); +}); diff --git a/src/v4/social/internal-components/Lexical/utils.ts b/src/v4/social/internal-components/Lexical/utils.ts index 8c37f7b4d..20aaa20c4 100644 --- a/src/v4/social/internal-components/Lexical/utils.ts +++ b/src/v4/social/internal-components/Lexical/utils.ts @@ -1,10 +1,11 @@ -import { SerializedAutoLinkNode } from '@lexical/link'; +import { SerializedAutoLinkNode, SerializedLinkNode } from '@lexical/link'; import { InitialConfigType, InitialEditorStateType } from '@lexical/react/LexicalComposer'; import { EditorThemeClasses, Klass, LexicalEditor, LexicalNode, + SerializedEditorState, SerializedLexicalNode, SerializedParagraphNode, SerializedRootNode, @@ -12,6 +13,7 @@ import { } from 'lexical'; import { Mentioned, Mentionees } from '~/v4/helpers/utils'; import { SerializedMentionNode } from './nodes/MentionNode'; +import * as linkify from 'linkifyjs'; export interface EditorStateJson extends SerializedLexicalNode { children: []; @@ -33,6 +35,10 @@ export function $isSerializedAutoLinkNode( return node.type === 'autolink'; } +export function $isSerializedLinkNode(node: SerializedLexicalNode): node is SerializedLinkNode { + return node.type === 'link'; +} + export type MentionData = { userId: string; displayName?: string; @@ -51,6 +57,7 @@ function createRootNode(): SerializedRootNode<SerializedParagraphNode> { function createParagraphNode(): SerializedParagraphNode { return { + textStyle: '', children: [], direction: 'ltr', format: '', @@ -73,22 +80,58 @@ function createSerializeTextNode(text: string): SerializedTextNode { }; } -function createSerializeMentionNode(mention: Mentioned): SerializedMentionNode<MentionData> { +function createSerializeMentionNode({ + text, + mention, +}: { + text: string; + mention: Mentioned; +}): SerializedMentionNode<MentionData> { return { detail: 0, format: 0, mode: 'normal', style: '', - text: ('@' + mention.userId) as string, + text: text.substring(mention.index, mention.index + mention.length + 1), type: 'mention', version: 1, data: { - displayName: mention.userId as string, + displayName: mention.displayName || (mention.userId as string), userId: mention.userId as string, }, }; } +function createSerializeLinkNode({ + url, + title, +}: { + url: string; + title: string; +}): SerializedLinkNode { + return { + children: [createSerializeTextNode(title)], + format: '', + direction: 'ltr', + indent: 0, + type: 'link', + version: 1, + url, + rel: null, + target: null, + title: null, + }; +} + +type LinkData = Mentioned & { + href: string; + value: string; +}; + +function isLinkData(data: Mentioned | LinkData): data is LinkData { + return (data as LinkData)?.type === 'url'; +} + export function textToEditorState(value: { data: { text: string }; metadata?: { @@ -100,27 +143,76 @@ export function textToEditorState(value: { const textArray = value.data.text.split('\n'); - const mentions = value.metadata?.mentioned || []; + const mentions = value.metadata?.mentioned ?? []; - let mentionIndex = 0; + const links: Array<LinkData> = linkify + .find(value.data.text) + .filter((link) => link.type === 'url') + .map((link) => ({ + index: link.start, + length: link.end - link.start + 1, + href: link.href, + value: link.value, + type: 'url', + })); + + const indexMap: Record<number, boolean> = {}; + + const mentionsAndLinks = [...mentions, ...links] + .sort((a, b) => a.index - b.index) + .reduce((acc, mentionAndLink) => { + // this function is used to remove duplicate mentions and links that cause an infinite loop + if (indexMap[mentionAndLink.index]) { + return acc; + } + + indexMap[mentionAndLink.index] = true; + return [...acc, mentionAndLink]; + }, [] as Mentioned[]); + + let mentionAndLinkIndex = 0; let globalIndex = 0; for (let i = 0; i < textArray.length; i += 1) { const paragraph = createParagraphNode(); let runningIndex = 0; - while (runningIndex < textArray[i].length) { - if (mentionIndex < mentions.length && mentions[mentionIndex].index === globalIndex) { - paragraph.children.push(createSerializeMentionNode(mentions[mentionIndex])); - runningIndex += mentions[mentionIndex].length; - globalIndex += mentions[mentionIndex].length; - mentionIndex += 1; + const currentLine = textArray[i]; + + while (runningIndex < currentLine.length) { + const mentionOrLink = mentionsAndLinks[mentionAndLinkIndex]; + + if (mentionAndLinkIndex < mentionsAndLinks.length && mentionOrLink.index === globalIndex) { + const mentionOrLink = mentionsAndLinks[mentionAndLinkIndex]; + + if (isLinkData(mentionOrLink)) { + paragraph.children.push( + createSerializeLinkNode({ + title: mentionOrLink.value, + url: mentionOrLink.href, + }), + ); + runningIndex += mentionOrLink.value.length; + globalIndex += mentionOrLink.value.length; + } else { + paragraph.children.push( + createSerializeMentionNode({ + text: value.data.text, + mention: mentionOrLink, + }), + ); + runningIndex += mentionOrLink.length + 1; + globalIndex += mentionOrLink.length + 1; + } + + mentionAndLinkIndex += 1; } else { const nextMentionIndex = - mentionIndex < mentions.length - ? mentions[mentionIndex].index - : globalIndex + textArray[i].length; - const textSegment = textArray[i].slice( + mentionAndLinkIndex < mentionsAndLinks.length + ? mentionOrLink.index + : globalIndex + currentLine.length; + + const textSegment = currentLine.slice( runningIndex, nextMentionIndex - globalIndex + runningIndex, ); @@ -132,8 +224,8 @@ export function textToEditorState(value: { } } - if (runningIndex < textArray[i].length) { - const textSegment = textArray[i].slice(runningIndex); + if (runningIndex < currentLine.length) { + const textSegment = currentLine.slice(runningIndex); if (textSegment) { paragraph.children.push(createSerializeTextNode(textSegment)); } @@ -148,9 +240,14 @@ export function textToEditorState(value: { return { root: rootNode }; } -export function editorStateToText(editor: LexicalEditor) { +export function editorToText(editor: LexicalEditor) { + const editorState = editor.getEditorState().toJSON(); + return editorStateToText(editorState); +} + +export function editorStateToText(editorState: SerializedEditorState) { const editorStateTextString: string[] = []; - const paragraphs = editor.getEditorState().toJSON().root.children as EditorStateJson[]; + const paragraphs = editorState.root.children as EditorStateJson[]; const mentioned: Mentioned[] = []; let isChannelMentioned = false; @@ -167,7 +264,7 @@ export function editorStateToText(editor: LexicalEditor) { paragraphText.push(child.text); runningIndex += child.text.length; } - if ($isSerializedAutoLinkNode(child)) { + if ($isSerializedLinkNode(child) || $isSerializedAutoLinkNode(child)) { child.children.forEach((c) => { if (!$isSerializedTextNode(c)) return; paragraphText.push(c.text); @@ -176,6 +273,8 @@ export function editorStateToText(editor: LexicalEditor) { } if ($isSerializedMentionNode<MentionData>(child)) { + const isStartWithAtSign = child.text.charAt(0) === '@'; + if (child.data.userId === 'all') { mentioned.push({ index: runningIndex, @@ -184,11 +283,13 @@ export function editorStateToText(editor: LexicalEditor) { }); isChannelMentioned = true; } else { + const textLength = isStartWithAtSign ? child.text.length - 1 : child.text.length; mentioned.push({ index: runningIndex, - length: child.text.length, + length: textLength, type: 'user', userId: child.data.userId, + displayName: child.data.displayName, }); mentioneeUserIds.push(child.data.userId); } diff --git a/src/v4/social/internal-components/PostAd/UIPostAd.tsx b/src/v4/social/internal-components/PostAd/UIPostAd.tsx index 6dfb7c943..284431958 100644 --- a/src/v4/social/internal-components/PostAd/UIPostAd.tsx +++ b/src/v4/social/internal-components/PostAd/UIPostAd.tsx @@ -7,6 +7,7 @@ import { Button } from '~/v4/core/natives/Button'; import { AdInformation } from '~/v4/social/internal-components/AdInformation/AdInformation'; import styles from './UIPostAd.module.css'; +import clsx from 'clsx'; interface UIPostAdProps { ad: Amity.Ad; @@ -60,9 +61,13 @@ export const UIPostAd = ({ onPress={handleCallToActionClick} > <div className={styles.footer__left}> - <Typography.Body className={styles.footer__content__description}> - {ad.description} - </Typography.Body> + <Typography.Body + renderer={({ typoClassName }) => ( + <div className={clsx(typoClassName, styles.footer__content__description)}> + {ad.description} + </div> + )} + /> <Typography.BodyBold className={styles.footer__content__headline}> {ad.headline} </Typography.BodyBold> @@ -70,9 +75,13 @@ export const UIPostAd = ({ {ad.callToActionUrl ? ( <div className={styles.footer__right}> <Button className={styles.footer__content__button} onPress={handleCallToActionClick}> - <Typography.CaptionBold className={styles.footer__content__button__text}> - {ad.callToAction} - </Typography.CaptionBold> + <Typography.CaptionBold + renderer={({ typoClassName }) => ( + <div className={clsx(typoClassName, styles.footer__content__button__text)}> + {ad.callToAction} + </div> + )} + /> </Button> </div> ) : null} diff --git a/src/v4/social/internal-components/PostMenu/PostMenu.tsx b/src/v4/social/internal-components/PostMenu/PostMenu.tsx index 99862ac55..a4dfe9d80 100644 --- a/src/v4/social/internal-components/PostMenu/PostMenu.tsx +++ b/src/v4/social/internal-components/PostMenu/PostMenu.tsx @@ -165,6 +165,7 @@ export const PostMenu = ({ <div className={styles.postMenu}> {showReportPostButton && !isLoading ? ( <Button + data-qa-anchor={`${pageId}/${componentId}/report_post_button`} className={styles.postMenu__item} onPress={() => { if (isFlaggedByMe) { @@ -183,6 +184,7 @@ export const PostMenu = ({ ) : null} {showEditPostButton ? ( <Button + data-qa-anchor={`${pageId}/${componentId}/edit_post`} className={styles.postMenu__item} onPress={() => { removeDrawerData(); @@ -197,7 +199,11 @@ export const PostMenu = ({ </Button> ) : null} {showDeletePostButton ? ( - <Button className={styles.postMenu__item} onPress={() => onDeleteClick()}> + <Button + data-qa-anchor={`${pageId}/${componentId}/delete_post`} + className={styles.postMenu__item} + onPress={() => onDeleteClick()} + > <TrashSvg className={styles.postMenu__deletePost__icon} /> <Typography.Title className={styles.postMenu__deletePost__text}> Delete post diff --git a/src/v4/social/internal-components/StoryCommentComposeBar/StoryCommentComposeBar.tsx b/src/v4/social/internal-components/StoryCommentComposeBar/StoryCommentComposeBar.tsx index 12e39766f..9784a3452 100644 --- a/src/v4/social/internal-components/StoryCommentComposeBar/StoryCommentComposeBar.tsx +++ b/src/v4/social/internal-components/StoryCommentComposeBar/StoryCommentComposeBar.tsx @@ -1,6 +1,5 @@ import { CommentRepository } from '@amityco/ts-sdk'; import React from 'react'; -import { FormattedMessage, useIntl } from 'react-intl'; import { Mentionees, Metadata } from '~/v4/helpers/utils'; import { Close, Lock2Icon } from '~/icons'; import { CommentComposeBar } from '~/v4/social/internal-components/CommentComposeBar'; @@ -27,8 +26,6 @@ export const StoryCommentComposeBar = ({ onCancelReply, referenceId, }: StoryCommentComposeBarProps) => { - const { formatMessage } = useIntl(); - const handleAddComment = async ( commentText: string, mentionees: Mentionees, @@ -68,7 +65,7 @@ export const StoryCommentComposeBar = ({ return ( <div className={styles.disabledCommentComposerBarContainer}> <Lock2Icon /> - {formatMessage({ id: 'storyViewer.commentSheet.disabled' })} + Comments are disabled for this story </div> ); } @@ -78,7 +75,7 @@ export const StoryCommentComposeBar = ({ {isReplying && ( <div className={styles.replyingBlock}> <div className={styles.replyingToText}> - <FormattedMessage id="storyViewer.commentSheet.replyingTo" />{' '} + {'Replying to '} <span className={styles.replyingToUsername}>{replyTo?.userId}</span> </div> <Close onClick={onCancelReply} className={styles.closeButton} /> @@ -86,12 +83,14 @@ export const StoryCommentComposeBar = ({ )} {!isReplying ? ( <CommentComposeBar + targetType="story" targetId={communityId} onSubmit={(text, mentionees, metadata) => handleAddComment(text, mentionees, metadata)} /> ) : ( <CommentComposeBar targetId={communityId} + targetType="story" userToReply={replyTo?.userId} onSubmit={(replyText, mentionees, metadata) => { handleReplyToComment(replyText, mentionees, metadata); diff --git a/src/v4/social/internal-components/StoryViewer/Renderers/Image.tsx b/src/v4/social/internal-components/StoryViewer/Renderers/Image.tsx index b18a340bf..bba58dfbf 100644 --- a/src/v4/social/internal-components/StoryViewer/Renderers/Image.tsx +++ b/src/v4/social/internal-components/StoryViewer/Renderers/Image.tsx @@ -77,7 +77,7 @@ export const renderer: CustomRenderer = ({ const member = members?.find((member) => member.userId === client?.userId); const isMember = member != null; - const { user } = useUser(client?.userId); + const { user } = useUser({ userId: client?.userId }); const isOfficial = community?.isOfficial || false; const isCreator = creator?.userId === user?.userId; diff --git a/src/v4/social/internal-components/StoryViewer/Renderers/Video.tsx b/src/v4/social/internal-components/StoryViewer/Renderers/Video.tsx index b4e2e867d..59c3764ba 100644 --- a/src/v4/social/internal-components/StoryViewer/Renderers/Video.tsx +++ b/src/v4/social/internal-components/StoryViewer/Renderers/Video.tsx @@ -97,7 +97,7 @@ export const renderer: CustomRenderer = ({ const [loaded, setLoaded] = useState(false); const { loader } = config; const { client } = useSDK(); - const { user } = useUser(client?.userId); + const { user } = useUser({ userId: client?.userId }); const vid = useRef<HTMLVideoElement>(null); const { muted, mute, unmute } = useAudioControl(); @@ -140,6 +140,8 @@ export const renderer: CustomRenderer = ({ const haveStoryPermission = isGlobalAdmin || isModeratorUser || checkStoryPermission(client, community?.communityId); + const [isLoading, setIsLoading] = useState(true); + const videoLoaded = useCallback(() => { messageHandler('UPDATE_VIDEO_DURATION', { // TODO: need to fix video type from TS-SDK @@ -166,6 +168,7 @@ export const renderer: CustomRenderer = ({ vid.current.pause(); action('pause', true); } else { + videoLoaded(); vid.current.play(); action('play', true); } @@ -268,12 +271,19 @@ export const renderer: CustomRenderer = ({ controls={false} onLoadedData={videoLoaded} playsInline - onWaiting={() => action('pause', true)} - onPlaying={() => action('play', true)} + onCanPlay={() => { + setIsLoading(false); + }} + onWaiting={() => { + action('pause', true); + }} + onPlaying={() => { + action('play', true); + }} muted={muted} autoPlay /> - {!loaded && ( + {(!loaded || isLoading) && ( <div className={clsx(rendererStyles.loadingOverlay)}>{loader || <div>loading...</div>}</div> )} <BottomSheet diff --git a/src/v4/social/internal-components/TabsBar/TabsBar.module.css b/src/v4/social/internal-components/TabsBar/TabsBar.module.css index 76579fd01..d40698df5 100644 --- a/src/v4/social/internal-components/TabsBar/TabsBar.module.css +++ b/src/v4/social/internal-components/TabsBar/TabsBar.module.css @@ -1,10 +1,3 @@ -/* TODO: remove this block */ -button, -fieldset, -input { - all: unset; -} - .tabsRoot { display: flex; flex-direction: column; @@ -19,6 +12,7 @@ input { } .tabsTrigger { + all: unset; height: 3rem; color: var(--asc-color-base-shade3); } diff --git a/src/v4/social/internal-components/TabsBar/TabsBar.tsx b/src/v4/social/internal-components/TabsBar/TabsBar.tsx index 8556a44d3..0ba18f4a3 100644 --- a/src/v4/social/internal-components/TabsBar/TabsBar.tsx +++ b/src/v4/social/internal-components/TabsBar/TabsBar.tsx @@ -37,7 +37,11 @@ export const TabsBar = ({ > <Tabs.List className={styles.tabsList}> {tabs.map((tab) => ( - <Tabs.Trigger value={tab.value} className={styles.tabsTrigger}> + <Tabs.Trigger + data-qa-anchor={`${pageId}/${componentId}/${tab.value}_tab`} + value={tab.value} + className={styles.tabsTrigger} + > <Typography.Title>{tab.label}</Typography.Title> </Tabs.Trigger> ))} diff --git a/src/v4/social/internal-components/TextWithMention/TextWithMention.module.css b/src/v4/social/internal-components/TextWithMention/TextWithMention.module.css index 7d43d48c7..9bbdb3bef 100644 --- a/src/v4/social/internal-components/TextWithMention/TextWithMention.module.css +++ b/src/v4/social/internal-components/TextWithMention/TextWithMention.module.css @@ -18,6 +18,12 @@ max-height: max-content; } +.textWithMention__paragraph { + height: 100%; + margin: 0; + padding: 0; +} + .textWithMention__fullContent { visibility: hidden; position: relative; @@ -43,6 +49,13 @@ display: inline; } +.textWithMention__link { + display: inline; + color: var(--asc-color-primary-shade1); + text-decoration: none; + cursor: pointer; +} + .textWithMention__showMoreLess { padding-left: 0.2rem; color: var(--asc-color-primary-default); diff --git a/src/v4/social/internal-components/TextWithMention/TextWithMention.tsx b/src/v4/social/internal-components/TextWithMention/TextWithMention.tsx index abb21b98d..0ad4ab6a1 100644 --- a/src/v4/social/internal-components/TextWithMention/TextWithMention.tsx +++ b/src/v4/social/internal-components/TextWithMention/TextWithMention.tsx @@ -1,41 +1,71 @@ import { SerializedParagraphNode, SerializedTextNode } from 'lexical'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Typography } from '~/v4/core/components'; -import { Mentioned } from '~/v4/helpers/utils'; -import { TextToEditorState } from '~/v4/social/components/CommentComposer/CommentInput'; +import { Mentioned, Mentionees } from '~/v4/helpers/utils'; import styles from './TextWithMention.module.css'; -import { v4 } from 'uuid'; +import { + textToEditorState, + $isSerializedMentionNode, + $isSerializedAutoLinkNode, + $isSerializedTextNode, + MentionData, + $isSerializedLinkNode, +} from '~/v4/social/internal-components/Lexical/utils'; +import clsx from 'clsx'; +import { useNavigation } from '~/v4/core/providers/NavigationProvider'; +import { Button } from '~/v4/core/natives/Button/Button'; interface TextWithMentionProps { + pageId?: string; + componentId?: string; maxLines?: number; data: { text: string; }; - mentionees: Amity.UserMention[]; + mentionees: Mentionees; metadata?: { mentioned?: Mentioned[]; }; } -export const TextWithMention = ({ maxLines = 8, ...props }: TextWithMentionProps) => { +export const TextWithMention = ({ + pageId = '*', + componentId = '*', + maxLines = 8, + data, + mentionees, + metadata, +}: TextWithMentionProps) => { const [isExpanded, setIsExpanded] = useState(false); const [isClamped, setIsClamped] = useState(false); const [isHidden, setIsHidden] = useState(false); - const contentRef = useRef<HTMLDivElement>(null); const fullContentRef = useRef<HTMLDivElement>(null); - const editorState = useMemo(() => { - return TextToEditorState(props); - }, [props]); + const { goToUserProfilePage } = useNavigation(); + + const editorState = useMemo( + () => + textToEditorState({ + data, + mentionees, + metadata, + }), + [data, mentionees, metadata], + ); useEffect(() => { // check if should be clamped or not, then hide the full content const fullContentHeight = fullContentRef.current?.clientHeight || 0; + const rootFontSize = parseInt( + window.getComputedStyle(document.body).getPropertyValue('font-size'), + 10, + ); + const clampHeight = parseFloat( getComputedStyle(document.documentElement).getPropertyValue('--asc-line-height-md'), - ) * 16; + ) * rootFontSize; if (fullContentHeight > clampHeight * maxLines) { setIsClamped(true); @@ -44,21 +74,40 @@ export const TextWithMention = ({ maxLines = 8, ...props }: TextWithMentionProps setIsHidden(true); }, []); - const renderText = (paragraph: SerializedParagraphNode) => { - return paragraph.children.map((text) => { - const uid = v4(); - if ((text as SerializedTextNode).type === 'mention') { + const renderText = (paragraph: SerializedParagraphNode, typoClassName: string) => { + return paragraph.children.map((child) => { + if ($isSerializedMentionNode<MentionData>(child)) { + return ( + <Button + data-qa-anchor={`${pageId}/${componentId}/mention`} + key={child.data.userId} + className={clsx(typoClassName, styles.textWithMention__mention)} + onPress={() => goToUserProfilePage(child.data.userId)} + > + {child.text} + </Button> + ); + } + if ($isSerializedAutoLinkNode(child) || $isSerializedLinkNode(child)) { return ( - <span key={uid} className={styles.textWithMention__mention}> - {(text as SerializedTextNode).text} - </span> + <a + key={child.url} + href={child.url} + className={clsx(typoClassName, styles.textWithMention__link)} + data-qa-anchor={`${pageId}/${componentId}/post_link`} + > + {$isSerializedTextNode(child.children[0]) ? child.children[0]?.text : child.url} + </a> ); } - return ( - <span key={uid} className={styles.textWithMention__text}> - {(text as SerializedTextNode).text} - </span> - ); + + if ($isSerializedTextNode(child)) { + return ( + <span className={clsx(typoClassName, styles.textWithMention__text)}>{child.text}</span> + ); + } + + return null; }); }; @@ -69,13 +118,24 @@ export const TextWithMention = ({ maxLines = 8, ...props }: TextWithMentionProps className={styles.textWithMention__fullContent} data-hidden={isHidden} > - {editorState.root.children.map((child) => { - const uuid = v4(); - return <Typography.Body key={uuid}>{renderText(child)}</Typography.Body>; + {editorState.root.children.map((paragraph) => { + return ( + <p className={styles.textWithMention__paragraph}> + {paragraph.children.length > 0 ? ( + <Typography.Body + renderer={({ typoClassName }) => { + return <>{renderText(paragraph, typoClassName)}</>; + }} + /> + ) : ( + <br /> + )} + </p> + ); })} </div> <div - key={isExpanded ? 'expanded' : 'collapsed'} + data-qa-anchor={`${pageId}/${componentId}/text_with_mention`} data-expanded={isExpanded} className={styles.textWithMention__clamp} style={ @@ -89,9 +149,20 @@ export const TextWithMention = ({ maxLines = 8, ...props }: TextWithMentionProps <Typography.Body>...See more</Typography.Body> </div> )} - {editorState.root.children.map((child, index) => { - const uuid = v4(); - return <Typography.Body key={uuid}>{renderText(child)}</Typography.Body>; + {editorState.root.children.map((paragraph, index) => { + return ( + <p className={styles.textWithMention__paragraph} key={index}> + {paragraph.children.length > 0 ? ( + <Typography.Body + renderer={({ typoClassName }) => { + return <>{renderText(paragraph, typoClassName)}</>; + }} + /> + ) : ( + <br /> + )} + </p> + ); })} </div> </div> diff --git a/src/v4/social/internal-components/UserAvatar/UserAvatar.tsx b/src/v4/social/internal-components/UserAvatar/UserAvatar.tsx index a487f819e..ff2f27db9 100644 --- a/src/v4/social/internal-components/UserAvatar/UserAvatar.tsx +++ b/src/v4/social/internal-components/UserAvatar/UserAvatar.tsx @@ -27,19 +27,32 @@ const UserSvg = ({ className, ...props }: React.SVGProps<SVGSVGElement>) => ( ); interface UserAvatarProps { + pageId?: string; + componentId?: string; userId?: string | null; className?: string; } -export function UserAvatar({ userId, className }: UserAvatarProps) { - const { user } = useUser(userId); +export function UserAvatar({ + pageId = '*', + componentId = '*', + userId, + className, +}: UserAvatarProps) { + const elementId = 'user_avatar'; + const { user } = useUser({ userId }); const userImage = useImage({ fileId: user?.avatar?.fileId }); if (user == null || userId == null || userImage == null) return <UserSvg className={className} />; return ( - <object data={userImage} type="image/png" className={clsx(styles.userAvatar__img, className)}> + <object + data-qa-anchor={`${pageId}/${componentId}/${elementId}`} + data={userImage} + type="image/png" + className={clsx(styles.userAvatar__img, className)} + > <UserSvg className={className} /> </object> ); diff --git a/src/v4/social/internal-components/VideoThumbnail/VideoThumbnail.tsx b/src/v4/social/internal-components/VideoThumbnail/VideoThumbnail.tsx index 918a14915..db3eb3294 100644 --- a/src/v4/social/internal-components/VideoThumbnail/VideoThumbnail.tsx +++ b/src/v4/social/internal-components/VideoThumbnail/VideoThumbnail.tsx @@ -6,6 +6,7 @@ import { CloseIcon, ExclamationCircle, Play } from '~/icons'; import { Spinner } from '~/v4/social/internal-components/Spinner'; interface VideoThumbnailProps { + pageId?: string; files: File[]; uploadedFiles: Amity.File[]; onLoadingChange: (loading: boolean) => void; @@ -18,6 +19,7 @@ interface VideoThumbnailProps { } export const VideoThumbnail = ({ + pageId = '*', files, uploadedFiles, onLoadingChange, @@ -69,8 +71,13 @@ export const VideoThumbnail = ({ </div> ) : ( <> - <img className={styles.thumbnail} src={file.thumbnail} /> + <img + data-qa-anchor={`${pageId}/*/video_thumbnail`} + className={styles.thumbnail} + src={file.thumbnail} + /> <button + data-qa-anchor={`${pageId}/*/remove_thumbnail`} type="reset" onClick={() => { handleRemoveThumbnail(file.file, index); diff --git a/src/v4/social/pages/AllCategoriesPage/AllCategoriesPage.module.css b/src/v4/social/pages/AllCategoriesPage/AllCategoriesPage.module.css new file mode 100644 index 000000000..4fb4a2a05 --- /dev/null +++ b/src/v4/social/pages/AllCategoriesPage/AllCategoriesPage.module.css @@ -0,0 +1,55 @@ +.allCategoriesPage { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding-left: 1rem; + padding-right: 1rem; +} + +.allCategoriesPage__navigation { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content minmax(0, 1fr); + align-items: center; + background-color: var(--asc-color-background-default); + height: 3.625rem; +} + +.allCategoriesList { + display: flex; + flex-direction: column; +} + +.allCategoriesList__divider { + height: 0.0625rem; + background-color: var(--asc-color-base-shade4); + width: 100%; +} + +.allCategoryItem { + display: grid; + align-items: center; + grid-template-columns: 2.5rem minmax(0, 1fr) min-content; + gap: 0.75rem; + padding: 0.5rem 0; +} + +.allCategoryItem__categoryName { + align-self: left; +} + +.allCategoryItem__image { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; +} + +.allCategoryItem__arrow { + width: 1.5rem; + height: 1.125rem; + fill: var(--asc-color-secondary-default); +} + +.allCategoriesList__intersectionNode { + width: 100%; + height: 1px; +} diff --git a/src/v4/social/pages/AllCategoriesPage/AllCategoriesPage.stories.tsx b/src/v4/social/pages/AllCategoriesPage/AllCategoriesPage.stories.tsx new file mode 100644 index 000000000..01f6c29b2 --- /dev/null +++ b/src/v4/social/pages/AllCategoriesPage/AllCategoriesPage.stories.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { AllCategoriesPage } from './AllCategoriesPage'; + +export default { + title: 'v4-social/pages/AllCategoriesPage', +}; + +export const AllCategoriesPageStories = { + render: () => { + return <AllCategoriesPage />; + }, +}; diff --git a/src/v4/social/pages/AllCategoriesPage/AllCategoriesPage.tsx b/src/v4/social/pages/AllCategoriesPage/AllCategoriesPage.tsx new file mode 100644 index 000000000..78aee9b68 --- /dev/null +++ b/src/v4/social/pages/AllCategoriesPage/AllCategoriesPage.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import styles from './AllCategoriesPage.module.css'; +import { useAmityPage } from '~/v4/core/hooks/uikit'; +import { Typography } from '~/v4/core/components'; +import { Button } from '~/v4/core/natives/Button'; +import useCategoriesCollection from '~/v4/core/hooks/collections/useCategoriesCollection'; +import useImage from '~/core/hooks/useImage'; +import { useNavigation } from '~/v4/core/providers/NavigationProvider'; +import { BackButton } from '~/v4/social/elements/BackButton'; +import { AllCategoriesTitle } from '~/v4/social/elements/AllCategoriesTitle'; +import ChevronRight from '~/v4/icons/ChevronRight'; +import { CategoryImage } from '~/v4/social/internal-components/CategoryImage'; +import useIntersectionObserver from '~/v4/core/hooks/useIntersectionObserver'; + +interface AllCategoryItemProps { + category: Amity.Category; + pageId: string; + onClick: (categoryId: string) => void; +} + +const AllCategoryItem = ({ category, pageId, onClick }: AllCategoryItemProps) => { + const avatarImg = useImage({ + fileId: category.avatarFileId, + imageSize: 'small', + }); + + return ( + <Button className={styles.allCategoryItem} onPress={() => onClick(category.categoryId)}> + <CategoryImage imgSrc={avatarImg} className={styles.allCategoryItem__image} pageId={pageId} /> + <Typography.BodyBold className={styles.allCategoryItem__categoryName}> + {category.name} + </Typography.BodyBold> + <ChevronRight className={styles.allCategoryItem__arrow} /> + </Button> + ); +}; + +export function AllCategoriesPage() { + const pageId = 'all_categories_page'; + + const [intersectionNode, setIntersectionNode] = useState<HTMLDivElement | null>(null); + + const { goToCommunitiesByCategoryPage, onBack } = useNavigation(); + + const { themeStyles, accessibilityId } = useAmityPage({ + pageId, + }); + + const { categories, isLoading, error, loadMore, hasMore } = useCategoriesCollection({ + query: { limit: 20 }, + }); + + useIntersectionObserver({ + node: intersectionNode, + options: { + threshold: 0.8, + }, + onIntersect: () => { + if (hasMore && isLoading === false) { + loadMore(); + } + }, + }); + + return ( + <div className={styles.allCategoriesPage} style={themeStyles} data-qa-anchor={accessibilityId}> + <div className={styles.allCategoriesPage__navigation}> + <BackButton pageId={pageId} onPress={onBack} /> + <AllCategoriesTitle pageId={pageId} /> + </div> + <div className={styles.allCategoriesList}> + {categories.map((category, index) => ( + <React.Fragment key={category.categoryId}> + <AllCategoryItem + category={category} + pageId={pageId} + onClick={(categoryId) => { + goToCommunitiesByCategoryPage({ + categoryId, + }); + }} + /> + {index < categories.length - 1 && <div className={styles.allCategoriesList__divider} />} + </React.Fragment> + ))} + <div + ref={(node) => setIntersectionNode(node)} + className={styles.allCategoriesList__intersectionNode} + /> + </div> + </div> + ); +} diff --git a/src/v4/social/pages/AllCategoriesPage/index.tsx b/src/v4/social/pages/AllCategoriesPage/index.tsx new file mode 100644 index 000000000..0b2fe38e6 --- /dev/null +++ b/src/v4/social/pages/AllCategoriesPage/index.tsx @@ -0,0 +1 @@ +export { AllCategoriesPage } from './AllCategoriesPage'; diff --git a/src/v4/social/pages/Application/index.tsx b/src/v4/social/pages/Application/index.tsx index ce8ed9141..a2cbea094 100644 --- a/src/v4/social/pages/Application/index.tsx +++ b/src/v4/social/pages/Application/index.tsx @@ -19,9 +19,12 @@ import CommunityEditPage from '~/social/pages/CommunityEdit'; import ProfileSettings from '~/social/components/ProfileSettings'; import { CommunityProfilePage } from '~/v4/social/pages/CommunityProfilePage'; import { CommunityTabProvider } from '~/v4/core/providers/CommunityTabProvider'; +import { AllCategoriesPage } from '~/v4/social/pages/AllCategoriesPage'; +import { CommunitiesByCategoryPage } from '~/v4/social/pages/CommunitiesByCategoryPage'; +import CommunityCreationModal from '~/social/components/CommunityCreationModal'; const Application = () => { - const { page } = useNavigation(); + const { page, onBack } = useNavigation(); const [open, setOpen] = useState(false); const [socialSettings, setSocialSettings] = useState<Amity.SocialSettings | null>(null); @@ -69,8 +72,11 @@ const Application = () => { )} {page.type === PageTypes.SelectPostTargetPage && <SelectPostTargetPage />} {page.type === PageTypes.MyCommunitiesSearchPage && <MyCommunitiesSearchPage />} + {page.type === PageTypes.AllCategoriesPage && <AllCategoriesPage />} + {page.type === PageTypes.CommunitiesByCategoryPage && ( + <CommunitiesByCategoryPage categoryId={page.context.categoryId} /> + )} {/* V3 */} - {page.type === PageTypes.Explore} {page.type === PageTypes.CommunityFeed && ( <CommunityFeed communityId={page.context.communityId} @@ -87,6 +93,9 @@ const Application = () => { )} {page.type === PageTypes.UserEdit && <ProfileSettings userId={page.context.userId} />} + {page.type === PageTypes.CommunityCreatePage && ( + <CommunityCreationModal isOpen={true} onClose={onBack} /> + )} {/*End of V3 */} </div> diff --git a/src/v4/social/pages/CommunitiesByCategoryPage/CommunitiesByCategoryPage.module.css b/src/v4/social/pages/CommunitiesByCategoryPage/CommunitiesByCategoryPage.module.css new file mode 100644 index 000000000..ac766f612 --- /dev/null +++ b/src/v4/social/pages/CommunitiesByCategoryPage/CommunitiesByCategoryPage.module.css @@ -0,0 +1,131 @@ +.communitiesByCategoryPage { + display: flex; + flex-direction: column; + padding: 1rem; + gap: 1rem; + height: 100%; +} + +.communitiesByCategoryPage__navigation { + display: grid; + grid-template: + 'back title title title title title title title title title title' auto + / minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax( + 0, + 1fr + ) + minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + align-items: center; + background-color: var(--asc-color-background-default); + height: 3.625rem; +} + +.communitiesByCategoryPage__navigationBackButton { + grid-area: back; +} + +.communitiesByCategoryPage__navigationTitle { + overflow: hidden; + grid-area: title; + display: flex; + justify-content: center; + align-items: center; +} + +.communitiesByCategoryPage__intersectionNode { + height: 1px; + width: 100%; +} + +.communitiesByCategoryItem { + display: grid; + grid-template-columns: [image-start] 5rem [image-end content-start] 1fr 1fr 1fr 1fr 1fr 1fr [content-end]; + grid-template-rows: + [name-start] auto + [name-end member-start] auto + [member-end]; + gap: 0.75rem; + width: 100%; +} + +.communitiesByCategoryItem[data-has-categories='true'] { + grid-template-rows: + [name-start] auto + [name-end cat-start] auto + [cat-end member-start] auto + [member-end]; +} + +.communitiesByCategoryItem__image { + grid-column: image-start / image-end; + grid-row: name-start / member-end; + place-self: center; + height: 5rem; + width: 5rem; + border-radius: var(--asc-border-radius-sm); +} + +.communitiesByCategoryItem__img { + width: 100%; + height: 100%; + overflow: hidden; + object-fit: cover; + border-radius: var(--asc-border-radius-sm); +} + +.communitiesByCategoryItem__content { + display: grid; + grid-column: content-start / content-end; + grid-row: name-start / member-end; + grid-template-columns: subgrid [sub-a] [sub-b] [sub-c] [sub-d] [sub-e] [sub-f] [sub-g]; + grid-template-rows: subgrid; + width: 100%; + justify-content: space-evenly; + gap: 0.5rem; +} + +.communitiesByCategoryItem__communityName { + grid-column: sub-a / sub-g; + grid-row: name-start / name-end; + display: flex; + justify-content: start; + align-items: center; + gap: 0.25rem; + width: 100%; +} + +.communitiesByCategoryItem__categories { + grid-column: sub-a / sub-e; + grid-row: cat-start / cat-end; +} + +.communitiesByCategoryItem__categories__container { + display: flex; + align-items: center; +} + +.communitiesByCategoryItem__member { + grid-column: sub-a / sub-e; + grid-row: member-start / member-end; +} + +.communitiesByCategoryItem__joinButton { + grid-row: name-start / member-end; + grid-column: sub-e / sub-g; + width: 4rem; + place-self: end end; +} + +.communitiesByCategoryItem__joinButton[data-has-categories='true'] { + grid-row: cat-start / member-end; +} + +.communitiesByCategoryItem__communityName__private { + width: 1.25rem; + height: 1.25rem; + display: flex; + justify-content: center; + align-items: center; + padding-top: 0.22rem; + padding-bottom: 0.28rem; +} diff --git a/src/v4/social/pages/CommunitiesByCategoryPage/CommunitiesByCategoryPage.tsx b/src/v4/social/pages/CommunitiesByCategoryPage/CommunitiesByCategoryPage.tsx new file mode 100644 index 000000000..6b211db3c --- /dev/null +++ b/src/v4/social/pages/CommunitiesByCategoryPage/CommunitiesByCategoryPage.tsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react'; +import useCommunitiesCollection from '~/v4/core/hooks/collections/useCommunitiesCollection'; +import { useAmityPage } from '~/v4/core/hooks/uikit'; +import { useCategory } from '~/v4/core/hooks/useCategory'; +import useIntersectionObserver from '~/v4/core/hooks/useIntersectionObserver'; +import { useNavigation } from '~/v4/core/providers/NavigationProvider'; +import { CategoryTitle } from '~/v4/social/elements/CategoryTitle'; +import { BackButton } from '~/v4/social/elements/BackButton'; +import { CommunityRowItem } from '~/v4/social/internal-components/CommunityRowItem'; +import { CommunityRowItemSkeleton } from '~/v4/social/internal-components/CommunityRowItem/CommunityRowItemSkeleton'; +import { EmptyCommunity } from './EmptyCommunity'; +import { CommunityRowItemDivider } from '~/v4/social/internal-components/CommunityRowItem/CommunityRowItemDivider'; +import { useCommunityActions } from '~/v4/social/hooks/useCommunityActions'; +import styles from './CommunitiesByCategoryPage.module.css'; + +interface CommunitiesByCategoryPageProps { + categoryId: string; +} + +export function CommunitiesByCategoryPage({ categoryId }: CommunitiesByCategoryPageProps) { + const pageId = 'communities_by_category_page'; + const { themeStyles, accessibilityId } = useAmityPage({ + pageId, + }); + + const { onBack, goToCommunityProfilePage, goToCommunitiesByCategoryPage } = useNavigation(); + + const [intersectionNode, setIntersectionNode] = useState<HTMLDivElement | null>(null); + + const { communities, isLoading, loadMore, hasMore } = useCommunitiesCollection({ + categoryId, + limit: 20, + }); + + const category = useCategory({ categoryId }); + + const { joinCommunity, leaveCommunity } = useCommunityActions(); + + const handleJoinButtonClick = (communityId: string) => joinCommunity(communityId); + const handleLeaveButtonClick = (communityId: string) => leaveCommunity(communityId); + + useIntersectionObserver({ + onIntersect: () => { + if (isLoading === false) { + loadMore(); + } + }, + node: intersectionNode, + options: { + threshold: 0.7, + }, + }); + + const isEmpty = communities.length === 0 && !isLoading; + + return ( + <div + className={styles.communitiesByCategoryPage} + style={themeStyles} + data-qa-anchor={accessibilityId} + > + <div className={styles.communitiesByCategoryPage__navigation}> + <div className={styles.communitiesByCategoryPage__navigationBackButton}> + <BackButton pageId={pageId} onPress={onBack} /> + </div> + <div className={styles.communitiesByCategoryPage__navigationTitle}> + <CategoryTitle pageId={pageId} categoryName={category?.name || ''} /> + </div> + </div> + {isEmpty ? ( + <EmptyCommunity pageId={pageId} /> + ) : ( + <div> + {communities.map((community, index) => ( + <React.Fragment key={community.communityId}> + <CommunityRowItemDivider /> + <CommunityRowItem + community={community} + pageId={pageId} + onClick={(communityId) => goToCommunityProfilePage(communityId)} + onCategoryClick={(categoryId) => goToCommunitiesByCategoryPage({ categoryId })} + onJoinButtonClick={handleJoinButtonClick} + onLeaveButtonClick={handleLeaveButtonClick} + showJoinButton + minCategoryCharacters={4} + maxCategoryCharacters={30} + maxCategoriesLength={2} + /> + </React.Fragment> + ))} + {isLoading && + Array.from({ length: 10 }).map((_, index) => ( + <React.Fragment key={index}> + <CommunityRowItemDivider /> + <CommunityRowItemSkeleton /> + </React.Fragment> + ))} + {hasMore && ( + <div + ref={(node) => setIntersectionNode(node)} + className={styles.communitiesByCategoryPage__intersectionNode} + /> + )} + </div> + )} + </div> + ); +} diff --git a/src/v4/social/pages/CommunitiesByCategoryPage/EmptyCommunity.module.css b/src/v4/social/pages/CommunitiesByCategoryPage/EmptyCommunity.module.css new file mode 100644 index 000000000..2b7e8dbba --- /dev/null +++ b/src/v4/social/pages/CommunitiesByCategoryPage/EmptyCommunity.module.css @@ -0,0 +1,10 @@ +.emptyCommunity { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background-color: var(--asc-color-background-default); + gap: 0.5rem; +} diff --git a/src/v4/social/pages/CommunitiesByCategoryPage/EmptyCommunity.tsx b/src/v4/social/pages/CommunitiesByCategoryPage/EmptyCommunity.tsx new file mode 100644 index 000000000..274499361 --- /dev/null +++ b/src/v4/social/pages/CommunitiesByCategoryPage/EmptyCommunity.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { CommunityEmptyTitle } from '~/v4/social/elements/CommunityEmptyTitle'; +import { CommunityEmptyImage } from '~/v4/social/elements/CommunityEmptyImage'; + +import styles from './EmptyCommunity.module.css'; + +export const EmptyCommunity = ({ pageId }: { pageId: string }) => { + return ( + <div className={styles.emptyCommunity}> + <CommunityEmptyImage /> + <CommunityEmptyTitle pageId={pageId} /> + </div> + ); +}; diff --git a/src/v4/social/pages/CommunitiesByCategoryPage/index.tsx b/src/v4/social/pages/CommunitiesByCategoryPage/index.tsx new file mode 100644 index 000000000..ef93cc2bd --- /dev/null +++ b/src/v4/social/pages/CommunitiesByCategoryPage/index.tsx @@ -0,0 +1 @@ +export { CommunitiesByCategoryPage } from './CommunitiesByCategoryPage'; diff --git a/src/v4/social/pages/CommunityProfilePage/CommunityProfilePage.module.css b/src/v4/social/pages/CommunityProfilePage/CommunityProfilePage.module.css index dc39006e0..65d6c74cd 100644 --- a/src/v4/social/pages/CommunityProfilePage/CommunityProfilePage.module.css +++ b/src/v4/social/pages/CommunityProfilePage/CommunityProfilePage.module.css @@ -22,19 +22,6 @@ transform: translateX(-50%); } -.communityProfilePage__tabContainer { - position: relative; - z-index: 1; -} - -.communityProfilePage__tabContainer.sticky { - position: fixed; - top: 0; - left: 0; - right: 0; - background-color: var(--asc-color-background-default); -} - .communityProfilePage__createPostButton { position: fixed; bottom: 1rem; diff --git a/src/v4/social/pages/CommunityProfilePage/CommunityProfilePage.tsx b/src/v4/social/pages/CommunityProfilePage/CommunityProfilePage.tsx index c7b863cde..df2bee0d5 100644 --- a/src/v4/social/pages/CommunityProfilePage/CommunityProfilePage.tsx +++ b/src/v4/social/pages/CommunityProfilePage/CommunityProfilePage.tsx @@ -82,7 +82,11 @@ export const CommunityProfilePage: React.FC<CommunityProfileProps> = ({ communit <RefreshSpinner className={styles.communityProfilePage__pullToRefresh__spinner} /> </div> - {community ? <CommunityHeader community={community} /> : <CommunityProfileSkeleton />} + {community ? ( + <CommunityHeader pageId={pageId} community={community} /> + ) : ( + <CommunityProfileSkeleton /> + )} <div key={refreshKey}>{renderTabContent()}</div> </div> diff --git a/src/v4/social/pages/DraftsPage/DraftsPage.module.css b/src/v4/social/pages/DraftsPage/DraftsPage.module.css index 8be9aed3b..e612f8bca 100644 --- a/src/v4/social/pages/DraftsPage/DraftsPage.module.css +++ b/src/v4/social/pages/DraftsPage/DraftsPage.module.css @@ -35,6 +35,7 @@ height: 100%; gap: 1rem; overflow: hidden; + background-color: var(--asc-color-black); } .header { @@ -64,8 +65,7 @@ justify-content: center; align-items: center; overflow: hidden; - border-top-left-radius: var(--asc-border-radius-lg); - border-top-right-radius: var(--asc-border-radius-lg); + border-radius: var(--asc-border-radius-lg); background: linear-gradient( 180deg, var(--asc-draft-image-container-color-0, var(--asc-color-black)) 0%, @@ -90,6 +90,7 @@ } .videoPreview { + max-height: calc(100% - 4.5rem); width: 100%; height: 100%; background-color: var(--asc-color-black); diff --git a/src/v4/social/pages/DraftsPage/DraftsPage.tsx b/src/v4/social/pages/DraftsPage/DraftsPage.tsx index ddf3de08b..1a2e621d4 100644 --- a/src/v4/social/pages/DraftsPage/DraftsPage.tsx +++ b/src/v4/social/pages/DraftsPage/DraftsPage.tsx @@ -46,7 +46,7 @@ export const PlainDraftStoryPage = ({ goToGlobalFeedPage: () => void; onDiscardCreateStory: () => void; }) => { - const { page } = useNavigation(); + const { page, onBack } = useNavigation(); const pageId = 'create_story_page'; const { accessibilityId, themeStyles } = useAmityPage({ pageId, @@ -101,7 +101,7 @@ export const PlainDraftStoryPage = ({ if (page.type === PageTypes.DraftPage && page.context.storyType === 'globalFeed') { goToGlobalFeedPage(); } else { - goToCommunityPage(targetId); + onBack(); } if (mediaType?.type === 'image' && targetId) { await StoryRepository.createImageStory( diff --git a/src/v4/social/pages/PostDetailPage/PostDetailPage.tsx b/src/v4/social/pages/PostDetailPage/PostDetailPage.tsx index a14861963..1722aec97 100644 --- a/src/v4/social/pages/PostDetailPage/PostDetailPage.tsx +++ b/src/v4/social/pages/PostDetailPage/PostDetailPage.tsx @@ -60,9 +60,11 @@ export function PostDetailPage({ id, hideTarget, category }: PostDetailPageProps <div className={styles.postDetailPage__comments}> {post && ( <CommentList + pageId={pageId} referenceId={post.postId} referenceType="post" onClickReply={(comment: Amity.Comment) => setReplyComment(comment)} + community={community} /> )} </div> @@ -73,7 +75,12 @@ export function PostDetailPage({ id, hideTarget, category }: PostDetailPageProps defaultClassName={styles.postDetailPage__backIcon} onPress={() => onBack()} /> - <Typography.Title className={styles.postDetailPage__topBar__title}>Post</Typography.Title> + <Typography.Title + data-qa-anchor={`${pageId}/page_title`} + className={styles.postDetailPage__topBar__title} + > + Post + </Typography.Title> <div className={styles.postDetailPage__topBar__menuBar}> <MenuButton pageId={pageId} @@ -97,6 +104,7 @@ export function PostDetailPage({ id, hideTarget, category }: PostDetailPageProps ) : ( post && ( <CommentComposer + pageId={pageId} referenceId={post.postId} referenceType={'post'} replyTo={replyComment} diff --git a/src/v4/social/pages/SelectPostTargetPage/SelectPostTargetPage.tsx b/src/v4/social/pages/SelectPostTargetPage/SelectPostTargetPage.tsx index e9614d730..9bfda3379 100644 --- a/src/v4/social/pages/SelectPostTargetPage/SelectPostTargetPage.tsx +++ b/src/v4/social/pages/SelectPostTargetPage/SelectPostTargetPage.tsx @@ -30,7 +30,7 @@ export function SelectPostTargetPage() { const { AmityPostTargetSelectionPage } = usePageBehavior(); const [intersectionNode, setIntersectionNode] = useState<HTMLDivElement | null>(null); const { currentUserId } = useSDK(); - const { user } = useUser(currentUserId); + const { user } = useUser({ userId: currentUserId }); useIntersectionObserver({ onIntersect: () => { if (hasMore && isLoading === false) { diff --git a/src/v4/social/pages/SocialGlobalSearchPage/SocialGlobalSearchPage.tsx b/src/v4/social/pages/SocialGlobalSearchPage/SocialGlobalSearchPage.tsx index 7e326d07b..642754a23 100644 --- a/src/v4/social/pages/SocialGlobalSearchPage/SocialGlobalSearchPage.tsx +++ b/src/v4/social/pages/SocialGlobalSearchPage/SocialGlobalSearchPage.tsx @@ -106,6 +106,7 @@ export function SocialGlobalSearchPage() { {searchValue.length > 0 && ( <TabsBar pageId={pageId} + componentId="top_search_bar" tabs={tabs} activeTab={activeTab} onTabChange={(newTab) => { diff --git a/src/v4/social/pages/SocialHomePage/Explore.module.css b/src/v4/social/pages/SocialHomePage/Explore.module.css new file mode 100644 index 000000000..57a41aede --- /dev/null +++ b/src/v4/social/pages/SocialHomePage/Explore.module.css @@ -0,0 +1,90 @@ +.explore { + display: flex; + flex-direction: column; +} + +.explore__recommendedForYou { + width: 100%; + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + padding: 0.5rem 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.explore__recommendedForYou::-webkit-scrollbar { + display: none; /* Safari and Chrome */ +} + +.explore__recommendedForYou[data-is-loading='true'] { + padding: 1.5rem 1rem; +} + +.explore__trendingNow { + padding: 0.5rem 1rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.explore__trendingTitleSkeleton { + width: 9.75rem; + height: 0.75rem; + flex-shrink: 0; + border-radius: 0.75rem; + background: var(--asc-color-base-shade4); + animation: skeleton-pulse 1.5s ease-in-out infinite; + margin-top: 0.75rem; + margin-bottom: 0.31rem; +} + +.explore__recommendedForYouTitle { + color: var(--asc-color-base-default); +} + +.explore__exploreCategories { + width: 100%; + padding: 0.75rem 1rem; +} + +.explore__exploreCategories::-webkit-scrollbar { + display: none; /* Safari and Chrome */ +} + +.explore__pullToRefresh { + height: var(--asc-pull-to-refresh-height); + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; +} + +.explore__pullToRefresh__spinner { + width: 1.25rem; + height: 1.25rem; + animation-name: spin; + animation-duration: 1000ms; + animation-iteration-count: infinite; + animation-timing-function: linear; +} + +.explore__divider { + background-color: var(--asc-color-base-shade4); + height: 0.5rem; + width: 100%; +} + +@keyframes skeleton-pulse { + 0% { + opacity: 0.6; + } + + 50% { + opacity: 1; + } + + 100% { + opacity: 0.6; + } +} diff --git a/src/v4/social/pages/SocialHomePage/Explore.tsx b/src/v4/social/pages/SocialHomePage/Explore.tsx new file mode 100644 index 000000000..bb56d098f --- /dev/null +++ b/src/v4/social/pages/SocialHomePage/Explore.tsx @@ -0,0 +1,103 @@ +import React, { useRef, useState } from 'react'; +import { ExploreCommunityCategories } from '~/v4/social/components/ExploreCommunityCategories'; +import { RecommendedCommunities } from '~/v4/social/components/RecommendedCommunities'; +import { TrendingCommunities } from '~/v4/social/components/TrendingCommunities'; +import { useExplore } from '~/v4/social/providers/ExploreProvider'; + +import styles from './Explore.module.css'; +import { ExploreError } from './ExploreError'; +import { ExploreEmpty } from '~/v4/social/components/ExploreEmpty'; +import { ExploreCommunityEmpty } from '~/v4/social/components/ExploreCommunityEmpty'; +import { ExploreTrendingTitle } from '~/v4/social/elements/ExploreTrendingTitle'; +import { ExploreRecommendedTitle } from '~/v4/social/elements/ExploreRecommendedTitle'; +import { RefreshSpinner } from '~/v4/icons/RefreshSpinner'; + +interface ExploreProps { + pageId: string; +} + +export function Explore({ pageId }: ExploreProps) { + const touchStartY = useRef(0); + const [touchDiff, setTouchDiff] = useState(0); + + const { + refresh, + isLoading, + isEmpty, + isCommunityEmpty, + noRecommendedCommunities, + noTrendingCommunities, + error, + } = useExplore(); + + if (error != null) { + return <ExploreError />; + } + + if (isEmpty) { + return <ExploreEmpty pageId={pageId} />; + } + + if (isCommunityEmpty) { + return <ExploreCommunityEmpty pageId={pageId} />; + } + + return ( + <div + className={styles.explore} + onDrag={(event) => event.stopPropagation()} + onTouchStart={(ev) => { + touchStartY.current = ev.touches[0].clientY; + }} + onTouchMove={(ev) => { + const touchY = ev.touches[0].clientY; + + if (touchStartY.current > touchY) { + return; + } + + setTouchDiff(Math.min(touchY - touchStartY.current, 100)); + }} + onTouchEnd={(ev) => { + touchStartY.current = 0; + if (touchDiff >= 75) { + refresh(); + } + setTouchDiff(0); + }} + > + <div + style={ + { + '--asc-pull-to-refresh-height': `${touchDiff}px`, + } as React.CSSProperties + } + className={styles.explore__pullToRefresh} + > + <RefreshSpinner className={styles.explore__pullToRefresh__spinner} /> + </div> + <div className={styles.explore__divider} /> + <div className={styles.explore__exploreCategories}> + <ExploreCommunityCategories pageId={pageId} /> + </div> + {isLoading ? <div className={styles.explore__divider} /> : null} + {noRecommendedCommunities === false ? ( + <div className={styles.explore__recommendedForYou} data-is-loading={isLoading === true}> + {isLoading ? null : <ExploreRecommendedTitle pageId={pageId} />} + <RecommendedCommunities pageId={pageId} /> + </div> + ) : null} + {isLoading ? <div className={styles.explore__divider} /> : null} + {noTrendingCommunities === false ? ( + <div className={styles.explore__trendingNow}> + {isLoading ? ( + <div className={styles.explore__trendingTitleSkeleton} /> + ) : ( + <ExploreTrendingTitle pageId={pageId} /> + )} + <TrendingCommunities pageId={pageId} /> + </div> + ) : null} + </div> + ); +} diff --git a/src/v4/social/pages/SocialHomePage/ExploreError.module.css b/src/v4/social/pages/SocialHomePage/ExploreError.module.css new file mode 100644 index 000000000..f5ccc192e --- /dev/null +++ b/src/v4/social/pages/SocialHomePage/ExploreError.module.css @@ -0,0 +1,18 @@ +.exploreError { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background-color: var(--asc-color-background-default); + gap: 1rem; +} + +.exploreError__text { + padding-bottom: 1.0625rem; + color: var(--asc-color-base-shade3); + display: flex; + flex-direction: column; + align-items: center; +} diff --git a/src/v4/social/pages/SocialHomePage/ExploreError.tsx b/src/v4/social/pages/SocialHomePage/ExploreError.tsx new file mode 100644 index 000000000..b9bf9536a --- /dev/null +++ b/src/v4/social/pages/SocialHomePage/ExploreError.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Typography } from '~/v4/core/components/Typography'; + +import styles from './ExploreError.module.css'; + +const ExploreErrorIcon = () => ( + <svg xmlns="http://www.w3.org/2000/svg" width="62" height="42" viewBox="0 0 62 42" fill="none"> + <path + d="M58.7773 0.841919C60.1484 0.841919 61.3086 2.00208 61.3086 3.37317V38.8107C61.3086 40.2872 60.1484 41.3419 58.7773 41.3419H5.62109C2.77344 41.3419 0.558594 39.1271 0.558594 36.2794V6.74817C0.558594 5.37708 1.61328 4.21692 3.08984 4.21692H7.30859V3.37317C7.30859 2.00208 8.36328 0.841919 9.83984 0.841919H58.7773ZM3.93359 36.2794C3.93359 37.2286 4.67188 37.9669 5.62109 37.9669C6.46484 37.9669 7.30859 37.2286 7.30859 36.2794V7.59192H3.93359V36.2794ZM57.9336 37.9669V4.21692H10.6836V36.2794V36.3849C10.6836 36.8068 10.4727 37.545 10.3672 37.9669H57.9336Z" + fill="#EBECEF" + /> + </svg> +); + +export const ExploreError = () => { + return ( + <div className={styles.exploreError}> + <ExploreErrorIcon /> + <div className={styles.exploreError__text}> + <Typography.Title>Something went wrong</Typography.Title> + <Typography.Caption>Please try again.</Typography.Caption> + </div> + </div> + ); +}; diff --git a/src/v4/social/pages/SocialHomePage/SocialHomePage.tsx b/src/v4/social/pages/SocialHomePage/SocialHomePage.tsx index 9fb77328f..07cf323c7 100644 --- a/src/v4/social/pages/SocialHomePage/SocialHomePage.tsx +++ b/src/v4/social/pages/SocialHomePage/SocialHomePage.tsx @@ -10,7 +10,8 @@ import { Newsfeed } from '~/v4/social/components/Newsfeed'; import { useAmityPage } from '~/v4/core/hooks/uikit'; import { CreatePostMenu } from '~/v4/social/components/CreatePostMenu'; import { useGlobalFeedContext } from '~/v4/social/providers/GlobalFeedProvider'; -import ExplorePage from '~/social/pages/Explore'; +import { Explore } from './Explore'; +import { ExploreProvider } from '~/v4/social/providers/ExploreProvider'; export enum HomePageTab { Newsfeed = 'Newsfeed', @@ -33,18 +34,20 @@ export function SocialHomePage() { const initialLoad = useRef(true); useEffect(() => { + if (activeTab !== HomePageTab.Newsfeed) return; if (!containerRef.current) return; containerRef.current.scrollTop = scrollPosition; setTimeout(() => { initialLoad.current = false; }, 100); - }, [containerRef.current]); + }, [containerRef.current, activeTab]); const handleClickButton = () => { setIsShowCreatePostMenu((prev) => !prev); }; const handleScroll = (event: React.UIEvent<HTMLDivElement, UIEvent>) => { + if (activeTab !== HomePageTab.Newsfeed) return; if (initialLoad.current) return; onScroll(event); }; @@ -94,7 +97,9 @@ export function SocialHomePage() { <div className={styles.socialHomePage__contents} ref={containerRef} onScroll={handleScroll}> {activeTab === HomePageTab.Newsfeed && <Newsfeed pageId={pageId} />} {activeTab === HomePageTab.Explore && ( - <ExplorePage isOpen={false} toggleOpen={() => {}} hideSideMenu={true} /> + <ExploreProvider> + <Explore pageId={pageId} /> + </ExploreProvider> )} {activeTab === HomePageTab.MyCommunities && <MyCommunities pageId={pageId} />} </div> diff --git a/src/v4/social/pages/StoryPage/CommunityFeedStory.tsx b/src/v4/social/pages/StoryPage/CommunityFeedStory.tsx index 1f8f28323..52b393f91 100644 --- a/src/v4/social/pages/StoryPage/CommunityFeedStory.tsx +++ b/src/v4/social/pages/StoryPage/CommunityFeedStory.tsx @@ -115,7 +115,7 @@ export const CommunityFeedStory = ({ const nextStory = () => { if (currentIndex === stories.length - 1) { - onBack(); + onClose(communityId); return; } setCurrentIndex(currentIndex + 1); @@ -214,7 +214,7 @@ export const CommunityFeedStory = ({ const increaseIndex = () => { if (currentIndex === stories.length - 1) { - onBack(); + onClose(communityId); return; } setCurrentIndex((prevIndex) => prevIndex + 1); diff --git a/src/v4/social/pages/StoryPage/GlobalFeedStory.tsx b/src/v4/social/pages/StoryPage/GlobalFeedStory.tsx index e09ac9809..1d02d0091 100644 --- a/src/v4/social/pages/StoryPage/GlobalFeedStory.tsx +++ b/src/v4/social/pages/StoryPage/GlobalFeedStory.tsx @@ -326,6 +326,13 @@ export const GlobalFeedStory: React.FC<GlobalFeedStoryProps> = ({ if (!stories || stories.length === 0) return null; + const getCurrentIndex = () => { + if (formattedStories[currentIndex]) return currentIndex; + const lastStoryIndex = formattedStories.length - 1; + setCurrentIndex(lastStoryIndex); + return lastStoryIndex; + }; + return ( <div className={clsx(styles.storyWrapper)} data-qa-anchor={accessibilityId}> <ArrowLeftButton onClick={previousStory} /> @@ -368,7 +375,7 @@ export const GlobalFeedStory: React.FC<GlobalFeedStoryProps> = ({ display: 'none', }} preventDefault - currentIndex={currentIndex} + currentIndex={getCurrentIndex()} stories={formattedStories} renderers={globalFeedRenderers as RendererObject[]} defaultInterval={DEFAULT_IMAGE_DURATION} diff --git a/src/v4/social/providers/ExploreProvider.tsx b/src/v4/social/providers/ExploreProvider.tsx new file mode 100644 index 000000000..543c7bebd --- /dev/null +++ b/src/v4/social/providers/ExploreProvider.tsx @@ -0,0 +1,118 @@ +import React, { createContext, useContext, useState } from 'react'; +import useCategoriesCollection from '~/v4/core/hooks/collections/useCategoriesCollection'; +import { useRecommendedCommunitiesCollection } from '~/v4/core/hooks/collections/useRecommendedCommunitiesCollection'; +import { useTrendingCommunitiesCollection } from '~/v4/core/hooks/collections/useTrendingCommunitiesCollection'; + +type ExploreContextType = { + fetchTrendingCommunities: () => void; + fetchRecommendedCommunities: () => void; + fetchCommunityCategories: () => void; + refetchRecommendedCommunities: () => void; + isLoading: boolean; + error: Error | null; + trendingCommunities: Amity.Community[]; + recommendedCommunities: Amity.Community[]; + noRecommendedCommunities: boolean; + noTrendingCommunities: boolean; + isEmpty: boolean; + isCommunityEmpty: boolean; + categories: Amity.Category[]; + refresh: () => void; +}; + +const ExploreContext = createContext<ExploreContextType>({ + fetchTrendingCommunities: () => {}, + fetchRecommendedCommunities: () => {}, + fetchCommunityCategories: () => {}, + refetchRecommendedCommunities: () => {}, + trendingCommunities: [], + recommendedCommunities: [], + categories: [], + isEmpty: false, + noRecommendedCommunities: false, + noTrendingCommunities: false, + isCommunityEmpty: false, + isLoading: false, + error: null, + refresh: () => {}, +}); + +export const useExplore = () => useContext(ExploreContext); + +type ExploreProviderProps = { + children: React.ReactNode; +}; + +export const ExploreProvider: React.FC<ExploreProviderProps> = ({ children }) => { + const [trendingCommunitiesEnable, setTrendingCommunitiesEnable] = useState(false); + const [recommendedCommunitiesEnable, setRecommendedCommunitiesEnable] = useState(false); + const [communityCategoriesEnable, setCommunityCategoriesEnable] = useState(false); + + const trendingData = useTrendingCommunitiesCollection({ + params: { limit: 5 }, + enabled: trendingCommunitiesEnable, + }); + + const recommendedData = useRecommendedCommunitiesCollection({ + params: { limit: 4 }, + enabled: recommendedCommunitiesEnable, + }); + + const categoriesData = useCategoriesCollection({ + query: { + limit: 5, + sortBy: 'name', + }, + enabled: communityCategoriesEnable, + }); + + const isLoading = trendingData.isLoading || recommendedData.isLoading || categoriesData.isLoading; + const error = trendingData.error && recommendedData.error && categoriesData.error; + + const refetchRecommendedCommunities = () => recommendedData.refresh(); + + const refresh = () => { + trendingData.refresh(); + refetchRecommendedCommunities(); + categoriesData.refresh(); + }; + + const noCategories = categoriesData.categories.length === 0 && !categoriesData.isLoading; + + const noRecommendedCommunities = + recommendedData.recommendedCommunities.length === 0 && !recommendedData.isLoading; + + const noTrendingCommunities = + trendingData.trendingCommunities.length === 0 && !trendingData.isLoading; + + const isCommunityEmpty = noRecommendedCommunities && noTrendingCommunities; + + const isEmpty = noCategories && isCommunityEmpty; + + const fetchTrendingCommunities = () => setTrendingCommunitiesEnable(true); + const fetchRecommendedCommunities = () => setRecommendedCommunitiesEnable(true); + const fetchCommunityCategories = () => setCommunityCategoriesEnable(true); + + return ( + <ExploreContext.Provider + value={{ + fetchTrendingCommunities, + fetchRecommendedCommunities, + fetchCommunityCategories, + refetchRecommendedCommunities, + trendingCommunities: trendingData.trendingCommunities, + recommendedCommunities: recommendedData.recommendedCommunities, + categories: categoriesData.categories, + noRecommendedCommunities, + noTrendingCommunities, + isEmpty, + isCommunityEmpty, + isLoading, + error, + refresh, + }} + > + {children} + </ExploreContext.Provider> + ); +};