commit
3d890c4073
1
.env.vagrant
Normal file
1
.env.vagrant
Normal file
@ -0,0 +1 @@
|
||||
VAGRANT=true
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -22,3 +22,6 @@ public/assets
|
||||
.env.production
|
||||
node_modules/
|
||||
neo4j/
|
||||
|
||||
# Ignore Vagrant files
|
||||
.vagrant/
|
||||
|
@ -87,3 +87,4 @@ AllCops:
|
||||
- 'bin/*'
|
||||
- 'Rakefile'
|
||||
- 'node_modules/**/*'
|
||||
- 'Vagrantfile'
|
||||
|
10
Gemfile
10
Gemfile
@ -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
|
||||
|
34
Gemfile.lock
34
Gemfile.lock
@ -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
2
Procfile
Normal file
@ -0,0 +1,2 @@
|
||||
web: bundle exec puma -C config/puma.rb
|
||||
worker: bundle exec sidekiq -q default -q mailers -q push
|
30
README.md
30
README.md
@ -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
109
Vagrantfile
vendored
Normal 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
91
app.json
Normal 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 |
BIN
app/assets/images/boost_sprite.png
Normal file
BIN
app/assets/images/boost_sprite.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 KiB |
@ -1,3 +1,8 @@
|
||||
//= require jquery
|
||||
//= require jquery_ujs
|
||||
//= require extras
|
||||
//= require best_in_place
|
||||
|
||||
$(function () {
|
||||
$(".best_in_place").best_in_place();
|
||||
});
|
||||
|
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
47
app/assets/javascripts/components/actions/cards.jsx
Normal file
47
app/assets/javascripts/components/actions/cards.jsx
Normal 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
|
||||
};
|
||||
};
|
@ -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,
|
||||
|
83
app/assets/javascripts/components/actions/favourites.jsx
Normal file
83
app/assets/javascripts/components/actions/favourites.jsx
Normal 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
|
||||
};
|
||||
};
|
@ -1,8 +0,0 @@
|
||||
export const ACCESS_TOKEN_SET = 'ACCESS_TOKEN_SET';
|
||||
|
||||
export function setAccessToken(token) {
|
||||
return {
|
||||
type: ACCESS_TOKEN_SET,
|
||||
token: token
|
||||
};
|
||||
};
|
@ -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
|
||||
};
|
||||
};
|
||||
|
19
app/assets/javascripts/components/actions/settings.jsx
Normal file
19
app/assets/javascripts/components/actions/settings.jsx
Normal 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()
|
||||
});
|
||||
};
|
||||
};
|
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
17
app/assets/javascripts/components/actions/store.jsx
Normal file
17
app/assets/javascripts/components/actions/store.jsx
Normal 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
|
||||
};
|
||||
};
|
@ -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));
|
||||
|
@ -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 (
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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;
|
@ -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') {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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;
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
@ -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} />;
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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')));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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) {
|
||||
|
@ -5,5 +5,5 @@ emojione.sprites = false;
|
||||
emojione.imagePathPNG = '/emoji/';
|
||||
|
||||
export default function emojify(text) {
|
||||
return emojione.unicodeToImage(text);
|
||||
return emojione.toImage(text);
|
||||
};
|
||||
|
@ -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}>
|
||||
|
@ -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}
|
||||
|
@ -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 () {
|
||||
|
@ -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} />
|
||||
}
|
||||
|
||||
});
|
||||
|
@ -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} />
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -38,7 +38,7 @@ const inputStyle = {
|
||||
border: 'none',
|
||||
padding: '10px',
|
||||
paddingRight: '30px',
|
||||
fontFamily: 'Roboto',
|
||||
fontFamily: 'inherit',
|
||||
background: '#282c37',
|
||||
color: '#9baec8',
|
||||
fontSize: '14px',
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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));
|
||||
},
|
||||
|
@ -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);
|
||||
|
@ -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 => ({
|
||||
|
@ -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 />
|
||||
|
@ -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));
|
@ -0,0 +1,10 @@
|
||||
import Column from '../ui/components/column';
|
||||
import MissingIndicator from '../../components/missing_indicator';
|
||||
|
||||
const GenericNotFound = () => (
|
||||
<Column>
|
||||
<MissingIndicator />
|
||||
</Column>
|
||||
);
|
||||
|
||||
export default GenericNotFound;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
@ -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;
|
@ -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);
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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':
|
||||
|
@ -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;
|
@ -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());
|
||||
}
|
||||
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
|
@ -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);
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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}
|
||||
|
5
app/assets/javascripts/components/is_mobile.jsx
Normal file
5
app/assets/javascripts/components/is_mobile.jsx
Normal file
@ -0,0 +1,5 @@
|
||||
const LAYOUT_BREAKPOINT = 1024;
|
||||
|
||||
export function isMobile(width) {
|
||||
return width <= LAYOUT_BREAKPOINT;
|
||||
};
|
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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": "Відмінити",
|
||||
|
@ -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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
25
app/assets/javascripts/components/middleware/loading_bar.jsx
Normal file
25
app/assets/javascripts/components/middleware/loading_bar.jsx
Normal 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);
|
||||
};
|
||||
};
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
14
app/assets/javascripts/components/reducers/cards.jsx
Normal file
14
app/assets/javascripts/components/reducers/cards.jsx
Normal 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;
|
||||
}
|
||||
};
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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: [
|
||||
|
46
app/assets/javascripts/components/reducers/settings.jsx
Normal file
46
app/assets/javascripts/components/reducers/settings.jsx
Normal 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;
|
||||
}
|
||||
};
|
39
app/assets/javascripts/components/reducers/status_lists.jsx
Normal file
39
app/assets/javascripts/components/reducers/status_lists.jsx
Normal 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;
|
||||
}
|
||||
};
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user