add full-text search

This commit is contained in:
Tao Bojlén 2019-07-26 22:30:11 +00:00
parent 5e9b498db0
commit 5ca8de5dbe
33 changed files with 410 additions and 84 deletions

View file

@ -1,3 +1,4 @@
{
"elixirLS.projectDir": "backend/"
"elixirLS.projectDir": "backend/",
"elixirLS.fetchDeps": false
}

View file

@ -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

View file

@ -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"

View file

@ -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",

View file

@ -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")

View file

@ -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

View file

@ -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 =

View file

@ -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,

View file

@ -0,0 +1,3 @@
defmodule Backend.Elasticsearch.Cluster do
use Elasticsearch.Cluster, otp_app: :backend
end

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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"},
}

View 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"
}
}
}
}
}

View file

@ -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

View file

@ -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>
))}

View file

@ -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() {
@ -187,9 +202,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
if (!style) {
style = this.cy!.style() as any;
}
style
.selector("node")
.style({
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.
@ -198,6 +211,30 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
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.searchResult")
.style({
"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;

View file

@ -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}

View file

@ -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) => ({

View file

@ -42,6 +42,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
public componentDidMount() {
// Load instance settings from server
if (!!this.authToken) {
getFromApi(`admin`, this.authToken!)
.then(response => {
this.setState({ settings: response });
@ -55,6 +56,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
});
});
}
}
public render() {
if (!this.authToken) {
@ -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}

View file

@ -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>
}
/>
);

View file

@ -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,

View file

@ -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";

View file

@ -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));

View file

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

View file

@ -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 {