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, |
|||
"workspaces": { |
|||
"packages": ["packages/*"], |
|||
"nohoist": ["**/web3", "**/web3/**"] |
|||
"packages": [ |
|||
"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 { Container, Progress } from 'semantic-ui-react'; |
|||
|
|||
// CSS |
|||
import '../assets/css/loading-component.css'; |
|||
|
|||
// Images |
|||
import ethereumLogo from '../assets/images/ethereum_logo.svg'; |
|||
import ipfsLogo from '../assets/images/ipfs_logo.svg'; |
|||
import orbitdbLogo from '../assets/images/orbitdb_logo.svg'; |
|||
import appLogo from '../assets/images/app_logo.png'; |
|||
import metamaskLogo from '../../../assets/images/metamask_logo.svg'; |
|||
import ethereumLogo from '../../../assets/images/ethereum_logo.svg'; |
|||
import ipfsLogo from '../../../assets/images/ipfs_logo.svg'; |
|||
import orbitdbLogo from '../../../assets/images/orbitdb_logo.svg'; |
|||
import appLogo from '../../../assets/images/app_logo.png'; |
|||
|
|||
const LoadingComponent = (props) => { |
|||
useEffect(() => function cleanup() { |
|||
document.body.classList.add('app'); |
|||
}, []); |
|||
|
|||
const { |
|||
imageType, messageList, progressType, title, message, progress, |
|||
} = props; |
|||
let imageSrc; let imageAlt; let listItems; let indicating; let |
|||
error; |
|||
|
|||
if (imageType === 'ethereum') { |
|||
if (imageType === 'metamask') { |
|||
imageSrc = metamaskLogo; |
|||
imageAlt = 'metamask_logo'; |
|||
} else if (imageType === 'ethereum') { |
|||
imageSrc = ethereumLogo; |
|||
imageAlt = 'ethereum_logo'; |
|||
} else if (imageType === 'ipfs') { |
@ -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 { List } from 'semantic-ui-react'; |
|||
import React, { |
|||
memo, useEffect, useMemo, useState, |
|||
} from 'react'; |
|||
import { |
|||
Dimmer, Grid, Image, List, Placeholder, |
|||
} from 'semantic-ui-react'; |
|||
import PropTypes from 'prop-types'; |
|||
import { useTranslation } from 'react-i18next'; |
|||
import TimeAgo from 'react-timeago'; |
|||
import { useHistory } from 'react-router'; |
|||
import { useDispatch, useSelector } from 'react-redux'; |
|||
import AppContext from '../../AppContext'; |
|||
import { Link } from 'react-router-dom'; |
|||
import { FETCH_USER_DATABASE } from '../../../redux/actions/peerDbReplicationActions'; |
|||
import { breeze } from '../../../redux/store'; |
|||
import './styles.css'; |
|||
import { TOPICS_DATABASE, USER_DATABASE } from '../../../constants/orbit/OrbitDatabases'; |
|||
import determineKVAddress from '../../../utils/orbitUtils'; |
|||
import { USER_PROFILE_PICTURE } from '../../../constants/orbit/UserDatabaseKeys'; |
|||
import { TOPIC_SUBJECT } from '../../../constants/orbit/TopicsDatabaseKeys'; |
|||
import { FORUM_CONTRACT } from '../../../constants/contracts/ContractNames'; |
|||
|
|||
const { orbit } = breeze; |
|||
|
|||
const TopicListRow = (props) => { |
|||
const { topicData, topicId } = props; |
|||
const { breeze: { orbit } } = useContext(AppContext.Context); |
|||
const [topicSubject, setTopicSubject] = useState(); |
|||
const { id: topicId, topicCallHash, loading } = props; |
|||
const getTopicResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getTopic); |
|||
const [numberOfReplies, setNumberOfReplies] = useState(null); |
|||
const [topicAuthorAddress, setTopicAuthorAddress] = useState(null); |
|||
const [topicAuthor, setTopicAuthor] = useState(null); |
|||
const [timeAgo, setTimeAgo] = useState(null); |
|||
const [topicSubject, setTopicSubject] = useState(null); |
|||
const [topicAuthorMeta, setTopicAuthorMeta] = useState(null); |
|||
const userAddress = useSelector((state) => state.user.address); |
|||
const topics = useSelector((state) => state.orbitData.topics); |
|||
const users = useSelector((state) => state.orbitData.users); |
|||
const dispatch = useDispatch(); |
|||
const history = useHistory(); |
|||
const { t } = useTranslation(); |
|||
|
|||
useEffect(() => { |
|||
if (!loading && topicCallHash && getTopicResults[topicCallHash] !== undefined) { |
|||
setTopicAuthorAddress(getTopicResults[topicCallHash].value[0]); |
|||
setTopicAuthor(getTopicResults[topicCallHash].value[1]); |
|||
setTimeAgo(getTopicResults[topicCallHash].value[2] * 1000); |
|||
setNumberOfReplies(getTopicResults[topicCallHash].value[3].length); |
|||
} |
|||
}, [getTopicResults, loading, topicCallHash]); |
|||
|
|||
useEffect(() => { |
|||
if (userAddress !== topicData.userAddress) { |
|||
if (topicAuthorAddress && userAddress !== topicAuthorAddress) { |
|||
dispatch({ |
|||
type: FETCH_USER_DATABASE, |
|||
orbit, |
|||
dbName: TOPICS_DATABASE, |
|||
userAddress: topicAuthorAddress, |
|||
}); |
|||
|
|||
dispatch({ |
|||
type: FETCH_USER_DATABASE, |
|||
orbit, |
|||
userAddress: topicData.userAddress, |
|||
dbName: USER_DATABASE, |
|||
userAddress: topicAuthorAddress, |
|||
}); |
|||
} |
|||
}, [dispatch, orbit, topicData.userAddress, topicId, userAddress]); |
|||
}, [dispatch, topicAuthorAddress, userAddress]); |
|||
|
|||
useEffect(() => { |
|||
const topicFound = topics |
|||
.find((topic) => topic.id === topicId); |
|||
|
|||
if (topicFound) { |
|||
setTopicSubject(topicFound); |
|||
setTopicSubject(topicFound[TOPIC_SUBJECT]); |
|||
} |
|||
}, [topicId, topics]); |
|||
|
|||
useEffect(() => { |
|||
if (topicAuthorAddress !== null) { |
|||
determineKVAddress({ orbit, dbName: USER_DATABASE, userAddress: topicAuthorAddress }) |
|||
.then((userOrbitAddress) => { |
|||
const userFound = users |
|||
.find((user) => user.id === userOrbitAddress); |
|||
|
|||
if (userFound) { |
|||
setTopicAuthorMeta(userFound); |
|||
} |
|||
}) |
|||
.catch((error) => { |
|||
console.error('Error during determination of key-value DB address:', error); |
|||
}); |
|||
} |
|||
}, [topicAuthorAddress, users]); |
|||
|
|||
const stopClickPropagation = (event) => { |
|||
event.stopPropagation(); |
|||
}; |
|||
|
|||
const authorAvatar = useMemo(() => (topicAuthorMeta !== null && topicAuthorMeta[USER_PROFILE_PICTURE] |
|||
? ( |
|||
<Image |
|||
className="profile-picture" |
|||
avatar |
|||
src={topicAuthorMeta[USER_PROFILE_PICTURE]} |
|||
/> |
|||
) |
|||
: ( |
|||
<List.Icon |
|||
name="user circle" |
|||
size="big" |
|||
inverted |
|||
color="black" |
|||
verticalAlign="middle" |
|||
/> |
|||
)), [topicAuthorMeta]); |
|||
|
|||
const authorAvatarLink = useMemo(() => { |
|||
if (topicAuthorAddress) { |
|||
return ( |
|||
<Link to={`/users/${topicAuthorAddress}`} onClick={stopClickPropagation}> |
|||
{authorAvatar} |
|||
</Link> |
|||
); |
|||
} |
|||
|
|||
return authorAvatar; |
|||
}, [authorAvatar, topicAuthorAddress]); |
|||
|
|||
return useMemo(() => { |
|||
const handleTopicClick = () => { |
|||
history.push(`/topics/${topicId}`); |
|||
}; |
|||
|
|||
return ( |
|||
<> |
|||
<Dimmer.Dimmable as={List.Item} onClick={handleTopicClick} blurring dimmed={loading} className="list-item"> |
|||
{authorAvatarLink} |
|||
<List.Content className="list-content"> |
|||
<List.Header> |
|||
<List.Icon name="right triangle" /> |
|||
{topicSubject && topicSubject.subject} |
|||
<Grid> |
|||
<Grid.Column floated="left" width={14}> |
|||
{topicSubject !== null |
|||
? topicSubject |
|||
: <Placeholder><Placeholder.Line length="very long" /></Placeholder>} |
|||
</Grid.Column> |
|||
<Grid.Column floated="right" width={2} textAlign="right"> |
|||
<span className="topic-metadata"> |
|||
{t('topic.list.row.topic.id', { id: topicId })} |
|||
</span> |
|||
</Grid.Column> |
|||
</Grid> |
|||
</List.Header> |
|||
<List.Content> |
|||
{topicData.username} |
|||
{topicData.numberOfReplies} |
|||
{' '} |
|||
replies |
|||
timestamp |
|||
<List.Description> |
|||
<Grid verticalAlign="middle"> |
|||
<Grid.Column floated="left" width={14}> |
|||
{topicAuthor !== null && timeAgo !== null |
|||
? ( |
|||
<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> |
|||
</> |
|||
</Dimmer.Dimmable> |
|||
); |
|||
}, [authorAvatarLink, history, loading, numberOfReplies, t, timeAgo, topicAuthor, topicId, topicSubject]); |
|||
}; |
|||
|
|||
const TopicData = PropTypes.PropTypes.shape({ |
|||
userAddress: PropTypes.string.isRequired, |
|||
username: PropTypes.string.isRequired, |
|||
timestamp: PropTypes.number.isRequired, |
|||
numberOfReplies: PropTypes.number.isRequired, |
|||
}); |
|||
TopicListRow.defaultProps = { |
|||
loading: false, |
|||
}; |
|||
|
|||
TopicListRow.propTypes = { |
|||
topicData: TopicData.isRequired, |
|||
topicId: PropTypes.number.isRequired, |
|||
id: PropTypes.number.isRequired, |
|||
topicCallHash: PropTypes.string, |
|||
loading: PropTypes.bool, |
|||
}; |
|||
|
|||
export default TopicListRow; |
|||
export default memo(TopicListRow); |
|||
|
@ -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{ |
|||
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_HOST_DEFAULT, |
|||
WEB3_PORT_DEFAULT, |
|||
WEB3_PORT_SOCKET_CONNECT_MAX_ATTEMPTS_DEFAULT, |
|||
WEB3_PORT_SOCKET_TIMEOUT_DEFAULT, |
|||
} from '../constants/configuration/defaults'; |
|||
|
|||
const { WEB3_URL, WEB3_PORT } = process.env; |
|||
const { WEB3_HOST, WEB3_PORT, WEBSOCKET_TIMEOUT } = process.env; |
|||
|
|||
const web3 = new Web3(Web3.givenProvider || `ws://${WEB3_URL}:${WEB3_PORT}`); |
|||
const web3WebsocketOptions = { |
|||
keepAlive: true, |
|||
timeout: WEBSOCKET_TIMEOUT !== undefined ? WEBSOCKET_TIMEOUT : WEB3_PORT_SOCKET_TIMEOUT_DEFAULT, |
|||
reconnect: { |
|||
maxAttempts: WEB3_PORT_SOCKET_CONNECT_MAX_ATTEMPTS_DEFAULT, |
|||
}, |
|||
}; |
|||
|
|||
const web3 = (WEB3_HOST !== undefined && WEB3_PORT !== undefined) |
|||
? new Web3.providers.WebsocketProvider(`ws://${WEB3_HOST}:${WEB3_PORT}`) |
|||
: new Web3(Web3.givenProvider || new Web3.providers.WebsocketProvider( |
|||
`ws://${WEB3_HOST_DEFAULT}:${WEB3_PORT_DEFAULT}`, web3WebsocketOptions, |
|||
)); |
|||
|
|||
const web3Options = { |
|||
web3, |
|||
customProvider: web3, |
|||
}; |
|||
|
|||
export default web3Options; |
|||
|
@ -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, { |
|||
useContext, useEffect, useMemo, useState, |
|||
memo, useEffect, useMemo, useState, |
|||
} from 'react'; |
|||
import { Container } from 'semantic-ui-react'; |
|||
import { useSelector } from 'react-redux'; |
|||
import AppContext from '../../components/AppContext'; |
|||
import Board from './Board'; |
|||
import './styles.css'; |
|||
import { drizzle } from '../../redux/store'; |
|||
import { FORUM_CONTRACT } from '../../constants/contracts/ContractNames'; |
|||
|
|||
const { contracts: { [FORUM_CONTRACT]: { methods: { getNumberOfTopics } } } } = drizzle; |
|||
|
|||
const Home = () => { |
|||
const { drizzle: { contracts: { Forum: { methods: { getNumberOfTopics } } } } } = useContext(AppContext.Context); |
|||
const [numberOfTopicsCallHash, setNumberOfTopicsCallHash] = useState(''); |
|||
const getNumberOfTopicsResults = useSelector((state) => state.contracts.Forum.getNumberOfTopics); |
|||
const getNumberOfTopicsResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getNumberOfTopics); |
|||
|
|||
useEffect(() => { |
|||
setNumberOfTopicsCallHash(getNumberOfTopics.cacheCall()); |
|||
}, [getNumberOfTopics]); |
|||
}, []); |
|||
|
|||
const numberOfTopics = useMemo(() => (getNumberOfTopicsResults[numberOfTopicsCallHash] !== undefined |
|||
? parseInt(getNumberOfTopicsResults[numberOfTopicsCallHash].value, 10) |
|||
: null), |
|||
[getNumberOfTopicsResults, numberOfTopicsCallHash]); |
|||
|
|||
return ( |
|||
return useMemo(() => ( |
|||
<Container id="home-container" textAlign="center"> |
|||
{numberOfTopics !== null && <Board numberOfTopics={numberOfTopics} />} |
|||
</Container> |
|||
); |
|||
), [numberOfTopics]); |
|||
}; |
|||
|
|||
export default Home; |
|||
export default memo(Home); |
|||
|
@ -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 { useDispatch, useSelector } from 'react-redux'; |
|||
import { |
|||
Container, Dimmer, Icon, Image, Placeholder, Step, |
|||
} from 'semantic-ui-react'; |
|||
import { Link } from 'react-router-dom'; |
|||
import { useHistory } from 'react-router'; |
|||
import TimeAgo from 'react-timeago'; |
|||
import { breeze, drizzle } from '../../../redux/store'; |
|||
import { FETCH_USER_DATABASE } from '../../../redux/actions/peerDbReplicationActions'; |
|||
import './styles.css'; |
|||
import PostList from '../../../components/PostList'; |
|||
import { TOPICS_DATABASE, USER_DATABASE } from '../../../constants/orbit/OrbitDatabases'; |
|||
import determineKVAddress from '../../../utils/orbitUtils'; |
|||
import { USER_PROFILE_PICTURE } from '../../../constants/orbit/UserDatabaseKeys'; |
|||
import { TOPIC_SUBJECT } from '../../../constants/orbit/TopicsDatabaseKeys'; |
|||
import PostCreate from '../../../components/PostCreate'; |
|||
import { FORUM_CONTRACT } from '../../../constants/contracts/ContractNames'; |
|||
|
|||
const { contracts: { [FORUM_CONTRACT]: { methods: { getTopic: { cacheCall: getTopicChainData } } } } } = drizzle; |
|||
const { orbit } = breeze; |
|||
|
|||
const TopicView = (props) => { |
|||
const { topicId } = props; |
|||
const { |
|||
topicId, topicAuthorAddress: initialTopicAuthorAddress, topicAuthor: initialTopicAuthor, |
|||
timestamp: initialTimestamp, postIds: initialPostIds, |
|||
} = props; |
|||
const drizzleInitialized = useSelector((state) => state.drizzleStatus.initialized); |
|||
const drizzleInitializationFailed = useSelector((state) => state.drizzleStatus.failed); |
|||
const userAddress = useSelector((state) => state.user.address); |
|||
const hasSignedUp = useSelector((state) => state.user.hasSignedUp); |
|||
const getTopicResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getTopic); |
|||
const topics = useSelector((state) => state.orbitData.topics); |
|||
const users = useSelector((state) => state.orbitData.users); |
|||
const [getTopicCallHash, setGetTopicCallHash] = useState([]); |
|||
const [topicAuthorAddress, setTopicAuthorAddress] = useState(initialTopicAuthorAddress || null); |
|||
const [topicAuthor, setTopicAuthor] = useState(initialTopicAuthor || null); |
|||
const [topicAuthorMeta, setTopicAuthorMeta] = useState(null); |
|||
const [timestamp, setTimestamp] = useState(initialTimestamp || null); |
|||
const [postIds, setPostIds] = useState(initialPostIds || null); |
|||
const [topicSubject, setTopicSubject] = useState(null); |
|||
const history = useHistory(); |
|||
const dispatch = useDispatch(); |
|||
|
|||
useEffect(() => { |
|||
const shouldGetTopicDataFromChain = topicAuthorAddress === null |
|||
|| topicAuthor === null |
|||
|| timestamp === null |
|||
|| postIds === null; |
|||
|
|||
if (drizzleInitialized && !drizzleInitializationFailed && shouldGetTopicDataFromChain) { |
|||
setGetTopicCallHash(getTopicChainData(topicId)); |
|||
} |
|||
}, [ |
|||
drizzleInitializationFailed, drizzleInitialized, postIds, timestamp, topicAuthor, topicAuthorAddress, topicId, |
|||
]); |
|||
|
|||
useEffect(() => { |
|||
if (getTopicCallHash && getTopicResults && getTopicResults[getTopicCallHash]) { |
|||
if (getTopicResults[getTopicCallHash].value == null) { |
|||
history.push('/'); |
|||
return; |
|||
} |
|||
|
|||
setTopicAuthorAddress(getTopicResults[getTopicCallHash].value[0]); |
|||
setTopicAuthor(getTopicResults[getTopicCallHash].value[1]); |
|||
setTimestamp(getTopicResults[getTopicCallHash].value[2] * 1000); |
|||
setPostIds(getTopicResults[getTopicCallHash].value[3].map((postId) => parseInt(postId, 10))); |
|||
|
|||
const topicFound = topics |
|||
.find((topic) => topic.id === topicId); |
|||
|
|||
if (topicFound === undefined && userAddress !== getTopicResults[getTopicCallHash].value[0]) { |
|||
dispatch({ |
|||
type: FETCH_USER_DATABASE, |
|||
orbit, |
|||
dbName: TOPICS_DATABASE, |
|||
userAddress: getTopicResults[getTopicCallHash].value[0], |
|||
}); |
|||
} |
|||
} |
|||
}, [dispatch, getTopicCallHash, getTopicResults, history, topicId, topics, userAddress]); |
|||
|
|||
useEffect(() => { |
|||
if (topicAuthorAddress !== null) { |
|||
determineKVAddress({ orbit, dbName: USER_DATABASE, userAddress: topicAuthorAddress }) |
|||
.then((userOrbitAddress) => { |
|||
const userFound = users |
|||
.find((user) => user.id === userOrbitAddress); |
|||
|
|||
if (userFound) { |
|||
setTopicAuthorMeta(userFound); |
|||
} else { |
|||
dispatch({ |
|||
type: FETCH_USER_DATABASE, |
|||
orbit, |
|||
dbName: USER_DATABASE, |
|||
userAddress: topicAuthorAddress, |
|||
}); |
|||
} |
|||
}) |
|||
.catch((error) => { |
|||
console.error('Error during determination of key-value DB address:', error); |
|||
}); |
|||
} |
|||
}, [dispatch, topicAuthorAddress, users]); |
|||
|
|||
useEffect(() => { |
|||
const topicFound = topics |
|||
.find((topic) => topic.id === topicId); |
|||
|
|||
if (topicFound) { |
|||
setTopicSubject(topicFound[TOPIC_SUBJECT]); |
|||
} |
|||
}, [topicId, topics]); |
|||
|
|||
return ( |
|||
<div> |
|||
TODO |
|||
</div> |
|||
<Container id="topic-container" textAlign="center"> |
|||
<Dimmer.Dimmable |
|||
blurring |
|||
dimmed={topicAuthorAddress === null && topicAuthor === null && timestamp === null} |
|||
> |
|||
<Step.Group fluid> |
|||
<Step key="topic-header-step-user"> |
|||
<Link to={`/users/${topicAuthorAddress}`}> |
|||
{topicAuthorMeta !== null && topicAuthorMeta[USER_PROFILE_PICTURE] |
|||
? ( |
|||
<Image |
|||
avatar |
|||
src={topicAuthorMeta[USER_PROFILE_PICTURE]} |
|||
/> |
|||
) |
|||
: ( |
|||
<Icon |
|||
name="user circle" |
|||
size="big" |
|||
inverted |
|||
color="black" |
|||
/> |
|||
)} |
|||
</Link> |
|||
<Step.Content> |
|||
<Step.Title> |
|||
<Link to={`/users/${topicAuthorAddress}`}> |
|||
{topicAuthor || ( |
|||
<Placeholder id="author-placeholder" inverted> |
|||
<Placeholder.Line length="full" /> |
|||
</Placeholder> |
|||
)} |
|||
</Link> |
|||
</Step.Title> |
|||
</Step.Content> |
|||
</Step> |
|||
<Step key="topic-header-step-title"> |
|||
<Step.Content> |
|||
<Step.Title> |
|||
{topicSubject || ( |
|||
<Placeholder id="subject-placeholder"> |
|||
<Placeholder.Line length="full" /> |
|||
</Placeholder> |
|||
)} |
|||
</Step.Title> |
|||
<Step.Description> |
|||
{timestamp |
|||
? <TimeAgo date={timestamp} /> |
|||
: ( |
|||
<Placeholder id="date-placeholder"> |
|||
<Placeholder.Line length="full" /> |
|||
</Placeholder> |
|||
)} |
|||
</Step.Description> |
|||
</Step.Content> |
|||
</Step> |
|||
</Step.Group> |
|||
</Dimmer.Dimmable> |
|||
<PostList postIds={postIds || []} loading={postIds === null} /> |
|||
{topicSubject !== null && postIds !== null && hasSignedUp && ( |
|||
<PostCreate |
|||
topicId={topicId} |
|||
postIndexInTopic={postIds.length + 1} |
|||
initialPostSubject={topicSubject} |
|||
/> |
|||
)} |
|||
</Container> |
|||
); |
|||
}; |
|||
|
|||
TopicView.propTypes = { |
|||
topicId: PropTypes.number.isRequired, |
|||
topicAuthorAddress: PropTypes.string, |
|||
topicAuthor: PropTypes.string, |
|||
timestamp: PropTypes.number, |
|||
postIds: PropTypes.arrayOf(PropTypes.number), |
|||
}; |
|||
|
|||
export default TopicView; |
|||
|
@ -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