Merge pull request #3 from tootsuite/master

Updating to current
This commit is contained in:
Anthony Bellew 2017-01-25 20:53:57 -07:00 committed by GitHub
commit 3d890c4073
276 changed files with 4837 additions and 1332 deletions

1
.env.vagrant Normal file
View File

@ -0,0 +1 @@
VAGRANT=true

3
.gitignore vendored
View File

@ -22,3 +22,6 @@ public/assets
.env.production
node_modules/
neo4j/
# Ignore Vagrant files
.vagrant/

View File

@ -87,3 +87,4 @@ AllCops:
- 'bin/*'
- 'Rakefile'
- 'node_modules/**/*'
- 'Vagrantfile'

10
Gemfile
View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
source 'https://rubygems.org'
ruby '2.3.1'
gem 'rails', '~> 5.0.1.0'
gem 'sass-rails', '~> 5.0'
@ -16,8 +17,9 @@ gem 'pg'
gem 'pghero'
gem 'dotenv-rails'
gem 'font-awesome-rails'
gem 'best_in_place', '~> 3.0.1'
gem 'paperclip', '~> 5.0'
gem 'paperclip', '~> 5.1'
gem 'paperclip-av-transcoder'
gem 'aws-sdk', '>= 2.0'
@ -29,7 +31,6 @@ gem 'link_header'
gem 'ostatus2'
gem 'goldfinger'
gem 'devise'
gem 'rails_autolink'
gem 'doorkeeper'
gem 'rabl'
gem 'oj'
@ -42,9 +43,11 @@ gem 'will_paginate'
gem 'rack-attack'
gem 'rack-cors', require: 'rack/cors'
gem 'sidekiq'
gem 'ledermann-rails-settings'
gem 'rails-settings-cached'
gem 'pg_search'
gem 'simple-navigation'
gem 'statsd-instrument'
gem 'ruby-oembed', require: 'oembed'
gem 'react-rails'
gem 'browserify-rails'
@ -69,6 +72,7 @@ group :development do
gem 'better_errors'
gem 'binding_of_caller'
gem 'letter_opener'
gem 'letter_opener_web'
gem 'bullet'
gem 'active_record_query_trace'
end

View File

