mirror of https://gitlab.com/ecentrics/concordia
Ezerous
4 years ago
102 changed files with 3720 additions and 647 deletions
@ -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 |
@ -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. |
@ -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 |
@ -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"` |
@ -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. |
@ -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 . |
@ -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; |
||||
|
} |
||||
|
} |
@ -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 |
@ -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"] |
@ -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 |
@ -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 |
@ -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: |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -0,0 +1,5 @@ |
|||||
|
ACCOUNTS_NUMBER=10 |
||||
|
ACCOUNTS_ETHER=100 |
||||
|
HOST=0.0.0.0 |
||||
|
PORT=8545 |
||||
|
NETWORK_ID=5778 |
@ -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 |
@ -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"] |
@ -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 |
@ -1,8 +1,13 @@ |
|||||
{ |
{ |
||||
"name": "apella", |
"name": "concordia", |
||||
"private": true, |
"private": true, |
||||
"workspaces": { |
"workspaces": { |
||||
"packages": ["packages/*"], |
"packages": [ |
||||
"nohoist": ["**/web3", "**/web3/**"] |
"packages/*" |
||||
|
], |
||||
|
"nohoist": [ |
||||
|
"**/web3", |
||||
|
"**/web3/**" |
||||
|
] |
||||
} |
} |
||||
} |
} |
||||
|
@ -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 |
@ -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* |
@ -1,7 +0,0 @@ |
|||||
body { |
|
||||
margin: 1em !important; |
|
||||
} |
|
||||
|
|
||||
.i18next-newlines { |
|
||||
white-space: pre-line !important; |
|
||||
} |
|
@ -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; |
||||
|
} |
After Width: | Height: | Size: 3.2 KiB |
@ -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; |
@ -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; |
@ -1,25 +1,30 @@ |
|||||
import React from 'react'; |
import React, { useEffect } from 'react'; |
||||
import PropTypes from 'prop-types'; |
import PropTypes from 'prop-types'; |
||||
|
|
||||
import { Container, Progress } from 'semantic-ui-react'; |
import { Container, Progress } from 'semantic-ui-react'; |
||||
|
|
||||
// CSS |
|
||||
import '../assets/css/loading-component.css'; |
|
||||
|
|
||||
// Images |
// Images |
||||
import ethereumLogo from '../assets/images/ethereum_logo.svg'; |
import metamaskLogo from '../../../assets/images/metamask_logo.svg'; |
||||
import ipfsLogo from '../assets/images/ipfs_logo.svg'; |
import ethereumLogo from '../../../assets/images/ethereum_logo.svg'; |
||||
import orbitdbLogo from '../assets/images/orbitdb_logo.svg'; |
import ipfsLogo from '../../../assets/images/ipfs_logo.svg'; |
||||
import appLogo from '../assets/images/app_logo.png'; |
import orbitdbLogo from '../../../assets/images/orbitdb_logo.svg'; |
||||
|
import appLogo from '../../../assets/images/app_logo.png'; |
||||
|
|
||||
const LoadingComponent = (props) => { |
const LoadingComponent = (props) => { |
||||
|
useEffect(() => function cleanup() { |
||||
|
document.body.classList.add('app'); |
||||
|
}, []); |
||||
|
|
||||
const { |
const { |
||||
imageType, messageList, progressType, title, message, progress, |
imageType, messageList, progressType, title, message, progress, |
||||
} = props; |
} = props; |
||||
let imageSrc; let imageAlt; let listItems; let indicating; let |
let imageSrc; let imageAlt; let listItems; let indicating; let |
||||
error; |
error; |
||||
|
|
||||
if (imageType === 'ethereum') { |
if (imageType === 'metamask') { |
||||
|
imageSrc = metamaskLogo; |
||||
|
imageAlt = 'metamask_logo'; |
||||
|
} else if (imageType === 'ethereum') { |
||||
imageSrc = ethereumLogo; |
imageSrc = ethereumLogo; |
||||
imageAlt = 'ethereum_logo'; |
imageAlt = 'ethereum_logo'; |
||||
} else if (imageType === 'ipfs') { |
} else if (imageType === 'ipfs') { |
@ -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; |
@ -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); |
|
@ -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; |
|
@ -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); |
@ -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; |
||||
|
} |
@ -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); |
@ -0,0 +1,9 @@ |
|||||
|
.post-profile-picture { |
||||
|
margin: 5px 0 0 0; |
||||
|
} |
||||
|
|
||||
|
.post-summary-meta-index { |
||||
|
float: right; |
||||
|
font-size: 12px; |
||||
|
opacity: 0.4; |
||||
|
} |
@ -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; |
@ -0,0 +1,3 @@ |
|||||
|
#post-list{ |
||||
|
height: 100%; |
||||
|
} |
@ -1,64 +1,190 @@ |
|||||
import React, { useContext, useEffect, useState } from 'react'; |
import React, { |
||||
import { List } from 'semantic-ui-react'; |
memo, useEffect, useMemo, useState, |
||||
|
} from 'react'; |
||||
|
import { |
||||
|
Dimmer, Grid, Image, List, Placeholder, |
||||
|
} from 'semantic-ui-react'; |
||||
import PropTypes from 'prop-types'; |
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 { useDispatch, useSelector } from 'react-redux'; |
||||
import AppContext from '../../AppContext'; |
import { Link } from 'react-router-dom'; |
||||
import { FETCH_USER_DATABASE } from '../../../redux/actions/peerDbReplicationActions'; |
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 TopicListRow = (props) => { |
||||
const { topicData, topicId } = props; |
const { id: topicId, topicCallHash, loading } = props; |
||||
const { breeze: { orbit } } = useContext(AppContext.Context); |
const getTopicResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getTopic); |
||||
const [topicSubject, setTopicSubject] = useState(); |
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 userAddress = useSelector((state) => state.user.address); |
||||
const topics = useSelector((state) => state.orbitData.topics); |
const topics = useSelector((state) => state.orbitData.topics); |
||||
|
const users = useSelector((state) => state.orbitData.users); |
||||
const dispatch = useDispatch(); |
const dispatch = useDispatch(); |
||||
|
const history = useHistory(); |
||||
|
const { t } = useTranslation(); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
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(() => { |
useEffect(() => { |
||||
if (userAddress !== topicData.userAddress) { |
if (topicAuthorAddress && userAddress !== topicAuthorAddress) { |
||||
|
dispatch({ |
||||
|
type: FETCH_USER_DATABASE, |
||||
|
orbit, |
||||
|
dbName: TOPICS_DATABASE, |
||||
|
userAddress: topicAuthorAddress, |
||||
|
}); |
||||
|
|
||||
dispatch({ |
dispatch({ |
||||
type: FETCH_USER_DATABASE, |
type: FETCH_USER_DATABASE, |
||||
orbit, |
orbit, |
||||
userAddress: topicData.userAddress, |
dbName: USER_DATABASE, |
||||
|
userAddress: topicAuthorAddress, |
||||
}); |
}); |
||||
} |
} |
||||
}, [dispatch, orbit, topicData.userAddress, topicId, userAddress]); |
}, [dispatch, topicAuthorAddress, userAddress]); |
||||
|
|
||||
useEffect(() => { |
useEffect(() => { |
||||
const topicFound = topics |
const topicFound = topics |
||||
.find((topic) => topic.id === topicId); |
.find((topic) => topic.id === topicId); |
||||
|
|
||||
if (topicFound) { |
if (topicFound) { |
||||
setTopicSubject(topicFound); |
setTopicSubject(topicFound[TOPIC_SUBJECT]); |
||||
} |
} |
||||
}, [topicId, topics]); |
}, [topicId, topics]); |
||||
|
|
||||
|
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 ( |
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> |
<List.Header> |
||||
<List.Icon name="right triangle" /> |
<Grid> |
||||
{topicSubject && topicSubject.subject} |
<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.Header> |
||||
<List.Content> |
<List.Description> |
||||
{topicData.username} |
<Grid verticalAlign="middle"> |
||||
{topicData.numberOfReplies} |
<Grid.Column floated="left" width={14}> |
||||
{' '} |
{topicAuthor !== null && timeAgo !== null |
||||
replies |
? ( |
||||
timestamp |
<div> |
||||
|
{t('topic.list.row.author', { author: topicAuthor })} |
||||
|
, |
||||
|
<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> |
</List.Content> |
||||
</> |
</Dimmer.Dimmable> |
||||
); |
); |
||||
|
}, [authorAvatarLink, history, loading, numberOfReplies, t, timeAgo, topicAuthor, topicId, topicSubject]); |
||||
}; |
}; |
||||
|
|
||||
const TopicData = PropTypes.PropTypes.shape({ |
TopicListRow.defaultProps = { |
||||
userAddress: PropTypes.string.isRequired, |
loading: false, |
||||
username: PropTypes.string.isRequired, |
}; |
||||
timestamp: PropTypes.number.isRequired, |
|
||||
numberOfReplies: PropTypes.number.isRequired, |
|
||||
}); |
|
||||
|
|
||||
TopicListRow.propTypes = { |
TopicListRow.propTypes = { |
||||
topicData: TopicData.isRequired, |
id: PropTypes.number.isRequired, |
||||
topicId: PropTypes.number.isRequired, |
topicCallHash: PropTypes.string, |
||||
|
loading: PropTypes.bool, |
||||
}; |
}; |
||||
|
|
||||
export default TopicListRow; |
export default memo(TopicListRow); |
||||
|
@ -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; |
||||
|
} |
@ -1,7 +1,3 @@ |
|||||
#topic-list{ |
#topic-list{ |
||||
height: 100%; |
height: 100%; |
||||
} |
} |
||||
|
|
||||
.list-item { |
|
||||
text-align: start; |
|
||||
} |
|
@ -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; |
@ -1,2 +0,0 @@ |
|||||
export const PLACEHOLDER_TYPE_TOPIC = 'PLACEHOLDER_TYPE_TOPIC'; |
|
||||
export const PLACEHOLDER_TYPE_POST = 'PLACEHOLDER_TYPE_POST'; |
|
@ -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; |
@ -0,0 +1,2 @@ |
|||||
|
export const REGISTER_STEP_SIGNUP = 'signup'; |
||||
|
export const REGISTER_STEP_PROFILE_INFORMATION = 'profile-information'; |
@ -0,0 +1,2 @@ |
|||||
|
export const TRANSACTION_SUCCESS = 'success'; |
||||
|
export const TRANSACTION_ERROR = 'error'; |
@ -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'; |
@ -0,0 +1 @@ |
|||||
|
export const FORUM_CONTRACT = 'Forum'; |
@ -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; |
@ -0,0 +1,8 @@ |
|||||
|
import { FORUM_CONTRACT } from '../ContractNames'; |
||||
|
import forumContractEvents from './ForumContractEvents'; |
||||
|
|
||||
|
const appEvents = { |
||||
|
[FORUM_CONTRACT]: forumContractEvents, |
||||
|
}; |
||||
|
|
||||
|
export default appEvents; |
@ -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; |
@ -0,0 +1,7 @@ |
|||||
|
export const POST_CONTENT = 'content'; |
||||
|
|
||||
|
const postsDatabaseKeys = [ |
||||
|
POST_CONTENT, |
||||
|
]; |
||||
|
|
||||
|
export default postsDatabaseKeys; |
@ -0,0 +1,7 @@ |
|||||
|
export const TOPIC_SUBJECT = 'subject'; |
||||
|
|
||||
|
const topicsDatabaseKeys = [ |
||||
|
TOPIC_SUBJECT, |
||||
|
]; |
||||
|
|
||||
|
export default topicsDatabaseKeys; |
@ -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; |
@ -0,0 +1,3 @@ |
|||||
|
#main-layout { |
||||
|
height: 100%; |
||||
|
} |
@ -1,11 +1,29 @@ |
|||||
import Web3 from 'web3'; |
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 = { |
const web3Options = { |
||||
web3, |
customProvider: web3, |
||||
}; |
}; |
||||
|
|
||||
export default web3Options; |
export default web3Options; |
||||
|
@ -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; |
@ -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; |
@ -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; |
@ -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!'); |
||||
|
} |
@ -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 |
||||
|
} |
@ -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; |
@ -1,31 +1,33 @@ |
|||||
import React, { |
import React, { |
||||
useContext, useEffect, useMemo, useState, |
memo, useEffect, useMemo, useState, |
||||
} from 'react'; |
} from 'react'; |
||||
import { Container } from 'semantic-ui-react'; |
import { Container } from 'semantic-ui-react'; |
||||
import { useSelector } from 'react-redux'; |
import { useSelector } from 'react-redux'; |
||||
import AppContext from '../../components/AppContext'; |
|
||||
import Board from './Board'; |
import Board from './Board'; |
||||
import './styles.css'; |
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 Home = () => { |
||||
const { drizzle: { contracts: { Forum: { methods: { getNumberOfTopics } } } } } = useContext(AppContext.Context); |
|
||||
const [numberOfTopicsCallHash, setNumberOfTopicsCallHash] = useState(''); |
const [numberOfTopicsCallHash, setNumberOfTopicsCallHash] = useState(''); |
||||
const getNumberOfTopicsResults = useSelector((state) => state.contracts.Forum.getNumberOfTopics); |
const getNumberOfTopicsResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getNumberOfTopics); |
||||
|
|
||||
useEffect(() => { |
useEffect(() => { |
||||
setNumberOfTopicsCallHash(getNumberOfTopics.cacheCall()); |
setNumberOfTopicsCallHash(getNumberOfTopics.cacheCall()); |
||||
}, [getNumberOfTopics]); |
}, []); |
||||
|
|
||||
const numberOfTopics = useMemo(() => (getNumberOfTopicsResults[numberOfTopicsCallHash] !== undefined |
const numberOfTopics = useMemo(() => (getNumberOfTopicsResults[numberOfTopicsCallHash] !== undefined |
||||
? parseInt(getNumberOfTopicsResults[numberOfTopicsCallHash].value, 10) |
? parseInt(getNumberOfTopicsResults[numberOfTopicsCallHash].value, 10) |
||||
: null), |
: null), |
||||
[getNumberOfTopicsResults, numberOfTopicsCallHash]); |
[getNumberOfTopicsResults, numberOfTopicsCallHash]); |
||||
|
|
||||
return ( |
return useMemo(() => ( |
||||
<Container id="home-container" textAlign="center"> |
<Container id="home-container" textAlign="center"> |
||||
{numberOfTopics !== null && <Board numberOfTopics={numberOfTopics} />} |
{numberOfTopics !== null && <Board numberOfTopics={numberOfTopics} />} |
||||
</Container> |
</Container> |
||||
); |
), [numberOfTopics]); |
||||
}; |
}; |
||||
|
|
||||
export default Home; |
export default memo(Home); |
||||
|
@ -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; |
@ -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; |
@ -0,0 +1,6 @@ |
|||||
|
.general-tab-profile-picture { |
||||
|
max-width: 112px; |
||||
|
max-height: 112px; |
||||
|
margin: 0; |
||||
|
vertical-align: middle; |
||||
|
} |
@ -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); |
@ -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; |
@ -0,0 +1,7 @@ |
|||||
|
.register-form-profile-picture-wrapper { |
||||
|
text-align: center; |
||||
|
} |
||||
|
|
||||
|
.register-form-profile-picture { |
||||
|
max-height: 30vh; |
||||
|
} |
@ -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; |
@ -1,18 +1,197 @@ |
|||||
import React from 'react'; |
import React, { useEffect, useState } from 'react'; |
||||
import PropTypes from 'prop-types'; |
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 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 ( |
return ( |
||||
<div> |
<Container id="topic-container" textAlign="center"> |
||||
TODO |
<Dimmer.Dimmable |
||||
</div> |
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 = { |
TopicView.propTypes = { |
||||
topicId: PropTypes.number.isRequired, |
topicId: PropTypes.number.isRequired, |
||||
|
topicAuthorAddress: PropTypes.string, |
||||
|
topicAuthor: PropTypes.string, |
||||
|
timestamp: PropTypes.number, |
||||
|
postIds: PropTypes.arrayOf(PropTypes.number), |
||||
}; |
}; |
||||
|
|
||||
export default TopicView; |
export default TopicView; |
||||
|
@ -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; |
||||
|
} |
@ -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 |
||||
|
``` |
@ -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, |
||||
|
}, |
||||
|
}; |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue