Browse Source

Merge branch develop into voting

develop
Ezerous 4 years ago
parent
commit
76b22bf48e
  1. 22
      .dockerignore
  2. 4
      .gitignore
  3. 21
      LICENSE
  4. 37
      README.md
  5. 67
      docker/Makefile
  6. 204
      docker/README.md
  7. 72
      docker/concordia-app/Dockerfile
  8. 22
      docker/concordia-app/nginx.conf
  9. 11
      docker/concordia-app/test-app.sh
  10. 66
      docker/concordia-contracts/Dockerfile
  11. 6
      docker/concordia-contracts/migrate.sh
  12. 16
      docker/concordia-contracts/test-contracts.sh
  13. 34
      docker/docker-compose.yml
  14. 7
      docker/env/concordia.docker.env
  15. 20
      docker/env/concordia.example.env
  16. 14
      docker/env/contracts.docker.env
  17. 7
      docker/env/contracts.example.env
  18. 5
      docker/env/ganache.docker.env
  19. 6
      docker/env/ganache.test.docker.env
  20. 10
      docker/ganache/Dockerfile
  21. 37
      docker/ganache/start-blockchain.sh
  22. 11
      package.json
  23. 0
      packages/concordia-app/.dockerignore
  24. 12
      packages/concordia-app/.env.development.example
  25. 22
      packages/concordia-app/.gitignore
  26. 5
      packages/concordia-app/package.json
  27. 76
      packages/concordia-app/public/locales/en/translation.json
  28. 4
      packages/concordia-app/public/manifest.json
  29. 8
      packages/concordia-app/src/App.jsx
  30. 5
      packages/concordia-app/src/Routes.jsx
  31. 7
      packages/concordia-app/src/assets/css/app.css
  32. 17
      packages/concordia-app/src/assets/css/index.css
  33. 8
      packages/concordia-app/src/assets/css/loading-component.css
  34. 1
      packages/concordia-app/src/assets/images/metamask_logo.svg
  35. 23
      packages/concordia-app/src/assets/particles.js
  36. 151
      packages/concordia-app/src/components/ClearDatabasesModal/index.jsx
  37. 46
      packages/concordia-app/src/components/CustomLoadingTabPane.jsx
  38. 23
      packages/concordia-app/src/components/InitializationScreen/CustomLoader/index.jsx
  39. 163
      packages/concordia-app/src/components/InitializationScreen/index.jsx
  40. 167
      packages/concordia-app/src/components/LoadingContainer.jsx
  41. 49
      packages/concordia-app/src/components/Placeholder/index.jsx
  42. 183
      packages/concordia-app/src/components/PostCreate/index.jsx
  43. 13
      packages/concordia-app/src/components/PostCreate/styles.css
  44. 165
      packages/concordia-app/src/components/PostList/PostListRow/index.jsx
  45. 9
      packages/concordia-app/src/components/PostList/PostListRow/styles.css
  46. 72
      packages/concordia-app/src/components/PostList/index.jsx
  47. 3
      packages/concordia-app/src/components/PostList/styles.css
  48. 194
      packages/concordia-app/src/components/TopicList/TopicListRow/index.jsx
  49. 21
      packages/concordia-app/src/components/TopicList/TopicListRow/styles.css
  50. 87
      packages/concordia-app/src/components/TopicList/index.jsx
  51. 4
      packages/concordia-app/src/components/TopicList/styles.css
  52. 104
      packages/concordia-app/src/components/UsernameSelector.jsx
  53. 2
      packages/concordia-app/src/constants/PlaceholderTypes.js
  54. 22
      packages/concordia-app/src/constants/ProfileTabs.js
  55. 2
      packages/concordia-app/src/constants/RegisterSteps.js
  56. 2
      packages/concordia-app/src/constants/TransactionStatus.js
  57. 7
      packages/concordia-app/src/constants/configuration/defaults.js
  58. 1
      packages/concordia-app/src/constants/contracts/ContractNames.js
  59. 13
      packages/concordia-app/src/constants/contracts/events/ForumContractEvents.js
  60. 8
      packages/concordia-app/src/constants/contracts/events/index.js
  61. 20
      packages/concordia-app/src/constants/orbit/OrbitDatabases.js
  62. 7
      packages/concordia-app/src/constants/orbit/PostsDatabaseKeys.js
  63. 7
      packages/concordia-app/src/constants/orbit/TopicsDatabaseKeys.js
  64. 9
      packages/concordia-app/src/constants/orbit/UserDatabaseKeys.js
  65. 14
      packages/concordia-app/src/index.jsx
  66. 38
      packages/concordia-app/src/layouts/MainLayout/MainLayoutMenu/index.jsx
  67. 2
      packages/concordia-app/src/layouts/MainLayout/index.jsx
  68. 3
      packages/concordia-app/src/layouts/MainLayout/styles.css
  69. 22
      packages/concordia-app/src/options/breezeOptions.js
  70. 13
      packages/concordia-app/src/options/drizzleOptions.js
  71. 24
      packages/concordia-app/src/options/web3Options.js
  72. 20
      packages/concordia-app/src/redux/actions/contractEventActions.js
  73. 7
      packages/concordia-app/src/redux/reducers/peerDbReplicationReducer.js
  74. 13
      packages/concordia-app/src/redux/sagas/eventSaga.js
  75. 3
      packages/concordia-app/src/redux/sagas/orbitSaga.js
  76. 49
      packages/concordia-app/src/redux/sagas/peerDbReplicationSaga.js
  77. 2
      packages/concordia-app/src/redux/sagas/rootSaga.js
  78. 18
      packages/concordia-app/src/redux/sagas/userSaga.js
  79. 10
      packages/concordia-app/src/redux/store.js
  80. 22
      packages/concordia-app/src/utils/indexedDB/indexedDBUtils.js
  81. 46
      packages/concordia-app/src/utils/indexedDB/patchIndexedDB.js
  82. 71
      packages/concordia-app/src/utils/styles.debug.css
  83. 12
      packages/concordia-app/src/utils/urlUtils.js
  84. 18
      packages/concordia-app/src/views/Home/index.jsx
  85. 235
      packages/concordia-app/src/views/Profile/GeneralTab/EditInformationModal/index.jsx
  86. 223
      packages/concordia-app/src/views/Profile/GeneralTab/index.jsx
  87. 6
      packages/concordia-app/src/views/Profile/GeneralTab/styles.css
  88. 114
      packages/concordia-app/src/views/Profile/index.jsx
  89. 175
      packages/concordia-app/src/views/Register/PersonalInformationStep/index.jsx
  90. 7
      packages/concordia-app/src/views/Register/PersonalInformationStep/styles.css
  91. 121
      packages/concordia-app/src/views/Register/SignUpStep/index.jsx
  92. 225
      packages/concordia-app/src/views/Register/index.jsx
  93. 81
      packages/concordia-app/src/views/Topic/TopicCreate/index.jsx
  94. 189
      packages/concordia-app/src/views/Topic/TopicView/index.jsx
  95. 20
      packages/concordia-app/src/views/Topic/TopicView/styles.css
  96. 2
      packages/concordia-app/src/views/Topic/index.jsx
  97. 68
      packages/concordia-contracts/README.md
  98. 16
      packages/concordia-contracts/constants/config/defaults.js
  99. 8
      packages/concordia-contracts/contracts/Forum.sol
  100. 4
      packages/concordia-contracts/package.json

22
.dockerignore

@ -0,0 +1,22 @@
node_modules
.idea
.git
docker/
!docker/concordia-contracts/migrate.sh
!docker/concordia-contracts/test-contracts.sh
!docker/concordia-app/test-app.sh
!docker/concordia-app/nginx.conf
!docker/ganache/start-blockchain.sh
packages/*/node_modules
packages/*/dist
packages/*/coverage
# TO-NEVER-DO: exclude the build folder of the contracts package, it's needed for building the application image.
packages/concordia-app/build
Jenkinsfile
README.md
packages/*/README.md

4
.gitignore

@ -21,8 +21,12 @@ yarn-error.log*
# Docker volumes
docker/volumes
docker/ganache/volumes
docker/reports
# Env var files
docker/env/concordia.env
docker/env/contracts.env
# Misc
.env.local

21
LICENSE

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 ECEntrics
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

37
README.md

@ -0,0 +1,37 @@
# Concordia
> A distributed forum using Blockchain, supporting direct democratic voting
## Setup
```shell script
cd apella
yarn
```
## Compile contracts
```shell script
cd packages/apella-contracts
yarn compile
```
## Run app
```shell script
cd packages/apella-app
yarn start
```
## Build app
```shell script
cd packages/apella-app
yarn build
```
## Using Docker images
This project provides docker images for a number of services required to setup Concordia, as well as for Concordia
itself.
Check out the README.md in the `./docker` directory

67
docker/Makefile

@ -0,0 +1,67 @@
PACKAGES := $(abspath ${CURDIR}/../packages)
REPORTS := $(abspath ${CURDIR}/reports)
GANACHE_VOLUMES := $(abspath ${CURDIR}/ganache/volumes)
run: compose-run build-contracts-migrate run-contracts-migrate build-app run-app
@echo "Concordia is up and running, head over to http://localhost:7777."
# Targets for building/running/stopping the blockchain and rendezvous server (using the docker-compose file)
compose-build:
@docker-compose -f ./docker-compose.yml -p concordia build
compose-run:
@docker-compose -f ./docker-compose.yml -p concordia up -d
compose-stop:
@docker-compose -f ./docker-compose.yml -p concordia down
compose-stop-clean-data:
@docker-compose -f ./docker-compose.yml -p concordia down -v
# Ganache targets
build-ganache:
@docker build ../ -f ./ganache/Dockerfile -t concordia-ganache
run-ganache:
@docker network create --driver bridge concordia_ganache_network || true &&\
docker run -d -v ${GANACHE_VOLUMES}/ganache_keys:/home/ganache_keys -p 8545:8545 --env-file=./env/ganache.docker.env --name concordia-ganache --net=concordia_ganache_network concordia-ganache:latest
run-ganache-test:
@docker network create --driver bridge concordia_ganache_test_network || true &&\
docker run --rm -d -p 8546:8546 --env-file=./env/ganache.test.docker.env --name concordia-ganache-test --net=concordia_ganache_test_network concordia-ganache:latest
# Rendezvous targets
run-rendezvous:
@docker network create --driver bridge concordia_rendezvous_network || true &&\
docker run -d -p 9090:9090 --name concordia-rendezvous libp2p/js-libp2p-webrtc-star:version-0.20.5
# Contracts targets
build-contracts:
@docker build ../ -f ./concordia-contracts/Dockerfile --target compile -t concordia-contracts --build-arg TZ=Europe/Athens
build-contracts-migrate:
@docker build ../ -f ./concordia-contracts/Dockerfile -t concordia-contracts-migrate --build-arg TZ=Europe/Athens
build-contracts-tests:
@docker build ../ -f ./concordia-contracts/Dockerfile --target test -t concordia-contracts-tests --build-arg TZ=Europe/Athens
run-contracts-tests:
@docker run --rm -v ${REPORTS}/contracts/:/usr/test-reports/ --env-file=./env/contracts.docker.env --net=concordia_ganache_test_network concordia-contracts-tests:latest
run-contracts-tests-host-chain:
@docker run --rm -v ${REPORTS}/contracts/:/usr/test-reports/ --env-file=./env/contracts.env --net=host concordia-contracts-tests:latest
run-contracts-migrate:
@docker run --rm -v ${PACKAGES}/concordia-contracts/build/:/usr/src/concordia/packages/concordia-contracts/build/ --env-file=./env/contracts.docker.env --net=concordia_ganache_network concordia-contracts-migrate:latest
run-contracts-migrate-host-chain:
@docker run --rm -v ${PACKAGES}/concordia-contracts/build/:/usr/src/concordia/packages/concordia-contracts/build/ --env-file=./env/contracts.env --net=host concordia-contracts-migrate:latest
get-contracts:
@docker run --rm -v ${PACKAGES}/concordia-contracts/build/:/build --entrypoint=sh concordia-contracts:latest -c 'cp /usr/src/concordia/packages/concordia-contracts/build/* /build'
# App targets
build-app:
@docker build ../ -f ./concordia-app/Dockerfile -t concordia-app --build-arg TZ=Europe/Athens
build-app-tests:
@docker build ../ -f ./concordia-app/Dockerfile --target test -t concordia-app-tests --build-arg TZ=Europe/Athens
run-app-tests:
@docker run --rm -v ${REPORTS}/app/:/usr/test-reports/ --env-file=./env/concordia.docker.env concordia-app-tests:latest
run-app:
@docker create --env-file=./env/concordia.docker.env -p 7777:80 --name concordia-app --net=concordia_ganache_network concordia-app:latest &&\
docker network connect concordia_rendezvous_network concordia-app &&\
docker start concordia-app
run-app-host-chain:
@docker run -d --env-file=./env/concordia.env --name concordia-app --net=host concordia-app:latest
# Other
clean-images:
@docker rmi `docker images -q -f "dangling=true"`

204
docker/README.md