@ -60,6 +60,9 @@ GEM
babel-source (>= 4.0, < 6)
execjs (~> 2.0)
bcrypt (3.1.11)
best_in_place (3.0.3)
actionpack (>= 3.2)
railties (>= 3.2)
better_errors (2.1.1)
coderay (>= 1.0.0)
erubis (>= 2.6.6)
@ -73,8 +76,7 @@ GEM
bullet (5.3.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.10.0)
climate_control (0.0.3)
activesupport (>= 3.0)
climate_control (0.1.0)
cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
coderay (1.1.1)
@ -86,7 +88,7 @@ GEM
execjs
coffee-script-source (1.10.0)
colorize (0.8.1)
concurrent-ruby (1.0.3)
concurrent-ruby (1.0.4)
connection_pool (2.2.1)
crack (0.4.3)
safe_yaml (~> 1.0.0)
@ -172,10 +174,12 @@ GEM
json (1.8.3)
launchy (2.4.3)
addressable (~> 2.3)
ledermann-rails-settings (2.4.2)
activerecord (>= 3.1)
letter_opener (1.4.1)
launchy (~> 2.2)
letter_opener_web (1.3.0)
actionmailer (>= 3.2)
letter_opener (~> 1.0)
railties (>= 3.2)
link_header (0.0.8)
lograge (0.4.1)
actionpack (>= 4, < 5.1)
@ -259,11 +263,11 @@ GEM
nokogiri (~> 1.6.0)
rails-html-sanitizer (1.0.3)
loofah (~> 2.0)
rails-settings-cached (0.6.5)
rails (>= 4.2.0)
rails_12factor (0.0.3)
rails_serve_static_assets
rails_stdout_logging
rails_autolink (1.1.6)
rails (> 3.1)
rails_serve_static_assets (0.0.5)
rails_stdout_logging (0.0.5)
railties (5.0.1)
@ -332,6 +336,7 @@ GEM
rainbow (>= 1.99.1, < 3.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
ruby-oembed (0.10.1)
ruby-progressbar (1.8.1)
safe_yaml (1.0.4)
sass (3.4.22)
@ -367,6 +372,7 @@ GEM
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
statsd-instrument (2.1.2)
temple (0.7.7)
term-ansicolor (1.4.0)
tins (~> 1.0)
@ -405,6 +411,7 @@ DEPENDENCIES
addressable
autoprefixer-rails
aws-sdk (>= 2.0)
best_in_place (~> 3.0.1)
better_errors
binding_of_caller
browserify-rails
@ -426,14 +433,14 @@ DEPENDENCIES
i18n-tasks (~> 0.9.6)
jbuilder (~> 2.0)
jquery-rails
ledermann-rails-settings
letter_opener
letter_opener_web
link_header
lograge
nokogiri
oj
ostatus2
paperclip (~> 5.0)
paperclip (~> 5.1)
paperclip-av-transcoder
pg
pg_search
@ -445,23 +452,28 @@ DEPENDENCIES
rack-cors
rack-timeout-puma
rails (~> 5.0.1.0)
rails-settings-cached
rails_12factor
rails_autolink
react-rails
redis (~> 3.2)
redis-rails
rspec-rails
rspec-sidekiq
rubocop
ruby-oembed
sass-rails (~> 5.0)
sdoc (~> 0.4.0)
sidekiq
simple-navigation
simple_form
simplecov
statsd-instrument
uglifier (>= 1.3.0)
webmock
will_paginate
RUBY VERSION
ruby 2.3.1p112
BUNDLED WITH
1.13.6
1.13.7

2
Procfile Normal file
View File

@ -0,0 +1,2 @@
web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiq -q default -q mailers -q push

View File

@ -1,11 +1,11 @@
Mastodon
========
[![Build Status](http://img.shields.io/travis/Gargron/goldfinger.svg)][travis]
[![Code Climate](https://img.shields.io/codeclimate/github/Gargron/mastodon.svg)][code_climate]
[![Build Status](http://img.shields.io/travis/tootsuite/mastodon.svg)][travis]
[![Code Climate](https://img.shields.io/codeclimate/github/tootsuite/mastodon.svg)][code_climate]
[travis]: https://travis-ci.org/Gargron/mastodon
[code_climate]: https://codeclimate.com/github/Gargron/mastodon
[travis]: https://travis-ci.org/tootsuite/mastodon
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
@ -25,11 +25,11 @@ If you would like, you can [support the development of this project on Patreon][
## Resources
- [List of Mastodon instances](https://github.com/Gargron/mastodon/wiki/List-of-Mastodon-instances)
- [List of Mastodon instances](docs/Using-Mastodon/List-of-Mastodon-instances.md)
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
- [API overview](https://github.com/Gargron/mastodon/wiki/API)
- [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL)
- [Frequently Asked Questions](https://github.com/Gargron/mastodon/wiki/FAQ)
- [API overview](docs/Using-the-API/API.md)
- [Frequently Asked Questions](docs/Using-Mastodon/FAQ.md)
- [List of apps](docs/Using-Mastodon/Apps.md)
## Features
@ -115,7 +115,19 @@ Which will re-create the updated containers, leaving databases and data as is. D
## Deployment without Docker
Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](https://github.com/Gargron/mastodon/wiki/Production-guide) for examples, configuration and instructions.
Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](docs/Running-Mastodon/Production-guide.md) for examples, configuration and instructions.
## Deployment on Heroku (experimental)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)
Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. [You can view a guide for deployment on Heroku here.](docs/Running-Mastodon/Heroku.md)
## Development with Vagrant
A quick way to get a development environment up and running is with Vagrant. You will need recent versions of [Vagrant](https://www.vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/) installed.
[You can find the guide for setting up a Vagrant development environment here.](docs/Running-Mastodon/Vagrant.md)
## Contributing

109
Vagrantfile vendored Normal file
View File

@ -0,0 +1,109 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
$provision = <<SCRIPT
cd /vagrant # This is where the host folder/repo is mounted
# Add the yarn repo + yarn repo keys
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main'
# Add repo for NodeJS
curl -sL https://deb.nodesource.com/setup_4.x | sudo bash -
# Add firewall rule to redirect 80 to 3000 and save
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000
echo iptables-persistent iptables-persistent/autosave_v4 boolean true | sudo debconf-set-selections
echo iptables-persistent iptables-persistent/autosave_v6 boolean true | sudo debconf-set-selections
sudo apt-get install iptables-persistent -y
# Add packages to build and run Mastodon
sudo apt-get install \
git-core \
g++ \
libpq-dev \
libxml2-dev \
libxslt1-dev \
imagemagick \
nodejs \
redis-server \
redis-tools \
postgresql \
postgresql-contrib \
yarn \
libreadline-dev \
-y
# Install rbenv
git clone https://github.com/rbenv/rbenv.git ~/.rbenv
cd ~/.rbenv && src/configure && make -C src
echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile
echo 'eval "$(rbenv init -)"' >> ~/.bash_profile
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
export PATH="$HOME/.rbenv/bin::$PATH"
eval "$(rbenv init -)"
echo "Compiling Ruby 2.3.1: warning, this takes a while!!!"
rbenv install 2.3.1
rbenv global 2.3.1
cd /vagrant
# Configure database
sudo -u postgres createuser -U postgres vagrant -s
sudo -u postgres createdb -U postgres mastodon_development
# Install gems and node modules
gem install bundler
bundle install
yarn install
# Build Mastodon
bundle exec rails db:setup
bundle exec rails assets:precompile
SCRIPT
$start = <<SCRIPT
cd /vagrant
export $(cat ".env.vagrant" | xargs)
rails s -d -b 0.0.0.0
SCRIPT
VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.box = "ubuntu/trusty64"
config.vm.provider :virtualbox do |vb|
vb.name = "mastodon"
vb.customize ["modifyvm", :id, "--memory", "1024"]
end
config.vm.hostname = "mastodon.dev"
# This uses the vagrant-hostsupdater plugin, and lets you
# access the development site at http://mastodon.dev.
# To install:
# $ vagrant plugin install hostsupdater
if defined?(VagrantPlugins::HostsUpdater)
config.vm.network :private_network, ip: "192.168.42.42"
config.hostsupdater.remove_on_suspend = false
end
# Otherwise, you can access the site at http://localhost:3000
config.vm.network :forwarded_port, guest: 80, host: 3000
# Full provisioning script, only runs on first 'vagrant up' or with 'vagrant provision'
config.vm.provision :shell, inline: $provision, privileged: false
# Start up script, runs on every 'vagrant up'
config.vm.provision :shell, inline: $start, run: 'always', privileged: false
end

91
app.json Normal file
View File

@ -0,0 +1,91 @@
{
"name": "Mastodon",
"description": "A GNU Social-compatible microblogging server",
"repository": "https://github.com/tootsuite/mastodon",
"logo": "https://github.com/tootsuite/mastodon/raw/master/app/assets/images/logo.png",
"env": {
"HEROKU": {
"description": "Leave this as true",
"value": "true",
"required": true
},
"LOCAL_DOMAIN": {
"description": "The domain that your Mastodon instance will run on (this can be appname.herokuapp.com or a custom domain)",
"required": true
},
"LOCAL_HTTPS": {
"description": "Will your domain support HTTPS? (Automatic for herokuapp, requires manual configuration for custom domains)",
"value": "false",
"required": true
},
"PAPERCLIP_SECRET": {
"description": "The secret key for storing media files",
"generator": "secret"
},
"SECRET_KEY_BASE": {
"description": "The secret key base",
"generator": "secret"
},
"SINGLE_USER_MODE": {
"description": "Should the instance run in single user mode? (Disable registrations, redirect to front page)",
"value": "false",
"required": true
},
"S3_ENABLED": {
"description": "Should Mastodon use Amazon S3 for storage? This is highly recommended, as Heroku does not have persistent file storage (files will be lost).",
"value": "true",
"required": false
},
"S3_BUCKET": {
"description": "Amazon S3 Bucket",
"required": false
},
"S3_REGION": {
"description": "Amazon S3 region that the bucket is located in",
"required": false
},
"AWS_ACCESS_KEY_ID": {
"description": "Amazon S3 Access Key",
"required": false
},
"AWS_SECRET_ACCESS_KEY": {
"description": "Amazon S3 Secret Key",
"required": false
},
"SMTP_SERVER": {
"description": "Hostname for SMTP server, if you want to enable email",
"required": false
},
"SMTP_PORT": {
"description": "Port for SMTP server",
"required": false
},
"SMTP_LOGIN": {
"description": "Username for SMTP server",
"required": false
},
"SMTP_PASSWORD": {
"description": "Password for SMTP server",
"required": false
},
"SMTP_DOMAIN": {
"description": "Domain for SMTP server. Will default to instance domain if blank.",
"required": false
}
},
"buildpacks": [
{
"url": "heroku/nodejs"
},
{
"url": "heroku/ruby"
}
],
"scripts": {
"postdeploy": "bundle exec rails db:migrate && bundle exec rails db:seed"
},
"addons": [
"heroku-postgresql",
"heroku-redis"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,3 +1,8 @@
//= require jquery
//= require jquery_ujs
//= require extras
//= require best_in_place
$(function () {
$(".best_in_place").best_in_place();
});

View File

@ -1,8 +1,6 @@
import api, { getLinks } from '../api'
import Immutable from 'immutable';
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL';
@ -67,13 +65,6 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
export function setAccountSelf(account) {
return {
type: ACCOUNT_SET_SELF,
account
};
};
export function fetchAccount(id) {
return (dispatch, getState) => {
dispatch(fetchAccountRequest(id));
@ -89,32 +80,39 @@ export function fetchAccount(id) {
export function fetchAccountTimeline(id, replace = false) {
return (dispatch, getState) => {
dispatch(fetchAccountTimelineRequest(id));
const ids = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List());
const ids = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List());
const newestId = ids.size > 0 ? ids.first() : null;
let params = '';
let skipLoading = false;
if (newestId !== null && !replace) {
params = `?since_id=${newestId}`;
params = `?since_id=${newestId}`;
skipLoading = true;
}
dispatch(fetchAccountTimelineRequest(id, skipLoading));
api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
dispatch(fetchAccountTimelineSuccess(id, response.data, replace));
dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading));
}).catch(error => {
dispatch(fetchAccountTimelineFail(id, error));
dispatch(fetchAccountTimelineFail(id, error, skipLoading));
});
};
};
export function expandAccountTimeline(id) {
return (dispatch, getState) => {
const lastId = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List()).last();
const lastId = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()).last();
dispatch(expandAccountTimelineRequest(id));
api(getState).get(`/api/v1/accounts/${id}/statuses?max_id=${lastId}`).then(response => {
api(getState).get(`/api/v1/accounts/${id}/statuses`, {
params: {
limit: 10,
max_id: lastId
}
}).then(response => {
dispatch(expandAccountTimelineSuccess(id, response.data));
}).catch(error => {
dispatch(expandAccountTimelineFail(id, error));
@ -210,27 +208,30 @@ export function unfollowAccountFail(error) {
};
};
export function fetchAccountTimelineRequest(id) {
export function fetchAccountTimelineRequest(id, skipLoading) {
return {
type: ACCOUNT_TIMELINE_FETCH_REQUEST,
id
id,
skipLoading
};
};
export function fetchAccountTimelineSuccess(id, statuses, replace) {
export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading) {
return {
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
id,
statuses,
replace
replace,
skipLoading
};
};
export function fetchAccountTimelineFail(id, error) {
export function fetchAccountTimelineFail(id, error, skipLoading) {
return {
type: ACCOUNT_TIMELINE_FETCH_FAIL,
id,
error
error,
skipLoading
};
};
@ -495,6 +496,10 @@ export function expandFollowingFail(id, error) {
export function fetchRelationships(account_ids) {
return (dispatch, getState) => {
if (account_ids.length === 0) {
return;
}
dispatch(fetchRelationshipsRequest(account_ids));
api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
@ -508,21 +513,24 @@ export function fetchRelationships(account_ids) {
export function fetchRelationshipsRequest(ids) {
return {
type: RELATIONSHIPS_FETCH_REQUEST,
ids
ids,
skipLoading: true
};
};
export function fetchRelationshipsSuccess(relationships) {
return {
type: RELATIONSHIPS_FETCH_SUCCESS,
relationships
relationships,
skipLoading: true
};
};
export function fetchRelationshipsFail(error) {
return {
type: RELATIONSHIPS_FETCH_FAIL,
error
error,
skipLoading: true
};
};

View File

@ -0,0 +1,47 @@
import api from '../api';
export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST';
export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS';
export const STATUS_CARD_FETCH_FAIL = 'STATUS_CARD_FETCH_FAIL';
export function fetchStatusCard(id) {
return (dispatch, getState) => {
dispatch(fetchStatusCardRequest(id));
api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
if (!response.data.url || !response.data.title || !response.data.description) {
return;
}
dispatch(fetchStatusCardSuccess(id, response.data));
}).catch(error => {
dispatch(fetchStatusCardFail(id, error));
});
};
};
export function fetchStatusCardRequest(id) {
return {
type: STATUS_CARD_FETCH_REQUEST,
id,
skipLoading: true
};
};
export function fetchStatusCardSuccess(id, card) {
return {
type: STATUS_CARD_FETCH_SUCCESS,
id,
card,
skipLoading: true
};
};
export function fetchStatusCardFail(id, error) {
return {
type: STATUS_CARD_FETCH_FAIL,
id,
error,
skipLoading: true
};
};

View File

@ -23,6 +23,8 @@ export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
@ -68,6 +70,7 @@ export function submitCompose() {
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public')
}).then(function (response) {
dispatch(submitComposeSuccess({ ...response.data }));
@ -218,6 +221,20 @@ export function changeComposeSensitivity(checked) {
};
};
export function changeComposeSpoilerness(checked) {
return {
type: COMPOSE_SPOILERNESS_CHANGE,
checked
};
};
export function changeComposeSpoilerText(text) {
return {
type: COMPOSE_SPOILER_TEXT_CHANGE,
text
};
};
export function changeComposeVisibility(checked) {
return {
type: COMPOSE_VISIBILITY_CHANGE,

View File

@ -0,0 +1,83 @@
import api, { getLinks } from '../api'
export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL';
export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL';
export function fetchFavouritedStatuses() {
return (dispatch, getState) => {
dispatch(fetchFavouritedStatusesRequest());
api(getState).get('/api/v1/favourites').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchFavouritedStatusesFail(error));
});
};
};
export function fetchFavouritedStatusesRequest() {
return {
type: FAVOURITED_STATUSES_FETCH_REQUEST
};
};
export function fetchFavouritedStatusesSuccess(statuses, next) {
return {
type: FAVOURITED_STATUSES_FETCH_SUCCESS,
statuses,
next
};
};
export function fetchFavouritedStatusesFail(error) {
return {
type: FAVOURITED_STATUSES_FETCH_FAIL,
error
};
};
export function expandFavouritedStatuses() {
return (dispatch, getState) => {
const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
if (url === null) {
return;
}
dispatch(expandFavouritedStatusesRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFavouritedStatusesFail(error));
});
};
};
export function expandFavouritedStatusesRequest() {
return {
type: FAVOURITED_STATUSES_EXPAND_REQUEST
};
};
export function expandFavouritedStatusesSuccess(statuses, next) {
return {
type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
statuses,
next
};
};
export function expandFavouritedStatusesFail(error) {
return {
type: FAVOURITED_STATUSES_EXPAND_FAIL,
error
};
};

View File

@ -1,8 +0,0 @@
export const ACCESS_TOKEN_SET = 'ACCESS_TOKEN_SET';
export function setAccessToken(token) {
return {
type: ACCESS_TOKEN_SET,
token: token
};
};

View File

@ -14,8 +14,6 @@ export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
export const NOTIFICATIONS_SETTING_CHANGE = 'NOTIFICATIONS_SETTING_CHANGE';
const fetchRelatedRelationships = (dispatch, notifications) => {
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
@ -26,21 +24,25 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
export function updateNotifications(notification, intlMessages, intlLocale) {
return (dispatch, getState) => {
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
dispatch({
type: NOTIFICATIONS_UPDATE,
notification,
account: notification.account,
status: notification.status
status: notification.status,
meta: playSound ? { sound: 'boop' } : undefined
});
fetchRelatedRelationships(dispatch, [notification]);
// Desktop notifications
if (typeof window.Notification !== 'undefined' && getState().getIn(['notifications', 'settings', 'alerts', notification.type], false)) {
if (typeof window.Notification !== 'undefined' && showAlert) {
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
const body = $('<p>').html(notification.status ? notification.status.content : '').text();
new Notification(title, { body, icon: notification.account.avatar });
new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
}
};
};
@ -94,13 +96,17 @@ export function expandNotifications() {
return (dispatch, getState) => {
const url = getState().getIn(['notifications', 'next'], null);
if (url === null) {
if (url === null || getState().getIn(['notifications', 'isLoading'])) {
return;
}
dispatch(expandNotificationsRequest());
api(getState).get(url).then(response => {
api(getState).get(url, {
params: {
limit: 5
}
}).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
@ -133,11 +139,3 @@ export function expandNotificationsFail(error) {
error
};
};
export function changeNotificationsSetting(key, checked) {
return {
type: NOTIFICATIONS_SETTING_CHANGE,
key,
checked
};
};

View File

@ -0,0 +1,19 @@
import axios from 'axios';
export const SETTING_CHANGE = 'SETTING_CHANGE';
export function changeSetting(key, value) {
return {
type: SETTING_CHANGE,
key,
value
};
};
export function saveSettings() {
return (_, getState) => {
axios.put('/api/web/settings', {
data: getState().get('settings').toJS()
});
};
};

View File

@ -1,6 +1,7 @@
import api from '../api';
import { deleteFromTimelines } from './timelines';
import { fetchStatusCard } from './cards';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@ -14,39 +15,44 @@ export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
export function fetchStatusRequest(id) {
export function fetchStatusRequest(id, skipLoading) {
return {
type: STATUS_FETCH_REQUEST,
id: id
id,
skipLoading
};
};
export function fetchStatus(id) {
return (dispatch, getState) => {
dispatch(fetchStatusRequest(id));
const skipLoading = getState().getIn(['statuses', id], null) !== null;
dispatch(fetchStatusRequest(id, skipLoading));
api(getState).get(`/api/v1/statuses/${id}`).then(response => {
dispatch(fetchStatusSuccess(response.data));
dispatch(fetchStatusSuccess(response.data, skipLoading));
dispatch(fetchContext(id));
dispatch(fetchStatusCard(id));
}).catch(error => {
dispatch(fetchStatusFail(id, error));
dispatch(fetchStatusFail(id, error, skipLoading));
});
};
};
export function fetchStatusSuccess(status, context) {
export function fetchStatusSuccess(status, skipLoading) {
return {
type: STATUS_FETCH_SUCCESS,
status: status,
context: context
status,
skipLoading
};
};
export function fetchStatusFail(id, error) {
export function fetchStatusFail(id, error, skipLoading) {
return {
type: STATUS_FETCH_FAIL,
id: id,
error: error
id,
error,
skipLoading
};
};

View File

@ -0,0 +1,17 @@
import Immutable from 'immutable';
export const STORE_HYDRATE = 'STORE_HYDRATE';
const convertState = rawState =>
Immutable.fromJS(rawState, (k, v) =>
Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
Number.isNaN(x * 1) ? x : x * 1));
export function hydrateStore(rawState) {
const state = convertState(rawState);
return {
type: STORE_HYDRATE,
state
};
};

View File

@ -14,11 +14,12 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
export function refreshTimelineSuccess(timeline, statuses) {
export function refreshTimelineSuccess(timeline, statuses, skipLoading) {
return {
type: TIMELINE_REFRESH_SUCCESS,
timeline: timeline,
statuses: statuses
timeline,
statuses,
skipLoading
};
};
@ -39,55 +40,65 @@ export function deleteFromTimelines(id) {
return (dispatch, getState) => {
const accountId = getState().getIn(['statuses', id, 'account']);
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
dispatch({
type: TIMELINE_DELETE,
id,
accountId,
references
references,
reblogOf
});
};
};
export function refreshTimelineRequest(timeline, id) {
export function refreshTimelineRequest(timeline, id, skipLoading) {
return {
type: TIMELINE_REFRESH_REQUEST,
timeline,
id
id,
skipLoading
};
};
export function refreshTimeline(timeline, id = null) {
return function (dispatch, getState) {
dispatch(refreshTimelineRequest(timeline, id));
if (getState().getIn(['timelines', timeline, 'isLoading'])) {
return;
}
const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
const newestId = ids.size > 0 ? ids.first() : null;
let params = '';
let path = timeline;
let params = '';
let path = timeline;
let skipLoading = false;
if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded'])) {
params = `?since_id=${newestId}`;
params = `?since_id=${newestId}`;
skipLoading = true;
}
if (id) {
path = `${path}/${id}`
}
dispatch(refreshTimelineRequest(timeline, id, skipLoading));
api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
dispatch(refreshTimelineSuccess(timeline, response.data));
dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading));
}).catch(function (error) {
dispatch(refreshTimelineFail(timeline, error));
dispatch(refreshTimelineFail(timeline, error, skipLoading));
});
};
};
export function refreshTimelineFail(timeline, error) {
export function refreshTimelineFail(timeline, error, skipLoading) {
return {
type: TIMELINE_REFRESH_FAIL,
timeline,
error
error,
skipLoading
};
};
@ -95,6 +106,12 @@ export function expandTimeline(timeline, id = null) {
return (dispatch, getState) => {
const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last();
if (!lastId || getState().getIn(['timelines', timeline, 'isLoading'])) {
// If timeline is empty, don't try to load older posts since there are none
// Also if already loading
return;
}
dispatch(expandTimelineRequest(timeline));
let path = timeline;
@ -103,7 +120,12 @@ export function expandTimeline(timeline, id = null) {
path = `${path}/${id}`
}
api(getState).get(`/api/v1/timelines/${path}?max_id=${lastId}`).then(response => {
api(getState).get(`/api/v1/timelines/${path}`, {
params: {
limit: 10,
max_id: lastId
}
}).then(response => {
dispatch(expandTimelineSuccess(timeline, response.data));
}).catch(error => {
dispatch(expandTimelineFail(timeline, error));

View File

@ -8,7 +8,9 @@ import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock' }
});
const outerStyle = {
@ -42,7 +44,9 @@ const Account = React.createClass({
account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
withNote: React.PropTypes.bool
onBlock: React.PropTypes.func.isRequired,
withNote: React.PropTypes.bool,
intl: React.PropTypes.object.isRequired
},
getDefaultProps () {
@ -57,6 +61,10 @@ const Account = React.createClass({
this.props.onFollow(this.props.account);
},
handleBlock () {
this.props.onBlock(this.props.account);
},
render () {
const { account, me, withNote, intl } = this.props;
@ -70,10 +78,18 @@ const Account = React.createClass({
note = <div style={noteStyle}>{account.get('note')}</div>;
}
if (account.get('id') !== me && account.get('relationship', null) != null) {
if (account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
if (requested) {
buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
} else if (blocking) {
buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
} else {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
}
}
return (

View File

@ -38,7 +38,8 @@ const AutosuggestTextarea = React.createClass({
onSuggestionsClearRequested: React.PropTypes.func.isRequired,
onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
onChange: React.PropTypes.func.isRequired,
onKeyUp: React.PropTypes.func
onKeyUp: React.PropTypes.func,
onKeyDown: React.PropTypes.func
},
getInitialState () {
@ -108,15 +109,28 @@ const AutosuggestTextarea = React.createClass({
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
this.props.onKeyDown(e);
},
onBlur () {
this.setState({ suggestionsHidden: true });
// If we hide the suggestions immediately, then this will prevent the
// onClick for the suggestions themselves from firing.
// Setting a short window for that to take place before hiding the
// suggestions ensures that can't happen.
setTimeout(() => {
this.setState({ suggestionsHidden: true });
}, 100);
},
onSuggestionClick (suggestion, e) {
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.textarea.focus();
},
componentWillReceiveProps (nextProps) {

View File

@ -8,12 +8,41 @@ const Avatar = React.createClass({
style: React.PropTypes.object
},
getInitialState () {
return {
hovering: false
};
},
mixins: [PureRenderMixin],
handleMouseEnter () {
this.setState({ hovering: true });
},
handleMouseLeave () {
this.setState({ hovering: false });
},
handleLoad () {
this.canvas.getContext('2d').drawImage(this.image, 0, 0, this.props.size, this.props.size);
},
setImageRef (c) {
this.image = c;
},
setCanvasRef (c) {
this.canvas = c;
},
render () {
const { hovering } = this.state;
return (
<div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}>
<img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ display: 'block', borderRadius: '4px' }} />
<div onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px`, position: 'relative' }}>
<img ref={this.setImageRef} onLoad={this.handleLoad} src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ position: 'absolute', top: '0', left: '0', visibility: hovering ? 'visible' : 'hidden', borderRadius: '4px' }} />
<canvas ref={this.setCanvasRef} width={this.props.size} height={this.props.size} style={{ borderRadius: '4px' }} />
</div>
);
}

View File

@ -27,7 +27,7 @@ const Button = React.createClass({
render () {
const style = {
fontFamily: 'Roboto',
fontFamily: 'inherit',
display: this.props.block ? 'block' : 'inline-block',
width: this.props.block ? '100%' : 'auto',
position: 'relative',

View File

@ -0,0 +1,60 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { Motion, spring } from 'react-motion';
const iconStyle = {
fontSize: '16px',
padding: '15px',
position: 'absolute',
right: '0',
top: '-48px',
cursor: 'pointer'
};
const ColumnCollapsable = React.createClass({
propTypes: {
icon: React.PropTypes.string.isRequired,
fullHeight: React.PropTypes.number.isRequired,
children: React.PropTypes.node,
onCollapse: React.PropTypes.func
},
getInitialState () {
return {
collapsed: true
};
},
mixins: [PureRenderMixin],
handleToggleCollapsed () {
const currentState = this.state.collapsed;
this.setState({ collapsed: !currentState });
if (!currentState && this.props.onCollapse) {
this.props.onCollapse();
}
},
render () {
const { icon, fullHeight, children } = this.props;
const { collapsed } = this.state;
return (
<div style={{ position: 'relative' }}>
<div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div>
<Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
{({ opacity, height }) =>
<div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}>
{children}
</div>
}
</Motion>
</div>
);
}
});
export default ColumnCollapsable;

View File

@ -1,13 +1,15 @@
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
const DropdownMenu = ({ icon, items, size }) => {
const DropdownMenu = ({ icon, items, size, direction }) => {
const directionClass = (direction == "left") ? "dropdown__left" : "dropdown__right";
return (
<Dropdown>
<DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }}>
<i className={`fa fa-fw fa-${icon}`} style={{ verticalAlign: 'middle' }} />
</DropdownTrigger>
<DropdownContent style={{ lineHeight: '18px', textAlign: 'left' }}>
<DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}>
<ul>
{items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
if (typeof action === 'function') {

View File

@ -1,4 +1,5 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { Motion, spring } from 'react-motion';
const IconButton = React.createClass({
@ -10,14 +11,16 @@ const IconButton = React.createClass({
active: React.PropTypes.bool,
style: React.PropTypes.object,
activeStyle: React.PropTypes.object,
disabled: React.PropTypes.bool
disabled: React.PropTypes.bool,
animate: React.PropTypes.bool
},
getDefaultProps () {
return {
size: 18,
active: false,
disabled: false
disabled: false,
animate: false
};
},
@ -49,9 +52,18 @@ const IconButton = React.createClass({
}
return (
<button aria-label={this.props.title} title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`} onClick={this.handleClick} style={style}>
<i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
</button>
<Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
{({ rotate }) =>
<button
aria-label={this.props.title}
title={this.props.title}
className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`}
onClick={this.handleClick}
style={style}>
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
</button>
}
</Motion>
);
}

View File

@ -35,7 +35,9 @@ const Lightbox = React.createClass({
propTypes: {
isVisible: React.PropTypes.bool,
onOverlayClicked: React.PropTypes.func,
onCloseClicked: React.PropTypes.func
onCloseClicked: React.PropTypes.func,
intl: React.PropTypes.object.isRequired,
children: React.PropTypes.node
},
mixins: [PureRenderMixin],
@ -57,19 +59,17 @@ const Lightbox = React.createClass({
render () {
const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
const content = isVisible ? children : <div />;
return (
<div className='lightbox' style={{...overlayStyle, display: isVisible ? 'flex' : 'none'}} onClick={onOverlayClicked}>
<Motion defaultStyle={{ y: -200 }} style={{ y: spring(isVisible ? 0 : -200) }}>
{({ y }) =>
<div style={{...dialogStyle, transform: `translateY(${y}px)`}}>
<Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}>
{({ backgroundOpacity, opacity, y }) =>
<div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex'}} onClick={onOverlayClicked}>
<div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }}>
<IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
{content}
{children}
</div>
}
</Motion>
</div>
</div>
}
</Motion>
);
}

View File

@ -1,15 +1,17 @@
import { FormattedMessage } from 'react-intl';
const LoadingIndicator = () => {
const style = {
textAlign: 'center',
fontSize: '16px',
fontWeight: '500',
color: '#616b86',
paddingTop: '120px'
};
return <div style={style}><FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /></div>;
const style = {
textAlign: 'center',
fontSize: '16px',
fontWeight: '500',
color: '#616b86',
paddingTop: '120px'
};
const LoadingIndicator = () => (
<div style={style}>
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
</div>
);
export default LoadingIndicator;

View File

@ -1,12 +1,18 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { FormattedMessage } from 'react-intl';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
});
const outerStyle = {
marginTop: '8px',
overflow: 'hidden',
width: '100%',
boxSizing: 'border-box'
boxSizing: 'border-box',
position: 'relative'
};
const spoilerStyle = {
@ -32,11 +38,18 @@ const spoilerSubSpanStyle = {
fontWeight: '500'
};
const spoilerButtonStyle = {
position: 'absolute',
top: '6px',
left: '8px',
zIndex: '100'
};
const MediaGallery = React.createClass({
getInitialState () {
return {
visible: false
visible: !this.props.sensitive
};
},
@ -59,21 +72,30 @@ const MediaGallery = React.createClass({
},
handleOpen () {
this.setState({ visible: true });
this.setState({ visible: !this.state.visible });
},
render () {
const { media, sensitive } = this.props;
const { media, intl, sensitive } = this.props;
let children;
if (sensitive && !this.state.visible) {
children = (
<div style={spoilerStyle} onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
if (!this.state.visible) {
if (sensitive) {
children = (
<div style={spoilerStyle} onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
children = (
<div style={spoilerStyle} onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
}
} else {
const size = media.take(4).size;
@ -134,9 +156,12 @@ const MediaGallery = React.createClass({
);
});
}
return (
<div style={{ ...outerStyle, height: `${this.props.height}px` }}>
<div style={spoilerButtonStyle} >
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
</div>
{children}
</div>
);
@ -144,4 +169,4 @@ const MediaGallery = React.createClass({
});
export default MediaGallery;
export default injectIntl(MediaGallery);

View File

@ -0,0 +1,17 @@
import { FormattedMessage } from 'react-intl';
const style = {
textAlign: 'center',
fontSize: '16px',
fontWeight: '500',
color: '#616b86',
paddingTop: '120px'
};
const MissingIndicator = () => (
<div style={style}>
<FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
</div>
);
export default MissingIndicator;

View File

@ -1,15 +1,18 @@
import {
FormattedMessage,
FormattedDate,
FormattedRelative
} from 'react-intl';
import { injectIntl, FormattedRelative } from 'react-intl';
const RelativeTimestamp = ({ timestamp }) => {
return <FormattedRelative value={new Date(timestamp)} />;
const RelativeTimestamp = ({ intl, timestamp }) => {
const date = new Date(timestamp);
return (
<time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}>
<FormattedRelative value={date} />
</time>
);
};
RelativeTimestamp.propTypes = {
intl: React.PropTypes.object.isRequired,
timestamp: React.PropTypes.string.isRequired
};
export default RelativeTimestamp;
export default injectIntl(RelativeTimestamp);

View File

@ -49,7 +49,7 @@ const StatusActionBar = React.createClass({
},
handleMentionClick () {
this.props.onMention(this.props.status.get('account'));
this.props.onMention(this.props.status.get('account'), this.context.router);
},
handleBlockClick () {
@ -77,10 +77,10 @@ const StatusActionBar = React.createClass({
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div style={{ width: '18px', height: '18px', float: 'left' }}>
<DropdownMenu items={menu} icon='ellipsis-h' size={18} />
<DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" />
</div>
</div>
);

View File

@ -1,6 +1,7 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import emojify from '../emoji';
import { FormattedMessage } from 'react-intl';
const StatusContent = React.createClass({
@ -13,6 +14,12 @@ const StatusContent = React.createClass({
onClick: React.PropTypes.func
},
getInitialState () {
return {
hidden: true
};
},
mixins: [PureRenderMixin],
componentDidMount () {
@ -31,8 +38,6 @@ const StatusContent = React.createClass({
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener');
}
link.addEventListener('click', this.onNormalClick, false);
}
},
@ -52,16 +57,59 @@ const StatusContent = React.createClass({
}
},
onNormalClick (e) {
e.stopPropagation();
handleMouseDown (e) {
this.startXY = [e.clientX, e.clientY];
},
handleMouseUp (e) {
const [ startX, startY ] = this.startXY;
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (e.target.localName === 'a' || (e.target.parentNode && e.target.parentNode.localName === 'a')) {
return;
}
if (deltaX + deltaY < 5 && e.button === 0) {
this.props.onClick();
}
this.startXY = null;
},
handleSpoilerClick () {
this.setState({ hidden: !this.state.hidden });
},
render () {
const { status, onClick } = this.props;
const { status } = this.props;
const { hidden } = this.state;
const content = { __html: emojify(status.get('content')) };
const spoilerContent = { __html: emojify(status.get('spoiler_text', '')) };
return <div className='status__content' style={{ cursor: 'pointer' }} dangerouslySetInnerHTML={content} onClick={onClick} />;
if (status.get('spoiler_text').length > 0) {
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
return (
<div className='status__content' style={{ cursor: 'pointer' }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p style={{ marginBottom: hidden ? '0px' : '' }} >
<span dangerouslySetInnerHTML={spoilerContent} /> <a onClick={this.handleSpoilerClick}>{toggleText}</a>
</p>
<div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} />
</div>
);
} else {
return (
<div
className='status__content'
style={{ cursor: 'pointer' }}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
);
}
},
});

View File

@ -11,7 +11,8 @@ const StatusList = React.createClass({
onScrollToBottom: React.PropTypes.func,
onScrollToTop: React.PropTypes.func,
onScroll: React.PropTypes.func,
trackScroll: React.PropTypes.bool
trackScroll: React.PropTypes.bool,
isLoading: React.PropTypes.bool
},
getDefaultProps () {
@ -24,10 +25,10 @@ const StatusList = React.createClass({
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
this._oldScrollPosition = scrollHeight - scrollTop;
if (scrollTop === scrollHeight - clientHeight && this.props.onScrollToBottom) {
if (250 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
this.props.onScrollToBottom();
} else if (scrollTop < 100 && this.props.onScrollToTop) {
this.props.onScrollToTop();
@ -36,21 +37,37 @@ const StatusList = React.createClass({
}
},
componentDidUpdate (prevProps) {
if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) {
const node = ReactDOM.findDOMNode(this);
componentDidMount () {
this.attachScrollListener();
},
if (node.scrollTop > 0) {
node.scrollTop = node.scrollHeight - this._oldScrollPosition;
}
componentDidUpdate (prevProps) {
if (this.node.scrollTop > 0 && (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && !!this._oldScrollPosition)) {
this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
}
},
componentWillUnmount () {
this.detachScrollListener();
},
attachScrollListener () {
this.node.addEventListener('scroll', this.handleScroll);
},
detachScrollListener () {
this.node.removeEventListener('scroll', this.handleScroll);
},
setRef (c) {
this.node = c;
},
render () {
const { statusIds, onScrollToBottom, trackScroll } = this.props;
const scrollableArea = (
<div className='scrollable' onScroll={this.handleScroll}>
<div className='scrollable' ref={this.setRef}>
<div>
{statusIds.map((statusId) => {
return <StatusContainer key={statusId} id={statusId} />;

View File

@ -4,7 +4,8 @@ import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
const messages = defineMessages({
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }
});
const videoStyle = {
@ -20,7 +21,7 @@ const videoStyle = {
const muteStyle = {
position: 'absolute',
top: '10px',
left: '10px',
right: '10px',
opacity: '0.8',
zIndex: '5'
};
@ -35,7 +36,8 @@ const spoilerStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
flexDirection: 'column',
position: 'relative'
};
const spoilerSpanStyle = {
@ -49,6 +51,13 @@ const spoilerSubSpanStyle = {
fontWeight: '500'
};
const spoilerButtonStyle = {
position: 'absolute',
top: '6px',
left: '8px',
zIndex: '100'
};
const VideoPlayer = React.createClass({
propTypes: {
media: ImmutablePropTypes.map.isRequired,
@ -66,7 +75,8 @@ const VideoPlayer = React.createClass({
getInitialState () {
return {
visible: false,
visible: !this.props.sensitive,
preview: true,
muted: true
};
},
@ -90,22 +100,49 @@ const VideoPlayer = React.createClass({
},
handleOpen () {
this.setState({ visible: true });
this.setState({ preview: !this.state.preview });
},
handleVisibility () {
this.setState({
visible: !this.state.visible,
preview: true
});
},
render () {
const { media, intl, width, height, sensitive } = this.props;
if (sensitive && !this.state.visible) {
return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else if (!sensitive && !this.state.visible) {
let spoilerButton = (
<div style={spoilerButtonStyle} >
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
</div>
);
if (!this.state.visible) {
if (sensitive) {
return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleVisibility}>
{spoilerButton}
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
{spoilerButton}
<span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
}
}
if (this.state.preview) {
return (
<div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}>
{spoilerButton}
<div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div>
</div>
);
@ -113,7 +150,8 @@ const VideoPlayer = React.createClass({
return (
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
<div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
{spoilerButton}
<div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div>
<video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
</div>
);

View File

@ -3,7 +3,9 @@ import { makeGetAccount } from '../selectors';
import Account from '../components/account';
import {
followAccount,
unfollowAccount
unfollowAccount,
blockAccount,
unblockAccount
} from '../actions/accounts';
const makeMapStateToProps = () => {
@ -24,6 +26,14 @@ const mapDispatchToProps = (dispatch) => ({
} else {
dispatch(followAccount(account.get('id')));
}
},
onBlock (account) {
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
} else {
dispatch(blockAccount(account.get('id')));
}
}
});

View File

@ -7,15 +7,13 @@ import {
refreshTimeline
} from '../actions/timelines';
import { updateNotifications } from '../actions/notifications';
import { setAccessToken } from '../actions/meta';
import { setAccountSelf } from '../actions/accounts';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import createBrowserHistory from 'history/lib/createBrowserHistory';
import {
applyRouterMiddleware,
useRouterHistory,
Router,
Route,
IndexRedirect,
IndexRoute
} from 'react-router';
import { useScroll } from 'react-router-scroll';
@ -35,6 +33,8 @@ import Favourites from '../features/favourites';
import HashtagTimeline from '../features/hashtag_timeline';
import Notifications from '../features/notifications';
import FollowRequests from '../features/follow_requests';
import GenericNotFound from '../features/generic_not_found';
import FavouritedStatuses from '../features/favourited_statuses';
import { IntlProvider, addLocaleData } from 'react-intl';
import en from 'react-intl/locale-data/en';
import de from 'react-intl/locale-data/de';
@ -44,9 +44,12 @@ import pt from 'react-intl/locale-data/pt';
import hu from 'react-intl/locale-data/hu';
import uk from 'react-intl/locale-data/uk';
import getMessagesForLocale from '../locales';
import { hydrateStore } from '../actions/store';
const store = configureStore();
store.dispatch(hydrateStore(window.INITIAL_STATE));
const browserHistory = useRouterHistory(createBrowserHistory)({
basename: '/web'
});
@ -56,31 +59,26 @@ addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]);
const Mastodon = React.createClass({
propTypes: {
token: React.PropTypes.string.isRequired,
timelines: React.PropTypes.object,
account: React.PropTypes.string,
locale: React.PropTypes.string.isRequired
},
mixins: [PureRenderMixin],
componentWillMount() {
const { token, account, locale } = this.props;
store.dispatch(setAccessToken(token));
store.dispatch(setAccountSelf(JSON.parse(account)));
const { locale } = this.props;
if (typeof App !== 'undefined') {
this.subscription = App.cable.subscriptions.create('TimelineChannel', {
received (data) {
switch(data.type) {
case 'update':
return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
case 'delete':
return store.dispatch(deleteFromTimelines(data.id));
case 'notification':
return store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale));
case 'update':
store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
break;
case 'delete':
store.dispatch(deleteFromTimelines(data.id));
break;
case 'notification':
store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale));
break;
}
}
@ -107,14 +105,16 @@ const Mastodon = React.createClass({
<Provider store={store}>
<Router history={browserHistory} render={applyRouterMiddleware(useScroll())}>
<Route path='/' component={UI}>
<IndexRoute component={GettingStarted} />
<IndexRedirect to="/getting-started" />
<Route path='getting-started' component={GettingStarted} />
<Route path='timelines/home' component={HomeTimeline} />
<Route path='timelines/mentions' component={MentionsTimeline} />
<Route path='timelines/public' component={PublicTimeline} />
<Route path='timelines/tag/:id' component={HashtagTimeline} />
<Route path='notifications' component={Notifications} />
<Route path='favourites' component={FavouritedStatuses} />
<Route path='statuses/new' component={Compose} />
<Route path='statuses/:statusId' component={Status} />
@ -128,6 +128,7 @@ const Mastodon = React.createClass({
</Route>
<Route path='follow_requests' component={FollowRequests} />
<Route path='*' component={GenericNotFound} />
</Route>
</Router>
</Provider>

View File

@ -15,6 +15,7 @@ import { blockAccount } from '../actions/accounts';
import { deleteStatus } from '../actions/statuses';
import { openMedia } from '../actions/modal';
import { createSelector } from 'reselect'
import { isMobile } from '../is_mobile'
const mapStateToProps = (state, props) => ({
statusBase: state.getIn(['statuses', props.id]),
@ -86,8 +87,11 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(deleteStatus(status.get('id')));
},
onMention (account) {
onMention (account, router) {
dispatch(mentionCompose(account));
if (isMobile(window.innerWidth)) {
router.push('/statuses/new');
}
},
onOpenMedia (url) {

View File

@ -5,5 +5,5 @@ emojione.sprites = false;
emojione.imagePathPNG = '/emoji/';
export default function emojify(text) {
return emojione.unicodeToImage(text);
return emojione.toImage(text);
};

View File

@ -66,7 +66,7 @@ const ActionBar = React.createClass({
return (
<div style={outerStyle}>
<div style={outerDropdownStyle}>
<DropdownMenu items={menu} icon='bars' size={24} />
<DropdownMenu items={menu} icon='bars' size={24} direction="right" />
</div>
<div style={outerLinksStyle}>

View File

@ -71,8 +71,8 @@ const Header = React.createClass({
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
</a>
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
<div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#489fde', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
<div style={{ color: '#d9e1e8', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
{info}
{actionBtn}

View File

@ -20,6 +20,7 @@ import LoadingIndicator from '../../components/loading_indicator';
import ActionBar from './components/action_bar';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';
import { isMobile } from '../../is_mobile'
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
@ -34,11 +35,16 @@ const makeMapStateToProps = () => {
const Account = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
account: ImmutablePropTypes.map,
me: React.PropTypes.number.isRequired
me: React.PropTypes.number.isRequired,
children: React.PropTypes.node
},
mixins: [PureRenderMixin],
@ -71,6 +77,9 @@ const Account = React.createClass({
handleMention () {
this.props.dispatch(mentionCompose(this.props.account));
if (isMobile(window.innerWidth)) {
this.context.router.push('/statuses/new');
}
},
render () {

View File

@ -9,7 +9,8 @@ import StatusList from '../../components/status_list';
import LoadingIndicator from '../../components/loading_indicator';
const mapStateToProps = (state, props) => ({
statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]),
statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items']),
isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']),
me: state.getIn(['meta', 'me'])
});
@ -18,7 +19,9 @@ const AccountTimeline = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list
statusIds: ImmutablePropTypes.list,
isLoading: React.PropTypes.bool,
me: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
@ -38,13 +41,13 @@ const AccountTimeline = React.createClass({
},
render () {
const { statusIds, me } = this.props;
const { statusIds, isLoading, me } = this.props;
if (!statusIds) {
return <LoadingIndicator />;
}
return <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
return <StatusList statusIds={statusIds} isLoading={isLoading} me={me} onScrollToBottom={this.handleScrollToBottom} />
}
});

View File

@ -14,6 +14,7 @@ import { Motion, spring } from 'react-motion';
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' },
publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }
});
@ -25,6 +26,8 @@ const ComposeForm = React.createClass({
suggestion_token: React.PropTypes.string,
suggestions: ImmutablePropTypes.list,
sensitive: React.PropTypes.bool,
spoiler: React.PropTypes.bool,
spoiler_text: React.PropTypes.string,
unlisted: React.PropTypes.bool,
private: React.PropTypes.bool,
fileDropDate: React.PropTypes.instanceOf(Date),
@ -32,6 +35,7 @@ const ComposeForm = React.createClass({
is_uploading: React.PropTypes.bool,
in_reply_to: ImmutablePropTypes.map,
media_count: React.PropTypes.number,
me: React.PropTypes.number,
onChange: React.PropTypes.func.isRequired,
onSubmit: React.PropTypes.func.isRequired,
onCancelReply: React.PropTypes.func.isRequired,
@ -39,6 +43,8 @@ const ComposeForm = React.createClass({
onFetchSuggestions: React.PropTypes.func.isRequired,
onSuggestionSelected: React.PropTypes.func.isRequired,
onChangeSensitivity: React.PropTypes.func.isRequired,
onChangeSpoilerness: React.PropTypes.func.isRequired,
onChangeSpoilerText: React.PropTypes.func.isRequired,
onChangeVisibility: React.PropTypes.func.isRequired,
onChangeListability: React.PropTypes.func.isRequired,
},
@ -49,7 +55,7 @@ const ComposeForm = React.createClass({
this.props.onChange(e.target.value);
},
handleKeyUp (e) {
handleKeyDown (e) {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.props.onSubmit();
}
@ -76,6 +82,15 @@ const ComposeForm = React.createClass({
this.props.onChangeSensitivity(e.target.checked);
},
handleChangeSpoilerness (e) {
this.props.onChangeSpoilerness(e.target.checked);
this.props.onChangeSpoilerText('');
},
handleChangeSpoilerText (e) {
this.props.onChangeSpoilerText(e.target.value);
},
handleChangeVisibility (e) {
this.props.onChangeVisibility(e.target.checked);
},
@ -85,7 +100,14 @@ const ComposeForm = React.createClass({
},
componentDidUpdate (prevProps) {
if (prevProps.in_reply_to !== this.props.in_reply_to) {
if ((prevProps.in_reply_to === null && this.props.in_reply_to !== null) || (prevProps.in_reply_to !== null && this.props.in_reply_to !== null && prevProps.in_reply_to.get('id') !== this.props.in_reply_to.get('id'))) {
// If replying to zero or one users, places the cursor at the end of the textbox.
// If replying to more than one user, selects any usernames past the first;
// this provides a convenient shortcut to drop everyone else from the conversation.
const selectionStart = this.props.text.search(/\s/) + 1;
const selectionEnd = this.props.text.length;
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
this.autosuggestTextarea.textarea.focus();
}
},
@ -103,8 +125,18 @@ const ComposeForm = React.createClass({
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
}
let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
return (
<div style={{ padding: '10px' }}>
<Motion defaultStyle={{ opacity: !this.props.spoiler ? 0 : 100, height: !this.props.spoiler ? 50 : 0 }} style={{ opacity: spring(!this.props.spoiler ? 0 : 100), height: spring(!this.props.spoiler ? 0 : 50) }}>
{({ opacity, height }) =>
<div className="spoiler-input" style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} type="text" className="spoiler-input__input" />
</div>
}
</Motion>
{replyArea}
<AutosuggestTextarea
@ -115,7 +147,7 @@ const ComposeForm = React.createClass({
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onKeyUp={this.handleKeyUp}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
@ -123,7 +155,7 @@ const ComposeForm = React.createClass({
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'right' }}><Button text={intl.formatMessage(messages.publish)} onClick={this.handleSubmit} disabled={disabled} /></div>
<div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div>
<div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div>
<UploadButtonContainer style={{ paddingTop: '4px' }} />
</div>
@ -132,7 +164,12 @@ const ComposeForm = React.createClass({
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
</label>
<Motion defaultStyle={{ opacity: this.props.private ? 0 : 100, height: this.props.private ? 39.5 : 0 }} style={{ opacity: spring(this.props.private ? 0 : 100), height: spring(this.props.private ? 0 : 39.5) }}>
<label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle' }}>
<Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} />
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide behind content warning' /></span>
</label>
<Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}>
{({ opacity, height }) =>
<label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
<Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} />

View File

@ -1,26 +1,75 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { Link } from 'react-router';
import { injectIntl, defineMessages } from 'react-intl';
const style = {
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
});
const outerStyle = {
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
overflowY: 'hidden'
};
const innerStyle = {
boxSizing: 'border-box',
background: '#454b5e',
padding: '0',
display: 'flex',
flexDirection: 'column',
overflowY: 'auto'
overflowY: 'auto',
flexGrow: '1'
};
const Drawer = React.createClass({
const tabStyle = {
display: 'block',
flex: '1 1 auto',
padding: '15px',
paddingBottom: '13px',
color: '#9baec8',
textDecoration: 'none',
textAlign: 'center',
fontSize: '16px',
borderBottom: '2px solid transparent'
};
mixins: [PureRenderMixin],
const tabActiveStyle = {
color: '#2b90d9',
borderBottom: '2px solid #2b90d9'
};
render () {
return (
<div className='drawer' style={style}>
{this.props.children}
const Drawer = ({ children, withHeader, intl }) => {
let header = '';
if (withHeader) {
header = (
<div className='drawer__header'>
<Link title={intl.formatMessage(messages.start)} style={tabStyle} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
<Link title={intl.formatMessage(messages.public)} style={tabStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
<a title={intl.formatMessage(messages.preferences)} style={tabStyle} href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
<a title={intl.formatMessage(messages.logout)} style={tabStyle} href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
</div>
);
}
});
return (
<div className='drawer' style={outerStyle}>
{header}
export default Drawer;
<div className='drawer__inner' style={innerStyle}>
{children}
</div>
</div>
);
};
Drawer.propTypes = {
withHeader: React.PropTypes.bool,
children: React.PropTypes.node,
intl: React.PropTypes.object
};
export default injectIntl(Drawer);

View File

@ -16,12 +16,12 @@ const NavigationBar = React.createClass({
render () {
return (
<div style={{ padding: '10px', display: 'flex', cursor: 'default' }}>
<div style={{ padding: '10px', display: 'flex', flexShrink: '0', cursor: 'default' }}>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Permalink>
<div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}>
<strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong>
<a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.settings' defaultMessage='Settings' /></a> · <Link to='/timelines/public' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.public_timeline' defaultMessage='Public timeline' /></Link> · <a href='/auth/sign_out' data-method='delete' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a>
<a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
</div>
</div>
);

View File

@ -38,7 +38,7 @@ const inputStyle = {
border: 'none',
padding: '10px',
paddingRight: '30px',
fontFamily: 'Roboto',
fontFamily: 'inherit',
background: '#282c37',
color: '#9baec8',
fontSize: '14px',

View File

@ -11,7 +11,9 @@ const UploadButton = React.createClass({
propTypes: {
disabled: React.PropTypes.bool,
onSelectFile: React.PropTypes.func.isRequired,
style: React.PropTypes.object
style: React.PropTypes.object,
resetFileKey: React.PropTypes.number,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
@ -31,12 +33,12 @@ const UploadButton = React.createClass({
},
render () {
const { intl } = this.props;
const { intl, resetFileKey, disabled } = this.props;
return (
<div style={this.props.style}>
<IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={this.props.disabled} onClick={this.handleClick} size={24} />
<input ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
<IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} />
<input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
</div>
);
}

View File

@ -12,15 +12,20 @@ const UploadForm = React.createClass({
propTypes: {
media: ImmutablePropTypes.list.isRequired,
is_uploading: React.PropTypes.bool,
onRemoveFile: React.PropTypes.func.isRequired
onRemoveFile: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
render () {
const { intl } = this.props;
const { intl, media } = this.props;
const uploads = this.props.media.map(attachment => (
if (!media.size) {
return null;
}
const uploads = media.map(attachment => (
<div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'>
<div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
@ -29,7 +34,7 @@ const UploadForm = React.createClass({
));
return (
<div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden' }}>
<div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}>
{uploads}
</div>
);

View File

@ -8,6 +8,8 @@ import {
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSensitivity,
changeComposeSpoilerness,
changeComposeSpoilerText,
changeComposeVisibility,
changeComposeListability
} from '../../../actions/compose';
@ -22,13 +24,16 @@ const makeMapStateToProps = () => {
suggestion_token: state.getIn(['compose', 'suggestion_token']),
suggestions: state.getIn(['compose', 'suggestions']),
sensitive: state.getIn(['compose', 'sensitive']),
spoiler: state.getIn(['compose', 'spoiler']),
spoiler_text: state.getIn(['compose', 'spoiler_text']),
unlisted: state.getIn(['compose', 'unlisted']),
private: state.getIn(['compose', 'private']),
fileDropDate: state.getIn(['compose', 'fileDropDate']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
media_count: state.getIn(['compose', 'media_attachments']).size
media_count: state.getIn(['compose', 'media_attachments']).size,
me: state.getIn(['compose', 'me'])
};
};
@ -65,6 +70,14 @@ const mapDispatchToProps = function (dispatch) {
dispatch(changeComposeSensitivity(checked));
},
onChangeSpoilerness (checked) {
dispatch(changeComposeSpoilerness(checked));
},
onChangeSpoilerText (checked) {
dispatch(changeComposeSpoilerText(checked));
},
onChangeVisibility (checked) {
dispatch(changeComposeVisibility(checked));
},

View File

@ -1,8 +1,10 @@
import { connect } from 'react-redux';
import NavigationBar from '../components/navigation_bar';
const mapStateToProps = (state, props) => ({
account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
});
const mapStateToProps = (state, props) => {
return {
account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
};
};
export default connect(mapStateToProps)(NavigationBar);

View File

@ -4,6 +4,7 @@ import { uploadCompose } from '../../../actions/compose';
const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
resetFileKey: state.getIn(['compose', 'resetFileKey'])
});
const mapDispatchToProps = dispatch => ({

View File

@ -10,7 +10,8 @@ import { mountCompose, unmountCompose } from '../../actions/compose';
const Compose = React.createClass({
propTypes: {
dispatch: React.PropTypes.func.isRequired
dispatch: React.PropTypes.func.isRequired,
withHeader: React.PropTypes.bool
},
mixins: [PureRenderMixin],
@ -25,7 +26,7 @@ const Compose = React.createClass({
render () {
return (
<Drawer>
<Drawer withHeader={this.props.withHeader}>
<SearchContainer />
<NavigationContainer />
<ComposeFormContainer />

View File

@ -0,0 +1,63 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
import Column from '../ui/components/column';
import StatusList from '../../components/status_list';
import ColumnBackButton from '../public_timeline/components/column_back_button';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
heading: { id: 'column.favourites', defaultMessage: 'Favourites' }
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
me: state.getIn(['meta', 'me'])
});
const Favourites = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
loaded: React.PropTypes.bool,
intl: React.PropTypes.object.isRequired,
me: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchFavouritedStatuses());
},
handleScrollToBottom () {
this.props.dispatch(expandFavouritedStatuses());
},
render () {
const { statusIds, loaded, intl, me } = this.props;
if (!loaded) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column icon='star' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButton />
<StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
</Column>
);
}
});
export default connect(mapStateToProps)(injectIntl(Favourites));

View File

@ -0,0 +1,10 @@
import Column from '../ui/components/column';
import MissingIndicator from '../../components/missing_indicator';
const GenericNotFound = () => (
<Column>
<MissingIndicator />
</Column>
);
export default GenericNotFound;

View File

@ -8,25 +8,16 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
const messages = defineMessages({
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
settings: { id: 'navigation_bar.settings', defaultMessage: 'Settings' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }
});
const mapStateToProps = state => ({
me: state.getIn(['accounts', state.getIn(['meta', 'me'])])
});
const hamburgerStyle = {
background: '#373b4a',
color: '#fff',
fontSize: '16px',
padding: '15px',
position: 'absolute',
right: '0',
top: '-48px',
cursor: 'default'
};
const GettingStarted = ({ intl, me }) => {
let followRequests = '';
@ -37,19 +28,21 @@ const GettingStarted = ({ intl, me }) => {
return (
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)}>
<div style={{ position: 'relative' }}>
<div style={hamburgerStyle}><i className='fa fa-bars' /></div>
<ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />
<ColumnLink icon='cog' text={intl.formatMessage(messages.settings)} href='/settings/profile' />
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
<ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
{followRequests}
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
</div>
<div className='static-content'>
<p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
<p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
<p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p>
<div className='scrollable optionally-scrollable'>
<div className='static-content getting-started'>
<p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
<p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
<p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p>
<p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a style={{ color: '#616b86'}} href="https://github.com/tootsuite/mastodon">tootsuite/mastodon</a> }} /></p>
</div>
</div>
<div className='getting-started__illustration' />
</Column>
);
};

View File

@ -0,0 +1,68 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnCollapsable from '../../../components/column_collapsable';
import SettingToggle from '../../notifications/components/setting_toggle';
import SettingText from './setting_text';
const messages = defineMessages({
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' }
});
const outerStyle = {
background: '#373b4a',
padding: '15px'
};
const sectionStyle = {
cursor: 'default',
display: 'block',
fontWeight: '500',
color: '#9baec8',
marginBottom: '10px'
};
const rowStyle = {
};
const ColumnSettings = React.createClass({
propTypes: {
settings: ImmutablePropTypes.map.isRequired,
onChange: React.PropTypes.func.isRequired,
onSave: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
render () {
const { settings, onChange, onSave, intl } = this.props;
return (
<ColumnCollapsable icon='sliders' fullHeight={209} onCollapse={onSave}>
<div style={outerStyle}>
<span style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} />
</div>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div>
<span style={sectionStyle}><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
<div style={rowStyle}>
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
</div>
</div>
</ColumnCollapsable>
);
}
});
export default injectIntl(ColumnSettings);

View File

@ -0,0 +1,41 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
const style = {
display: 'block',
fontFamily: 'inherit',
marginBottom: '10px',
padding: '7px 0',
boxSizing: 'border-box',
width: '100%'
};
const SettingText = React.createClass({
propTypes: {
settings: ImmutablePropTypes.map.isRequired,
settingKey: React.PropTypes.array.isRequired,
label: React.PropTypes.string.isRequired,
onChange: React.PropTypes.func.isRequired
},
handleChange (e) {
this.props.onChange(this.props.settingKey, e.target.value)
},
render () {
const { settings, settingKey, label } = this.props;
return (
<input
style={style}
className='setting-text'
value={settings.getIn(settingKey)}
onChange={this.handleChange}
placeholder={label}
/>
);
}
});
export default SettingText;

View File

@ -0,0 +1,21 @@
import { connect } from 'react-redux';
import ColumnSettings from '../components/column_settings';
import { changeSetting, saveSettings } from '../../../actions/settings';
const mapStateToProps = state => ({
settings: state.getIn(['settings', 'home'])
});
const mapDispatchToProps = dispatch => ({
onChange (key, checked) {
dispatch(changeSetting(['home', ...key], checked));
},
onSave () {
dispatch(saveSettings());
}
});
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View File

@ -1,9 +1,8 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../ui/components/column';
import { refreshTimeline } from '../../actions/timelines';
import { defineMessages, injectIntl } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' }
@ -12,20 +11,17 @@ const messages = defineMessages({
const HomeTimeline = React.createClass({
propTypes: {
dispatch: React.PropTypes.func.isRequired
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(refreshTimeline('home'));
},
render () {
const { intl } = this.props;
return (
<Column icon='home' heading={intl.formatMessage(messages.title)}>
<ColumnSettingsContainer />
<StatusListContainer {...this.props} type='home' />
</Column>
);
@ -33,4 +29,4 @@ const HomeTimeline = React.createClass({
});
export default connect()(injectIntl(HomeTimeline));
export default injectIntl(HomeTimeline);

View File

@ -1,37 +1,14 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Toggle from 'react-toggle';
import { Motion, spring } from 'react-motion';
import { FormattedMessage } from 'react-intl';
import ColumnCollapsable from '../../../components/column_collapsable';
import SettingToggle from './setting_toggle';
const outerStyle = {
background: '#373b4a',
padding: '15px'
};
const iconStyle = {
fontSize: '16px',
padding: '15px',
position: 'absolute',
right: '0',
top: '-48px',
cursor: 'pointer'
};
const labelStyle = {
display: 'block',
lineHeight: '24px',
verticalAlign: 'middle'
};
const labelSpanStyle = {
display: 'inline-block',
verticalAlign: 'middle',
marginBottom: '14px',
marginLeft: '8px',
color: '#9baec8'
};
const sectionStyle = {
cursor: 'default',
display: 'block',
@ -48,100 +25,55 @@ const ColumnSettings = React.createClass({
propTypes: {
settings: ImmutablePropTypes.map.isRequired,
onChange: React.PropTypes.func.isRequired
},
getInitialState () {
return {
collapsed: true
};
onChange: React.PropTypes.func.isRequired,
onSave: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
handleToggleCollapsed () {
this.setState({ collapsed: !this.state.collapsed });
},
handleChange (key, e) {
this.props.onChange(key, e.target.checked);
},
render () {
const { settings } = this.props;
const { collapsed } = this.state;
const { settings, onChange, onSave } = this.props;
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
return (
<div style={{ position: 'relative' }}>
<div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className='fa fa-sliders' /></div>
<ColumnCollapsable icon='sliders' fullHeight={616} onCollapse={onSave}>
<div style={outerStyle}>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : 458) }}>
{({ opacity, height }) =>
<div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}>
<div style={outerStyle}>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
<SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
<SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
</div>
<div style={rowStyle}>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['alerts', 'follow'])} onChange={this.handleChange.bind(this, ['alerts', 'follow'])} />
<span style={labelSpanStyle}>{alertStr}</span>
</label>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['shows', 'follow'])} onChange={this.handleChange.bind(this, ['shows', 'follow'])} />
<span style={labelSpanStyle}>{showStr}</span>
</label>
</div>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
<SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
<SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
</div>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<div style={rowStyle}>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['alerts', 'favourite'])} onChange={this.handleChange.bind(this, ['alerts', 'favourite'])} />
<span style={labelSpanStyle}>{alertStr}</span>
</label>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
<SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
<SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
</div>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['shows', 'favourite'])} onChange={this.handleChange.bind(this, ['shows', 'favourite'])} />
<span style={labelSpanStyle}>{showStr}</span>
</label>
</div>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<div style={rowStyle}>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['alerts', 'mention'])} onChange={this.handleChange.bind(this, ['alerts', 'mention'])} />
<span style={labelSpanStyle}>{alertStr}</span>
</label>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['shows', 'mention'])} onChange={this.handleChange.bind(this, ['shows', 'mention'])} />
<span style={labelSpanStyle}>{showStr}</span>
</label>
</div>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
<div style={rowStyle}>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['alerts', 'reblog'])} onChange={this.handleChange.bind(this, ['alerts', 'reblog'])} />
<span style={labelSpanStyle}>{alertStr}</span>
</label>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['shows', 'reblog'])} onChange={this.handleChange.bind(this, ['shows', 'reblog'])} />
<span style={labelSpanStyle}>{showStr}</span>
</label>
</div>
</div>
</div>
}
</Motion>
</div>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
<SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
</div>
</div>
</ColumnCollapsable>
);
}

View File

@ -4,6 +4,8 @@ import StatusContainer from '../../../containers/status_container';
import AccountContainer from '../../../containers/account_container';
import { FormattedMessage } from 'react-intl';
import Permalink from '../../../components/permalink';
import emojify from '../../../emoji';
import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
const messageStyle = {
marginLeft: '68px',
@ -71,7 +73,7 @@ const Notification = React.createClass({
<i className='fa fa-fw fa-retweet' style={{ color: '#2b90d9' }} />
</div>
<FormattedMessage id='notification.reblog' defaultMessage='{name} reblogged your status' values={{ name: link }} />
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
</div>
<StatusContainer id={notification.get('status')} muted={true} />
@ -83,7 +85,8 @@ const Notification = React.createClass({
const { notification } = this.props;
const account = notification.get('account');
const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
const link = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`}>{displayName}</Permalink>;
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
const link = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
switch(notification.get('type')) {
case 'follow':

View File

@ -0,0 +1,32 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import Toggle from 'react-toggle';
const labelStyle = {
display: 'block',
lineHeight: '24px',
verticalAlign: 'middle'
};
const labelSpanStyle = {
display: 'inline-block',
verticalAlign: 'middle',
marginBottom: '14px',
marginLeft: '8px',
color: '#9baec8'
};
const SettingToggle = ({ settings, settingKey, label, onChange }) => (
<label style={labelStyle}>
<Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} />
<span style={labelSpanStyle}>{label}</span>
</label>
);
SettingToggle.propTypes = {
settings: ImmutablePropTypes.map.isRequired,
settingKey: React.PropTypes.array.isRequired,
label: React.PropTypes.node.isRequired,
onChange: React.PropTypes.func.isRequired
};
export default SettingToggle;

View File

@ -1,15 +1,19 @@
import { connect } from 'react-redux';
import ColumnSettings from '../components/column_settings';
import { changeNotificationsSetting } from '../../../actions/notifications';
import { changeSetting, saveSettings } from '../../../actions/settings';
const mapStateToProps = state => ({
settings: state.getIn(['notifications', 'settings'])
settings: state.getIn(['settings', 'notifications'])
});
const mapDispatchToProps = dispatch => ({
onChange (key, checked) {
dispatch(changeNotificationsSetting(key, checked));
dispatch(changeSetting(['notifications', ...key], checked));
},
onSave () {
dispatch(saveSettings());
}
});

View File

@ -2,10 +2,7 @@ import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from '../ui/components/column';
import {
refreshNotifications,
expandNotifications
} from '../../actions/notifications';
import { expandNotifications } from '../../actions/notifications';
import NotificationContainer from './containers/notification_container';
import { ScrollContainer } from 'react-router-scroll';
import { defineMessages, injectIntl } from 'react-intl';
@ -18,12 +15,13 @@ const messages = defineMessages({
});
const getNotifications = createSelector([
state => Immutable.List(state.getIn(['notifications', 'settings', 'shows']).filter(item => !item).keys()),
state => Immutable.List(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
state => state.getIn(['notifications', 'items'])
], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
const mapStateToProps = state => ({
notifications: getNotifications(state)
notifications: getNotifications(state),
isLoading: state.getIn(['notifications', 'isLoading'], true)
});
const Notifications = React.createClass({
@ -32,7 +30,8 @@ const Notifications = React.createClass({
notifications: ImmutablePropTypes.list.isRequired,
dispatch: React.PropTypes.func.isRequired,
trackScroll: React.PropTypes.bool,
intl: React.PropTypes.object.isRequired
intl: React.PropTypes.object.isRequired,
isLoading: React.PropTypes.bool
},
getDefaultProps () {
@ -43,15 +42,11 @@ const Notifications = React.createClass({
mixins: [PureRenderMixin],
componentWillMount () {
const { dispatch } = this.props;
dispatch(refreshNotifications());
},
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
if (scrollTop === scrollHeight - clientHeight) {
if (250 > offset && !this.props.isLoading) {
this.props.dispatch(expandNotifications());
}
},
@ -70,6 +65,7 @@ const Notifications = React.createClass({
if (trackScroll) {
return (
<Column icon='bell' heading={intl.formatMessage(messages.title)}>
<ColumnSettingsContainer />
<ScrollContainer scrollKey='notifications'>
{scrollableArea}
</ScrollContainer>

View File

@ -61,8 +61,8 @@ const ActionBar = React.createClass({
<div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" /></div>
</div>
);
}

View File

@ -0,0 +1,100 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
const outerStyle = {
display: 'flex',
cursor: 'pointer',
fontSize: '14px',
border: '1px solid #363c4b',
borderRadius: '4px',
color: '#616b86',
marginTop: '14px',
textDecoration: 'none',
overflow: 'hidden'
};
const contentStyle = {
flex: '1 1 auto',
padding: '8px',
paddingLeft: '14px',
overflow: 'hidden'
};
const titleStyle = {
display: 'block',
fontWeight: '500',
marginBottom: '5px',
color: '#d9e1e8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
};
const descriptionStyle = {
color: '#d9e1e8'
};
const imageOuterStyle = {
flex: '0 0 100px',
background: '#373b4a'
};
const imageStyle = {
display: 'block',
width: '100%',
height: 'auto',
margin: '0',
borderRadius: '4px 0 0 4px'
};
const hostStyle = {
display: 'block',
marginTop: '5px',
fontSize: '13px'
};
const getHostname = url => {
const parser = document.createElement('a');
parser.href = url;
return parser.hostname;
};
const Card = React.createClass({
propTypes: {
card: ImmutablePropTypes.map
},
mixins: [PureRenderMixin],
render () {
const { card } = this.props;
if (card === null) {
return null;
}
let image = '';
if (card.get('image')) {
image = (
<div style={imageOuterStyle}>
<img src={card.get('image')} alt={card.get('title')} style={imageStyle} />
</div>
);
}
return (
<a style={outerStyle} href={card.get('url')} className='status-card'>
{image}
<div style={contentStyle}>
<strong style={titleStyle} title={card.get('title')}>{card.get('title')}</strong>
<p style={descriptionStyle}>{card.get('description').substring(0, 50)}</p>
<span style={hostStyle}>{getHostname(card.get('url'))}</span>
</div>
</a>
);
}
});
export default Card;

View File

@ -7,6 +7,7 @@ import MediaGallery from '../../../components/media_gallery';
import VideoPlayer from '../../../components/video_player';
import { Link } from 'react-router';
import { FormattedDate, FormattedNumber } from 'react-intl';
import CardContainer from '../containers/card_container';
const DetailedStatus = React.createClass({
@ -32,7 +33,9 @@ const DetailedStatus = React.createClass({
render () {
const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
let media = '';
let media = '';
let applicationLink = '';
if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
@ -40,6 +43,12 @@ const DetailedStatus = React.createClass({
} else {
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
}
} else {
media = <CardContainer statusId={status.get('id')} />;
}
if (status.get('application')) {
applicationLink = <span> · <a className='detailed-status__application' style={{ color: 'inherit' }} href={status.getIn(['application', 'website'])} target='_blank' rel='nooopener'>{status.getIn(['application', 'name'])}</a></span>;
}
return (
@ -54,7 +63,7 @@ const DetailedStatus = React.createClass({
{media}
<div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
<a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a> · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link>
<a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link>
</div>
</div>
);

View File

@ -0,0 +1,8 @@
import { connect } from 'react-redux';
import Card from '../components/card';
const mapStateToProps = (state, { statusId }) => ({
card: state.getIn(['cards', statusId], null)
});
export default connect(mapStateToProps)(Card);

View File

@ -23,6 +23,7 @@ import { ScrollContainer } from 'react-router-scroll';
import ColumnBackButton from '../../components/column_back_button';
import StatusContainer from '../../containers/status_container';
import { openMedia } from '../../actions/modal';
import { isMobile } from '../../is_mobile'
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
@ -47,7 +48,8 @@ const Status = React.createClass({
dispatch: React.PropTypes.func.isRequired,
status: ImmutablePropTypes.map,
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list
descendantsIds: ImmutablePropTypes.list,
me: React.PropTypes.number
},
mixins: [PureRenderMixin],
@ -80,6 +82,10 @@ const Status = React.createClass({
handleMentionClick (account) {
this.props.dispatch(mentionCompose(account));
if (isMobile(window.innerWidth)) {
this.context.router.push('/statuses/new');
}
},
handleOpenMedia (url) {

View File

@ -13,10 +13,10 @@ const iconStyle = {
marginRight: '5px'
};
const ColumnLink = ({ icon, text, to, href }) => {
const ColumnLink = ({ icon, text, to, href, method }) => {
if (href) {
return (
<a href={href} style={outerStyle} className='column-link'>
<a href={href} style={outerStyle} className='column-link' data-method={method}>
<i className={`fa fa-fw fa-${icon}`} style={iconStyle} />
{text}
</a>

View File

@ -3,15 +3,14 @@ import { FormattedMessage } from 'react-intl';
const outerStyle = {
background: '#373b4a',
margin: '10px',
flex: '0 0 auto',
marginBottom: '0'
overflowY: 'auto'
};
const tabStyle = {
display: 'block',
flex: '1 1 auto',
padding: '10px',
padding: '10px 5px',
color: '#fff',
textDecoration: 'none',
textAlign: 'center',
@ -31,7 +30,7 @@ const TabsBar = () => {
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /> <FormattedMessage id='tabs_bar.public' defaultMessage='Public' /></Link>
<Link style={{ ...tabStyle, flexGrow: '0', flexBasis: '30px' }} activeStyle={tabActiveStyle} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
</div>
);
};

View File

@ -1,6 +1,9 @@
import { connect } from 'react-redux';
import { closeModal } from '../../../actions/modal';
import Lightbox from '../../../components/lightbox';
import { connect } from 'react-redux';
import { closeModal } from '../../../actions/modal';
import Lightbox from '../../../components/lightbox';
import ImageLoader from 'react-imageloader';
import LoadingIndicator from '../../../components/loading_indicator';
import PureRenderMixin from 'react-addons-pure-render-mixin';
const mapStateToProps = state => ({
url: state.getIn(['modal', 'url']),
@ -23,6 +26,18 @@ const imageStyle = {
maxHeight: '80vh'
};
const loadingStyle = {
background: '#373b4a',
width: '400px',
paddingBottom: '120px'
};
const preloader = () => (
<div style={loadingStyle}>
<LoadingIndicator />
</div>
);
const Modal = React.createClass({
propTypes: {
@ -32,12 +47,18 @@ const Modal = React.createClass({
onOverlayClicked: React.PropTypes.func
},
mixins: [PureRenderMixin],
render () {
const { url, ...other } = this.props;
return (
<Lightbox {...other}>
<img src={url} style={imageStyle} />
<ImageLoader
src={url}
preloader={preloader}
imgProps={{ style: imageStyle }}
/>
</Lightbox>
);
}

View File

@ -2,26 +2,56 @@ import { connect } from 'react-redux';
import StatusList from '../../../components/status_list';
import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines';
import Immutable from 'immutable';
import { createSelector } from 'reselect';
const getStatusIds = createSelector([
(state, { type }) => state.getIn(['settings', type], Immutable.Map()),
(state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()),
(state) => state.get('statuses')
], (columnSettings, statusIds, statuses) => statusIds.filter(id => {
const statusForId = statuses.get(id);
let showStatus = true;
if (columnSettings.getIn(['shows', 'reblog']) === false) {
showStatus = showStatus && statusForId.get('reblog') === null;
}
if (columnSettings.getIn(['shows', 'reply']) === false) {
showStatus = showStatus && statusForId.get('in_reply_to_id') === null;
}
if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) {
try {
const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i');
showStatus = showStatus && !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'content']) : statusForId.get('content'));
} catch(e) {
// Bad regex, don't affect filters
}
}
return showStatus;
}));
const mapStateToProps = (state, props) => ({
statusIds: state.getIn(['timelines', props.type, 'items'], Immutable.List())
statusIds: getStatusIds(state, props),
isLoading: state.getIn(['timelines', props.type, 'isLoading'], true)
});
const mapDispatchToProps = function (dispatch, props) {
return {
onScrollToBottom () {
dispatch(scrollTopTimeline(props.type, false));
dispatch(expandTimeline(props.type, props.id));
},
const mapDispatchToProps = (dispatch, { type, id }) => ({
onScrollToTop () {
dispatch(scrollTopTimeline(props.type, true));
},
onScrollToBottom () {
dispatch(scrollTopTimeline(type, false));
dispatch(expandTimeline(type, id));
},
onScroll () {
dispatch(scrollTopTimeline(props.type, false));
}
};
};
onScrollToTop () {
dispatch(scrollTopTimeline(type, true));
},
onScroll () {
dispatch(scrollTopTimeline(type, false));
}
});
export default connect(mapStateToProps, mapDispatchToProps)(StatusList);

View File

@ -8,12 +8,20 @@ import Compose from '../compose';
import TabsBar from './components/tabs_bar';
import ModalContainer from './containers/modal_container';
import Notifications from '../notifications';
import { connect } from 'react-redux';
import { isMobile } from '../../is_mobile';
import { debounce } from 'react-decoration';
import { uploadCompose } from '../../actions/compose';
import { connect } from 'react-redux';
import { refreshTimeline } from '../../actions/timelines';
import { refreshNotifications } from '../../actions/notifications';
const UI = React.createClass({
propTypes: {
dispatch: React.PropTypes.func.isRequired,
children: React.PropTypes.node
},
getInitialState () {
return {
width: window.innerWidth
@ -41,7 +49,7 @@ const UI = React.createClass({
handleDrop (e) {
e.preventDefault();
if (e.dataTransfer) {
if (e.dataTransfer && e.dataTransfer.files.length === 1) {
this.props.dispatch(uploadCompose(e.dataTransfer.files));
}
},
@ -50,6 +58,9 @@ const UI = React.createClass({
window.addEventListener('resize', this.handleResize, { passive: true });
window.addEventListener('dragover', this.handleDragOver);
window.addEventListener('drop', this.handleDrop);
this.props.dispatch(refreshTimeline('home'));
this.props.dispatch(refreshNotifications());
},
componentWillUnmount () {
@ -59,11 +70,9 @@ const UI = React.createClass({
},
render () {
const layoutBreakpoint = 1024;
let mountedColumns;
if (this.state.width <= layoutBreakpoint) {
if (isMobile(this.state.width)) {
mountedColumns = (
<ColumnsArea>
{this.props.children}
@ -72,7 +81,7 @@ const UI = React.createClass({
} else {
mountedColumns = (
<ColumnsArea>
<Compose />
<Compose withHeader={true} />
<HomeTimeline trackScroll={false} />
<Notifications trackScroll={false} />
{this.props.children}

View File

@ -0,0 +1,5 @@
const LAYOUT_BREAKPOINT = 1024;
export function isMobile(width) {
return width <= LAYOUT_BREAKPOINT;
};

View File

@ -8,6 +8,9 @@ const en = {
"status.reblog": "Teilen",
"status.favourite": "Favorisieren",
"status.reblogged_by": "{name} teilte",
"status.sensitive_warning": "Sensible Inhalte",
"status.sensitive_toggle": "Klicken um zu zeigen",
"status.open": "Öffnen",
"video_player.toggle_sound": "Ton umschalten",
"account.mention": "Erwähnen",
"account.edit_profile": "Profil bearbeiten",
@ -19,14 +22,17 @@ const en = {
"account.follows": "Folgt",
"account.followers": "Folger",
"account.follows_you": "Folgt dir",
"account.requested": "Warte auf Erlaubnis",
"getting_started.heading": "Erste Schritte",
"getting_started.about_addressing": "Du kannst Leuten folgen, falls du ihren Nutzernamen und ihre Domain kennst, in dem du eine e-mail-artige Addresse in das Suchfeld oben an der Seite eingibst.",
"getting_started.about_shortcuts": "Falls der Zielnutzer an derselben Domain ist wie du, funktioniert der Nutzername auch alleine. Das gilt auch für Erwähnungen in Beiträgen.",
"getting_started.about_developer": "Der Entwickler des Projekts kann unter Gargron@mastodon.social gefunden werden",
"getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
"column.home": "Home",
"column.mentions": "Erwähnungen",
"column.public": "Gesamtes Bekanntes Netz",
"column.notifications": "Mitteilungen",
"column.follow_requests": "Folgeanfragen",
"tabs_bar.compose": "Schreiben",
"tabs_bar.home": "Home",
"tabs_bar.mentions": "Erwähnungen",
@ -36,9 +42,12 @@ const en = {
"compose_form.publish": "Veröffentlichen",
"compose_form.sensitive": "Medien als sensitiv markieren",
"compose_form.unlisted": "Öffentlich nicht auflisten",
"navigation_bar.settings": "Einstellungen",
"compose_form.private": "Als privat markieren",
"navigation_bar.edit_profile": "Profil bearbeiten",
"navigation_bar.preferences": "Einstellungen",
"navigation_bar.public_timeline": "Öffentlich",
"navigation_bar.logout": "Abmelden",
"navigation_bar.follow_requests": "Folgeanfragen",
"reply_indicator.cancel": "Abbrechen",
"search.placeholder": "Suche",
"search.account": "Konto",
@ -48,7 +57,21 @@ const en = {
"notification.follow": "{name} folgt dir",
"notification.favourite": "{name} favorisierte deinen Status",
"notification.reblog": "{name} teilte deinen Status",
"notification.mention": "{name} erwähnte dich"
"notification.mention": "{name} erwähnte dich",
"notifications.column_settings.alert": "Desktop-Benachrichtigunen",
"notifications.column_settings.show": "In der Spalte anzeigen",
"notifications.column_settings.follow": "Neue Folger:",
"notifications.column_settings.favourite": "Favorisierungen:",
"notifications.column_settings.mention": "Erwähnungen:",
"notifications.column_settings.reblog": "Geteilte Beiträge:",
"follow_request.authorize": "Erlauben",
"follow_request.reject": "Ablehnen",
"home.column_settings.basic": "Einfach",
"home.column_settings.advanced": "Fortgeschritten",
"home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
"home.column_settings.show_replies": "Antworten anzeigen",
"home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke",
"missing_indicator.label": "Nicht gefunden"
};
export default en;

View File

@ -17,7 +17,6 @@ const en = {
"account.unfollow": "Unfollow",
"account.block": "Block",
"account.follow": "Follow",
"account.block": "Block",
"account.posts": "Posts",
"account.follows": "Follows",
"account.followers": "Followers",
@ -27,6 +26,7 @@ const en = {
"getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.",
"getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.",
"getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social",
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.",
"column.home": "Home",
"column.mentions": "Mentions",
"column.public": "Public",
@ -40,7 +40,9 @@ const en = {
"compose_form.publish": "Toot",
"compose_form.sensitive": "Mark media as sensitive",
"compose_form.private": "Mark as private",
"navigation_bar.settings": "Settings",
"compose_form.unlisted": "Do not display in public timeline",
"navigation_bar.edit_profile": "Edit profile",
"navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Public timeline",
"navigation_bar.logout": "Logout",
"reply_indicator.cancel": "Cancel",

View File

@ -37,7 +37,8 @@ const es = {
"compose_form.publish": "Publicar",
"compose_form.sensitive": "Marcar el contenido como sensible",
"compose_form.unlisted": "Privado",
"navigation_bar.settings": "Ajustes",
"navigation_bar.edit_profile": "Editar perfil",
"navigation_bar.preferences": "Preferencias",
"navigation_bar.public_timeline": "Público",
"navigation_bar.logout": "Cerrar sesión",
"reply_indicator.cancel": "Cancelar",

View File

@ -38,7 +38,8 @@ const fr = {
"compose_form.publish": "Pouet",
"compose_form.sensitive": "Marquer le contenu comme délicat",
"compose_form.unlisted": "Ne pas apparaître dans le fil public",
"navigation_bar.settings": "Paramètres",
"navigation_bar.edit_profile": "Modifier le profil",
"navigation_bar.preferences": "Préférences",
"navigation_bar.public_timeline": "Public",
"navigation_bar.logout": "Déconnexion",
"reply_indicator.cancel": "Annuler",

View File

@ -38,7 +38,8 @@ const hu = {
"compose_form.publish": "Tülk!",
"compose_form.sensitive": "Tartalom érzékenynek jelölése",
"compose_form.unlisted": "Listázatlan mód",
"navigation_bar.settings": "Beállítások",
"navigation_bar.edit_profile": "Profil szerkesztése",
"navigation_bar.preferences": "Beállítások",
"navigation_bar.public_timeline": "Nyilvános időfolyam",
"navigation_bar.logout": "Kijelentkezés",
"reply_indicator.cancel": "Mégsem",

View File

@ -36,7 +36,8 @@ const pt = {
"compose_form.publish": "Publicar",
"compose_form.sensitive": "Marcar conteúdo como sensível",
"compose_form.unlisted": "Modo não-listado",
"navigation_bar.settings": "Configurações",
"navigation_bar.edit_profile": "Editar perfil",
"navigation_bar.preferences": "Preferências",
"navigation_bar.public_timeline": "Timeline Pública",
"navigation_bar.logout": "Logout",
"reply_indicator.cancel": "Cancelar",

View File

@ -38,7 +38,8 @@ const uk = {
"compose_form.publish": "Дмухнути",
"compose_form.sensitive": "Непристойний зміст",
"compose_form.unlisted": "Таємний режим",
"navigation_bar.settings": "Налаштування",
"navigation_bar.edit_profile": "Редагувати профіль",
"navigation_bar.preferences": "Налаштування",
"navigation_bar.public_timeline": "Публічна стіна",
"navigation_bar.logout": "Вийти",
"reply_indicator.cancel": "Відмінити",

View File

@ -23,7 +23,7 @@ export default function errorsMiddleware() {
dispatch(showAlert(title, message));
} else {
console.error(action.error);
dispatch(showAlert('Oops!', 'An unexpected error occurred. Inspect the console for more details'));
dispatch(showAlert('Oops!', 'An unexpected error occurred.'));
}
}
}

View File

@ -0,0 +1,25 @@
import { showLoading, hideLoading } from 'react-redux-loading-bar';
const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED'];
export default function loadingBarMiddleware(config = {}) {
const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
return ({ dispatch }) => next => (action) => {
if (action.type && !action.skipLoading) {
const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;
const isPending = new RegExp(`${PENDING}$`, 'g');
const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
const isRejected = new RegExp(`${REJECTED}$`, 'g');
if (action.type.match(isPending)) {
dispatch(showLoading());
} else if (action.type.match(isFulfilled) || action.type.match(isRejected)) {
dispatch(hideLoading());
}
}
return next(action);
};
};

View File

@ -1,5 +1,4 @@
import {
ACCOUNT_SET_SELF,
ACCOUNT_FETCH_SUCCESS,
FOLLOWERS_FETCH_SUCCESS,
FOLLOWERS_EXPAND_SUCCESS,
@ -7,7 +6,9 @@ import {
FOLLOWING_EXPAND_SUCCESS,
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
FOLLOW_REQUESTS_FETCH_SUCCESS
FOLLOW_REQUESTS_FETCH_SUCCESS,
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_SUCCESS
} from '../actions/accounts';
import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
import {
@ -33,6 +34,11 @@ import {
NOTIFICATIONS_REFRESH_SUCCESS,
NOTIFICATIONS_EXPAND_SUCCESS
} from '../actions/notifications';
import {
FAVOURITED_STATUSES_FETCH_SUCCESS,
FAVOURITED_STATUSES_EXPAND_SUCCESS
} from '../actions/favourites';
import { STORE_HYDRATE } from '../actions/store';
import Immutable from 'immutable';
const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account));
@ -67,38 +73,45 @@ const initialState = Immutable.Map();
export default function accounts(state = initialState, action) {
switch(action.type) {
case ACCOUNT_SET_SELF:
case ACCOUNT_FETCH_SUCCESS:
case NOTIFICATIONS_UPDATE:
return normalizeAccount(state, action.account);
case FOLLOWERS_FETCH_SUCCESS:
case FOLLOWERS_EXPAND_SUCCESS:
case FOLLOWING_FETCH_SUCCESS:
case FOLLOWING_EXPAND_SUCCESS:
case REBLOGS_FETCH_SUCCESS:
case FAVOURITES_FETCH_SUCCESS:
case COMPOSE_SUGGESTIONS_READY:
case SEARCH_SUGGESTIONS_READY:
case FOLLOW_REQUESTS_FETCH_SUCCESS:
return normalizeAccounts(state, action.accounts);
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:
return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
return normalizeAccountsFromStatuses(state, action.statuses);
case REBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNREBLOG_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeAccountFromStatus(state, action.response);
case TIMELINE_UPDATE:
case STATUS_FETCH_SUCCESS:
return normalizeAccountFromStatus(state, action.status);
default:
return state;
case STORE_HYDRATE:
return state.merge(action.state.get('accounts'));
case ACCOUNT_FETCH_SUCCESS:
case NOTIFICATIONS_UPDATE:
return normalizeAccount(state, action.account);
case FOLLOWERS_FETCH_SUCCESS:
case FOLLOWERS_EXPAND_SUCCESS:
case FOLLOWING_FETCH_SUCCESS:
case FOLLOWING_EXPAND_SUCCESS:
case REBLOGS_FETCH_SUCCESS:
case FAVOURITES_FETCH_SUCCESS:
case COMPOSE_SUGGESTIONS_READY:
case SEARCH_SUGGESTIONS_READY:
case FOLLOW_REQUESTS_FETCH_SUCCESS:
return normalizeAccounts(state, action.accounts);
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:
return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
case FAVOURITED_STATUSES_FETCH_SUCCESS:
case FAVOURITED_STATUSES_EXPAND_SUCCESS:
return normalizeAccountsFromStatuses(state, action.statuses);
case REBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNREBLOG_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeAccountFromStatus(state, action.response);
case TIMELINE_UPDATE:
case STATUS_FETCH_SUCCESS:
return normalizeAccountFromStatus(state, action.status);
case ACCOUNT_FOLLOW_SUCCESS:
return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
case ACCOUNT_UNFOLLOW_SUCCESS:
return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
default:
return state;
}
};

View File

@ -0,0 +1,14 @@
import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards';
import Immutable from 'immutable';
const initialState = Immutable.Map();
export default function cards(state = initialState, action) {
switch(action.type) {
case STATUS_CARD_FETCH_SUCCESS:
return state.set(action.id, Immutable.fromJS(action.card));
default:
return state;
}
};

View File

@ -17,16 +17,20 @@ import {
COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT,
COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LISTABILITY_CHANGE
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
import { ACCOUNT_SET_SELF } from '../actions/accounts';
import { STORE_HYDRATE } from '../actions/store';
import Immutable from 'immutable';
const initialState = Immutable.Map({
mounted: false,
sensitive: false,
spoiler: false,
spoiler_text: '',
unlisted: false,
private: false,
text: '',
@ -38,7 +42,8 @@ const initialState = Immutable.Map({
media_attachments: Immutable.List(),
suggestion_token: null,
suggestions: Immutable.List(),
me: null
me: null,
resetFileKey: Math.floor((Math.random() * 0x10000))
});
function statusToTextMentions(state, status) {
@ -55,6 +60,8 @@ function statusToTextMentions(state, status) {
function clearAll(state) {
return state.withMutations(map => {
map.set('text', '');
map.set('spoiler', false);
map.set('spoiler_text', '');
map.set('is_submitting', false);
map.set('in_reply_to', null);
map.update('media_attachments', list => list.clear());
@ -65,6 +72,7 @@ function appendMedia(state, media) {
return state.withMutations(map => {
map.update('media_attachments', list => list.push(media));
map.set('is_uploading', false);
map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
map.update('text', oldText => `${oldText} ${media.get('text_url')}`.trim());
});
};
@ -80,7 +88,7 @@ function removeMedia(state, mediaId) {
const insertSuggestion = (state, position, token, completion) => {
return state.withMutations(map => {
map.update('text', oldText => `${oldText.slice(0, position)}${completion}${oldText.slice(position + token.length)}`);
map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
map.set('suggestion_token', null);
map.update('suggestions', Immutable.List(), list => list.clear());
});
@ -88,64 +96,68 @@ const insertSuggestion = (state, position, token, completion) => {
export default function compose(state = initialState, action) {
switch(action.type) {
case COMPOSE_MOUNT:
return state.set('mounted', true);
case COMPOSE_UNMOUNT:
return state.set('mounted', false);
case COMPOSE_SENSITIVITY_CHANGE:
return state.set('sensitive', action.checked);
case COMPOSE_VISIBILITY_CHANGE:
return state.set('private', action.checked);
case COMPOSE_LISTABILITY_CHANGE:
return state.set('unlisted', action.checked);
case COMPOSE_CHANGE:
return state.set('text', action.text);
case COMPOSE_REPLY:
return state.withMutations(map => {
map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status));
});
case COMPOSE_REPLY_CANCEL:
return state.withMutations(map => {
map.set('in_reply_to', null);
map.set('text', '');
});
case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true);
case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state);
case COMPOSE_SUBMIT_FAIL:
return state.set('is_submitting', false);
case COMPOSE_UPLOAD_REQUEST:
return state.withMutations(map => {
map.set('is_uploading', true);
map.set('fileDropDate', new Date());
});
case COMPOSE_UPLOAD_SUCCESS:
return appendMedia(state, Immutable.fromJS(action.media));
case COMPOSE_UPLOAD_FAIL:
return state.set('is_uploading', false);
case COMPOSE_UPLOAD_UNDO:
return removeMedia(state, action.media_id);
case COMPOSE_UPLOAD_PROGRESS:
return state.set('progress', Math.round((action.loaded / action.total) * 100));
case COMPOSE_MENTION:
return state.update('text', text => `${text}@${action.account.get('acct')} `);
case COMPOSE_SUGGESTIONS_CLEAR:
return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
case COMPOSE_SUGGESTIONS_READY:
return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
case COMPOSE_SUGGESTION_SELECT:
return insertSuggestion(state, action.position, action.token, action.completion);
case TIMELINE_DELETE:
if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null);
} else {
return state;
}
case ACCOUNT_SET_SELF:
return state.set('me', action.account.id).set('private', action.account.locked);
default:
case STORE_HYDRATE:
return state.merge(action.state.get('compose'));
case COMPOSE_MOUNT:
return state.set('mounted', true);
case COMPOSE_UNMOUNT:
return state.set('mounted', false);
case COMPOSE_SENSITIVITY_CHANGE:
return state.set('sensitive', action.checked);
case COMPOSE_SPOILERNESS_CHANGE:
return (action.checked ? state : state.set('spoiler_text', '')).set('spoiler', action.checked);
case COMPOSE_SPOILER_TEXT_CHANGE:
return state.set('spoiler_text', action.text);
case COMPOSE_VISIBILITY_CHANGE:
return state.set('private', action.checked);
case COMPOSE_LISTABILITY_CHANGE:
return state.set('unlisted', action.checked);
case COMPOSE_CHANGE:
return state.set('text', action.text);
case COMPOSE_REPLY:
return state.withMutations(map => {
map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status));
});
case COMPOSE_REPLY_CANCEL:
return state.withMutations(map => {
map.set('in_reply_to', null);
map.set('text', '');
});
case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true);
case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state);
case COMPOSE_SUBMIT_FAIL:
return state.set('is_submitting', false);
case COMPOSE_UPLOAD_REQUEST:
return state.withMutations(map => {
map.set('is_uploading', true);
map.set('fileDropDate', new Date());
});
case COMPOSE_UPLOAD_SUCCESS:
return appendMedia(state, Immutable.fromJS(action.media));
case COMPOSE_UPLOAD_FAIL:
return state.set('is_uploading', false);
case COMPOSE_UPLOAD_UNDO:
return removeMedia(state, action.media_id);
case COMPOSE_UPLOAD_PROGRESS:
return state.set('progress', Math.round((action.loaded / action.total) * 100));
case COMPOSE_MENTION:
return state.update('text', text => `${text}@${action.account.get('acct')} `);
case COMPOSE_SUGGESTIONS_CLEAR:
return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
case COMPOSE_SUGGESTIONS_READY:
return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
case COMPOSE_SUGGESTION_SELECT:
return insertSuggestion(state, action.position, action.token, action.completion);
case TIMELINE_DELETE:
if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null);
} else {
return state;
}
default:
return state;
}
};

View File

@ -11,6 +11,9 @@ import statuses from './statuses';
import relationships from './relationships';
import search from './search';
import notifications from './notifications';
import settings from './settings';
import status_lists from './status_lists';
import cards from './cards';
export default combineReducers({
timelines,
@ -20,9 +23,12 @@ export default combineReducers({
loadingBar: loadingBarReducer,
modal,
user_lists,
status_lists,
accounts,
statuses,
relationships,
search,
notifications
notifications,
settings,
cards
});

View File

@ -1,16 +1,16 @@
import { ACCESS_TOKEN_SET } from '../actions/meta';
import { ACCOUNT_SET_SELF } from '../actions/accounts';
import { STORE_HYDRATE } from '../actions/store';
import Immutable from 'immutable';
const initialState = Immutable.Map();
const initialState = Immutable.Map({
access_token: null,
me: null
});
export default function meta(state = initialState, action) {
switch(action.type) {
case ACCESS_TOKEN_SET:
return state.set('access_token', action.token);
case ACCOUNT_SET_SELF:
return state.set('me', action.account.id);
default:
return state;
case STORE_HYDRATE:
return state.merge(action.state.get('meta'));
default:
return state;
}
};

View File

@ -8,14 +8,14 @@ const initialState = Immutable.Map({
export default function modal(state = initialState, action) {
switch(action.type) {
case MEDIA_OPEN:
return state.withMutations(map => {
map.set('url', action.url);
map.set('open', true);
});
case MODAL_CLOSE:
return state.set('open', false);
default:
return state;
case MEDIA_OPEN:
return state.withMutations(map => {
map.set('url', action.url);
map.set('open', true);
});
case MODAL_CLOSE:
return state.set('open', false);
default:
return state;
}
};

View File

@ -2,7 +2,10 @@ import {
NOTIFICATIONS_UPDATE,
NOTIFICATIONS_REFRESH_SUCCESS,
NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_SETTING_CHANGE
NOTIFICATIONS_REFRESH_REQUEST,
NOTIFICATIONS_EXPAND_REQUEST,
NOTIFICATIONS_REFRESH_FAIL,
NOTIFICATIONS_EXPAND_FAIL
} from '../actions/notifications';
import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
import Immutable from 'immutable';
@ -11,22 +14,7 @@ const initialState = Immutable.Map({
items: Immutable.List(),
next: null,
loaded: false,
settings: Immutable.Map({
alerts: Immutable.Map({
follow: true,
favourite: true,
reblog: true,
mention: true
}),
shows: Immutable.Map({
follow: true,
favourite: true,
reblog: true,
mention: true
})
})
isLoading: true
});
const notificationToMap = notification => Immutable.Map({
@ -48,7 +36,11 @@ const normalizeNotifications = (state, notifications, next) => {
items = items.set(i, notificationToMap(n));
});
return state.update('items', list => loaded ? list.unshift(...items) : list.push(...items)).set('next', next).set('loaded', true);
return state
.update('items', list => loaded ? list.unshift(...items) : list.push(...items))
.set('next', next)
.set('loaded', true)
.set('isLoading', false);
};
const appendNormalizedNotifications = (state, notifications, next) => {
@ -58,7 +50,10 @@ const appendNormalizedNotifications = (state, notifications, next) => {
items = items.set(i, notificationToMap(n));
});
return state.update('items', list => list.push(...items)).set('next', next);
return state
.update('items', list => list.push(...items))
.set('next', next)
.set('isLoading', false);
};
const filterNotifications = (state, relationship) => {
@ -67,17 +62,20 @@ const filterNotifications = (state, relationship) => {
export default function notifications(state = initialState, action) {
switch(action.type) {
case NOTIFICATIONS_UPDATE:
return normalizeNotification(state, action.notification);
case NOTIFICATIONS_REFRESH_SUCCESS:
return normalizeNotifications(state, action.notifications, action.next);
case NOTIFICATIONS_EXPAND_SUCCESS:
return appendNormalizedNotifications(state, action.notifications, action.next);
case ACCOUNT_BLOCK_SUCCESS:
return filterNotifications(state, action.relationship);
case NOTIFICATIONS_SETTING_CHANGE:
return state.setIn(['settings', ...action.key], action.checked);
default:
return state;
case NOTIFICATIONS_REFRESH_REQUEST:
case NOTIFICATIONS_EXPAND_REQUEST:
case NOTIFICATIONS_REFRESH_FAIL:
case NOTIFICATIONS_EXPAND_FAIL:
return state.set('isLoading', true);
case NOTIFICATIONS_UPDATE:
return normalizeNotification(state, action.notification);
case NOTIFICATIONS_REFRESH_SUCCESS:
return normalizeNotifications(state, action.notifications, action.next);
case NOTIFICATIONS_EXPAND_SUCCESS:
return appendNormalizedNotifications(state, action.notifications, action.next);
case ACCOUNT_BLOCK_SUCCESS:
return filterNotifications(state, action.relationship);
default:
return state;
}
};

View File

@ -23,7 +23,7 @@ const normalizeSuggestions = (state, value, accounts) => {
}
];
if (value.indexOf('@') === -1) {
if (value.indexOf('@') === -1 && value.indexOf(' ') === -1) {
newSuggestions.push({
title: 'hashtag',
items: [

View File

@ -0,0 +1,46 @@
import { SETTING_CHANGE } from '../actions/settings';
import { STORE_HYDRATE } from '../actions/store';
import Immutable from 'immutable';
const initialState = Immutable.Map({
home: Immutable.Map({
shows: Immutable.Map({
reblog: true,
reply: true
})
}),
notifications: Immutable.Map({
alerts: Immutable.Map({
follow: true,
favourite: true,
reblog: true,
mention: true
}),
shows: Immutable.Map({
follow: true,
favourite: true,
reblog: true,
mention: true
}),
sounds: Immutable.Map({
follow: true,
favourite: true,
reblog: true,
mention: true
})
})
});
export default function settings(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
return state.mergeDeep(action.state.get('settings'));
case SETTING_CHANGE:
return state.setIn(action.key, action.value);
default:
return state;
}
};

View File

@ -0,0 +1,39 @@
import {
FAVOURITED_STATUSES_FETCH_SUCCESS,
FAVOURITED_STATUSES_EXPAND_SUCCESS
} from '../actions/favourites';
import Immutable from 'immutable';
const initialState = Immutable.Map({
favourites: Immutable.Map({
next: null,
loaded: false,
items: Immutable.List()
})
});
const normalizeList = (state, listType, statuses, next) => {
return state.update(listType, listMap => listMap.withMutations(map => {
map.set('next', next);
map.set('loaded', true);
map.set('items', Immutable.List(statuses.map(item => item.id)));
}));
};
const appendToList = (state, listType, statuses, next) => {
return state.update(listType, listMap => listMap.withMutations(map => {
map.set('next', next);
map.set('items', map.get('items').push(...statuses.map(item => item.id)));
}));
};
export default function statusLists(state = initialState, action) {
switch(action.type) {
case FAVOURITED_STATUSES_FETCH_SUCCESS:
return normalizeList(state, 'favourites', action.statuses, action.next);
case FAVOURITED_STATUSES_EXPAND_SUCCESS:
return appendToList(state, 'favourites', action.statuses, action.next);
default:
return state;
}
};

View File

@ -28,6 +28,10 @@ import {
NOTIFICATIONS_REFRESH_SUCCESS,
NOTIFICATIONS_EXPAND_SUCCESS
} from '../actions/notifications';
import {
FAVOURITED_STATUSES_FETCH_SUCCESS,
FAVOURITED_STATUSES_EXPAND_SUCCESS
} from '../actions/favourites';
import Immutable from 'immutable';
const normalizeStatus = (state, status) => {
@ -77,36 +81,38 @@ const initialState = Immutable.Map();
export default function statuses(state = initialState, action) {
switch(action.type) {
case TIMELINE_UPDATE:
case STATUS_FETCH_SUCCESS:
case NOTIFICATIONS_UPDATE:
return normalizeStatus(state, action.status);
case REBLOG_SUCCESS:
case UNREBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeStatus(state, action.response);
case FAVOURITE_REQUEST:
return state.setIn([action.status.get('id'), 'favourited'], true);
case FAVOURITE_FAIL:
return state.setIn([action.status.get('id'), 'favourited'], false);
case REBLOG_REQUEST:
return state.setIn([action.status.get('id'), 'reblogged'], true);
case REBLOG_FAIL:
return state.setIn([action.status.get('id'), 'reblogged'], false);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:
return normalizeStatuses(state, action.statuses);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case ACCOUNT_BLOCK_SUCCESS:
return filterStatuses(state, action.relationship);
default:
return state;
case TIMELINE_UPDATE:
case STATUS_FETCH_SUCCESS:
case NOTIFICATIONS_UPDATE:
return normalizeStatus(state, action.status);
case REBLOG_SUCCESS:
case UNREBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeStatus(state, action.response);
case FAVOURITE_REQUEST:
return state.setIn([action.status.get('id'), 'favourited'], true);
case FAVOURITE_FAIL:
return state.setIn([action.status.get('id'), 'favourited'], false);
case REBLOG_REQUEST:
return state.setIn([action.status.get('id'), 'reblogged'], true);
case REBLOG_FAIL:
return state.setIn([action.status.get('id'), 'reblogged'], false);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:
case FAVOURITED_STATUSES_FETCH_SUCCESS:
case FAVOURITED_STATUSES_EXPAND_SUCCESS:
return normalizeStatuses(state, action.statuses);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case ACCOUNT_BLOCK_SUCCESS:
return filterStatuses(state, action.relationship);
default:
return state;
}
};

View File

@ -1,9 +1,12 @@
import {
TIMELINE_REFRESH_REQUEST,
TIMELINE_REFRESH_SUCCESS,
TIMELINE_REFRESH_FAIL,
TIMELINE_UPDATE,
TIMELINE_DELETE,
TIMELINE_EXPAND_SUCCESS,
TIMELINE_EXPAND_REQUEST,
TIMELINE_EXPAND_FAIL,
TIMELINE_SCROLL_TOP
} from '../actions/timelines';
import {
@ -13,37 +16,43 @@ import {
UNFAVOURITE_SUCCESS
} from '../actions/interactions';
import {
ACCOUNT_FETCH_SUCCESS,
ACCOUNT_TIMELINE_FETCH_REQUEST,
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_FETCH_FAIL,
ACCOUNT_TIMELINE_EXPAND_REQUEST,
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_FAIL,
ACCOUNT_BLOCK_SUCCESS
} from '../actions/accounts';
import {
STATUS_FETCH_SUCCESS,
CONTEXT_FETCH_SUCCESS
} from '../actions/statuses';
import Immutable from 'immutable';
const initialState = Immutable.Map({
home: Immutable.Map({
isLoading: false,
loaded: false,
top: true,
items: Immutable.List()
}),
mentions: Immutable.Map({
isLoading: false,
loaded: false,
top: true,
items: Immutable.List()
}),
public: Immutable.Map({
isLoading: false,
loaded: false,
top: true,
items: Immutable.List()
}),
tag: Immutable.Map({
isLoading: false,
id: null,
loaded: false,
top: true,
@ -82,6 +91,7 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => {
});
state = state.setIn([timeline, 'loaded'], true);
state = state.setIn([timeline, 'isLoading'], false);
return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : ids));
};
@ -94,6 +104,8 @@ const appendNormalizedTimeline = (state, timeline, statuses) => {
moreIds = moreIds.set(i, status.get('id'));
});
state = state.setIn([timeline, 'isLoading'], false);
return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds));
};
@ -105,7 +117,10 @@ const normalizeAccountTimeline = (state, accountId, statuses, replace = false) =
ids = ids.set(i, status.get('id'));
});
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => (replace ? ids : list.unshift(...ids)));
return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
.set('isLoading', false)
.set('loaded', true)
.update('items', Immutable.List(), list => (replace ? ids : list.unshift(...ids))));
};
const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
@ -116,7 +131,9 @@ const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
moreIds = moreIds.set(i, status.get('id'));
});
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds));
return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
.set('isLoading', false)
.update('items', list => list.push(...moreIds)));
};
const updateTimeline = (state, timeline, status, references) => {
@ -145,14 +162,19 @@ const updateTimeline = (state, timeline, status, references) => {
return state;
};
const deleteStatus = (state, id, accountId, references) => {
const deleteStatus = (state, id, accountId, references, reblogOf) => {
if (reblogOf) {
// If we are deleting a reblog, just replace reblog with its original
return state.updateIn(['home', 'items'], list => list.map(item => item === id ? reblogOf : item));
}
// Remove references from timelines
['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
});
// Remove references from account timelines
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.filterNot(item => item === id));
state = state.updateIn(['accounts_timelines', accountId, 'items'], Immutable.List([]), list => list.filterNot(item => item === id));
// Remove references from context
state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => {
@ -202,8 +224,11 @@ const resetTimeline = (state, timeline, id) => {
if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) {
state = state.update(timeline, map => map
.set('id', id)
.set('isLoading', true)
.set('loaded', false)
.update('items', list => list.clear()));
} else {
state = state.setIn([timeline, 'isLoading'], true);
}
return state;
@ -211,27 +236,37 @@ const resetTimeline = (state, timeline, id) => {
export default function timelines(state = initialState, action) {
switch(action.type) {
case TIMELINE_REFRESH_REQUEST:
return resetTimeline(state, action.timeline, action.id);
case TIMELINE_REFRESH_SUCCESS:
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
case TIMELINE_EXPAND_SUCCESS:
return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.accountId, action.references);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
case ACCOUNT_BLOCK_SUCCESS:
return filterTimelines(state, action.relationship, action.statuses);
case TIMELINE_SCROLL_TOP:
return state.setIn([action.timeline, 'top'], action.top);
default:
return state;
case TIMELINE_REFRESH_REQUEST:
case TIMELINE_EXPAND_REQUEST:
return resetTimeline(state, action.timeline, action.id);
case TIMELINE_REFRESH_FAIL:
case TIMELINE_EXPAND_FAIL:
return state.setIn([action.timeline, 'isLoading'], false);
case TIMELINE_REFRESH_SUCCESS:
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
case TIMELINE_EXPAND_SUCCESS:
return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
case ACCOUNT_TIMELINE_FETCH_REQUEST:
case ACCOUNT_TIMELINE_EXPAND_REQUEST:
return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', true));
case ACCOUNT_TIMELINE_FETCH_FAIL:
case ACCOUNT_TIMELINE_EXPAND_FAIL:
return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false));
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
case ACCOUNT_BLOCK_SUCCESS:
return filterTimelines(state, action.relationship, action.statuses);
case TIMELINE_SCROLL_TOP:
return state.setIn([action.timeline, 'top'], action.top);
default:
return state;
}
};

Some files were not shown because too many files have changed in this diff Show More