Commit 4b07e1f0 authored by Eugen Rochko's avatar Eugen Rochko

Rework instance picker into a wizard

- Interest categories
- Languages
- Results limited by:
  - >= 2.1.2
  - >= 10 active users
  - open registrations
  - not down
- Sorted by active users
- Only top 60 results fetched
- Refetch from API when params change
parent 7d3ba79e
......@@ -13,12 +13,14 @@
"react-intl": "^2.4.0",
"react-motion": "^0.4.7",
"react-redux": "^5.0.5",
"react-responsive-select": "^3.1.4",
"react-router-dom": "^4.0.0",
"react-router-hash-link": "^1.1.0",
"react-snapshot": "^1.1.0",
"redux": "^3.7.1",
"redux-thunk": "^2.2.0",
"reselect": "^3.0.1",
"smoothscroll-polyfill": "^0.4.0",
"twemoji": "^2.5.0"
},
"devDependencies": {
......
import React, { Component } from 'react'
// From https://github.com/mauricevancooten/react-anchor-link-smooth-scroll
class AnchorLink extends Component {
constructor(props) {
super(props)
this.smoothScroll = this.smoothScroll.bind(this)
}
componentDidMount() {
require('smoothscroll-polyfill').polyfill()
}
smoothScroll(e) {
e.preventDefault()
let offset = 0
if (typeof this.props.offset !== 'undefined') {
offset = parseInt(this.props.offset, 10)
}
const id = e.currentTarget.getAttribute('href').slice(1)
window.scroll({
top: document.getElementById(id).offsetTop - offset,
behavior: 'smooth'
})
this.props.onClick && this.props.onClick(e)
}
render() {
return (
<a {...this.props} onClick={this.smoothScroll}>{this.props.children}</a>
)
}
}
export default AnchorLink
import React from 'react';
import { FormattedHTMLMessage as FormattedMessage } from 'react-intl';
import { HashLink as Link } from 'react-router-hash-link';
import AnchorLink from './AnchorLink';
import Features from './Features';
import Wizard from './WizardContainer';
......@@ -33,8 +34,8 @@ const Home = () => (
<h1><FormattedMessage id='home.headline' defaultMessage='Social networking, <strong>back in your hands</strong>' /></h1>
<p><FormattedMessage id='home.tagline' defaultMessage='The world’s largest free, open-source, decentralized microblogging network' /></p>
<Link to='/#getting-started' className='cta button'><FormattedMessage id='home.get_started' defaultMessage='Get started' /></Link>
<Link to='/#how-it-works' className='cta button alt'><FormattedMessage id='home.how_it_works' defaultMessage='How it works' /></Link>
<AnchorLink href='#getting-started' className='cta button'><FormattedMessage id='home.get_started' defaultMessage='Get started' /></AnchorLink>
<AnchorLink href='#how-it-works' className='cta button alt'><FormattedMessage id='home.how_it_works' defaultMessage='How it works' /></AnchorLink>
</div>
<div className='hero'>
......
......@@ -2,25 +2,47 @@ import React from 'react';
import WizardRow from './WizardRow';
import { Scrollbars } from 'react-custom-scrollbars';
import { FormattedHTMLMessage as FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import WizardLanguageSelectContainer from './WizardLanguageSelectContainer';
import ReactResponsiveSelect from 'react-responsive-select';
const messages = defineMessages({
search: { id: 'wizard.search', defaultMessage: 'Search for an instance' }
i_am: { id: 'wizard.filters.i_am', defaultMessage: 'I am ' },
i_speak: { id: 'wizard.filters.i_speak', defaultMessage: 'I speak ' },
all_languages: { id: 'wizard.filters.all_languages', defaultMessage: 'all languages' },
everything: { id: 'wizard.filters.everything', defaultMessage: 'everything' },
artist: { id: 'wizard.filters.artist', defaultMessage: 'an artist' },
musician: { id: 'wizard.filters.musician', defaultMessage: 'a musician' },
writer: { id: 'wizard.filters.writer', defaultMessage: 'a writer' },
reader: { id: 'wizard.filters.reader', defaultMessage: 'a book lover' },
activist: { id: 'wizard.filters.activist', defaultMessage: 'an activist' },
sports_fan: { id: 'wizard.filters.sports_fan', defaultMessage: 'a sports fan' },
gamer: { id: 'wizard.filters.gamer', defaultMessage: 'a gamer' },
dev: { id: 'wizard.filters.dev', defaultMessage: 'a developer' },
sysadmin: { id: 'wizard.filters.sysadmin', defaultMessage: 'a sysadmin' },
academia: { id: 'wizard.filters.academia', defaultMessage: 'in academia' },
});
const caretIcon = (
<svg className="caret-icon" x="0px" y="0px" width="11.848px" height="6.338px" viewBox="351.584 2118.292 11.848 6.338">
<g><path d="M363.311,2118.414c-0.164-0.163-0.429-0.163-0.592,0l-5.205,5.216l-5.215-5.216c-0.163-0.163-0.429-0.163-0.592,0s-0.163,0.429,0,0.592l5.501,5.501c0.082,0.082,0.184,0.123,0.296,0.123c0.103,0,0.215-0.041,0.296-0.123l5.501-5.501C363.474,2118.843,363.474,2118.577,363.311,2118.414L363.311,2118.414z"/></g>
</svg>
);
class Wizard extends React.PureComponent {
componentDidMount () {
this.props.onMount();
}
handleChange = e => {
this.props.onChange(e.target.value);
handleCategoryChange = ({ value }) => {
const { category } = this.props;
if (category === value) return;
this.props.onChangeCategory(value);
}
handleClear = e => {
e.preventDefault();
this.props.onClear();
handleLanguageChange = ({ value }) => {
const { language } = this.props;
if (language === value) return;
this.props.onChangeLanguage(value);
}
renderThumb ({ style, ...props }) {
......@@ -33,44 +55,59 @@ class Wizard extends React.PureComponent {
}
render () {
const { instances, searchValue, intl } = this.props;
const hasValue = searchValue.length > 0;
const { instances, category, language, intl } = this.props;
return (
<div className='wizard-page' id='getting-started'>
<h2><FormattedMessage id='wizard.get_started' defaultMessage='<strong>Get started:</strong> Choose an instance' /></h2>
<p><FormattedMessage id='wizard.text' defaultMessage='Each server is a separate, independently owned gateway into the fediverse. You can talk to your friends regardless of which one you choose, but each will have different moderation policies and interests, so choose the one that feels the most comfortable to you.' /></p>
<div className='wizard-controls'>
<div className='external-wizard'>
<a className='cta button' target='_blank' href="https://instances.social"><FormattedMessage id='wizard.help_me_choose' defaultMessage='Help me choose' /></a>
<a className='cta button alt' target='_blank' href="https://bridge.joinmastodon.org"><FormattedMessage id='wizard.find_friends' defaultMessage='Find Twitter friends' /></a>
</div>
<div className='spacer'></div>
<div className='language-select'>
<WizardLanguageSelectContainer />
</div>
<div className='search'>
<label htmlFor='instance-search' className='accessibly-hidden'><FormattedMessage id='wizard.search' defaultMessage='Search for an instance' /></label>
<input
id='instance-search'
className='search__input'
type='text'
placeholder={intl.formatMessage(messages.search)}
value={searchValue}
onChange={this.handleChange}
/>
<div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
<i className={`ion-android-search ${hasValue ? '' : 'active'}`} />
<i className={`ion-android-cancel ${hasValue ? 'active' : ''}`} />
</div>
</div>
</div>
<h1><i className='ion-person-add' /> <FormattedMessage id='wizard.sign_up' defaultMessage='Sign up' /></h1>
<form className='wizard-controls'>
<ReactResponsiveSelect
name="category"
options={[
{ value: '', text: intl.formatMessage(messages.everything) },
{ value: 'art', text: intl.formatMessage(messages.artist) },
{ value: 'music', text: intl.formatMessage(messages.musician) },
{ value: 'books', text: intl.formatMessage(messages.writer) },
{ value: 'books', text: intl.formatMessage(messages.reader) },
{ value: 'activism', text: intl.formatMessage(messages.activist) },
{ value: 'sports', text: intl.formatMessage(messages.sports_fan) },
{ value: 'games', text: intl.formatMessage(messages.gamer) },
{ value: 'tech', text: intl.formatMessage(messages.dev) },
{ value: 'tech', text: intl.formatMessage(messages.sysadmin) },
{ value: 'academia', text: intl.formatMessage(messages.academia) },
]}
caretIcon={caretIcon}
selectedValue={category}
prefix={intl.formatMessage(messages.i_am)}
onChange={this.handleCategoryChange}
/>
<span className='wizard-controls__label'>,</span>
<ReactResponsiveSelect
name="language"
options={[
{ value: '', text: intl.formatMessage(messages.all_languages) },
{ value: 'en', text: 'English' },
{ value: 'de', text: 'Deutsch' },
{ value: 'fr', text: 'Français' },
{ value: 'es', text: 'Español' },
{ value: 'pl', text: 'Polski' },
{ value: 'pt', text: 'Português' },
{ value: 'ru', text: 'Русский' },
{ value: 'ja', text: '日本語' },
{ value: 'zh', text: '中文' },
{ value: 'ar', text: 'العربية' },
]}
caretIcon={caretIcon}
selectedValue={language}
prefix={intl.formatMessage(messages.i_speak)}
onChange={this.handleLanguageChange}
/>
<span className='wizard-controls__label'><FormattedMessage id='wizard.filters.results' defaultMessage='and here is where I can sign up:' /></span>
</form>
<div className='wizard'>
<Scrollbars className='wizard-content' style={{ height: 500 }} renderThumbVertical={this.renderThumb}>
......@@ -80,7 +117,15 @@ class Wizard extends React.PureComponent {
</Scrollbars>
</div>
<p className='hint'><FormattedMessage tagName='strong' id='wizard.tip' defaultMessage='Tip:' /><FormattedMessage id='wizard.tip_text' defaultMessage='This isn’t just your home, it’s also the address that other people can find you by. It’ll be <samp>@username@example.com</samp> like choosing an email address.' /></p>
<div className='wizard-hint'>
<div className='wizard-hint__icon'>
<i className='ion-information-circled' />
</div>
<div className='wizard-hint__text'>
<FormattedMessage id='wizard.hint' defaultMessage="This is like picking an e-mail hosting provider, but more social since you'll see your neighbours. The domain will also be part of your username!" />
</div>
</div>
</div>
);
}
......
import Wizard from './Wizard';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchInstances, changeSearchValue } from './actions';
import fuzzysearch from 'fuzzysearch';
const getInstances = createSelector(
[
state => state.locale,
state => state.searchValue,
state => state.instancesLocale,
state => state.instances,
],
(_, searchValue, locale, instances) => {
searchValue = searchValue.toLowerCase();
return instances.filter(item => {
const eligible = !item.dead && item.uptime > 0.70 && item.open_registrations;
const isSearching = searchValue.length > 0;
return eligible &&
(locale === null || (item.info && item.info.languages.indexOf(locale) !== -1)) &&
(!isSearching || fuzzysearch(searchValue, item.searchable));
});
}
);
import { fetchInstances, changeFilterCategory, changeFilterLanguage } from './actions';
const mapStateToProps = state => ({
instances: getInstances(state),
searchValue: state.searchValue,
instances: state.instances,
category: state.filter.category,
language: state.filter.language,
});
const mapDispatchToProps = dispatch => ({
onMount: () => dispatch(fetchInstances()),
onChange: value => dispatch(changeSearchValue(value)),
onClear: () => dispatch(changeSearchValue('')),
onMount: () => {
dispatch(fetchInstances());
},
onChangeCategory: value => {
dispatch(changeFilterCategory(value));
dispatch(fetchInstances());
},
onChangeLanguage: value => {
dispatch(changeFilterLanguage(value));
dispatch(fetchInstances());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Wizard);
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
const options = [
{ value: null, label: <FormattedMessage id='wizard.filters.all_languages' defaultMessage='All' /> },
{ value: 'en', label: 'English' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'ja', label: '日本語' },
{ value: 'zh', label: '中文' },
{ value: 'ar', label: 'العربية' },
];
export default class WizardLanguageSelect extends PureComponent {
state = {
opened: false
};
renderMenu () {
const { value } = this.props;
return (
<div className='dropdown__menu'>
{options.map(option => (
<div key={option.value} className={classNames('dropdown__option', { active: option.value === value })} onClick={this.handleOptionClick.bind(this, option.value)}>
{option.label}
</div>
))}
</div>
);
}
handleOptionClick = (value) => {
this.props.onChange(value);
this.setState({ opened: false });
}
handleClick = () => {
this.setState({ opened: !this.state.opened });
}
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.setState({ opened: false });
}
}
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, false);
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, false);
}
setRef = c => {
this.node = c;
}
render () {
const { opened } = this.state;
return (
<div className={classNames('dropdown', { active: opened })} ref={this.setRef}>
<div className='dropdown__control' tabIndex='0' role='button' onClick={this.handleClick}>A <i className='dropdown__arrow ion-ios-arrow-down' /></div>
{opened && this.renderMenu()}
</div>
);
}
}
import WizardLanguageSelect from './WizardLanguageSelect';
import { connect } from 'react-redux';
import { changeInstancesLocale } from './actions';
const mapStateToProps = state => ({
value: state.instancesLocale
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeInstancesLocale(value))
});
export default connect(mapStateToProps, mapDispatchToProps)(WizardLanguageSelect);
......@@ -3,39 +3,32 @@ import axios from 'axios';
const INSTANCES_API_TOKEN = 'JEzPe4Ff5c5WA7k4IP5tx0rJMDzEMFxhmXXZvBG4LFSF0Almf0ewfBAKtbPsqMWx1E0hYe6Wy2Zx6HJHP2LmSwUvKneZVOOnelmFaGB7yNeoCvWUxfM0WyVL0FODQPm7';
export const INSTANCES_FETCH_SUCCESS = 'INSTANCES_FETCH_SUCCESS';
export const SEARCH_VALUE_CHANGE = 'SEARCH_VALUE_CHANGE';
export const LOCALE_CHANGE = 'LOCALE_CHANGE';
export const INSTANCES_LOCALE_CHANGE = 'INSTANCES_LOCALE_CHANGE';
const shuffle = array => {
let currentIndex = array.length, temporaryValue, randomIndex;
while (0 !== currentIndex) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
temporaryValue = array[currentIndex];
array[currentIndex] = array[randomIndex];
array[randomIndex] = temporaryValue;
}
return array;
};
export const FILTER_LANGUAGE_CHANGE = 'FILTER_LANGUAGE_CHANGE';
export const FILTER_CATEGORY_CHANGE = 'FILTER_CATEGORY_CHANGE';
export function fetchInstances() {
return (dispatch, getState) => {
if (getState().instances.length > 0) {
return;
}
const { category, language } = getState().filter;
const headers = { 'Authorization': `Bearer ${INSTANCES_API_TOKEN}` };
const params = { count: 0, include_down: false, include_closed: false, sort_by: 'users', sort_order: 'desc', supported_features: 'mstdn_custom_emojis' };
axios.get('https://instances.social/api/1.0/instances/list', { headers, params }).then(res => {
let { instances } = res.data;
shuffle(instances);
dispatch(fetchInstancesSuccess(instances));
});
const params = {
count: 60,
include_down: false,
include_closed: false,
min_version: '2.1.2',
min_active_users: '10',
category,
sort_by: 'active_users',
sort_order: 'desc',
};
if (language !== '') {
params.language = language;
}
axios.get('https://instances.social/api/1.0/instances/list', { headers, params }).then(({ data }) => dispatch(fetchInstancesSuccess(data.instances)));
};
};
......@@ -46,9 +39,9 @@ export function fetchInstancesSuccess(data) {
};
};
export function changeSearchValue(data) {
export function changeFilterCategory(data) {
return {
type: SEARCH_VALUE_CHANGE,
type: FILTER_CATEGORY_CHANGE,
data,
};
};
......@@ -60,9 +53,9 @@ export function changeLocale(data) {
};
};
export function changeInstancesLocale(data) {
export function changeFilterLanguage(data) {
return {
type: INSTANCES_LOCALE_CHANGE,
type: FILTER_LANGUAGE_CHANGE,
data,
};
};
import React from 'react';
import { render } from 'react-snapshot';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import reducer from './reducer';
import AppContainer from './AppContainer';
import './index.css';
const store = createStore(reducer, applyMiddleware(thunk));
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(reducer, composeEnhancers(applyMiddleware(thunk)));
render(
<Provider store={store}>
......
import {
INSTANCES_FETCH_SUCCESS,
SEARCH_VALUE_CHANGE,
LOCALE_CHANGE,
INSTANCES_LOCALE_CHANGE,
FILTER_CATEGORY_CHANGE,
FILTER_LANGUAGE_CHANGE,
} from './actions';
const supportedLocales = ['en', 'fr', 'pl', 'es', 'ja', 'de','pt-BR', 'ar'];
......@@ -18,34 +18,26 @@ const initialLocale = () => {
};
const initialState = {
instances: [],
searchValue: '',
locale: initialLocale(),
instancesLocale: null,
};
const createSearchable = item => {
let searchable = [];
searchable.push(item.name);
if (item.info) {
searchable.push(item.info.theme || 'general');
}
instances: [],
return { ...item, searchable: searchable.join(' ').toLowerCase() };
filter: {
category: '',
language: '',
},
};
export default function reducer(state = initialState, action) {
switch(action.type) {
case INSTANCES_FETCH_SUCCESS:
return { ...state, instances: action.data.filter(item => !item.dead || !item.open_registrations).map(createSearchable) };
case SEARCH_VALUE_CHANGE:
return { ...state, searchValue: action.data };
case LOCALE_CHANGE:
return { ...state, locale: action.data };
case INSTANCES_LOCALE_CHANGE:
return { ...state, instancesLocale: action.data };
case INSTANCES_FETCH_SUCCESS:
return { ...state, instances: action.data };
case FILTER_CATEGORY_CHANGE:
return { ...state, filter: { ...state.filter, category: action.data } };
case FILTER_LANGUAGE_CHANGE:
return { ...state, filter: { ...state.filter, language: action.data } };
default:
return state;
}
......
......@@ -116,6 +116,9 @@ $phi: 1.6180339887498948482;
line-height: 36px;
padding: 4px 16px;
margin-bottom: 10px;
background: transparent;
border: 0;
cursor: pointer;
&.button {
font-weight: 500;
......
......@@ -2,22 +2,47 @@
.wizard-page {
max-width: 800px;
margin: 100px auto;
margin-bottom: 0;
margin: 0 auto;
padding: 60px 0;
position: relative;
h2 {
font-family: 'Montserrat', sans-serif;
font-size: 21px;
margin-bottom: 20px;
color: $lighter;
&::before {
content: "";
display: block;
background: url('assets/elephant-friend-top.png');
transform: scaleX(-1);
position: absolute;
width: 393px;
height: 176px;
z-index: -1;
top: 0;
right: -50px;
}
strong {
color: $lightest;
}
h1 {
font-family: 'Montserrat', sans-serif;
font-size: 26px;
margin-bottom: 30px - 8px;
background: $darkest;
color: $vibrant;
display: inline-block;
padding: 8px 0;
.lang-ar & {
text-align: right;
}
&::before {
content: "";
display: block;
position: absolute;
height: 60px;
width: 0;
top: 0;
left: 50px;
border-left: 2px solid $vibrant;
z-index: -1;
}
}
p {
......@@ -54,12 +79,35 @@
}
}
.wizard-hint {
background: lighten($darkest, 14%);
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.2);
border-radius: 4px;
display: flex;
align-items: center;
color: darken($lighter, 8%);
padding: 10px 16px;
margin-top: 30px;
font-size: 14px;
line-height: 21px;
&__icon {
font-size: 40px;
margin-right: 15px;
}
&__text {
color: $lighter;
}
}
.wizard {
margin: 15px auto;
border: 1px solid darken($darkest, 4%);
background: lighten($darkest, 8%);
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.2);
border-radius: 10px;
max-width: 800px;
max-height: 60vh;
overflow: hidden;
.wizard-header {
......@@ -245,200 +293,227 @@
}
}
.search {
position: relative;
}
.wizard-controls {
margin-top: 20px;
margin-bottom: 30px;
display: flex;
align-items: center;
.cta.button {
padding-top: 1px;
padding-bottom: 1px;
&__label {
font-size: 18px;
font-weight: 500;
margin-right: 10px;
line-height: 44px;
}
&.alt {
padding-top: 0;
padding-bottom: 0;
}
.rrs {
margin-right: 10px;
}
}
.spacer {
flex-grow: 1
@media only screen and (max-width: 800px) {
.wizard-page {
margin: 60px 0;
padding: 0 20px;
}
.language-select {
margin-right: 15px;
.wizard-controls {
margin-top: 60px;
}
.dropdown__control {
background: lighten($darkest, 8%);
transition: none;
border-radius: 4px;
line-height: 36px;
padding: 0 16px;
.wizard {
max-height: 60vh;
}
}
&:hover {
background-color: lighten($darkest, 12%);