@ -0,0 +1,204 @@
# Concordia Dockerized
This page provides information about the provided docker images, their configuration and supported deployment
strategies.
TLDR: head down to [Putting it all together/Scripts](#piat-mkfile-targets) for a quick setup.
## Services
Concordia requires at the minimum two services to work, a blockchain and a rendezvous server.
Additionally, the Concordia application code must be provided to the user. Currently, the only way of distributing the
application code is via a webserver as a web application.
### Ganache
Ganache is a personal blockchain software used during development. It is a very convenient way of developing and testing
dApps. More information can be found in the project's [website](https://www.trufflesuite.com/ganache).
Note that any other Ethereum compliant blockchain can be used.
### Rendezvous
Concordia uses a distributed database to store forum data. A rendezvous server is needed in order for users to discover
peers in the network and get access to the data.
### Application
The Concordia application is a React app that handles interactions with the contracts and the distributed database used.
## Docker images
This repository provides docker images to easily setup (and destroy) instances of all required services Concordia.
Furthermore, we provide an image that builds the contracts and handles their migration to the blockchain in use.
### Ganache
The Dockerfile is provided in the path `./ganache`. The image makes use of the environment variables described
bellow.
| Environment variable | Default value | Usage |
| --- | --- | --- |
| ACCOUNTS_NUMBER | 10 | Set the number of accounts generated |
| ACCOUNTS_ETHER | 100 | Set the amount of ETH assigned to each account |
| MNEMONIC | NaN | The mnemonic phrase sued as a seed for deterministic account generation |
| HOST | 0.0.0.0 | The hostname to listen on |
| PORT | 8545 | The port to listen on |
| NETWORK_ID | 5778 | The network id used to identify ganache |
Note that the Ganache instance running inside the container will save the generated blockchain keys in the path
`/home/ganache_keys/keys.json`. If you need to access the keys (eg for getting a private key and importing in Metamask)
you can mount a volume to this path to have easier access.
Also, the database used by Ganache for storing blockchain information is placed in the path `/home/ganache_db/`. You can
maintain the blockchain state between runs by mounting a volume to the database path. To do that, add the docker flag
`-v host/absolute/path/to/ganache_db:/home/ganache_db`.
### Rendezvous
The rendezvous server used here is `js-libp2p-webrtc-star`. The server listens on port 9090. More information can be
found on the github page of the project [here](https://github.com/libp2p/js-libp2p-webrtc-star).
### Contracts
This is a provision system that compiles and deploys the contracts to any Ethereum blockchain.
A Dockerfile is provided in the path `./concordia-contracts` that will build the contracts used by Concordia and
handle their deployment to any Ethereum network defined using env-vars upon container run. Dockerfile contains three
useful stages, described in the table bellow.
| Stage name | Entrypoint | Usage |
| --- | --- | --- |
| compile | Exits immediately | Compiles the contracts |
| test | Runs contract tests | Compiles contracts and runs tests using blockchain defined by env vars |
| runtime | Migrates contracts | Compiles contracts and migrates to the blockchain defined by env vars. Does **not** run tests |
The image makes use of the environment variables described bellow.
| Environment variable | Default value | Usage |
| --- | --- | --- |
| MIGRATE_NETWORK | develop | Set the network where the contracts will be deployed/tested (set this to "env" unless you know what you're doing) |
| DEPLOY_CHAIN_HOST | NaN | Set the hostname of the blockchain network that will be used for deployment (requires network to be "env") |
| DEPLOY_CHAIN_PORT | NaN | Set the port of the blockchain network that will be used for deployment (requires network to be "env") |
| TEST_CHAIN_HOST | NaN | Set the hostname of the blockchain network that will be used for testing (requires network to be "env") |
| TEST_CHAIN_PORT | NaN | Set the port of the blockchain network that will be used for testing (requires network to be "env") |
You can find the contract artifacts in the directory `/usr/src/concordia/packages/concordia-contracts/build/` inside
the image.
**Attention**: make sure the targeted blockchain is up and running before trying to migrate the contracts.
### Application
The Dockerfile provided in the path `./concordia-application` builds the application for production and serves
the resulting build using an nginx server. Dockerfile contains two useful stages, described in the table bellow.
| Stage name | Entrypoint | Usage |
| --- | --- | --- |
| test | Runs tests | Fetches npm packages and runs tests |
| runtime | Serves application | Builds for production and serves it through nginx |
The image makes use of the environment variables described bellow.
| Environment variable | Default value | Usage |
| --- | --- | --- |
| REACT_APP_RENDEZVOUS_HOST | 127.0.0.1 | Set the hostname of the rendezvous server |
| REACT_APP_RENDEZVOUS_PORT | 9090 | Set the port of the rendezvous server |
**Attention**: this image will copy the contract artifacts from the directory `/packages/concordia-contracts/build`.
The image is bound the these artifacts after build. If the contracts change or get re-deployed the image must be
re-built to use the new artifacts.
**Attention**: make sure the contracts have been deployed before **building** this image. Also, make sure the rendezvous
server is up and running.
## Docker Compose
A docker-compose file also is provided. The docker-compose handles the lifecycle of the Ganache and Rendezvous server
containers.
## Putting it all together
You can find some ready to use scripts for common scenarios like dev deploys and testing in the `./docker` directory.
These scripts are documented in the following chapters.
### <a name="piat-mkfile-targets"></a> Makefile targets
Concordia uses blockchain and other distributed technologies. There are a number of ways to set up a running instance of
this application.
This chapter will guide you through simple setups for testing and production that depend on local blockchain (ganache)
instances which do not require real ETH to work or have any other charges.
#### Testing the contracts
Build the ganache image and spin up a blockchain for testing:
```shell
make build-ganache run-ganache-test
```
Build the testing stage of the contracts image:
```shell
make build-contracts-tests
```
Run the tests:
```shell
make run-contracts-tests
```
The results should be printed in the terminal, but are also available in the directory `./reports/contracts`.
#### Testing the application
Build the testing stage of the application image:
```shell
make build-app-tests
```
Run the test:
```shell
make run-app-tests
```
The results should be printed in the terminal, but are also available in the directory `./reports/app`.
#### Production
Just run the target:
```shell
make run
```
And you' re done! Head to [localhost:7777](localhost:7777) and voilà, a working Concordia instance appears! The
blockchain is exposed in the address `localhost:8545`.
**Tip**: the accounts (private keys) generated by Ganache are available in the file `./volumes/ganache_keys/keys.json`.
Note that the `make run` command might take several minutes to execute (depending on your system). What happens under
the hood is that:
- the ganache image is built
- blockchain and rendezvous server containers are started
- migration stage of the contracts image is built
- the contracts are deployed to the blockchain:
- the application image is built and then deployed
### Env Files
Targets in the Makefile make use of env files suffixed by `.docker` located in the directory `./env`. Using this
environment variables, you can change various configuration options of the testing/production deploys.
Targets suffixed with `host-chain` will try to use a blockchain and rendezvous server running in the host machine. They
use the `--net=host` docker option and get the required environment variables from different env files,
`./env/contracts.env` and `./env/concordia.env` (notice these env files don't include the `.docker`). These env files do
not exist by default. The values set will largely depend on how you choose to run services in your system (which ports
you use etc.), so be sure to create them before running any `host-chain` target. Luckily example files are provided.

72
docker/concordia-app/Dockerfile

@ -0,0 +1,72 @@
# --------------------------------------------------
# Stage 1 (Init application build base)
# --------------------------------------------------
FROM node:14-buster as base
LABEL maintainers.1="Apostolos Fanakis <apostolof@auth.gr>"
LABEL maintainers.2="Panagiotis Nikolaidis <ezerous@gmail.com>"
LABEL gr.thmmy.ecentrics.concordia-image.name="app"
WORKDIR /usr/src/concordia
# Copy the root package.json and yarn.lock
COPY ./package.json .
COPY ./yarn.lock .
# Copy package.json files from contracts and app, then install base modules
COPY ./packages/concordia-contracts/package.json ./packages/concordia-contracts/package.json
COPY ./packages/concordia-app/package.json ./packages/concordia-app/
RUN yarn install --frozen-lockfile
# Gets the rest of the source code
COPY ./packages/concordia-contracts ./packages/concordia-contracts
COPY ./packages/concordia-app ./packages/concordia-app
# --------------------------------------------------
# Stage 2 (Test)
# --------------------------------------------------
FROM base as test
# Fix timezome (needed for timestamps on report files)
ARG TZ
ENV TZ=${TZ}
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /opt/concordia-app
COPY ./docker/concordia-app/test-app.sh .
WORKDIR /usr/src/concordia/packages/concordia-app
ENTRYPOINT ["/opt/concordia-app/test-app.sh"]
# --------------------------------------------------
# Stage 3 (Build)
# --------------------------------------------------
FROM base as build
WORKDIR /usr/src/concordia/packages/concordia-app
RUN yarn build
# --------------------------------------------------
# Stage 4 (Runtime)
# --------------------------------------------------
FROM nginx:1.17-alpine as runtime
LABEL maintainers.1="Apostolos Fanakis <apostolof@auth.gr>"
LABEL maintainers.2="Panagiotis Nikolaidis <ezerous@gmail.com"
LABEL gr.thmmy.ecentrics.concordia-image.name="app"
# Fix timezome
ARG TZ
RUN apk add -U tzdata \
&& cp /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone \
&& apk del tzdata \
&& rm -rf /var/cache/apk/*
WORKDIR "/var/www/concordia-app"
COPY ./docker/concordia-app/nginx.conf /etc/nginx/conf.d/default.conf
COPY --chown=nginx:nginx --from=build /usr/src/concordia/packages/concordia-app/build .

22
docker/concordia-app/nginx.conf

@ -0,0 +1,22 @@
server {
listen 80;
server_name localhost;
#charset koi8-r;
#access_log /var/log/nginx/host.access.log main;
location / {
root /var/www/concordia-app;
index index.html index.htm;
try_files "$uri" "$uri/" /index.html;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

11
docker/concordia-app/test-app.sh

@ -0,0 +1,11 @@
#!/bin/sh
yarn lint -f html -o /usr/test-reports/concordia-app-eslint.html --no-color
if [ $? -eq 0 ]; then
echo "TESTS RAN SUCCESSFULLY!"
exit 0
else
echo "SOME TESTS FAILED!"
exit 1
fi

66
docker/concordia-contracts/Dockerfile

@ -0,0 +1,66 @@
# --------------------------------------------------
# Stage 1 (Init contracts build base)
# --------------------------------------------------
FROM node:14-alpine as base
LABEL maintainers.1="Apostolos Fanakis <apostolof@auth.gr>"
LABEL maintainers.2="Panagiotis Nikolaidis <ezerous@gmail.com>"
LABEL gr.thmmy.ecentrics.concordia-image.name="contracts"
# Fix timezome (needed for timestamps on report files)
ARG TZ
RUN apk add -U tzdata \
&& cp /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone \
&& apk del tzdata \
&& rm -rf /var/cache/apk/*
WORKDIR /usr/src/concordia
# Copy the root package.json and yarn.lock
COPY ./package.json .
COPY ./yarn.lock .
# Copy the contracts package.json, then install modules
COPY ./packages/concordia-contracts/package.json ./packages/concordia-contracts/
RUN yarn install --frozen-lockfile --network-timeout 100000
# Gets the rest of the source code
COPY ./packages/concordia-contracts ./packages/concordia-contracts
# --------------------------------------------------
# Stage 2 (Compile)
# --------------------------------------------------
FROM base as compile
WORKDIR /usr/src/concordia/packages/concordia-contracts
RUN yarn compile
# --------------------------------------------------
# Stage 3 (Test)
# --------------------------------------------------
FROM compile as test
WORKDIR /opt/concordia-contracts
COPY ./docker/concordia-contracts/test-contracts.sh .
WORKDIR /usr/src/concordia/packages/concordia-contracts
ENTRYPOINT ["/opt/concordia-contracts/test-contracts.sh"]
# --------------------------------------------------
# Stage 4 (Runtime)
# --------------------------------------------------
FROM compile as runtime
LABEL maintainers.1="Apostolos Fanakis <apostolof@auth.gr>"
LABEL maintainers.2="Panagiotis Nikolaidis <ezerous@gmail.com>"
LABEL gr.thmmy.ecentrics.concordia-image.name="contracts"
WORKDIR /opt/concordia-contracts
COPY ./docker/concordia-contracts/migrate.sh .
RUN ["chmod", "+x", "/opt/concordia-contracts/migrate.sh"]
ENTRYPOINT ["/opt/concordia-contracts/migrate.sh"]

6
docker/concordia-contracts/migrate.sh

@ -0,0 +1,6 @@
#!/bin/sh
export CHAIN_HOST="$DEPLOY_CHAIN_HOST"
export CHAIN_PORT="$DEPLOY_CHAIN_PORT"
cd /usr/src/concordia/packages/concordia-contracts && yarn _migrate --network "${MIGRATE_NETWORK}" --reset

16
docker/concordia-contracts/test-contracts.sh

@ -0,0 +1,16 @@
#!/bin/sh
export CHAIN_HOST="$TEST_CHAIN_HOST"
export CHAIN_PORT="$TEST_CHAIN_PORT"
yarn _eslint -f html -o /usr/test-reports/concordia-contracts-eslint.html --no-color &&
(yarn _solhint >/usr/test-reports/concordia-contracts-solhint.report) &&
(yarn test --network env >/usr/test-reports/concordia-contracts-truffle-tests.report)
if [ $? -eq 0 ]; then
echo "TESTS RAN SUCCESSFULLY!"
exit 0
else
echo "SOME TESTS FAILED!"
exit 1
fi

34
docker/docker-compose.yml

@ -0,0 +1,34 @@
version: '3.8'
services:
ganache:
build:
context: ../
dockerfile: ./docker/ganache/Dockerfile
image: concordia-ganache
container_name: concordia-ganache
env_file:
- env/ganache.docker.env
expose:
- 8545
ports:
- 8545:8545
user: root
volumes:
- ./ganache/volumes/ganache_keys:/home/ganache_keys
networks:
ganache_network:
restart: always
rendezvous:
image: libp2p/js-libp2p-webrtc-star:version-0.20.5
container_name: concordia-rendezvous
networks:
rendezvous_network:
ports:
- 9090:9090
restart: always
networks:
ganache_network:
rendezvous_network:

7
docker/env/concordia.docker.env

@ -0,0 +1,7 @@
# Variables needed in runtime (in browser)
REACT_APP_RENDEZVOUS_HOST=rendezvous
REACT_APP_RENDEZVOUS_PORT=9090
# If the rendezvous server is running on host use these instead
#REACT_APP_RENDEZVOUS_HOST=127.0.0.1
#REACT_APP_RENDEZVOUS_PORT=9090

20
docker/env/concordia.example.env

@ -0,0 +1,20 @@
# Set to "CI" if in CI environment, anything else (including unset) will be ignored
BUILD_ENV={CI}
# Docker compose variables
VIRTUAL_HOST=example.com
VIRTUAL_PORT=3000
# If you uncomment the lines below, Concordia will become available through https BUT the rendezvous
# server will stop working and IPFS initialization won't complete
#LETSENCRYPT_HOST=example.com
#LETSENCRYPT_EMAIL=someemail.email.com
# Variables needed in runtime
# TO-NEVER-DO: change CONCORDIA_HOST to localhost
CONCORDIA_HOST=0.0.0.0
CONCORDIA_PORT=3000
# Variables needed in runtime (in browser)
REACT_APP_RENDEZVOUS_HOST=xx.xxx.xxx.xxx
REACT_APP_RENDEZVOUS_PORT=9090

14
docker/env/contracts.docker.env

@ -0,0 +1,14 @@
# Variables needed in runtime
MIGRATE_NETWORK=env
DEPLOY_CHAIN_HOST=concordia-ganache
DEPLOY_CHAIN_PORT=8545
TEST_CHAIN_HOST=concordia-ganache-test
TEST_CHAIN_PORT=8546
# If the blockchain is running on host use these instead
#DEPLOY_CHAIN_HOST=127.0.0.1
#DEPLOY_CHAIN_PORT=8545
#TEST_CHAIN_HOST=127.0.0.1
#TEST_CHAIN_PORT=8546

7
docker/env/contracts.example.env

@ -0,0 +1,7 @@
# Variables needed in runtime
MIGRATE_NETWORK=env
DEPLOY_CHAIN_HOST=xx.xxx.xxx.xxx
DEPLOY_CHAIN_PORT=8545
TEST_CHAIN_HOST=xx.xxx.xxx.xxx
TEST_CHAIN_PORT=8545

5
docker/env/ganache.docker.env

@ -0,0 +1,5 @@
ACCOUNTS_NUMBER=10
ACCOUNTS_ETHER=100
HOST=0.0.0.0
PORT=8545
NETWORK_ID=5778

6
docker/env/ganache.test.docker.env

@ -0,0 +1,6 @@
ACCOUNTS_NUMBER=5
ACCOUNTS_ETHER=1
MNEMONIC="myth like bonus scare over problem client lizard pioneer submit female collect"
HOST=0.0.0.0
PORT=8546
NETWORK_ID=5778

10
docker/ganache/Dockerfile

@ -0,0 +1,10 @@
FROM trufflesuite/ganache-cli:latest
RUN mkdir /home/ganache_db /home/ganache_keys
WORKDIR /opt/concordia-ganache
COPY ./docker/ganache/start-blockchain.sh .
RUN ["chmod", "+x", "/opt/concordia-ganache/start-blockchain.sh"]
ENTRYPOINT ["/opt/concordia-ganache/start-blockchain.sh"]

37
docker/ganache/start-blockchain.sh

@ -0,0 +1,37 @@
#!/bin/sh
N_ACCOUNTS="${ACCOUNTS_NUMBER:-10}"
ETHER="${ACCOUNTS_ETHER:-10}"
HOST="${HOST:-"0.0.0.0"}"
PORT="${PORT:-8545}"
ID="${NETWORK_ID:-5778}"
if [ -z "${MNEMONIC}" ]; then
echo "Starting Ganache with non deterministic address generation"
node /app/ganache-core.docker.cli.js \
--accounts "$N_ACCOUNTS" \
--defaultBalanceEther "$ETHER" \
--host "$HOST" \
--port "$PORT" \
--networkId "$ID" \
--account_keys_path "/home/ganache_keys/keys.json" \
--db "/home/ganache_db/" \
--allowUnlimitedContractSize \
--noVMErrorsOnRPCResponse \
--verbose
else
echo "Starting Ganache with deterministic address generation"
node /app/ganache-core.docker.cli.js \
--accounts "$N_ACCOUNTS" \
--defaultBalanceEther "$ETHER" \
--mnemonic "$MNEMONIC" \
--host "$HOST" \
--port "$PORT" \
--networkId "$ID" \
--account_keys_path "/home/ganache_keys/keys.json" \
--db "/home/ganache_db/" \
--allowUnlimitedContractSize \
--noVMErrorsOnRPCResponse \
--deterministic \
--verbose
fi

11
package.json

@ -1,8 +1,13 @@
{
"name": "apella",
"name": "concordia",
"private": true,
"workspaces": {
"packages": ["packages/*"],
"nohoist": ["**/web3", "**/web3/**"]
"packages": [
"packages/*"
],
"nohoist": [
"**/web3",
"**/web3/**"
]
}
}

0
packages/concordia-app/.dockerignore

12
packages/concordia-app/.env.development.example

@ -0,0 +1,12 @@
# This is an example development configuration for the app
# To create your own configuration, copy this one and ommit the ".example" from the filename, then change the
# environment cariables to the prefered values.
# Node dev-server host & port
HOST=localhost
PORT=7000
# Variables needed in runtime (in browser)
# Carefull, IPFS won't accept localhost as a valid hostname
REACT_APP_RENDEZVOUS_HOST=127.0.0.1
REACT_APP_RENDEZVOUS_PORT=9090

22
packages/concordia-app/.gitignore

@ -0,0 +1,22 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# testing
/coverage
# production
/build
# misc
.DS_Store
.env
.env.local
.env.development
.env.development.local
.env.test
.env.test.local
.env.production
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

5
packages/concordia-app/package.json

@ -25,8 +25,8 @@
},
"dependencies": {
"@ezerous/breeze": "~0.4.0",
"@ezerous/drizzle": "~0.4.0",
"@ezerous/eth-identity-provider": "^0.1.0",
"@ezerous/drizzle": "~0.4.1",
"@ezerous/eth-identity-provider": "~0.1.2",
"@reduxjs/toolkit": "~1.4.0",
"@welldone-software/why-did-you-render": "^6.0.0-rc.1",
"concordia-contracts": "~0.1.0",
@ -44,6 +44,7 @@
"react-router-dom": "^5.2.0",
"react-scripts": "~3.4.3",
"redux-saga": "~1.1.3",
"react-timeago": "~5.2.0",
"semantic-ui-css": "~2.4.1",
"semantic-ui-react": "~1.2.1",
"web3": "1.3.0"

76
packages/concordia-app/public/locales/en/translation.json

@ -2,22 +2,80 @@
"board.header.no.topics.message": "There are no topics yet!",
"board.sub.header.no.topics.guest": "Sign up and be the first to post.",
"board.sub.header.no.topics.user": "Be the first to post.",
"clear.databases.modal.cancel.button": "Cancel, keep databases",
"clear.databases.modal.clear.button": "Yes, delete databases",
"clear.databases.modal.clearing.progress.message": "This might take a minute...",
"clear.databases.modal.clearing.progress.title": "Clearing all Concordia databases",
"clear.databases.modal.description.body.user": "Although this action is generally recoverable some of your topics and posts may be permanently lost.",
"clear.databases.modal.description.pre": "You are about to clear the Concordia databases stored locally in your browser.",
"clear.databases.modal.form.username.label.guest": "Please type concordia to confirm.",
"clear.databases.modal.form.username.label.user": "Please type your username to confirm.",
"clear.databases.modal.title": "Clear all Concordia databases. Are you sure?",
"custom.loading.tab.pane.default.generic.message": "Magic in the background",
"edit.information.modal.form.cancel.button": "Cancel",
"edit.information.modal.form.error.invalid.profile.picture.url.message": "The profile picture URL provided is not valid.",
"edit.information.modal.form.error.message.header": "Form contains errors",
"edit.information.modal.form.location.field.label": "Location",
"edit.information.modal.form.location.field.placeholder": "Location",
"edit.information.modal.form.profile.picture.field.label": "Profile picture URL",
"edit.information.modal.form.profile.picture.field.placeholder": "URL",
"edit.information.modal.form.submit.button": "Submit",
"edit.information.modal.title": "Edit profile information",
"post.create.form.send.button": "Post",
"post.form.content.field.placeholder": "Message",
"post.form.subject.field.placeholder": "Subject",
"post.list.row.post.id": "#{{id}}",
"profile.general.tab.address.row.title": "Account address:",
"profile.general.tab.clear.databases.button.title": "Clear databases",
"profile.general.tab.edit.info.button.title": "Edit information",
"profile.general.tab.location.row.not.set": "Not set",
"profile.general.tab.location.row.title": "Location:",
"profile.general.tab.number.of.posts.row.title": "Number of posts:",
"profile.general.tab.number.of.topics.row.title": "Number of topics created:",
"profile.general.tab.posts.db.address.row.title": "PostsDB:",
"profile.general.tab.registration.date.row.title": "Member since:",
"profile.general.tab.save.info.button.title": "Save information",
"profile.general.tab.title": "General",
"profile.general.tab.topics.db.address.row.title": "TopicsDB:",
"profile.general.tab.user.db.address.row.title": "UserDB:",
"profile.general.tab.username.row.title": "Username:",
"profile.posts.tab.title": "Posts",
"profile.topics.tab.title": "Topics",
"profile.user.has.no.posts.header.message": "{{user}} has not posted yet",
"profile.user.has.no.topics.header.message": "{{user}} has created no topics yet",
"register.card.header": "Sign Up",
"register.form.button.back": "Back",
"register.form.button.guest": "Continue as guest",
"register.form.button.submit": "Sign Up",
"register.form.error.message.header": "Form contains errors",
"register.form.error.username.taken.message": "The username {{username}} is already taken.",
"register.form.header.already.member.message": "There is already an account for this address.\nIf you want to create another account please change your address.",
"register.form.username.field.label": "Username",
"register.form.username.field.placeholder": "Username",
"register.form.personal.information.step.button.skip": "Skip for now",
"register.form.personal.information.step.button.submit": "Submit",
"register.form.personal.information.step.error.invalid.profile.picture.url.message": "The profile picture URL provided is not valid.",
"register.form.personal.information.step.error.message.header": "Form contains errors",
"register.form.personal.information.step.location.field.label": "Location",
"register.form.personal.information.step.location.field.placeholder": "Location",
"register.form.personal.information.step.profile.picture.field.label": "Profile picture URL",
"register.form.personal.information.step.profile.picture.field.placeholder": "URL",
"register.form.profile.information.step.description": "Give a hint about who you are",
"register.form.profile.information.step.title": "Profile Information",
"register.form.sign.up.step.button.guest": "Continue as guest",
"register.form.sign.up.step.button.submit": "Sign Up",
"register.form.sign.up.step.description": "Create a Concordia account",
"register.form.sign.up.step.error.message.header": "Form contains errors",
"register.form.sign.up.step.title": "Sign Up",
"register.p.account.address": "Account address:",
"topbar.button.clear.databases": "Clear databases",
"topbar.button.create.topic": "Create topic",
"topbar.button.profile": "Profile",
"topbar.button.register": "Sign Up",
"topic.create.form.content.field.label": "First post content",
"topic.create.form.content.field.placeholder": "Message",
"topic.create.form.post.button": "Post",
"topic.create.form.subject.field.label": "Topic subject",
"topic.create.form.subject.field.placeholder": "Subject",
"topic.create.form.message.field.label": "First post message",
"topic.create.form.message.field.placeholder": "Message",
"topic.create.form.post.button": "Post"
"topic.list.row.author": "by {{author}}",
"topic.list.row.number.of.replies": "{{numberOfReplies}} replies",
"topic.list.row.topic.id": "#{{id}}",
"username.selector.error.username.empty.message": "Username is required",
"username.selector.error.username.taken.message": "The username {{username}} is already taken.",
"username.selector.username.field.label": "Username",
"username.selector.username.field.placeholder": "Username"
}

4
packages/concordia-app/public/manifest.json

@ -1,6 +1,6 @@
{
"short_name": "Apella",
"name": "Apella",
"short_name": "Concordia",
"name": "Concordia",
"icons": [
{
"src": "favicon.ico",

8
packages/concordia-app/src/App.jsx

@ -2,23 +2,23 @@ import React from 'react';
import { Provider } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom';
import PropTypes from 'prop-types';
import LoadingContainer from './components/LoadingContainer';
import InitializationScreen from './components/InitializationScreen';
import Routes from './Routes';
import './intl/index';
import 'semantic-ui-css/semantic.min.css';
import './assets/css/app.css';
const App = ({ store }) => (
<Provider store={store}>
<LoadingContainer>
<InitializationScreen>
<Router>
<Routes />
</Router>
</LoadingContainer>
</InitializationScreen>
</Provider>
);
App.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
store: PropTypes.object.isRequired,
};

5
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: () => <Redirect to="/404" />,
},

7
packages/concordia-app/src/assets/css/app.css

@ -1,7 +0,0 @@
body {
margin: 1em !important;
}
.i18next-newlines {
white-space: pre-line !important;
}

17
packages/concordia-app/src/assets/css/index.css

@ -0,0 +1,17 @@
body.app {
overflow: auto;
margin: 1em !important;
}
#root {
height: 100%;
}
.i18next-newlines {
white-space: pre-line !important;
}
.text-secondary {
color: gray;
font-style: italic;
}

