Compare commits

..

No commits in common. "master" and "v2.8.6" have entirely different histories.

80 changed files with 5260 additions and 42124 deletions

View File

@ -1,43 +0,0 @@
name: Elixir CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Elixir
uses: erlef/setup-elixir@885971a72ed1f9240973bd92ab57af8c1aa68f24
with:
elixir-version: '1.12.2' # Define the elixir version [required]
otp-version: '24.0.4' # Define the OTP version [required]
- name: Restore dependencies cache
uses: actions/cache@v2
with:
working-directory: ./backend
path: deps
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- name: Install dependencies
working-directory: ./backend
run: |
mix local.hex --force
mix local.rebar --force
mix deps.get
- name: Compile dependencies
working-directory: ./backend
run: mix deps.compile
- name: Run Credo
working-directory: ./backend
run: mix credo --strict
- name: Run sobelow
working-directory: ./backend
run: mix sobelow --config

View File

@ -1,37 +0,0 @@
# This is a basic workflow to help you get started with Actions
name: CI
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the master branch
push:
branches: [ master ]
pull_request:
branches: [ master ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- name: Setup Node.js environment
uses: actions/setup-node@v2.3.0
- name: Setup deps
working-directory: ./frontend
run: yarn install
- name: Lint
working-directory: ./frontend
run: yarn lint

View File

@ -1,3 +1,10 @@
include:
template: Dependency-Scanning.gitlab-ci.yml
dependency_scanning:
only:
- schedules
test-frontend: test-frontend:
image: node:lts-alpine image: node:lts-alpine
stage: test stage: test
@ -13,16 +20,16 @@ test-frontend:
- frontend/.yarn - frontend/.yarn
only: only:
changes: changes:
- frontend/**/* - frontend/*
test-backend: test-backend:
stage: test stage: test
image: elixir:1.10 image: elixir:1.9
variables: variables:
MIX_ENV: test MIX_ENV: test
only: only:
changes: changes:
- backend/**/* - backend/*
before_script: before_script:
- cd backend - cd backend
script: script:
@ -60,4 +67,4 @@ deploy-gephi-production:
except: except:
- schedules - schedules
script: script:
- git-push dokku@api.fediverse.space:gephi master - git-push dokku@api.fediverse.space:gephi master

View File

@ -1,6 +1,7 @@
{ {
"recommendations": [ "recommendations": [
"jakebecker.elixir-ls", "jakebecker.elixir-ls",
"ms-vscode.vscode-typescript-tslint-plugin",
"kevinmcgowan.typescriptimport", "kevinmcgowan.typescriptimport",
"msjsdiag.debugger-for-chrome" "msjsdiag.debugger-for-chrome"
] ]

17
.vscode/launch.json vendored
View File

@ -5,15 +5,12 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"type": "mix_task", "type": "chrome",
"request": "launch", "request": "launch",
"name": "phx.server", "name": "Launch Chrome",
"task": "phx.server", "url": "http://localhost:3000",
"taskArgs": [], "webRoot": "${workspaceFolder}/frontend/src",
"projectDir": "${workspaceRoot}/backend", "runtimeExecutable": "/usr/bin/chromium-browser"
"env": { }
"SKIP_CRAWL": "1"
}
},
] ]
} }

View File

@ -19,70 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security ### Security
## [2.9.6 - 2020-10-13]
### Added
- Added link to personal website on About page.
### Fixed
- Allow `data:` images in Netlify CSP.
- Fix inability to DM login links in releases (#104).
## [2.9.5 - 2020-10-11]
### Fixed
- Fixed crawler not finding API in some cases
## [2.9.4 - 2020-10-09]
### Fixed
- Fix CSP issues for Plausible analytics
## [2.9.3 - 2020-10-09]
### Added
- Allow Plausible privacy-preserving analytics in CSP
### Changed
- Update dependencies
- Update to Elixir 1.10
### Fixed
- Fixed CSP headers for data: images
## [2.9.2 - 2020-08-31]
### Removed
- Remove staging server
## [2.9.1 - 2020-08-31]
### Fixed
- Added AppSignal logo to "Special thanks" section
## [2.9.0 - 2020-06-19]
### Changed
- Bring back `develop` staging backup (now managed in DNS)
- Increase default number of concurrent crawlers to 100
- Accessibility improvements (according to axe review)
- Update dependencies
### Security
- Add rate limiting of auth endpoints
- Added security headers to netlify frontend
- Sanitize crawled HTML in the backend
## [2.8.6 - 2020-01-16] ## [2.8.6 - 2020-01-16]
### Changed ### Changed

View File

@ -1,12 +1,12 @@
# index.community 🌐 # fediverse.space 🌐
The map of the fediverse that you always wanted. The map of the fediverse that you always wanted.
Read the latest updates on Mastodon: [@indexCommunity](https://social.inex.rocks/@indexCommunity) Read the latest updates on Mastodon: [@fediversespace](https://mastodon.social/@fediversespace)
![A screenshot of fediverse.space](screenshot.png) ![A screenshot of fediverse.space](screenshot.png)
- [index.community 🌐](#indexcommunity-%f0%9f%8c%90) - [fediverse.space 🌐](#fediversespace-%f0%9f%8c%90)
- [Requirements](#requirements) - [Requirements](#requirements)
- [Running it](#running-it) - [Running it](#running-it)
- [Backend](#backend) - [Backend](#backend)
@ -20,11 +20,9 @@ Read the latest updates on Mastodon: [@indexCommunity](https://social.inex.rocks
## Requirements ## Requirements
Note: examples here use `podman`. In most cases you should be able to replace `podman` with `docker`. Though dockerized, backend development is easiest if you have the following installed.
Though containerized, backend development is easiest if you have the following installed. - For the scraper + API:
- For the crawler + API:
- Elixir - Elixir
- Postgres - Postgres
- For laying out the graph: - For laying out the graph:
@ -38,11 +36,9 @@ Though containerized, backend development is easiest if you have the following i
### Backend ### Backend
- `cp example.env .env` and modify environment variables as required - `cp example.env .env` and modify environment variables as required
- `podman build gephi && podman build phoenix` - `docker-compose build`
- `podman run --name elasticsearch -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" elasticsearch:6.8.9` - `docker-compose up -d phoenix`
- If you've `run` this container previously, use `podman start elasticsearch` - if you don't specify `phoenix`, it'll also start `gephi` which should only be run as a regular one-off job
- `podman run --name postgres -e "POSTGRES_USER=postgres" -e "POSTGRES_PASSWORD=postgres" -p 5432:5432 postgres:12`
- `podman-compose -f compose.backend-services.yml -f compose.phoenix.yml`
- Create the elasticsearch index: - Create the elasticsearch index:
- `iex -S mix app.start` - `iex -S mix app.start`
- `Elasticsearch.Index.hot_swap(Backend.Elasticsearch.Cluster, :instances)` - `Elasticsearch.Index.hot_swap(Backend.Elasticsearch.Cluster, :instances)`
@ -57,6 +53,10 @@ Though containerized, backend development is easiest if you have the following i
### Backend ### Backend
`./gradlew shadowJar` compiles the graph layout program. `java -Xmx1g -jar build/libs/graphBuilder.jar` runs it. `./gradlew shadowJar` compiles the graph layout program. `java -Xmx1g -jar build/libs/graphBuilder.jar` runs it.
If running in docker, this means you run
- `docker-compose build gephi`
- `docker-compose run gephi java -Xmx1g -jar build/libs/graphBuilder.jar` lays out the graph
### Frontend ### Frontend
@ -102,6 +102,8 @@ SHELL=/bin/bash
0 2 * * * /usr/bin/dokku run gephi java -Xmx1g -jar build/libs/graphBuilder.jar 0 2 * * * /usr/bin/dokku run gephi java -Xmx1g -jar build/libs/graphBuilder.jar
``` ```
10. (Optional) Set up caching with something like [dokku-nginx-cache](https://github.com/Aluxian/dokku-nginx-cache)
Before the app starts running, make sure that the Elasticsearch index exists -- otherwise it'll create one called Before the app starts running, make sure that the Elasticsearch index exists -- otherwise it'll create one called
`instances`, which should be the name of the alias. Then it won't be able to hot swap if you reindex in the future. `instances`, which should be the name of the alias. Then it won't be able to hot swap if you reindex in the future.

View File

@ -1,7 +1,7 @@
FROM elixir:1.12-alpine as build FROM elixir:1.9.0-alpine as build
# install build dependencies # install build dependencies
RUN apk add --update git build-base RUN apk add --update git build-base
# prepare build dir # prepare build dir
RUN mkdir /app RUN mkdir /app
@ -37,7 +37,7 @@ RUN mix release
# prepare release image # prepare release image
FROM alpine:3.9 AS app FROM alpine:3.9 AS app
RUN apk add --update bash openssl libstdc++ build-base RUN apk add --update bash openssl
RUN mkdir /app RUN mkdir /app
WORKDIR /app WORKDIR /app

View File

@ -2,7 +2,7 @@
## Notes ## Notes
- This project requires Elixir >= 1.10. - This project requires Elixir >= 1.9.
- Run with `SKIP_CRAWL=true` to just run the server (useful for working on the API without also crawling) - Run with `SKIP_CRAWL=true` to just run the server (useful for working on the API without also crawling)
- This project is automatically scanned for potential vulnerabilities with [Sobelow](https://sobelow.io/). - This project is automatically scanned for potential vulnerabilities with [Sobelow](https://sobelow.io/).
@ -24,8 +24,6 @@ There are several environment variables you can set to configure how the crawler
- `FRONTEND_DOMAIN` (required). Used to generate login links for instance admins. - `FRONTEND_DOMAIN` (required). Used to generate login links for instance admins.
- Don't enter `https://`, this is added automatically. - Don't enter `https://`, this is added automatically.
- `SENDGRID_API_KEY`. Needed to send emails to the admin, or to instance admins who want to opt in/out. - `SENDGRID_API_KEY`. Needed to send emails to the admin, or to instance admins who want to opt in/out.
- `MASTODON_DOMAIN`. The domain (e.g. `mastodon.social`) that your bot login account is hosted on.
- `MASTODON_TOKEN`. The access token for the bot login account.
## Deployment ## Deployment

View File

@ -13,13 +13,15 @@ config :backend,
# Configures the endpoint # Configures the endpoint
config :backend, BackendWeb.Endpoint, config :backend, BackendWeb.Endpoint,
url: [host: "localhost"], url: [host: "localhost"],
secret_key_base: System.get_env("SECRET_KEY_BASE"), secret_key_base: "XL4NKGBN9lZMrQbMEI1KJOlwAt8S7younVJl90TdAgzmwyapr3g7BRYSNYvX0sZ9",
render_errors: [view: BackendWeb.ErrorView, accepts: ~w(json)] render_errors: [view: BackendWeb.ErrorView, accepts: ~w(json)],
pubsub: [name: Backend.PubSub, adapter: Phoenix.PubSub.PG2],
instrumenters: [Appsignal.Phoenix.Instrumenter]
config :backend, Backend.Repo, queue_target: 5000 config :backend, Backend.Repo, queue_target: 5000
config :backend, Backend.Elasticsearch.Cluster, config :backend, Backend.Elasticsearch.Cluster,
url: "http://elastic:9200", url: "http://localhost:9200",
api: Elasticsearch.API.HTTP, api: Elasticsearch.API.HTTP,
json_library: Jason json_library: Jason
@ -35,22 +37,19 @@ config :gollum,
# 24 hrs # 24 hrs
refresh_secs: 86_400, refresh_secs: 86_400,
lazy_refresh: true, lazy_refresh: true,
user_agent: "index.community crawler" user_agent: "fediverse.space crawler"
config :backend, Graph.Cache, config :backend, Graph.Cache,
# 1 hour # 1 hour
gc_interval: 3600 gc_interval: 3600
config :ex_twilio,
account_sid: System.get_env("TWILIO_ACCOUNT_SID"),
auth_token: System.get_env("TWILIO_AUTH_TOKEN")
config :backend, Backend.Mailer, config :backend, Backend.Mailer,
adapter: Swoosh.Adapters.SMTP, adapter: Swoosh.Adapters.Sendgrid,
relay: System.get_env("MAILER_RELAY"), api_key: System.get_env("SENDGRID_API_KEY")
username: System.get_env("MAILER_USERNAME"),
password: System.get_env("MAILER_PASSWORD"),
ssl: true,
tls: :always,
auth: :always,
port: 465
config :backend, Mastodon.Messenger, config :backend, Mastodon.Messenger,
domain: System.get_env("MASTODON_DOMAIN"), domain: System.get_env("MASTODON_DOMAIN"),
@ -61,7 +60,7 @@ config :backend, :crawler,
status_count_limit: 5000, status_count_limit: 5000,
personal_instance_threshold: 10, personal_instance_threshold: 10,
crawl_interval_mins: 30, crawl_interval_mins: 30,
crawl_workers: 100, crawl_workers: 50,
blacklist: [ blacklist: [
# spam # spam
"gab.best", "gab.best",
@ -72,9 +71,10 @@ config :backend, :crawler,
# dummy instances used for pleroma CI # dummy instances used for pleroma CI
"pleroma.online" "pleroma.online"
], ],
user_agent: "index.community crawler", user_agent: "fediverse.space crawler",
require_bidirectional_mentions: false, require_bidirectional_mentions: false,
admin_phone: System.get_env("ADMIN_PHONE"), admin_phone: System.get_env("ADMIN_PHONE"),
twilio_phone: System.get_env("TWILIO_PHONE"),
admin_email: System.get_env("ADMIN_EMAIL") admin_email: System.get_env("ADMIN_EMAIL")
config :backend, Backend.Scheduler, config :backend, Backend.Scheduler,
@ -91,10 +91,6 @@ config :backend, Backend.Scheduler,
{"0 */3 * * *", {Backend.Scheduler, :check_for_spam_instances, []}} {"0 */3 * * *", {Backend.Scheduler, :check_for_spam_instances, []}}
] ]
config :phoenix, :template_engines,
eex: Appsignal.Phoenix.Template.EExEngine,
exs: Appsignal.Phoenix.Template.ExsEngine
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs" import_config "#{Mix.env()}.exs"

View File

@ -7,7 +7,7 @@ import Config
# watchers to your application. For example, we use it # watchers to your application. For example, we use it
# with webpack to recompile .js and .css sources. # with webpack to recompile .js and .css sources.
config :backend, BackendWeb.Endpoint, config :backend, BackendWeb.Endpoint,
http: [port: 4001], http: [port: 4000],
debug_errors: true, debug_errors: true,
code_reloader: true, code_reloader: true,
check_origin: false, check_origin: false,
@ -53,7 +53,7 @@ config :backend, Backend.Repo,
username: "postgres", username: "postgres",
password: "postgres", password: "postgres",
database: "backend_dev", database: "backend_dev",
hostname: "127.0.0.1:5435", hostname: "localhost",
pool_size: 10 pool_size: 10
config :backend, :crawler, config :backend, :crawler,

View File

@ -19,7 +19,7 @@ config :backend, Backend.Elasticsearch.Cluster,
config :appsignal, :config, revision: System.get_env("GIT_REV") config :appsignal, :config, revision: System.get_env("GIT_REV")
port = String.to_integer(System.get_env("PORT") || "4001") port = String.to_integer(System.get_env("PORT") || "4000")
config :backend, BackendWeb.Endpoint, config :backend, BackendWeb.Endpoint,
http: [:inet6, port: port], http: [:inet6, port: port],
@ -28,20 +28,16 @@ config :backend, BackendWeb.Endpoint,
secret_key_base: System.get_env("SECRET_KEY_BASE"), secret_key_base: System.get_env("SECRET_KEY_BASE"),
server: true server: true
config :ex_twilio,
account_sid: System.get_env("TWILIO_ACCOUNT_SID"),
auth_token: System.get_env("TWILIO_AUTH_TOKEN")
config :backend, :crawler, config :backend, :crawler,
admin_phone: System.get_env("ADMIN_PHONE"), admin_phone: System.get_env("ADMIN_PHONE"),
twilio_phone: System.get_env("TWILIO_PHONE"),
admin_email: System.get_env("ADMIN_EMAIL"), admin_email: System.get_env("ADMIN_EMAIL"),
frontend_domain: System.get_env("FRONTEND_DOMAIN") frontend_domain: System.get_env("FRONTEND_DOMAIN")
config :backend, Backend.Mailer, config :backend, Backend.Mailer,
adapter: Swoosh.Adapters.SMTP, adapter: Swoosh.Adapters.Sendgrid,
relay: System.get_env("MAILER_RELAY"), api_key: System.get_env("SENDGRID_API_KEY")
username: System.get_env("MAILER_USERNAME"),
password: System.get_env("MAILER_PASSWORD"),
ssl: true,
auth: :always,
port: 465
config :backend, Mastodon.Messenger,
domain: System.get_env("MASTODON_DOMAIN"),
token: System.get_env("MASTODON_TOKEN")

View File

@ -1,37 +0,0 @@
version: "2"
networks:
space:
external: false
services:
server:
build: .
restart: unless-stopped
networks:
- space
volumes:
- /home/gitea/data:/data
depends_on:
- db
db:
image: postgres:12-alpine
restart: unless-stopped
environment:
- POSTGRES_PASSWORD: postgres
- POSTGRES_USER: postgres
networks:
- space
volumes:
- /var/lib/postgresql/data
elastic:
image: elasticsearch:6.8.9
restart: unless-stopped
environment:
- discovery.type: single-node
networks:
- space

View File

@ -7,7 +7,6 @@ defmodule Backend.Application do
import Backend.Util import Backend.Util
def start(_type, _args) do def start(_type, _args) do
:telemetry.attach( :telemetry.attach(
"appsignal-ecto", "appsignal-ecto",
[:backend, :repo, :query], [:backend, :repo, :query],

View File

@ -18,7 +18,7 @@ defmodule Backend.Crawler.ApiCrawler do
# {domain, type} e.g. {"gab.com", "reject"} # {domain, type} e.g. {"gab.com", "reject"}
@type federation_restriction :: {String.t(), String.t()} @type federation_restriction :: {String.t(), String.t()}
@type instance_type :: :mastodon | :pleroma | :gab | :misskey | :gnusocial | :smithereen @type instance_type :: :mastodon | :pleroma | :gab | :misskey | :gnusocial
defstruct [ defstruct [
:version, :version,

View File

@ -159,8 +159,8 @@ defmodule Backend.Crawler do
## Update the instance we crawled ## ## Update the instance we crawled ##
instance = %Instance{ instance = %Instance{
domain: domain, domain: domain,
description: HtmlSanitizeEx.basic_html(result.description), description: result.description,
version: HtmlSanitizeEx.basic_html(result.version), version: result.version,
user_count: result.user_count, user_count: result.user_count,
status_count: result.status_count, status_count: result.status_count,
type: instance_type, type: instance_type,

View File

@ -12,7 +12,7 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
@impl ApiCrawler @impl ApiCrawler
def is_instance_type?(domain, result) do def is_instance_type?(domain, result) do
# We might already know that this is a Pleroma instance from nodeinfo # We might already know that this is a Pleroma instance from nodeinfo
if result != nil and (Map.get(result, :instance_type) == :pleroma or Map.get(result, :instance_type) == :smithereen) do if result != nil and Map.get(result, :instance_type) == :pleroma do
true true
else else
case get_and_decode("https://#{domain}/api/v1/instance") do case get_and_decode("https://#{domain}/api/v1/instance") do
@ -230,7 +230,6 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
defp get_instance_type(instance_stats) do defp get_instance_type(instance_stats) do
cond do cond do
Map.get(instance_stats, "version") |> String.downcase() =~ "pleroma" -> :pleroma Map.get(instance_stats, "version") |> String.downcase() =~ "pleroma" -> :pleroma
Map.get(instance_stats, "version") |> String.downcase() =~ "smithereen" -> :smithereen
is_gab?(instance_stats) -> :gab is_gab?(instance_stats) -> :gab
true -> :mastodon true -> :mastodon
end end

View File

@ -26,7 +26,7 @@ defmodule Backend.Crawler.StaleInstanceManager do
case instance_count do case instance_count do
# Add m.s. as the seed and schedule the next add # Add m.s. as the seed and schedule the next add
0 -> 0 ->
add_to_queue("mastodon.ml") add_to_queue("mastodon.social")
schedule_add() schedule_add()
# Start immediately # Start immediately

View File

@ -14,7 +14,7 @@ defmodule Backend.Release do
] ]
# Ecto repos to start, if any # Ecto repos to start, if any
@repos Application.compile_env(:backend, :ecto_repos, []) @repos Application.get_env(:backend, :ecto_repos, [])
# Elasticsearch clusters to start # Elasticsearch clusters to start
@clusters [Backend.Elasticsearch.Cluster] @clusters [Backend.Elasticsearch.Cluster]
# Elasticsearch indexes to build # Elasticsearch indexes to build

View File

@ -3,7 +3,7 @@ defmodule Backend.Scheduler do
This module runs recurring tasks. This module runs recurring tasks.
""" """
use Quantum, otp_app: :backend use Quantum.Scheduler, otp_app: :backend
alias Backend.{Crawl, CrawlInteraction, Edge, FederationRestriction, Instance, Repo} alias Backend.{Crawl, CrawlInteraction, Edge, FederationRestriction, Instance, Repo}
alias Backend.Mailer.AdminEmail alias Backend.Mailer.AdminEmail
@ -280,6 +280,7 @@ defmodule Backend.Scheduler do
end).() end).()
Logger.info(message) Logger.info(message)
send_admin_sms(message)
AdminEmail.send("Potential spam", message) AdminEmail.send("Potential spam", message)
else else
Logger.debug("Did not find potential spam instances.") Logger.debug("Did not find potential spam instances.")

View File

@ -113,6 +113,21 @@ defmodule Backend.Util do
end) end)
end end
@doc """
Sends an SMS to the admin phone number if configured.
"""
def send_admin_sms(body) do
if get_config(:admin_phone) != nil and get_config(:twilio_phone) != nil do
ExTwilio.Message.create(
to: get_config(:admin_phone),
from: get_config(:twilio_phone),
body: body
)
else
Logger.info("Could not send SMS to admin; not configured.")
end
end
@spec clean_domain(String.t()) :: String.t() @spec clean_domain(String.t()) :: String.t()
def clean_domain(domain) do def clean_domain(domain) do
cleaned = cleaned =
@ -155,9 +170,7 @@ defmodule Backend.Util do
timeout: timeout timeout: timeout
) do ) do
{:ok, %{status_code: 200, body: body}} -> Jason.decode(body) {:ok, %{status_code: 200, body: body}} -> Jason.decode(body)
{:ok, %{status_code: 401}} -> Jason.decode("[]") {:ok, _} -> {:error, %HTTPoison.Error{reason: "Non-200 response"}}
{:ok, %{status_code: 404}} -> Jason.decode("[]")
{:ok, %{body: body}} -> {:error, %HTTPoison.Error{reason: "Non-200 response. Body: #{body}"}}
{:error, err} -> {:error, err} {:error, err} -> {:error, err}
end end
end end

View File

