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);