8
packages/concordia-app/src/assets/css/loading-component.css

@ -2,16 +2,16 @@ body {
overflow: hidden;
}
ul {
list-style-position: inside;
}
.loading-screen {
margin-top: 10em;
text-align: center;
font-size: large;
}
.loading-screen ul {
list-style-position: inside;
}
.loading-img {
margin-bottom: 3em;
height: 12em;

1
packages/concordia-app/src/assets/images/metamask_logo.svg

@ -0,0 +1 @@
<svg fill="none" height="33" viewBox="0 0 35 33" width="35" xmlns="http://www.w3.org/2000/svg"><g stroke-linecap="round" stroke-linejoin="round" stroke-width=".25"><path d="m32.9582 1-13.1341 9.7183 2.4424-5.72731z" fill="#e17726" stroke="#e17726"/><g fill="#e27625" stroke="#e27625"><path d="m2.66296 1 13.01714 9.809-2.3254-5.81802z"/><path d="m28.2295 23.5335-3.4947 5.3386 7.4829 2.0603 2.1436-7.2823z"/><path d="m1.27281 23.6501 2.13055 7.2823 7.46994-2.0603-3.48166-5.3386z"/><path d="m10.4706 14.5149-2.0786 3.1358 7.405.3369-.2469-7.969z"/><path d="m25.1505 14.5149-5.1575-4.58704-.1688 8.05974 7.4049-.3369z"/><path d="m10.8733 28.8721 4.4819-2.1639-3.8583-3.0062z"/><path d="m20.2659 26.7082 4.4689 2.1639-.6105-5.1701z"/></g><path d="m24.7348 28.8721-4.469-2.1639.3638 2.9025-.039 1.231z" fill="#d5bfb2" stroke="#d5bfb2"/><path d="m10.8732 28.8721 4.1572 1.9696-.026-1.231.3508-2.9025z" fill="#d5bfb2" stroke="#d5bfb2"/><path d="m15.1084 21.7842-3.7155-1.0884 2.6243-1.2051z" fill="#233447" stroke="#233447"/><path d="m20.5126 21.7842 1.0913-2.2935 2.6372 1.2051z" fill="#233447" stroke="#233447"/><path d="m10.8733 28.8721.6495-5.3386-4.13117.1167z" fill="#cc6228" stroke="#cc6228"/><path d="m24.0982 23.5335.6366 5.3386 3.4946-5.2219z" fill="#cc6228" stroke="#cc6228"/><path d="m27.2291 17.6507-7.405.3369.6885 3.7966 1.0913-2.2935 2.6372 1.2051z" fill="#cc6228" stroke="#cc6228"/><path d="m11.3929 20.6958 2.6242-1.2051 1.0913 2.2935.6885-3.7966-7.40495-.3369z" fill="#cc6228" stroke="#cc6228"/><path d="m8.392 17.6507 3.1049 6.0513-.1039-3.0062z" fill="#e27525" stroke="#e27525"/><path d="m24.2412 20.6958-.1169 3.0062 3.1049-6.0513z" fill="#e27525" stroke="#e27525"/><path d="m15.797 17.9876-.6886 3.7967.8704 4.4833.1949-5.9087z" fill="#e27525" stroke="#e27525"/><path d="m19.8242 17.9876-.3638 2.3584.1819 5.9216.8704-4.4833z" fill="#e27525" stroke="#e27525"/><path d="m20.5127 21.7842-.8704 4.4834.6236.4406 3.8584-3.0062.1169-3.0062z" fill="#f5841f" stroke="#f5841f"/><path d="m11.3929 20.6958.104 3.0062 3.8583 3.0062.6236-.4406-.8704-4.4834z" fill="#f5841f" stroke="#f5841f"/><path d="m20.5906 30.8417.039-1.231-.3378-.2851h-4.9626l-.3248.2851.026 1.231-4.1572-1.9696 1.4551 1.1921 2.9489 2.0344h5.0536l2.962-2.0344 1.442-1.1921z" fill="#c0ac9d" stroke="#c0ac9d"/><path d="m20.2659 26.7082-.6236-.4406h-3.6635l-.6236.4406-.3508 2.9025.3248-.2851h4.9626l.3378.2851z" fill="#161616" stroke="#161616"/><path d="m33.5168 11.3532 1.1043-5.36447-1.6629-4.98873-12.6923 9.3944 4.8846 4.1205 6.8983 2.0085 1.52-1.7752-.6626-.4795 1.0523-.9588-.8054-.622 1.0523-.8034z" fill="#763e1a" stroke="#763e1a"/><path d="m1 5.98873 1.11724 5.36447-.71451.5313 1.06527.8034-.80545.622 1.05228.9588-.66255.4795 1.51997 1.7752 6.89835-2.0085 4.8846-4.1205-12.69233-9.3944z" fill="#763e1a" stroke="#763e1a"/><path d="m32.0489 16.5234-6.8983-2.0085 2.0786 3.1358-3.1049 6.0513 4.1052-.0519h6.1318z" fill="#f5841f" stroke="#f5841f"/><path d="m10.4705 14.5149-6.89828 2.0085-2.29944 7.1267h6.11883l4.10519.0519-3.10487-6.0513z" fill="#f5841f" stroke="#f5841f"/><path d="m19.8241 17.9876.4417-7.5932 2.0007-5.4034h-8.9119l2.0006 5.4034.4417 7.5932.1689 2.3842.013 5.8958h3.6635l.013-5.8958z" fill="#f5841f" stroke="#f5841f"/></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

23
packages/concordia-app/src/assets/particles.js

@ -1,7 +1,7 @@
const particlesOptions = {
particles: {
number: {
value: 60,
value: 90,
density: {
enable: true,
value_area: 1500,
@ -9,11 +9,11 @@ const particlesOptions = {
},
line_linked: {
enable: true,
opacity: 0.02,
opacity: 0.04,
},
move: {
direction: 'right',
speed: 0.05,
direction: 'none',
speed: 0.12,
},
size: {
value: 1,
@ -21,24 +21,11 @@ const particlesOptions = {
opacity: {
anim: {
enable: true,
speed: 1,
speed: 1.3,
opacity_min: 0.05,
},
},
},
interactivity: {
events: {
onclick: {
enable: true,
mode: 'push',
},
},
modes: {
push: {
particles_nb: 1,
},
},
},
retina_detect: true,
};

151
packages/concordia-app/src/components/ClearDatabasesModal/index.jsx

@ -0,0 +1,151 @@
import React, {
useCallback, useMemo, useState,
useEffect,
} from 'react';
import {
Button, Form, Input, Modal,
} from 'semantic-ui-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import purgeIndexedDBs from '../../utils/indexedDB/indexedDBUtils';
const ClearDatabasesModal = (props) => {
const {
open, onDatabasesCleared, onCancel,
} = props;
const [confirmationInput, setConfirmationInput] = useState('');
const [userConfirmed, setUserConfirmed] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const user = useSelector((state) => state.user);
const { t } = useTranslation();
useEffect(() => {
if (user.hasSignedUp && confirmationInput === user.username) {
setUserConfirmed(true);
} else if (!user.hasSignedUp && confirmationInput === 'concordia') {
setUserConfirmed(true);
} else {
setUserConfirmed(false);
}
}, [confirmationInput, user.hasSignedUp, user.username]);
const handleSubmit = useCallback(() => {
setIsClearing(true);
purgeIndexedDBs()
.then(() => {
onDatabasesCleared();
}).catch((reason) => console.log(reason));
}, [onDatabasesCleared]);
const onCancelTry = useCallback(() => {
if (!isClearing) {
setConfirmationInput('');
onCancel();
}
}, [isClearing, onCancel]);
const handleInputChange = (event, { value }) => { setConfirmationInput(value); };
const modalContent = useMemo(() => {
if (isClearing) {
return (
<>
<p>
{t('clear.databases.modal.clearing.progress.message')}
</p>
</>
);
}
if (user.hasSignedUp) {
return (
<>
<p>
{t('clear.databases.modal.description.pre')}
</p>
<p>
{t('clear.databases.modal.description.body.user')}
</p>
<Form>
<Form.Field>
<label htmlFor="form-clear-databases-field-confirm">
{t('clear.databases.modal.form.username.label.user')}
</label>
<Input
id="form-clear-databases-field-confirm"
name="confirmationInput"
value={confirmationInput}
onChange={handleInputChange}
/>
</Form.Field>
</Form>
</>
);
}
return (
<>
<p>
{t('clear.databases.modal.description.pre')}
</p>
<Form>
<Form.Field>
<label htmlFor="form-clear-databases-field-confirm">
{t('clear.databases.modal.form.username.label.guest')}
</label>
<Input
id="form-clear-databases-field-confirm"
name="confirmationInput"
value={confirmationInput}
onChange={handleInputChange}
/>
</Form.Field>
</Form>
</>
);
}, [confirmationInput, isClearing, t, user.hasSignedUp]);
return useMemo(() => (
<Modal
onClose={onCancelTry}
open={open}
size="small"
>
<Modal.Header>
{isClearing
? t('clear.databases.modal.clearing.progress.title')
: t('clear.databases.modal.title')}
</Modal.Header>
<Modal.Content>
<Modal.Description>
{modalContent}
</Modal.Description>
</Modal.Content>
{!isClearing && (
<Modal.Actions>
<Button color="black" basic onClick={onCancelTry} disabled={isClearing}>
{t('clear.databases.modal.cancel.button')}
</Button>
<Button onClick={handleSubmit} negative disabled={!userConfirmed}>
{t('clear.databases.modal.clear.button')}
</Button>
</Modal.Actions>
)}
</Modal>
), [handleSubmit, isClearing, modalContent, onCancelTry, open, t, userConfirmed]);
};
ClearDatabasesModal.defaultProps = {
open: false,
};
ClearDatabasesModal.propTypes = {
open: PropTypes.bool,
onDatabasesCleared: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
};
export default ClearDatabasesModal;

46
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, 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 (
<Tab.Pane>
<Dimmer active inverted>
<Loader inverted>
{loadingMessage !== undefined
? loadingMessage
: t('custom.loading.tab.pane.default.generic.message')}
</Loader>
</Dimmer>
<Placeholder fluid>
<Placeholder.Line length="very long" />
<Placeholder.Line length="medium" />
<Placeholder.Line length="long" />
</Placeholder>
</Tab.Pane>
);
}
return (
<Tab.Pane>
{children}
</Tab.Pane>
);
}, [children, loading, loadingMessage, t]);
};
CustomLoadingTabPane.propTypes = {
loading: PropTypes.bool,
loadingMessage: PropTypes.string,
children: PropTypes.element.isRequired,
};
export default CustomLoadingTabPane;

23
packages/concordia-app/src/components/LoadingComponent.jsx → packages/concordia-app/src/components/InitializationScreen/CustomLoader/index.jsx

@ -1,25 +1,30 @@
import React from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { Container, Progress } from 'semantic-ui-react';
// CSS
import '../assets/css/loading-component.css';
// Images
import ethereumLogo from '../assets/images/ethereum_logo.svg';
import ipfsLogo from '../assets/images/ipfs_logo.svg';
import orbitdbLogo from '../assets/images/orbitdb_logo.svg';
import appLogo from '../assets/images/app_logo.png';
import metamaskLogo from '../../../assets/images/metamask_logo.svg';
import ethereumLogo from '../../../assets/images/ethereum_logo.svg';
import ipfsLogo from '../../../assets/images/ipfs_logo.svg';
import orbitdbLogo from '../../../assets/images/orbitdb_logo.svg';
import appLogo from '../../../assets/images/app_logo.png';
const LoadingComponent = (props) => {
useEffect(() => function cleanup() {
document.body.classList.add('app');
}, []);
const {
imageType, messageList, progressType, title, message, progress,
} = props;
let imageSrc; let imageAlt; let listItems; let indicating; let
error;
if (imageType === 'ethereum') {
if (imageType === 'metamask') {
imageSrc = metamaskLogo;
imageAlt = 'metamask_logo';
} else if (imageType === 'ethereum') {
imageSrc = ethereumLogo;
imageAlt = 'ethereum_logo';
} else if (imageType === 'ipfs') {

163
packages/concordia-app/src/components/InitializationScreen/index.jsx

@ -0,0 +1,163 @@
import React, { Children } from 'react';
import { breezeConstants } from '@ezerous/breeze';
import { useSelector } from 'react-redux';
import CustomLoader from './CustomLoader';
// CSS
import '../../assets/css/loading-component.css';
import { FORUM_CONTRACT } from '../../constants/contracts/ContractNames';
const InitializationLoader = ({ children }) => {
const initializing = useSelector((state) => state.drizzleStatus.initializing);
const failed = useSelector((state) => state.drizzleStatus.failed);
const ipfsStatus = useSelector((state) => state.ipfs.status);
const orbitStatus = useSelector((state) => state.orbit.status);
const web3Status = useSelector((state) => state.web3.status);
const web3NetworkId = useSelector((state) => state.web3.networkId);
const web3NetworkFailed = useSelector((state) => state.web3.networkFailed);
const web3AccountsFailed = useSelector((state) => state.web3.accountsFailed);
const contractInitialized = useSelector((state) => state.contracts[FORUM_CONTRACT].initialized);
const contractDeployed = useSelector((state) => state.contracts[FORUM_CONTRACT].deployed);
const userFetched = useSelector((state) => state.user.address);
if (!window.ethereum) {
return (
<CustomLoader
title="Couldn't detect MetaMask!"
message={['Please make sure to install ', <a href="https://metamask.io/">MetaMask</a>, ' first.']}
imageType="metamask"
progress={10}
progressType="error"
/>
);
}
if ((web3Status === 'initializing' || !web3NetworkId) && !web3NetworkFailed) {
return (
<CustomLoader
title="Connecting to the Ethereum network..."
message="Please make sure to unlock MetaMask and grant the app the right to connect to your account."
imageType="ethereum"
progress={20}
progressType="indicating"
/>
);
}
if (web3Status === 'failed' || web3NetworkFailed) {
return (
<CustomLoader
title="No connection to the Ethereum network!"
message="Please make sure that:"
messageList={['MetaMask is unlocked and pointed to the correct, available network',
'The app has been granted the right to connect to your account']}
imageType="ethereum"
progress={20}
progressType="error"
/>
);
}
if (web3Status === 'initialized' && web3AccountsFailed) {
return (
<CustomLoader
title="We can't find any Ethereum accounts!"
message="Please make sure that MetaMask is unlocked."
imageType="ethereum"
progress={20}
progressType="error"
/>
);
}
if (initializing || (!failed && !contractInitialized && contractDeployed)) {
return (
<CustomLoader
title="Initializing contracts..."
message=""
imageType="ethereum"
progress={40}
progressType="indicating"
/>
);
}
if (!contractDeployed) {
return (
<CustomLoader
title="No contracts found on the current network!"
message="Please make sure that you are connected to the correct network and the contracts are deployed."
imageType="ethereum"
progress={40}
progressType="error"
/>
);
}
if (ipfsStatus === breezeConstants.STATUS_INITIALIZING) {
return (
<CustomLoader
title="Initializing IPFS..."
message=""
imageType="ipfs"
progress={60}
progressType="indicating"
/>
);
}
if (ipfsStatus === breezeConstants.STATUS_FAILED) {
return (
<CustomLoader
title="IPFS initialization failed!"
message=""
imageType="ipfs"
progress={60}
progressType="error"
/>
);
}
if (orbitStatus === breezeConstants.STATUS_INITIALIZING) {
const message = process.env.NODE_ENV === 'development'
? 'If needed, please sign the transaction in MetaMask to create the databases.'
: 'Please sign the transaction in MetaMask to create the databases.';
return (
<CustomLoader
title="Preparing OrbitDB..."
message={message}
imageType="orbit"
progress={80}
progressType="indicating"
/>
);
}
if (orbitStatus === breezeConstants.STATUS_FAILED) {
return (
<CustomLoader
title="OrbitDB initialization failed!"
message=""
imageType="orbit"
progress={80}
progressType="error"
/>
);
}
if (!userFetched) {
return (
<CustomLoader
title="Loading dapp..."
message=""
imageType="app"
progress={90}
progressType="indicating"
/>
);
}
return Children.only(children);
};
export default InitializationLoader;

167
packages/concordia-app/src/components/LoadingContainer.jsx

@ -1,167 +0,0 @@
import React, { Children, Component } from 'react';
import { connect } from 'react-redux';
import { breezeConstants } from '@ezerous/breeze';
import LoadingComponent from './LoadingComponent';
// CSS
import '../assets/css/loading-component.css';
class LoadingContainer extends Component {
render() {
const {
web3: {
status, networkId, networkFailed, accountsFailed,
},
drizzleStatus: {
initializing,
failed,
},
contractInitialized, contractDeployed, ipfsStatus, orbitStatus, userFetched, children,
} = this.props;
if ((status === 'initializing' || !networkId)
&& !networkFailed) {
return (
<LoadingComponent
title="Connecting to the Ethereum network..."
message="Please make sure to unlock MetaMask and grant the app the right to connect to your account."
imageType="ethereum"
progress={20}
progressType="indicating"
/>
);
}
if (status === 'failed' || networkFailed) {
return (
<LoadingComponent
title="No connection to the Ethereum network!"
message="Please make sure that:"
message_list={['MetaMask is unlocked and pointed to the correct, available network',
'The app has been granted the right to connect to your account']}
imageType="ethereum"
progress={20}
progressType="error"
/>
);
}
if (status === 'initialized' && accountsFailed) {
return (
<LoadingComponent
title="We can't find any Ethereum accounts!"
message="Please make sure that MetaMask is unlocked."
imageType="ethereum"
progress={20}
progressType="error"
/>
);
}
if (initializing
|| (!failed && !contractInitialized && contractDeployed)) {
return (
<LoadingComponent
title="Initializing contracts..."
message=""
imageType="ethereum"
progress={40}
progressType="indicating"
/>
);
}
if (!contractDeployed) {
return (
<LoadingComponent
title="No contracts found on the current network!"
message="Please make sure that you are connected to the correct network and the contracts are deployed."
imageType="ethereum"
progress={40}
progressType="error"
/>
);
}
if (ipfsStatus === breezeConstants.STATUS_INITIALIZING) {
return (
<LoadingComponent
title="Initializing IPFS..."
message=""
imageType="ipfs"
progress={60}
progressType="indicating"
/>
);
}
if (ipfsStatus === breezeConstants.STATUS_FAILED) {
return (
<LoadingComponent
title="IPFS initialization failed!"
message=""
imageType="ipfs"
progress={60}
progressType="error"
/>
);
}
if (orbitStatus === breezeConstants.STATUS_INITIALIZING) {
const message = process.env.NODE_ENV === 'development'
? 'If needed, please sign the transaction in MetaMask to create the databases.'
: 'Please sign the transaction in MetaMask to create the databases.';
return (
<LoadingComponent
title="Preparing OrbitDB..."
message={message}
imageType="orbit"
progress={80}
progressType="indicating"
/>
);
}
if (orbitStatus === breezeConstants.STATUS_FAILED) {
return (
<LoadingComponent
title="OrbitDB initialization failed!"
message=""
imageType="orbit"
progress={80}
progressType="error"
/>
);
}
if (!userFetched) {
return (
<LoadingComponent
title="Loading dapp..."
message=""
imageType="app"
progress={90}
progressType="indicating"
/>
);
}
return Children.only(children);
}
}
const mapStateToProps = (state) => ({
drizzleStatus: state.drizzleStatus,
breezeStatus: state.breezeStatus,
ipfsStatus: state.ipfs.status,
orbitStatus: state.orbit.status,
web3: state.web3,
accounts: state.accounts,
contractInitialized: state.contracts.Forum.initialized,
contractDeployed: state.contracts.Forum.deployed,
userFetched: state.user.address,
});
export default connect(mapStateToProps)(LoadingContainer);

49
packages/concordia-app/src/components/Placeholder/index.jsx

@ -1,49 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { List } from 'semantic-ui-react';
import { PLACEHOLDER_TYPE_POST, PLACEHOLDER_TYPE_TOPIC } from '../../constants/PlaceholderTypes';
const Placeholder = (props) => {
const { placeholderType, extra } = props;
switch (placeholderType) {
case PLACEHOLDER_TYPE_TOPIC:
return (
<>
<List.Header>
<List.Icon name="right triangle" />
topicSubject
</List.Header>
<List.Content>
username
Number of Replies
timestamp
</List.Content>
</>
);
case PLACEHOLDER_TYPE_POST:
return (
<div>LOADING POST</div>
);
default:
return <div />;
}
};
const TopicPlaceholderExtra = PropTypes.PropTypes.shape({
topicId: PropTypes.number.isRequired,
});
const PostPlaceholderExtra = PropTypes.PropTypes.shape({
postIndex: PropTypes.number.isRequired,
});
Placeholder.propTypes = {
placeholderType: PropTypes.string.isRequired,
extra: PropTypes.oneOfType([
TopicPlaceholderExtra.isRequired,
PostPlaceholderExtra.isRequired,
]),
};
export default Placeholder;

183
packages/concordia-app/src/components/PostCreate/index.jsx

@ -0,0 +1,183 @@
import React, {
memo, useCallback, useEffect, useState,
} from 'react';
import {
Button, Feed, Form, Icon, Image, TextArea,
} from 'semantic-ui-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import determineKVAddress from '../../utils/orbitUtils';
import { POSTS_DATABASE, USER_DATABASE } from '../../constants/orbit/OrbitDatabases';
import { FETCH_USER_DATABASE } from '../../redux/actions/peerDbReplicationActions';
import { USER_PROFILE_PICTURE } from '../../constants/orbit/UserDatabaseKeys';
import { breeze, drizzle } from '../../redux/store';
import './styles.css';
import { TRANSACTION_ERROR, TRANSACTION_SUCCESS } from '../../constants/TransactionStatus';
import { POST_CONTENT } from '../../constants/orbit/PostsDatabaseKeys';
import { FORUM_CONTRACT } from '../../constants/contracts/ContractNames';
import { POST_CREATED_EVENT } from '../../constants/contracts/events/ForumContractEvents';
const { contracts: { [FORUM_CONTRACT]: { methods: { createPost } } } } = drizzle;
const { orbit } = breeze;
const PostCreate = (props) => {
const {
topicId, initialPostSubject, account,
} = props;
const transactionStack = useSelector((state) => state.transactionStack);
const transactions = useSelector((state) => state.transactions);
const [postContent, setPostContent] = useState('');
const [userProfilePictureUrl, setUserProfilePictureUrl] = useState();
const [createPostCacheSendStackId, setCreatePostCacheSendStackId] = useState('');
const [posting, setPosting] = useState(false);
const [storingPost, setStoringPost] = useState(false);
const userAddress = useSelector((state) => state.user.address);
const users = useSelector((state) => state.orbitData.users);
const dispatch = useDispatch();
const { t } = useTranslation();
useEffect(() => {
if (userAddress) {
determineKVAddress({ orbit, dbName: USER_DATABASE, userAddress })
.then((userOrbitAddress) => {
const userFound = users
.find((user) => user.id === userOrbitAddress);
if (userFound) {
setUserProfilePictureUrl(userFound[USER_PROFILE_PICTURE]);
} else {
dispatch({
type: FETCH_USER_DATABASE,
orbit,
dbName: USER_DATABASE,
userAddress,
});
}
})
.catch((error) => {
console.error('Error during determination of key-value DB address:', error);
});
}
}, [dispatch, userAddress, users]);
const handleInputChange = useCallback((event) => {
if (posting) {
return;
}
switch (event.target.name) {
case 'postContent':
setPostContent(event.target.value);
break;
default:
break;
}
}, [posting]);
useEffect(() => {
if (posting && !storingPost && transactionStack && transactionStack[createPostCacheSendStackId]
&& transactions[transactionStack[createPostCacheSendStackId]]) {
if (transactions[transactionStack[createPostCacheSendStackId]].status === TRANSACTION_ERROR) {
setPosting(false);
} else if (transactions[transactionStack[createPostCacheSendStackId]].status === TRANSACTION_SUCCESS) {
const {
receipt: { events: { [POST_CREATED_EVENT]: { returnValues: { postID: contractPostId } } } },
} = transactions[transactionStack[createPostCacheSendStackId]];
const { stores } = orbit;
const postsDb = Object.values(stores).find((store) => store.dbname === POSTS_DATABASE);
postsDb
.put(contractPostId, {
[POST_CONTENT]: postContent,
}, { pin: true })
.then(() => {
setPostContent('');
setPosting(false);
setStoringPost(false);
setCreatePostCacheSendStackId('');
})
.catch((reason) => {
console.log(reason);
});
setStoringPost(true);
}
}
}, [
createPostCacheSendStackId, initialPostSubject, postContent, posting, storingPost, transactionStack,
transactions,
]);
const savePost = useCallback(() => {
if (postContent === '') {
return;
}
setPosting(true);
setCreatePostCacheSendStackId(createPost.cacheSend(...[topicId], { from: account }));
}, [account, postContent, topicId]);
return (
<Feed>
<Feed.Event>
<Feed.Label className="post-profile-picture">
{userProfilePictureUrl
? (
<Image
avatar
src={userProfilePictureUrl}
/>
)
: (
<Icon
name="user circle"
size="big"
inverted
color="black"
/>
)}
</Feed.Label>
<Feed.Content>
<Feed.Summary>
<Form>
<TextArea
placeholder={t('post.form.content.field.placeholder')}
name="postContent"
size="mini"
rows={4}
value={postContent}
onChange={handleInputChange}
/>
</Form>
</Feed.Summary>
<Feed.Meta>
<Feed.Like>
<Form.Button
animated
type="button"
color="green"
disabled={posting || postContent === ''}
onClick={savePost}
>
<Button.Content visible>
{t('post.create.form.send.button')}
</Button.Content>
<Button.Content hidden>
<Icon name="send" />
</Button.Content>
</Form.Button>
</Feed.Like>
</Feed.Meta>
</Feed.Content>
</Feed.Event>
</Feed>
);
};
PostCreate.propTypes = {
topicId: PropTypes.number.isRequired,
};
export default memo(PostCreate);

13
packages/concordia-app/src/components/PostCreate/styles.css

@ -0,0 +1,13 @@
.post-profile-picture {
margin: 5px 0 0 0;
}
.post-summary-meta-index {
float: right;
font-size: 12px;
opacity: 0.4;
}
.like:hover .icon {
color: #fff !important;
}

165
packages/concordia-app/src/components/PostList/PostListRow/index.jsx

@ -0,0 +1,165 @@
import React, {
memo, useEffect, useMemo, useState,
} from 'react';
import {
Dimmer, Icon, Image, Feed, Placeholder,
} from 'semantic-ui-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import TimeAgo from 'react-timeago';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { FETCH_USER_DATABASE } from '../../../redux/actions/peerDbReplicationActions';
import { breeze } from '../../../redux/store';
import './styles.css';
import { POSTS_DATABASE, USER_DATABASE } from '../../../constants/orbit/OrbitDatabases';
import determineKVAddress from '../../../utils/orbitUtils';
import { USER_PROFILE_PICTURE } from '../../../constants/orbit/UserDatabaseKeys';
import { POST_CONTENT } from '../../../constants/orbit/PostsDatabaseKeys';
import { FORUM_CONTRACT } from '../../../constants/contracts/ContractNames';
const { orbit } = breeze;
const PostListRow = (props) => {
const {
id: postId, postIndexInTopic, postCallHash, loading,
} = props;
const getPostResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getPost);
const [postAuthorAddress, setPostAuthorAddress] = useState(null);
const [postAuthor, setPostAuthor] = useState(null);
const [timeAgo, setTimeAgo] = useState(null);
const [postContent, setPostContent] = useState(null);
const [postAuthorMeta, setPostAuthorMeta] = useState(null);
const userAddress = useSelector((state) => state.user.address);
const posts = useSelector((state) => state.orbitData.posts);
const users = useSelector((state) => state.orbitData.users);
const dispatch = useDispatch();
const { t } = useTranslation();
useEffect(() => {
if (!loading && postCallHash && getPostResults[postCallHash] !== undefined) {
setPostAuthorAddress(getPostResults[postCallHash].value[0]);
setPostAuthor(getPostResults[postCallHash].value[1]);
setTimeAgo(getPostResults[postCallHash].value[2] * 1000);
}
}, [getPostResults, loading, postCallHash]);
useEffect(() => {
if (postAuthorAddress && userAddress !== postAuthorAddress) {
dispatch({
type: FETCH_USER_DATABASE,
orbit,
dbName: POSTS_DATABASE,
userAddress: postAuthorAddress,
});
dispatch({
type: FETCH_USER_DATABASE,
orbit,
dbName: USER_DATABASE,
userAddress: postAuthorAddress,
});
}
}, [dispatch, postAuthorAddress, userAddress]);
useEffect(() => {
const postFound = posts
.find((post) => post.id === postId);
if (postFound) {
setPostContent(postFound[POST_CONTENT]);
}
}, [postId, posts]);
useEffect(() => {
if (postAuthorAddress !== null) {
determineKVAddress({ orbit, dbName: USER_DATABASE, userAddress: postAuthorAddress })
.then((userOrbitAddress) => {
const userFound = users
.find((user) => user.id === userOrbitAddress);
if (userFound) {
setPostAuthorMeta(userFound);
}
})
.catch((error) => {
console.error('Error during determination of key-value DB address:', error);
});
}
}, [postAuthorAddress, users]);
const authorAvatar = useMemo(() => (postAuthorMeta !== null && postAuthorMeta[USER_PROFILE_PICTURE]
? (
<Image
avatar
src={postAuthorMeta[USER_PROFILE_PICTURE]}
/>
)
: (
<Icon
name="user circle"
size="big"
inverted
color="black"
/>
)), [postAuthorMeta]);
const authorAvatarLink = useMemo(() => {
if (postAuthorAddress) {
return (
<Link to={`/users/${postAuthorAddress}`}>
{authorAvatar}
</Link>
);
}
return authorAvatar;
}, [authorAvatar, postAuthorAddress]);
return useMemo(() => (
<Dimmer.Dimmable as={Feed.Event} blurring dimmed={loading}>
<Feed.Label className="post-profile-picture">
{authorAvatarLink}
</Feed.Label>
<Feed.Content>
<Feed.Summary>
<div>
<span className="post-summary-meta-index">
{t('post.list.row.post.id', { id: postIndexInTopic })}
</span>
</div>
{postAuthor !== null && setPostAuthorAddress !== null && timeAgo !== null
? (
<>
<Feed.User as={Link} to={`/users/${postAuthorAddress}`}>{postAuthor}</Feed.User>
<Feed.Date className="post-summary-meta-date">
<TimeAgo date={timeAgo} />
</Feed.Date>
</>
)
: <Placeholder><Placeholder.Line length="medium" /></Placeholder>}
</Feed.Summary>
<Feed.Extra>
{postContent !== null
? postContent
: <Placeholder><Placeholder.Line length="long" /></Placeholder>}
</Feed.Extra>
</Feed.Content>
</Dimmer.Dimmable>
), [
authorAvatarLink, loading, postAuthor, postAuthorAddress, postContent, postIndexInTopic, t, timeAgo,
]);
};
PostListRow.defaultProps = {
loading: false,
};
PostListRow.propTypes = {
id: PropTypes.number.isRequired,
postIndexInTopic: PropTypes.number.isRequired,
postCallHash: PropTypes.string,
loading: PropTypes.bool,
};
export default memo(PostListRow);

9
packages/concordia-app/src/components/PostList/PostListRow/styles.css

@ -0,0 +1,9 @@
.post-profile-picture {
margin: 5px 0 0 0;
}
.post-summary-meta-index {
float: right;
font-size: 12px;
opacity: 0.4;
}

72
packages/concordia-app/src/components/PostList/index.jsx

@ -0,0 +1,72 @@
import React, {
useEffect, useMemo, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { Dimmer, Feed, Loader } from 'semantic-ui-react';
import PostListRow from './PostListRow';
import { drizzle } from '../../redux/store';
import { FORUM_CONTRACT } from '../../constants/contracts/ContractNames';
const { contracts: { [FORUM_CONTRACT]: { methods: { getPost: { cacheCall: getPostChainData } } } } } = drizzle;
const PostList = (props) => {
const { postIds, loading } = props;
const [getPostCallHashes, setGetPostCallHashes] = useState([]);
const drizzleInitialized = useSelector((state) => state.drizzleStatus.initialized);
const drizzleInitializationFailed = useSelector((state) => state.drizzleStatus.failed);
useEffect(() => {
if (drizzleInitialized && !drizzleInitializationFailed && !loading) {
const newPostsFound = postIds
.filter((postId) => !getPostCallHashes
.map((getPostCallHash) => getPostCallHash.id)
.includes(postId));
if (newPostsFound.length > 0) {
setGetPostCallHashes([
...getPostCallHashes,
...newPostsFound
.map((postId) => ({
id: postId,
hash: getPostChainData(postId),
})),
]);
}
}
}, [drizzleInitializationFailed, drizzleInitialized, getPostCallHashes, loading, postIds]);
const posts = useMemo(() => {
if (loading) {
return null;
}
return postIds
.map((postId, index) => {
const postHash = getPostCallHashes.find((getPostCallHash) => getPostCallHash.id === postId);
return (
<PostListRow
id={postId}
postIndexInTopic={index + 1}
key={postId}
postCallHash={postHash && postHash.hash}
loading={postHash === undefined}
/>
);
});
}, [getPostCallHashes, loading, postIds]);
return (
<Dimmer.Dimmable as={Feed} blurring dimmed={loading} id="post-list" size="large">
<Loader active={loading} />
{posts}
</Dimmer.Dimmable>
);
};
PostList.propTypes = {
postIds: PropTypes.arrayOf(PropTypes.number).isRequired,
loading: PropTypes.bool,
};
export default PostList;

3
packages/concordia-app/src/components/PostList/styles.css

@ -0,0 +1,3 @@
#post-list{
height: 100%;
}

194
packages/concordia-app/src/components/TopicList/TopicListRow/index.jsx

@ -1,64 +1,190 @@
import React, { useContext, useEffect, useState } from 'react';
import { List } from 'semantic-ui-react';
import React, {
memo, useEffect, useMemo, useState,
} from 'react';
import {
Dimmer, Grid, Image, List, Placeholder,
} from 'semantic-ui-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import TimeAgo from 'react-timeago';
import { useHistory } from 'react-router';
import { useDispatch, useSelector } from 'react-redux';
import AppContext from '../../AppContext';
import { Link } from 'react-router-dom';
import { FETCH_USER_DATABASE } from '../../../redux/actions/peerDbReplicationActions';
import { breeze } from '../../../redux/store';
import './styles.css';
import { TOPICS_DATABASE, USER_DATABASE } from '../../../constants/orbit/OrbitDatabases';
import determineKVAddress from '../../../utils/orbitUtils';
import { USER_PROFILE_PICTURE } from '../../../constants/orbit/UserDatabaseKeys';
import { TOPIC_SUBJECT } from '../../../constants/orbit/TopicsDatabaseKeys';
import { FORUM_CONTRACT } from '../../../constants/contracts/ContractNames';
const { orbit } = breeze;
const TopicListRow = (props) => {
const { topicData, topicId } = props;
const { breeze: { orbit } } = useContext(AppContext.Context);
const [topicSubject, setTopicSubject] = useState();
const { id: topicId, topicCallHash, loading } = props;
const getTopicResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getTopic);
const [numberOfReplies, setNumberOfReplies] = useState(null);
const [topicAuthorAddress, setTopicAuthorAddress] = useState(null);
const [topicAuthor, setTopicAuthor] = useState(null);
const [timeAgo, setTimeAgo] = useState(null);
const [topicSubject, setTopicSubject] = useState(null);
const [topicAuthorMeta, setTopicAuthorMeta] = useState(null);
const userAddress = useSelector((state) => state.user.address);
const topics = useSelector((state) => state.orbitData.topics);
const users = useSelector((state) => state.orbitData.users);
const dispatch = useDispatch();
const history = useHistory();
const { t } = useTranslation();
useEffect(() => {
if (userAddress !== topicData.userAddress) {
if (!loading && topicCallHash && getTopicResults[topicCallHash] !== undefined) {
setTopicAuthorAddress(getTopicResults[topicCallHash].value[0]);
setTopicAuthor(getTopicResults[topicCallHash].value[1]);
setTimeAgo(getTopicResults[topicCallHash].value[2] * 1000);
setNumberOfReplies(getTopicResults[topicCallHash].value[3].length);
}
}, [getTopicResults, loading, topicCallHash]);
useEffect(() => {
if (topicAuthorAddress && userAddress !== topicAuthorAddress) {
dispatch({
type: FETCH_USER_DATABASE,
orbit,
dbName: TOPICS_DATABASE,
userAddress: topicAuthorAddress,
});
dispatch({
type: FETCH_USER_DATABASE,
orbit,
userAddress: topicData.userAddress,
dbName: USER_DATABASE,
userAddress: topicAuthorAddress,
});
}
}, [dispatch, orbit, topicData.userAddress, topicId, userAddress]);
}, [dispatch, topicAuthorAddress, userAddress]);
useEffect(() => {
const topicFound = topics
.find((topic) => topic.id === topicId);
if (topicFound) {
setTopicSubject(topicFound);
setTopicSubject(topicFound[TOPIC_SUBJECT]);
}
}, [topicId, topics]);
return (
<>
<List.Header>
<List.Icon name="right triangle" />
{topicSubject && topicSubject.subject}
</List.Header>
<List.Content>
{topicData.username}
{topicData.numberOfReplies}
{' '}
replies
timestamp
</List.Content>
</>
);
useEffect(() => {
if (topicAuthorAddress !== null) {
determineKVAddress({ orbit, dbName: USER_DATABASE, userAddress: topicAuthorAddress })
.then((userOrbitAddress) => {
const userFound = users
.find((user) => user.id === userOrbitAddress);
if (userFound) {
setTopicAuthorMeta(userFound);
}
})
.catch((error) => {
console.error('Error during determination of key-value DB address:', error);
});
}
}, [topicAuthorAddress, users]);
const stopClickPropagation = (event) => {
event.stopPropagation();
};
const authorAvatar = useMemo(() => (topicAuthorMeta !== null && topicAuthorMeta[USER_PROFILE_PICTURE]
? (
<Image
className="profile-picture"
avatar
src={topicAuthorMeta[USER_PROFILE_PICTURE]}
/>
)
: (
<List.Icon
name="user circle"
size="big"
inverted
color="black"
verticalAlign="middle"
/>
)), [topicAuthorMeta]);
const authorAvatarLink = useMemo(() => {
if (topicAuthorAddress) {
return (
<Link to={`/users/${topicAuthorAddress}`} onClick={stopClickPropagation}>
{authorAvatar}
</Link>
);
}
return authorAvatar;
}, [authorAvatar, topicAuthorAddress]);
return useMemo(() => {
const handleTopicClick = () => {
history.push(`/topics/${topicId}`);
};
return (
<Dimmer.Dimmable as={List.Item} onClick={handleTopicClick} blurring dimmed={loading} className="list-item">
{authorAvatarLink}
<List.Content className="list-content">
<List.Header>
<Grid>
<Grid.Column floated="left" width={14}>
{topicSubject !== null
? topicSubject
: <Placeholder><Placeholder.Line length="very long" /></Placeholder>}
</Grid.Column>
<Grid.Column floated="right" width={2} textAlign="right">
<span className="topic-metadata">
{t('topic.list.row.topic.id', { id: topicId })}
</span>
</Grid.Column>
</Grid>
</List.Header>
<List.Description>
<Grid verticalAlign="middle">
<Grid.Column floated="left" width={14}>
{topicAuthor !== null && timeAgo !== null
? (
<div>
{t('topic.list.row.author', { author: topicAuthor })}
,&nbsp;
<TimeAgo date={timeAgo} />
</div>
)
: <Placeholder><Placeholder.Line length="long" /></Placeholder>}
</Grid.Column>
<Grid.Column floated="right" width={2} textAlign="right">
{numberOfReplies !== null
? (
<span className="topic-metadata">
{t('topic.list.row.number.of.replies', { numberOfReplies })}
</span>
)
: <Placeholder fluid><Placeholder.Line /></Placeholder>}
</Grid.Column>
</Grid>
</List.Description>
</List.Content>
</Dimmer.Dimmable>
);
}, [authorAvatarLink, history, loading, numberOfReplies, t, timeAgo, topicAuthor, topicId, topicSubject]);
};
const TopicData = PropTypes.PropTypes.shape({
userAddress: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
timestamp: PropTypes.number.isRequired,
numberOfReplies: PropTypes.number.isRequired,
});
TopicListRow.defaultProps = {
loading: false,
};
TopicListRow.propTypes = {
topicData: TopicData.isRequired,
topicId: PropTypes.number.isRequired,
id: PropTypes.number.isRequired,
topicCallHash: PropTypes.string,
loading: PropTypes.bool,
};
export default TopicListRow;
export default memo(TopicListRow);

21
packages/concordia-app/src/components/TopicList/TopicListRow/styles.css

@ -0,0 +1,21 @@
.topic-metadata {
font-size: 12px !important;
font-weight: initial;
}
.list-item {
display: flex !important;
text-align: start;
}
.profile-picture {
cursor: pointer;
max-width: 36px;
max-height: 36px;
margin: 0;
vertical-align: middle;
}
.list-content {
flex-grow: 1;
}

87
packages/concordia-app/src/components/TopicList/index.jsx

@ -1,85 +1,54 @@
import React, {
useCallback,
useContext, useEffect, useMemo, useState,
useEffect, useMemo, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { List } from 'semantic-ui-react';
import { useHistory } from 'react-router';
import AppContext from '../AppContext';
import TopicListRow from './TopicListRow';
import { PLACEHOLDER_TYPE_TOPIC } from '../../constants/PlaceholderTypes';
import Placeholder from '../Placeholder';
import './styles.css';
import { drizzle } from '../../redux/store';
import { FORUM_CONTRACT } from '../../constants/contracts/ContractNames';
const { contracts: { [FORUM_CONTRACT]: { methods: { getTopic: { cacheCall: getTopicChainData } } } } } = drizzle;
const TopicList = (props) => {
const { topicIds } = props;
const { drizzle: { contracts: { Forum: { methods: { getTopic } } } } } = useContext(AppContext.Context);
const [getTopicCallHashes, setGetTopicCallHashes] = useState([]);
const getTopicResults = useSelector((state) => state.contracts.Forum.getTopic);
const drizzleStatus = useSelector((state) => state.drizzleStatus);
const history = useHistory();
const drizzleInitialized = useSelector((state) => state.drizzleStatus.initialized);
const drizzleInitializationFailed = useSelector((state) => state.drizzleStatus.failed);
useEffect(() => {
// TODO: is the drizzleStatus check necessary?
if (drizzleStatus.initialized && !drizzleStatus.failed) {
const newTopicPosted = topicIds
.some((topicId) => !getTopicCallHashes
if (drizzleInitialized && !drizzleInitializationFailed) {
const newTopicsFound = topicIds
.filter((topicId) => !getTopicCallHashes
.map((getTopicCallHash) => getTopicCallHash.id)
.includes(topicId));
if (newTopicPosted) {
setGetTopicCallHashes(topicIds.map((topicId) => {
const foundGetTopicCallHash = getTopicCallHashes.find((getTopicCallHash) => getTopicCallHash.id === topicId);
if (foundGetTopicCallHash !== undefined) {
return ({ ...foundGetTopicCallHash });
}
return ({
id: topicId,
hash: getTopic.cacheCall(topicId),
});
}));
if (newTopicsFound.length > 0) {
setGetTopicCallHashes([
...getTopicCallHashes,
...newTopicsFound
.map((topicId) => ({
id: topicId,
hash: getTopicChainData(topicId),
})),
]);
}
}
}, [drizzleStatus.failed, drizzleStatus.initialized, getTopic, getTopicCallHashes, topicIds]);
const handleTopicClick = useCallback((topicId) => {
history.push(`/topics/${topicId}`);
}, [history]);
}, [drizzleInitializationFailed, drizzleInitialized, getTopicCallHashes, topicIds]);
const topics = useMemo(() => topicIds
.map((topicId) => {
const getTopicHash = getTopicCallHashes.find((getTopicCallHash) => getTopicCallHash.id === topicId);
if (getTopicHash && getTopicResults[getTopicHash.hash] !== undefined) {
const topicData = {
userAddress: getTopicResults[getTopicHash.hash].value[0],
username: getTopicResults[getTopicHash.hash].value[1],
timestamp: getTopicResults[getTopicHash.hash].value[2] * 1000,
numberOfReplies: getTopicResults[getTopicHash.hash].value[3].length,
};
return (
<List.Item key={topicId} className="list-item" name={topicId} onClick={() => handleTopicClick(topicId)}>
<TopicListRow
topicData={topicData}
topicId={topicId}
/>
</List.Item>
);
}
const topicHash = getTopicCallHashes.find((getTopicCallHash) => getTopicCallHash.id === topicId);
return (
<List.Item key={topicId} className="list-item" name={topicId} onClick={() => handleTopicClick(topicId)}>
<Placeholder
placeholderType={PLACEHOLDER_TYPE_TOPIC}
extra={{ topicId }}
/>
</List.Item>
<TopicListRow
id={topicId}
key={topicId}
topicCallHash={topicHash && topicHash.hash}
loading={topicHash === undefined}
/>
);
}), [getTopicCallHashes, getTopicResults, handleTopicClick, topicIds]);
}), [getTopicCallHashes, topicIds]);
return (
<List selection divided id="topic-list" size="big">

4
packages/concordia-app/src/components/TopicList/styles.css

@ -1,7 +1,3 @@
#topic-list{
height: 100%;
}
.list-item {
text-align: start;
}

104
packages/concordia-app/src/components/UsernameSelector.jsx

@ -0,0 +1,104 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import {
Form, Input,
} from 'semantic-ui-react';
import throttle from 'lodash/throttle';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { drizzle } from '../redux/store';
import { FORUM_CONTRACT } from '../constants/contracts/ContractNames';
const { contracts: { [FORUM_CONTRACT]: { methods: { isUserNameTaken } } } } = drizzle;
const UsernameSelector = (props) => {
const {
initialUsername, username, onChangeCallback, onErrorChangeCallback,
} = props;
const isUserNameTakenResults = useSelector((state) => state.contracts[FORUM_CONTRACT].isUserNameTaken);
const { t } = useTranslation();
useEffect(() => {
if (username.length > 0) {
const checkedUsernames = Object
.values(isUserNameTakenResults)
.map((callCompleted) => ({
checkedUsername: callCompleted.args[0],
isTaken: callCompleted.value,
}));
const checkedUsername = checkedUsernames
.find((callCompleted) => callCompleted.checkedUsername === username);
if (checkedUsername && checkedUsername.isTaken && username !== initialUsername) {
onErrorChangeCallback({
usernameChecked: true,
error: true,
errorMessage: t('username.selector.error.username.taken.message', { username }),
});
} else {
onErrorChangeCallback({
usernameChecked: true,
error: false,
errorMessage: null,
});
}
return;
}
// Username input is empty
if (initialUsername && initialUsername !== '') {
onErrorChangeCallback({
usernameChecked: true,
error: true,
errorMessage: t('username.selector.error.username.empty.message'),
});
} else {
onErrorChangeCallback({
usernameChecked: true,
error: false,
errorMessage: null,
});
}
}, [initialUsername, isUserNameTakenResults, onErrorChangeCallback, t, username, username.length]);
const checkUsernameTaken = useMemo(() => throttle(
(usernameToCheck) => {
isUserNameTaken.cacheCall(usernameToCheck);
}, 200,
), []);
const handleInputChange = useCallback((event, { value }) => {
onChangeCallback(value);
if (value.length > 0) {
checkUsernameTaken(value);
}
}, [checkUsernameTaken, onChangeCallback]);
return (
<Form.Field required>
<label htmlFor="form-field-username-selector">
{t('username.selector.username.field.label')}
</label>
<Input
id="form-field-username-selector"
placeholder={t('username.selector.username.field.placeholder')}
name="usernameInput"
className="form-input"
value={username}
onChange={handleInputChange}
/>
</Form.Field>
);
};
UsernameSelector.propTypes = {
initialUsername: PropTypes.string,
username: PropTypes.string.isRequired,
onChangeCallback: PropTypes.func.isRequired,
onErrorChangeCallback: PropTypes.func.isRequired,
};
export default UsernameSelector;

2
packages/concordia-app/src/constants/PlaceholderTypes.js

@ -1,2 +0,0 @@
export const PLACEHOLDER_TYPE_TOPIC = 'PLACEHOLDER_TYPE_TOPIC';
export const PLACEHOLDER_TYPE_POST = 'PLACEHOLDER_TYPE_POST';

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

2
packages/concordia-app/src/constants/RegisterSteps.js

@ -0,0 +1,2 @@
export const REGISTER_STEP_SIGNUP = 'signup';
export const REGISTER_STEP_PROFILE_INFORMATION = 'profile-information';

2
packages/concordia-app/src/constants/TransactionStatus.js

@ -0,0 +1,2 @@
export const TRANSACTION_SUCCESS = 'success';
export const TRANSACTION_ERROR = 'error';

7
packages/concordia-app/src/constants/configuration/defaults.js

@ -0,0 +1,7 @@
export const WEB3_HOST_DEFAULT = '127.0.0.1';
export const WEB3_PORT_DEFAULT = '8545';
export const WEB3_PORT_SOCKET_TIMEOUT_DEFAULT = 30000;
export const WEB3_PORT_SOCKET_CONNECT_MAX_ATTEMPTS_DEFAULT = 3;
export const REACT_APP_RENDEZVOUS_HOST_DEFAULT = '127.0.0.1';
export const REACT_APP_RENDEZVOUS_PORT_DEFAULT = '9090';

1
packages/concordia-app/src/constants/contracts/ContractNames.js

@ -0,0 +1 @@
export const FORUM_CONTRACT = 'Forum';

13
packages/concordia-app/src/constants/contracts/events/ForumContractEvents.js

@ -0,0 +1,13 @@
export const USER_SIGNED_UP_EVENT = 'UserSignedUp';
export const USERNAME_UPDATED_EVENT = 'UsernameUpdated';
export const TOPIC_CREATED_EVENT = 'TopicCreated';
export const POST_CREATED_EVENT = 'PostCreated';
const forumContractEvents = [
USER_SIGNED_UP_EVENT,
USERNAME_UPDATED_EVENT,
TOPIC_CREATED_EVENT,
POST_CREATED_EVENT,
];
export default forumContractEvents;

8
packages/concordia-app/src/constants/contracts/events/index.js

@ -0,0 +1,8 @@
import { FORUM_CONTRACT } from '../ContractNames';
import forumContractEvents from './ForumContractEvents';
const appEvents = {
[FORUM_CONTRACT]: forumContractEvents,
};
export default appEvents;

20
packages/concordia-app/src/constants/orbit/OrbitDatabases.js

@ -0,0 +1,20 @@
export const USER_DATABASE = 'user';
export const TOPICS_DATABASE = 'topics';
export const POSTS_DATABASE = 'posts';
const databases = [
{
address: USER_DATABASE,
type: 'keyvalue',
},
{
address: TOPICS_DATABASE,
type: 'keyvalue',
},
{
address: POSTS_DATABASE,
type: 'keyvalue',
},
];
export default databases;

7
packages/concordia-app/src/constants/orbit/PostsDatabaseKeys.js

@ -0,0 +1,7 @@
export const POST_CONTENT = 'content';
const postsDatabaseKeys = [
POST_CONTENT,
];
export default postsDatabaseKeys;

7
packages/concordia-app/src/constants/orbit/TopicsDatabaseKeys.js

@ -0,0 +1,7 @@
export const TOPIC_SUBJECT = 'subject';
const topicsDatabaseKeys = [
TOPIC_SUBJECT,
];
export default topicsDatabaseKeys;

9
packages/concordia-app/src/constants/orbit/UserDatabaseKeys.js

@ -0,0 +1,9 @@
export const USER_PROFILE_PICTURE = 'profile_picture';
export const USER_LOCATION = 'location';
const userDatabaseKeys = [
USER_PROFILE_PICTURE,
USER_LOCATION,
];
export default userDatabaseKeys;

14
packages/concordia-app/src/index.jsx

@ -1,24 +1,16 @@
import './utils/indexedDB/patchIndexedDB';
import './utils/wdyr';
import React, { Suspense } from 'react';
import { render } from 'react-dom';
import { Drizzle } from '@ezerous/drizzle';
import { Breeze } from '@ezerous/breeze';
import App from './App';
import store from './redux/store';
import AppContext from './components/AppContext';
import drizzleOptions from './options/drizzleOptions';
import breezeOptions from './options/breezeOptions';
import * as serviceWorker from './utils/serviceWorker';
import LoadingScreen from './components/LoadingScreen';
const drizzle = new Drizzle(drizzleOptions, store);
const breeze = new Breeze(breezeOptions, store);
import './assets/css/index.css';
render(
<Suspense fallback={<LoadingScreen />}>
<AppContext.Provider drizzle={drizzle} breeze={breeze}>
<App store={store} />
</AppContext.Provider>
<App store={store} />
</Suspense>,
document.getElementById('root'),
);

38
packages/concordia-app/src/layouts/MainLayout/MainLayoutMenu/index.jsx

@ -1,16 +1,32 @@
import React from 'react';
import { Menu } from 'semantic-ui-react';
import React, { useState } from 'react';
import { Dropdown, Menu } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router';
import { useSelector } from 'react-redux';
import AppContext from '../../../components/AppContext';
import appLogo from '../../../assets/images/app_logo.png';
import ClearDatabasesModal from '../../../components/ClearDatabasesModal';
const MainLayoutMenu = () => {
const hasSignedUp = useSelector((state) => state.user.hasSignedUp);
const [isClearDatabasesOpen, setIsClearDatabasesOpen] = useState(false);
const history = useHistory();
const { t } = useTranslation();
const handleClearDatabasesClick = () => {
setIsClearDatabasesOpen(true);
};
const handleDatabasesCleared = () => {
setIsClearDatabasesOpen(false);
history.push('/home');
window.location.reload(false);
};
const handleCancelDatabasesClear = () => {
setIsClearDatabasesOpen(false);
};
return (
<AppContext.Consumer>
{() => (
@ -57,6 +73,24 @@ const MainLayoutMenu = () => {
</Menu.Item>
)}
</Menu.Menu>
<Dropdown key="overflow" item direction="left">
<Dropdown.Menu>
<Dropdown.Item
link
name="clear-databases"
key="clear-databases"
onClick={handleClearDatabasesClick}
>
{t('topbar.button.clear.databases')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<ClearDatabasesModal
open={isClearDatabasesOpen}
onDatabasesCleared={handleDatabasesCleared}
onCancel={handleCancelDatabasesClear}
/>
</Menu>
)}
</AppContext.Consumer>

2
packages/concordia-app/src/layouts/MainLayout/index.jsx

@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import MainLayoutMenu from './MainLayoutMenu';
import './styles.css';
const MainLayout = (props) => {
const { children } = props;

3
packages/concordia-app/src/layouts/MainLayout/styles.css

@ -0,0 +1,3 @@
#main-layout {
height: 100%;
}

22
packages/concordia-app/src/options/breezeOptions.js

@ -1,13 +1,22 @@
import { EthereumContractIdentityProvider } from '@ezerous/eth-identity-provider';
import databases from '../constants/orbit/OrbitDatabases';
import {
REACT_APP_RENDEZVOUS_HOST_DEFAULT,
REACT_APP_RENDEZVOUS_PORT_DEFAULT,
} from '../constants/configuration/defaults';
const REACT_APP_RENDEZVOUS_HOST = process.env.REACT_APP_RENDEZVOUS_HOST || REACT_APP_RENDEZVOUS_HOST_DEFAULT;
const REACT_APP_RENDEZVOUS_PORT = process.env.REACT_APP_RENDEZVOUS_PORT || REACT_APP_RENDEZVOUS_PORT_DEFAULT;
const breezeOptions = {
ipfs: {
repo: 'concordia',
config: {
Addresses: {
Swarm: [
// Use local signaling server (see also rendezvous script in package.json)
// For more information: https://github.com/libp2p/js-libp2p-webrtc-star
'/ip4/127.0.0.1/tcp/9090/wss/p2p-webrtc-star',
`/ip4/${REACT_APP_RENDEZVOUS_HOST}/tcp/${REACT_APP_RENDEZVOUS_PORT}/wss/p2p-webrtc-star`,
// Use the following public servers if needed
// '/dns4/wrtc-star1.par.dwebops.pub/tcp/443/wss/p2p-webrtc-star',
@ -24,16 +33,7 @@ const breezeOptions = {
},
orbit: {
identityProvider: EthereumContractIdentityProvider,
databases: [
{
address: 'topics',
type: 'keyvalue',
},
{
address: 'posts',
type: 'keyvalue',
},
],
databases,
},
};

13
packages/concordia-app/src/options/drizzleOptions.js

@ -1,17 +1,12 @@
// See also: https://truffleframework.com/docs/drizzle/reference/drizzle-options
// Check out the documentation: https://truffleframework.com/docs/drizzle/reference/drizzle-options
import { contracts } from 'concordia-contracts';
import web3Options from './web3Options';
import appEvents from '../constants/contracts/events';
const drizzleOptions = {
web3: {
customProvider: web3Options.web3,
},
web3: web3Options,
contracts,
events: {
Forum: ['UserSignedUp', 'UsernameUpdated', 'TopicCreated', 'PostCreated'],
Voting: ['PollCreated', 'UserVotedPoll'],
PostVoting: ['UserVotedPost'],
},
events: { ...appEvents },
reloadWindowOnNetworkChange: true,
reloadWindowOnAccountChange: true, // We need it to reinitialize breeze and create new Orbit databases
};

24
packages/concordia-app/src/options/web3Options.js

@ -1,11 +1,29 @@
import Web3 from 'web3';
import {
WEB3_HOST_DEFAULT,
WEB3_PORT_DEFAULT,
WEB3_PORT_SOCKET_CONNECT_MAX_ATTEMPTS_DEFAULT,
WEB3_PORT_SOCKET_TIMEOUT_DEFAULT,
} from '../constants/configuration/defaults';
const { WEB3_URL, WEB3_PORT } = process.env;
const { WEB3_HOST, WEB3_PORT, WEBSOCKET_TIMEOUT } = process.env;
const web3 = new Web3(Web3.givenProvider || `ws://${WEB3_URL}:${WEB3_PORT}`);
const web3WebsocketOptions = {
keepAlive: true,
timeout: WEBSOCKET_TIMEOUT !== undefined ? WEBSOCKET_TIMEOUT : WEB3_PORT_SOCKET_TIMEOUT_DEFAULT,
reconnect: {
maxAttempts: WEB3_PORT_SOCKET_CONNECT_MAX_ATTEMPTS_DEFAULT,
},
};
const web3 = (WEB3_HOST !== undefined && WEB3_PORT !== undefined)
? new Web3.providers.WebsocketProvider(`ws://${WEB3_HOST}:${WEB3_PORT}`)
: new Web3(Web3.givenProvider || new Web3.providers.WebsocketProvider(
`ws://${WEB3_HOST_DEFAULT}:${WEB3_PORT_DEFAULT}`, web3WebsocketOptions,
));
const web3Options = {
web3,
customProvider: web3,
};
export default web3Options;

20
packages/concordia-app/src/redux/actions/contractEventActions.js

@ -0,0 +1,20 @@
import {
POST_CREATED_EVENT,
TOPIC_CREATED_EVENT,
USER_SIGNED_UP_EVENT,
USERNAME_UPDATED_EVENT,
} from '../../constants/contracts/events/ForumContractEvents';
export const FORUM_EVENT_USER_SIGNED_UP = 'FORUM_EVENT_USER_SIGNED_UP';
export const FORUM_EVENT_USERNAME_UPDATED = 'FORUM_EVENT_USERNAME_UPDATED';
export const FORUM_EVENT_TOPIC_CREATED = 'FORUM_EVENT_TOPIC_CREATED';
export const FORUM_EVENT_POST_CREATED = 'FORUM_EVENT_POST_CREATED';
const eventActionMap = {
[USER_SIGNED_UP_EVENT]: FORUM_EVENT_USER_SIGNED_UP,
[USERNAME_UPDATED_EVENT]: FORUM_EVENT_USERNAME_UPDATED,
[TOPIC_CREATED_EVENT]: FORUM_EVENT_TOPIC_CREATED,
[POST_CREATED_EVENT]: FORUM_EVENT_POST_CREATED,
};
export default eventActionMap;

7
packages/concordia-app/src/redux/reducers/peerDbReplicationReducer.js

@ -1,7 +1,7 @@
import { UPDATE_ORBIT_DATA } from '../actions/peerDbReplicationActions';
const initialState = {
fetchedPeerDatabases: [],
users: [],
topics: [],
posts: [],
};
@ -10,10 +10,13 @@ const peerDbReplicationReducer = (state = initialState, action) => {
const { type } = action;
if (type === UPDATE_ORBIT_DATA) {
const { topics, posts } = action;
const { users, topics, posts } = action;
return {
...state,
users: [
...users,
],
topics: [
...topics,
],

13
packages/concordia-app/src/redux/sagas/eventSaga.js

@ -0,0 +1,13 @@
import { put, takeEvery } from 'redux-saga/effects';
import { CONTRACT_EVENT_FIRED } from '@ezerous/drizzle/src/contracts/constants';
import eventActionMap from '../actions/contractEventActions';
function* eventBreakDown({ event }) {
yield put({ type: eventActionMap[event.event], event: { ...event } });
}
function* eventSaga() {
yield takeEvery(CONTRACT_EVENT_FIRED, eventBreakDown);
}
export default eventSaga;

3
packages/concordia-app/src/redux/sagas/orbitSaga.js

@ -10,7 +10,8 @@ import { EthereumContractIdentityProvider } from '@ezerous/eth-identity-provider
function* initOrbitDatabases(action) {
const { account, breeze } = action;
yield put(breezeActions.orbit.orbitInit(breeze, account + EthereumContractIdentityProvider.contractAddress)); // same as breeze.initOrbit(account);
// same as breeze.initOrbit(account);
yield put(breezeActions.orbit.orbitInit(breeze, account + EthereumContractIdentityProvider.contractAddress));
}
function* orbitSaga() {

49
packages/concordia-app/src/redux/sagas/peerDbReplicationSaga.js

@ -9,22 +9,54 @@ import {
} from '@ezerous/breeze/src/orbit/orbitActions';
import determineKVAddress from '../../utils/orbitUtils';
import { FETCH_USER_DATABASE, UPDATE_ORBIT_DATA } from '../actions/peerDbReplicationActions';
import { POSTS_DATABASE, TOPICS_DATABASE, USER_DATABASE } from '../../constants/orbit/OrbitDatabases';
import userDatabaseKeys from '../../constants/orbit/UserDatabaseKeys';
import { TOPIC_SUBJECT } from '../../constants/orbit/TopicsDatabaseKeys';
import { POST_CONTENT } from '../../constants/orbit/PostsDatabaseKeys';
function* fetchUserDb({ orbit, userAddress }) {
function* fetchUserDb({ orbit, userAddress, dbName }) {
const peerDbAddress = yield call(determineKVAddress, {
orbit, dbName: 'topics', userAddress,
orbit, dbName, userAddress,
});
yield put(addOrbitDB({ address: peerDbAddress, type: 'keyvalue' }));
}
function* updateReduxState({ database }) {
const { topics, posts } = yield select((state) => ({
const { users, topics, posts } = yield select((state) => ({
users: state.orbitData.users,
topics: state.orbitData.topics,
posts: state.orbitData.posts,
}));
if (database.dbname === 'topics') {
if (database.dbname === USER_DATABASE) {
const oldUsersUnchanged = users
.filter((user) => database.id !== user.id);
yield put({
type: UPDATE_ORBIT_DATA,
users: [
...oldUsersUnchanged,
{
id: database.id,
// Don't ask how.. it just works
...Object
.entries(database.all)
.filter(([key]) => userDatabaseKeys.includes(key))
.reduce(((acc, keyValue) => {
const [key, value] = keyValue;
acc[key] = value;
return acc;
}), {}),
},
],
topics: [...topics],
posts: [...posts],
});
}
if (database.dbname === TOPICS_DATABASE) {
const oldTopicsUnchanged = topics
.filter((topic) => !Object
.keys(database.all)
@ -33,20 +65,21 @@ function* updateReduxState({ database }) {
yield put({
type: UPDATE_ORBIT_DATA,
users: [...users],
topics: [
...oldTopicsUnchanged,
...Object
.entries(database.all)
.map(([key, value]) => ({
id: parseInt(key, 10),
subject: value.subject,
[TOPIC_SUBJECT]: value[TOPIC_SUBJECT],
})),
],
posts: [...posts],
});
}
if (database.dbname === 'posts') {
if (database.dbname === POSTS_DATABASE) {
const oldPostsUnchanged = posts
.filter((post) => !Object
.keys(database.all)
@ -55,13 +88,13 @@ function* updateReduxState({ database }) {
yield put({
type: UPDATE_ORBIT_DATA,
users: [...users],
topics: [...topics],
posts: [
...oldPostsUnchanged,
...Object.entries(database.all).map(([key, value]) => ({
id: parseInt(key, 10),
subject: value.subject,
message: value.message,
[POST_CONTENT]: value[POST_CONTENT],
})),
],
});

2
packages/concordia-app/src/redux/sagas/rootSaga.js

@ -4,11 +4,13 @@ import { breezeSagas } from '@ezerous/breeze';
import orbitSaga from './orbitSaga';
import userSaga from './userSaga';
import peerDbReplicationSaga from './peerDbReplicationSaga';
import eventSaga from './eventSaga';
export default function* root() {
const sagas = [
...drizzleSagas,
...breezeSagas,
eventSaga,
orbitSaga,
userSaga,
peerDbReplicationSaga,

18
packages/concordia-app/src/redux/sagas/userSaga.js

@ -1,13 +1,14 @@
/* eslint-disable no-console */
import {
all, call, put, take,
all, call, put, take, takeLatest,
} from 'redux-saga/effects';
import { drizzleActions } from '@ezerous/drizzle';
import { USER_DATA_UPDATED, USER_DATA_ERROR } from '../actions/userActions';
import { FORUM_EVENT_USER_SIGNED_UP } from '../actions/contractEventActions';
import { FORUM_CONTRACT } from '../../constants/contracts/ContractNames';
function* fetchUserData({ drizzle, account }) {
const contract = drizzle.contracts.Forum;
const contract = drizzle.contracts[FORUM_CONTRACT];
const transaction = yield call(contract.methods.hasUserSignedUp, account);
try {
@ -31,6 +32,15 @@ function* fetchUserData({ drizzle, account }) {
}
}
function* userHasSignedUp({ event }) {
yield put({
type: USER_DATA_UPDATED,
...{
address: event.returnValues.userAddress, username: event.returnValues.username,
},
});
}
function* userSaga() {
const res = yield all([
take(drizzleActions.drizzle.DRIZZLE_INITIALIZED),
@ -38,6 +48,8 @@ function* userSaga() {
]);
yield fetchUserData({ drizzle: res[0].drizzle, account: res[1].accounts[0] });
yield takeLatest(FORUM_EVENT_USER_SIGNED_UP, userHasSignedUp);
}
export default userSaga;

10
packages/concordia-app/src/redux/store.js

@ -1,11 +1,14 @@
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit';
import { drizzleReducers, drizzleMiddlewares, generateContractsInitialState } from '@ezerous/drizzle';
import { breezeReducers } from '@ezerous/breeze';
import {
drizzleReducers, drizzleMiddlewares, generateContractsInitialState, Drizzle,
} from '@ezerous/drizzle';
import { Breeze, breezeReducers } from '@ezerous/breeze';
import createSagaMiddleware from 'redux-saga';
import userReducer from './reducers/userReducer';
import rootSaga from './sagas/rootSaga';
import drizzleOptions from '../options/drizzleOptions';
import peerDbReplicationReducer from './reducers/peerDbReplicationReducer';
import breezeOptions from '../options/breezeOptions';
const initialState = {
contracts: generateContractsInitialState(drizzleOptions),
@ -24,5 +27,8 @@ const store = configureStore({
preloadedState: initialState,
});
export const drizzle = new Drizzle(drizzleOptions, store);
export const breeze = new Breeze(breezeOptions, store);
sagaMiddleware.run(rootSaga);
export default store;

22
packages/concordia-app/src/utils/indexedDB/indexedDBUtils.js

@ -0,0 +1,22 @@
import { breeze } from '../../redux/store';
const purgeIndexedDBs = async () => {
const { ipfs, orbit } = breeze;
if (orbit) await orbit.stop();
if (ipfs) await ipfs.stop();
const databases = await indexedDB.databases();
return Promise.all(
databases.map((db) => new Promise(
(resolve, reject) => {
const request = indexedDB.deleteDatabase(db.name);
request.onblocked = resolve;
request.onsuccess = resolve;
request.onerror = reject;
},
)),
);
};
export default purgeIndexedDBs;

46
packages/concordia-app/src/utils/indexedDB/patchIndexedDB.js

@ -0,0 +1,46 @@
/* Patches browsers that do not yet support indexedDB.databases()
(https://developer.mozilla.org/en-US/docs/Web/API/IDBFactory/databases)
See also https://gist.github.com/rmehner/b9a41d9f659c9b1c3340#gistcomment-3449418) */
if (window.indexedDB && typeof window.indexedDB.databases === 'undefined') {
const LOCALSTORAGE_CACHE_KEY = 'indexedDBDatabases';
// Store a key value map of databases
const getFromStorage = () => JSON.parse(window.localStorage[LOCALSTORAGE_CACHE_KEY] || '{}');
// Write the database to local storage
const writeToStorage = (value) => { window.localStorage[LOCALSTORAGE_CACHE_KEY] = JSON.stringify(value); };
IDBFactory.prototype.databases = () => Promise.resolve(
Object.entries(getFromStorage()).reduce((acc, [name, version]) => {
acc.push({ name, version });
return acc;
}, []),
);
// Intercept the existing open handler to write our DBs names
// and versions to localStorage
const { open } = IDBFactory.prototype;
// eslint-disable-next-line func-names
IDBFactory.prototype.open = function (...args) {
const dbName = args[0];
const version = args[1] || 1;
const existing = getFromStorage();
writeToStorage({ ...existing, [dbName]: version });
return open.apply(this, args);
};
// Intercept the existing deleteDatabase handler remove our
// dbNames from localStorage
const { deleteDatabase } = IDBFactory.prototype;
// eslint-disable-next-line func-names
IDBFactory.prototype.deleteDatabase = function (...args) {
const dbName = args[0];
const existing = getFromStorage();
delete existing[dbName];
writeToStorage(existing);
return deleteDatabase.apply(this, args);
};
console.debug('IndexedDB patched successfully!');
}

71
packages/concordia-app/src/utils/styles.debug.css

@ -0,0 +1,71 @@
* {
outline: 2px dotted red
}
* * {
outline: 2px dotted green
}
* * * {
outline: 2px dotted orange
}
* * * * {
outline: 2px dotted blue
}
* * * * * {
outline: 1px solid red
}
* * * * * * {
outline: 1px solid green
}
* * * * * * * {
outline: 1px solid orange
}
* * * * * * * * {
outline: 1px solid blue
}
/* Solid Green */
* *:hover {
border: 2px solid #89A81E
}
/* Solid Orange */
* * *:hover {
border: 2px solid #F34607
}
/* Solid Blue */
* * * *:hover {
border: 2px solid #5984C3
}
/* Solid Red */
* * * * *:hover {
border: 2px solid #CD1821
}
/* Dotted Green */
* * * * * *:hover {
border: 2px dotted #89A81E
}
/* Dotted Orange */
* * * * * * *:hover {
border: 2px dotted #F34607
}
/* Dotted Blue */
* * * * * * * *:hover {
border: 2px dotted #5984C3
}
/* Dotted Red */
* * * * * * * * *:hover {
border: 2px dotted #CD1821
}

12
packages/concordia-app/src/utils/urlUtils.js

@ -0,0 +1,12 @@
const checkUrlValid = (url) => {
const pattern = new RegExp('^(https?:\\/\\/)?' // protocol
+ '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' // domain name
+ '((\\d{1,3}\\.){3}\\d{1,3}))' // OR ip (v4) address
+ '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' // port and path
+ '(\\?[;&a-z\\d%_.~+=-]*)?' // query string
+ '(\\#[-a-z\\d_]*)?$', 'i'); // fragment locator
return !!pattern.test(url);
};
export default checkUrlValid;

18
packages/concordia-app/src/views/Home/index.jsx

@ -1,31 +1,33 @@
import React, {
useContext, useEffect, useMemo, useState,
memo, useEffect, useMemo, useState,
} from 'react';
import { Container } from 'semantic-ui-react';
import { useSelector } from 'react-redux';
import AppContext from '../../components/AppContext';
import Board from './Board';
import './styles.css';
import { drizzle } from '../../redux/store';
import { FORUM_CONTRACT } from '../../constants/contracts/ContractNames';
const { contracts: { [FORUM_CONTRACT]: { methods: { getNumberOfTopics } } } } = drizzle;
const Home = () => {
const { drizzle: { contracts: { Forum: { methods: { getNumberOfTopics } } } } } = useContext(AppContext.Context);
const [numberOfTopicsCallHash, setNumberOfTopicsCallHash] = useState('');
const getNumberOfTopicsResults = useSelector((state) => state.contracts.Forum.getNumberOfTopics);
const getNumberOfTopicsResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getNumberOfTopics);
useEffect(() => {
setNumberOfTopicsCallHash(getNumberOfTopics.cacheCall());
}, [getNumberOfTopics]);
}, []);
const numberOfTopics = useMemo(() => (getNumberOfTopicsResults[numberOfTopicsCallHash] !== undefined
? parseInt(getNumberOfTopicsResults[numberOfTopicsCallHash].value, 10)
: null),
[getNumberOfTopicsResults, numberOfTopicsCallHash]);
return (
return useMemo(() => (
<Container id="home-container" textAlign="center">
{numberOfTopics !== null && <Board numberOfTopics={numberOfTopics} />}
</Container>
);
), [numberOfTopics]);
};
export default Home;
export default memo(Home);

235
packages/concordia-app/src/views/Profile/GeneralTab/EditInformationModal/index.jsx

@ -0,0 +1,235 @@
import React, {
useCallback, useEffect, useMemo, useState,
} from 'react';
import {
Button, Form, Icon, Image, Input, Message, Modal,
} from 'semantic-ui-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import checkUrlValid from '../../../../utils/urlUtils';
import { USER_LOCATION, USER_PROFILE_PICTURE } from '../../../../constants/orbit/UserDatabaseKeys';
import { USER_DATABASE } from '../../../../constants/orbit/OrbitDatabases';
import { breeze, drizzle } from '../../../../redux/store';
import UsernameSelector from '../../../../components/UsernameSelector';
import { FORUM_CONTRACT } from '../../../../constants/contracts/ContractNames';
const { orbit: { stores } } = breeze;
const { contracts: { [FORUM_CONTRACT]: { methods: { updateUsername } } } } = drizzle;
const EditInformationModal = (props) => {
const {
initialUsername, initialAuthorAvatar, initialUserLocation, open, onSubmit, onCancel,
} = props;
const [usernameInput, setUsernameInput] = useState(initialUsername);
const [usernameChecked, setUsernameChecked] = useState(true);
const [profilePictureInput, setProfilePictureInput] = useState('');
const [profilePictureUrlValid, setProfilePictureUrlValid] = useState(true);
const [locationInput, setLocationInput] = useState('');
const [error, setError] = useState(false);
const [errorMessages, setErrorMessages] = useState([]);
const [usernameError, setUsernameError] = useState(false);
const [usernameErrorMessage, setUsernameErrorMessage] = useState('');
const { t } = useTranslation();
useEffect(() => {
setLocationInput(initialUserLocation || '');
}, [initialUserLocation]);
useEffect(() => {
setProfilePictureInput(initialAuthorAvatar || '');
setProfilePictureUrlValid(initialAuthorAvatar ? checkUrlValid(initialAuthorAvatar) : true);
}, [initialAuthorAvatar]);
useEffect(() => {
let formHasError = false;
const formErrors = [];
if (!profilePictureUrlValid) {
formHasError = true;
formErrors.push(t('edit.information.modal.form.error.invalid.profile.picture.url.message'));
}
setError(formHasError);
setErrorMessages(formErrors);
}, [profilePictureUrlValid, t]);
const handleUsernameChange = (modifiedUsername) => {
setUsernameInput(modifiedUsername);
};
const handleUsernameErrorChange = useCallback(({
usernameChecked: isUsernameChecked,
error: hasUsernameError,
errorMessage,
}) => {
setUsernameChecked(isUsernameChecked);
if (hasUsernameError) {
setUsernameError(true);
setUsernameErrorMessage(errorMessage);
} else {
setUsernameError(false);
}
}, []);
const handleInputChange = useCallback((event, { name, value }) => {
if (name === 'profilePictureInput') {
setProfilePictureInput(value);
if (value.length > 0) {
setProfilePictureUrlValid(checkUrlValid(value));
} else {
setProfilePictureUrlValid(true);
}
}
if (name === 'locationInput') {
setLocationInput(value);
}
}, []);
const profilePicture = useMemo(() => (profilePictureInput.length > 0 && profilePictureUrlValid
? (<Image size="medium" src={profilePictureInput} wrapped />)
: (<Icon name="user circle" size="massive" inverted color="black" />)
), [profilePictureInput, profilePictureUrlValid]);
const handleSubmit = useCallback(() => {
const keyValuesToStore = [];
keyValuesToStore.push({
key: USER_PROFILE_PICTURE,
value: profilePictureInput,
});
keyValuesToStore.push({
key: USER_LOCATION,
value: locationInput,
});
const userDb = Object.values(stores).find((store) => store.dbname === USER_DATABASE);
const promiseArray = keyValuesToStore
.map((keyValueToStore) => {
if (keyValueToStore.value !== '') {
return userDb
.put(keyValueToStore.key, keyValueToStore.value, { pin: true });
}
return userDb.del(keyValueToStore.key);
});
Promise
.all(promiseArray)
.then(() => {
// TODO: display a message
})
.catch((reason) => {
console.log(reason);
});
if (usernameInput !== initialUsername) {
updateUsername.cacheSend(usernameInput);
}
onSubmit();
}, [initialUsername, locationInput, onSubmit, profilePictureInput, usernameInput]);
return useMemo(() => (
<Modal
onClose={onCancel}
open={open}
>
<Modal.Header>{t('edit.information.modal.title')}</Modal.Header>
<Modal.Content image>
{profilePicture}
<Modal.Description>
<Form>
<UsernameSelector
initialUsername={initialUsername}
username={usernameInput}
onChangeCallback={handleUsernameChange}
onErrorChangeCallback={handleUsernameErrorChange}
/>
<Form.Field>
<label htmlFor="form-edit-information-field-profile-picture">
{t('edit.information.modal.form.profile.picture.field.label')}
</label>
<Input
id="form-edit-information-field-profile-picture"
placeholder={t('edit.information.modal.form.profile.picture.field.placeholder')}
name="profilePictureInput"
className="form-input"
value={profilePictureInput}
onChange={handleInputChange}
/>
</Form.Field>
<Form.Field>
<label htmlFor="form-edit-information-field-location">
{t('edit.information.modal.form.location.field.label')}
</label>
<Input
id="form-edit-information-field-location"
placeholder={t('edit.information.modal.form.location.field.placeholder')}
name="locationInput"
className="form-input"
value={locationInput}
onChange={handleInputChange}
/>
</Form.Field>
</Form>
{error === true && (
errorMessages
.map((errorMessage) => (
<Message
error
header={t('edit.information.modal.form.error.message.header')}
content={errorMessage}
/>
))
)}
{usernameError === true && (
<Message
error
header={t('edit.information.modal.form.error.message.header')}
content={usernameErrorMessage}
/>
)}
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button color="black" onClick={onCancel}>
{t('edit.information.modal.form.cancel.button')}
</Button>
<Button
content={t('edit.information.modal.form.submit.button')}
labelPosition="right"
icon="checkmark"
onClick={handleSubmit}
positive
loading={!usernameChecked}
disabled={!usernameChecked || error || usernameError}
/>
</Modal.Actions>
</Modal>
), [
error, errorMessages, handleInputChange, handleSubmit, handleUsernameErrorChange, initialUsername, locationInput,
onCancel, open, profilePicture, profilePictureInput, t, usernameChecked, usernameError, usernameErrorMessage,
usernameInput,
]);
};
EditInformationModal.defaultProps = {
open: false,
};
EditInformationModal.propTypes = {
profileAddress: PropTypes.string.isRequired,
initialUsername: PropTypes.string.isRequired,
initialAuthorAvatar: PropTypes.string,
initialUserLocation: PropTypes.string,
open: PropTypes.bool,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
};
export default EditInformationModal;

223
packages/concordia-app/src/views/Profile/GeneralTab/index.jsx

@ -0,0 +1,223 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button, Icon, Image, Placeholder, Table,
} from 'semantic-ui-react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import determineKVAddress from '../../../utils/orbitUtils';
import databases, { USER_DATABASE } from '../../../constants/orbit/OrbitDatabases';
import { FETCH_USER_DATABASE } from '../../../redux/actions/peerDbReplicationActions';
import { breeze } from '../../../redux/store';
import { USER_LOCATION, USER_PROFILE_PICTURE } from '../../../constants/orbit/UserDatabaseKeys';
import './styles.css';
import EditInformationModal from './EditInformationModal';
const { orbit } = breeze;
const GeneralTab = (props) => {
const {
profileAddress, username, numberOfTopics, numberOfPosts, userRegistrationTimestamp, isSelf,
} = props;
const [userInfoOrbitAddress, setUserInfoOrbitAddress] = useState(null);
const [userTopicsOrbitAddress, setUserTopicsOrbitAddress] = useState(null);
const [userPostsOrbitAddress, setUserPostsOrbitAddress] = useState(null);
const [profileMetadataFetched, setProfileMetadataFetched] = useState(false);
const [userAvatarUrl, setUserAvatarUrl] = useState(null);
const [userLocation, setUserLocation] = useState(null);
const [editingProfileInformation, setEditingProfileInformation] = useState(false);
const users = useSelector((state) => state.orbitData.users);
const dispatch = useDispatch();
const { t } = useTranslation();
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) {
setProfileMetadataFetched(true);
setUserAvatarUrl(userFound[USER_PROFILE_PICTURE]);
setUserLocation(userFound[USER_LOCATION]);
} 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(() => (profileMetadataFetched && userAvatarUrl
? (
<Image
className="general-tab-profile-picture"
centered
size="tiny"
src={userAvatarUrl}
/>
)
: (
<Icon
name="user circle"
size="massive"
inverted
color="black"
/>
)), [profileMetadataFetched, userAvatarUrl]);
const userLocationCell = useMemo(() => {
if (!profileMetadataFetched) {
return (
<Placeholder><Placeholder.Line length="medium" /></Placeholder>
);
}
if (!userLocation) {
return <span className="text-secondary">{t('profile.general.tab.location.row.not.set')}</span>;
}
return userLocation;
}, [profileMetadataFetched, t, userLocation]);
const handleEditInfoClick = () => {
setEditingProfileInformation(true);
};
const closeEditInformationModal = () => {
setEditingProfileInformation(false);
};
const editInformationModal = useMemo(() => profileMetadataFetched && (
<EditInformationModal
profileAddress={profileAddress}
initialUsername={username}
initialAuthorAvatar={userAvatarUrl}
initialUserLocation={userLocation}
open={editingProfileInformation}
onCancel={closeEditInformationModal}
onSubmit={closeEditInformationModal}
/>
), [editingProfileInformation, profileAddress, profileMetadataFetched, userAvatarUrl, userLocation, username]);
return useMemo(() => (
<>
<Table basic="very" singleLine>
<Table.Body>
<Table.Row textAlign="center">
<Table.Cell colSpan="3">{authorAvatar}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell><strong>{t('profile.general.tab.username.row.title')}</strong></Table.Cell>
<Table.Cell>{username}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell><strong>{t('profile.general.tab.address.row.title')}</strong></Table.Cell>
<Table.Cell>{profileAddress}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell><strong>{t('profile.general.tab.user.db.address.row.title')}</strong></Table.Cell>
<Table.Cell>
{userInfoOrbitAddress || (<Placeholder><Placeholder.Line /></Placeholder>)}
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell><strong>{t('profile.general.tab.topics.db.address.row.title')}</strong></Table.Cell>
<Table.Cell>
{userTopicsOrbitAddress || (<Placeholder><Placeholder.Line /></Placeholder>)}
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell><strong>{t('profile.general.tab.posts.db.address.row.title')}</strong></Table.Cell>
<Table.Cell>
{userPostsOrbitAddress || (<Placeholder><Placeholder.Line /></Placeholder>)}
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell><strong>{t('profile.general.tab.number.of.topics.row.title')}</strong></Table.Cell>
<Table.Cell>
{numberOfTopics}
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell><strong>{t('profile.general.tab.number.of.posts.row.title')}</strong></Table.Cell>
<Table.Cell>
{numberOfPosts}
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell><strong>{t('profile.general.tab.location.row.title')}</strong></Table.Cell>
<Table.Cell>
{userLocationCell}
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell><strong>{t('profile.general.tab.registration.date.row.title')}</strong></Table.Cell>
<Table.Cell>
{new Date(userRegistrationTimestamp * 1000).toLocaleString()}
</Table.Cell>
</Table.Row>
</Table.Body>
{isSelf && (
<Table.Footer fullWidth>
<Table.Row>
<Table.HeaderCell colSpan="2">
<Button
floated="right"
icon
labelPosition="left"
primary
disabled={!profileMetadataFetched}
size="small"
onClick={handleEditInfoClick}
>
<Icon name="edit" />
{t('profile.general.tab.edit.info.button.title')}
</Button>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
)}
</Table>
{isSelf && editInformationModal}
</>
), [
authorAvatar, editInformationModal, isSelf, numberOfPosts, numberOfTopics, profileAddress, profileMetadataFetched,
t, userInfoOrbitAddress, userLocationCell, userPostsOrbitAddress, userRegistrationTimestamp, userTopicsOrbitAddress,
username,
]);
};
GeneralTab.defaultProps = {
isSelf: false,
};
GeneralTab.propTypes = {
profileAddress: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
numberOfTopics: PropTypes.number.isRequired,
numberOfPosts: PropTypes.number.isRequired,
userRegistrationTimestamp: PropTypes.string.isRequired,
isSelf: PropTypes.bool,
};
export default GeneralTab;

6
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;
}

114
packages/concordia-app/src/views/Profile/index.jsx

@ -0,0 +1,114 @@
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/contracts/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
: (
<GeneralTab
profileAddress={profileAddress}
username={username}
numberOfTopics={userTopicIds.length}
numberOfPosts={userPostIds.length}
userRegistrationTimestamp={userRegistrationTimestamp}
isSelf={profileAddress === self.address}
/>
)), [
loading, profileAddress, self.address, userPostIds.length, userRegistrationTimestamp, userTopicIds.length, username,
]);
const topicsTab = useMemo(() => (userTopicIds.length > 0
? (<TopicList topicIds={userTopicIds} />)
: (
<Header textAlign="center" as="h2">
{t('profile.user.has.no.topics.header.message', { user: username })}
</Header>
)
), [t, userTopicIds, username]);
const postsTab = useMemo(() => (userPostIds.length > 0
? (<PostList postIds={userPostIds} />)
: (
<Header textAlign="center" as="h2">
{t('profile.user.has.no.posts.header.message', { user: username })}
</Header>
)), [t, userPostIds, username]);
const panes = useMemo(() => {
const generalTabPane = (<CustomLoadingTabPane loading={loading}>{generalTab}</CustomLoadingTabPane>);
const topicsTabPane = (<CustomLoadingTabPane loading={loading}>{topicsTab}</CustomLoadingTabPane>);
const postsTabPane = (<CustomLoadingTabPane loading={loading}>{postsTab}</CustomLoadingTabPane>);
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(() => (
<Container id="home-container" textAlign="center">
<Tab panes={panes} />
</Container>
), [panes]);
};
export default memo(Profile);

175
packages/concordia-app/src/views/Register/PersonalInformationStep/index.jsx

@ -0,0 +1,175 @@
import React, {
useCallback, useEffect, useMemo, useState,
} from 'react';
import {
Button, Card, Form, Image, Input, Message,
} from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router';
import checkUrlValid from '../../../utils/urlUtils';
import { breeze } from '../../../redux/store';
import './styles.css';
import { USER_DATABASE } from '../../../constants/orbit/OrbitDatabases';
import { USER_LOCATION, USER_PROFILE_PICTURE } from '../../../constants/orbit/UserDatabaseKeys';
const { orbit: { stores } } = breeze;
const PersonalInformationStep = (props) => {
const { pushNextStep } = props;
const [profilePictureInput, setProfilePictureInput] = useState('');
const [profilePictureUrlValid, setProfilePictureUrlValid] = useState(true);
const [locationInput, setLocationInput] = useState('');
const [error, setError] = useState(false);
const [errorMessages, setErrorMessages] = useState([]);
const history = useHistory();
const { t } = useTranslation();
useEffect(() => {
let formHasError = false;
const formErrors = [];
if (!profilePictureUrlValid) {
formHasError = true;
formErrors.push(t('register.form.personal.information.step.error.invalid.profile.picture.url.message'));
}
setError(formHasError);
setErrorMessages(formErrors);
}, [profilePictureUrlValid, t]);
const handleInputChange = useCallback((event, { name, value }) => {
if (name === 'profilePictureInput') {
setProfilePictureInput(value);
if (value.length > 0) {
setProfilePictureUrlValid(checkUrlValid(value));
} else {
setProfilePictureUrlValid(true);
}
}
if (name === 'locationInput') {
setLocationInput(value);
}
}, []);
const profilePicture = useMemo(() => (profilePictureInput.length > 0 && profilePictureUrlValid
? (
<div className="register-form-profile-picture-wrapper">
<Image rounded src={profilePictureInput} className="register-form-profile-picture" />
</div>
)
: null
), [profilePictureInput, profilePictureUrlValid]);
const handleSubmit = useCallback(() => {
if (error) {
return;
}
const keyValuesToStore = [];
if (profilePictureInput.length > 0) {
keyValuesToStore.push({
key: USER_PROFILE_PICTURE,
value: profilePictureInput,
});
}
if (locationInput.length > 0) {
keyValuesToStore.push({
key: USER_LOCATION,
value: locationInput,
});
}
if (keyValuesToStore.length > 0) {
const userDb = Object.values(stores).find((store) => store.dbname === USER_DATABASE);
keyValuesToStore
.reduce((acc, keyValueToStore) => acc
.then(() => userDb
.put(keyValueToStore.key, keyValueToStore.value, { pin: true })),
Promise.resolve())
.then(() => pushNextStep())
.catch((reason) => {
console.log(reason);
});
}
}, [error, locationInput, profilePictureInput, pushNextStep]);
const goToHomePage = () => history.push('/');
return (
<>
<Card.Content>
<Card.Description>
<Form>
<Form.Field>
<label htmlFor="form-register-field-profile-picture">
{t('register.form.personal.information.step.profile.picture.field.label')}
</label>
<Input
id="form-register-field-profile-picture"
placeholder={t('register.form.personal.information.step.profile.picture.field.placeholder')}
name="profilePictureInput"
className="form-input"
value={profilePictureInput}
onChange={handleInputChange}
/>
</Form.Field>
{profilePicture}
<Form.Field>
<label htmlFor="form-register-field-location">
{t('register.form.personal.information.step.location.field.label')}
</label>
<Input
id="form-register-field-location"
placeholder={t('register.form.personal.information.step.location.field.placeholder')}
name="locationInput"
className="form-input"
value={locationInput}
onChange={handleInputChange}
/>
</Form.Field>
</Form>
</Card.Description>
</Card.Content>
{error === true && (
<Card.Content extra>
{errorMessages
.map((errorMessage) => (
<Message
error
header={t('register.form.personal.information.step.error.message.header')}
content={errorMessage}
/>
))}
</Card.Content>
)}
<Card.Content extra>
<Button
color="green"
floated="right"
content={t('register.form.personal.information.step.button.submit')}
onClick={handleSubmit}
disabled={!profilePictureUrlValid}
/>
<Button
color="violet"
floated="right"
basic
content={t('register.form.personal.information.step.button.skip')}
onClick={goToHomePage}
/>
</Card.Content>
</>
);
};
PersonalInformationStep.propTypes = {
pushNextStep: PropTypes.func.isRequired,
};
export default PersonalInformationStep;

7
packages/concordia-app/src/views/Register/PersonalInformationStep/styles.css

@ -0,0 +1,7 @@
.register-form-profile-picture-wrapper {
text-align: center;
}
.register-form-profile-picture {
max-height: 30vh;
}

121
packages/concordia-app/src/views/Register/SignUpStep/index.jsx

@ -0,0 +1,121 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
Button, Card, Form, Message,
} from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import PropTypes from 'prop-types';
import { drizzle } from '../../../redux/store';
import { TRANSACTION_ERROR, TRANSACTION_SUCCESS } from '../../../constants/TransactionStatus';
import { FORUM_CONTRACT } from '../../../constants/contracts/ContractNames';
import UsernameSelector from '../../../components/UsernameSelector';
const { contracts: { [FORUM_CONTRACT]: { methods: { signUp } } } } = drizzle;
const SignUpStep = (props) => {
const { pushNextStep, account } = props;
const user = useSelector((state) => state.user);
const transactionStack = useSelector((state) => state.transactionStack);
const transactions = useSelector((state) => state.transactions);
const [usernameInput, setUsernameInput] = useState('');
const [usernameIsChecked, setUsernameIsChecked] = useState(true);
const [error, setError] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [signingUp, setSigningUp] = useState(false);
const [registerCacheSendStackId, setRegisterCacheSendStackId] = useState('');
const history = useHistory();
const { t } = useTranslation();
useEffect(() => {
if (signingUp && transactionStack && transactionStack[registerCacheSendStackId]
&& transactions[transactionStack[registerCacheSendStackId]]) {
if (transactions[transactionStack[registerCacheSendStackId]].status === TRANSACTION_ERROR) {
setSigningUp(false);
} else if (transactions[transactionStack[registerCacheSendStackId]].status === TRANSACTION_SUCCESS) {
pushNextStep();
// TODO: display a welcome message?
}
}
}, [pushNextStep, registerCacheSendStackId, signingUp, transactionStack, transactions]);
const handleUsernameChange = useCallback((modifiedUsername) => {
setUsernameInput(modifiedUsername);
}, []);
const handleUsernameErrorChange = useCallback(({
usernameChecked: isUsernameChecked,
error: hasUsernameError,
errorMessage: usernameErrorMessage,
}) => {
setUsernameIsChecked(isUsernameChecked);
if (hasUsernameError) {
setError(true);
setErrorMessage(usernameErrorMessage);
} else {
setError(false);
}
}, []);
const handleSubmit = useCallback(() => {
if (user.hasSignedUp) {
signUp.cacheSend(usernameInput);
} else {
setSigningUp(true);
setRegisterCacheSendStackId(signUp.cacheSend(...[usernameInput], { from: account }));
}
}, [account, user.hasSignedUp, usernameInput]);
const goToHomePage = () => history.push('/');
return (
<>
<Card.Content>
<Card.Description>
<Form loading={signingUp}>
<UsernameSelector
username={usernameInput}
onChangeCallback={handleUsernameChange}
onErrorChangeCallback={handleUsernameErrorChange}
/>
</Form>
</Card.Description>
</Card.Content>
{error === true && (
<Card.Content extra>
<Message
error
header={t('register.form.sign.up.step.error.message.header')}
content={errorMessage}
/>
</Card.Content>
)}
<Card.Content extra>
<Button
color="green"
floated="right"
content={t('register.form.sign.up.step.button.submit')}
onClick={handleSubmit}
disabled={error || signingUp || usernameInput.length === 0}
loading={!usernameIsChecked}
/>
<Button
color="violet"
floated="right"
basic
content={t('register.form.sign.up.step.button.guest')}
onClick={goToHomePage}
disabled={signingUp}
/>
</Card.Content>
</>
);
};
SignUpStep.propTypes = {
pushNextStep: PropTypes.func.isRequired,
};
export default SignUpStep;

225
packages/concordia-app/src/views/Register/index.jsx

@ -1,184 +1,121 @@
import React, {
useCallback, useContext, useEffect, useMemo, useState,
} from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import {
Button, Card, Form, Header, Input, Message,
Button, Card, Header, Icon, Step,
} from 'semantic-ui-react';
import throttle from 'lodash/throttle';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import AppContext from '../../components/AppContext';
import './styles.css';
import SignUpStep from './SignUpStep';
import PersonalInformationStep from './PersonalInformationStep';
import { REGISTER_STEP_PROFILE_INFORMATION, REGISTER_STEP_SIGNUP } from '../../constants/RegisterSteps';
const Register = (props) => {
const { account } = props;
const {
drizzle: {
contracts: {
Forum: {
methods: { isUserNameTaken, signUp },
},
},
},
} = useContext(AppContext.Context);
const Register = () => {
const [currentStep, setCurrentStep] = useState('signup');
const user = useSelector((state) => state.user);
const isUserNameTakenResults = useSelector((state) => state.contracts.Forum.isUserNameTaken);
const transactionStack = useSelector((state) => state.transactionStack);
const transactions = useSelector((state) => state.transactions);
const [usernameInput, setUsernameInput] = useState('');
const [usernameIsChecked, setUsernameIsChecked] = useState(true);
const [usernameIsTaken, setUsernameIsTaken] = useState(true);
const [error, setError] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [signingUp, setSigningUp] = useState(false);
const [registerCacheSendStackId, setRegisterCacheSendStackId] = useState('');
const [signingUp] = useState(!user.hasSignedUp);
const history = useHistory();
const { t } = useTranslation();
useEffect(() => {
if (usernameInput.length > 0) {
const checkedUsernames = Object
.values(isUserNameTakenResults)
.map((callCompleted) => ({
checkedUsername: callCompleted.args[0],
isTaken: callCompleted.value,
}));
const checkedUsername = checkedUsernames
.find((callCompleted) => callCompleted.checkedUsername === usernameInput);
setUsernameIsChecked(checkedUsername !== undefined);
const goToHomePage = useCallback(() => history.push('/'), [history]);
if (checkedUsername && checkedUsername.isTaken) {
setUsernameIsTaken(true);
setError(true);
setErrorMessage(t('register.form.error.username.taken.message', { username: usernameInput }));
} else {
setUsernameIsTaken(false);
setError(false);
}
const pushNextStep = useCallback(() => {
if (currentStep === REGISTER_STEP_SIGNUP) {
setCurrentStep(REGISTER_STEP_PROFILE_INFORMATION);
}
}, [isUserNameTakenResults, t, usernameInput]);
useEffect(() => {
if (signingUp && transactionStack && transactionStack[registerCacheSendStackId]
&& transactions[transactionStack[registerCacheSendStackId]]) {
if (transactions[transactionStack[registerCacheSendStackId]].status === 'error') {
setSigningUp(false);
} else if (transactions[transactionStack[registerCacheSendStackId]].status === 'success') {
history.push('/');
// TODO: display a welcome message?
}
if (currentStep === REGISTER_STEP_PROFILE_INFORMATION) {
goToHomePage();
}
}, [registerCacheSendStackId, signingUp, transactions, transactionStack, history]);
const checkUsernameTaken = useMemo(() => throttle(
(username) => {
isUserNameTaken.cacheCall(username);
}, 200,
), [isUserNameTaken]);
}, [currentStep, goToHomePage]);
const handleInputChange = useCallback((event, { value }) => {
setUsernameInput(value);
if (value.length > 0) {
checkUsernameTaken(value);
const activeStep = useMemo(() => {
if (currentStep === REGISTER_STEP_SIGNUP) {
return (
<SignUpStep pushNextStep={pushNextStep} />
);
}
}, [checkUsernameTaken]);
const handleSubmit = useCallback(() => {
if (user.hasSignedUp) {
signUp.cacheSend(usernameInput);
} else {
setSigningUp(true);
setRegisterCacheSendStackId(signUp.cacheSend(...[usernameInput], { from: account }));
if (currentStep === REGISTER_STEP_PROFILE_INFORMATION) {
return (
<PersonalInformationStep pushNextStep={pushNextStep} />
);
}
}, [account, signUp, user.hasSignedUp, usernameInput]);
const goToHomePage = React.useCallback(() => history.push('/'), [history]);
return null;
}, [currentStep, pushNextStep]);
return (
<div className="centered form-card-container">
<Card fluid>
<Card.Content>
<Card.Header>Sign Up</Card.Header>
{
!user.hasSignedUp && (
<Card.Header>
<Step.Group>
<Step
key="register-form-step-signup"
active={currentStep === REGISTER_STEP_SIGNUP}
>
<Icon name="signup" />
<Step.Content>
<Step.Title>
{t('register.form.sign.up.step.title')}
</Step.Title>
<Step.Description>
{t('register.form.sign.up.step.description')}
</Step.Description>
</Step.Content>
</Step>
<Step
key="register-form-step-profile-information"
active={currentStep === REGISTER_STEP_PROFILE_INFORMATION}
>
<Icon name="user circle" />
<Step.Content>
<Step.Title>
{t('register.form.profile.information.step.title')}
</Step.Title>
<Step.Description>
{t('register.form.profile.information.step.description')}
</Step.Description>
</Step.Content>
</Step>
</Step.Group>
</Card.Header>
)
}
<Card.Description>
<p>
<strong>{t('register.p.account.address')}</strong>
&nbsp;
{user.address}
</p>
{user.hasSignedUp
? (
<div>
<Header as="h4" className="i18next-newlines">
{t('register.form.header.already.member.message')}
</Header>
</div>
)
: (
<Form loading={signingUp}>
<Form.Field required>
<label htmlFor="form-register-field-username">
{t('register.form.username.field.label')}
</label>
<Input
id="form-register-field-username"
placeholder={t('register.form.username.field.placeholder')}
name="usernameInput"
className="form-input"
value={usernameInput}
onChange={handleInputChange}
/>
</Form.Field>
</Form>
)}
</Card.Description>
</Card.Content>
{error === true && (
<Card.Content extra>
<Message
error
header={t('register.form.error.message.header')}
content={errorMessage}
/>
</Card.Content>
)}
<Card.Content extra>
{user.hasSignedUp
? (
<Button
color="black"
floated="right"
content={t('register.form.button.back')}
onClick={goToHomePage}
/>
)
: (
<>
{user.hasSignedUp && !signingUp
? (
<>
<Card.Content>
<Card.Description>
<div>
<Header as="h4" className="i18next-newlines">
{t('register.form.header.already.member.message')}
</Header>
</div>
</Card.Description>
</Card.Content>
<Card.Content extra>
<Button
color="green"
color="black"
floated="right"
content={t('register.form.button.submit')}
onClick={handleSubmit}
disabled={usernameIsTaken || signingUp}
loading={!usernameIsChecked}
/>
<Button
color="violet"
floated="right"
basic
content={t('register.form.button.guest')}
content={t('register.form.button.back')}
onClick={goToHomePage}
disabled={signingUp}
/>
</>
)}
</Card.Content>
</Card.Content>
</>
)
: activeStep}
</Card>
</div>
);

81
packages/concordia-app/src/views/Topic/TopicCreate/index.jsx

@ -1,5 +1,5 @@
import React, {
useCallback, useContext, useEffect, useState,
useCallback, useEffect, useState,
} from 'react';
import {
Button, Container, Form, Icon, Input, TextArea,
@ -7,34 +7,24 @@ import {
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router';
import { useSelector } from 'react-redux';
import AppContext from '../../../components/AppContext';
import './styles.css';
import { drizzle, breeze } from '../../../redux/store';
import { TRANSACTION_ERROR, TRANSACTION_SUCCESS } from '../../../constants/TransactionStatus';
import { POSTS_DATABASE, TOPICS_DATABASE } from '../../../constants/orbit/OrbitDatabases';
import { TOPIC_SUBJECT } from '../../../constants/orbit/TopicsDatabaseKeys';
import { POST_CONTENT } from '../../../constants/orbit/PostsDatabaseKeys';
import { FORUM_CONTRACT } from '../../../constants/contracts/ContractNames';
import { TOPIC_CREATED_EVENT } from '../../../constants/contracts/events/ForumContractEvents';
const { contracts: { [FORUM_CONTRACT]: { methods: { createTopic } } } } = drizzle;
const { orbit: { stores } } = breeze;
const TopicCreate = (props) => {
const { account } = props;
const {
drizzle: {
contracts: {
Forum: {
methods: { createTopic },
},
},
},
breeze: {
orbit: {
stores,
},
},
} = useContext(AppContext.Context);
const transactionStack = useSelector((state) => state.transactionStack);
const transactions = useSelector((state) => state.transactions);
const [subjectInput, setSubjectInput] = useState('');
const [messageInput, setMessageInput] = useState('');
const [topicSubjectInputEmptySubmit, setTopicSubjectInputEmptySubmit] = useState(false);
const [topicMessageInputEmptySubmit, setTopicMessageInputEmptySubmit] = useState(false);
const [contentInput, setContentInput] = useState('');
const [createTopicCacheSendStackId, setCreateTopicCacheSendStackId] = useState('');
const [posting, setPosting] = useState(false);
@ -50,8 +40,8 @@ const TopicCreate = (props) => {
case 'subjectInput':
setSubjectInput(event.target.value);
break;
case 'messageInput':
setMessageInput(event.target.value);
case 'contentInput':
setContentInput(event.target.value);
break;
default:
break;
@ -61,13 +51,13 @@ const TopicCreate = (props) => {
useEffect(() => {
if (posting && transactionStack && transactionStack[createTopicCacheSendStackId]
&& transactions[transactionStack[createTopicCacheSendStackId]]) {
if (transactions[transactionStack[createTopicCacheSendStackId]].status === 'error') {
if (transactions[transactionStack[createTopicCacheSendStackId]].status === TRANSACTION_ERROR) {
setPosting(false);
} else if (transactions[transactionStack[createTopicCacheSendStackId]].status === 'success') {
} else if (transactions[transactionStack[createTopicCacheSendStackId]].status === TRANSACTION_SUCCESS) {
const {
receipt: {
events: {
TopicCreated: {
[TOPIC_CREATED_EVENT]: {
returnValues: {
topicID: topicId,
postID: postId,
@ -77,15 +67,14 @@ const TopicCreate = (props) => {
},
} = transactions[transactionStack[createTopicCacheSendStackId]];
const topicsDb = Object.values(stores).find((store) => store.dbname === 'topics');
const postsDb = Object.values(stores).find((store) => store.dbname === 'posts');
const topicsDb = Object.values(stores).find((store) => store.dbname === TOPICS_DATABASE);
const postsDb = Object.values(stores).find((store) => store.dbname === POSTS_DATABASE);
topicsDb
.put(topicId, { subject: subjectInput }, { pin: true })
.put(topicId, { [TOPIC_SUBJECT]: subjectInput }, { pin: true })
.then(() => postsDb
.put(postId, {
subject: subjectInput,
content: messageInput,
[POST_CONTENT]: contentInput,
}, { pin: true }))
.then(() => {
history.push(`/topics/${topicId}`);
@ -95,24 +84,16 @@ const TopicCreate = (props) => {
});
}
}
}, [
transactions, transactionStack, history, posting, createTopicCacheSendStackId, subjectInput, messageInput, stores,
]);
}, [createTopicCacheSendStackId, history, contentInput, posting, subjectInput, transactionStack, transactions]);
const validateAndPost = useCallback(() => {
if (subjectInput === '') {
setTopicSubjectInputEmptySubmit(true);
return;
}
if (messageInput === '') {
setTopicMessageInputEmptySubmit(true);
if (subjectInput === '' || contentInput === '') {
return;
}
setPosting(true);
setCreateTopicCacheSendStackId(createTopic.cacheSend(...[], { from: account }));
}, [account, createTopic, messageInput, subjectInput]);
}, [account, contentInput, subjectInput]);
return (
<Container>
@ -126,23 +107,19 @@ const TopicCreate = (props) => {
placeholder={t('topic.create.form.subject.field.placeholder')}
name="subjectInput"
className="form-input"
error={topicSubjectInputEmptySubmit}
value={subjectInput}
onChange={handleSubjectInputChange}
/>
</Form.Field>
<Form.Field required>
<label htmlFor="form-topic-create-field-message">
{t('topic.create.form.message.field.label')}
{t('topic.create.form.content.field.label')}
</label>
<TextArea
id="form-topic-create-field-message"
name="messageInput"
className={topicMessageInputEmptySubmit
? 'form-textarea-required'
: ''}
value={messageInput}
placeholder={t('topic.create.form.message.field.placeholder')}
name="contentInput"
value={contentInput}
placeholder={t('topic.create.form.content.field.placeholder')}
rows={5}
autoheight="true"
onChange={handleSubjectInputChange}
@ -154,7 +131,7 @@ const TopicCreate = (props) => {
key="form-topic-create-button-submit"
type="button"
color="green"
disabled={posting}
disabled={posting || subjectInput === '' || contentInput === ''}
onClick={validateAndPost}
>
<Button.Content visible>

189
packages/concordia-app/src/views/Topic/TopicView/index.jsx

@ -1,18 +1,197 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import {
Container, Dimmer, Icon, Image, Placeholder, Step,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { useHistory } from 'react-router';
import TimeAgo from 'react-timeago';
import { breeze, drizzle } from '../../../redux/store';
import { FETCH_USER_DATABASE } from '../../../redux/actions/peerDbReplicationActions';
import './styles.css';
import PostList from '../../../components/PostList';
import { TOPICS_DATABASE, USER_DATABASE } from '../../../constants/orbit/OrbitDatabases';
import determineKVAddress from '../../../utils/orbitUtils';
import { USER_PROFILE_PICTURE } from '../../../constants/orbit/UserDatabaseKeys';
import { TOPIC_SUBJECT } from '../../../constants/orbit/TopicsDatabaseKeys';
import PostCreate from '../../../components/PostCreate';
import { FORUM_CONTRACT } from '../../../constants/contracts/ContractNames';
const { contracts: { [FORUM_CONTRACT]: { methods: { getTopic: { cacheCall: getTopicChainData } } } } } = drizzle;
const { orbit } = breeze;
const TopicView = (props) => {
const { topicId } = props;
const {
topicId, topicAuthorAddress: initialTopicAuthorAddress, topicAuthor: initialTopicAuthor,
timestamp: initialTimestamp, postIds: initialPostIds,
} = props;
const drizzleInitialized = useSelector((state) => state.drizzleStatus.initialized);
const drizzleInitializationFailed = useSelector((state) => state.drizzleStatus.failed);
const userAddress = useSelector((state) => state.user.address);
const hasSignedUp = useSelector((state) => state.user.hasSignedUp);
const getTopicResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getTopic);
const topics = useSelector((state) => state.orbitData.topics);
const users = useSelector((state) => state.orbitData.users);
const [getTopicCallHash, setGetTopicCallHash] = useState([]);
const [topicAuthorAddress, setTopicAuthorAddress] = useState(initialTopicAuthorAddress || null);
const [topicAuthor, setTopicAuthor] = useState(initialTopicAuthor || null);
const [topicAuthorMeta, setTopicAuthorMeta] = useState(null);
const [timestamp, setTimestamp] = useState(initialTimestamp || null);
const [postIds, setPostIds] = useState(initialPostIds || null);
const [topicSubject, setTopicSubject] = useState(null);
const history = useHistory();
const dispatch = useDispatch();
useEffect(() => {
const shouldGetTopicDataFromChain = topicAuthorAddress === null
|| topicAuthor === null
|| timestamp === null
|| postIds === null;
if (drizzleInitialized && !drizzleInitializationFailed && shouldGetTopicDataFromChain) {
setGetTopicCallHash(getTopicChainData(topicId));
}
}, [
drizzleInitializationFailed, drizzleInitialized, postIds, timestamp, topicAuthor, topicAuthorAddress, topicId,
]);
useEffect(() => {
if (getTopicCallHash && getTopicResults && getTopicResults[getTopicCallHash]) {
if (getTopicResults[getTopicCallHash].value == null) {
history.push('/');
return;
}
setTopicAuthorAddress(getTopicResults[getTopicCallHash].value[0]);
setTopicAuthor(getTopicResults[getTopicCallHash].value[1]);
setTimestamp(getTopicResults[getTopicCallHash].value[2] * 1000);
setPostIds(getTopicResults[getTopicCallHash].value[3].map((postId) => parseInt(postId, 10)));
const topicFound = topics
.find((topic) => topic.id === topicId);
if (topicFound === undefined && userAddress !== getTopicResults[getTopicCallHash].value[0]) {
dispatch({
type: FETCH_USER_DATABASE,
orbit,
dbName: TOPICS_DATABASE,
userAddress: getTopicResults[getTopicCallHash].value[0],
});
}
}
}, [dispatch, getTopicCallHash, getTopicResults, history, topicId, topics, userAddress]);
useEffect(() => {
if (topicAuthorAddress !== null) {
determineKVAddress({ orbit, dbName: USER_DATABASE, userAddress: topicAuthorAddress })
.then((userOrbitAddress) => {
const userFound = users
.find((user) => user.id === userOrbitAddress);
if (userFound) {
setTopicAuthorMeta(userFound);
} else {
dispatch({
type: FETCH_USER_DATABASE,
orbit,
dbName: USER_DATABASE,
userAddress: topicAuthorAddress,
});
}
})
.catch((error) => {
console.error('Error during determination of key-value DB address:', error);
});
}
}, [dispatch, topicAuthorAddress, users]);
useEffect(() => {
const topicFound = topics
.find((topic) => topic.id === topicId);
if (topicFound) {
setTopicSubject(topicFound[TOPIC_SUBJECT]);
}
}, [topicId, topics]);
return (
<div>
TODO
</div>
<Container id="topic-container" textAlign="center">
<Dimmer.Dimmable
blurring
dimmed={topicAuthorAddress === null && topicAuthor === null && timestamp === null}
>
<Step.Group fluid>
<Step key="topic-header-step-user">
<Link to={`/users/${topicAuthorAddress}`}>
{topicAuthorMeta !== null && topicAuthorMeta[USER_PROFILE_PICTURE]
? (
<Image
avatar
src={topicAuthorMeta[USER_PROFILE_PICTURE]}
/>
)
: (
<Icon
name="user circle"
size="big"
inverted
color="black"
/>
)}
</Link>
<Step.Content>
<Step.Title>
<Link to={`/users/${topicAuthorAddress}`}>
{topicAuthor || (
<Placeholder id="author-placeholder" inverted>
<Placeholder.Line length="full" />
</Placeholder>
)}
</Link>
</Step.Title>
</Step.Content>
</Step>
<Step key="topic-header-step-title">
<Step.Content>
<Step.Title>
{topicSubject || (
<Placeholder id="subject-placeholder">
<Placeholder.Line length="full" />
</Placeholder>
)}
</Step.Title>
<Step.Description>
{timestamp
? <TimeAgo date={timestamp} />
: (
<Placeholder id="date-placeholder">
<Placeholder.Line length="full" />
</Placeholder>
)}
</Step.Description>
</Step.Content>
</Step>
</Step.Group>
</Dimmer.Dimmable>
<PostList postIds={postIds || []} loading={postIds === null} />
{topicSubject !== null && postIds !== null && hasSignedUp && (
<PostCreate
topicId={topicId}
postIndexInTopic={postIds.length + 1}
initialPostSubject={topicSubject}
/>
)}
</Container>
);
};
TopicView.propTypes = {
topicId: PropTypes.number.isRequired,
topicAuthorAddress: PropTypes.string,
topicAuthor: PropTypes.string,
timestamp: PropTypes.number,
postIds: PropTypes.arrayOf(PropTypes.number),
};
export default TopicView;

20
packages/concordia-app/src/views/Topic/TopicView/styles.css

@ -0,0 +1,20 @@
#topic-container {
height: 100%;
}
#topic-grid {
height: 100%;
}
#author-placeholder {
width: 150px !important;
}
#subject-placeholder {
width: 250px !important;
}
#date-placeholder {
width: 150px !important;
margin: 0 auto;
}

2
packages/concordia-app/src/views/Topic/index.jsx

@ -12,7 +12,7 @@ const Topic = () => {
<TopicCreate />
)
: (
<TopicView topicId={topicId} />
<TopicView topicId={parseInt(topicId, 10)} />
);
};

68
packages/concordia-contracts/README.md

@ -0,0 +1,68 @@
# Concordia Contracts Package
This is the package where the contracts that power Concordia live.
## Compile contracts
```shell script
yarn compile
```
## Lint contracts (and tests)
```shell script
yarn lint
```
## Migrate contracts
Default host and port values of the blockchain are:
| host | port |
|---|---|
| 127.0.0.1 | 8545 |
Migrate (using the development network by default):
```shell script
yarn migrate
```
### Setting different host and port values
Define the host and port of the blockchain in use.
Linux:
```shell script
export CHAIN_HOST="127.0.0.1"
export CHAIN_PORT="7545"
```
Windows:
```shell script
SET CHAIN_HOST="127.0.0.1"
SET CHAIN_PORT="7545"
```
Migrate using the `env` network :
```shell script
yarn _migrate --network env
```
**Notice the underscore `_` suffix in the script name. This is not a mistake.**
## Test contracts
Default host and port values of the blockchain are:
| host | port |
|---|---|
| 127.0.0.1 | 8546 |
Test:
```shell script
yarn test
```
### Setting different host and port values
Define the host and port of the blockchain in use like above.
Test:
```shell script
yarn test --network env
```

16
packages/concordia-contracts/constants/config/defaults.js

@ -0,0 +1,16 @@
const DEVELOP_CHAIN_HOST_DEFAULT = '127.0.0.1';
const DEVELOP_CHAIN_PORT_DEFAULT = '8545';
const TEST_CHAIN_HOST_DEFAULT = '127.0.0.1';
const TEST_CHAIN_PORT_DEFAULT = '8546';
module.exports = {
develop: {
chainHost: DEVELOP_CHAIN_HOST_DEFAULT,
chainPort: DEVELOP_CHAIN_PORT_DEFAULT,
},
test: {
chainHost: TEST_CHAIN_HOST_DEFAULT,
chainPort: TEST_CHAIN_PORT_DEFAULT,
},
};

8
packages/concordia-contracts/contracts/Forum.sol

@ -1,5 +1,6 @@
//SPDX-License-Identifier: MIT
pragma solidity 0.7.5;
pragma experimental ABIEncoderV2;
contract Forum {
// Error messages for require()
@ -21,7 +22,7 @@ contract Forum {
mapping(string => address) userAddresses;
event UserSignedUp(string username, address userAddress);
event UsernameUpdated(string newName, string oldName,address userAddress);
event UsernameUpdated(string newName, string oldName, address userAddress);
function signUp(string memory username) public returns (bool) {
require (!hasUserSignedUp(msg.sender), USER_HAS_NOT_SIGNED_UP);
@ -77,6 +78,11 @@ contract Forum {
return users[userAddress].timestamp;
}
function getUser(address userAddress) public view returns (User memory) {
require(hasUserSignedUp(userAddress), USER_HAS_NOT_SIGNED_UP);
return users[userAddress];
}
//----------------------------------------POSTING----------------------------------------
struct Topic {
uint topicID;

4
packages/concordia-contracts/package.json

@ -10,7 +10,9 @@
"_eslint": "yarn eslint . --format table",
"_solhint": "yarn solhint --formatter table contracts/*.sol test/*.sol",
"test": "yarn truffle test",
"migrate": "yarn truffle migrate --network develop --reset"
"migrate": "yarn _migrate --network develop",
"migrate-reset": "yarn _migrate --network develop --reset",
"_migrate": "yarn truffle migrate"
},
"dependencies": {
"@openzeppelin/contracts": "~3.2.0",

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save