@ -46,7 +46,7 @@ defmodule BackendWeb.Endpoint do
) )
plug(Corsica, plug(Corsica,
origins: ["http://localhost:3001", ~r{^https://(.*\.?)index\.community$}, ~r{^https://(.*\.?)fediverse\.space$}], origins: ["http://localhost:3000", ~r{^https?://(.*\.?)fediverse\.space$}],
allow_headers: ["content-type", "token"] allow_headers: ["content-type", "token"]
) )

View File

@ -1,53 +0,0 @@
defmodule BackendWeb.RateLimiter do
@moduledoc """
Functions used to rate limit:
* all endpoints by IP/endpoint
* authentication endpoints by domain
"""
import Phoenix.Controller, only: [json: 2]
import Plug.Conn, only: [put_status: 2]
use Plug.Builder
def rate_limit(conn, options \\ []) do
case check_rate(conn, options) do
{:ok, _count} -> conn # Do nothing, allow execution to continue
{:error, _count} -> render_error(conn)
end
end
def rate_limit_authentication(conn, options \\ []) do
domain =
if Map.has_key?(conn.params, "id") do
Map.get(conn.params, "id")
else
Map.get(conn.params, "domain")
end
options = Keyword.put(options, :bucket_name, "authorization: #{domain}")
rate_limit(conn, options)
end
defp check_rate(conn, options) do
interval_milliseconds = options[:interval_seconds] * 1000
max_requests = options[:max_requests]
bucket_name = options[:bucket_name] || bucket_name(conn)
ExRated.check_rate(bucket_name, interval_milliseconds, max_requests)
end
# Bucket name should be a combination of ip address and request path, like so:
#
# "127.0.0.1:/api/v1/authorizations"
defp bucket_name(conn) do
path = Enum.join(conn.path_info, "/")
ip = conn.remote_ip |> Tuple.to_list |> Enum.join(".")
"#{ip}:#{path}"
end
defp render_error(conn) do
conn
|> put_status(:forbidden)
|> json(%{error: "Rate limit exceeded."})
|> halt # Stop execution of further plugs, return response now
end
end

View File

@ -1,14 +1,8 @@
defmodule BackendWeb.Router do defmodule BackendWeb.Router do
use BackendWeb, :router use BackendWeb, :router
import BackendWeb.RateLimiter
pipeline :api do pipeline :api do
plug(:accepts, ["json"]) plug(:accepts, ["json"])
plug(:rate_limit, max_requests: 5, interval_seconds: 10) # requests to the same endpoint
end
pipeline :api_admin do
plug(:rate_limit_authentication, max_requests: 5, interval_seconds: 60)
end end
scope "/api", BackendWeb do scope "/api", BackendWeb do
@ -18,12 +12,8 @@ defmodule BackendWeb.Router do
resources("/graph", GraphController, only: [:index, :show]) resources("/graph", GraphController, only: [:index, :show])
resources("/search", SearchController, only: [:index]) resources("/search", SearchController, only: [:index])
scope "/admin" do resources("/admin/login", AdminLoginController, only: [:show, :create])
pipe_through :api_admin get "/admin", AdminController, :show
post "/admin", AdminController, :update
resources("/login", AdminLoginController, only: [:show, :create])
get "/", AdminController, :show
post "/", AdminController, :update
end
end end
end end

View File

@ -12,7 +12,7 @@ defmodule Backend.Mailer.AdminEmail do
if admin_email != nil do if admin_email != nil do
new() new()
|> to(admin_email) |> to(admin_email)
|> from("noreply@index.community") |> from("noreply@fediverse.space")
|> subject(subject) |> subject(subject)
|> text_body(body) |> text_body(body)
|> Backend.Mailer.deliver!() |> Backend.Mailer.deliver!()

View File

@ -16,8 +16,8 @@ defmodule Backend.Mailer.UserEmail do
new() new()
|> to(address) |> to(address)
|> from("noreply@index.community") |> from("noreply@fediverse.space")
|> subject("Login to index.community") |> subject("Login to fediverse.space")
|> text_body(body) |> text_body(body)
|> Backend.Mailer.deliver() |> Backend.Mailer.deliver()
end end

View File

@ -23,11 +23,11 @@ defmodule Backend.MixProject do
extra_applications: [ extra_applications: [
:logger, :logger,
:runtime_tools, :runtime_tools,
:mnesia,
:gollum, :gollum,
:ex_twilio,
:elasticsearch, :elasticsearch,
:appsignal, :appsignal
:swoosh,
:gen_smtp
] ]
] ]
end end
@ -41,33 +41,33 @@ defmodule Backend.MixProject do
# Type `mix help deps` for examples and options. # Type `mix help deps` for examples and options.
defp deps do defp deps do
[ [
{:phoenix, "~> 1.5"}, {:phoenix, "~> 1.4.3"},
{:phoenix_pubsub, "~> 2.0"}, {:phoenix_pubsub, "~> 1.1"},
{:phoenix_ecto, "~> 4.0"}, {:phoenix_ecto, "~> 4.0"},
{:ecto_sql, "~> 3.0"}, {:ecto_sql, "~> 3.0"},
{:postgrex, ">= 0.0.0"}, {:postgrex, ">= 0.0.0"},
{:gettext, "~> 0.11"}, {:gettext, "~> 0.11"},
{:jason, "~> 1.0"}, {:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.1"}, {:plug_cowboy, "~> 2.0"},
{:httpoison, "~> 1.7", override: true}, {:httpoison, "~> 1.5"},
{:timex, "~> 3.5"}, {:timex, "~> 3.5"},
{:honeydew, "~> 1.5.0"}, {:honeydew, "~> 1.4.3"},
{:quantum, "~> 3.3"}, {:quantum, "~> 2.3"},
{:corsica, "~> 1.1.2"}, {:corsica, "~> 1.1.2"},
{:sobelow, "~> 0.8", only: [:dev, :test]}, {:sobelow, "~> 0.8", only: [:dev, :test]},
{:gollum, "~> 0.3.2"}, {:gollum, "~> 0.3.2"},
{:public_suffix, git: "https://github.com/axelson/publicsuffix-elixir"}, {:public_suffix, "~> 0.6.0"},
{:swoosh, "~> 1.0"}, {:idna, "~> 5.1.2", override: true},
{:gen_smtp, "~> 1.1"}, {:swoosh, "~> 0.23.3"},
{:ex_twilio, "~> 0.7.0"},
{:elasticsearch, "~> 1.0"}, {:elasticsearch, "~> 1.0"},
{:appsignal, "~> 1.0"}, {:appsignal, "~> 1.10.1"},
{:credo, "~> 1.1", only: [:dev, :test], runtime: false}, {:credo, "~> 1.1", only: [:dev, :test], runtime: false},
{:nebulex, "~> 1.1"}, {:nebulex, "~> 1.1"},
{:hunter, "~> 0.5.1"}, {:hunter, "~> 0.5.1"},
{:poison, "~> 4.0", override: true},
{:scrivener_ecto, "~> 2.2"}, {:scrivener_ecto, "~> 2.2"},
{:recase, "~> 0.7"}, {:recase, "~> 0.6.0"}
{:ex_rated, "~> 2.0"},
{:html_sanitize_ex, "~> 1.4"}
] ]
end end

View File

@ -1,66 +1,66 @@
%{ %{
"appsignal": {:hex, :appsignal, "1.13.5", "153ebe929fae8f637d43bf66058efecbb4affc4037caa466d31a236cb3f2e788", [:make, :mix], [{:decorator, "~> 1.2.3 or ~> 1.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, ">= 1.2.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.9", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, ">= 1.3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b8b6847c0d7f8ad03523be0fa6fdd670679ad42d62e2a8b74e599eba0247096c"}, "appsignal": {:hex, :appsignal, "1.10.13", "d5df34ac7dc2d937510716f2089cc5f1d45b3f10f38225d19c35f31810b9266d", [:make, :mix], [{:decorator, "~> 1.2.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.2.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, ">= 1.3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "artificery": {:hex, :artificery, "0.4.2", "3ded6e29e13113af52811c72f414d1e88f711410cac1b619ab3a2666bbd7efd4", [:mix], [], "hexpm"},
"certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"corsica": {:hex, :corsica, "1.1.3", "5f1de40bc9285753aa03afbdd10c364dac79b2ddbf2ba9c5c9c47b397ec06f40", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8156b3a14a114a346262871333a931a1766b2597b56bf994fcfcb65443a348ad"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"}, "corsica": {:hex, :corsica, "1.1.2", "5ad8b9dcbeeda4762d78a57c0c8c2f88e1eef8741508517c98cb79e0db1f107d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
"credo": {:hex, :credo, "1.5.6", "e04cc0fdc236fefbb578e0c04bd01a471081616e741d386909e527ac146016c6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4b52a3e558bd64e30de62a648518a5ea2b6e3e5d2b164ef5296244753fc7eb17"}, "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm"},
"crontab": {:hex, :crontab, "1.1.10", "dc9bb1f4299138d47bce38341f5dcbee0aa6c205e864fba7bc847f3b5cb48241", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "1347d889d1a0eda997990876b4894359e34bfbbd688acbb0ba28a2795ca40685"}, "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"}, "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
"decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"}, "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"},
"ecto": {:hex, :ecto, "3.6.2", "efdf52acfc4ce29249bab5417415bd50abd62db7b0603b8bab0d7b996548c2bc", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "efad6dfb04e6f986b8a3047822b0f826d9affe8e4ebdd2aeedbfcb14fd48884e"}, "decorator": {:hex, :decorator, "1.2.4", "31dfff6143d37f0b68d0bffb3b9f18ace14fea54d4f1b5e4f86ead6f00d9ff6e", [:mix], [], "hexpm"},
"ecto_sql": {:hex, :ecto_sql, "3.6.2", "9526b5f691701a5181427634c30655ac33d11e17e4069eff3ae1176c764e0ba3", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.6.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ec9d7e6f742ea39b63aceaea9ac1d1773d574ea40df5a53ef8afbd9242fdb6b"}, "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm"},
"elasticsearch": {:hex, :elasticsearch, "1.0.1", "8339538d90af6b280f10ecd02b1eae372f09373e629b336a13461babf7366495", [:mix], [{:httpoison, ">= 0.0.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sigaws, "~> 0.7", [hex: :sigaws, repo: "hexpm", optional: true]}, {:vex, "~> 0.6", [hex: :vex, repo: "hexpm", optional: false]}], "hexpm", "83e7d8b8bee3e7e19a06ab4d357d24845ac1da894e79678227fd52c0b7f71867"}, "ecto": {:hex, :ecto, "3.2.2", "bb6d1dbcd7ef975b60637e63182e56f3d7d0b5dd9c46d4b9d6183a5c455d65d1", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"ex2ms": {:hex, :ex2ms, "1.6.0", "f39bbd9ff1b0f27b3f707bab2d167066dd8965e7df1149b962d94c74615d0e09", [:mix], [], "hexpm", "0d1ab5e08421af5cd69146efb408dbb1ff77f38a2f4df5f086f2512dc8cf65bf"}, "ecto_sql": {:hex, :ecto_sql, "3.2.0", "751cea597e8deb616084894dd75cbabfdbe7255ff01e8c058ca13f0353a3921b", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
"ex_rated": {:hex, :ex_rated, "2.0.1", "49b4c170039fc62fa93ea28df16e3586e98c2fe0aec10f75e6717fba8039637f", [:mix], [{:ex2ms, "~> 1.5", [hex: :ex2ms, repo: "hexpm", optional: false]}], "hexpm", "2f675b649f74028842ae3d1f0c5090f8a664682df98c82836db6f1d321eaa42a"}, "elasticsearch": {:hex, :elasticsearch, "1.0.0", "626d3fb8e7554d9c93eb18817ae2a3d22c2a4191cc903c4644b1334469b15374", [:mix], [{:httpoison, ">= 0.0.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sigaws, "~> 0.7", [hex: :sigaws, repo: "hexpm", optional: true]}, {:vex, "~> 0.6.0", [hex: :vex, repo: "hexpm", optional: false]}], "hexpm"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "ex_twilio": {:hex, :ex_twilio, "0.7.0", "d7ce624ef4661311ae28c3e3aa060ecb66a9f4843184d7400c29072f7d3f5a4a", [:mix], [{:httpoison, ">= 0.9.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:inflex, "~> 1.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.0", [hex: :joken, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"gen_smtp": {:hex, :gen_smtp, "1.1.1", "bf9303c31735100631b1d708d629e4c65944319d1143b5c9952054f4a1311d85", [:rebar3], [{:hut, "1.3.0", [hex: :hut, repo: "hexpm", optional: false]}, {:ranch, ">= 1.7.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "51bc50cc017efd4a4248cbc39ea30fb60efa7d4a49688986fafad84434ff9ab7"}, "gen_stage": {:hex, :gen_stage, "0.14.2", "6a2a578a510c5bfca8a45e6b27552f613b41cf584b58210f017088d3d17d0b14", [:mix], [], "hexpm"},
"gen_stage": {:hex, :gen_stage, "1.1.0", "dd0c0f8d2f3b993fdbd3d58e94abbe65380f4e78bdee3fa93d5618d7d14abe60", [:mix], [], "hexpm", "7f2b36a6d02f7ef2ba410733b540ec423af65ec9c99f3d1083da508aca3b9305"}, "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"},
"gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, "gettext": {:hex, :gettext, "0.17.0", "abe21542c831887a2b16f4c94556db9c421ab301aee417b7c4fbde7fbdbe01ec", [:mix], [], "hexpm"},
"gollum": {:hex, :gollum, "0.3.3", "25ebb47700b9236bc4e5382bf91b72e4cdaf9bae3556172eff27e770735a198f", [:mix], [{:httpoison, "~> 1.5.1", [hex: :httpoison, repo: "hexpm", optional: false]}], "hexpm", "39268eeaf4f0adb6fdebe4f8c36b10a277881ab2eee3419c9b6727759e2f5a5d"}, "gollum": {:hex, :gollum, "0.3.3", "25ebb47700b9236bc4e5382bf91b72e4cdaf9bae3556172eff27e770735a198f", [:mix], [{:httpoison, "~> 1.5.1", [hex: :httpoison, repo: "hexpm", optional: false]}], "hexpm"},
"hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"honeydew": {:hex, :honeydew, "1.5.0", "53088c1d87399efa5c0939adc8d32a9713b8fe6ce00a77c6769d2d363abac6bc", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "f71669e25f6a972e970ecbd79c34c4ad4b28369be78e4f8164fe8d0c5a674907"}, "honeydew": {:hex, :honeydew, "1.4.5", "03818730602274ef0119652d664b92ddf733256e857d29899ce6841e01345bd1", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.1", "e8a67da405fe9f0d1be121a40a60f70811192033a5b8d00a95dddd807f5e053e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "68d92656f47cd73598c45ad2394561f025c8c65d146001b955fd7b517858962a"}, "httpoison": {:hex, :httpoison, "1.5.1", "0f55b5b673b03c5c327dac7015a67cb571b99b631acc0bc1b0b98dcd6b9f2104", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, "hunter": {:hex, :hunter, "0.5.1", "374dc4a800e2c340659657f8875e466075c7ea532e0d7a7787665f272b410150", [:mix], [{:httpoison, "~> 1.5", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 4.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"hunter": {:hex, :hunter, "0.5.1", "374dc4a800e2c340659657f8875e466075c7ea532e0d7a7787665f272b410150", [:mix], [{:httpoison, "~> 1.5", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 4.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "209b2cca7e4d51d5ff7ee4a0ab6cdc4c6ad23ddd61c9e12ceeee6f7ffbeae9c8"}, "idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"hut": {:hex, :hut, "1.3.0", "71f2f054e657c03f959cf1acc43f436ea87580696528ca2a55c8afb1b06c85e7", [:"erlang.mk", :rebar, :rebar3], [], "hexpm", "7e15d28555d8a1f2b5a3a931ec120af0753e4853a4c66053db354f35bf9ab563"}, "inflex": {:hex, :inflex, "1.10.0", "8366a7696e70e1813aca102e61274addf85d99f4a072b2f9c7984054ea1b9d29", [:mix], [], "hexpm"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "joken": {:hex, :joken, "2.1.0", "bf21a73105d82649f617c5e59a7f8919aa47013d2519ebcc39d998d8d12adda9", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"},
"joken": {:hex, :joken, "2.3.0", "62a979c46f2c81dcb8ddc9150453b60d3757d1ac393c72bb20fc50a7b0827dc6", [:mix], [{:jose, "~> 1.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "57b263a79c0ec5d536ac02d569c01e6b4de91bd1cb825625fe90eab4feb7bc1e"}, "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"},
"jose": {:hex, :jose, "1.11.1", "59da64010c69aad6cde2f5b9248b896b84472e99bd18f246085b7b9fe435dcdb", [:mix, :rebar3], [], "hexpm", "078f6c9fb3cd2f4cfafc972c814261a7d1e8d2b3685c0a76eb87e158efff1ac5"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
"mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "nebulex": {:hex, :nebulex, "1.1.0", "be45cc3a2b7d01eb7da05747d38072d336187d05796ad9ef2d9dad9be430f915", [:mix], [{:shards, "~> 0.6", [hex: :shards, repo: "hexpm", optional: false]}], "hexpm"},
"mochiweb": {:hex, :mochiweb, "2.21.0", "3fe5c3403606726d7bc6dabbf36f9d634d5364ce7f33ce73442937fa54feec37", [:rebar3], [], "hexpm", "f848bfa1b75c32d56da9d2730245e34df4b39079c5d45d7b966b072ba53f8a13"}, "paginator": {:hex, :paginator, "0.6.0", "bc2c01abdd98281ff39b6a7439cf540091122a7927bdaabc167c61d4508f9cbb", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm"},
"nebulex": {:hex, :nebulex, "1.2.2", "5b2bb7420a103b2a4278f354c9bd239bc77cd3bbdeddcebc4cc1d6ee656f126c", [:mix], [{:decorator, "~> 1.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:shards, "~> 0.6", [hex: :shards, repo: "hexpm", optional: false]}], "hexpm", "6804ddd7660fd4010a5af5957316ab7471c2db003189dba79dc3dd7b3f0aabf6"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "phoenix": {:hex, :phoenix, "1.4.10", "619e4a545505f562cd294df52294372d012823f4fd9d34a6657a8b242898c255", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix": {:hex, :phoenix, "1.5.9", "a6368d36cfd59d917b37c44386e01315bc89f7609a10a45a22f47c007edf2597", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e4bce20a67c012f1fbb0af90e5da49fa7bf0d34e3a067795703b74aef75427d"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.3.0", "2c69a452c2e0ee8c93345ae1cdc1696ef4877ff9cbb15c305def41960c3c4ebf", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "0ac491924217550c8f42c81c1f390b5d81517d12ceaf9abf3e701156760a848e"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"},
"plug": {:hex, :plug, "1.12.0", "39dc7f1ef8c46bb1bf6dd8f6a49f526c45b4b92ce553687fd885b559a46d0230", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5282c76e89efdf43f2e04bd268ca99d738039f9518137f02ff468cee3ba78096"}, "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"plug_cowboy": {:hex, :plug_cowboy, "2.5.1", "7cc96ff645158a94cf3ec9744464414f02287f832d6847079adfe0b58761cbd0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "107d0a5865fa92bcb48e631cc0729ae9ccfa0a9f9a1bd8f01acb513abf1c2d64"}, "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"},
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm"},
"poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, "postgrex": {:hex, :postgrex, "0.15.1", "23ce3417de70f4c0e9e7419ad85bdabcc6860a6925fe2c6f3b1b5b1e8e47bf2f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"postgrex": {:hex, :postgrex, "0.15.10", "2809dee1b1d76f7cbabe570b2a9285c2e7b41be60cf792f5f2804a54b838a067", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1560ca427542f6b213f8e281633ae1a3b31cdbcd84ebd7f50628765b8f6132be"}, "public_suffix": {:hex, :public_suffix, "0.6.0", "100cfe86f13f9f6f0cf67e743b1b83c78dd1223a2c422fa03ebf4adff514cbc3", [:mix], [{:idna, ">= 1.2.0 and < 6.0.0", [hex: :idna, repo: "hexpm", optional: false]}], "hexpm"},
"public_suffix": {:git, "https://github.com/axelson/publicsuffix-elixir", "89372422ab8b433de508519ef474e39699fd11ca", []}, "quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm"},
"quantum": {:hex, :quantum, "3.3.0", "e8f6b9479728774288c5f426b11a6e3e8f619f3c226163a7e18bccfe543b714d", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3b83ef137ab3887e783b013418b5ce3e847d66b71c4ef0f233b0321c84b72f67"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "recase": {:hex, :recase, "0.6.0", "1dd2dd2f4e06603b74977630e739f08b7fedbb9420cc14de353666c2fc8b99f4", [:mix], [], "hexpm"},
"recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, "scrivener": {:hex, :scrivener, "2.7.0", "fa94cdea21fad0649921d8066b1833d18d296217bfdf4a5389a2f45ee857b773", [:mix], [], "hexpm"},
"scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"}, "scrivener_ecto": {:hex, :scrivener_ecto, "2.2.0", "53d5f1ba28f35f17891cf526ee102f8f225b7024d1cdaf8984875467158c9c5e", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm"},
"scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"}, "shards": {:hex, :shards, "0.6.0", "678d292ad74a4598a872930f9b12251f43e97f6050287f1fb712fbfd3d282f75", [:make, :rebar3], [], "hexpm"},
"shards": {:hex, :shards, "0.6.2", "e05d05537883220c3b8a8f9d40d5c8ba7ff6064c63ebb6b23046972f6863b2d1", [:make, :rebar3], [], "hexpm", "58afa3712f1f1256a2a15e39fa95b7cd758087aaa7a25beaf786daabd87890f0"}, "sobelow": {:hex, :sobelow, "0.9.1", "0e3baeb03c2f98364a11dfb20bdad0790a2153aac2f07d3f8cdf7997c09dd649", [:mix], [], "hexpm"},
"sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm"},
"swoosh": {:hex, :swoosh, "1.5.0", "2be4cfc1be10f2203d1854c85b18d8c7be0321445a782efd53ef0b2b88f03ce4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b53891359e3ddca263ece784051243de84c9244c421a0dee1bff1d52fc5ca420"}, "swoosh": {:hex, :swoosh, "0.23.5", "bfd9404bbf5069b1be2ffd317923ce57e58b332e25dbca2a35dedd7820dfee5a", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"},
"telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"},
"timex": {:hex, :timex, "3.7.5", "3eca56e23bfa4e0848f0b0a29a92fa20af251a975116c6d504966e8a90516dfd", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "a15608dca680f2ef663d71c95842c67f0af08a0f3b1d00e17bbd22872e2874e4"}, "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
"tzdata": {:hex, :tzdata, "1.1.0", "72f5babaa9390d0f131465c8702fa76da0919e37ba32baa90d93c583301a8359", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "18f453739b48d3dc5bcf0e8906d2dc112bb40baafe2c707596d89f3c8dd14034"}, "tzdata": {:hex, :tzdata, "1.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"},
"vex": {:hex, :vex, "0.9.0", "613ea5eb3055662e7178b83e25b2df0975f68c3d8bb67c1645f0573e1a78d606", [:mix], [], "hexpm", "c69fff44d5c8aa3f1faee71bba1dcab05dd36364c5a629df8bb11751240c857f"}, "vex": {:hex, :vex, "0.6.0", "4e79b396b2ec18cd909eed0450b19108d9631842598d46552dc05031100b7a56", [:mix], [], "hexpm"},
} }

65
docker-compose.yml Normal file
View File

@ -0,0 +1,65 @@
version: "3"
services:
db:
image: postgres
environment:
- DATABASE_URL
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- database_network
elasticsearch:
image: elasticsearch:6.8.1
ports:
- "9200:9200"
volumes:
- esdata:/usr/share/elasticsearch/data
networks:
- phoenix_network
- es_network
# Kibana is just for development, really
kibana:
image: kibana:6.8.1
networks:
- es_network
ports:
- "5601:5601"
# This is for running the occasional graph layout task. It's in docker-compose.yml so that it's built at the same time
# as everything else, but it should be run regularly with a cron job or similar.
gephi:
environment:
- DATABASE_URL
build: ./gephi
volumes:
- gradle-cache:/code/.gradle
depends_on:
- db
networks:
- database_network
phoenix:
build: ./backend
networks:
- database_network
- phoenix_network
depends_on:
- db
ports:
- "${PORT}:${PORT}"
environment:
- DATABASE_URL
- SECRET_KEY_BASE
- PORT
- BACKEND_HOSTNAME
volumes:
pgdata:
esdata:
gradle-cache:
networks:
database_network:
driver: bridge
phoenix_network:
driver: bridge
es_network:
driver: bridge

View File

@ -1,4 +0,0 @@
node_modules
dist
build
coverage

View File

@ -1,24 +0,0 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
parserOptions: {
tsconfigRootDir: __dirname,
project: ["./tsconfig.json"],
},
plugins: ["@typescript-eslint", "prettier"],
extends: [
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier/@typescript-eslint",
"prettier",
],
rules: {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"react/prop-types": 0,
"@typescript-eslint/no-non-null-assertion": 0
},
};

View File

@ -1,3 +0,0 @@
module.exports = {
printWidth: 100
}

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +0,0 @@
server {
listen 80;
listen [::]:80;
server_name fediverse.space;
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
gzip_types
application/javascript
application/vnd.geo+json
application/vnd.ms-fontobject
application/x-font-ttf
application/x-web-app-manifest+json
font/opentype
image/bmp
image/svg+xml
image/x-icon
text/cache-manifest
text/css
text/plain
text/vcard
text/vnd.rim.location.xloc
text/vtt
text/x-component
text/x-cross-domain-policy;
root /website;
index index.html;
location / {
try_files $uri /index.html;
}
}

25236
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,13 +6,11 @@
"start": "NODE_ENV=development react-scripts start", "start": "NODE_ENV=development react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"typecheck": "tsc --noemit", "typecheck": "tsc --noemit",
"lint": "yarn typecheck && yarn eslint src/ --ext .js,.jsx,.ts,.tsx", "lint": "yarn typecheck && tslint -p tsconfig.json -c tslint.json \"src/**/*.{ts,tsx}\"",
"lint:fix": "yarn lint --fix", "lint:fix": "yarn lint --fix",
"pretty": "prettier --write \"src/**/*.{ts,tsx}\"", "pretty": "prettier --write \"src/**/*.{ts,tsx}\"",
"test": "yarn lint && react-scripts test --ci", "test": "yarn lint && react-scripts test --ci",
"eject": "react-scripts eject", "eject": "react-scripts eject"
"snyk-protect": "snyk protect",
"prepare": "yarn run snyk-protect"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
@ -22,74 +20,67 @@
"lint-staged": { "lint-staged": {
"src/**/*.{ts,tsx}": [ "src/**/*.{ts,tsx}": [
"yarn pretty", "yarn pretty",
"yarn lint:fix" "yarn lint:fix",
"git add"
] ]
}, },
"prettier": { "prettier": {
"printWidth": 120 "printWidth": 120
}, },
"dependencies": { "dependencies": {
"@blueprintjs/core": "^3.33.0", "@blueprintjs/core": "^3.19.1",
"@blueprintjs/icons": "^3.22.0", "@blueprintjs/icons": "^3.11.0",
"@blueprintjs/select": "^3.14.2", "@blueprintjs/select": "^3.11.1",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"connected-react-router": "^6.5.2", "connected-react-router": "^6.5.2",
"cross-fetch": "^3.0.6", "cross-fetch": "^3.0.4",
"cytoscape": "^3.16.1", "cytoscape": "^3.11.0",
"cytoscape-popper": "^1.0.7", "cytoscape-popper": "^1.0.4",
"inflection": "^1.12.0", "inflection": "^1.12.0",
"lodash": "^4.17.20", "lodash": "^4.17.15",
"moment": "^2.29.1", "moment": "^2.22.2",
"normalize.css": "^8.0.0", "normalize.css": "^8.0.0",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"react": "^16.10.2", "react": "^16.10.2",
"react-dom": "^16.10.2", "react-dom": "^16.10.2",
"react-redux": "^7.2.1", "react-redux": "^7.1.1",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.1.2",
"react-sigma": "^1.2.35", "react-scripts": "^3.2.0",
"react-virtualized": "^9.22.2", "react-sigma": "^1.2.30",
"react-virtualized": "^9.21.1",
"redux": "^4.0.4", "redux": "^4.0.4",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"sanitize-html": "^2.0.0", "sanitize-html": "^1.20.1",
"snyk": "^1.410.1", "styled-components": "^4.4.0",
"styled-components": "^5.2.0",
"tippy.js": "^4.3.5" "tippy.js": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@blueprintjs/tslint-config": "^1.9.0",
"@types/classnames": "^2.2.9", "@types/classnames": "^2.2.9",
"@types/cytoscape": "^3.14.7", "@types/cytoscape": "^3.8.3",
"@types/inflection": "^1.5.28", "@types/inflection": "^1.5.28",
"@types/jest": "^26.0.14", "@types/jest": "^24.0.19",
"@types/lodash": "^4.14.161", "@types/lodash": "^4.14.144",
"@types/node": "^14.11.5", "@types/node": "^12.7.12",
"@types/numeral": "^0.0.28", "@types/numeral": "^0.0.26",
"@types/react": "^16.9.51", "@types/react": "^16.9.6",
"@types/react-axe": "^3.1.0", "@types/react-dom": "^16.9.2",
"@types/react-dom": "^16.9.8", "@types/react-redux": "^7.1.4",
"@types/react-redux": "^7.1.9", "@types/react-router-dom": "^5.1.0",
"@types/react-router-dom": "^5.1.6", "@types/sanitize-html": "^1.20.2",
"@types/sanitize-html": "^1.27.0", "@types/styled-components": "4.1.19",
"@types/styled-components": "5.1.3", "husky": "^3.0.9",
"@typescript-eslint/eslint-plugin": "^2.24.0", "lint-staged": "^9.4.2",
"@typescript-eslint/parser": "^2.34.0", "react-axe": "^3.3.0",
"eslint-config-airbnb-typescript": "^7.2.1", "tslint": "^5.20.0",
"eslint-config-prettier": "^6.12.0", "tslint-config-security": "^1.16.0",
"eslint-plugin-import": "^2.22.1", "tslint-eslint-rules": "^5.4.0",
"eslint-plugin-jsx-a11y": "^6.3.1", "typescript": "^3.6.4"
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.21.3",
"eslint-plugin-react-hooks": "^4.1.2",
"husky": "^4.3.0",
"lint-staged": "^10.4.0",
"prettier": "^2.1.2",
"react-scripts": "3.4.3",
"typescript": "^3.9.2"
}, },
"browserslist": [ "browserslist": [
">0.2%", ">0.2%",
"not dead", "not dead",
"not ie <= 11", "not ie <= 11",
"not op_mini all" "not op_mini all"
], ]
"snyk": true }
}

View File

@ -4,30 +4,28 @@ import { Classes } from "@blueprintjs/core";
import { ConnectedRouter } from "connected-react-router"; import { ConnectedRouter } from "connected-react-router";
import { Route } from "react-router-dom"; import { Route } from "react-router-dom";
import { Nav } from "./components/organisms"; import { Nav } from "./components/organisms/";
import { import {
AboutScreen, AboutScreen,
AdminScreen, AdminScreen,
GraphScreen, GraphScreen,
LoginScreen, LoginScreen,
TableScreen, TableScreen,
VerifyLoginScreen, VerifyLoginScreen
} from "./components/screens"; } from "./components/screens/";
import { history } from "./index"; import { history } from "./index";
const AppRouter: React.FC = () => ( const AppRouter: React.FC = () => (
<ConnectedRouter history={history}> <ConnectedRouter history={history}>
<div className={`${Classes.DARK} App`}> <div className={`${Classes.DARK} App`}>
<Nav /> <Nav />
<main role="main"> <Route path="/instances" exact={true} component={TableScreen} />
<Route path="/instances" exact component={TableScreen} /> <Route path="/about" exact={true} component={AboutScreen} />
<Route path="/about" exact component={AboutScreen} /> <Route path="/admin/login" exact={true} component={LoginScreen} />
<Route path="/admin/login" exact component={LoginScreen} /> <Route path="/admin/verify" exact={true} component={VerifyLoginScreen} />
<Route path="/admin/verify" exact component={VerifyLoginScreen} /> <Route path="/admin" exact={true} component={AdminScreen} />
<Route path="/admin" exact component={AdminScreen} /> {/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */}
{/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */} <GraphScreen />
<GraphScreen />
</main>
</div> </div>
</ConnectedRouter> </ConnectedRouter>
); );

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -1 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 170.08 170.08"><defs><style>.cls-1{fill:#04246e;}.cls-2{fill:#fff;}</style></defs><title>square-mark-white</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><rect class="cls-1" width="170.08" height="170.08"/><path class="cls-2" d="M127.34,58c-8.4,0-14.41,7.4-20.6,15.65-3.84-17.51-8.11-35-21.7-35s-17.86,17.5-21.71,35C57.15,65.37,51.13,58,42.74,58c-5.35,0-14.39,3.63-14.39,17.25L28.41,100c0,13.36,7.14,16.64,11.42,17.76h0c7.39,1.91,25.17,3.69,45.18,3.69s37.8-1.78,45.18-3.69h0c4.28-1.12,11.42-4.4,11.42-17.76l.06-24.79c0-13.62-9-17.25-14.39-17.25M85,46.33c8.15,0,11.24,16.06,15.57,35.33C95.86,87.5,90.83,92.21,85,92.21S74.21,87.5,69.47,81.66c4-18,7.17-35.33,15.57-35.33M36.13,100l-.06-24.79c0-8.6,4.67-9.53,6.67-9.53,5.92,0,12.28,9.88,18.36,17.83-4.29,18-8.83,29-19.56,26.72-2.78-.77-5.41-2.53-5.41-10.23m21,12.53c4.75-5.16,7.78-13.21,10.08-21.73,5,5.19,10.69,9.13,17.8,9.13S97.86,96,102.83,90.81c2.31,8.52,5.33,16.57,10.07,21.73-8.29.75-18.28,1.21-27.86,1.21s-19.59-.46-27.88-1.21M134,100c0,7.7-2.63,9.46-5.42,10.23-10.73,2.27-15.26-8.72-19.56-26.72,6.08-7.94,12.45-17.83,18.37-17.83,2,0,6.67.93,6.67,9.53Z"/></g></g></svg> <?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 18.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 603.8 104.9" enable-background="new 0 0 603.8 104.9" xml:space="preserve">
<g>
<polygon fill="#4988A2" points="481.8,16.9 481.8,80.7 561.3,80.7 "/>
<polygon fill="#7A4198" points="538.3,80.7 602.1,7.4 602.1,80.7 "/>
<polygon fill="#478B60" points="481.9,80.7 513.5,1.1 561,80.7 "/>
<polygon fill="#135F66" points="488.3,80.7 515.3,43.9 547.7,69.9 538.3,80.7 "/>
<polygon fill="#2B7B82" points="488.3,80.7 481.9,80.7 501.1,32.4 515.3,43.9 "/>
<polygon fill="#194A7F" points="547.7,69.9 538.3,80.7 561.1,80.7 "/>
<polyline fill="#5E2B7C" points="551.7,65.3 547.7,69.9 561.1,80.7 551.7,65.3 "/>
<polygon fill="#7A2980" points="602.1,80.7 561.1,80.7 551.7,65.3 575.6,37.8 "/>
<polygon fill="#456630" points="515.3,43.9 528.6,26.4 551.7,65.3 547.7,69.9 "/>
<polygon fill="#E77A45" points="528.6,26.4 535.4,16.9 551.3,39.2 566.9,23.9 575.6,37.8 551.7,65.3 "/>
</g>
<g>
<path fill="#6F8087" d="M61.7,80.7H49.5l-6.9-22.6H19.1l-6.6,22.6H0.5L24,4h14.2L61.7,80.7z M40.6,49.2l-6-20 c-1.4-5-2.7-10.4-3.8-15.1h-0.2c-1.1,4.7-2.3,10.3-3.6,15l-6.1,20.1H40.6z"/>
<path fill="#6F8087" d="M82.3,33.9c3.8-6.2,9.8-9.6,17.5-9.6c12.4,0,21.8,11,21.8,28.2c0,20.3-11.7,29.4-23.4,29.4 c-6.6,0-12-3.1-14.7-7.6h-0.2v28.9H71.7V43.3c0-7.2-0.2-12.9-0.4-17.8h10.2l0.6,8.4H82.3z M83.3,58.4c0,9.9,6.8,14.1,12.5,14.1 c9,0,13.9-8.3,13.9-19.8c0-10.5-4.7-19.2-13.6-19.2c-6.9,0-12.8,6.5-12.8,14.6V58.4z"/>
<path fill="#6F8087" d="M143.7,33.9c3.8-6.2,9.8-9.6,17.5-9.6c12.4,0,21.8,11,21.8,28.2c0,20.3-11.7,29.4-23.4,29.4 c-6.6,0-12-3.1-14.7-7.6h-0.2v28.9h-11.6V43.3c0-7.2-0.2-12.9-0.4-17.8h10.2l0.6,8.4H143.7z M144.7,58.4c0,9.9,6.8,14.1,12.5,14.1 c9,0,13.9-8.3,13.9-19.8c0-10.5-4.7-19.2-13.6-19.2c-6.9,0-12.8,6.5-12.8,14.6V58.4z"/>
<g>
<path fill="#404D5C" d="M230.9,15.9c-2.5-1.4-7.3-3.4-13.6-3.4c-9.1,0-12.9,5.3-12.9,10.2c0,6.5,4.3,9.7,14,13.9 c12.3,5.4,18.2,12.1,18.2,22.7c0,12.8-9.5,22.5-26.8,22.5c-7.2,0-14.8-2.1-18.5-4.6l2.6-9.7C198,70,204.3,72,210.6,72 c9.1,0,14.2-4.7,14.2-11.6c0-6.5-3.9-10.5-13-14.3c-11.4-4.6-19.1-11.5-19.1-22c0-12.1,9.7-21.3,25-21.3c7.3,0,12.8,1.8,16.1,3.5 L230.9,15.9z"/>
<path fill="#404D5C" d="M254.3,17.7c-4,0-6.7-3-6.7-6.7c0-3.9,2.8-6.8,6.8-6.8c4,0,6.7,2.9,6.7,6.8 C261.1,14.7,258.5,17.7,254.3,17.7z M260.1,80.7h-11.6V25.4h11.6V80.7z"/>
<path fill="#404D5C" d="M320.4,25.5c-0.3,3.9-0.5,8.5-0.5,15.9v31.5c0,11-0.9,31.4-27.1,31.4c-6.4,0-13.1-1.4-17.4-4l2.6-9 c3.4,2,8.7,3.9,14.9,3.9c9,0,15.4-5,15.4-17.5v-5.3h-0.2c-2.8,4.7-8.2,8.1-15.3,8.1c-13,0-22-11.5-22-27 c0-18.7,11.3-29.2,23.4-29.2c8.2,0,12.8,4.2,15.1,8.6h0.2l0.5-7.4H320.4z M308.2,46.8c0-7.3-4.8-13.3-11.9-13.3 c-8,0-13.6,7.6-13.6,19.4c0,10.8,4.9,18.5,13.5,18.5c6,0,12-4.6,12-13.8V46.8z"/>
<path fill="#404D5C" d="M379.5,80.7h-11.6V48.3c0-7.8-2.6-14.4-10.5-14.4c-5.7,0-11.8,4.7-11.8,13.5v33.3h-11.6V41 c0-6.1-0.2-10.8-0.4-15.5h10.1l0.6,8.2h0.3c2.6-4.7,8.4-9.4,16.7-9.4c8.6,0,18.2,5.6,18.2,22.7V80.7z"/>
<path fill="#404D5C" d="M432,67.6c0,4.7,0.1,9.5,0.8,13.1h-10.5l-0.8-6.4h-0.3c-3.1,4.3-8.5,7.5-15.4,7.5 c-10.3,0-16.2-7.6-16.2-16.1c0-13.7,11.9-20.6,30.9-20.6c0-4.1,0-12.3-11.1-12.3c-4.9,0-9.9,1.5-13.4,3.8l-2.4-7.7 c3.9-2.5,10.4-4.6,17.8-4.6c16.2,0,20.8,10.7,20.8,22.5V67.6z M420.7,52.9c-9.1,0-19.6,1.7-19.6,11.5c0,6.1,3.8,8.8,8.1,8.8 c6.3,0,11.5-4.8,11.5-11.2V52.9z"/>
<path fill="#404D5C" d="M457.8,80.7h-11.1V7.4l11.1-6.3V80.7z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -11,7 +11,7 @@ const FloatingCardElement = styled(Card)`
z-index: 2; z-index: 2;
`; `;
const FloatingCard: React.FC<ICardProps> = (props) => ( const FloatingCard: React.FC<ICardProps> = props => (
<FloatingCardRow> <FloatingCardRow>
<FloatingCardElement elevation={Elevation.ONE} {...props} /> <FloatingCardElement elevation={Elevation.ONE} {...props} />
</FloatingCardRow> </FloatingCardRow>

View File

@ -7,13 +7,13 @@ const StyledSwitch = styled(Switch)`
margin: 0; margin: 0;
`; `;
interface GraphHideEdgesButtonProps { interface IGraphHideEdgesButtonProps {
isShowingEdges: boolean; isShowingEdges: boolean;
toggleEdges: () => void; toggleEdges: () => void;
} }
const GraphHideEdgesButton: React.FC<GraphHideEdgesButtonProps> = ({ isShowingEdges, toggleEdges }) => ( const GraphHideEdgesButton: React.FC<IGraphHideEdgesButtonProps> = ({ isShowingEdges, toggleEdges }) => (
<FloatingCard> <FloatingCard>
<StyledSwitch checked={isShowingEdges} label="Show connections" onChange={toggleEdges} tabIndex={-1} /> <StyledSwitch checked={isShowingEdges} label="Show connections" onChange={toggleEdges} />
</FloatingCard> </FloatingCard>
); );
export default GraphHideEdgesButton; export default GraphHideEdgesButton;

View File

@ -6,9 +6,9 @@ import React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { FloatingCard, InstanceType } from "."; import { FloatingCard, InstanceType } from ".";
import { QUANTITATIVE_COLOR_SCHEME } from "../../constants"; import { QUANTITATIVE_COLOR_SCHEME } from "../../constants";
import { ColorScheme } from "../../types"; import { IColorScheme } from "../../types";
const ColorSchemeSelect = Select.ofType<ColorScheme>(); const ColorSchemeSelect = Select.ofType<IColorScheme>();
const StyledLi = styled.li` const StyledLi = styled.li`
margin-top: 2px; margin-top: 2px;
@ -27,12 +27,12 @@ const ColorBarContainer = styled.div`
flex-direction: column; flex-direction: column;
margin-right: 10px; margin-right: 10px;
`; `;
interface ColorBarProps { interface IColorBarProps {
color: string; color: string;
} }
const ColorBar = styled.div<ColorBarProps>` const ColorBar = styled.div<IColorBarProps>`
width: 10px; width: 10px;
background-color: ${(props) => props.color}; background-color: ${props => props.color};
flex: 1; flex: 1;
`; `;
const TextContainer = styled.div` const TextContainer = styled.div`
@ -41,46 +41,13 @@ const TextContainer = styled.div`
justify-content: space-between; justify-content: space-between;
`; `;
const renderItem: ItemRenderer<ColorScheme> = (colorScheme, { handleClick, modifiers }) => { interface IGraphKeyProps {
if (!modifiers.matchesPredicate) { current?: IColorScheme;
return null; colorSchemes: IColorScheme[];
}
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
};
const renderQualitativeKey = (values: string[]) => (
<ul className={Classes.LIST_UNSTYLED}>
{values.map((v) => (
<StyledLi key={v}>
<InstanceType type={v} />
</StyledLi>
))}
</ul>
);
const renderQuantitativeKey = (range: number[]) => {
const [min, max] = range;
return (
<ColorKeyContainer>
<ColorBarContainer>
{QUANTITATIVE_COLOR_SCHEME.map((color) => (
<ColorBar color={color} key={color} />
))}
</ColorBarContainer>
<TextContainer>
<span className={Classes.TEXT_SMALL}>{numeral.default(min).format("0")}</span>
<span className={Classes.TEXT_SMALL}>{numeral.default(max).format("0")}</span>
</TextContainer>
</ColorKeyContainer>
);
};
interface GraphKeyProps {
current?: ColorScheme;
colorSchemes: ColorScheme[];
ranges?: { [key: string]: [number, number] }; ranges?: { [key: string]: [number, number] };
onItemSelect: (colorScheme?: ColorScheme) => void; onItemSelect: (colorScheme?: IColorScheme) => void;
} }
const GraphKey: React.FC<GraphKeyProps> = ({ current, colorSchemes, ranges, onItemSelect }) => { const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, ranges, onItemSelect }) => {
const unsetColorScheme = () => { const unsetColorScheme = () => {
onItemSelect(undefined); onItemSelect(undefined);
}; };
@ -107,9 +74,8 @@ const GraphKey: React.FC<GraphKeyProps> = ({ current, colorSchemes, ranges, onIt
text={(current && current.name) || "Select..."} text={(current && current.name) || "Select..."}
icon={IconNames.TINT} icon={IconNames.TINT}
rightIcon={IconNames.CARET_DOWN} rightIcon={IconNames.CARET_DOWN}
tabIndex={-1}
/> />
<Button icon={IconNames.SMALL_CROSS} minimal onClick={unsetColorScheme} disabled={!current} tabIndex={-1} /> <Button icon={IconNames.SMALL_CROSS} minimal={true} onClick={unsetColorScheme} disabled={!current} />
</ColorSchemeSelect> </ColorSchemeSelect>
<br /> <br />
{!!current && !!key && ( {!!current && !!key && (
@ -122,4 +88,38 @@ const GraphKey: React.FC<GraphKeyProps> = ({ current, colorSchemes, ranges, onIt
); );
}; };
const renderItem: ItemRenderer<IColorScheme> = (colorScheme, { handleClick, modifiers }) => {
if (!modifiers.matchesPredicate) {
return null;
}
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
};
const renderQualitativeKey = (values: string[]) => (
<ul className={Classes.LIST_UNSTYLED}>
{values.map(v => (
<StyledLi key={v}>
<InstanceType type={v} />
</StyledLi>
))}
</ul>
);
const renderQuantitativeKey = (range: number[]) => {
const [min, max] = range;
return (
<ColorKeyContainer>
<ColorBarContainer>
{QUANTITATIVE_COLOR_SCHEME.map((color, idx) => (
<ColorBar color={color} key={color} />
))}
</ColorBarContainer>
<TextContainer>
<span className={Classes.TEXT_SMALL}>{numeral.default(min).format("0")}</span>
<span className={Classes.TEXT_SMALL}>{numeral.default(max).format("0")}</span>
</TextContainer>
</ColorKeyContainer>
);
};
export default GraphKey; export default GraphKey;

View File

@ -2,12 +2,12 @@ import { Button } from "@blueprintjs/core";
import * as React from "react"; import * as React from "react";
import FloatingCard from "./FloatingCard"; import FloatingCard from "./FloatingCard";
interface GraphResetButtonProps { interface IGraphResetButtonProps {
onClick: () => void; onClick: () => void;
} }
const GraphResetButton: React.FC<GraphResetButtonProps> = ({ onClick }) => ( const GraphResetButton: React.FC<IGraphResetButtonProps> = ({ onClick }) => (
<FloatingCard> <FloatingCard>
<Button icon="compass" title="Reset graph view" onClick={onClick} tabIndex={-1} /> <Button icon="compass" title="Reset graph view" onClick={onClick} />
</FloatingCard> </FloatingCard>
); );
export default GraphResetButton; export default GraphResetButton;

View File

@ -5,7 +5,7 @@ import { QUALITATIVE_COLOR_SCHEME } from "../../constants";
import { typeColorScheme } from "../../types"; import { typeColorScheme } from "../../types";
import { getTypeDisplayString } from "../../util"; import { getTypeDisplayString } from "../../util";
interface InstanceTypeProps { interface IInstanceTypeProps {
type: string; type: string;
colorAfterName?: boolean; colorAfterName?: boolean;
} }
@ -13,9 +13,9 @@ interface InstanceTypeProps {
* By default, renders the color followed by the name of the instance type. * By default, renders the color followed by the name of the instance type.
* You can change this by passing `colorAfterName={true}`. * You can change this by passing `colorAfterName={true}`.
*/ */
const InstanceType: React.FC<InstanceTypeProps> = ({ type, colorAfterName }) => { const InstanceType: React.FC<IInstanceTypeProps> = ({ type, colorAfterName }) => {
const idx = typeColorScheme.values.indexOf(type); const idx = typeColorScheme.values.indexOf(type);
const name = ` ${getTypeDisplayString(type)}`; const name = " " + getTypeDisplayString(type);
return ( return (
<> <>
{!!colorAfterName && name} {!!colorAfterName && name}

View File

@ -11,19 +11,19 @@ const Backdrop = styled.div`
z-index: 3; z-index: 3;
`; `;
interface ContainerProps { interface IContainerProps {
fullWidth?: boolean; fullWidth?: boolean;
} }
const Container = styled.div<ContainerProps>` const Container = styled.div<IContainerProps>`
max-width: ${(props) => (props.fullWidth ? "100%" : "800px")}; max-width: ${props => (props.fullWidth ? "100%" : "800px")};
margin: auto; margin: auto;
padding: 2em; padding: 2em;
`; `;
interface PageProps { interface IPageProps {
fullWidth?: boolean; fullWidth?: boolean;
} }
const Page: React.FC<PageProps> = ({ children, fullWidth }) => ( const Page: React.FC<IPageProps> = ({ children, fullWidth }) => (
<Backdrop> <Backdrop>
<Container fullWidth={fullWidth}>{children}</Container> <Container fullWidth={fullWidth}>{children}</Container>
</Backdrop> </Backdrop>

View File

@ -10,9 +10,9 @@ import {
QUALITATIVE_COLOR_SCHEME, QUALITATIVE_COLOR_SCHEME,
QUANTITATIVE_COLOR_SCHEME, QUANTITATIVE_COLOR_SCHEME,
SEARCH_RESULT_COLOR, SEARCH_RESULT_COLOR,
SELECTED_NODE_COLOR, SELECTED_NODE_COLOR
} from "../../constants"; } from "../../constants";
import { ColorScheme } from "../../types"; import { IColorScheme } from "../../types";
import { getBuckets, getTypeDisplayString } from "../../util"; import { getBuckets, getTypeDisplayString } from "../../util";
const CytoscapeContainer = styled.div` const CytoscapeContainer = styled.div`
@ -21,8 +21,8 @@ const CytoscapeContainer = styled.div`
flex: 1; flex: 1;
`; `;
interface CytoscapeProps { interface ICytoscapeProps {
colorScheme?: ColorScheme; colorScheme?: IColorScheme;
currentNodeId: string | null; currentNodeId: string | null;
elements: cytoscape.ElementsDefinition; elements: cytoscape.ElementsDefinition;
hoveringOver?: string; hoveringOver?: string;
@ -32,11 +32,10 @@ interface CytoscapeProps {
navigateToInstancePath?: (domain: string) => void; navigateToInstancePath?: (domain: string) => void;
navigateToRoot?: () => void; navigateToRoot?: () => void;
} }
class Cytoscape extends React.PureComponent<CytoscapeProps> { class Cytoscape extends React.PureComponent<ICytoscapeProps> {
private cy?: cytoscape.Core; private cy?: cytoscape.Core;
public componentDidMount() { public componentDidMount() {
// eslint-disable-next-line react/no-find-dom-node
const container = ReactDOM.findDOMNode(this); const container = ReactDOM.findDOMNode(this);
this.cy = cytoscape({ this.cy = cytoscape({
autoungrabify: true, autoungrabify: true,
@ -45,16 +44,16 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
hideEdgesOnViewport: true, hideEdgesOnViewport: true,
hideLabelsOnViewport: true, hideLabelsOnViewport: true,
layout: { layout: {
name: "preset", name: "preset"
}, },
maxZoom: 2, maxZoom: 2,
minZoom: 0.01, minZoom: 0.01,
pixelRatio: 1.0, pixelRatio: 1.0,
selectionType: "single", selectionType: "single"
}); });
// Setup node tooltip on hover // Setup node tooltip on hover
this.cy.nodes().forEach((n) => { this.cy.nodes().forEach(n => {
const tooltipContent = `${n.data("id")} (${getTypeDisplayString(n.data("type"))})`; const tooltipContent = `${n.data("id")} (${getTypeDisplayString(n.data("type"))})`;
const ref = (n as any).popperRef(); const ref = (n as any).popperRef();
const t = tippy(ref, { const t = tippy(ref, {
@ -62,12 +61,12 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
animation: "fade", animation: "fade",
content: tooltipContent, content: tooltipContent,
duration: 100, duration: 100,
trigger: "manual", trigger: "manual"
}); });
n.on("mouseover", () => { n.on("mouseover", e => {
(t as Instance).show(); (t as Instance).show();
}); });
n.on("mouseout", () => { n.on("mouseout", e => {
(t as Instance).hide(); (t as Instance).hide();
}); });
}); });
@ -79,25 +78,25 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
.style({ .style({
"curve-style": "haystack", // fast edges "curve-style": "haystack", // fast edges
"line-color": DEFAULT_NODE_COLOR, "line-color": DEFAULT_NODE_COLOR,
width: "mapData(weight, 0, 0.5, 1, 20)", width: "mapData(weight, 0, 0.5, 1, 20)"
}) })
.selector("node[label]") .selector("node[label]")
.style({ .style({
color: DEFAULT_NODE_COLOR, color: DEFAULT_NODE_COLOR,
"font-size": "mapData(size, 1, 6, 10, 100)", "font-size": "mapData(size, 1, 6, 10, 100)",
"min-zoomed-font-size": 16, "min-zoomed-font-size": 16
}) })
.selector(".hidden") // used to hide nodes not in the neighborhood of the selected, or to hide edges .selector(".hidden") // used to hide nodes not in the neighborhood of the selected, or to hide edges
.style({ .style({
display: "none", display: "none"
}) })
.selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them .selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them
.style({ .style({
width: 2, width: 2
}); });
this.resetNodeColorScheme(style); // this function also called `update()` this.resetNodeColorScheme(style); // this function also called `update()`
this.cy.nodes().on("select", (e) => { this.cy.nodes().on("select", e => {
const instanceId = e.target.data("id"); const instanceId = e.target.data("id");
if (instanceId && instanceId !== this.props.currentNodeId) { if (instanceId && instanceId !== this.props.currentNodeId) {
if (this.props.navigateToInstancePath) { if (this.props.navigateToInstancePath) {
@ -111,19 +110,21 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
this.cy!.nodes().removeClass("hidden"); this.cy!.nodes().removeClass("hidden");
this.cy!.edges().removeClass("thickEdge"); this.cy!.edges().removeClass("thickEdge");
// Then hide everything except neighborhood // Then hide everything except neighborhood
this.cy!.nodes().diff(neighborhood).left.addClass("hidden"); this.cy!.nodes()
.diff(neighborhood)
.left.addClass("hidden");
neighborhood.connectedEdges().addClass("thickEdge"); neighborhood.connectedEdges().addClass("thickEdge");
}); });
}); });
this.cy.nodes().on("unselect", () => { this.cy.nodes().on("unselect", e => {
this.cy!.batch(() => { this.cy!.batch(() => {
this.cy!.nodes().removeClass("hidden"); this.cy!.nodes().removeClass("hidden");
this.cy!.edges().removeClass("thickEdge"); this.cy!.edges().removeClass("thickEdge");
}); });
}); });
this.cy.on("click", (e) => { this.cy.on("click", e => {
// Clicking on the background should also deselect // Clicking on the background should also deselect
const { target } = e; const target = e.target;
if (!target || target === this.cy || target.isEdge()) { if (!target || target === this.cy || target.isEdge()) {
if (this.props.navigateToRoot) { if (this.props.navigateToRoot) {
// Go to the URL "/" // Go to the URL "/"
@ -135,7 +136,7 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
this.setNodeSelection(); this.setNodeSelection();
} }
public componentDidUpdate(prevProps: CytoscapeProps) { public componentDidUpdate(prevProps: ICytoscapeProps) {
this.setNodeSelection(prevProps.currentNodeId); this.setNodeSelection(prevProps.currentNodeId);
if (prevProps.colorScheme !== this.props.colorScheme) { if (prevProps.colorScheme !== this.props.colorScheme) {
this.updateColorScheme(); this.updateColorScheme();
@ -173,12 +174,12 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
if (currentNodeId) { if (currentNodeId) {
this.cy.zoom({ this.cy.zoom({
level: 0.2, level: 0.2,
position: this.cy.$id(currentNodeId).position(), position: this.cy.$id(currentNodeId).position()
}); });
} else { } else {
this.cy.zoom({ this.cy.zoom({
level: 0.2, level: 0.2,
position: { x: 0, y: 0 }, position: { x: 0, y: 0 }
}); });
} }
} }
@ -220,7 +221,7 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
// quite good as it is, so... // quite good as it is, so...
height: "mapData(size, 1, 6, 20, 200)", height: "mapData(size, 1, 6, 20, 200)",
label: "data(id)", label: "data(id)",
width: "mapData(size, 1, 6, 20, 200)", width: "mapData(size, 1, 6, 20, 200)"
}); });
this.setNodeSearchColorScheme(style); this.setNodeSearchColorScheme(style);
@ -239,16 +240,16 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
"background-color": SEARCH_RESULT_COLOR, "background-color": SEARCH_RESULT_COLOR,
"border-color": SEARCH_RESULT_COLOR, "border-color": SEARCH_RESULT_COLOR,
"border-opacity": 0.7, "border-opacity": 0.7,
"border-width": 250, "border-width": 250
}) })
.selector("node.hovered") .selector("node.hovered")
.style({ .style({
"border-color": HOVERED_NODE_COLOR, "border-color": HOVERED_NODE_COLOR,
"border-width": 1000, "border-width": 1000
}) })
.selector("node:selected") .selector("node:selected")
.style({ .style({
"background-color": SELECTED_NODE_COLOR, "background-color": SELECTED_NODE_COLOR
}) })
.update(); .update();
}; };
@ -262,11 +263,10 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
if (!colorScheme) { if (!colorScheme) {
this.resetNodeColorScheme(); this.resetNodeColorScheme();
return; return;
} } else if (colorScheme.type === "qualitative") {
if (colorScheme.type === "qualitative") {
colorScheme.values.forEach((v, idx) => { colorScheme.values.forEach((v, idx) => {
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({ style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
"background-color": QUALITATIVE_COLOR_SCHEME[idx], "background-color": QUALITATIVE_COLOR_SCHEME[idx]
}); });
}); });
} else if (colorScheme.type === "quantitative") { } else if (colorScheme.type === "quantitative") {
@ -284,7 +284,7 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
const max = idx === QUANTITATIVE_COLOR_SCHEME.length - 1 ? maxVal + 1 : buckets[idx + 1]; const max = idx === QUANTITATIVE_COLOR_SCHEME.length - 1 ? maxVal + 1 : buckets[idx + 1];
const selector = `node[${dataKey} >= ${min}][${dataKey} < ${max}]`; const selector = `node[${dataKey} >= ${min}][${dataKey} < ${max}]`;
style = style.selector(selector).style({ style = style.selector(selector).style({
"background-color": color, "background-color": color
}); });
}); });
} }
@ -304,10 +304,10 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
} }
const { hoveringOver } = this.props; const { hoveringOver } = this.props;
if (prevHoveredId) { if (!!prevHoveredId) {
this.cy.$id(prevHoveredId).removeClass("hovered"); this.cy.$id(prevHoveredId).removeClass("hovered");
} }
if (hoveringOver) { if (!!hoveringOver) {
this.cy.$id(hoveringOver).addClass("hovered"); this.cy.$id(hoveringOver).addClass("hovered");
} }
}; };
@ -322,7 +322,7 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
this.cy!.nodes().removeClass("searchResult"); this.cy!.nodes().removeClass("searchResult");
if (!!searchResultIds && searchResultIds.length > 0) { if (!!searchResultIds && searchResultIds.length > 0) {
const currentResultSelector = searchResultIds.map((id) => `node[id = "${id}"]`).join(", "); const currentResultSelector = searchResultIds.map(id => `node[id = "${id}"]`).join(", ");
this.cy!.$(currentResultSelector).addClass("searchResult"); this.cy!.$(currentResultSelector).addClass("searchResult");
} }
}); });
@ -344,11 +344,11 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
/* Helper function to remove edges if source or target node is missing */ /* Helper function to remove edges if source or target node is missing */
private cleanElements = (elements: cytoscape.ElementsDefinition): cytoscape.ElementsDefinition => { private cleanElements = (elements: cytoscape.ElementsDefinition): cytoscape.ElementsDefinition => {
const domains = new Set(elements.nodes.map((n) => n.data.id)); const domains = new Set(elements.nodes.map(n => n.data.id));
const edges = elements.edges.filter((e) => domains.has(e.data.source) && domains.has(e.data.target)); const edges = elements.edges.filter(e => domains.has(e.data.source) && domains.has(e.data.target));
return { return {
edges, edges,
nodes: elements.nodes, nodes: elements.nodes
}; };
}; };
} }

View File

@ -2,11 +2,11 @@ import { NonIdealState } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import * as React from "react"; import * as React from "react";
interface ErrorStateProps { interface IErrorStateProps {
description?: string; description?: string;
} }
const ErrorState: React.FC<ErrorStateProps> = ({ description }) => ( const ErrorState: React.FC<IErrorStateProps> = ({ description }) => (
<NonIdealState icon={IconNames.ERROR} title="Something went wrong." description={description} /> <NonIdealState icon={IconNames.ERROR} title={"Something went wrong."} description={description} />
); );
export default ErrorState; export default ErrorState;

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { ColorScheme } from "../../types"; import { IColorScheme } from "../../types";
import { GraphHideEdgesButton, GraphKey, GraphResetButton } from "../atoms"; import { GraphHideEdgesButton, GraphKey, GraphResetButton } from "../atoms";
const GraphToolsContainer = styled.div` const GraphToolsContainer = styled.div`
@ -11,33 +11,35 @@ const GraphToolsContainer = styled.div`
flex-direction: column; flex-direction: column;
`; `;
interface GraphToolsProps { interface IGraphToolsProps {
currentColorScheme?: ColorScheme; currentColorScheme?: IColorScheme;
colorSchemes: ColorScheme[]; colorSchemes: IColorScheme[];
isShowingEdges: boolean; isShowingEdges: boolean;
ranges?: { [key: string]: [number, number] }; ranges?: { [key: string]: [number, number] };
onColorSchemeSelect: (colorScheme?: ColorScheme) => void; onColorSchemeSelect: (colorScheme?: IColorScheme) => void;
onResetButtonClick: () => void; onResetButtonClick: () => void;
toggleEdges: () => void; toggleEdges: () => void;
} }
const GraphTools: React.FC<GraphToolsProps> = ({ const GraphTools: React.FC<IGraphToolsProps> = ({
currentColorScheme, currentColorScheme,
colorSchemes, colorSchemes,
isShowingEdges, isShowingEdges,
ranges, ranges,
onColorSchemeSelect, onColorSchemeSelect,
onResetButtonClick, onResetButtonClick,
toggleEdges, toggleEdges
}) => ( }) => {
<GraphToolsContainer> return (
<GraphResetButton onClick={onResetButtonClick} /> <GraphToolsContainer>
<GraphHideEdgesButton isShowingEdges={isShowingEdges} toggleEdges={toggleEdges} /> <GraphResetButton onClick={onResetButtonClick} />
<GraphKey <GraphHideEdgesButton isShowingEdges={isShowingEdges} toggleEdges={toggleEdges} />
current={currentColorScheme} <GraphKey
colorSchemes={colorSchemes} current={currentColorScheme}
onItemSelect={onColorSchemeSelect} colorSchemes={colorSchemes}
ranges={ranges} onItemSelect={onColorSchemeSelect}
/> ranges={ranges}
</GraphToolsContainer> />
); </GraphToolsContainer>
);
};
export default GraphTools; export default GraphTools;

View File

@ -4,7 +4,7 @@ import * as numeral from "numeral";
import React from "react"; import React from "react";
import sanitize from "sanitize-html"; import sanitize from "sanitize-html";
import styled from "styled-components"; import styled from "styled-components";
import { SearchResultInstance } from "../../redux/types"; import { ISearchResultInstance } from "../../redux/types";
import { InstanceType } from "../atoms"; import { InstanceType } from "../atoms";
const StyledCard = styled(Card)` const StyledCard = styled(Card)`
@ -32,18 +32,18 @@ const StyledUserCount = styled.div`
const StyledDescription = styled.div` const StyledDescription = styled.div`
margin-top: 10px; margin-top: 10px;
`; `;
interface SearchResultProps { interface ISearchResultProps {
result: SearchResultInstance; result: ISearchResultInstance;
onClick: () => void; onClick: () => void;
onMouseEnter: () => void; onMouseEnter: () => void;
onMouseLeave: () => void; onMouseLeave: () => void;
} }
const SearchResult: React.FC<SearchResultProps> = ({ result, onClick, onMouseEnter, onMouseLeave }) => { const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick, onMouseEnter, onMouseLeave }) => {
let shortenedDescription; let shortenedDescription;
if (result.description) { if (result.description) {
shortenedDescription = result.description && sanitize(result.description); shortenedDescription = result.description && sanitize(result.description);
if (shortenedDescription.length > 100) { if (shortenedDescription.length > 100) {
shortenedDescription = `${shortenedDescription.substring(0, 100)}...`; shortenedDescription = shortenedDescription.substring(0, 100) + "...";
} }
} }
@ -59,7 +59,7 @@ const SearchResult: React.FC<SearchResultProps> = ({ result, onClick, onMouseEnt
return ( return (
<StyledCard <StyledCard
elevation={Elevation.ONE} elevation={Elevation.ONE}
interactive interactive={true}
key={result.name} key={result.name}
onClick={onClick} onClick={onClick}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}

View File

@ -1,12 +1,12 @@
import { Classes, H3 } from "@blueprintjs/core"; import { Classes, H3 } from "@blueprintjs/core";
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { FederationRestrictions } from "../../redux/types"; import { IFederationRestrictions } from "../../redux/types";
const maybeGetList = (domains?: string[]) => const maybeGetList = (domains?: string[]) =>
domains && ( domains && (
<ul> <ul>
{domains.sort().map((domain) => ( {domains.sort().map(domain => (
<li key={domain}> <li key={domain}>
<Link to={`/instance/${domain}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button"> <Link to={`/instance/${domain}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
{domain} {domain}
@ -16,10 +16,10 @@ const maybeGetList = (domains?: string[]) =>
</ul> </ul>
); );
interface FederationTabProps { interface IFederationTabProps {
restrictions?: FederationRestrictions; restrictions?: IFederationRestrictions;
} }
const FederationTab: React.FC<FederationTabProps> = ({ restrictions }) => { const FederationTab: React.FC<IFederationTabProps> = ({ restrictions }) => {
if (!restrictions) { if (!restrictions) {
return null; return null;
} }

View File

@ -6,33 +6,33 @@ import { push } from "connected-react-router";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { fetchGraph } from "../../redux/actions"; import { fetchGraph } from "../../redux/actions";
import { AppState, GraphResponse } from "../../redux/types"; import { IAppState, IGraphResponse } from "../../redux/types";
import { colorSchemes, ColorScheme } from "../../types"; import { colorSchemes, IColorScheme } from "../../types";
import { domainMatchSelector } from "../../util"; import { domainMatchSelector } from "../../util";
import { Cytoscape, ErrorState, GraphTools } from "../molecules"; import { Cytoscape, ErrorState, GraphTools } from "../molecules/";
const GraphDiv = styled.div` const GraphDiv = styled.div`
flex: 2; flex: 2;
`; `;
interface GraphProps { interface IGraphProps {
currentInstanceName: string | null; currentInstanceName: string | null;
fetchGraph: () => void; fetchGraph: () => void;
graphResponse?: GraphResponse; graphResponse?: IGraphResponse;
graphLoadError: boolean; graphLoadError: boolean;
hoveringOverResult?: string; hoveringOverResult?: string;
isLoadingGraph: boolean; isLoadingGraph: boolean;
searchResultDomains: string[]; searchResultDomains: string[];
navigate: (path: string) => void; navigate: (path: string) => void;
} }
interface GraphState { interface IGraphState {
colorScheme?: ColorScheme; colorScheme?: IColorScheme;
isShowingEdges: boolean; isShowingEdges: boolean;
} }
class GraphImpl extends React.PureComponent<GraphProps, GraphState> { class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
private cytoscapeComponent: React.RefObject<Cytoscape>; private cytoscapeComponent: React.RefObject<Cytoscape>;
public constructor(props: GraphProps) { public constructor(props: IGraphProps) {
super(props); super(props);
this.cytoscapeComponent = React.createRef(); this.cytoscapeComponent = React.createRef();
this.state = { colorScheme: undefined, isShowingEdges: true }; this.state = { colorScheme: undefined, isShowingEdges: true };
@ -76,7 +76,7 @@ class GraphImpl extends React.PureComponent<GraphProps, GraphState> {
); );
} }
return <GraphDiv aria-hidden>{content}</GraphDiv>; return <GraphDiv>{content}</GraphDiv>;
} }
private loadGraph = () => { private loadGraph = () => {
@ -95,7 +95,7 @@ class GraphImpl extends React.PureComponent<GraphProps, GraphState> {
this.setState({ isShowingEdges: !this.state.isShowingEdges }); this.setState({ isShowingEdges: !this.state.isShowingEdges });
}; };
private setColorScheme = (colorScheme?: ColorScheme) => { private setColorScheme = (colorScheme?: IColorScheme) => {
this.setState({ colorScheme }); this.setState({ colorScheme });
}; };
@ -107,7 +107,7 @@ class GraphImpl extends React.PureComponent<GraphProps, GraphState> {
this.props.navigate("/"); this.props.navigate("/");
}; };
} }
const mapStateToProps = (state: AppState) => { const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
currentInstanceName: match && match.params.domain, currentInstanceName: match && match.params.domain,
@ -115,12 +115,15 @@ const mapStateToProps = (state: AppState) => {
graphResponse: state.data.graphResponse, graphResponse: state.data.graphResponse,
hoveringOverResult: state.search.hoveringOverResult, hoveringOverResult: state.search.hoveringOverResult,
isLoadingGraph: state.data.isLoadingGraph, isLoadingGraph: state.data.isLoadingGraph,
searchResultDomains: state.search.results.map((r) => r.name), searchResultDomains: state.search.results.map(r => r.name)
}; };
}; };
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
fetchGraph: () => dispatch(fetchGraph() as any), fetchGraph: () => dispatch(fetchGraph() as any),
navigate: (path: string) => dispatch(push(path)), navigate: (path: string) => dispatch(push(path))
}); });
const Graph = connect(mapStateToProps, mapDispatchToProps)(GraphImpl); const Graph = connect(
mapStateToProps,
mapDispatchToProps
)(GraphImpl);
export default Graph; export default Graph;

View File

@ -8,7 +8,7 @@ import { connect } from "react-redux";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { loadInstanceList } from "../../redux/actions"; import { loadInstanceList } from "../../redux/actions";
import { AppState, InstanceListResponse, InstanceSort, SortField, InstanceDetails } from "../../redux/types"; import { IAppState, IInstanceListResponse, IInstanceSort, SortField } from "../../redux/types";
import { InstanceType } from "../atoms"; import { InstanceType } from "../atoms";
import { ErrorState } from "../molecules"; import { ErrorState } from "../molecules";
@ -41,15 +41,15 @@ const InsularityColumn = styled.th`
width: 15%; width: 15%;
`; `;
interface InstanceTableProps { interface IInstanceTableProps {
loadError: boolean; loadError: boolean;
instancesResponse?: InstanceListResponse; instancesResponse?: IInstanceListResponse;
instanceListSort: InstanceSort; instanceListSort: IInstanceSort;
isLoading: boolean; isLoading: boolean;
loadInstanceList: (page?: number, sort?: InstanceSort) => void; loadInstanceList: (page?: number, sort?: IInstanceSort) => void;
navigate: (path: string) => void; navigate: (path: string) => void;
} }
class InstanceTable extends React.PureComponent<InstanceTableProps> { class InstanceTable extends React.PureComponent<IInstanceTableProps> {
public componentDidMount() { public componentDidMount() {
const { isLoading, instancesResponse, loadError } = this.props; const { isLoading, instancesResponse, loadError } = this.props;
if (!isLoading && !instancesResponse && !loadError) { if (!isLoading && !instancesResponse && !loadError) {
@ -61,23 +61,22 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
const { isLoading, instancesResponse, loadError } = this.props; const { isLoading, instancesResponse, loadError } = this.props;
if (loadError) { if (loadError) {
return <ErrorState />; return <ErrorState />;
} } else if (isLoading || !instancesResponse) {
if (isLoading || !instancesResponse) {
return <NonIdealState icon={<Spinner />} />; return <NonIdealState icon={<Spinner />} />;
} }
const { instances, pageNumber: currentPage, totalPages, totalEntries, pageSize } = instancesResponse; const { instances, pageNumber: currentPage, totalPages, totalEntries, pageSize } = instancesResponse!;
const pagesToDisplay = this.getPagesToDisplay(totalPages, currentPage); const pagesToDisplay = this.getPagesToDisplay(totalPages, currentPage);
return ( return (
<> <>
<StyledTable striped bordered interactive> <StyledTable striped={true} bordered={true} interactive={true}>
<thead> <thead>
<tr> <tr>
<InstanceColumn> <InstanceColumn>
Instance Instance
<Button <Button
minimal minimal={true}
icon={this.getSortIcon("domain")} icon={this.getSortIcon("domain")}
onClick={this.sortByFactory("domain")} onClick={this.sortByFactory("domain")}
intent={this.getSortIntent("domain")} intent={this.getSortIntent("domain")}
@ -88,7 +87,7 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
<UserCountColumn> <UserCountColumn>
Users Users
<Button <Button
minimal minimal={true}
icon={this.getSortIcon("userCount")} icon={this.getSortIcon("userCount")}
onClick={this.sortByFactory("userCount")} onClick={this.sortByFactory("userCount")}
intent={this.getSortIntent("userCount")} intent={this.getSortIntent("userCount")}
@ -97,7 +96,7 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
<StatusCountColumn> <StatusCountColumn>
Statuses Statuses
<Button <Button
minimal minimal={true}
icon={this.getSortIcon("statusCount")} icon={this.getSortIcon("statusCount")}
onClick={this.sortByFactory("statusCount")} onClick={this.sortByFactory("statusCount")}
intent={this.getSortIntent("statusCount")} intent={this.getSortIntent("statusCount")}
@ -106,7 +105,7 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
<InsularityColumn> <InsularityColumn>
Insularity Insularity
<Button <Button
minimal minimal={true}
icon={this.getSortIcon("insularity")} icon={this.getSortIcon("insularity")}
onClick={this.sortByFactory("insularity")} onClick={this.sortByFactory("insularity")}
intent={this.getSortIntent("insularity")} intent={this.getSortIntent("insularity")}
@ -115,7 +114,7 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{instances.map((i: InstanceDetails) => ( {instances.map(i => (
<tr key={i.name} onClick={this.goToInstanceFactory(i.name)}> <tr key={i.name} onClick={this.goToInstanceFactory(i.name)}>
<td>{i.name}</td> <td>{i.name}</td>
<td>{i.type && <InstanceType type={i.type} />}</td> <td>{i.type && <InstanceType type={i.type} />}</td>
@ -135,7 +134,7 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
</p> </p>
<ButtonGroup> <ButtonGroup>
{zip(pagesToDisplay, pagesToDisplay.slice(1)).map(([page, nextPage]) => { {zip(pagesToDisplay, pagesToDisplay.slice(1)).map(([page, nextPage], idx) => {
if (page === undefined) { if (page === undefined) {
return null; return null;
} }
@ -143,7 +142,7 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
const isEndOfSection = nextPage !== undefined && page + 1 !== nextPage && page !== totalPages; const isEndOfSection = nextPage !== undefined && page + 1 !== nextPage && page !== totalPages;
return ( return (
<React.Fragment key={page}> <>
<Button <Button
key={page} key={page}
onClick={this.loadPageFactory(page)} onClick={this.loadPageFactory(page)}
@ -153,11 +152,11 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
{page} {page}
</Button> </Button>
{isEndOfSection && ( {isEndOfSection && (
<Button disabled key="..."> <Button disabled={true} key={"..."}>
... {"..."}
</Button> </Button>
)} )}
</React.Fragment> </>
); );
})} })}
</ButtonGroup> </ButtonGroup>
@ -188,19 +187,20 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
const { instanceListSort } = this.props; const { instanceListSort } = this.props;
if (instanceListSort.field !== field) { if (instanceListSort.field !== field) {
return IconNames.SORT; return IconNames.SORT;
} } else if (instanceListSort.direction === "asc") {
if (instanceListSort.direction === "asc") {
return IconNames.SORT_ASC; return IconNames.SORT_ASC;
} else {
return IconNames.SORT_DESC;
} }
return IconNames.SORT_DESC;
}; };
private getSortIntent = (field: SortField) => { private getSortIntent = (field: SortField) => {
const { instanceListSort } = this.props; const { instanceListSort } = this.props;
if (instanceListSort.field === field) { if (instanceListSort.field === field) {
return Intent.PRIMARY; return Intent.PRIMARY;
} else {
return Intent.NONE;
} }
return Intent.NONE;
}; };
private getPagesToDisplay = (totalPages: number, currentPage: number) => { private getPagesToDisplay = (totalPages: number, currentPage: number) => {
@ -214,19 +214,24 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
const pagesToDisplay = firstPages.concat(surroundingPages).concat(lastPages); const pagesToDisplay = firstPages.concat(surroundingPages).concat(lastPages);
return sortedUniq(sortBy(pagesToDisplay, (n) => n)); return sortedUniq(sortBy(pagesToDisplay, n => n));
}; };
} }
const mapStateToProps = (state: AppState) => ({ const mapStateToProps = (state: IAppState) => {
instanceListSort: state.data.instanceListSort, return {
instancesResponse: state.data.instancesResponse, instanceListSort: state.data.instanceListSort,
isLoading: state.data.isLoadingInstanceList, instancesResponse: state.data.instancesResponse,
loadError: state.data.instanceListLoadError, isLoading: state.data.isLoadingInstanceList,
}); loadError: state.data.instanceListLoadError
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
loadInstanceList: (page?: number, sort?: InstanceSort) => dispatch(loadInstanceList(page, sort) as any), loadInstanceList: (page?: number, sort?: IInstanceSort) => dispatch(loadInstanceList(page, sort) as any),
navigate: (path: string) => dispatch(push(path)), navigate: (path: string) => dispatch(push(path))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(InstanceTable); export default connect(
mapStateToProps,
mapDispatchToProps
)(InstanceTable);

View File

@ -1,19 +1,21 @@
import * as React from "react"; import * as React from "react";
import { Alignment, Navbar, Classes } from "@blueprintjs/core"; import { Alignment, Navbar } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import { Classes } from "@blueprintjs/core";
import { match, NavLink } from "react-router-dom"; import { match, NavLink } from "react-router-dom";
import { InstanceDomainPath } from "../../constants"; import { IInstanceDomainPath } from "../../constants";
interface NavState { interface INavState {
aboutIsOpen: boolean; aboutIsOpen: boolean;
} }
const graphIsActive = (currMatch: match<InstanceDomainPath>, location: Location) => const graphIsActive = (currMatch: match<IInstanceDomainPath>, location: Location) => {
location.pathname === "/" || location.pathname.startsWith("/instance/"); return location.pathname === "/" || location.pathname.startsWith("/instance/");
};
class Nav extends React.Component<{}, NavState> { class Nav extends React.Component<{}, INavState> {
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.state = { aboutIsOpen: false }; this.state = { aboutIsOpen: false };
@ -21,46 +23,44 @@ class Nav extends React.Component<{}, NavState> {
public render() { public render() {
return ( return (
<nav role="navigation"> <Navbar fixedToTop={true}>
<Navbar fixedToTop={true}> <Navbar.Group align={Alignment.LEFT}>
<Navbar.Group align={Alignment.LEFT}> <Navbar.Heading>fediverse.space</Navbar.Heading>
<Navbar.Heading>fediverse.space</Navbar.Heading> <Navbar.Divider />
<Navbar.Divider /> <NavLink
<NavLink to="/"
to="/" className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.GLOBE_NETWORK}`}
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.GLOBE_NETWORK}`} activeClassName={Classes.INTENT_PRIMARY}
isActive={graphIsActive as any} isActive={graphIsActive as any}
activeClassName="current-navbar-item" >
> Home
Home </NavLink>
</NavLink> <NavLink
<NavLink to="/instances"
to="/instances" className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.TH}`}
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.TH}`} activeClassName={Classes.INTENT_PRIMARY}
activeClassName="current-navbar-item" >
> Instances
Instances </NavLink>
</NavLink> <NavLink
<NavLink to="/about"
to="/about" className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`}
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`} activeClassName={Classes.INTENT_PRIMARY}
activeClassName="current-navbar-item" exact={true}
exact={true} >
> About
About </NavLink>
</NavLink> </Navbar.Group>
</Navbar.Group> <Navbar.Group align={Alignment.RIGHT}>
<Navbar.Group align={Alignment.RIGHT}> <NavLink
<NavLink to="/admin"
to="/admin" className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.COG}`}
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.COG}`} activeClassName={Classes.INTENT_PRIMARY}
activeClassName="current-navbar-item" >
> Administration
Administration </NavLink>
</NavLink> </Navbar.Group>
</Navbar.Group> </Navbar>
</Navbar>
</nav>
); );
} }
} }

View File

@ -3,7 +3,7 @@ import { IconNames } from "@blueprintjs/icons";
import React, { MouseEvent } from "react"; import React, { MouseEvent } from "react";
import styled from "styled-components"; import styled from "styled-components";
import { INSTANCE_TYPES } from "../../constants"; import { INSTANCE_TYPES } from "../../constants";
import { getSearchFilterDisplayValue, SearchFilter } from "../../searchFilters"; import { getSearchFilterDisplayValue, ISearchFilter } from "../../searchFilters";
import { getTypeDisplayString } from "../../util"; import { getTypeDisplayString } from "../../util";
const SearchFilterContainer = styled.div` const SearchFilterContainer = styled.div`
@ -19,30 +19,30 @@ const StyledTag = styled(Tag)`
margin-left: 5px; margin-left: 5px;
`; `;
interface SearchFiltersProps { interface ISearchFiltersProps {
selectedFilters: SearchFilter[]; selectedFilters: ISearchFilter[];
selectFilter: (filter: SearchFilter) => void; selectFilter: (filter: ISearchFilter) => void;
deselectFilter: (e: MouseEvent<HTMLButtonElement>, props: ITagProps) => void; deselectFilter: (e: MouseEvent<HTMLButtonElement>, props: ITagProps) => void;
} }
const SearchFilters: React.FC<SearchFiltersProps> = ({ selectedFilters, selectFilter, deselectFilter }) => { const SearchFilters: React.FC<ISearchFiltersProps> = ({ selectedFilters, selectFilter, deselectFilter }) => {
const hasInstanceTypeFilter = selectedFilters.some((sf) => sf.field === "type"); const hasInstanceTypeFilter = selectedFilters.some(sf => sf.field === "type");
const handleSelectInstanceType = (e: MouseEvent<HTMLElement>) => { const handleSelectInstanceType = (e: MouseEvent<HTMLElement>) => {
const field = "type"; const field = "type";
const relation = "eq"; const relation = "eq";
const value = e.currentTarget.innerText.toLowerCase().replace(" ", ""); const value = e.currentTarget.innerText.toLowerCase().replace(" ", "");
const filter: SearchFilter = { const filter: ISearchFilter = {
displayValue: getSearchFilterDisplayValue(field, relation, value), displayValue: getSearchFilterDisplayValue(field, relation, value),
field, field,
relation, relation,
value, value
}; };
selectFilter(filter); selectFilter(filter);
}; };
const renderMenu = () => ( const renderMenu = () => (
<Menu> <Menu>
<MenuItem icon={IconNames.SYMBOL_CIRCLE} text="Instance type" disabled={hasInstanceTypeFilter}> <MenuItem icon={IconNames.SYMBOL_CIRCLE} text="Instance type" disabled={hasInstanceTypeFilter}>
{INSTANCE_TYPES.map((t) => ( {INSTANCE_TYPES.map(t => (
<MenuItem key={t} text={getTypeDisplayString(t)} onClick={handleSelectInstanceType} /> <MenuItem key={t} text={getTypeDisplayString(t)} onClick={handleSelectInstanceType} />
))} ))}
</MenuItem> </MenuItem>
@ -51,15 +51,15 @@ const SearchFilters: React.FC<SearchFiltersProps> = ({ selectedFilters, selectFi
return ( return (
<SearchFilterContainer> <SearchFilterContainer>
<TagContainer> <TagContainer>
{selectedFilters.map((filter) => ( {selectedFilters.map(filter => (
<StyledTag key={filter.displayValue} minimal onRemove={deselectFilter}> <StyledTag key={filter.displayValue} minimal={true} onRemove={deselectFilter}>
{filter.displayValue} {filter.displayValue}
</StyledTag> </StyledTag>
))} ))}
</TagContainer> </TagContainer>
<Popover autoFocus={false} content={renderMenu()} position={Position.BOTTOM}> <Popover autoFocus={false} content={renderMenu()} position={Position.BOTTOM}>
<Button minimal icon={IconNames.FILTER}> <Button minimal={true} icon={IconNames.FILTER}>
Add filter {"Add filter"}
</Button> </Button>
</Popover> </Popover>
</SearchFilterContainer> </SearchFilterContainer>

View File

@ -17,9 +17,11 @@ const StyledCard = styled(Card)`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
`; `;
const SidebarContainer: React.FC = ({ children }) => ( const SidebarContainer: React.FC = ({ children }) => {
<RightDiv> return (
<StyledCard elevation={Elevation.TWO}>{children}</StyledCard> <RightDiv>
</RightDiv> <StyledCard elevation={Elevation.TWO}>{children}</StyledCard>
); </RightDiv>
);
};
export default SidebarContainer; export default SidebarContainer;

View File

@ -1,14 +1,16 @@
import { Classes, Code, H1, H2, H3 } from "@blueprintjs/core"; import { Classes, Code, H1, H2, H4 } from "@blueprintjs/core";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import * as nlnetLogo from "../../assets/nlnet.png"; // import appsignalLogo from "../../assets/appsignal.svg";
import { Page } from "../atoms"; import gitlabLogo from "../../assets/gitlab.png";
import nlnetLogo from "../../assets/nlnet.png";
import { Page } from "../atoms/";
const SponsorContainer = styled.div` const SponsorContainer = styled.div`
margin-bottom: 20px; margin-bottom: 20px;
`; `;
const Sponsor = styled.div` const Sponsor = styled.div`
margin: 10px 40px 10px 0; margin: 10px;
display: inline-block; display: inline-block;
`; `;
@ -25,41 +27,36 @@ const AboutScreen: React.FC = () => (
<p> <p>
You can follow the project on{" "} You can follow the project on{" "}
<a href="https://social.inex.rocks/@indexCommunity" target="_blank" rel="noopener noreferrer"> <a href="https://mastodon.social/@fediversespace" target="_blank" rel="noopener noreferrer">
Mastodon Mastodon
</a> </a>
. .
</p> </p>
<p>
This is a fork of the original fediverse.space by{" "}
<a href="https://www.btao.org" target="_blank" rel="noopener noreferrer">
Tao Bojlén
</a>
.
</p>
<br /> <br />
<H2>FAQ</H2> <H2>FAQ</H2>
<H3>Why can&apos;t I see details about my instance?</H3> <H4>Why can't I see details about my instance?</H4>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
fediverse.space only supports servers using the Mastodon API, the Misskey API, the GNU Social API, or Nodeinfo. fediverse.space only supports servers using the Mastodon API, the Misskey API, the GNU Social API, or Nodeinfo.
Instances with 10 or fewer users won&apos;t be crawled -- it&apos;s a tool for understanding communities, not Instances with 10 or fewer users won't be scraped -- it's a tool for understanding communities, not individuals.
individuals.
</p> </p>
<H3> <H4>
When is <Code>$OTHER_FEDIVERSE_SERVER</Code> going to be added? When is <Code>$OTHER_FEDIVERSE_SERVER</Code> going to be added?
</H3> </H4>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
We are in the early forking-out phase, and don&apos;t provide any support yet. Check back later. Check out{" "}
<a href="https://gitlab.com/taobojlen/fediverse.space/issues/24" target="_blank" rel="noopener noreferrer">
this GitLab issue
</a>
.
</p> </p>
<H3>How do I add my personal instance?</H3> <H4>How do I add my personal instance?</H4>
<p className={Classes.RUNNING_TEXT}>Click on the Administration link in the top right to opt-in.</p> <p className={Classes.RUNNING_TEXT}>Click on the Administration link in the top right to opt-in.</p>
<H3>How do you calculate the strength of relationships between instances?</H3> <H4>How do you calculate the strength of relationships between instances?</H4>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
fediverse.space looks at public statuses from within the last month on the public timeline of each instance. It fediverse.space looks at public statuses from within the last month on the public timeline of each instance. It
calculates at the ratio of calculates at the ratio of
@ -67,23 +64,6 @@ const AboutScreen: React.FC = () => (
to reflect that smaller instances can play a large role in a community. to reflect that smaller instances can play a large role in a community.
</p> </p>
<H3>Who maintains this instance?</H3>
<p className={Classes.RUNNING_TEXT}>
index.community (fork domain) is an{" "}
<a href="https://innereq.org" target="_blank" rel="noopener noreferrer">
InnerEq.org
</a>{" "}
project maintained by Inex Code. You can help cover the cost of the server by becoming a{" "}
<a href="https://www.patreon.com/inexcode" target="_blank" rel="noopener noreferrer">
patron
</a>{" "}
or donating some{" "}
<a href="https://inex.rocks/#donate" target="_blank" rel="noopener noreferrer">
crypto
</a>
.
</p>
<br /> <br />
<H2>Special thanks</H2> <H2>Special thanks</H2>
@ -94,6 +74,16 @@ const AboutScreen: React.FC = () => (
</a> </a>
</Sponsor> </Sponsor>
<br /> <br />
{/* <Sponsor>
<a href="https://appsignal.com" target="_blank" rel="noopener noreferrer">
<img src={appsignalLogo} alt="Appsignal logo" height={40} />
</a>
</Sponsor> */}
<Sponsor>
<a href="https://gitlab.com" target="_blank" rel="noopener noreferrer">
<img src={gitlabLogo} alt="GitLab logo" height={40} />
</a>
</Sponsor>
</SponsorContainer> </SponsorContainer>
<p className={Classes.RUNNING_TEXT}>Inspiration for this site comes from several places:</p> <p className={Classes.RUNNING_TEXT}>Inspiration for this site comes from several places:</p>
@ -124,7 +114,7 @@ const AboutScreen: React.FC = () => (
</ul> </ul>
<p> <p>
The source code for fediverse.space is available on{" "} The source code for fediverse.space is available on{" "}
<a href="https://gitlab.com/fediverse.space/fediverse.space" target="_blank" rel="noopener noreferrer"> <a href="https://gitlab.com/taobojlen/fediverse.space" target="_blank" rel="noopener noreferrer">
GitLab GitLab
</a> </a>
; issues and pull requests are welcome! ; issues and pull requests are welcome!

View File

@ -17,7 +17,7 @@ const ButtonContainer = styled.div`
justify-content: space-between; justify-content: space-between;
`; `;
interface AdminSettings { interface IAdminSettings {
domain: string; domain: string;
optIn: boolean; optIn: boolean;
optOut: boolean; optOut: boolean;
@ -25,26 +25,26 @@ interface AdminSettings {
statusCount: number; statusCount: number;
} }
interface AdminScreenProps { interface IAdminScreenProps {
navigate: (path: string) => void; navigate: (path: string) => void;
} }
interface AdminScreenState { interface IAdminScreenState {
settings?: AdminSettings; settings?: IAdminSettings;
isUpdating: boolean; isUpdating: boolean;
} }
class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState> { class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenState> {
private authToken = getAuthToken(); private authToken = getAuthToken();
public constructor(props: AdminScreenProps) { public constructor(props: IAdminScreenProps) {
super(props); super(props);
this.state = { isUpdating: false }; this.state = { isUpdating: false };
} }
public componentDidMount() { public componentDidMount() {
// Load instance settings from server // Load instance settings from server
if (this.authToken) { if (!!this.authToken) {
getFromApi(`admin`, this.authToken) getFromApi(`admin`, this.authToken!)
.then((response) => { .then(response => {
this.setState({ settings: response }); this.setState({ settings: response });
}) })
.catch(() => { .catch(() => {
@ -52,7 +52,7 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
icon: IconNames.ERROR, icon: IconNames.ERROR,
intent: Intent.DANGER, intent: Intent.DANGER,
message: "Failed to load settings.", message: "Failed to load settings.",
timeout: 0, timeout: 0
}); });
unsetAuthToken(); unsetAuthToken();
}); });
@ -78,7 +78,7 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
<Switch <Switch
id="opt-in-switch" id="opt-in-switch"
checked={!!settings.optIn} checked={!!settings.optIn}
large large={true}
label="Opt in" label="Opt in"
disabled={!!isUpdating} disabled={!!isUpdating}
onChange={this.updateOptIn} onChange={this.updateOptIn}
@ -89,7 +89,7 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
<Switch <Switch
id="opt-out-switch" id="opt-out-switch"
checked={!!settings.optOut} checked={!!settings.optOut}
large large={true}
label="Opt out" label="Opt out"
disabled={!!isUpdating} disabled={!!isUpdating}
onChange={this.updateOptOut} onChange={this.updateOptOut}
@ -116,9 +116,9 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
} }
private updateOptIn = (e: React.FormEvent<HTMLInputElement>) => { private updateOptIn = (e: React.FormEvent<HTMLInputElement>) => {
const settings = this.state.settings as AdminSettings; const settings = this.state.settings as IAdminSettings;
const optIn = e.currentTarget.checked; const optIn = e.currentTarget.checked;
let { optOut } = settings; let optOut = settings.optOut;
if (optIn) { if (optIn) {
optOut = false; optOut = false;
} }
@ -126,9 +126,9 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
}; };
private updateOptOut = (e: React.FormEvent<HTMLInputElement>) => { private updateOptOut = (e: React.FormEvent<HTMLInputElement>) => {
const settings = this.state.settings as AdminSettings; const settings = this.state.settings as IAdminSettings;
const optOut = e.currentTarget.checked; const optOut = e.currentTarget.checked;
let { optIn } = settings; let optIn = settings.optIn;
if (optOut) { if (optOut) {
optIn = false; optIn = false;
} }
@ -140,15 +140,15 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
this.setState({ isUpdating: true }); this.setState({ isUpdating: true });
const body = { const body = {
optIn: this.state.settings!.optIn, optIn: this.state.settings!.optIn,
optOut: this.state.settings!.optOut, optOut: this.state.settings!.optOut
}; };
postToApi(`admin`, body, this.authToken!) postToApi(`admin`, body, this.authToken!)
.then((response) => { .then(response => {
this.setState({ settings: response, isUpdating: false }); this.setState({ settings: response, isUpdating: false });
AppToaster.show({ AppToaster.show({
icon: IconNames.TICK, icon: IconNames.TICK,
intent: Intent.SUCCESS, intent: Intent.SUCCESS,
message: "Successfully updated settings.", message: "Successfully updated settings."
}); });
}) })
.catch(() => { .catch(() => {
@ -161,13 +161,16 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
unsetAuthToken(); unsetAuthToken();
AppToaster.show({ AppToaster.show({
icon: IconNames.LOG_OUT, icon: IconNames.LOG_OUT,
message: "Logged out.", message: "Logged out."
}); });
this.props.navigate("/admin/login"); this.props.navigate("/admin/login");
}; };
} }
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
navigate: (path: string) => dispatch(push(path)), navigate: (path: string) => dispatch(push(path))
}); });
export default connect(undefined, mapDispatchToProps)(AdminScreen); export default connect(
undefined,
mapDispatchToProps
)(AdminScreen);

View File

@ -7,9 +7,9 @@ import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
import { InstanceScreen, SearchScreen } from "."; import { InstanceScreen, SearchScreen } from ".";
import { INSTANCE_DOMAIN_PATH } from "../../constants"; import { INSTANCE_DOMAIN_PATH } from "../../constants";
import { loadInstance } from "../../redux/actions"; import { loadInstance } from "../../redux/actions";
import { AppState } from "../../redux/types"; import { IAppState } from "../../redux/types";
import { domainMatchSelector, isSmallScreen } from "../../util"; import { domainMatchSelector, isSmallScreen } from "../../util";
import { Graph, SidebarContainer } from "../organisms"; import { Graph, SidebarContainer } from "../organisms/";
const GraphContainer = styled.div` const GraphContainer = styled.div`
display: flex; display: flex;
@ -24,13 +24,13 @@ const FullDiv = styled.div`
right: 0; right: 0;
`; `;
interface GraphScreenProps extends RouteComponentProps { interface IGraphScreenProps extends RouteComponentProps {
currentInstanceName: string | null; currentInstanceName: string | null;
pathname: string; pathname: string;
graphLoadError: boolean; graphLoadError: boolean;
loadInstance: (domain: string | null) => void; loadInstance: (domain: string | null) => void;
} }
interface GraphScreenState { interface IGraphScreenState {
hasBeenViewed: boolean; hasBeenViewed: boolean;
} }
/** /**
@ -41,8 +41,8 @@ interface GraphScreenState {
* However, if it's not the first page viewed (e.g. if someone opens directly on /about) we don't want to render the * However, if it's not the first page viewed (e.g. if someone opens directly on /about) we don't want to render the
* graph since it slows down everything else! * graph since it slows down everything else!
*/ */
class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState> { class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenState> {
public constructor(props: GraphScreenProps) { public constructor(props: IGraphScreenProps) {
super(props); super(props);
this.state = { hasBeenViewed: false }; this.state = { hasBeenViewed: false };
} }
@ -56,7 +56,7 @@ class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState
this.loadCurrentInstance(); this.loadCurrentInstance();
} }
public componentDidUpdate(prevProps: GraphScreenProps) { public componentDidUpdate(prevProps: IGraphScreenProps) {
this.setHasBeenViewed(); this.setHasBeenViewed();
this.loadCurrentInstance(prevProps.currentInstanceName); this.loadCurrentInstance(prevProps.currentInstanceName);
} }
@ -72,7 +72,7 @@ class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState
} }
}; };
private renderRoutes = () => ( private renderRoutes = ({ location }: RouteComponentProps) => (
<FullDiv> <FullDiv>
<GraphContainer> <GraphContainer>
{/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */} {/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */}
@ -80,7 +80,7 @@ class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState
<SidebarContainer> <SidebarContainer>
<Switch> <Switch>
<Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} /> <Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} />
<Route exact path="/" component={SearchScreen} /> <Route exact={true} path="/" component={SearchScreen} />
</Switch> </Switch>
</SidebarContainer> </SidebarContainer>
</GraphContainer> </GraphContainer>
@ -94,16 +94,19 @@ class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState
}; };
} }
const mapStateToProps = (state: AppState) => { const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
currentInstanceName: match && match.params.domain, currentInstanceName: match && match.params.domain,
graphLoadError: state.data.graphLoadError, graphLoadError: state.data.graphLoadError,
pathname: state.router.location.pathname, pathname: state.router.location.pathname
}; };
}; };
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any), loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any)
}); });
const GraphScreen = connect(mapStateToProps, mapDispatchToProps)(GraphScreenImpl); const GraphScreen = connect(
mapStateToProps,
mapDispatchToProps
)(GraphScreenImpl);
export default withRouter(GraphScreen); export default withRouter(GraphScreen);

View File

@ -20,7 +20,7 @@ import {
Spinner, Spinner,
Tab, Tab,
Tabs, Tabs,
Tooltip, Tooltip
} from "@blueprintjs/core"; } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
@ -28,10 +28,10 @@ import { push } from "connected-react-router";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { AppState, Graph, GraphResponse, InstanceDetails, Peer } from "../../redux/types"; import { IAppState, IGraph, IGraphResponse, IInstanceDetails } from "../../redux/types";
import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util"; import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util";
import { InstanceType } from "../atoms"; import { InstanceType } from "../atoms";
import { Cytoscape, ErrorState } from "../molecules"; import { Cytoscape, ErrorState } from "../molecules/";
import { FederationTab } from "../organisms"; import { FederationTab } from "../organisms";
const InstanceScreenContainer = styled.div` const InstanceScreenContainer = styled.div`
@ -82,25 +82,25 @@ const StyledGraphContainer = styled.div`
flex-direction: column; flex-direction: column;
margin-bottom: 10px; margin-bottom: 10px;
`; `;
interface InstanceScreenProps { interface IInstanceScreenProps {
graph?: Graph; graph?: IGraph;
instanceName: string | null; instanceName: string | null;
instanceLoadError: boolean; instanceLoadError: boolean;
instanceDetails: InstanceDetails | null; instanceDetails: IInstanceDetails | null;
isLoadingInstanceDetails: boolean; isLoadingInstanceDetails: boolean;
navigateToRoot: () => void; navigateToRoot: () => void;
navigateToInstance: (domain: string) => void; navigateToInstance: (domain: string) => void;
} }
interface InstanceScreenState { interface IInstanceScreenState {
neighbors?: string[]; neighbors?: string[];
isProcessingNeighbors: boolean; isProcessingNeighbors: boolean;
// Local (neighborhood) graph. Used only on small screens (mobile devices). // Local (neighborhood) graph. Used only on small screens (mobile devices).
isLoadingLocalGraph: boolean; isLoadingLocalGraph: boolean;
localGraph?: Graph; localGraph?: IGraph;
localGraphLoadError?: boolean; localGraphLoadError?: boolean;
} }
class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, InstanceScreenState> { class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInstanceScreenState> {
public constructor(props: InstanceScreenProps) { public constructor(props: IInstanceScreenProps) {
super(props); super(props);
this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false }; this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false };
} }
@ -116,9 +116,9 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
!this.props.instanceDetails.status !this.props.instanceDetails.status
) { ) {
content = <ErrorState />; content = <ErrorState />;
} else if (this.props.instanceDetails.status.toLowerCase().includes("personal instance")) { } else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) {
content = this.renderPersonalInstanceErrorState(); content = this.renderPersonalInstanceErrorState();
} else if (this.props.instanceDetails.status.toLowerCase().includes("robots.txt")) { } else if (this.props.instanceDetails.status.toLowerCase().indexOf("robots.txt") > -1) {
content = this.renderRobotsTxtState(); content = this.renderRobotsTxtState();
} else if (this.props.instanceDetails.status !== "success") { } else if (this.props.instanceDetails.status !== "success") {
content = this.renderMissingDataState(); content = this.renderMissingDataState();
@ -130,7 +130,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
<HeadingContainer> <HeadingContainer>
<StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2> <StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2>
<StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}> <StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
<AnchorButton icon={IconNames.LINK} minimal onClick={this.openInstanceLink} /> <AnchorButton icon={IconNames.LINK} minimal={true} onClick={this.openInstanceLink} />
</StyledHeadingTooltip> </StyledHeadingTooltip>
<StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} /> <StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} />
</HeadingContainer> </HeadingContainer>
@ -145,7 +145,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
this.processEdgesToFindNeighbors(); this.processEdgesToFindNeighbors();
} }
public componentDidUpdate(prevProps: InstanceScreenProps, prevState: InstanceScreenState) { public componentDidUpdate(prevProps: IInstanceScreenProps, prevState: IInstanceScreenState) {
const isNewInstance = prevProps.instanceName !== this.props.instanceName; const isNewInstance = prevProps.instanceName !== this.props.instanceName;
const receivedNewEdges = !!this.props.graph && !this.state.isProcessingNeighbors && !this.state.neighbors; const receivedNewEdges = !!this.props.graph && !this.state.isProcessingNeighbors && !this.state.neighbors;
const receivedNewLocalGraph = !!this.state.localGraph && !prevState.localGraph; const receivedNewLocalGraph = !!this.state.localGraph && !prevState.localGraph;
@ -164,13 +164,10 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
} }
this.setState({ isProcessingNeighbors: true }); this.setState({ isProcessingNeighbors: true });
const graphToUse = graph || localGraph; const graphToUse = !!graph ? graph : localGraph;
if (!graphToUse) { const edges = graphToUse!.edges.filter(e => [e.data.source, e.data.target].indexOf(instanceName!) > -1);
return;
}
const edges = graphToUse.edges.filter((e) => [e.data.source, e.data.target].includes(instanceName));
const neighbors: any[] = []; const neighbors: any[] = [];
edges.forEach((e) => { edges.forEach(e => {
if (e.data.source === instanceName) { if (e.data.source === instanceName) {
neighbors.push({ neighbor: e.data.target, weight: e.data.weight }); neighbors.push({ neighbor: e.data.target, weight: e.data.weight });
} else { } else {
@ -186,38 +183,39 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
} }
this.setState({ isLoadingLocalGraph: true }); this.setState({ isLoadingLocalGraph: true });
getFromApi(`graph/${this.props.instanceName}`) getFromApi(`graph/${this.props.instanceName}`)
.then((response: GraphResponse) => { .then((response: IGraphResponse) => {
// We do some processing of edges here to make sure that every edge's source and target are in the neighborhood // We do some processing of edges here to make sure that every edge's source and target are in the neighborhood
// We could (and should) be doing this in the backend, but I don't want to mess around with complex SQL // We could (and should) be doing this in the backend, but I don't want to mess around with complex SQL
// queries. // queries.
// TODO: think more about moving the backend to a graph database that would make this easier. // TODO: think more about moving the backend to a graph database that would make this easier.
const { graph } = response; const graph = response.graph;
const nodeIds = new Set(graph.nodes.map((n) => n.data.id)); const nodeIds = new Set(graph.nodes.map(n => n.data.id));
const edges = graph.edges.filter((e) => nodeIds.has(e.data.source) && nodeIds.has(e.data.target)); const edges = graph.edges.filter(e => nodeIds.has(e.data.source) && nodeIds.has(e.data.target));
this.setState({ isLoadingLocalGraph: false, localGraph: { ...graph, edges } }); this.setState({ isLoadingLocalGraph: false, localGraph: { ...graph, edges } });
}) })
.catch(() => this.setState({ isLoadingLocalGraph: false, localGraphLoadError: true })); .catch(() => this.setState({ isLoadingLocalGraph: false, localGraphLoadError: true }));
}; };
private renderTabs = () => { private renderTabs = () => {
const { instanceDetails } = this.props;
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0; const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
const federationRestrictions = instanceDetails && instanceDetails.federationRestrictions; const federationRestrictions = this.props.instanceDetails && this.props.instanceDetails.federationRestrictions;
const hasLocalGraph = const hasLocalGraph =
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0; !!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0;
const insularCallout = const insularCallout =
this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors && !hasLocalGraph ? ( this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors && !hasLocalGraph ? (
<StyledCallout icon={IconNames.INFO_SIGN} title="Insular instance"> <StyledCallout icon={IconNames.INFO_SIGN} title="Insular instance">
<p>This instance doesn&apos;t have any neighbors that we know of, so it&apos;s hidden from the graph.</p> <p>This instance doesn't have any neighbors that we know of, so it's hidden from the graph.</p>
</StyledCallout> </StyledCallout>
) : undefined; ) : (
undefined
);
return ( return (
<> <>
{insularCallout} {insularCallout}
{this.maybeRenderLocalGraph()} {this.maybeRenderLocalGraph()}
<StyledTabs> <StyledTabs>
{instanceDetails && instanceDetails.description && ( {this.props.instanceDetails!.description && (
<Tab id="description" title="Description" panel={this.renderDescription()} /> <Tab id="description" title="Description" panel={this.renderDescription()} />
)} )}
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />} {this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
@ -233,11 +231,11 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
</StyledTabs> </StyledTabs>
<StyledLinkToFdNetwork> <StyledLinkToFdNetwork>
<AnchorButton <AnchorButton
href={`https://fedidb.org/network/instance?domain=${this.props.instanceName}`} href={`https://fediverse.network/${this.props.instanceName}`}
minimal minimal={true}
rightIcon={IconNames.SHARE} rightIcon={IconNames.SHARE}
target="_blank" target="_blank"
text="See more statistics at fedidb.org" text="See more statistics at fediverse.network"
/> />
</StyledLinkToFdNetwork> </StyledLinkToFdNetwork>
</> </>
@ -246,17 +244,18 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
private maybeRenderLocalGraph = () => { private maybeRenderLocalGraph = () => {
const { localGraph } = this.state; const { localGraph } = this.state;
const hasLocalGraph = !!localGraph && localGraph.nodes.length > 0 && localGraph.edges.length > 0; const hasLocalGraph =
if (!hasLocalGraph || !localGraph) { !!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0;
if (!hasLocalGraph) {
return; return;
} }
return ( return (
<StyledGraphContainer aria-hidden> <StyledGraphContainer>
<Cytoscape <Cytoscape
elements={localGraph} elements={localGraph!}
currentNodeId={this.props.instanceName} currentNodeId={this.props.instanceName}
navigateToInstancePath={this.props.navigateToInstance} navigateToInstancePath={this.props.navigateToInstance}
showEdges showEdges={true}
/> />
<Divider /> <Divider />
</StyledGraphContainer> </StyledGraphContainer>
@ -269,11 +268,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
}; };
private renderDescription = () => { private renderDescription = () => {
const { instanceDetails } = this.props; const description = this.props.instanceDetails!.description;
if (!instanceDetails) {
return;
}
const { description } = instanceDetails;
if (!description) { if (!description) {
return; return;
} }
@ -293,10 +288,10 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
insularity, insularity,
type, type,
statusesPerDay, statusesPerDay,
statusesPerUserPerDay, statusesPerUserPerDay
} = this.props.instanceDetails; } = this.props.instanceDetails;
return ( return (
<StyledHTMLTable small striped> <StyledHTMLTable small={true} striped={true}>
<tbody> <tbody>
<tr> <tr>
<td>Version</td> <td>Version</td>
@ -304,7 +299,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
</tr> </tr>
<tr> <tr>
<td>Instance type</td> <td>Instance type</td>
<td>{(type && <InstanceType type={type} colorAfterName />) || "Unknown"}</td> <td>{(type && <InstanceType type={type} colorAfterName={true} />) || "Unknown"}</td>
</tr> </tr>
<tr> <tr>
<td>Users</td> <td>Users</td>
@ -316,8 +311,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
</tr> </tr>
<tr> <tr>
<td> <td>
Insularity Insularity{" "}
{" "}
<Tooltip <Tooltip
content={ content={
<span> <span>
@ -336,8 +330,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
</tr> </tr>
<tr> <tr>
<td> <td>
Statuses / day Statuses / day{" "}
{" "}
<Tooltip <Tooltip
content={ content={
<span> <span>
@ -356,8 +349,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
</tr> </tr>
<tr> <tr>
<td> <td>
Statuses / person / day Statuses / person / day{" "}
{" "}
<Tooltip <Tooltip
content={ content={
<span> <span>
@ -380,7 +372,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
</tr> </tr>
<tr> <tr>
<td>Last updated</td> <td>Last updated</td>
<td>{moment(`${lastUpdated}Z`).fromNow() || "Unknown"}</td> <td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
</tr> </tr>
</tbody> </tbody>
</StyledHTMLTable> </StyledHTMLTable>
@ -420,7 +412,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
would mean that every single status on {this.props.instanceName} contained a mention of someone on the other would mean that every single status on {this.props.instanceName} contained a mention of someone on the other
instance, and vice versa. instance, and vice versa.
</p> </p>
<StyledHTMLTable small striped interactive={false}> <StyledHTMLTable small={true} striped={true} interactive={false}>
<thead> <thead>
<tr> <tr>
<th>Instance</th> <th>Instance</th>
@ -434,15 +426,11 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
}; };
private renderPeers = () => { private renderPeers = () => {
const { instanceDetails } = this.props; const peers = this.props.instanceDetails!.peers;
if (!instanceDetails) {
return;
}
const { peers } = instanceDetails;
if (!peers || peers.length === 0) { if (!peers || peers.length === 0) {
return; return;
} }
const peerRows = peers.map((instance: Peer) => ( const peerRows = peers.map(instance => (
<tr key={instance.name}> <tr key={instance.name}>
<td> <td>
<Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button"> <Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
@ -456,7 +444,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
<p className={Classes.TEXT_MUTED}> <p className={Classes.TEXT_MUTED}>
All the instances, past and present, that {this.props.instanceName} knows about. All the instances, past and present, that {this.props.instanceName} knows about.
</p> </p>
<StyledHTMLTable small striped interactive={false} className="fediverse-sidebar-table"> <StyledHTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
<tbody>{peerRows}</tbody> <tbody>{peerRows}</tbody>
</StyledHTMLTable> </StyledHTMLTable>
</div> </div>
@ -465,62 +453,71 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
private renderLoadingState = () => <NonIdealState icon={<Spinner />} />; private renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
private renderPersonalInstanceErrorState = () => ( private renderPersonalInstanceErrorState = () => {
<NonIdealState return (
icon={IconNames.BLOCKED_PERSON}
title="No data"
description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in."
action={
<Link to="/admin" className={Classes.BUTTON} role="button">
Opt in
</Link>
}
/>
);
private renderMissingDataState = () => (
<>
<NonIdealState <NonIdealState
icon={IconNames.ERROR} icon={IconNames.BLOCKED_PERSON}
title="No data" title="No data"
description="This instance could not be crawled. Either it was down or it's an instance type we don't support yet." description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in."
action={
<Link to={"/admin"} className={Classes.BUTTON} role="button">
{"Opt in"}
</Link>
}
/> />
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}> );
{this.props.instanceDetails && this.props.instanceDetails.status} };
</span>
</>
);
private renderRobotsTxtState = () => ( private renderMissingDataState = () => {
<NonIdealState return (
icon={ <>
<span role="img" aria-label="robot"> <NonIdealState
🤖 icon={IconNames.ERROR}
title="No data"
description="This instance could not be crawled. Either it was down or it's an instance type we don't support yet."
/>
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}>
{this.props.instanceDetails && this.props.instanceDetails.status}
</span> </span>
} </>
title="No data" );
description="This instance was not crawled because its robots.txt did not allow us to." };
/>
); private renderRobotsTxtState = () => {
return (
<NonIdealState
icon={
<span role="img" aria-label="robot">
🤖
</span>
}
title="No data"
description="This instance was not crawled because its robots.txt did not allow us to."
/>
);
};
private openInstanceLink = () => { private openInstanceLink = () => {
window.open(`https://${this.props.instanceName}`, "_blank"); window.open("https://" + this.props.instanceName, "_blank");
}; };
} }
const mapStateToProps = (state: AppState) => { const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
graph: state.data.graphResponse && state.data.graphResponse.graph, graph: state.data.graphResponse && state.data.graphResponse.graph,
instanceDetails: state.currentInstance.currentInstanceDetails, instanceDetails: state.currentInstance.currentInstanceDetails,
instanceLoadError: state.currentInstance.error, instanceLoadError: state.currentInstance.error,
instanceName: match && match.params.domain, instanceName: match && match.params.domain,
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails, isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails
}; };
}; };
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)), navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
navigateToRoot: () => dispatch(push("/")), navigateToRoot: () => dispatch(push("/"))
}); });
const InstanceScreen = connect(mapStateToProps, mapDispatchToProps)(InstanceScreenImpl); const InstanceScreen = connect(
mapStateToProps,
mapDispatchToProps
)(InstanceScreenImpl);
export default InstanceScreen; export default InstanceScreen;

View File

@ -1,4 +1,4 @@
import { Button, Classes, FormGroup, H1, H2, Icon, InputGroup, Intent } from "@blueprintjs/core"; import { Button, Classes, FormGroup, H1, H4, Icon, InputGroup, Intent } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import React from "react"; import React from "react";
import { Redirect } from "react-router"; import { Redirect } from "react-router";
@ -8,11 +8,11 @@ import { getAuthToken, getFromApi, postToApi } from "../../util";
import { Page } from "../atoms"; import { Page } from "../atoms";
import { ErrorState } from "../molecules"; import { ErrorState } from "../molecules";
interface FormContainerProps { interface IFormContainerProps {
error: boolean; error: boolean;
} }
const FormContainer = styled.div<FormContainerProps>` const FormContainer = styled.div<IFormContainerProps>`
${(props) => (props.error ? "margin: 20px auto 0 auto;" : "margin-top: 20px;")} ${props => (props.error ? "margin: 20px auto 0 auto;" : "margin-top: 20px;")}
`; `;
const LoginTypeContainer = styled.div` const LoginTypeContainer = styled.div`
display: flex; display: flex;
@ -31,28 +31,23 @@ const StyledIcon = styled(Icon)`
margin-bottom: 10px; margin-bottom: 10px;
`; `;
interface LoginTypes { interface ILoginTypes {
domain: string; domain: string;
email?: string; email?: string;
fediverseAccount?: string; fediverseAccount?: string;
} }
interface LoginScreenState { interface ILoginScreenState {
domain: string; domain: string;
isGettingLoginTypes: boolean; isGettingLoginTypes: boolean;
isSendingLoginRequest: boolean; isSendingLoginRequest: boolean;
loginTypes?: LoginTypes; loginTypes?: ILoginTypes;
selectedLoginType?: "email" | "fediverseAccount"; selectedLoginType?: "email" | "fediverseAccount";
error: boolean; error: boolean;
} }
class LoginScreen extends React.PureComponent<{}, LoginScreenState> { class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
public constructor(props: any) { public constructor(props: any) {
super(props); super(props);
this.state = { this.state = { domain: "", error: false, isGettingLoginTypes: false, isSendingLoginRequest: false };
domain: "",
error: false,
isGettingLoginTypes: false,
isSendingLoginRequest: false,
};
} }
public render() { public render() {
@ -64,13 +59,13 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
const { error, loginTypes, isSendingLoginRequest, selectedLoginType } = this.state; const { error, loginTypes, isSendingLoginRequest, selectedLoginType } = this.state;
let content; let content;
if (error) { if (!!error) {
content = ( content = (
<ErrorState description="This could be because the instance is down. If not, please reload the page and try again." /> <ErrorState description="This could be because the instance is down. If not, please reload the page and try again." />
); );
} else if (!!selectedLoginType && !isSendingLoginRequest) { } else if (!!selectedLoginType && !isSendingLoginRequest) {
content = this.renderPostLogin(); content = this.renderPostLogin();
} else if (loginTypes) { } else if (!!loginTypes) {
content = this.renderChooseLoginType(); content = this.renderChooseLoginType();
} else { } else {
content = this.renderChooseInstance(); content = this.renderChooseInstance();
@ -79,14 +74,16 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
return ( return (
<Page> <Page>
<H1>Login</H1> <H1>Login</H1>
<p className={Classes.RUNNING_TEXT}>You must be the instance admin to manage how fediverse.space</p>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
It&apos;s currently only possible to administrate Mastodon and Pleroma instances. If you want to login with a You must be the instance admin to manage how fediverse.space interacts with your instance.
direct message, your instance must federate with social.inex.rocks and vice versa. </p>
<p className={Classes.RUNNING_TEXT}>
It's currently only possible to administrate Mastodon and Pleroma instances. If you want to login with a
direct message, your instance must federate with mastodon.social and vice versa.
</p> </p>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
If you run another server type, you can manually opt in or out by writing to{" "} If you run another server type, you can manually opt in or out by writing to{" "}
<a href="https://social.inex.rocks/@indexCommunity">@indexCommunity</a>. <a href="https://mastodon.social/@fediversespace">@fediversespace</a>.
</p> </p>
<FormContainer error={this.state.error}>{content}</FormContainer> <FormContainer error={this.state.error}>{content}</FormContainer>
</Page> </Page>
@ -98,7 +95,7 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
const onButtonClick = () => this.getLoginTypes(); const onButtonClick = () => this.getLoginTypes();
return ( return (
<form onSubmit={this.getLoginTypes}> <form onSubmit={this.getLoginTypes}>
<FormGroup label="Instance domain" labelFor="domain-input" disabled={isGettingLoginTypes} inline> <FormGroup label="Instance domain" labelFor="domain-input" disabled={isGettingLoginTypes} inline={true}>
<InputGroup <InputGroup
disabled={isGettingLoginTypes} disabled={isGettingLoginTypes}
id="domain-input" id="domain-input"
@ -107,7 +104,7 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
rightElement={ rightElement={
<Button <Button
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
minimal minimal={true}
rightIcon={IconNames.ARROW_RIGHT} rightIcon={IconNames.ARROW_RIGHT}
title="submit" title="submit"
loading={isGettingLoginTypes} loading={isGettingLoginTypes}
@ -130,16 +127,21 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
const loginWithDm = () => this.login("fediverseAccount"); const loginWithDm = () => this.login("fediverseAccount");
return ( return (
<> <>
<H2>Choose an authentication method</H2> <H4>Choose an authentication method</H4>
<LoginTypeContainer> <LoginTypeContainer>
{loginTypes.email && ( {loginTypes.email && (
<LoginTypeButton large icon={IconNames.ENVELOPE} onClick={loginWithEmail} loading={!!isSendingLoginRequest}> <LoginTypeButton
large={true}
icon={IconNames.ENVELOPE}
onClick={loginWithEmail}
loading={!!isSendingLoginRequest}
>
{`Email ${loginTypes.email}`} {`Email ${loginTypes.email}`}
</LoginTypeButton> </LoginTypeButton>
)} )}
{loginTypes.fediverseAccount && ( {loginTypes.fediverseAccount && (
<LoginTypeButton <LoginTypeButton
large large={true}
icon={IconNames.GLOBE_NETWORK} icon={IconNames.GLOBE_NETWORK}
onClick={loginWithDm} onClick={loginWithDm}
loading={!!isSendingLoginRequest} loading={!!isSendingLoginRequest}
@ -173,7 +175,7 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
}; };
private getLoginTypes = (e?: React.FormEvent<HTMLFormElement>) => { private getLoginTypes = (e?: React.FormEvent<HTMLFormElement>) => {
if (e) { if (!!e) {
e.preventDefault(); e.preventDefault();
} }
this.setState({ isGettingLoginTypes: true }); this.setState({ isGettingLoginTypes: true });
@ -182,8 +184,8 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
domain = domain.slice(8); domain = domain.slice(8);
} }
getFromApi(`admin/login/${domain.trim()}`) getFromApi(`admin/login/${domain.trim()}`)
.then((response) => { .then(response => {
if (response.error) { if (!!response.error) {
// Go to catch() below // Go to catch() below
throw new Error(response.error); throw new Error(response.error);
} else { } else {
@ -194,7 +196,7 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
AppToaster.show({ AppToaster.show({
icon: IconNames.ERROR, icon: IconNames.ERROR,
intent: Intent.DANGER, intent: Intent.DANGER,
message: err.message, message: err.message
}); });
this.setState({ isGettingLoginTypes: false }); this.setState({ isGettingLoginTypes: false });
}); });
@ -203,7 +205,7 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
private login = (type: "email" | "fediverseAccount") => { private login = (type: "email" | "fediverseAccount") => {
this.setState({ isSendingLoginRequest: true, selectedLoginType: type }); this.setState({ isSendingLoginRequest: true, selectedLoginType: type });
postToApi("admin/login", { domain: this.state.loginTypes!.domain, type }) postToApi("admin/login", { domain: this.state.loginTypes!.domain, type })
.then((response) => { .then(response => {
if ("error" in response || "errors" in response) { if ("error" in response || "errors" in response) {
// Go to catch() below // Go to catch() below
throw new Error(); throw new Error();

View File

@ -1,4 +1,4 @@
import { Button, Callout, H1, InputGroup, Intent, NonIdealState, Spinner } from "@blueprintjs/core"; import { Button, Callout, H2, InputGroup, Intent, NonIdealState, Spinner } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import { push } from "connected-react-router"; import { push } from "connected-react-router";
import { get, isEqual } from "lodash"; import { get, isEqual } from "lodash";
@ -7,20 +7,20 @@ import { connect } from "react-redux";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { setResultHover, updateSearch } from "../../redux/actions"; import { setResultHover, updateSearch } from "../../redux/actions";
import { AppState, SearchResultInstance } from "../../redux/types"; import { IAppState, ISearchResultInstance } from "../../redux/types";
import { SearchFilter } from "../../searchFilters"; import { ISearchFilter } from "../../searchFilters";
import { isSmallScreen } from "../../util"; import { isSmallScreen } from "../../util";
import { SearchResult } from "../molecules"; import { SearchResult } from "../molecules";
import { SearchFilters } from "../organisms"; import { SearchFilters } from "../organisms";
interface SearchBarContainerProps { interface ISearchBarContainerProps {
hasSearchResults: boolean; hasSearchResults: boolean;
hasError: boolean; hasError: boolean;
} }
const SearchBarContainer = styled.div<SearchBarContainerProps>` const SearchBarContainer = styled.div<ISearchBarContainerProps>`
width: 80%; width: 80%;
text-align: center; text-align: center;
margin: ${(props) => (props.hasSearchResults || props.hasError ? "0 auto" : "auto")}; margin: ${props => (props.hasSearchResults || props.hasError ? "0 auto" : "auto")};
align-self: center; align-self: center;
`; `;
const SearchResults = styled.div` const SearchResults = styled.div`
@ -38,22 +38,22 @@ const CalloutContainer = styled.div`
text-align: left; text-align: left;
`; `;
interface SearchScreenProps { interface ISearchScreenProps {
error: boolean; error: boolean;
isLoadingResults: boolean; isLoadingResults: boolean;
query: string; query: string;
hasMoreResults: boolean; hasMoreResults: boolean;
results: SearchResultInstance[]; results: ISearchResultInstance[];
handleSearch: (query: string, filters: SearchFilter[]) => void; handleSearch: (query: string, filters: ISearchFilter[]) => void;
navigateToInstance: (domain: string) => void; navigateToInstance: (domain: string) => void;
setIsHoveringOver: (domain?: string) => void; setIsHoveringOver: (domain?: string) => void;
} }
interface SearchScreenState { interface ISearchScreenState {
currentQuery: string; currentQuery: string;
searchFilters: SearchFilter[]; searchFilters: ISearchFilter[];
} }
class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenState> { class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreenState> {
public constructor(props: SearchScreenProps) { public constructor(props: ISearchScreenProps) {
super(props); super(props);
this.state = { currentQuery: "", searchFilters: [] }; this.state = { currentQuery: "", searchFilters: [] };
} }
@ -81,7 +81,7 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
} else if (!!results && results.length > 0) { } else if (!!results && results.length > 0) {
content = ( content = (
<SearchResults> <SearchResults>
{results.map((result) => ( {results.map(result => (
<SearchResult <SearchResult
result={result} result={result}
key={result.name} key={result.name}
@ -92,7 +92,7 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
))} ))}
{isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />} {isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />}
{!isLoadingResults && hasMoreResults && ( {!isLoadingResults && hasMoreResults && (
<Button onClick={this.search} minimal> <Button onClick={this.search} minimal={true}>
Load more results Load more results
</Button> </Button>
)} )}
@ -104,11 +104,11 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
if (isLoadingResults) { if (isLoadingResults) {
rightSearchBarElement = <Spinner size={Spinner.SIZE_SMALL} />; rightSearchBarElement = <Spinner size={Spinner.SIZE_SMALL} />;
} else if (query || error) { } else if (query || error) {
rightSearchBarElement = <Button minimal icon={IconNames.CROSS} onClick={this.clearQuery} aria-label="Search" />; rightSearchBarElement = <Button minimal={true} icon={IconNames.CROSS} onClick={this.clearQuery} />;
} else { } else {
rightSearchBarElement = ( rightSearchBarElement = (
<Button <Button
minimal minimal={true}
icon={IconNames.ARROW_RIGHT} icon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
onClick={this.search} onClick={this.search}
@ -121,13 +121,12 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
<> <>
{isSmallScreen && results.length === 0 && this.renderMobileWarning()} {isSmallScreen && results.length === 0 && this.renderMobileWarning()}
<SearchBarContainer hasSearchResults={!!query && !!results} hasError={!!error}> <SearchBarContainer hasSearchResults={!!query && !!results} hasError={!!error}>
<H1>Find an instance</H1> <H2>Find an instance</H2>
<InputGroup <InputGroup
leftIcon={IconNames.SEARCH} leftIcon={IconNames.SEARCH}
rightElement={rightSearchBarElement} rightElement={rightSearchBarElement}
large large={true}
placeholder="Search instance names and descriptions" placeholder="Search instance names and descriptions"
aria-label="Search instance names and descriptions"
type="search" type="search"
value={this.state.currentQuery} value={this.state.currentQuery}
onChange={this.handleInputChange} onChange={this.handleInputChange}
@ -162,10 +161,10 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
this.setState({ currentQuery: "" }, () => this.props.handleSearch("", [])); this.setState({ currentQuery: "" }, () => this.props.handleSearch("", []));
}; };
private selectSearchFilter = (filter: SearchFilter) => { private selectSearchFilter = (filter: ISearchFilter) => {
const { searchFilters } = this.state; const { searchFilters } = this.state;
// Don't add the same filters twice // Don't add the same filters twice
if (searchFilters.some((sf) => isEqual(sf, filter))) { if (searchFilters.some(sf => isEqual(sf, filter))) {
return; return;
} }
this.setState({ searchFilters: [...searchFilters, filter] }, this.search); this.setState({ searchFilters: [...searchFilters, filter] }, this.search);
@ -174,9 +173,9 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
private deselectSearchFilter = (e: MouseEvent<HTMLButtonElement>) => { private deselectSearchFilter = (e: MouseEvent<HTMLButtonElement>) => {
const { searchFilters } = this.state; const { searchFilters } = this.state;
const displayValueToRemove = get(e, "currentTarget.parentElement.innerText", ""); const displayValueToRemove = get(e, "currentTarget.parentElement.innerText", "");
if (displayValueToRemove) { if (!!displayValueToRemove) {
this.setState( this.setState(
{ searchFilters: searchFilters.filter((sf) => sf.displayValue !== displayValueToRemove) }, { searchFilters: searchFilters.filter(sf => sf.displayValue !== displayValueToRemove) },
this.search this.search
); );
} }
@ -205,16 +204,19 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
); );
} }
const mapStateToProps = (state: AppState) => ({ const mapStateToProps = (state: IAppState) => ({
error: state.search.error, error: state.search.error,
hasMoreResults: !!state.search.next, hasMoreResults: !!state.search.next,
isLoadingResults: state.search.isLoadingResults, isLoadingResults: state.search.isLoadingResults,
query: state.search.query, query: state.search.query,
results: state.search.results, results: state.search.results
}); });
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
handleSearch: (query: string, filters: SearchFilter[]) => dispatch(updateSearch(query, filters) as any), handleSearch: (query: string, filters: ISearchFilter[]) => dispatch(updateSearch(query, filters) as any),
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)), navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain)), setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(SearchScreen); export default connect(
mapStateToProps,
mapDispatchToProps
)(SearchScreen);

View File

@ -6,8 +6,8 @@ import { InstanceTable } from "../organisms";
class TableScreen extends React.PureComponent { class TableScreen extends React.PureComponent {
public render() { public render() {
return ( return (
<Page fullWidth> <Page fullWidth={true}>
<H1>Instances</H1> <H1>{"Instances"}</H1>
<InstanceTable /> <InstanceTable />
</Page> </Page>
); );

View File

@ -1,20 +1,20 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Redirect } from "react-router"; import { Redirect } from "react-router";
import { AppState } from "../../redux/types"; import { IAppState } from "../../redux/types";
import { setAuthToken } from "../../util"; import { setAuthToken } from "../../util";
import { Page } from "../atoms"; import { Page } from "../atoms";
interface VerifyLoginScreenProps { interface IVerifyLoginScreenProps {
search: string; search: string;
} }
const VerifyLoginScreen: React.FC<VerifyLoginScreenProps> = ({ search }) => { const VerifyLoginScreen: React.FC<IVerifyLoginScreenProps> = ({ search }) => {
const [didSaveToken, setDidSaveToken] = useState(false); const [didSaveToken, setDidSaveToken] = useState(false);
const token = new URLSearchParams(search).get("token"); const token = new URLSearchParams(search).get("token");
useEffect(() => { useEffect(() => {
// Save the auth token // Save the auth token
if (token) { if (!!token) {
setAuthToken(token); setAuthToken(token);
setDidSaveToken(true); setDidSaveToken(true);
} }
@ -22,14 +22,15 @@ const VerifyLoginScreen: React.FC<VerifyLoginScreenProps> = ({ search }) => {
if (!token) { if (!token) {
return <Redirect to="/admin/login" />; return <Redirect to="/admin/login" />;
} } else if (!didSaveToken) {
if (!didSaveToken) {
return <Page />; return <Page />;
} }
return <Redirect to="/admin" />; return <Redirect to="/admin" />;
}; };
const mapStateToProps = (state: AppState) => ({ const mapStateToProps = (state: IAppState) => {
search: state.router.location.search, return {
}); search: state.router.location.search
};
};
export default connect(mapStateToProps)(VerifyLoginScreen); export default connect(mapStateToProps)(VerifyLoginScreen);

View File

@ -20,9 +20,8 @@ export const QUALITATIVE_COLOR_SCHEME = [
"#AD99FF", "#AD99FF",
"#0E5A8A", "#0E5A8A",
"#0A6640", "#0A6640",
"#AAB42F",
"#A66321", "#A66321",
"#A82A2A", "#A82A2A"
]; ];
// From https://blueprintjs.com/docs/#core/colors.sequential-color-schemes // From https://blueprintjs.com/docs/#core/colors.sequential-color-schemes
@ -36,11 +35,11 @@ export const QUANTITATIVE_COLOR_SCHEME = [
"#C15B3F", "#C15B3F",
"#B64C2F", "#B64C2F",
"#AA3C1F", "#AA3C1F",
"#9E2B0E", "#9E2B0E"
]; ];
export const INSTANCE_DOMAIN_PATH = "/instance/:domain"; export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
export interface InstanceDomainPath { export interface IInstanceDomainPath {
domain: string; domain: string;
} }
@ -56,6 +55,5 @@ export const INSTANCE_TYPES = [
"friendica", "friendica",
"hubzilla", "hubzilla",
"plume", "plume",
"wordpress", "wordpress"
"smithereen",
]; ];

View File

@ -12,7 +12,3 @@ body {
.app-toaster { .app-toaster {
z-index: 1000; z-index: 1000;
} }
.current-navbar-item {
background-color: #293742 !important;
}

View File

@ -25,7 +25,8 @@ FocusStyleManager.onlyShowFocusOnTabs();
export const history = createBrowserHistory(); export const history = createBrowserHistory();
// Initialize redux // Initialize redux
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; // @ts-ignore
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore( const store = createStore(
createRootReducer(history), createRootReducer(history),
composeEnhancers(applyMiddleware(routerMiddleware(history), thunk)) composeEnhancers(applyMiddleware(routerMiddleware(history), thunk))

View File

@ -1 +1 @@
// / <reference types="react-scripts" /> /// <reference types="react-scripts" />

View File

@ -2,145 +2,171 @@ import { isEqual } from "lodash";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import { push } from "connected-react-router"; import { push } from "connected-react-router";
import { SearchFilter } from "../searchFilters"; import { ISearchFilter } from "../searchFilters";
import { getFromApi } from "../util"; import { getFromApi } from "../util";
import { ActionType, AppState, Graph, InstanceDetails, InstanceSort, SearchResponse } from "./types"; import { ActionType, IAppState, IGraph, IInstanceDetails, IInstanceSort, ISearchResponse } from "./types";
// Instance details // Instance details
const requestInstanceDetails = (instanceName: string) => ({ const requestInstanceDetails = (instanceName: string) => {
payload: instanceName, return {
type: ActionType.REQUEST_INSTANCE_DETAILS, payload: instanceName,
}); type: ActionType.REQUEST_INSTANCE_DETAILS
const receiveInstanceDetails = (instanceDetails: InstanceDetails) => ({ };
payload: instanceDetails, };
type: ActionType.RECEIVE_INSTANCE_DETAILS, const receiveInstanceDetails = (instanceDetails: IInstanceDetails) => {
}); return {
const instanceLoadFailed = () => ({ payload: instanceDetails,
type: ActionType.INSTANCE_LOAD_ERROR, type: ActionType.RECEIVE_INSTANCE_DETAILS
}); };
const deselectInstance = () => ({ };
type: ActionType.DESELECT_INSTANCE, const instanceLoadFailed = () => {
}); return {
type: ActionType.INSTANCE_LOAD_ERROR
};
};
const deselectInstance = () => {
return {
type: ActionType.DESELECT_INSTANCE
};
};
// Graph // Graph
const requestGraph = () => ({ const requestGraph = () => {
type: ActionType.REQUEST_GRAPH, return {
}); type: ActionType.REQUEST_GRAPH
const receiveGraph = (graph: Graph) => ({ };
payload: graph, };
type: ActionType.RECEIVE_GRAPH, const receiveGraph = (graph: IGraph) => {
}); return {
const graphLoadFailed = () => ({ payload: graph,
type: ActionType.GRAPH_LOAD_ERROR, type: ActionType.RECEIVE_GRAPH
}); };
};
const graphLoadFailed = () => {
return {
type: ActionType.GRAPH_LOAD_ERROR
};
};
// Instance list // Instance list
const requestInstanceList = (sort?: InstanceSort) => ({ const requestInstanceList = (sort?: IInstanceSort) => ({
payload: sort, payload: sort,
type: ActionType.REQUEST_INSTANCES, type: ActionType.REQUEST_INSTANCES
}); });
const receiveInstanceList = (instances: InstanceDetails[]) => ({ const receiveInstanceList = (instances: IInstanceDetails[]) => ({
payload: instances, payload: instances,
type: ActionType.RECEIVE_INSTANCES, type: ActionType.RECEIVE_INSTANCES
}); });
const instanceListLoadFailed = () => ({ const instanceListLoadFailed = () => ({
type: ActionType.INSTANCE_LIST_LOAD_ERROR, type: ActionType.INSTANCE_LIST_LOAD_ERROR
}); });
// Search // Search
const requestSearchResult = (query: string, filters: SearchFilter[]) => ({ const requestSearchResult = (query: string, filters: ISearchFilter[]) => {
payload: { query, filters }, return {
type: ActionType.REQUEST_SEARCH_RESULTS, payload: { query, filters },
}); type: ActionType.REQUEST_SEARCH_RESULTS
const receiveSearchResults = (result: SearchResponse) => ({ };
payload: result, };
type: ActionType.RECEIVE_SEARCH_RESULTS, const receiveSearchResults = (result: ISearchResponse) => {
}); return {
const searchFailed = () => ({ payload: result,
type: ActionType.SEARCH_RESULTS_ERROR, type: ActionType.RECEIVE_SEARCH_RESULTS
}); };
};
const searchFailed = () => {
return {
type: ActionType.SEARCH_RESULTS_ERROR
};
};
const resetSearch = () => ({ const resetSearch = () => {
type: ActionType.RESET_SEARCH, return {
}); type: ActionType.RESET_SEARCH
};
};
export const setResultHover = (domain?: string) => ({ export const setResultHover = (domain?: string) => {
payload: domain, return {
type: ActionType.SET_SEARCH_RESULT_HOVER, payload: domain,
}); type: ActionType.SET_SEARCH_RESULT_HOVER
};
};
/** Async actions: https://redux.js.org/advanced/asyncactions */ /** Async actions: https://redux.js.org/advanced/asyncactions */
export const loadInstance = (instanceName: string | null) => (dispatch: Dispatch, getState: () => AppState) => { export const loadInstance = (instanceName: string | null) => {
if (!instanceName) { return (dispatch: Dispatch, getState: () => IAppState) => {
dispatch(deselectInstance()); if (!instanceName) {
if (getState().router.location.pathname.startsWith("/instance/")) { dispatch(deselectInstance());
dispatch(push("/")); if (getState().router.location.pathname.startsWith("/instance/")) {
dispatch(push("/"));
}
return;
} }
return; dispatch(requestInstanceDetails(instanceName));
} return getFromApi("instances/" + instanceName)
dispatch(requestInstanceDetails(instanceName)); .then(details => dispatch(receiveInstanceDetails(details)))
return getFromApi(`instances/${instanceName}`) .catch(() => dispatch(instanceLoadFailed()));
.then((details) => dispatch(receiveInstanceDetails(details))) };
.catch(() => dispatch(instanceLoadFailed()));
}; };
export const updateSearch = (query: string, filters: SearchFilter[]) => ( export const updateSearch = (query: string, filters: ISearchFilter[]) => {
dispatch: Dispatch, return (dispatch: Dispatch, getState: () => IAppState) => {
getState: () => AppState query = query.trim();
) => {
query = query.trim();
if (!query) { if (!query) {
dispatch(resetSearch()); dispatch(resetSearch());
return; return;
} }
const prevQuery = getState().search.query; const prevQuery = getState().search.query;
const prevFilters = getState().search.filters; const prevFilters = getState().search.filters;
const isNewQuery = prevQuery !== query || !isEqual(prevFilters, filters); const isNewQuery = prevQuery !== query || !isEqual(prevFilters, filters);
const { next } = getState().search; const next = getState().search.next;
let url = `search/?query=${query}`; let url = `search/?query=${query}`;
if (!isNewQuery && next) { if (!isNewQuery && next) {
url += `&after=${next}`; url += `&after=${next}`;
} }
// Add filters // Add filters
// The format is e.g. type_eq=mastodon or user_count_gt=1000 // The format is e.g. type_eq=mastodon or user_count_gt=1000
filters.forEach((filter) => { filters.forEach(filter => {
url += `&${filter.field}_${filter.relation}=${filter.value}`; url += `&${filter.field}_${filter.relation}=${filter.value}`;
}); });
dispatch(requestSearchResult(query, filters)); dispatch(requestSearchResult(query, filters));
return getFromApi(url) return getFromApi(url)
.then((result) => dispatch(receiveSearchResults(result))) .then(result => dispatch(receiveSearchResults(result)))
.catch(() => dispatch(searchFailed())); .catch(() => dispatch(searchFailed()));
};
}; };
export const fetchGraph = () => (dispatch: Dispatch) => { export const fetchGraph = () => {
dispatch(requestGraph()); return (dispatch: Dispatch) => {
return getFromApi("graph") dispatch(requestGraph());
.then((graph) => dispatch(receiveGraph(graph))) return getFromApi("graph")
.catch(() => dispatch(graphLoadFailed())); .then(graph => dispatch(receiveGraph(graph)))
.catch(() => dispatch(graphLoadFailed()));
};
}; };
export const loadInstanceList = (page?: number, sort?: InstanceSort) => ( export const loadInstanceList = (page?: number, sort?: IInstanceSort) => {
dispatch: Dispatch, return (dispatch: Dispatch, getState: () => IAppState) => {
getState: () => AppState sort = sort ? sort : getState().data.instanceListSort;
) => { dispatch(requestInstanceList(sort));
sort = sort || getState().data.instanceListSort; const params: string[] = [];
dispatch(requestInstanceList(sort)); if (!!page) {
const params: string[] = []; params.push(`page=${page}`);
if (page) { }
params.push(`page=${page}`); if (!!sort) {
} params.push(`sortField=${sort.field}`);
if (sort) { params.push(`sortDirection=${sort.direction}`);
params.push(`sortField=${sort.field}`); }
params.push(`sortDirection=${sort.direction}`); const path = !!params ? `instances?${params.join("&")}` : "instances";
} return getFromApi(path)
const path = params ? `instances?${params.join("&")}` : "instances"; .then(instancesListResponse => dispatch(receiveInstanceList(instancesListResponse)))
return getFromApi(path) .catch(() => dispatch(instanceListLoadFailed()));
.then((instancesListResponse) => dispatch(receiveInstanceList(instancesListResponse))) };
.catch(() => dispatch(instanceListLoadFailed()));
}; };

View File

@ -3,34 +3,34 @@ import { isEqual } from "lodash";
import { combineReducers } from "redux"; import { combineReducers } from "redux";
import { History } from "history"; import { History } from "history";
import { ActionType, Action, CurrentInstanceState, DataState, SearchState } from "./types"; import { ActionType, IAction, ICurrentInstanceState, IDataState, ISearchState } from "./types";
const initialDataState: DataState = { const initialDataState: IDataState = {
graphLoadError: false, graphLoadError: false,
instanceListLoadError: false, instanceListLoadError: false,
instanceListSort: { field: "userCount", direction: "desc" }, instanceListSort: { field: "userCount", direction: "desc" },
isLoadingGraph: false, isLoadingGraph: false,
isLoadingInstanceList: false, isLoadingInstanceList: false
}; };
const data = (state: DataState = initialDataState, action: Action): DataState => { const data = (state: IDataState = initialDataState, action: IAction): IDataState => {
switch (action.type) { switch (action.type) {
case ActionType.REQUEST_GRAPH: case ActionType.REQUEST_GRAPH:
return { return {
...state, ...state,
graphResponse: undefined, graphResponse: undefined,
isLoadingGraph: true, isLoadingGraph: true
}; };
case ActionType.RECEIVE_GRAPH: case ActionType.RECEIVE_GRAPH:
return { return {
...state, ...state,
graphResponse: action.payload, graphResponse: action.payload,
isLoadingGraph: false, isLoadingGraph: false
}; };
case ActionType.GRAPH_LOAD_ERROR: case ActionType.GRAPH_LOAD_ERROR:
return { return {
...state, ...state,
graphLoadError: true, graphLoadError: true,
isLoadingGraph: false, isLoadingGraph: false
}; };
case ActionType.REQUEST_INSTANCES: case ActionType.REQUEST_INSTANCES:
return { return {
@ -38,71 +38,71 @@ const data = (state: DataState = initialDataState, action: Action): DataState =>
instanceListLoadError: false, instanceListLoadError: false,
instanceListSort: action.payload, instanceListSort: action.payload,
instancesResponse: undefined, instancesResponse: undefined,
isLoadingInstanceList: true, isLoadingInstanceList: true
}; };
case ActionType.RECEIVE_INSTANCES: case ActionType.RECEIVE_INSTANCES:
return { return {
...state, ...state,
instancesResponse: action.payload, instancesResponse: action.payload,
isLoadingInstanceList: false, isLoadingInstanceList: false
}; };
case ActionType.INSTANCE_LIST_LOAD_ERROR: case ActionType.INSTANCE_LIST_LOAD_ERROR:
return { return {
...state, ...state,
instanceListLoadError: true, instanceListLoadError: true,
isLoadingInstanceList: false, isLoadingInstanceList: false
}; };
default: default:
return state; return state;
} }
}; };
const initialCurrentInstanceState: CurrentInstanceState = { const initialCurrentInstanceState: ICurrentInstanceState = {
currentInstanceDetails: null, currentInstanceDetails: null,
error: false, error: false,
isLoadingInstanceDetails: false, isLoadingInstanceDetails: false
}; };
const currentInstance = (state = initialCurrentInstanceState, action: Action): CurrentInstanceState => { const currentInstance = (state = initialCurrentInstanceState, action: IAction): ICurrentInstanceState => {
switch (action.type) { switch (action.type) {
case ActionType.REQUEST_INSTANCE_DETAILS: case ActionType.REQUEST_INSTANCE_DETAILS:
return { return {
...state, ...state,
error: false, error: false,
isLoadingInstanceDetails: true, isLoadingInstanceDetails: true
}; };
case ActionType.RECEIVE_INSTANCE_DETAILS: case ActionType.RECEIVE_INSTANCE_DETAILS:
return { return {
...state, ...state,
currentInstanceDetails: action.payload, currentInstanceDetails: action.payload,
error: false, error: false,
isLoadingInstanceDetails: false, isLoadingInstanceDetails: false
}; };
case ActionType.DESELECT_INSTANCE: case ActionType.DESELECT_INSTANCE:
return { return {
...state, ...state,
currentInstanceDetails: null, currentInstanceDetails: null,
error: false, error: false
}; };
case ActionType.INSTANCE_LOAD_ERROR: case ActionType.INSTANCE_LOAD_ERROR:
return { return {
...state, ...state,
error: true, error: true,
isLoadingInstanceDetails: false, isLoadingInstanceDetails: false
}; };
default: default:
return state; return state;
} }
}; };
const initialSearchState: SearchState = { const initialSearchState: ISearchState = {
error: false, error: false,
filters: [], filters: [],
isLoadingResults: false, isLoadingResults: false,
next: "", next: "",
query: "", query: "",
results: [], results: []
}; };
const search = (state = initialSearchState, action: Action): SearchState => { const search = (state = initialSearchState, action: IAction): ISearchState => {
switch (action.type) { switch (action.type) {
case ActionType.REQUEST_SEARCH_RESULTS: case ActionType.REQUEST_SEARCH_RESULTS:
const { query, filters } = action.payload; const { query, filters } = action.payload;
@ -114,7 +114,7 @@ const search = (state = initialSearchState, action: Action): SearchState => {
isLoadingResults: true, isLoadingResults: true,
next: isNewQuery ? "" : state.next, next: isNewQuery ? "" : state.next,
query, query,
results: isNewQuery ? [] : state.results, results: isNewQuery ? [] : state.results
}; };
case ActionType.RECEIVE_SEARCH_RESULTS: case ActionType.RECEIVE_SEARCH_RESULTS:
return { return {
@ -122,7 +122,7 @@ const search = (state = initialSearchState, action: Action): SearchState => {
error: false, error: false,
isLoadingResults: false, isLoadingResults: false,
next: action.payload.next, next: action.payload.next,
results: state.results.concat(action.payload.results), results: state.results.concat(action.payload.results)
}; };
case ActionType.SEARCH_RESULTS_ERROR: case ActionType.SEARCH_RESULTS_ERROR:
return { ...initialSearchState, error: true }; return { ...initialSearchState, error: true };
@ -131,7 +131,7 @@ const search = (state = initialSearchState, action: Action): SearchState => {
case ActionType.SET_SEARCH_RESULT_HOVER: case ActionType.SET_SEARCH_RESULT_HOVER:
return { return {
...state, ...state,
hoveringOverResult: action.payload, hoveringOverResult: action.payload
}; };
default: default:
return state; return state;
@ -141,7 +141,8 @@ const search = (state = initialSearchState, action: Action): SearchState => {
export default (history: History) => export default (history: History) =>
combineReducers({ combineReducers({
router: connectRouter(history), router: connectRouter(history),
// tslint:disable-next-line:object-literal-sort-keys
currentInstance, currentInstance,
data, data,
search, search
}); });

View File

@ -1,5 +1,5 @@
import { RouterState } from "connected-react-router"; import { RouterState } from "connected-react-router";
import { SearchFilter } from "../searchFilters"; import { ISearchFilter } from "../searchFilters";
export enum ActionType { export enum ActionType {
// Instance details // Instance details
@ -22,33 +22,33 @@ export enum ActionType {
SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR", SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR",
RESET_SEARCH = "RESET_SEARCH", RESET_SEARCH = "RESET_SEARCH",
// Search -- hovering over results // Search -- hovering over results
SET_SEARCH_RESULT_HOVER = "SET_SEARCH_RESULT_HOVER", SET_SEARCH_RESULT_HOVER = "SET_SEARCH_RESULT_HOVER"
} }
export interface Action { export interface IAction {
type: ActionType; type: ActionType;
payload: any; payload: any;
} }
export type SortField = "domain" | "userCount" | "statusCount" | "insularity"; export type SortField = "domain" | "userCount" | "statusCount" | "insularity";
export type SortDirection = "asc" | "desc"; export type SortDirection = "asc" | "desc";
export interface InstanceSort { export interface IInstanceSort {
field: SortField; field: SortField;
direction: SortDirection; direction: SortDirection;
} }
export interface Peer { export interface IPeer {
name: string; name: string;
} }
export interface SearchResultInstance { export interface ISearchResultInstance {
name: string; name: string;
description?: string; description?: string;
userCount?: number; userCount?: number;
type?: string; type?: string;
} }
export interface FederationRestrictions { export interface IFederationRestrictions {
reportRemoval?: string[]; reportRemoval?: string[];
reject?: string[]; reject?: string[];
mediaRemoval?: string[]; mediaRemoval?: string[];
@ -59,7 +59,7 @@ export interface FederationRestrictions {
accept?: string[]; accept?: string[];
} }
export interface InstanceDetails { export interface IInstanceDetails {
name: string; name: string;
description?: string; description?: string;
version?: string; version?: string;
@ -67,8 +67,8 @@ export interface InstanceDetails {
insularity?: number; insularity?: number;
statusCount?: number; statusCount?: number;
domainCount?: number; domainCount?: number;
peers?: Peer[]; peers?: IPeer[];
federationRestrictions: FederationRestrictions; federationRestrictions: IFederationRestrictions;
lastUpdated?: string; lastUpdated?: string;
status: string; status: string;
type?: string; type?: string;
@ -76,7 +76,7 @@ export interface InstanceDetails {
statusesPerUserPerDay?: number; statusesPerUserPerDay?: number;
} }
interface GraphNode { interface IGraphNode {
data: { data: {
id: string; id: string;
label: string; label: string;
@ -88,7 +88,7 @@ interface GraphNode {
}; };
} }
interface GraphEdge { interface IGraphEdge {
data: { data: {
source: string; source: string;
target: string; target: string;
@ -97,65 +97,65 @@ interface GraphEdge {
}; };
} }
interface GraphMetadata { interface IGraphMetadata {
ranges: { [key: string]: [number, number] }; ranges: { [key: string]: [number, number] };
} }
export interface Graph { export interface IGraph {
nodes: GraphNode[]; nodes: IGraphNode[];
edges: GraphEdge[]; edges: IGraphEdge[];
} }
export interface GraphResponse { export interface IGraphResponse {
graph: Graph; graph: IGraph;
metadata: GraphMetadata; metadata: IGraphMetadata;
} }
export interface SearchResponse { export interface ISearchResponse {
results: SearchResultInstance[]; results: ISearchResultInstance[];
next: string | null; next: string | null;
} }
export interface InstanceListResponse { export interface IInstanceListResponse {
pageNumber: number; pageNumber: number;
totalPages: number; totalPages: number;
totalEntries: number; totalEntries: number;
pageSize: number; pageSize: number;
instances: InstanceDetails[]; instances: IInstanceDetails[];
} }
// Redux state // Redux state
// The current instance name is stored in the URL. See state -> router -> location // The current instance name is stored in the URL. See state -> router -> location
export interface CurrentInstanceState { export interface ICurrentInstanceState {
currentInstanceDetails: InstanceDetails | null; currentInstanceDetails: IInstanceDetails | null;
isLoadingInstanceDetails: boolean; isLoadingInstanceDetails: boolean;
error: boolean; error: boolean;
} }
export interface DataState { export interface IDataState {
graphResponse?: GraphResponse; graphResponse?: IGraphResponse;
instancesResponse?: InstanceListResponse; instancesResponse?: IInstanceListResponse;
instanceListSort: InstanceSort; instanceListSort: IInstanceSort;
isLoadingGraph: boolean; isLoadingGraph: boolean;
isLoadingInstanceList: boolean; isLoadingInstanceList: boolean;
graphLoadError: boolean; graphLoadError: boolean;
instanceListLoadError: boolean; instanceListLoadError: boolean;
} }
export interface SearchState { export interface ISearchState {
error: boolean; error: boolean;
isLoadingResults: boolean; isLoadingResults: boolean;
next: string; next: string;
query: string; query: string;
results: SearchResultInstance[]; results: ISearchResultInstance[];
filters: SearchFilter[]; filters: ISearchFilter[];
hoveringOverResult?: string; hoveringOverResult?: string;
} }
export interface AppState { export interface IAppState {
router: RouterState; router: RouterState;
currentInstance: CurrentInstanceState; currentInstance: ICurrentInstanceState;
data: DataState; data: IDataState;
search: SearchState; search: ISearchState;
} }

View File

@ -1,8 +1,8 @@
type SearchFilterRelation = "eq" | "gt" | "gte" | "lt" | "lte"; type ISearchFilterRelation = "eq" | "gt" | "gte" | "lt" | "lte";
export interface SearchFilter { export interface ISearchFilter {
// The ES field to filter on // The ES field to filter on
field: string; field: string;
relation: SearchFilterRelation; relation: ISearchFilterRelation;
// The value we want to filter to // The value we want to filter to
value: string; value: string;
// Human-meaningful text that we're showing in the UI // Human-meaningful text that we're showing in the UI
@ -10,19 +10,17 @@ export interface SearchFilter {
} }
// Maps to translate this to user-friendly text // Maps to translate this to user-friendly text
type SearchFilterField = "type" | "user_count";
const searchFilterFieldTranslations = { const searchFilterFieldTranslations = {
type: "Instance type", type: "Instance type",
// eslint-disable-next-line @typescript-eslint/camelcase user_count: "User count"
user_count: "User count",
}; };
const searchFilterRelationTranslations = { const searchFilterRelationTranslations = {
eq: "=", eq: "=",
gt: ">", gt: ">",
gte: ">=", gte: ">=",
lt: "<", lt: "<",
lte: "<=", lte: "<="
}; };
export const getSearchFilterDisplayValue = (field: SearchFilterField, relation: SearchFilterRelation, value: string) => export const getSearchFilterDisplayValue = (field: string, relation: ISearchFilterRelation, value: string) =>
`${searchFilterFieldTranslations[field]} ${searchFilterRelationTranslations[relation]} ${value}`; `${searchFilterFieldTranslations[field]} ${searchFilterRelationTranslations[relation]} ${value}`;

View File

@ -2,5 +2,5 @@ import { Position, Toaster } from "@blueprintjs/core";
export default Toaster.create({ export default Toaster.create({
className: "app-toaster", className: "app-toaster",
position: Position.TOP, position: Position.TOP
}); });

View File

@ -1,6 +1,6 @@
import { INSTANCE_TYPES } from "./constants"; import { INSTANCE_TYPES } from "./constants";
interface ColorSchemeBase { interface IColorSchemeBase {
// The name of the coloring, e.g. "Instance type" // The name of the coloring, e.g. "Instance type"
name: string; name: string;
// The name of the key in a cytoscape node's `data` field to color by. // The name of the key in a cytoscape node's `data` field to color by.
@ -9,30 +9,30 @@ interface ColorSchemeBase {
description?: string; description?: string;
type: "qualitative" | "quantitative"; type: "qualitative" | "quantitative";
} }
interface QualitativeColorScheme extends ColorSchemeBase { interface IQualitativeColorScheme extends IColorSchemeBase {
// The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"]. // The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"].
values: string[]; values: string[];
type: "qualitative"; type: "qualitative";
} }
interface QuantitativeColorScheme extends ColorSchemeBase { interface IQuantitativeColorScheme extends IColorSchemeBase {
type: "quantitative"; type: "quantitative";
exponential: boolean; exponential: boolean;
} }
export type ColorScheme = QualitativeColorScheme | QuantitativeColorScheme; export type IColorScheme = IQualitativeColorScheme | IQuantitativeColorScheme;
export const typeColorScheme: QualitativeColorScheme = { export const typeColorScheme: IQualitativeColorScheme = {
cytoscapeDataKey: "type", cytoscapeDataKey: "type",
name: "Instance type", name: "Instance type",
type: "qualitative", type: "qualitative",
values: INSTANCE_TYPES, values: INSTANCE_TYPES
}; };
export const activityColorScheme: QuantitativeColorScheme = { export const activityColorScheme: IQuantitativeColorScheme = {
cytoscapeDataKey: "statusesPerDay", cytoscapeDataKey: "statusesPerDay",
description: "The average number of statuses posted per day. This is an exponential scale.", description: "The average number of statuses posted per day. This is an exponential scale.",
exponential: true, exponential: true,
name: "Activity", name: "Activity",
type: "quantitative", type: "quantitative"
}; };
export const colorSchemes: ColorScheme[] = [typeColorScheme, activityColorScheme]; export const colorSchemes: IColorScheme[] = [typeColorScheme, activityColorScheme];

View File

@ -1 +0,0 @@
declare module "*.png";

View File

@ -1,39 +1,41 @@
import { createMatchSelector } from "connected-react-router"; import { createMatchSelector } from "connected-react-router";
import fetch from "cross-fetch"; import fetch from "cross-fetch";
import { range } from "lodash"; import { range } from "lodash";
import { DESKTOP_WIDTH_THRESHOLD, InstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants"; import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
import { AppState } from "./redux/types"; import { IAppState } from "./redux/types";
let API_ROOT = "https://api.index.community/api/"; let API_ROOT = "http://localhost:4000/api/";
if (["true", true, 1, "1"].includes(process.env.REACT_APP_STAGING || "")) { if (["true", true, 1, "1"].indexOf(process.env.REACT_APP_STAGING || "") > -1) {
API_ROOT = "https://api.index.community/api/"; API_ROOT = "https://phoenix.api.fediverse.space/api/";
} else if (process.env.NODE_ENV === "production") { } else if (process.env.NODE_ENV === "production") {
API_ROOT = "https://api.index.community/api/"; API_ROOT = "https://phoenix.api.fediverse.space/api/";
} }
export const getFromApi = (path: string, token?: string): Promise<any> => { export const getFromApi = (path: string, token?: string): Promise<any> => {
const domain = API_ROOT.endsWith("/") ? API_ROOT : `${API_ROOT}/`; const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/";
const headers = token ? { token } : undefined; const headers = token ? { token } : undefined;
return fetch(encodeURI(domain + path), { return fetch(encodeURI(domain + path), {
headers, headers
}).then((response) => response.json()); }).then(response => response.json());
}; };
export const postToApi = (path: string, body: any, token?: string): Promise<any> => { export const postToApi = (path: string, body: any, token?: string): Promise<any> => {
const domain = API_ROOT.endsWith("/") ? API_ROOT : `${API_ROOT}/`; const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/";
const defaultHeaders = { const defaultHeaders = {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json"
}; };
const headers = token ? { ...defaultHeaders, token } : defaultHeaders; const headers = token ? { ...defaultHeaders, token } : defaultHeaders;
return fetch(encodeURI(domain + path), { return fetch(encodeURI(domain + path), {
body: JSON.stringify(body), body: JSON.stringify(body),
headers, headers,
method: "POST", method: "POST"
}).then((response) => response.json()); }).then(response => {
return response.json();
});
}; };
export const domainMatchSelector = createMatchSelector<AppState, InstanceDomainPath>(INSTANCE_DOMAIN_PATH); export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);
export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD; export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;
@ -47,25 +49,28 @@ export const unsetAuthToken = () => {
sessionStorage.removeItem("adminToken"); sessionStorage.removeItem("adminToken");
}; };
export const getAuthToken = () => sessionStorage.getItem("adminToken"); export const getAuthToken = () => {
return sessionStorage.getItem("adminToken");
};
export const getBuckets = (min: number, max: number, steps: number, exponential: boolean) => { export const getBuckets = (min: number, max: number, steps: number, exponential: boolean) => {
if (exponential) { if (exponential) {
const logSpace = range(steps).map((i) => Math.E ** i); const logSpace = range(steps).map(i => Math.E ** i);
// Scale the log space to the linear range // Scale the log space to the linear range
const logRange = logSpace[logSpace.length - 1] - logSpace[0]; const logRange = logSpace[logSpace.length - 1] - logSpace[0];
const linearRange = max - min; const linearRange = max - min;
const scalingFactor = linearRange / logRange; const scalingFactor = linearRange / logRange;
const translation = min - logSpace[0]; const translation = min - logSpace[0];
return logSpace.map((i) => (i + translation) * scalingFactor); return logSpace.map(i => (i + translation) * scalingFactor);
} else {
// Linear
const bucketSize = (max - min) / steps;
return range(min, max, bucketSize);
} }
// Linear
const bucketSize = (max - min) / steps;
return range(min, max, bucketSize);
}; };
const typeToDisplay: { [field: string]: string } = { const typeToDisplay = {
gnusocial: "GNU Social", gnusocial: "GNU Social"
}; };
export const getTypeDisplayString = (key: string) => { export const getTypeDisplayString = (key: string) => {
if (key in typeToDisplay) { if (key in typeToDisplay) {

View File

@ -1,32 +1,40 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true,
"sourceMap": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"jsx": "preserve",
"lib": [
"es2015",
"dom"
],
"module": "esnext", "module": "esnext",
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"noImplicitAny": true, "noImplicitAny": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"jsx": "react", "outDir": "build",
"rootDir": "src",
"sourceMap": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"target": "es5",
"skipLibCheck": true,
"esModuleInterop": true,
"strict": true,
"typeRoots": [ "typeRoots": [
"./node_modules/@types", "./node_modules/@types",
"./src/typings" "./src/typings"
], ],
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
}, },
"exclude": [
"node_modules",
"build"
],
"include": [ "include": [
"src" "src"
] ]

11
frontend/tslint.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": [
"tslint:recommended",
"tslint-eslint-rules",
"tslint-react",
"@blueprintjs/tslint-config/blueprint-rules",
"tslint-config-prettier",
"tslint-config-security"
],
"exclude": ["**/*.css"]
}

File diff suppressed because it is too large Load Diff

View File

@ -2,9 +2,6 @@
base = "frontend/" base = "frontend/"
publish = "frontend/build/" publish = "frontend/build/"
[build.environment]
INLINE_RUNTIME_CHUNK = "false"
[context.develop.environment] [context.develop.environment]
REACT_APP_STAGING = "true" REACT_APP_STAGING = "true"
@ -19,11 +16,3 @@
to = "/index.html" to = "/index.html"
status = 200 status = 200
[[headers]]
for = "/*"
[headers.values]
X-Content-Type-Options = "nosniff"
X-Frame-Options = "DENY"
X-XSS-Protection = "1"
Content-Security-Policy = "default-src 'self' https://*.fediverse.space https://plausible.cursed.technology; style-src 'self' 'unsafe-inline'; img-src 'self' data:"