add full-text search
This commit is contained in:
parent
5e9b498db0
commit
5ca8de5dbe
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"elixirLS.projectDir": "backend/"
|
||||
"elixirLS.projectDir": "backend/",
|
||||
"elixirLS.fetchDeps": false
|
||||
}
|
|
@ -10,10 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
|
||||
- Instance administrators can now log in to opt in or out of crawling.
|
||||
- Added ElasticSearch full-text search over instance domains and descriptions.
|
||||
- Search results are now highlighted on the graph.
|
||||
- When you hover a search result, it is now highlighted on the graph.
|
||||
|
||||
### Changed
|
||||
|
||||
- Instances are now crawled hourly instead of every 30 minutes.
|
||||
- The colors for color coding have been made brighter (more visible against the dark background.
|
||||
|
||||
### Deprecated
|
||||
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
web: /app/bin/backend start
|
||||
release: /app/bin/backend eval "Backend.Release.migrate"
|
||||
release: /app/bin/backend eval "Backend.Release.run_all"
|
|
@ -19,6 +19,20 @@ config :backend, BackendWeb.Endpoint,
|
|||
|
||||
config :backend, Backend.Repo, queue_target: 5000
|
||||
|
||||
config :backend, Backend.Elasticsearch.Cluster,
|
||||
url: "http://localhost:9200",
|
||||
api: Elasticsearch.API.HTTP,
|
||||
json_library: Jason,
|
||||
indexes: %{
|
||||
instances: %{
|
||||
settings: "priv/elasticsearch/instances.json",
|
||||
store: Backend.Elasticsearch.Store,
|
||||
sources: [Backend.Instance],
|
||||
bulk_page_size: 1000,
|
||||
bulk_wait_interval: 1_000
|
||||
}
|
||||
}
|
||||
|
||||
# Configures Elixir's Logger
|
||||
config :logger, :console,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
|
|
|
@ -14,7 +14,8 @@ config :backend, Backend.Repo,
|
|||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
|
||||
ssl: ssl
|
||||
|
||||
# show_sensitive_data_on_connection_error: true
|
||||
config :backend, Backend.Elasticsearch.Cluster,
|
||||
url: System.get_env("ELASTICSEARCH_URL") || "http://localhost:9200"
|
||||
|
||||
port = String.to_integer(System.get_env("PORT") || "4000")
|
||||
|
||||
|
|
|
@ -101,15 +101,67 @@ defmodule Backend.Api do
|
|||
end
|
||||
end
|
||||
|
||||
def search_instances(query, cursor_after \\ nil) do
|
||||
ilike_query = "%#{query}%"
|
||||
def search_instances(query, from \\ 0) do
|
||||
page_size = 50
|
||||
|
||||
%{entries: instances, metadata: metadata} =
|
||||
Instance
|
||||
|> where([i], ilike(i.domain, ^ilike_query) and not i.opt_out)
|
||||
|> order_by(asc: :id)
|
||||
|> Repo.paginate(after: cursor_after, cursor_fields: [:id], limit: 50)
|
||||
search_response =
|
||||
Elasticsearch.post(Backend.Elasticsearch.Cluster, "/instances/_search", %{
|
||||
"sort" => "_score",
|
||||
"from" => from,
|
||||
"size" => page_size,
|
||||
"query" => %{
|
||||
"bool" => %{
|
||||
"should" => [
|
||||
%{
|
||||
"multi_match" => %{
|
||||
"query" => query,
|
||||
"fields" => [
|
||||
"description.english"
|
||||
]
|
||||
}
|
||||
},
|
||||
%{
|
||||
"wildcard" => %{
|
||||
"domain.keyword" => %{
|
||||
"value" => query,
|
||||
"boost" => 100
|
||||
}
|
||||
}
|
||||
},
|
||||
%{
|
||||
"wildcard" => %{
|
||||
"domain.keyword" => %{
|
||||
"value" => "*#{query}*",
|
||||
"boost" => 1
|
||||
}
|
||||
}
|
||||
},
|
||||
%{
|
||||
"match" => %{
|
||||
"domain.ngram^0.5" => query
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
%{instances: instances, next: metadata.after}
|
||||
with {:ok, result} <- search_response do
|
||||
hits =
|
||||
get_in(result, ["hits", "hits"])
|
||||
|> Enum.map(fn h -> h |> Map.get("_source") |> convert_keys_to_atoms() end)
|
||||
|
||||
next =
|
||||
if length(hits) < page_size do
|
||||
nil
|
||||
else
|
||||
from + page_size
|
||||
end
|
||||
|
||||
%{
|
||||
hits: hits,
|
||||
next: next
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -21,7 +21,8 @@ defmodule Backend.Application do
|
|||
Honeydew.start_queue(:crawl_queue, failure_mode: Honeydew.FailureMode.Abandon)
|
||||
Honeydew.start_workers(:crawl_queue, Backend.Crawler, num: crawl_worker_count)
|
||||
end},
|
||||
Backend.Scheduler
|
||||
Backend.Scheduler,
|
||||
Backend.Elasticsearch.Cluster
|
||||
]
|
||||
|
||||
children =
|
||||
|
|
|
@ -32,7 +32,6 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
|||
end
|
||||
|
||||
@impl ApiCrawler
|
||||
# sobelow_skip ["DOS.StringToAtom"]
|
||||
def crawl(domain) do
|
||||
instance = Jason.decode!(get!("https://#{domain}/api/v1/instance").body)
|
||||
|
||||
|
@ -48,7 +47,7 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
|||
else
|
||||
Map.merge(
|
||||
Map.take(instance["stats"], ["user_count"])
|
||||
|> Map.new(fn {k, v} -> {String.to_atom(k), v} end),
|
||||
|> convert_keys_to_atoms(),
|
||||
%{
|
||||
peers: [],
|
||||
interactions: %{},
|
||||
|
@ -63,7 +62,6 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
|||
end
|
||||
|
||||
@spec crawl_large_instance(String.t(), any()) :: ApiCrawler.t()
|
||||
# sobelow_skip ["DOS.StringToAtom"]
|
||||
defp crawl_large_instance(domain, instance) do
|
||||
# servers may not publish peers
|
||||
peers =
|
||||
|
@ -94,7 +92,7 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
|||
Map.take(instance, ["version", "description"]),
|
||||
Map.take(instance["stats"], ["user_count", "status_count"])
|
||||
)
|
||||
|> Map.new(fn {k, v} -> {String.to_atom(k), v} end),
|
||||
|> convert_keys_to_atoms(),
|
||||
%{
|
||||
peers: peers,
|
||||
interactions: interactions,
|
||||
|
|
3
backend/lib/backend/elasticsearch/cluster.ex
Normal file
3
backend/lib/backend/elasticsearch/cluster.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule Backend.Elasticsearch.Cluster do
|
||||
use Elasticsearch.Cluster, otp_app: :backend
|
||||
end
|
16
backend/lib/backend/elasticsearch/store.ex
Normal file
16
backend/lib/backend/elasticsearch/store.ex
Normal file
|
@ -0,0 +1,16 @@
|
|||
defmodule Backend.Elasticsearch.Store do
|
||||
@behaviour Elasticsearch.Store
|
||||
|
||||
alias Backend.Repo
|
||||
|
||||
@impl true
|
||||
def stream(schema) do
|
||||
Repo.stream(schema)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def transaction(fun) do
|
||||
{:ok, result} = Repo.transaction(fun, timeout: :infinity)
|
||||
result
|
||||
end
|
||||
end
|
|
@ -46,4 +46,19 @@ defmodule Backend.Instance do
|
|||
|> validate_required([:domain])
|
||||
|> put_assoc(:peers, attrs.peers)
|
||||
end
|
||||
|
||||
defimpl Elasticsearch.Document, for: Backend.Instance do
|
||||
def id(instance), do: instance.id
|
||||
def routing(_), do: false
|
||||
|
||||
def encode(instance) do
|
||||
# Make sure this corresponds with priv/elasticseach/instances.json
|
||||
%{
|
||||
domain: instance.domain,
|
||||
description: instance.description,
|
||||
type: instance.type,
|
||||
user_count: instance.user_count
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,12 +1,24 @@
|
|||
defmodule Backend.Release do
|
||||
@app :backend
|
||||
|
||||
alias Elasticsearch.Index
|
||||
alias Backend.Elasticsearch.Cluster
|
||||
|
||||
def run_all do
|
||||
migrate()
|
||||
index()
|
||||
end
|
||||
|
||||
def migrate do
|
||||
for repo <- repos() do
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
||||
end
|
||||
end
|
||||
|
||||
def index do
|
||||
Index.hot_swap(Cluster, "instances")
|
||||
end
|
||||
|
||||
def rollback(repo, version) do
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||
end
|
||||
|
|
|
@ -157,4 +157,14 @@ defmodule Backend.Util do
|
|||
"#{String.downcase(username)}@#{clean_domain(domain)}"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts a map with string keys to a map with atom keys.
|
||||
Be very careful with this -- only use it on maps where you know the keys! Never run it if the keys can be supplied
|
||||
by the user.
|
||||
"""
|
||||
# sobelow_skip ["DOS.StringToAtom"]
|
||||
def convert_keys_to_atoms(map) do
|
||||
map |> Map.new(fn {k, v} -> {String.to_atom(k), v} end)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,8 +6,8 @@ defmodule BackendWeb.SearchController do
|
|||
|
||||
def index(conn, params) do
|
||||
query = Map.get(params, "query")
|
||||
cursor_after = Map.get(params, "after", nil)
|
||||
%{instances: instances, next: next} = Api.search_instances(query, cursor_after)
|
||||
render(conn, "index.json", instances: instances, next: next)
|
||||
from = Map.get(params, "after", "0") |> String.to_integer()
|
||||
%{hits: hits, next: next} = Api.search_instances(query, from)
|
||||
render(conn, "index.json", hits: hits, next: next)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,11 +1,8 @@
|
|||
defmodule BackendWeb.AdminView do
|
||||
use BackendWeb, :view
|
||||
import Backend.Util
|
||||
require Logger
|
||||
|
||||
def render("show.json", %{instance: instance}) do
|
||||
Logger.info(inspect(instance))
|
||||
|
||||
%{
|
||||
domain: domain,
|
||||
opt_in: opt_in,
|
||||
|
|
|
@ -20,7 +20,7 @@ defmodule BackendWeb.InstanceView do
|
|||
end
|
||||
|
||||
cond do
|
||||
instance.user_count < user_threshold ->
|
||||
instance.user_count < user_threshold and not instance.opt_in ->
|
||||
%{
|
||||
name: instance.domain,
|
||||
status: "personal instance"
|
||||
|
|
|
@ -3,28 +3,28 @@ defmodule BackendWeb.SearchView do
|
|||
alias BackendWeb.SearchView
|
||||
import Backend.Util
|
||||
|
||||
def render("index.json", %{instances: instances, next: next}) do
|
||||
def render("index.json", %{hits: hits, next: next}) do
|
||||
%{
|
||||
results: render_many(instances, SearchView, "instance.json", as: :instance),
|
||||
results: render_many(hits, SearchView, "instance.json", as: :hit),
|
||||
next: next
|
||||
}
|
||||
end
|
||||
|
||||
def render("instance.json", %{instance: instance}) do
|
||||
def render("instance.json", %{hit: hit}) do
|
||||
threshold = get_config(:personal_instance_threshold)
|
||||
|
||||
description =
|
||||
if instance.user_count != nil and instance.user_count < threshold do
|
||||
if hit.user_count != nil and hit.user_count < threshold do
|
||||
nil
|
||||
else
|
||||
instance.description
|
||||
hit.description
|
||||
end
|
||||
|
||||
%{
|
||||
name: instance.domain,
|
||||
name: hit.domain,
|
||||
description: description,
|
||||
userCount: instance.user_count,
|
||||
type: instance.type
|
||||
userCount: hit.user_count,
|
||||
type: hit.type
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -52,7 +52,8 @@ defmodule Backend.MixProject do
|
|||
{:public_suffix, "~> 0.6.0"},
|
||||
{:idna, "~> 5.1.2", override: true},
|
||||
{:swoosh, "~> 0.23.3"},
|
||||
{:ex_twilio, "~> 0.7.0"}
|
||||
{:ex_twilio, "~> 0.7.0"},
|
||||
{:elasticsearch, "~> 1.0"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
"distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"ecto": {:hex, :ecto, "3.1.7", "fa21d06ef56cdc2fdaa62574e8c3ba34a2751d44ea34c30bc65f0728421043e5", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.1.6", "1e80e30d16138a729c717f73dcb938590bcdb3a4502f3012414d0cbb261045d8", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0 or ~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"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"},
|
||||
"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_stage": {:hex, :gen_stage, "0.14.2", "6a2a578a510c5bfca8a45e6b27552f613b41cf584b58210f017088d3d17d0b14", [:mix], [], "hexpm"},
|
||||
"gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"},
|
||||
|
@ -51,4 +52,5 @@
|
|||
"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.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"},
|
||||
"vex": {:hex, :vex, "0.6.0", "4e79b396b2ec18cd909eed0450b19108d9631842598d46552dc05031100b7a56", [:mix], [], "hexpm"},
|
||||
}
|
||||
|
|
53
backend/priv/elasticsearch/instances.json
Normal file
53
backend/priv/elasticsearch/instances.json
Normal file
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"settings": {
|
||||
"number_of_shards": 1,
|
||||
"number_of_replicas": 0,
|
||||
"analysis": {
|
||||
"analyzer": {
|
||||
"ngramAnalyzer": {
|
||||
"tokenizer": "ngramTokenizer"
|
||||
}
|
||||
},
|
||||
"tokenizer": {
|
||||
"ngramTokenizer": {
|
||||
"type": "ngram",
|
||||
"min_gram": 5,
|
||||
"max_gram": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mappings": {
|
||||
"_doc": {
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "text",
|
||||
"fields": {
|
||||
"ngram": {
|
||||
"type": "text",
|
||||
"analyzer": "ngramAnalyzer"
|
||||
},
|
||||
"keyword": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "text",
|
||||
"fields": {
|
||||
"english": {
|
||||
"type": "text",
|
||||
"analyzer": "english"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"user_count": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,22 @@ services:
|
|||
- 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:
|
||||
|
@ -26,6 +42,7 @@ services:
|
|||
build: ./backend
|
||||
networks:
|
||||
- database_network
|
||||
- phoenix_network
|
||||
depends_on:
|
||||
- db
|
||||
ports:
|
||||
|
@ -37,7 +54,12 @@ services:
|
|||
- BACKEND_HOSTNAME
|
||||
volumes:
|
||||
pgdata:
|
||||
esdata:
|
||||
gradle-cache:
|
||||
networks:
|
||||
database_network:
|
||||
driver: bridge
|
||||
phoenix_network:
|
||||
driver: bridge
|
||||
es_network:
|
||||
driver: bridge
|
||||
|
|
|
@ -47,7 +47,7 @@ const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, onItemSelec
|
|||
<H6>Key</H6>
|
||||
<ul className={Classes.LIST_UNSTYLED}>
|
||||
{current.values.map(v => (
|
||||
<StyledLi>
|
||||
<StyledLi key={v}>
|
||||
<InstanceType type={v} />
|
||||
</StyledLi>
|
||||
))}
|
||||
|
|
|
@ -1,9 +1,16 @@
|
|||
import cytoscape from "cytoscape";
|
||||
import { isEqual } from "lodash";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import styled from "styled-components";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
import { DEFAULT_NODE_COLOR, QUALITATIVE_COLOR_SCHEME, SELECTED_NODE_COLOR } from "../../constants";
|
||||
import {
|
||||
DEFAULT_NODE_COLOR,
|
||||
HOVERED_NODE_COLOR,
|
||||
QUALITATIVE_COLOR_SCHEME,
|
||||
SEARCH_RESULT_COLOR,
|
||||
SELECTED_NODE_COLOR
|
||||
} from "../../constants";
|
||||
import { IColorSchemeType } from "../../types";
|
||||
|
||||
const CytoscapeContainer = styled.div`
|
||||
|
@ -16,6 +23,8 @@ interface ICytoscapeProps {
|
|||
colorScheme?: IColorSchemeType;
|
||||
currentNodeId: string | null;
|
||||
elements: cytoscape.ElementsDefinition;
|
||||
hoveringOver?: string;
|
||||
searchResultIds?: string[];
|
||||
navigateToInstancePath?: (domain: string) => void;
|
||||
navigateToRoot?: () => void;
|
||||
}
|
||||
|
@ -128,6 +137,12 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
|||
if (prevProps.colorScheme !== this.props.colorScheme) {
|
||||
this.updateColorScheme();
|
||||
}
|
||||
if (prevProps.hoveringOver !== this.props.hoveringOver) {
|
||||
this.updateHoveredNodeClass(prevProps.hoveringOver);
|
||||
}
|
||||
if (!isEqual(prevProps.searchResultIds, this.props.searchResultIds)) {
|
||||
this.updateSearchResultNodeClass();
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
|
@ -184,20 +199,42 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
|||
* This is used during initilization to avoid having to do multiple renderings of the graph.
|
||||
*/
|
||||
private resetNodeColorScheme = (style?: any) => {
|
||||
if (!style) {
|
||||
style = this.cy!.style() as any;
|
||||
}
|
||||
style = style.selector("node").style({
|
||||
"background-color": DEFAULT_NODE_COLOR,
|
||||
// The size from the backend is log_10(userCount), which from 10 <= userCount <= 1,000,000 gives us the range
|
||||
// 1-6. We map this to the range of sizes we want.
|
||||
// TODO: I should probably check that that the backend is actually using log_10 and not log_e, but it look
|
||||
// quite good as it is, so...
|
||||
height: "mapData(size, 1, 6, 20, 200)",
|
||||
label: "data(id)",
|
||||
width: "mapData(size, 1, 6, 20, 200)"
|
||||
});
|
||||
|
||||
this.setNodeSearchColorScheme(style);
|
||||
};
|
||||
|
||||
/**
|
||||
* We always want to set node search/hover styles at the end of a style change to make sure they don't get overwritten.
|
||||
*/
|
||||
private setNodeSearchColorScheme = (style?: any) => {
|
||||
if (!style) {
|
||||
style = this.cy!.style() as any;
|
||||
}
|
||||
style
|
||||
.selector("node")
|
||||
.selector("node.searchResult")
|
||||
.style({
|
||||
"background-color": DEFAULT_NODE_COLOR,
|
||||
// The size from the backend is log_10(userCount), which from 10 <= userCount <= 1,000,000 gives us the range
|
||||
// 1-6. We map this to the range of sizes we want.
|
||||
// TODO: I should probably check that that the backend is actually using log_10 and not log_e, but it look
|
||||
// quite good as it is, so...
|
||||
height: "mapData(size, 1, 6, 20, 200)",
|
||||
label: "data(id)",
|
||||
width: "mapData(size, 1, 6, 20, 200)"
|
||||
"background-color": SEARCH_RESULT_COLOR,
|
||||
"border-color": SEARCH_RESULT_COLOR,
|
||||
"border-opacity": 0.7,
|
||||
"border-width": 250
|
||||
})
|
||||
.selector("node.hovered")
|
||||
.style({
|
||||
"border-color": HOVERED_NODE_COLOR,
|
||||
"border-width": 1000
|
||||
})
|
||||
.selector("node:selected")
|
||||
.style({
|
||||
|
@ -220,12 +257,47 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
|||
"background-color": QUALITATIVE_COLOR_SCHEME[idx]
|
||||
});
|
||||
});
|
||||
style
|
||||
.selector("node:selected")
|
||||
.style({ "background-color": SELECTED_NODE_COLOR })
|
||||
.update();
|
||||
|
||||
this.setNodeSearchColorScheme(style);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This function sets the hover class on the node that's currently being hovered over in the search results
|
||||
* (and removes it from the previous one if there was one).
|
||||
*
|
||||
* We explicitly pass the ID of the previously hovered node, rather than just using a class selector.
|
||||
* This is because lookups by ID are significantly faster than class selectors.
|
||||
*/
|
||||
private updateHoveredNodeClass = (prevHoveredId?: string) => {
|
||||
if (!this.cy) {
|
||||
throw new Error("Expected cytoscape, but there wasn't one!");
|
||||
}
|
||||
const { hoveringOver } = this.props;
|
||||
|
||||
if (!!prevHoveredId) {
|
||||
this.cy.$id(prevHoveredId).removeClass("hovered");
|
||||
}
|
||||
if (!!hoveringOver) {
|
||||
this.cy.$id(hoveringOver).addClass("hovered");
|
||||
}
|
||||
};
|
||||
|
||||
private updateSearchResultNodeClass = () => {
|
||||
if (!this.cy) {
|
||||
throw new Error("Expected cytoscape, but there wasn't one!");
|
||||
}
|
||||
const { searchResultIds } = this.props;
|
||||
|
||||
this.cy.batch(() => {
|
||||
this.cy!.nodes().removeClass("searchResult");
|
||||
|
||||
if (!!searchResultIds && searchResultIds.length > 0) {
|
||||
const currentResultSelector = searchResultIds.map(id => `node[id = "${id}"]`).join(", ");
|
||||
this.cy!.$(currentResultSelector).addClass("searchResult");
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default Cytoscape;
|
||||
|
|
|
@ -35,8 +35,10 @@ const StyledDescription = styled.div`
|
|||
interface ISearchResultProps {
|
||||
result: ISearchResultInstance;
|
||||
onClick: () => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}
|
||||
const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick }) => {
|
||||
const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick, onMouseEnter, onMouseLeave }) => {
|
||||
let shortenedDescription;
|
||||
if (result.description) {
|
||||
shortenedDescription = result.description && sanitize(result.description);
|
||||
|
@ -55,7 +57,14 @@ const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<StyledCard elevation={Elevation.ONE} interactive={true} key={result.name} onClick={onClick}>
|
||||
<StyledCard
|
||||
elevation={Elevation.ONE}
|
||||
interactive={true}
|
||||
key={result.name}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<StyledHeadingContainer>
|
||||
<StyledH4>{result.name}</StyledH4>
|
||||
{typeIcon}
|
||||
|
|
|
@ -20,7 +20,9 @@ interface IGraphProps {
|
|||
fetchGraph: () => void;
|
||||
graph?: IGraph;
|
||||
graphLoadError: boolean;
|
||||
hoveringOverResult?: string;
|
||||
isLoadingGraph: boolean;
|
||||
searchResultDomains: string[];
|
||||
navigate: (path: string) => void;
|
||||
}
|
||||
interface IGraphState {
|
||||
|
@ -52,8 +54,10 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
|||
colorScheme={this.state.colorScheme}
|
||||
currentNodeId={this.props.currentInstanceName}
|
||||
elements={this.props.graph}
|
||||
hoveringOver={this.props.hoveringOverResult}
|
||||
navigateToInstancePath={this.navigateToInstancePath}
|
||||
navigateToRoot={this.navigateToRoot}
|
||||
searchResultIds={this.props.searchResultDomains}
|
||||
ref={this.cytoscapeComponent}
|
||||
/>
|
||||
<GraphTools
|
||||
|
@ -99,7 +103,9 @@ const mapStateToProps = (state: IAppState) => {
|
|||
currentInstanceName: match && match.params.domain,
|
||||
graph: state.data.graph,
|
||||
graphLoadError: state.data.error,
|
||||
isLoadingGraph: state.data.isLoadingGraph
|
||||
hoveringOverResult: state.search.hoveringOverResult,
|
||||
isLoadingGraph: state.data.isLoadingGraph,
|
||||
searchResultDomains: state.search.results.map(r => r.name)
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
|
|
|
@ -42,18 +42,20 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
|||
|
||||
public componentDidMount() {
|
||||
// Load instance settings from server
|
||||
getFromApi(`admin`, this.authToken!)
|
||||
.then(response => {
|
||||
this.setState({ settings: response });
|
||||
})
|
||||
.catch(() => {
|
||||
AppToaster.show({
|
||||
icon: IconNames.ERROR,
|
||||
intent: Intent.DANGER,
|
||||
message: "Failed to load settings.",
|
||||
timeout: 0
|
||||
if (!!this.authToken) {
|
||||
getFromApi(`admin`, this.authToken!)
|
||||
.then(response => {
|
||||
this.setState({ settings: response });
|
||||
})
|
||||
.catch(() => {
|
||||
AppToaster.show({
|
||||
icon: IconNames.ERROR,
|
||||
intent: Intent.DANGER,
|
||||
message: "Failed to load settings.",
|
||||
timeout: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
|
@ -71,7 +73,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
|||
<p>{`${settings.userCount} users with ${settings.statusCount || "(unknown)"} statuses.`}</p>
|
||||
<form onSubmit={this.updateSettings}>
|
||||
{settings.userCount < 10 && (
|
||||
<FormGroup helperText="Check this if you'd like your personal instance to be crawled by fediverse.space.">
|
||||
<FormGroup helperText="Check this if you'd like your personal instance to be crawled by fediverse.space. This takes up to 24 hours to take effect.">
|
||||
<Switch
|
||||
id="opt-in-switch"
|
||||
checked={!!settings.optIn}
|
||||
|
@ -82,7 +84,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
|||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
<FormGroup helperText="Check this if you don't want to your instance to be crawled. You won't appear on fediverse.space.">
|
||||
<FormGroup helperText="Check this if you don't want to your instance to be crawled. You won't appear on fediverse.space. The change is immediate.">
|
||||
<Switch
|
||||
id="opt-out-switch"
|
||||
checked={!!settings.optOut}
|
||||
|
|
|
@ -385,9 +385,9 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
|||
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={
|
||||
<AnchorButton icon={IconNames.CONFIRM} href="https://cursed.technology/@fediversespace" target="_blank">
|
||||
Message @fediversespace to opt in
|
||||
</AnchorButton>
|
||||
<Link to={"/admin"} className={Classes.BUTTON} role="button">
|
||||
{"Opt in"}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -5,7 +5,7 @@ import React from "react";
|
|||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
import styled from "styled-components";
|
||||
import { updateSearch } from "../../redux/actions";
|
||||
import { setResultHover, updateSearch } from "../../redux/actions";
|
||||
import { IAppState, ISearchResultInstance } from "../../redux/types";
|
||||
import { SearchResult } from "../molecules";
|
||||
|
||||
|
@ -34,6 +34,7 @@ interface ISearchScreenProps {
|
|||
results: ISearchResultInstance[];
|
||||
handleSearch: (query: string) => void;
|
||||
navigateToInstance: (domain: string) => void;
|
||||
setIsHoveringOver: (domain?: string) => void;
|
||||
}
|
||||
interface ISearchScreenState {
|
||||
currentQuery: string;
|
||||
|
@ -72,7 +73,13 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
|||
{this.renderSearchBar()}
|
||||
<SearchResults>
|
||||
{results.map(result => (
|
||||
<SearchResult result={result} key={result.name} onClick={this.selectInstanceFactory(result.name)} />
|
||||
<SearchResult
|
||||
result={result}
|
||||
key={result.name}
|
||||
onClick={this.selectInstanceFactory(result.name)}
|
||||
onMouseEnter={this.onMouseEnterFactory(result.name)}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
/>
|
||||
))}
|
||||
{isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />}
|
||||
{!isLoadingResults && hasMoreResults && (
|
||||
|
@ -100,9 +107,18 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
|||
};
|
||||
|
||||
private selectInstanceFactory = (domain: string) => () => {
|
||||
this.props.setIsHoveringOver(undefined);
|
||||
this.props.navigateToInstance(domain);
|
||||
};
|
||||
|
||||
private onMouseEnterFactory = (domain: string) => () => {
|
||||
this.props.setIsHoveringOver(domain);
|
||||
};
|
||||
|
||||
private onMouseLeave = () => {
|
||||
this.props.setIsHoveringOver(undefined);
|
||||
};
|
||||
|
||||
private renderSearchBar = () => (
|
||||
<SearchBarContainer className={`${Classes.INPUT_GROUP} ${Classes.LARGE}`}>
|
||||
<span className={`${Classes.ICON} bp3-icon-${IconNames.SEARCH}`} />
|
||||
|
@ -128,7 +144,8 @@ const mapStateToProps = (state: IAppState) => ({
|
|||
});
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
handleSearch: (query: string) => dispatch(updateSearch(query) as any),
|
||||
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`))
|
||||
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
|
||||
setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain))
|
||||
});
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
|
|
|
@ -3,19 +3,21 @@ export const DESKTOP_WIDTH_THRESHOLD = 1000;
|
|||
|
||||
export const DEFAULT_NODE_COLOR = "#CED9E0";
|
||||
export const SELECTED_NODE_COLOR = "#48AFF0";
|
||||
export const SEARCH_RESULT_COLOR = "#AD99FF";
|
||||
export const HOVERED_NODE_COLOR = SEARCH_RESULT_COLOR;
|
||||
|
||||
// From https://blueprintjs.com/docs/#core/colors.qualitative-color-schemes
|
||||
// From https://blueprintjs.com/docs/#core/colors.qualitative-color-schemes, but brightened
|
||||
export const QUALITATIVE_COLOR_SCHEME = [
|
||||
"#2965CC",
|
||||
"#29A634",
|
||||
"#D99E0B",
|
||||
"#D13913",
|
||||
"#8F398F",
|
||||
"#00B3A4",
|
||||
"#DB2C6F",
|
||||
"#9BBF30",
|
||||
"#96622D",
|
||||
"#7157D9"
|
||||
"#669EFF",
|
||||
"#62D96B",
|
||||
"#FFC940",
|
||||
"#FF6E4A",
|
||||
"#C274C2",
|
||||
"#2EE6D6",
|
||||
"#FF66A1",
|
||||
"#D1F26D",
|
||||
"#C99765",
|
||||
"#AD99FF"
|
||||
];
|
||||
|
||||
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
|
||||
|
|
|
@ -71,6 +71,13 @@ const resetSearch = () => {
|
|||
};
|
||||
};
|
||||
|
||||
export const setResultHover = (domain?: string) => {
|
||||
return {
|
||||
payload: domain,
|
||||
type: ActionType.SET_SEARCH_RESULT_HOVER
|
||||
};
|
||||
};
|
||||
|
||||
/** Async actions: https://redux.js.org/advanced/asyncactions */
|
||||
|
||||
export const loadInstance = (instanceName: string | null) => {
|
||||
|
@ -98,9 +105,11 @@ export const updateSearch = (query: string) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const isNewQuery = getState().search.query !== query;
|
||||
|
||||
const next = getState().search.next;
|
||||
let url = `search/?query=${query}`;
|
||||
if (next) {
|
||||
if (!isNewQuery && next) {
|
||||
url += `&after=${next}`;
|
||||
}
|
||||
dispatch(requestSearchResult(query));
|
||||
|
|
|
@ -86,6 +86,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
|
|||
...state,
|
||||
error: false,
|
||||
isLoadingResults: true,
|
||||
next: isNewQuery ? "" : state.next,
|
||||
query,
|
||||
results: isNewQuery ? [] : state.results
|
||||
};
|
||||
|
@ -108,6 +109,11 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
|
|||
};
|
||||
case ActionType.RESET_SEARCH:
|
||||
return initialSearchState;
|
||||
case ActionType.SET_SEARCH_RESULT_HOVER:
|
||||
return {
|
||||
...state,
|
||||
hoveringOverResult: action.payload
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -15,9 +15,9 @@ export enum ActionType {
|
|||
REQUEST_SEARCH_RESULTS = "REQUEST_SEARCH_RESULTS",
|
||||
RECEIVE_SEARCH_RESULTS = "RECEIVE_SEARCH_RESULTS",
|
||||
SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR",
|
||||
RESET_SEARCH = "RESET_SEARCH"
|
||||
// REQUEST_INSTANCES = "REQUEST_INSTANCES",
|
||||
// RECEIVE_INSTANCES = "RECEIVE_INSTANCES",
|
||||
RESET_SEARCH = "RESET_SEARCH",
|
||||
// Search -- hovering over results
|
||||
SET_SEARCH_RESULT_HOVER = "SET_SEARCH_RESULT_HOVER"
|
||||
}
|
||||
|
||||
export interface IAction {
|
||||
|
@ -102,6 +102,7 @@ export interface ISearchState {
|
|||
next: string;
|
||||
query: string;
|
||||
results: ISearchResultInstance[];
|
||||
hoveringOverResult?: string;
|
||||
}
|
||||
|
||||
export interface IAppState {
|
||||
|
|
Binary file not shown.
Loading…
Reference in a new issue