diff --git a/packages/concordia-app/public/locales/en/translation.json b/packages/concordia-app/public/locales/en/translation.json index 803805b..afec7ee 100644 --- a/packages/concordia-app/public/locales/en/translation.json +++ b/packages/concordia-app/public/locales/en/translation.json @@ -39,5 +39,11 @@ "topic.create.form.subject.field.placeholder": "Subject", "topic.list.row.author.date": "Created by {{author}}, {{timeAgo}}", "topic.list.row.number.of.replies": "{{numberOfReplies}} replies", - "topic.list.row.topic.id": "#{{id}}" + "topic.list.row.topic.id": "#{{id}}", + "custom.loading.tab.pane.default.generic.message": "Magic in the background", + "profile.user.has.no.topics.header.message": "{{user}} has created no topics yet", + "profile.user.has.no.posts.header.message": "{{user}} has not posted yet", + "profile.general.tab.title": "General", + "profile.topics.tab.title": "Topics", + "profile.posts.tab.title": "Posts" } \ No newline at end of file diff --git a/packages/concordia-app/src/Routes.jsx b/packages/concordia-app/src/Routes.jsx index 9931cfa..15a11b2 100644 --- a/packages/concordia-app/src/Routes.jsx +++ b/packages/concordia-app/src/Routes.jsx @@ -44,6 +44,11 @@ const routesConfig = [ path: '/topics/:id(\\bnew\\b|\\d+)', component: lazy(() => import('./views/Topic')), }, + { + exact: true, + path: ['/users/:id', '/profiles/:id', '/profile'], + component: lazy(() => import('./views/Profile')), + }, { component: () => , }, diff --git a/packages/concordia-app/src/components/CustomLoadingTabPane.jsx b/packages/concordia-app/src/components/CustomLoadingTabPane.jsx new file mode 100644 index 0000000..5e52fd7 --- /dev/null +++ b/packages/concordia-app/src/components/CustomLoadingTabPane.jsx @@ -0,0 +1,46 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { + Dimmer, Loader, Placeholder, Segment, Tab, +} from 'semantic-ui-react'; +import { useTranslation } from 'react-i18next'; + +const CustomLoadingTabPane = (props) => { + const { loading, loadingMessage, children } = props; + const { t } = useTranslation(); + + return useMemo(() => { + if (loading) { + return ( + + + + {loadingMessage !== undefined + ? loadingMessage + : t('custom.loading.tab.pane.default.generic.message')} + + + + + + + + + ); + } + + return ( + + {children} + + ); + }, [children, loading, loadingMessage, t]); +}; + +CustomLoadingTabPane.propTypes = { + loading: PropTypes.bool, + loadingMessage: PropTypes.string, + children: PropTypes.element.isRequired, +}; + +export default CustomLoadingTabPane; diff --git a/packages/concordia-app/src/constants/ProfileTabs.js b/packages/concordia-app/src/constants/ProfileTabs.js new file mode 100644 index 0000000..17f4584 --- /dev/null +++ b/packages/concordia-app/src/constants/ProfileTabs.js @@ -0,0 +1,22 @@ +export const GENERAL_TAB = { + id: 'general-tab', + intl_display_name_id: 'profile.general.tab.title', +}; + +export const TOPICS_TAB = { + id: 'topics-tab', + intl_display_name_id: 'profile.topics.tab.title', +}; + +export const POSTS_TAB = { + id: 'posts-tab', + intl_display_name_id: 'profile.posts.tab.title', +}; + +const profileTabs = [ + GENERAL_TAB, + TOPICS_TAB, + POSTS_TAB, +]; + +export default profileTabs; diff --git a/packages/concordia-app/src/views/Profile/GeneralTab/index.jsx b/packages/concordia-app/src/views/Profile/GeneralTab/index.jsx new file mode 100644 index 0000000..f4c6e35 --- /dev/null +++ b/packages/concordia-app/src/views/Profile/GeneralTab/index.jsx @@ -0,0 +1,155 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Icon, Image, Placeholder, Table, +} from 'semantic-ui-react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { useDispatch, useSelector } from 'react-redux'; +import determineKVAddress from '../../../utils/orbitUtils'; +import databases, { USER_DATABASE } from '../../../constants/OrbitDatabases'; +import { FETCH_USER_DATABASE } from '../../../redux/actions/peerDbReplicationActions'; +import { breeze } from '../../../redux/store'; +import { USER_LOCATION, USER_PROFILE_PICTURE } from '../../../constants/UserDatabaseKeys'; +import './styles.css'; + +const { orbit } = breeze; + +const GeneralTab = (props) => { + const { + profileAddress, username, numberOfTopics, numberOfPosts, userRegistrationTimestamp, + } = props; + const [userInfoOrbitAddress, setUserInfoOrbitAddress] = useState(null); + const [userTopicsOrbitAddress, setUserTopicsOrbitAddress] = useState(null); + const [userPostsOrbitAddress, setUserPostsOrbitAddress] = useState(null); + const [profileMeta, setProfileMeta] = useState(null); + const users = useSelector((state) => state.orbitData.users); + const dispatch = useDispatch(); + + useEffect(() => { + if (profileAddress) { + Promise + .all(databases + .map((database) => determineKVAddress({ + orbit, + dbName: database.address, + userAddress: profileAddress, + }))) + .then((values) => { + const [userOrbitAddress, topicsOrbitAddress, postsOrbitAddress] = values; + setUserInfoOrbitAddress(userOrbitAddress); + setUserTopicsOrbitAddress(topicsOrbitAddress); + setUserPostsOrbitAddress(postsOrbitAddress); + + const userFound = users + .find((user) => user.id === userOrbitAddress); + + if (userFound) { + setProfileMeta(userFound); + } else { + dispatch({ + type: FETCH_USER_DATABASE, + orbit, + dbName: USER_DATABASE, + userAddress: userOrbitAddress, + }); + } + }).catch((error) => { + console.error('Error during determination of key-value DB address:', error); + }); + } + }, [dispatch, profileAddress, users]); + + const authorAvatar = useMemo(() => (profileMeta !== null && profileMeta[USER_PROFILE_PICTURE] + ? ( + + ) + : ( + + )), [profileMeta]); + + return useMemo(() => ( + + + + {authorAvatar} + + + Username: + {username} + + + Account address: + {profileAddress} + + + UserDB: + + {userInfoOrbitAddress || ()} + + + + TopicsDB: + + {userTopicsOrbitAddress || ()} + + + + PostsDB: + + {userPostsOrbitAddress || ()} + + + + Number of topics created: + + {numberOfTopics} + + + + Number of posts: + + {numberOfPosts} + + + + Number of posts: + + {profileMeta !== null && profileMeta[USER_LOCATION] + ? profileMeta[USER_LOCATION] + : } + + + + Member since: + + {moment(userRegistrationTimestamp * 1000).format('dddd, MMMM Do YYYY, h:mm:ss A')} + + + +
+ ), [ + authorAvatar, numberOfPosts, numberOfTopics, profileAddress, profileMeta, userInfoOrbitAddress, + userPostsOrbitAddress, userRegistrationTimestamp, userTopicsOrbitAddress, username, + ]); +}; + +GeneralTab.propTypes = { + profileAddress: PropTypes.string.isRequired, + username: PropTypes.string.isRequired, + numberOfTopics: PropTypes.number.isRequired, + numberOfPosts: PropTypes.number.isRequired, + userRegistrationTimestamp: PropTypes.string.isRequired, +}; + +export default GeneralTab; diff --git a/packages/concordia-app/src/views/Profile/GeneralTab/styles.css b/packages/concordia-app/src/views/Profile/GeneralTab/styles.css new file mode 100644 index 0000000..6ea4f94 --- /dev/null +++ b/packages/concordia-app/src/views/Profile/GeneralTab/styles.css @@ -0,0 +1,6 @@ +.general-tab-profile-picture { + max-width: 112px; + max-height: 112px; + margin: 0; + vertical-align: middle; +} \ No newline at end of file diff --git a/packages/concordia-app/src/views/Profile/index.jsx b/packages/concordia-app/src/views/Profile/index.jsx new file mode 100644 index 0000000..69ed6cd --- /dev/null +++ b/packages/concordia-app/src/views/Profile/index.jsx @@ -0,0 +1,111 @@ +import React, { + memo, useEffect, useMemo, useState, +} from 'react'; +import { Container, Header, Tab } from 'semantic-ui-react'; +import { useSelector } from 'react-redux'; +import { useHistory, useRouteMatch } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { drizzle } from '../../redux/store'; +import { FORUM_CONTRACT } from '../../constants/ContractNames'; +import CustomLoadingTabPane from '../../components/CustomLoadingTabPane'; +import TopicList from '../../components/TopicList'; +import PostList from '../../components/PostList'; +import GeneralTab from './GeneralTab'; +import { GENERAL_TAB, POSTS_TAB, TOPICS_TAB } from '../../constants/ProfileTabs'; + +const { contracts: { [FORUM_CONTRACT]: { methods: { getUser } } } } = drizzle; + +const Profile = () => { + const [userCallHash, setUserCallHash] = useState(''); + const getUserResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getUser); + const [profileAddress, setProfileAddress] = useState(); + const [username, setUsername] = useState(null); + const [userTopicIds, setUserTopicIds] = useState([]); + const [userPostIds, setUserPostIds] = useState([]); + const [userRegistrationTimestamp, setUserRegistrationTimestamp] = useState(null); + const [loading, setLoading] = useState(true); + const self = useSelector((state) => state.user); + const { t } = useTranslation(); + const match = useRouteMatch(); + const history = useHistory(); + + useEffect(() => { + if (history.location.pathname === '/profile') { + if (self.hasSignedUp) { + setProfileAddress(self.address); + } else { + history.push('/'); + } + } else { + const { id: userAddress } = match.params; + + setProfileAddress(userAddress); + } + }, [history, match.params, self.address, self.hasSignedUp]); + + useEffect(() => { + if (profileAddress) { + setUserCallHash(getUser.cacheCall(profileAddress)); + } + }, [profileAddress]); + + useEffect(() => { + if (getUserResults[userCallHash] !== undefined && getUserResults[userCallHash].value) { + const [lUsername, topicIds, postIds, registrationTimestamp] = getUserResults[userCallHash].value; + setUsername(lUsername); + setUserTopicIds(topicIds.map((userTopicId) => parseInt(userTopicId, 10))); + setUserPostIds(postIds.map((userPostId) => parseInt(userPostId, 10))); + setUserRegistrationTimestamp(registrationTimestamp); + setLoading(false); + } + }, [getUserResults, userCallHash]); + + const generalTab = useMemo(() => (loading + ? null + : ( + + )), [loading, profileAddress, userPostIds.length, userRegistrationTimestamp, userTopicIds.length, username]); + + const topicsTab = useMemo(() => (userTopicIds.length > 0 + ? () + : ( +
+ {t('profile.user.has.no.topics.header.message', { user: username })} +
+ ) + ), [t, userTopicIds, username]); + + const postsTab = useMemo(() => (userPostIds.length > 0 + ? () + : ( +
+ {t('profile.user.has.no.posts.header.message', { user: username })} +
+ )), [t, userPostIds, username]); + + const panes = useMemo(() => { + const generalTabPane = ({generalTab}); + const topicsTabPane = ({topicsTab}); + const postsTabPane = ({postsTab}); + + return ([ + { menuItem: t(GENERAL_TAB.intl_display_name_id), render: () => generalTabPane }, + { menuItem: t(TOPICS_TAB.intl_display_name_id), render: () => topicsTabPane }, + { menuItem: t(POSTS_TAB.intl_display_name_id), render: () => postsTabPane }, + ]); + }, [generalTab, loading, postsTab, t, topicsTab]); + + return useMemo(() => ( + + + + ), [panes]); +}; + +export default memo(Profile);