mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-15 16:59:41 +00:00
Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f88b8ce757 | ||
|
|
aea151a0de | ||
|
|
43df35213e | ||
|
|
6f7c9774c7 | ||
|
|
2e0a38d07c | ||
|
|
d5e086a47b | ||
|
|
7bb72ff198 | ||
|
|
b62c31306e | ||
|
|
f8b9b0810d | ||
|
|
225ce8cfce | ||
|
|
ea44d89383 | ||
|
|
dd02fc0ec4 | ||
|
|
f3e5625d2d | ||
|
|
fdd30af595 | ||
|
|
6611e3a2ef | ||
|
|
4baca34a45 | ||
|
|
564e01eaf6 | ||
|
|
c9a7e6e1e3 | ||
|
|
6c05e3063a | ||
|
|
a782e3b39d | ||
|
|
53deeeca01 | ||
|
|
e865673175 | ||
|
|
b5c6d00afa | ||
|
|
13ee88926d | ||
|
|
f0f791bb76 | ||
|
|
0895ff414e | ||
|
|
de1f3aab86 | ||
|
|
1de2833f30 | ||
|
|
b8eda3026f | ||
|
|
4470330385 | ||
|
|
f9c9fef157 | ||
|
|
07e56d52b1 | ||
|
|
6394baff4d | ||
|
|
2a22d4076e | ||
|
|
2993370de0 | ||
|
|
db4671fd3f | ||
|
|
6e0b3ddb0d | ||
|
|
df2c0b8dad | ||
|
|
04bfd4262f | ||
|
|
7075cef8f9 | ||
|
|
492a682e34 | ||
|
|
67b35a601a | ||
|
|
aa9d48343d | ||
|
|
edefd87adf | ||
|
|
70ab6624f5 | ||
|
|
4d336cefac | ||
|
|
20f581f796 | ||
|
|
e21a3fe0cd | ||
|
|
91144d46ec | ||
|
|
244d1307a3 | ||
|
|
a3384b6ea6 | ||
|
|
cc54683694 | ||
|
|
ab7cb80dd5 | ||
|
|
44856fb641 | ||
|
|
7a6d95f70c | ||
|
|
9b195f5dd3 | ||
|
|
33f7e1cf99 | ||
|
|
157f03f8bd | ||
|
|
36e7eeb6b9 | ||
|
|
64302b3c99 | ||
|
|
160b5148ec | ||
|
|
c257b29d86 | ||
|
|
f7f3e6e3be | ||
|
|
cbfc12044d | ||
|
|
b113cf97fb | ||
|
|
2ddf4e09f9 | ||
|
|
cdc49c6b4b | ||
|
|
5e511acb82 | ||
|
|
45776b55b0 | ||
|
|
e2ff39bf5d | ||
|
|
ffaba617d2 | ||
|
|
8d8ef18bb6 | ||
|
|
4eeb7947bd | ||
|
|
71e7537330 | ||
|
|
ecd4042c20 | ||
|
|
e0a4455622 | ||
|
|
998f161e1d | ||
|
|
1a1b9bbbc0 | ||
|
|
d7c55853e9 | ||
|
|
77efdfa110 | ||
|
|
451733961b | ||
|
|
68eed8c61f | ||
|
|
87b618ab02 | ||
|
|
f49ed8c819 | ||
|
|
38ce960ff9 | ||
|
|
cfba03bd27 | ||
|
|
81065bc06c | ||
|
|
3306a5d524 | ||
|
|
dd5e724c3f | ||
|
|
f249a8c187 | ||
|
|
65ae9637d6 | ||
|
|
aec51e40ee | ||
|
|
5f737c7228 | ||
|
|
0634e8dee5 | ||
|
|
29e79f770f | ||
|
|
427ba27641 | ||
|
|
769b1ebbe0 | ||
|
|
22a8801dbc | ||
|
|
52d7f862d3 | ||
|
|
35de03fbe3 | ||
|
|
f40843d680 | ||
|
|
d85df27053 | ||
|
|
bef4d8dab8 | ||
|
|
b0168c8f3c | ||
|
|
ef9d4f4e06 | ||
|
|
1f650d327d | ||
|
|
06016453bd | ||
|
|
943c69c65d | ||
|
|
a4b8069cf5 | ||
|
|
e8a8703a4b | ||
|
|
296ce2d45a | ||
|
|
2af3abd279 | ||
|
|
05af66d6b9 | ||
|
|
d772db4344 | ||
|
|
3554d638b3 | ||
|
|
87ba52ad3f | ||
|
|
15d01a5e08 | ||
|
|
b304cc07d5 | ||
|
|
b60430fe8f | ||
|
|
8bdbe99d69 | ||
|
|
68402228f3 | ||
|
|
5cfc9efad3 | ||
|
|
2f5b205916 | ||
|
|
cdad5d322d | ||
|
|
4f654eb822 | ||
|
|
3298c7e1c8 | ||
|
|
038a407b9e |
@@ -3,3 +3,5 @@
|
|||||||
public/system
|
public/system
|
||||||
public/assets
|
public/assets
|
||||||
node_modules
|
node_modules
|
||||||
|
storybook
|
||||||
|
neo4j
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ DB_USER=postgres
|
|||||||
DB_NAME=postgres
|
DB_NAME=postgres
|
||||||
DB_PASS=
|
DB_PASS=
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
NEO4J_HOST=neo4j
|
||||||
|
NEO4J_PORT=7474
|
||||||
|
|
||||||
# Federation
|
# Federation
|
||||||
LOCAL_DOMAIN=example.com
|
LOCAL_DOMAIN=example.com
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ public/assets
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
node_modules/
|
node_modules/
|
||||||
|
neo4j/
|
||||||
|
|||||||
17
.travis.yml
17
.travis.yml
@@ -1,11 +1,18 @@
|
|||||||
language: ruby
|
language: ruby
|
||||||
cache: bundler
|
cache: bundler
|
||||||
|
|
||||||
|
notifications:
|
||||||
|
email: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
matrix:
|
||||||
|
- TRAVIS_NODE_VERSION="4"
|
||||||
global:
|
global:
|
||||||
- LOCAL_DOMAIN=cb6e6126.ngrok.io
|
- LOCAL_DOMAIN=cb6e6126.ngrok.io
|
||||||
- LOCAL_HTTPS=true
|
- LOCAL_HTTPS=true
|
||||||
- RAILS_ENV=test
|
- RAILS_ENV=test
|
||||||
|
- NEO4J_HOST=localhost
|
||||||
|
- NEO4J_PORT=7575
|
||||||
|
|
||||||
addons:
|
addons:
|
||||||
postgresql: 9.4
|
postgresql: 9.4
|
||||||
@@ -19,11 +26,15 @@ services:
|
|||||||
bundler_args: --without development production --retry=3 --jobs=3
|
bundler_args: --without development production --retry=3 --jobs=3
|
||||||
|
|
||||||
install:
|
install:
|
||||||
- npm install -g npm@2
|
- nvm install $TRAVIS_NODE_VERSION
|
||||||
- npm install
|
- npm install -g npm@3
|
||||||
|
- npm install -g yarn
|
||||||
- bundle install
|
- bundle install
|
||||||
|
- yarn install
|
||||||
|
|
||||||
before_script:
|
before_script:
|
||||||
- bundle exec rails db:create db:migrate
|
- bundle exec rails db:create db:migrate
|
||||||
|
|
||||||
script: bundle exec rspec
|
script:
|
||||||
|
- bundle exec rspec
|
||||||
|
- npm test
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ FROM ruby:2.2.4
|
|||||||
ENV RAILS_ENV=production
|
ENV RAILS_ENV=production
|
||||||
|
|
||||||
RUN echo 'deb http://httpredir.debian.org/debian jessie-backports main contrib non-free' >> /etc/apt/sources.list
|
RUN echo 'deb http://httpredir.debian.org/debian jessie-backports main contrib non-free' >> /etc/apt/sources.list
|
||||||
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev libxml2-dev libxslt1-dev nodejs nodejs-legacy npm ffmpeg && rm -rf /var/lib/apt/lists/*
|
RUN curl -sL https://deb.nodesource.com/setup_4.x | bash -
|
||||||
|
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev libxml2-dev libxslt1-dev nodejs ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN npm install -g npm@3 && npm install -g yarn
|
||||||
RUN mkdir /mastodon
|
RUN mkdir /mastodon
|
||||||
|
|
||||||
WORKDIR /mastodon
|
WORKDIR /mastodon
|
||||||
@@ -13,7 +15,8 @@ ADD Gemfile.lock /mastodon/Gemfile.lock
|
|||||||
RUN bundle install --deployment --without test development
|
RUN bundle install --deployment --without test development
|
||||||
|
|
||||||
ADD package.json /mastodon/package.json
|
ADD package.json /mastodon/package.json
|
||||||
RUN npm install
|
ADD yarn.lock /mastodon/yarn.lock
|
||||||
|
RUN yarn
|
||||||
|
|
||||||
ADD . /mastodon
|
ADD . /mastodon
|
||||||
|
|
||||||
17
Dockerfile.neo4j
Normal file
17
Dockerfile.neo4j
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM neo4j:latest
|
||||||
|
|
||||||
|
ENV NEO4J_AUTH=none
|
||||||
|
|
||||||
|
RUN cd /var/lib/neo4j/plugins \
|
||||||
|
&& wget http://products.graphaware.com/download/framework-server-community/graphaware-server-community-all-3.0.6.43.jar \
|
||||||
|
&& wget http://products.graphaware.com/download/noderank/graphaware-noderank-3.0.6.43.3.jar
|
||||||
|
RUN echo "dbms.unmanaged_extension_classes=com.graphaware.server=/graphaware" >> /var/lib/neo4j/conf/neo4j.conf
|
||||||
|
RUN echo 'com.graphaware.runtime.enabled=true\n\
|
||||||
|
com.graphaware.module.NR.1=com.graphaware.module.noderank.NodeRankModuleBootstrapper\n\
|
||||||
|
com.graphaware.module.NR.maxTopRankNodes=10\n\
|
||||||
|
com.graphaware.module.NR.dampingFactor=0.85\n\
|
||||||
|
com.graphaware.module.NR.propertyKey=nodeRank\n'\
|
||||||
|
>> /var/lib/neo4j/conf/neo4j.conf
|
||||||
|
RUN echo 'com.graphaware.runtime.stats.disabled=true\n\
|
||||||
|
com.graphaware.server.stats.disabled=true\n'\
|
||||||
|
>> /var/lib/neo4j/conf/neo4j.conf
|
||||||
2
Gemfile
2
Gemfile
@@ -38,6 +38,8 @@ gem 'simple_form'
|
|||||||
gem 'will_paginate'
|
gem 'will_paginate'
|
||||||
gem 'rack-attack'
|
gem 'rack-attack'
|
||||||
gem 'sidekiq'
|
gem 'sidekiq'
|
||||||
|
gem 'ledermann-rails-settings'
|
||||||
|
gem 'neography'
|
||||||
|
|
||||||
gem 'react-rails'
|
gem 'react-rails'
|
||||||
gem 'browserify-rails'
|
gem 'browserify-rails'
|
||||||
|
|||||||
26
Gemfile.lock
26
Gemfile.lock
@@ -97,6 +97,7 @@ GEM
|
|||||||
dotenv (= 2.1.1)
|
dotenv (= 2.1.1)
|
||||||
railties (>= 4.0, < 5.1)
|
railties (>= 4.0, < 5.1)
|
||||||
erubis (2.7.0)
|
erubis (2.7.0)
|
||||||
|
excon (0.53.0)
|
||||||
execjs (2.7.0)
|
execjs (2.7.0)
|
||||||
fabrication (2.15.2)
|
fabrication (2.15.2)
|
||||||
fast_blank (1.0.0)
|
fast_blank (1.0.0)
|
||||||
@@ -107,7 +108,7 @@ GEM
|
|||||||
ruby-progressbar (~> 1.4)
|
ruby-progressbar (~> 1.4)
|
||||||
globalid (0.3.7)
|
globalid (0.3.7)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
goldfinger (1.0.5)
|
goldfinger (1.1.0)
|
||||||
addressable (~> 2.4)
|
addressable (~> 2.4)
|
||||||
http (~> 2.0)
|
http (~> 2.0)
|
||||||
nokogiri (~> 1.6)
|
nokogiri (~> 1.6)
|
||||||
@@ -145,6 +146,8 @@ GEM
|
|||||||
json (1.8.3)
|
json (1.8.3)
|
||||||
launchy (2.4.3)
|
launchy (2.4.3)
|
||||||
addressable (~> 2.3)
|
addressable (~> 2.3)
|
||||||
|
ledermann-rails-settings (2.4.2)
|
||||||
|
activerecord (>= 3.1)
|
||||||
letter_opener (1.4.1)
|
letter_opener (1.4.1)
|
||||||
launchy (~> 2.2)
|
launchy (~> 2.2)
|
||||||
libv8 (3.16.14.15)
|
libv8 (3.16.14.15)
|
||||||
@@ -163,15 +166,22 @@ GEM
|
|||||||
mime-types-data (3.2016.0521)
|
mime-types-data (3.2016.0521)
|
||||||
mimemagic (0.3.0)
|
mimemagic (0.3.0)
|
||||||
mini_portile2 (2.1.0)
|
mini_portile2 (2.1.0)
|
||||||
minitest (5.9.0)
|
minitest (5.9.1)
|
||||||
multi_json (1.12.1)
|
multi_json (1.12.1)
|
||||||
|
neography (1.8.0)
|
||||||
|
excon (>= 0.33.0)
|
||||||
|
json (>= 1.7.7)
|
||||||
|
multi_json (>= 1.3.2)
|
||||||
|
os (>= 0.9.6)
|
||||||
|
rake (>= 0.8.7)
|
||||||
|
rubyzip (>= 1.0.0)
|
||||||
nio4r (1.2.1)
|
nio4r (1.2.1)
|
||||||
nokogiri (1.6.8)
|
nokogiri (1.6.8.1)
|
||||||
mini_portile2 (~> 2.1.0)
|
mini_portile2 (~> 2.1.0)
|
||||||
pkg-config (~> 1.1.7)
|
|
||||||
oj (2.17.3)
|
oj (2.17.3)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ostatus2 (0.3.1)
|
os (0.9.6)
|
||||||
|
ostatus2 (1.0.2)
|
||||||
addressable (~> 2.4)
|
addressable (~> 2.4)
|
||||||
http (~> 2.0)
|
http (~> 2.0)
|
||||||
nokogiri (~> 1.6)
|
nokogiri (~> 1.6)
|
||||||
@@ -187,7 +197,6 @@ GEM
|
|||||||
parser (2.3.1.2)
|
parser (2.3.1.2)
|
||||||
ast (~> 2.2)
|
ast (~> 2.2)
|
||||||
pg (0.18.4)
|
pg (0.18.4)
|
||||||
pkg-config (1.1.7)
|
|
||||||
powerpack (0.1.1)
|
powerpack (0.1.1)
|
||||||
pry (0.10.4)
|
pry (0.10.4)
|
||||||
coderay (~> 1.1.0)
|
coderay (~> 1.1.0)
|
||||||
@@ -236,7 +245,7 @@ GEM
|
|||||||
rake (>= 0.8.7)
|
rake (>= 0.8.7)
|
||||||
thor (>= 0.18.1, < 2.0)
|
thor (>= 0.18.1, < 2.0)
|
||||||
rainbow (2.1.0)
|
rainbow (2.1.0)
|
||||||
rake (11.2.2)
|
rake (11.3.0)
|
||||||
rdoc (4.2.2)
|
rdoc (4.2.2)
|
||||||
json (~> 1.4)
|
json (~> 1.4)
|
||||||
react-rails (1.8.2)
|
react-rails (1.8.2)
|
||||||
@@ -281,6 +290,7 @@ GEM
|
|||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (~> 1.0, >= 1.0.1)
|
unicode-display_width (~> 1.0, >= 1.0.1)
|
||||||
ruby-progressbar (1.8.1)
|
ruby-progressbar (1.8.1)
|
||||||
|
rubyzip (1.2.0)
|
||||||
safe_yaml (1.0.4)
|
safe_yaml (1.0.4)
|
||||||
sass (3.4.22)
|
sass (3.4.22)
|
||||||
sass-rails (5.0.6)
|
sass-rails (5.0.6)
|
||||||
@@ -366,9 +376,11 @@ DEPENDENCIES
|
|||||||
httplog
|
httplog
|
||||||
jbuilder (~> 2.0)
|
jbuilder (~> 2.0)
|
||||||
jquery-rails
|
jquery-rails
|
||||||
|
ledermann-rails-settings
|
||||||
letter_opener
|
letter_opener
|
||||||
link_header
|
link_header
|
||||||
lograge
|
lograge
|
||||||
|
neography
|
||||||
nokogiri
|
nokogiri
|
||||||
oj
|
oj
|
||||||
ostatus2
|
ostatus2
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ Consult the example configuration file, `.env.production.sample` for the full li
|
|||||||
|
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- Redis
|
- Redis
|
||||||
|
- Neo4J (optional)
|
||||||
|
- GraphAware NodeRank
|
||||||
|
|
||||||
## Running with Docker and Docker-Compose
|
## Running with Docker and Docker-Compose
|
||||||
|
|
||||||
@@ -86,6 +88,7 @@ The container has two volumes, for the assets and for user uploads. The default
|
|||||||
- `rake mastodon:push:clear` unsubscribes from PuSH notifications for remote users that have no local followers. You may not want to actually do that, to keep a fuller footprint of the fediverse or in case your users will soon re-follow
|
- `rake mastodon:push:clear` unsubscribes from PuSH notifications for remote users that have no local followers. You may not want to actually do that, to keep a fuller footprint of the fediverse or in case your users will soon re-follow
|
||||||
- `rake mastodon:push:refresh` re-subscribes PuSH for expiring remote users, this should be run periodically from a cronjob and quite often as the expiration time depends on the particular hub of the remote user
|
- `rake mastodon:push:refresh` re-subscribes PuSH for expiring remote users, this should be run periodically from a cronjob and quite often as the expiration time depends on the particular hub of the remote user
|
||||||
- `rake mastodon:feeds:clear` removes all timelines, which forces them to be re-built on the fly next time a user tries to fetch their home/mentions timeline. Only for troubleshooting
|
- `rake mastodon:feeds:clear` removes all timelines, which forces them to be re-built on the fly next time a user tries to fetch their home/mentions timeline. Only for troubleshooting
|
||||||
|
- `rake mastodon:graphs:sync` re-imports all follow relationships into Neo4J. Only for troubleshooting
|
||||||
|
|
||||||
Running any of these tasks via docker-compose would look like this:
|
Running any of these tasks via docker-compose would look like this:
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
2
app/assets/javascripts/application_public.js
Normal file
2
app/assets/javascripts/application_public.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
//= require jquery
|
||||||
|
//= require jquery_ujs
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
window.React = require('react');
|
window.React = require('react');
|
||||||
window.ReactDOM = require('react-dom');
|
window.ReactDOM = require('react-dom');
|
||||||
|
window.Perf = require('react-addons-perf');
|
||||||
|
|
||||||
//= require_tree ./components
|
//= require_tree ./components
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import api from '../api'
|
import api from '../api'
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
|
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ export function fetchAccountTimeline(id) {
|
|||||||
|
|
||||||
export function expandAccountTimeline(id) {
|
export function expandAccountTimeline(id) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const lastId = getState().getIn(['timelines', 'accounts_timelines', id]).last();
|
const lastId = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List()).last();
|
||||||
|
|
||||||
dispatch(expandAccountTimelineRequest(id));
|
dispatch(expandAccountTimelineRequest(id));
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export const NOTIFICATION_SHOW = 'NOTIFICATION_SHOW';
|
||||||
export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS';
|
export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS';
|
||||||
export const NOTIFICATION_CLEAR = 'NOTIFICATION_CLEAR';
|
export const NOTIFICATION_CLEAR = 'NOTIFICATION_CLEAR';
|
||||||
|
|
||||||
@@ -13,3 +14,11 @@ export function clearNotifications() {
|
|||||||
type: NOTIFICATION_CLEAR
|
type: NOTIFICATION_CLEAR
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function showNotification(title, message) {
|
||||||
|
return {
|
||||||
|
type: NOTIFICATION_SHOW,
|
||||||
|
title: title,
|
||||||
|
message: message
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
37
app/assets/javascripts/components/actions/suggestions.jsx
Normal file
37
app/assets/javascripts/components/actions/suggestions.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
|
||||||
|
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
|
||||||
|
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
|
||||||
|
|
||||||
|
export function fetchSuggestions() {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(fetchSuggestionsRequest());
|
||||||
|
|
||||||
|
api(getState).get('/api/v1/accounts/suggestions').then(response => {
|
||||||
|
dispatch(fetchSuggestionsSuccess(response.data));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(fetchSuggestionsFail(error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchSuggestionsRequest() {
|
||||||
|
return {
|
||||||
|
type: SUGGESTIONS_FETCH_REQUEST
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchSuggestionsSuccess(suggestions) {
|
||||||
|
return {
|
||||||
|
type: SUGGESTIONS_FETCH_SUCCESS,
|
||||||
|
suggestions: suggestions
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchSuggestionsFail(error) {
|
||||||
|
return {
|
||||||
|
type: SUGGESTIONS_FETCH_FAIL,
|
||||||
|
error: error
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||||
|
|
||||||
|
const DropdownMenu = ({ icon, items, size }) => {
|
||||||
|
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' }}>
|
||||||
|
<ul>
|
||||||
|
{items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
|
||||||
|
if (typeof action === 'function') {
|
||||||
|
e.preventDefault();
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
}}>{text}</a></li>)}
|
||||||
|
</ul>
|
||||||
|
</DropdownContent>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
DropdownMenu.propTypes = {
|
||||||
|
icon: React.PropTypes.string.isRequired,
|
||||||
|
items: React.PropTypes.array.isRequired,
|
||||||
|
size: React.PropTypes.number.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropdownMenu;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const LoadingIndicator = () => {
|
||||||
|
const style = {
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#616b86',
|
||||||
|
paddingTop: '120px'
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div style={style}>Loading...</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingIndicator;
|
||||||
@@ -35,7 +35,7 @@ const RelativeTimestamp = React.createClass({
|
|||||||
|
|
||||||
componentWillMount () {
|
componentWillMount () {
|
||||||
this._updateMomentText();
|
this._updateMomentText();
|
||||||
this.interval = setInterval(this._updateMomentText, 6000);
|
this.interval = setInterval(this._updateMomentText, 60000);
|
||||||
},
|
},
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
|
|||||||
@@ -41,16 +41,21 @@ const Status = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
var media = '';
|
let media = '';
|
||||||
|
let { status, ...other } = this.props;
|
||||||
|
|
||||||
var { status, ...other } = this.props;
|
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||||
|
let displayName = status.getIn(['account', 'display_name']);
|
||||||
|
|
||||||
|
if (displayName.length === 0) {
|
||||||
|
displayName = status.getIn(['account', 'username']);
|
||||||
|
}
|
||||||
|
|
||||||
if (status.get('reblog') !== null) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ cursor: 'pointer' }} onClick={this.handleClick}>
|
<div style={{ cursor: 'pointer' }} onClick={this.handleClick}>
|
||||||
<div style={{ marginLeft: '68px', color: '#616b86', padding: '8px 0', paddingBottom: '2px', fontSize: '14px', position: 'relative' }}>
|
<div style={{ marginLeft: '68px', color: '#616b86', padding: '8px 0', paddingBottom: '2px', fontSize: '14px', position: 'relative' }}>
|
||||||
<div style={{ position: 'absolute', 'left': '-26px'}}><i className='fa fa-fw fa-retweet'></i></div>
|
<div style={{ position: 'absolute', 'left': '-26px'}}><i className='fa fa-fw fa-retweet'></i></div>
|
||||||
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'><strong style={{ color: '#616b86'}}>{status.getIn(['account', 'display_name'])}</strong></a> reblogged
|
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'><strong style={{ color: '#616b86'}}>{displayName}</strong></a> reblogged
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Status {...other} wrapped={true} status={status.get('reblog')} />
|
<Status {...other} wrapped={true} status={status.get('reblog')} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import IconButton from './icon_button';
|
import IconButton from './icon_button';
|
||||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
import DropdownMenu from './dropdown_menu';
|
||||||
|
|
||||||
const StatusActionBar = React.createClass({
|
const StatusActionBar = React.createClass({
|
||||||
propTypes: {
|
propTypes: {
|
||||||
@@ -26,23 +26,16 @@ const StatusActionBar = React.createClass({
|
|||||||
this.props.onReblog(this.props.status);
|
this.props.onReblog(this.props.status);
|
||||||
},
|
},
|
||||||
|
|
||||||
handleDeleteClick(e) {
|
handleDeleteClick () {
|
||||||
e.preventDefault();
|
|
||||||
this.props.onDelete(this.props.status);
|
this.props.onDelete(this.props.status);
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, me } = this.props;
|
const { status, me } = this.props;
|
||||||
let menu = '';
|
let menu = [];
|
||||||
|
|
||||||
if (status.getIn(['account', 'id']) === me) {
|
if (status.getIn(['account', 'id']) === me) {
|
||||||
menu = (
|
menu.push({ text: 'Delete', action: this.handleDeleteClick });
|
||||||
<ul>
|
|
||||||
<li><a href='#' onClick={this.handleDeleteClick}>Delete</a></li>
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
menu = <ul />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,13 +45,7 @@ const StatusActionBar = React.createClass({
|
|||||||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div>
|
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div>
|
||||||
|
|
||||||
<div onClick={e => e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}>
|
<div onClick={e => e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}>
|
||||||
<Dropdown>
|
<DropdownMenu items={menu} icon='ellipsis-h' size={18} />
|
||||||
<DropdownTrigger className='icon-button' style={{ fontSize: '18px', lineHeight: '18px', width: '18px', height: '18px' }}>
|
|
||||||
<i className='fa fa-fw fa-ellipsis-h' />
|
|
||||||
</DropdownTrigger>
|
|
||||||
|
|
||||||
<DropdownContent>{menu}</DropdownContent>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ const StatusContent = React.createClass({
|
|||||||
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
|
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
|
||||||
|
|
||||||
if (mention) {
|
if (mention) {
|
||||||
link.addEventListener('click', this.onMentionClick.bind(this, mention));
|
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||||
} else {
|
} else {
|
||||||
link.setAttribute('target', '_blank');
|
link.setAttribute('target', '_blank');
|
||||||
link.setAttribute('rel', 'noopener');
|
link.setAttribute('rel', 'noopener');
|
||||||
link.addEventListener('click', this.onNormalClick.bind(this));
|
link.addEventListener('click', this.onNormalClick, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const StatusList = React.createClass({
|
|||||||
const { statuses, onScrollToBottom, ...other } = this.props;
|
const { statuses, onScrollToBottom, ...other } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable' onScroll={this.handleScroll}>
|
<div style={{ overflowY: 'scroll', flex: '1 1 auto', overflowX: 'hidden' }} className='scrollable' onScroll={this.handleScroll}>
|
||||||
<div>
|
<div>
|
||||||
{statuses.map((status) => {
|
{statuses.map((status) => {
|
||||||
return <Status key={status.get('id')} {...other} status={status} />;
|
return <Status key={status.get('id')} {...other} status={status} />;
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import configureStore from '../store/configureStore';
|
import configureStore from '../store/configureStore';
|
||||||
import {
|
import {
|
||||||
refreshTimelineSuccess,
|
refreshTimelineSuccess,
|
||||||
updateTimeline,
|
updateTimeline,
|
||||||
deleteFromTimelines,
|
deleteFromTimelines,
|
||||||
refreshTimeline
|
refreshTimeline
|
||||||
} from '../actions/timelines';
|
} from '../actions/timelines';
|
||||||
import { setAccessToken } from '../actions/meta';
|
import { setAccessToken } from '../actions/meta';
|
||||||
import { setAccountSelf } from '../actions/accounts';
|
import { setAccountSelf } from '../actions/accounts';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import { Router, Route, hashHistory } from 'react-router';
|
import {
|
||||||
import Account from '../features/account';
|
Router,
|
||||||
import Settings from '../features/settings';
|
Route,
|
||||||
import Status from '../features/status';
|
hashHistory,
|
||||||
import Subscriptions from '../features/subscriptions';
|
IndexRoute
|
||||||
import UI from '../features/ui';
|
} from 'react-router';
|
||||||
|
import UI from '../features/ui';
|
||||||
|
import Account from '../features/account';
|
||||||
|
import Status from '../features/status';
|
||||||
|
import GettingStarted from '../features/getting_started';
|
||||||
|
import PublicTimeline from '../features/public_timeline';
|
||||||
|
import AccountTimeline from '../features/account_timeline';
|
||||||
|
import HomeTimeline from '../features/home_timeline';
|
||||||
|
import MentionsTimeline from '../features/mentions_timeline';
|
||||||
|
import Compose from '../features/compose';
|
||||||
|
|
||||||
const store = configureStore();
|
const store = configureStore();
|
||||||
|
|
||||||
@@ -32,21 +41,8 @@ const Mastodon = React.createClass({
|
|||||||
store.dispatch(setAccessToken(this.props.token));
|
store.dispatch(setAccessToken(this.props.token));
|
||||||
store.dispatch(setAccountSelf(JSON.parse(this.props.account)));
|
store.dispatch(setAccountSelf(JSON.parse(this.props.account)));
|
||||||
|
|
||||||
for (var timelineType in this.props.timelines) {
|
|
||||||
if (this.props.timelines.hasOwnProperty(timelineType)) {
|
|
||||||
store.dispatch(refreshTimelineSuccess(timelineType, JSON.parse(this.props.timelines[timelineType])));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof App !== 'undefined') {
|
if (typeof App !== 'undefined') {
|
||||||
App.timeline = App.cable.subscriptions.create("TimelineChannel", {
|
this.subscription = App.cable.subscriptions.create('TimelineChannel', {
|
||||||
connected () {
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
disconnected () {
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
switch(data.type) {
|
switch(data.type) {
|
||||||
@@ -61,19 +57,31 @@ const Mastodon = React.createClass({
|
|||||||
return store.dispatch(refreshTimeline('mentions'));
|
return store.dispatch(refreshTimeline('mentions'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
if (typeof this.subscription !== 'undefined') {
|
||||||
|
this.subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<Router history={hashHistory}>
|
<Router history={hashHistory}>
|
||||||
<Route path='/' component={UI}>
|
<Route path='/' component={UI}>
|
||||||
<Route path='/settings' component={Settings} />
|
<IndexRoute component={GettingStarted} />
|
||||||
<Route path='/subscriptions' component={Subscriptions} />
|
<Route path='/statuses/new' component={Compose} />
|
||||||
|
<Route path='/statuses/home' component={HomeTimeline} />
|
||||||
|
<Route path='/statuses/mentions' component={MentionsTimeline} />
|
||||||
|
<Route path='/statuses/all' component={PublicTimeline} />
|
||||||
<Route path='/statuses/:statusId' component={Status} />
|
<Route path='/statuses/:statusId' component={Status} />
|
||||||
<Route path='/accounts/:accountId' component={Account} />
|
<Route path='/accounts/:accountId' component={Account}>
|
||||||
|
<IndexRoute component={AccountTimeline} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Router>
|
</Router>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Button from '../../../components/button';
|
import DropdownMenu from '../../../components/dropdown_menu';
|
||||||
|
|
||||||
const ActionBar = React.createClass({
|
const ActionBar = React.createClass({
|
||||||
|
|
||||||
@@ -16,47 +16,42 @@ const ActionBar = React.createClass({
|
|||||||
render () {
|
render () {
|
||||||
const { account, me } = this.props;
|
const { account, me } = this.props;
|
||||||
|
|
||||||
let infoText = '';
|
let menu = [];
|
||||||
let follow = '';
|
|
||||||
let buttonText = '';
|
|
||||||
let block = '';
|
|
||||||
let disabled = false;
|
|
||||||
|
|
||||||
if (account.get('id') === me) {
|
if (account.get('id') === me) {
|
||||||
buttonText = 'This is you!';
|
menu.push({ text: 'Edit profile', href: '/settings/profile' });
|
||||||
disabled = true;
|
} else if (account.getIn(['relationship', 'blocking'])) {
|
||||||
|
menu.push({ text: 'Unblock', action: this.props.onBlock });
|
||||||
|
} else if (account.getIn(['relationship', 'following'])) {
|
||||||
|
menu.push({ text: 'Unfollow', action: this.props.onFollow });
|
||||||
|
menu.push({ text: 'Block', action: this.props.onBlock });
|
||||||
} else {
|
} else {
|
||||||
let blockText = '';
|
menu.push({ text: 'Follow', action: this.props.onFollow });
|
||||||
|
menu.push({ text: 'Block', action: this.props.onBlock });
|
||||||
if (account.getIn(['relationship', 'blocking'])) {
|
|
||||||
buttonText = 'Blocked';
|
|
||||||
disabled = true;
|
|
||||||
blockText = 'Unblock';
|
|
||||||
} else {
|
|
||||||
if (account.getIn(['relationship', 'following'])) {
|
|
||||||
buttonText = 'Unfollow';
|
|
||||||
} else {
|
|
||||||
buttonText = 'Follow';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account.getIn(['relationship', 'followed_by'])) {
|
|
||||||
infoText = 'Follows you!';
|
|
||||||
}
|
|
||||||
|
|
||||||
blockText = 'Block';
|
|
||||||
}
|
|
||||||
|
|
||||||
block = <Button text={blockText} onClick={this.props.onBlock} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!account.getIn(['relationship', 'blocking'])) {
|
|
||||||
follow = <Button text={buttonText} onClick={this.props.onFollow} disabled={disabled} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto' }}>
|
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto', display: 'flex' }}>
|
||||||
{follow} {block}
|
<div style={{ flex: '1 1 auto', display: 'flex', lineHeight: '18px' }}>
|
||||||
<span style={{ color: '#616b86', fontWeight: '500', textTransform: 'uppercase', float: 'right', display: 'block' }}>{infoText}</span>
|
<div style={{ overflow: 'hidden', width: '80px', borderRight: '1px solid #363c4b', padding: '10px', paddingRight: '5px' }}>
|
||||||
|
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Posts</span>
|
||||||
|
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('statuses_count')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ overflow: 'hidden', width: '80px', borderRight: '1px solid #363c4b', padding: '10px 5px' }}>
|
||||||
|
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Follows</span>
|
||||||
|
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('following_count')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ overflow: 'hidden', width: '80px', padding: '10px 5px', borderRight: '1px solid #363c4b' }}>
|
||||||
|
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Followers</span>
|
||||||
|
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('followers_count')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '10px', flex: '1 1 auto' }}>
|
||||||
|
<DropdownMenu items={menu} icon='bars' size={24} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,24 +4,41 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||||||
const Header = React.createClass({
|
const Header = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
account: ImmutablePropTypes.map.isRequired
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
|
me: React.PropTypes.number.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account } = this.props;
|
const { account, me } = this.props;
|
||||||
|
|
||||||
|
let displayName = account.get('display_name');
|
||||||
|
let info = '';
|
||||||
|
|
||||||
|
if (displayName.length === 0) {
|
||||||
|
displayName = account.get('username');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
|
||||||
|
info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}>Follows you</span>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover' }}>
|
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}>
|
||||||
<div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '30px 10px' }}>
|
<div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '30px 10px' }}>
|
||||||
<div style={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}>
|
<a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
|
||||||
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
|
<div style={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}>
|
||||||
</div>
|
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }}>{displayName}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<span style={{ color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500', display: 'block' }}>{account.get('display_name')}</span>
|
|
||||||
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '15px' }}>@{account.get('acct')}</span>
|
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '15px' }}>@{account.get('acct')}</span>
|
||||||
<p style={{ color: '#616b86', fontSize: '14px' }}>{account.get('note')}</p>
|
<p style={{ color: '#616b86', fontSize: '14px' }}>{account.get('note')}</p>
|
||||||
|
|
||||||
|
{info}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,30 +10,17 @@ import {
|
|||||||
fetchAccountTimeline,
|
fetchAccountTimeline,
|
||||||
expandAccountTimeline
|
expandAccountTimeline
|
||||||
} from '../../actions/accounts';
|
} from '../../actions/accounts';
|
||||||
import { deleteStatus } from '../../actions/statuses';
|
|
||||||
import { replyCompose } from '../../actions/compose';
|
|
||||||
import {
|
|
||||||
favourite,
|
|
||||||
reblog,
|
|
||||||
unreblog,
|
|
||||||
unfavourite
|
|
||||||
} from '../../actions/interactions';
|
|
||||||
import Header from './components/header';
|
import Header from './components/header';
|
||||||
import {
|
import {
|
||||||
selectStatus,
|
getAccountTimeline,
|
||||||
selectAccount
|
getAccount
|
||||||
} from '../../reducers/timelines';
|
} from '../../selectors';
|
||||||
import StatusList from '../../components/status_list';
|
import LoadingIndicator from '../../components/loading_indicator';
|
||||||
import Immutable from 'immutable';
|
|
||||||
import ActionBar from './components/action_bar';
|
import ActionBar from './components/action_bar';
|
||||||
|
import Column from '../ui/components/column';
|
||||||
function selectStatuses(state, accountId) {
|
|
||||||
return state.getIn(['timelines', 'accounts_timelines', accountId], Immutable.List()).map(id => selectStatus(state, id)).filterNot(status => status === null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
account: selectAccount(state, Number(props.params.accountId)),
|
account: getAccount(state, Number(props.params.accountId)),
|
||||||
statuses: selectStatuses(state, Number(props.params.accountId)),
|
|
||||||
me: state.getIn(['timelines', 'me'])
|
me: state.getIn(['timelines', 'me'])
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,20 +30,18 @@ const Account = React.createClass({
|
|||||||
params: React.PropTypes.object.isRequired,
|
params: React.PropTypes.object.isRequired,
|
||||||
dispatch: React.PropTypes.func.isRequired,
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
account: ImmutablePropTypes.map,
|
account: ImmutablePropTypes.map,
|
||||||
statuses: ImmutablePropTypes.list
|
me: React.PropTypes.number.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
componentWillMount () {
|
componentWillMount () {
|
||||||
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
|
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
|
||||||
this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId)));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
componentWillReceiveProps(nextProps) {
|
componentWillReceiveProps(nextProps) {
|
||||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||||
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
|
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
|
||||||
this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId)));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -76,47 +61,25 @@ const Account = React.createClass({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleReply (status) {
|
|
||||||
this.props.dispatch(replyCompose(status));
|
|
||||||
},
|
|
||||||
|
|
||||||
handleReblog (status) {
|
|
||||||
if (status.get('reblogged')) {
|
|
||||||
this.props.dispatch(unreblog(status));
|
|
||||||
} else {
|
|
||||||
this.props.dispatch(reblog(status));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
handleFavourite (status) {
|
|
||||||
if (status.get('favourited')) {
|
|
||||||
this.props.dispatch(unfavourite(status));
|
|
||||||
} else {
|
|
||||||
this.props.dispatch(favourite(status));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
handleDelete (status) {
|
|
||||||
this.props.dispatch(deleteStatus(status.get('id')));
|
|
||||||
},
|
|
||||||
|
|
||||||
handleScrollToBottom () {
|
|
||||||
this.props.dispatch(expandAccountTimeline(this.props.account.get('id')));
|
|
||||||
},
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account, statuses, me } = this.props;
|
const { account, me } = this.props;
|
||||||
|
|
||||||
if (account === null) {
|
if (account === null) {
|
||||||
return <div>Loading {this.props.params.accountId}...</div>;
|
return (
|
||||||
|
<Column>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}>
|
<Column>
|
||||||
<Header account={account} />
|
<Header account={account} me={me} />
|
||||||
|
|
||||||
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} />
|
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} />
|
||||||
<StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} />
|
|
||||||
</div>
|
{this.props.children}
|
||||||
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import { getAccountTimeline } from '../../selectors';
|
||||||
|
import {
|
||||||
|
fetchAccountTimeline,
|
||||||
|
expandAccountTimeline
|
||||||
|
} from '../../actions/accounts';
|
||||||
|
import { deleteStatus } from '../../actions/statuses';
|
||||||
|
import { replyCompose } from '../../actions/compose';
|
||||||
|
import {
|
||||||
|
favourite,
|
||||||
|
reblog,
|
||||||
|
unreblog,
|
||||||
|
unfavourite
|
||||||
|
} from '../../actions/interactions';
|
||||||
|
import StatusList from '../../components/status_list';
|
||||||
|
|
||||||
|
const mapStateToProps = (state, props) => ({
|
||||||
|
statuses: getAccountTimeline(state, Number(props.params.accountId)),
|
||||||
|
me: state.getIn(['timelines', 'me'])
|
||||||
|
});
|
||||||
|
|
||||||
|
const AccountTimeline = React.createClass({
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
params: React.PropTypes.object.isRequired,
|
||||||
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
|
statuses: ImmutablePropTypes.list
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
componentWillMount () {
|
||||||
|
this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId)));
|
||||||
|
},
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||||
|
this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleReply (status) {
|
||||||
|
this.props.dispatch(replyCompose(status));
|
||||||
|
},
|
||||||
|
|
||||||
|
handleReblog (status) {
|
||||||
|
if (status.get('reblogged')) {
|
||||||
|
this.props.dispatch(unreblog(status));
|
||||||
|
} else {
|
||||||
|
this.props.dispatch(reblog(status));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleFavourite (status) {
|
||||||
|
if (status.get('favourited')) {
|
||||||
|
this.props.dispatch(unfavourite(status));
|
||||||
|
} else {
|
||||||
|
this.props.dispatch(favourite(status));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDelete (status) {
|
||||||
|
this.props.dispatch(deleteStatus(status.get('id')));
|
||||||
|
},
|
||||||
|
|
||||||
|
handleScrollToBottom () {
|
||||||
|
this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
|
||||||
|
},
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { statuses, me } = this.props;
|
||||||
|
|
||||||
|
return <StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} onDelete={this.handleDelete} />
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(AccountTimeline);
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import Avatar from '../../../components/avatar';
|
||||||
|
import DisplayName from '../../../components/display_name';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
const outerStyle = {
|
||||||
|
marginBottom: '10px',
|
||||||
|
borderTop: '1px solid #616b86',
|
||||||
|
position: 'relative'
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerStyle = {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
display: 'block',
|
||||||
|
padding: '10px',
|
||||||
|
color: '#9baec8',
|
||||||
|
background: '#454b5e',
|
||||||
|
width: '120px',
|
||||||
|
marginTop: '-18px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemStyle = {
|
||||||
|
display: 'block',
|
||||||
|
padding: '10px',
|
||||||
|
color: '#9baec8',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textDecoration: 'none'
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayNameStyle = {
|
||||||
|
display: 'block',
|
||||||
|
fontWeight: '500',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
};
|
||||||
|
|
||||||
|
const acctStyle = {
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStyle = {
|
||||||
|
fontWeight: '400',
|
||||||
|
color: '#2b90d9'
|
||||||
|
};
|
||||||
|
|
||||||
|
const SuggestionsBox = React.createClass({
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
accounts: ImmutablePropTypes.list.isRequired,
|
||||||
|
perWindow: React.PropTypes.number
|
||||||
|
},
|
||||||
|
|
||||||
|
getInitialState () {
|
||||||
|
return {
|
||||||
|
index: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getDefaultProps () {
|
||||||
|
return {
|
||||||
|
perWindow: 2
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
handleNextClick (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
let newIndex = this.state.index + 1;
|
||||||
|
|
||||||
|
if (this.props.accounts.skip(this.props.perWindow * newIndex).size === 0) {
|
||||||
|
newIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ index: newIndex });
|
||||||
|
},
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { accounts, perWindow } = this.props;
|
||||||
|
|
||||||
|
if (accounts.size === 0) {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextLink = '';
|
||||||
|
|
||||||
|
if (accounts.size > perWindow) {
|
||||||
|
nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}>Next</a>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={outerStyle}>
|
||||||
|
<strong style={headerStyle}>
|
||||||
|
Who to follow {nextLink}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
{accounts.skip(perWindow * this.state.index).take(perWindow).map(account => {
|
||||||
|
let displayName = account.get('display_name');
|
||||||
|
|
||||||
|
if (displayName.length === 0) {
|
||||||
|
displayName = account.get('username');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={account.get('id')} style={itemStyle} to={`/accounts/${account.get('id')}`}>
|
||||||
|
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div>
|
||||||
|
<strong style={displayNameStyle}>{displayName}</strong>
|
||||||
|
<span style={acctStyle}>{account.get('acct')}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SuggestionsBox;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { getSuggestions } from '../../../selectors';
|
||||||
|
import SuggestionsBox from '../components/suggestions_box';
|
||||||
|
|
||||||
|
const mapStateToProps = (state) => ({
|
||||||
|
accounts: getSuggestions(state)
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(SuggestionsBox);
|
||||||
40
app/assets/javascripts/components/features/compose/index.jsx
Normal file
40
app/assets/javascripts/components/features/compose/index.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Drawer from '../ui/components/drawer';
|
||||||
|
import ComposeFormContainer from '../ui/containers/compose_form_container';
|
||||||
|
import FollowFormContainer from '../ui/containers/follow_form_container';
|
||||||
|
import UploadFormContainer from '../ui/containers/upload_form_container';
|
||||||
|
import NavigationContainer from '../ui/containers/navigation_container';
|
||||||
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
import SuggestionsContainer from './containers/suggestions_container';
|
||||||
|
import { fetchSuggestions } from '../../actions/suggestions';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
const Compose = React.createClass({
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
dispatch: React.PropTypes.func.isRequired
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
this.props.dispatch(fetchSuggestions());
|
||||||
|
},
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return (
|
||||||
|
<Drawer>
|
||||||
|
<div style={{ flex: '1 1 auto' }}>
|
||||||
|
<NavigationContainer />
|
||||||
|
<ComposeFormContainer />
|
||||||
|
<UploadFormContainer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SuggestionsContainer />
|
||||||
|
<FollowFormContainer />
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect()(Compose);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Column from '../ui/components/column';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
const GettingStarted = () => {
|
||||||
|
return (
|
||||||
|
<Column>
|
||||||
|
<div className='static-content'>
|
||||||
|
<h1>Getting started</h1>
|
||||||
|
<p>Mastodon is still in development and one of the lacking areas at the moment is user discovery.</p>
|
||||||
|
<p>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 in the bottom of the sidebar.</p>
|
||||||
|
<p>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>The developer of this project can be followed as Gargron@mastodon.social</p>
|
||||||
|
<p>Also <Link to='/statuses/all' style={{ color: '#2b90d9', textDecoration: 'none' }}>check out the public timeline for a start</Link>!</p>
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GettingStarted;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const HomeTimeline = React.createClass({
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
dispatch: React.PropTypes.func.isRequired
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
componentWillMount () {
|
||||||
|
this.props.dispatch(refreshTimeline('home'));
|
||||||
|
},
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return (
|
||||||
|
<Column icon='home' heading='Home'>
|
||||||
|
<StatusListContainer type='home' />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect()(HomeTimeline);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const MentionsTimeline = React.createClass({
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
dispatch: React.PropTypes.func.isRequired
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
componentWillMount () {
|
||||||
|
this.props.dispatch(refreshTimeline('mentions'));
|
||||||
|
},
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return (
|
||||||
|
<Column icon='at' heading='Mentions'>
|
||||||
|
<StatusListContainer type='mentions' />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect()(MentionsTimeline);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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,
|
||||||
|
updateTimeline
|
||||||
|
} from '../../actions/timelines';
|
||||||
|
|
||||||
|
const PublicTimeline = React.createClass({
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
dispatch: React.PropTypes.func.isRequired
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
componentWillMount () {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
|
||||||
|
dispatch(refreshTimeline('public'));
|
||||||
|
|
||||||
|
if (typeof App !== 'undefined') {
|
||||||
|
this.subscription = App.cable.subscriptions.create('PublicChannel', {
|
||||||
|
|
||||||
|
received (data) {
|
||||||
|
dispatch(updateTimeline('public', JSON.parse(data.message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
if (typeof this.subscription !== 'undefined') {
|
||||||
|
this.subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return (
|
||||||
|
<Column icon='globe' heading='Public'>
|
||||||
|
<StatusListContainer type='public' />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect()(PublicTimeline);
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
const Settings = React.createClass({
|
|
||||||
|
|
||||||
propTypes: {
|
|
||||||
params: React.PropTypes.object.isRequired,
|
|
||||||
dispatch: React.PropTypes.func.isRequired
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
|
||||||
|
|
||||||
componentWillMount () {
|
|
||||||
//
|
|
||||||
},
|
|
||||||
|
|
||||||
render () {
|
|
||||||
return <div>Settings</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(Settings);
|
|
||||||
@@ -1,26 +1,36 @@
|
|||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import IconButton from '../../../components/icon_button';
|
import IconButton from '../../../components/icon_button';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import DropdownMenu from '../../../components/dropdown_menu';
|
||||||
|
|
||||||
const ActionBar = React.createClass({
|
const ActionBar = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
onReply: React.PropTypes.func.isRequired,
|
onReply: React.PropTypes.func.isRequired,
|
||||||
onReblog: React.PropTypes.func.isRequired,
|
onReblog: React.PropTypes.func.isRequired,
|
||||||
onFavourite: React.PropTypes.func.isRequired
|
onFavourite: React.PropTypes.func.isRequired,
|
||||||
|
onDelete: React.PropTypes.func.isRequired,
|
||||||
|
me: React.PropTypes.number.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status } = this.props;
|
const { status, me } = this.props;
|
||||||
|
|
||||||
|
let menu = [];
|
||||||
|
|
||||||
|
if (me === status.getIn(['account', 'id'])) {
|
||||||
|
menu.push({ text: 'Delete', action: () => this.props.onDelete(status) });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}>
|
<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='Reply' icon='reply' onClick={() => this.props.onReply(status)} /></div>
|
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title='Reply' icon='reply' onClick={() => this.props.onReply(status)} /></div>
|
||||||
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={() => this.props.onReblog(status)} /></div>
|
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={() => this.props.onReblog(status)} /></div>
|
||||||
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={() => this.props.onFavourite(status)} /></div>
|
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={() => this.props.onFavourite(status)} /></div>
|
||||||
|
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,24 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||||||
import { fetchStatus } from '../../actions/statuses';
|
import { fetchStatus } from '../../actions/statuses';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
import EmbeddedStatus from '../../components/status';
|
import EmbeddedStatus from '../../components/status';
|
||||||
|
import LoadingIndicator from '../../components/loading_indicator';
|
||||||
import DetailedStatus from './components/detailed_status';
|
import DetailedStatus from './components/detailed_status';
|
||||||
import ActionBar from './components/action_bar';
|
import ActionBar from './components/action_bar';
|
||||||
|
import Column from '../ui/components/column';
|
||||||
import { favourite, reblog } from '../../actions/interactions';
|
import { favourite, reblog } from '../../actions/interactions';
|
||||||
import { replyCompose } from '../../actions/compose';
|
import { replyCompose } from '../../actions/compose';
|
||||||
import { selectStatus } from '../../reducers/timelines';
|
import { deleteStatus } from '../../actions/statuses';
|
||||||
|
import {
|
||||||
function selectStatuses(state, ids) {
|
getStatus,
|
||||||
return ids.map(id => selectStatus(state, id)).filterNot(status => status === null);
|
getStatusAncestors,
|
||||||
};
|
getStatusDescendants
|
||||||
|
} from '../../selectors';
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
status: selectStatus(state, Number(props.params.statusId)),
|
status: getStatus(state, Number(props.params.statusId)),
|
||||||
ancestors: selectStatuses(state, state.getIn(['timelines', 'ancestors', Number(props.params.statusId)], Immutable.OrderedSet())),
|
ancestors: getStatusAncestors(state, Number(props.params.statusId)),
|
||||||
descendants: selectStatuses(state, state.getIn(['timelines', 'descendants', Number(props.params.statusId)], Immutable.OrderedSet()))
|
descendants: getStatusDescendants(state, Number(props.params.statusId)),
|
||||||
|
me: state.getIn(['timelines', 'me'])
|
||||||
});
|
});
|
||||||
|
|
||||||
const Status = React.createClass({
|
const Status = React.createClass({
|
||||||
@@ -54,28 +58,38 @@ const Status = React.createClass({
|
|||||||
this.props.dispatch(reblog(status));
|
this.props.dispatch(reblog(status));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleDeleteClick (status) {
|
||||||
|
this.props.dispatch(deleteStatus(status.get('id')));
|
||||||
|
},
|
||||||
|
|
||||||
renderChildren (list) {
|
renderChildren (list) {
|
||||||
return list.map(s => <EmbeddedStatus status={s} key={s.get('id')} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} />);
|
return list.map(s => <EmbeddedStatus status={s} me={this.props.me} key={s.get('id')} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} />);
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, ancestors, descendants } = this.props;
|
const { status, ancestors, descendants, me } = this.props;
|
||||||
|
|
||||||
if (status === null) {
|
if (status === null) {
|
||||||
return <div>Loading {this.props.params.statusId}...</div>;
|
return (
|
||||||
|
<Column>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = status.get('account');
|
const account = status.get('account');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
|
<Column>
|
||||||
<div>{this.renderChildren(ancestors)}</div>
|
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
|
||||||
|
<div>{this.renderChildren(ancestors)}</div>
|
||||||
|
|
||||||
<DetailedStatus status={status} />
|
<DetailedStatus status={status} me={me} />
|
||||||
<ActionBar status={status} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} />
|
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} />
|
||||||
|
|
||||||
<div>{this.renderChildren(descendants)}</div>
|
<div>{this.renderChildren(descendants)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
const Subscriptions = React.createClass({
|
|
||||||
|
|
||||||
propTypes: {
|
|
||||||
params: React.PropTypes.object.isRequired,
|
|
||||||
dispatch: React.PropTypes.func.isRequired
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
|
||||||
|
|
||||||
componentWillMount () {
|
|
||||||
//
|
|
||||||
},
|
|
||||||
|
|
||||||
render () {
|
|
||||||
return <div>Subscriptions</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(Subscriptions);
|
|
||||||
@@ -18,7 +18,7 @@ const scrollTop = (node) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
node.scrollTo(0, easingOutQuint(0, elapsed, offset, targetY, duration));
|
node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration);
|
||||||
requestAnimationFrame(step);
|
requestAnimationFrame(step);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +29,12 @@ const scrollTop = (node) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
background: '#282c37',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
};
|
||||||
|
|
||||||
const Column = React.createClass({
|
const Column = React.createClass({
|
||||||
|
|
||||||
@@ -50,10 +56,6 @@ const Column = React.createClass({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleScroll () {
|
|
||||||
// todo
|
|
||||||
},
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
let header = '';
|
let header = '';
|
||||||
|
|
||||||
@@ -61,10 +63,8 @@ const Column = React.createClass({
|
|||||||
header = <ColumnHeader icon={this.props.icon} type={this.props.heading} onClick={this.handleHeaderClick} />;
|
header = <ColumnHeader icon={this.props.icon} type={this.props.heading} onClick={this.handleHeaderClick} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const style = { width: '350px', flex: '0 0 auto', background: '#282c37', margin: '10px', marginRight: '0', marginBottom: '0', display: 'flex', flexDirection: 'column' };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={style} onWheel={this.handleWheel} onScroll={this.handleScroll}>
|
<div className='column' style={style} onWheel={this.handleWheel}>
|
||||||
{header}
|
{header}
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
display: 'flex',
|
||||||
|
flex: '1 1 auto',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
overflowX: 'auto'
|
||||||
|
};
|
||||||
|
|
||||||
const ColumnsArea = React.createClass({
|
const ColumnsArea = React.createClass({
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'row', flex: '1', marginRight: '10px', marginBottom: '10px', overflowX: 'auto' }}>
|
<div className='columns-area' style={style}>
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
background: '#454b5e',
|
||||||
|
padding: '0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflowY: 'auto'
|
||||||
|
};
|
||||||
|
|
||||||
const Drawer = React.createClass({
|
const Drawer = React.createClass({
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '280px', flex: '0 0 auto', boxSizing: 'border-box', background: '#454b5e', margin: '10px', marginRight: '0', padding: '0', display: 'flex', flexDirection: 'column' }}>
|
<div className='drawer' style={style}>
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const FollowForm = React.createClass({
|
|||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', lineHeight: '20px', padding: '10px', background: '#373b4a' }}>
|
<div style={{ display: 'flex', lineHeight: '20px', padding: '10px', background: '#373b4a' }}>
|
||||||
<input type='text' disabled={this.props.is_submitting} placeholder='username@domain' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='follow-form__input' style={{ flex: '1 1 auto', boxSizing: 'border-box', display: 'block', border: 'none', padding: '10px', fontFamily: 'Roboto', color: '#282c37', fontSize: '14px', margin: '0' }} />
|
<input autoComplete='off' type='text' disabled={this.props.is_submitting} placeholder='username@domain' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='follow-form__input' style={{ flex: '1 1 auto', boxSizing: 'border-box', display: 'block', border: 'none', padding: '10px', fontFamily: 'Roboto', color: '#282c37', fontSize: '14px', margin: '0' }} />
|
||||||
<div style={{ padding: '10px', paddingRight: '0' }}><IconButton title='Follow' size={20} icon='user-plus' onClick={this.handleSubmit} disabled={this.props.is_submitting} /></div>
|
<div style={{ padding: '10px', paddingRight: '0' }}><IconButton title='Follow' size={20} icon='user-plus' onClick={this.handleSubmit} disabled={this.props.is_submitting} /></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ const NavigationBar = React.createClass({
|
|||||||
<div style={{ padding: '10px', display: 'flex', cursor: 'default' }}>
|
<div style={{ padding: '10px', display: 'flex', cursor: 'default' }}>
|
||||||
<Link to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Link>
|
<Link to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Link>
|
||||||
|
|
||||||
<div style={{ flex: '1 1 auto', marginLeft: '8px' }}>
|
<div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}>
|
||||||
<strong style={{ fontWeight: '500', display: 'block' }}>{this.props.account.get('acct')}</strong>
|
<strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong>
|
||||||
<a href='/settings' style={{ color: '#9baec8', textDecoration: 'none' }}>Settings <i className='fa fa fa-cog' /></a>
|
<a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}>Settings</a> · <Link to='/statuses/all' style={{ color: 'inherit', textDecoration: 'none' }}>Public timeline</Link> · <a href='/auth/sign_out' data-method='delete' style={{ color: 'inherit', textDecoration: 'none' }}>Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
const outerStyle = {
|
||||||
|
background: '#373b4a',
|
||||||
|
margin: '10px',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
marginBottom: '0',
|
||||||
|
display: 'flex'
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabStyle = {
|
||||||
|
display: 'block',
|
||||||
|
flex: '1 1 auto',
|
||||||
|
padding: '10px',
|
||||||
|
color: '#fff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
borderBottom: '2px solid #373b4a'
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabActiveStyle = {
|
||||||
|
borderBottom: '2px solid #2b90d9',
|
||||||
|
color: '#2b90d9'
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabsBar = () => {
|
||||||
|
return (
|
||||||
|
<div style={outerStyle}>
|
||||||
|
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/new'><i className='fa fa-fw fa-pencil' /> Compose</Link>
|
||||||
|
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/home'><i className='fa fa-fw fa-home' /> Home</Link>
|
||||||
|
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/mentions'><i className='fa fa-fw fa-at' /> Mentions</Link>
|
||||||
|
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/all'><i className='fa fa-fw fa-globe' /> Public</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TabsBar;
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import ComposeForm from '../components/compose_form';
|
import ComposeForm from '../components/compose_form';
|
||||||
import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose';
|
import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose';
|
||||||
import { selectStatus } from '../../../reducers/timelines';
|
import { getStatus } from '../../../selectors';
|
||||||
|
|
||||||
const mapStateToProps = function (state, props) {
|
const mapStateToProps = function (state, props) {
|
||||||
return {
|
return {
|
||||||
text: state.getIn(['compose', 'text']),
|
text: state.getIn(['compose', 'text']),
|
||||||
is_submitting: state.getIn(['compose', 'is_submitting']),
|
is_submitting: state.getIn(['compose', 'is_submitting']),
|
||||||
is_uploading: state.getIn(['compose', 'is_uploading']),
|
is_uploading: state.getIn(['compose', 'is_uploading']),
|
||||||
in_reply_to: selectStatus(state, state.getIn(['compose', 'in_reply_to']))
|
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,10 @@ import {
|
|||||||
dismissNotification,
|
dismissNotification,
|
||||||
clearNotifications
|
clearNotifications
|
||||||
} from '../../../actions/notifications';
|
} from '../../../actions/notifications';
|
||||||
|
import { getNotifications } from '../../../selectors';
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
notifications: state.get('notifications').map((item, i) => ({
|
notifications: getNotifications(state)
|
||||||
message: item.get('message'),
|
|
||||||
title: item.get('title'),
|
|
||||||
key: item.get('key'),
|
|
||||||
dismissAfter: 5000
|
|
||||||
})).toJS()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => {
|
const mapDispatchToProps = (dispatch) => {
|
||||||
|
|||||||
@@ -8,14 +8,18 @@ import {
|
|||||||
unfavourite
|
unfavourite
|
||||||
} from '../../../actions/interactions';
|
} from '../../../actions/interactions';
|
||||||
import { expandTimeline } from '../../../actions/timelines';
|
import { expandTimeline } from '../../../actions/timelines';
|
||||||
import { selectStatus } from '../../../reducers/timelines';
|
import { makeGetTimeline } from '../../../selectors';
|
||||||
import { deleteStatus } from '../../../actions/statuses';
|
import { deleteStatus } from '../../../actions/statuses';
|
||||||
|
|
||||||
const mapStateToProps = function (state, props) {
|
const makeMapStateToProps = () => {
|
||||||
return {
|
const getTimeline = makeGetTimeline();
|
||||||
statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id)),
|
|
||||||
|
const mapStateToProps = (state, props) => ({
|
||||||
|
statuses: getTimeline(state, props.type),
|
||||||
me: state.getIn(['timelines', 'me'])
|
me: state.getIn(['timelines', 'me'])
|
||||||
};
|
});
|
||||||
|
|
||||||
|
return mapStateToProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = function (dispatch, props) {
|
const mapDispatchToProps = function (dispatch, props) {
|
||||||
@@ -50,4 +54,4 @@ const mapDispatchToProps = function (dispatch, props) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(StatusList);
|
export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
|
||||||
|
|||||||
@@ -1,49 +1,38 @@
|
|||||||
import ColumnsArea from './components/columns_area';
|
import ColumnsArea from './components/columns_area';
|
||||||
import Column from './components/column';
|
|
||||||
import Drawer from './components/drawer';
|
|
||||||
import ComposeFormContainer from './containers/compose_form_container';
|
|
||||||
import FollowFormContainer from './containers/follow_form_container';
|
|
||||||
import UploadFormContainer from './containers/upload_form_container';
|
|
||||||
import StatusListContainer from './containers/status_list_container';
|
|
||||||
import NotificationsContainer from './containers/notifications_container';
|
import NotificationsContainer from './containers/notifications_container';
|
||||||
import NavigationContainer from './containers/navigation_container';
|
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import LoadingBarContainer from './containers/loading_bar_container';
|
import LoadingBarContainer from './containers/loading_bar_container';
|
||||||
|
import HomeTimeline from '../home_timeline';
|
||||||
|
import MentionsTimeline from '../mentions_timeline';
|
||||||
|
import Compose from '../compose';
|
||||||
|
import MediaQuery from 'react-responsive';
|
||||||
|
import TabsBar from './components/tabs_bar';
|
||||||
|
|
||||||
const UI = React.createClass({
|
const UI = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
|
||||||
router: React.PropTypes.object
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
const layoutBreakpoint = 1024;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: '0 0 auto', display: 'flex', width: '100%', height: '100%', background: '#1a1c23' }}>
|
<div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', width: '100%', height: '100%', background: '#1a1c23' }}>
|
||||||
<Drawer>
|
<MediaQuery maxWidth={layoutBreakpoint}>
|
||||||
<div style={{ flex: '1 1 auto' }}>
|
<TabsBar />
|
||||||
<NavigationContainer />
|
</MediaQuery>
|
||||||
<ComposeFormContainer />
|
|
||||||
<UploadFormContainer />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FollowFormContainer />
|
<MediaQuery maxWidth={layoutBreakpoint} component={ColumnsArea}>
|
||||||
</Drawer>
|
{this.props.children}
|
||||||
|
</MediaQuery>
|
||||||
|
|
||||||
<ColumnsArea>
|
<MediaQuery minWidth={layoutBreakpoint}>
|
||||||
<Column icon='home' heading='Home'>
|
<ColumnsArea>
|
||||||
<StatusListContainer type='home' />
|
<Compose />
|
||||||
</Column>
|
<HomeTimeline />
|
||||||
|
<MentionsTimeline />
|
||||||
<Column icon='at' heading='Mentions'>
|
|
||||||
<StatusListContainer type='mentions' />
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
<Column>
|
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</Column>
|
</ColumnsArea>
|
||||||
</ColumnsArea>
|
</MediaQuery>
|
||||||
|
|
||||||
<NotificationsContainer />
|
<NotificationsContainer />
|
||||||
<LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} />
|
<LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} />
|
||||||
|
|||||||
31
app/assets/javascripts/components/middleware/errors.jsx
Normal file
31
app/assets/javascripts/components/middleware/errors.jsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { showNotification } from '../actions/notifications';
|
||||||
|
|
||||||
|
const defaultFailSuffix = 'FAIL';
|
||||||
|
|
||||||
|
export default function errorsMiddleware() {
|
||||||
|
return ({ dispatch }) => next => action => {
|
||||||
|
if (action.type) {
|
||||||
|
const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
|
||||||
|
|
||||||
|
if (action.type.match(isFail)) {
|
||||||
|
if (action.error.response) {
|
||||||
|
const { data, status, statusText } = action.error.response;
|
||||||
|
|
||||||
|
let message = statusText;
|
||||||
|
let title = `${status}`;
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
message = data.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(showNotification(title, message));
|
||||||
|
} else {
|
||||||
|
console.error(action.error);
|
||||||
|
dispatch(showNotification('Oops!', 'An unexpected error occurred. Inspect the console for more details'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(action);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,68 +1,20 @@
|
|||||||
import { COMPOSE_SUBMIT_FAIL, COMPOSE_UPLOAD_FAIL } from '../actions/compose';
|
|
||||||
import { FOLLOW_SUBMIT_FAIL } from '../actions/follow';
|
|
||||||
import {
|
import {
|
||||||
REBLOG_FAIL,
|
NOTIFICATION_SHOW,
|
||||||
UNREBLOG_FAIL,
|
NOTIFICATION_DISMISS,
|
||||||
FAVOURITE_FAIL,
|
NOTIFICATION_CLEAR
|
||||||
UNFAVOURITE_FAIL
|
} from '../actions/notifications';
|
||||||
} from '../actions/interactions';
|
import Immutable from 'immutable';
|
||||||
import {
|
|
||||||
TIMELINE_REFRESH_FAIL,
|
|
||||||
TIMELINE_EXPAND_FAIL
|
|
||||||
} from '../actions/timelines';
|
|
||||||
import { NOTIFICATION_DISMISS, NOTIFICATION_CLEAR } from '../actions/notifications';
|
|
||||||
import {
|
|
||||||
ACCOUNT_FETCH_FAIL,
|
|
||||||
ACCOUNT_FOLLOW_FAIL,
|
|
||||||
ACCOUNT_UNFOLLOW_FAIL,
|
|
||||||
ACCOUNT_TIMELINE_FETCH_FAIL,
|
|
||||||
ACCOUNT_TIMELINE_EXPAND_FAIL
|
|
||||||
} from '../actions/accounts';
|
|
||||||
import {
|
|
||||||
STATUS_FETCH_FAIL,
|
|
||||||
STATUS_DELETE_FAIL
|
|
||||||
} from '../actions/statuses';
|
|
||||||
import Immutable from 'immutable';
|
|
||||||
|
|
||||||
const initialState = Immutable.List();
|
const initialState = Immutable.List([]);
|
||||||
|
|
||||||
function notificationFromError(state, error) {
|
|
||||||
let n = Immutable.Map({
|
|
||||||
key: state.size > 0 ? state.last().get('key') + 1 : 0,
|
|
||||||
message: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error.response) {
|
|
||||||
n = n.withMutations(map => {
|
|
||||||
map.set('message', error.response.statusText);
|
|
||||||
map.set('title', `${error.response.status}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
n = n.set('message', `${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.push(n);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function notifications(state = initialState, action) {
|
export default function notifications(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case COMPOSE_SUBMIT_FAIL:
|
case NOTIFICATION_SHOW:
|
||||||
case COMPOSE_UPLOAD_FAIL:
|
return state.push(Immutable.Map({
|
||||||
case FOLLOW_SUBMIT_FAIL:
|
key: state.size > 0 ? state.last().get('key') + 1 : 0,
|
||||||
case REBLOG_FAIL:
|
title: action.title,
|
||||||
case FAVOURITE_FAIL:
|
message: action.message
|
||||||
case TIMELINE_REFRESH_FAIL:
|
}));
|
||||||
case TIMELINE_EXPAND_FAIL:
|
|
||||||
case ACCOUNT_FETCH_FAIL:
|
|
||||||
case ACCOUNT_FOLLOW_FAIL:
|
|
||||||
case ACCOUNT_UNFOLLOW_FAIL:
|
|
||||||
case ACCOUNT_TIMELINE_FETCH_FAIL:
|
|
||||||
case ACCOUNT_TIMELINE_EXPAND_FAIL:
|
|
||||||
case STATUS_FETCH_FAIL:
|
|
||||||
case STATUS_DELETE_FAIL:
|
|
||||||
case UNREBLOG_FAIL:
|
|
||||||
case UNFAVOURITE_FAIL:
|
|
||||||
return notificationFromError(state, action.error);
|
|
||||||
case NOTIFICATION_DISMISS:
|
case NOTIFICATION_DISMISS:
|
||||||
return state.filterNot(item => item.get('key') === action.notification.key);
|
return state.filterNot(item => item.get('key') === action.notification.key);
|
||||||
case NOTIFICATION_CLEAR:
|
case NOTIFICATION_CLEAR:
|
||||||
|
|||||||
@@ -25,53 +25,30 @@ import {
|
|||||||
STATUS_DELETE_SUCCESS
|
STATUS_DELETE_SUCCESS
|
||||||
} from '../actions/statuses';
|
} from '../actions/statuses';
|
||||||
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
|
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
|
||||||
|
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const initialState = Immutable.Map({
|
const initialState = Immutable.Map({
|
||||||
home: Immutable.List([]),
|
home: Immutable.List([]),
|
||||||
mentions: Immutable.List([]),
|
mentions: Immutable.List([]),
|
||||||
|
public: Immutable.List([]),
|
||||||
statuses: Immutable.Map(),
|
statuses: Immutable.Map(),
|
||||||
accounts: Immutable.Map(),
|
accounts: Immutable.Map(),
|
||||||
accounts_timelines: Immutable.Map(),
|
accounts_timelines: Immutable.Map(),
|
||||||
me: null,
|
me: null,
|
||||||
ancestors: Immutable.Map(),
|
ancestors: Immutable.Map(),
|
||||||
descendants: Immutable.Map(),
|
descendants: Immutable.Map(),
|
||||||
relationships: Immutable.Map()
|
relationships: Immutable.Map(),
|
||||||
|
suggestions: Immutable.List([])
|
||||||
});
|
});
|
||||||
|
|
||||||
export function selectStatus(state, id) {
|
|
||||||
let status = state.getIn(['timelines', 'statuses', id], null);
|
|
||||||
|
|
||||||
if (status === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
status = status.set('account', selectAccount(state, status.get('account')));
|
|
||||||
|
|
||||||
if (status.get('reblog') !== null) {
|
|
||||||
status = status.set('reblog', selectStatus(state, status.get('reblog')));
|
|
||||||
}
|
|
||||||
|
|
||||||
return status;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function selectAccount(state, id) {
|
|
||||||
let account = state.getIn(['timelines', 'accounts', id], null);
|
|
||||||
|
|
||||||
if (account === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return account.set('relationship', state.getIn(['timelines', 'relationships', id]));
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeStatus(state, status) {
|
function normalizeStatus(state, status) {
|
||||||
// Separate account
|
// Separate account
|
||||||
let account = status.get('account');
|
let account = status.get('account');
|
||||||
status = status.set('account', account.get('id'));
|
status = status.set('account', account.get('id'));
|
||||||
|
|
||||||
// Separate reblog, repeat for reblog
|
// Separate reblog, repeat for reblog
|
||||||
let reblog = status.get('reblog');
|
let reblog = status.get('reblog', null);
|
||||||
|
|
||||||
if (reblog !== null) {
|
if (reblog !== null) {
|
||||||
status = status.set('reblog', reblog.get('id'));
|
status = status.set('reblog', reblog.get('id'));
|
||||||
@@ -101,16 +78,18 @@ function normalizeStatus(state, status) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function normalizeTimeline(state, timeline, statuses) {
|
function normalizeTimeline(state, timeline, statuses) {
|
||||||
|
let ids = Immutable.List([]);
|
||||||
|
|
||||||
statuses.forEach((status, i) => {
|
statuses.forEach((status, i) => {
|
||||||
state = normalizeStatus(state, status);
|
state = normalizeStatus(state, status);
|
||||||
state = state.setIn([timeline, i], status.get('id'));
|
ids = ids.set(i, status.get('id'));
|
||||||
});
|
});
|
||||||
|
|
||||||
return state;
|
return state.set(timeline, ids);
|
||||||
};
|
};
|
||||||
|
|
||||||
function appendNormalizedTimeline(state, timeline, statuses) {
|
function appendNormalizedTimeline(state, timeline, statuses) {
|
||||||
let moreIds = Immutable.List();
|
let moreIds = Immutable.List([]);
|
||||||
|
|
||||||
statuses.forEach((status, i) => {
|
statuses.forEach((status, i) => {
|
||||||
state = normalizeStatus(state, status);
|
state = normalizeStatus(state, status);
|
||||||
@@ -121,29 +100,44 @@ function appendNormalizedTimeline(state, timeline, statuses) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function normalizeAccountTimeline(state, accountId, statuses) {
|
function normalizeAccountTimeline(state, accountId, statuses) {
|
||||||
|
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => {
|
||||||
|
return (list.size > 0) ? list.clear() : list;
|
||||||
|
});
|
||||||
|
|
||||||
statuses.forEach((status, i) => {
|
statuses.forEach((status, i) => {
|
||||||
state = normalizeStatus(state, status);
|
state = normalizeStatus(state, status);
|
||||||
state = state.updateIn(['accounts_timelines', accountId], Immutable.List(), list => list.set(i, status.get('id')));
|
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.set(i, status.get('id')));
|
||||||
});
|
});
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
|
|
||||||
function appendNormalizedAccountTimeline(state, accountId, statuses) {
|
function appendNormalizedAccountTimeline(state, accountId, statuses) {
|
||||||
let moreIds = Immutable.List();
|
let moreIds = Immutable.List([]);
|
||||||
|
|
||||||
statuses.forEach((status, i) => {
|
statuses.forEach((status, i) => {
|
||||||
state = normalizeStatus(state, status);
|
state = normalizeStatus(state, status);
|
||||||
moreIds = moreIds.set(i, status.get('id'));
|
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.List([]), list => list.push(...moreIds));
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateTimeline(state, timeline, status) {
|
function updateTimeline(state, timeline, status) {
|
||||||
state = normalizeStatus(state, status);
|
state = normalizeStatus(state, status);
|
||||||
state = state.update(timeline, list => list.unshift(status.get('id')));
|
|
||||||
state = state.updateIn(['accounts_timelines', status.getIn(['account', 'id'])], Immutable.List(), list => list.unshift(status.get('id')));
|
state = state.update(timeline, list => {
|
||||||
|
const reblogOfId = status.getIn(['reblog', 'id'], null);
|
||||||
|
|
||||||
|
if (reblogOfId !== null) {
|
||||||
|
const otherReblogs = state.get('statuses').filter(item => item.get('reblog') === reblogOfId).map((_, itemId) => itemId);
|
||||||
|
list = list.filterNot(itemId => (itemId === reblogOfId || otherReblogs.includes(itemId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.unshift(status.get('id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
state = state.updateIn(['accounts_timelines', status.getIn(['account', 'id'])], Immutable.List([]), list => (list.includes(status.get('id')) ? list : list.unshift(status.get('id'))));
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
@@ -161,7 +155,7 @@ function deleteStatus(state, id) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Remove references from account timelines
|
// Remove references from account timelines
|
||||||
state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List(), list => list.filterNot(item => item === id));
|
state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List([]), list => list.filterNot(item => item === id));
|
||||||
|
|
||||||
// Remove reblogs of deleted status
|
// Remove reblogs of deleted status
|
||||||
const references = state.get('statuses').filter(item => item.get('reblog') === id);
|
const references = state.get('statuses').filter(item => item.get('reblog') === id);
|
||||||
@@ -183,6 +177,10 @@ function normalizeAccount(state, account, relationship) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function normalizeRelationship(state, relationship) {
|
function normalizeRelationship(state, relationship) {
|
||||||
|
if (state.get('suggestions').includes(relationship.get('id')) && (relationship.get('following') || relationship.get('blocking'))) {
|
||||||
|
state = state.update('suggestions', list => list.filterNot(id => id === relationship.get('id')));
|
||||||
|
}
|
||||||
|
|
||||||
return state.setIn(['relationships', relationship.get('id')], relationship);
|
return state.setIn(['relationships', relationship.get('id')], relationship);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,6 +208,14 @@ function normalizeContext(state, status, ancestors, descendants) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeSuggestions(state, accounts) {
|
||||||
|
accounts.forEach(account => {
|
||||||
|
state = state.setIn(['accounts', account.get('id')], account);
|
||||||
|
});
|
||||||
|
|
||||||
|
return state.set('suggestions', accounts.map(account => account.get('id')));
|
||||||
|
};
|
||||||
|
|
||||||
export default function timelines(state = initialState, action) {
|
export default function timelines(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case TIMELINE_REFRESH_SUCCESS:
|
case TIMELINE_REFRESH_SUCCESS:
|
||||||
@@ -242,6 +248,8 @@ export default function timelines(state = initialState, action) {
|
|||||||
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
||||||
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
||||||
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
||||||
|
case SUGGESTIONS_FETCH_SUCCESS:
|
||||||
|
return normalizeSuggestions(state, Immutable.fromJS(action.suggestions));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
92
app/assets/javascripts/components/selectors/index.jsx
Normal file
92
app/assets/javascripts/components/selectors/index.jsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { createSelector } from 'reselect'
|
||||||
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
|
const getStatuses = state => state.getIn(['timelines', 'statuses']);
|
||||||
|
const getAccounts = state => state.getIn(['timelines', 'accounts']);
|
||||||
|
|
||||||
|
const getAccountBase = (state, id) => state.getIn(['timelines', 'accounts', id], null);
|
||||||
|
const getAccountRelationship = (state, id) => state.getIn(['timelines', 'relationships', id]);
|
||||||
|
|
||||||
|
export const getAccount = createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
|
||||||
|
if (base === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.set('relationship', relationship);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusBase = (state, id) => state.getIn(['timelines', 'statuses', id], null);
|
||||||
|
|
||||||
|
export const getStatus = createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => {
|
||||||
|
if (base === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assembleStatus(base.get('id'), statuses, accounts);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAccountTimelineIds = (state, id) => state.getIn(['timelines', 'accounts_timelines', id], Immutable.List());
|
||||||
|
|
||||||
|
const assembleStatus = (id, statuses, accounts) => {
|
||||||
|
let status = statuses.get(id, null);
|
||||||
|
let reblog = null;
|
||||||
|
|
||||||
|
if (status === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.get('reblog', null) !== null) {
|
||||||
|
reblog = statuses.get(status.get('reblog'), null);
|
||||||
|
|
||||||
|
if (reblog !== null) {
|
||||||
|
reblog = reblog.set('account', accounts.get(reblog.get('account')));
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status.set('reblog', reblog).set('account', accounts.get(status.get('account')));
|
||||||
|
};
|
||||||
|
|
||||||
|
const assembleStatusList = (ids, statuses, accounts) => {
|
||||||
|
return ids.map(statusId => assembleStatus(statusId, statuses, accounts)).filterNot(status => status === null);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAccountTimeline = createSelector([getAccountTimelineIds, getStatuses, getAccounts], assembleStatusList);
|
||||||
|
|
||||||
|
const getTimelineIds = (state, timelineType) => state.getIn(['timelines', timelineType]);
|
||||||
|
|
||||||
|
export const makeGetTimeline = () => {
|
||||||
|
return createSelector([getTimelineIds, getStatuses, getAccounts], assembleStatusList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusAncestorsIds = (state, id) => state.getIn(['timelines', 'ancestors', id], Immutable.OrderedSet());
|
||||||
|
|
||||||
|
export const getStatusAncestors = createSelector([getStatusAncestorsIds, getStatuses, getAccounts], assembleStatusList);
|
||||||
|
|
||||||
|
const getStatusDescendantsIds = (state, id) => state.getIn(['timelines', 'descendants', id], Immutable.OrderedSet());
|
||||||
|
|
||||||
|
export const getStatusDescendants = createSelector([getStatusDescendantsIds, getStatuses, getAccounts], assembleStatusList);
|
||||||
|
|
||||||
|
const getNotificationsBase = state => state.get('notifications');
|
||||||
|
|
||||||
|
export const getNotifications = createSelector([getNotificationsBase], (base) => {
|
||||||
|
let arr = [];
|
||||||
|
|
||||||
|
base.forEach(item => {
|
||||||
|
arr.push({
|
||||||
|
message: item.get('message'),
|
||||||
|
title: item.get('title'),
|
||||||
|
key: item.get('key'),
|
||||||
|
dismissAfter: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return arr;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSuggestionsBase = (state) => state.getIn(['timelines', 'suggestions']);
|
||||||
|
|
||||||
|
export const getSuggestions = createSelector([getSuggestionsBase, getAccounts], (base, accounts) => {
|
||||||
|
return base.map(accountId => accounts.get(accountId));
|
||||||
|
});
|
||||||
@@ -2,9 +2,10 @@ import { createStore, applyMiddleware, compose } from 'redux';
|
|||||||
import thunk from 'redux-thunk';
|
import thunk from 'redux-thunk';
|
||||||
import appReducer from '../reducers';
|
import appReducer from '../reducers';
|
||||||
import { loadingBarMiddleware } from 'react-redux-loading-bar';
|
import { loadingBarMiddleware } from 'react-redux-loading-bar';
|
||||||
|
import errorsMiddleware from '../middleware/errors';
|
||||||
|
|
||||||
export default function configureStore(initialState) {
|
export default function configureStore(initialState) {
|
||||||
return createStore(appReducer, initialState, compose(applyMiddleware(thunk, loadingBarMiddleware({
|
return createStore(appReducer, initialState, compose(applyMiddleware(thunk, loadingBarMiddleware({
|
||||||
promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
|
promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
|
||||||
})), window.devToolsExtension ? window.devToolsExtension() : f => f));
|
}), errorsMiddleware()), window.devToolsExtension ? window.devToolsExtension() : f => f));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,6 +58,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.details {
|
.details {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
|
|||||||
@@ -57,6 +57,43 @@ table {
|
|||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #42495b;
|
||||||
|
border: 0px none #ffffff;
|
||||||
|
border-radius: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #525a70;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:active {
|
||||||
|
background: #42495b;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
border: 0px none #ffffff;
|
||||||
|
border-radius: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track:hover {
|
||||||
|
background: #282c37;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track:active {
|
||||||
|
background: #282c37;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Roboto', sans-serif;
|
font-family: 'Roboto', sans-serif;
|
||||||
background: #282c37 image-url('background-photo.jpeg');
|
background: #282c37 image-url('background-photo.jpeg');
|
||||||
@@ -152,173 +189,6 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-container {
|
|
||||||
max-width: 400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
|
|
||||||
.field {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-field {
|
|
||||||
padding: 15px 0;
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-size: 16px;
|
|
||||||
color: #fff;
|
|
||||||
width: 100px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=file] {
|
|
||||||
width: 280px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=text], input[type=email], input[type=password], textarea {
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 2px solid #9baec8;
|
|
||||||
padding: 7px 0;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #fff;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
outline: 0;
|
|
||||||
font-family: 'Roboto';
|
|
||||||
|
|
||||||
&:invalid {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus:invalid {
|
|
||||||
border-bottom-color: #df405a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:required:valid {
|
|
||||||
border-bottom-color: #79bd9a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active, &:focus {
|
|
||||||
border-bottom-color: #2b90d9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.field_with_error {
|
|
||||||
input[type=text], input[type=email], input[type=password] {
|
|
||||||
border-bottom-color: #df405a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #9baec8;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.prompt-highlight {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
code.copypasteable {
|
|
||||||
display: block;
|
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 20px;
|
|
||||||
background: #282c37;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
margin-top: 30px;
|
|
||||||
|
|
||||||
button {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #2b90d9;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
outline: 0;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: lighten(#2b90d9, 5%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active, &:focus {
|
|
||||||
position: relative;
|
|
||||||
top: 1px;
|
|
||||||
background-color: darken(#2b90d9, 5%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.negative {
|
|
||||||
background: #df405a;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: lighten(#df405a, 5%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active, &:focus {
|
|
||||||
background-color: darken(#df405a, 5%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-footer {
|
|
||||||
margin-top: 30px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #9baec8;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #d9e1e8;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#error_explanation {
|
|
||||||
background: #282c37;
|
|
||||||
color: #9baec8;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 15px 10px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-left: 15px;
|
|
||||||
list-style: circle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-list {
|
.no-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
||||||
@@ -359,6 +229,7 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@import 'forms';
|
||||||
@import 'accounts';
|
@import 'accounts';
|
||||||
@import 'stream_entries';
|
@import 'stream_entries';
|
||||||
@import 'components';
|
@import 'components';
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@@ -197,7 +198,7 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
display: block;
|
display: block;
|
||||||
padding: 6px 16px;
|
padding: 6px 16px;
|
||||||
width: 120px;
|
width: 100px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background: #d9e1e8;
|
background: #d9e1e8;
|
||||||
color: #282c37;
|
color: #282c37;
|
||||||
@@ -208,3 +209,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.static-content {
|
||||||
|
padding: 10px;
|
||||||
|
padding-top: 20px;
|
||||||
|
color: #616b86;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns-area {
|
||||||
|
margin: 10px;
|
||||||
|
margin-left: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
width: 330px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column, .drawer {
|
||||||
|
margin-left: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1024px) {
|
||||||
|
.column, .drawer {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns-area {
|
||||||
|
margin: 10px;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
182
app/assets/stylesheets/forms.scss
Normal file
182
app/assets/stylesheets/forms.scss
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
.form-container {
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple_form {
|
||||||
|
.input {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input.file {
|
||||||
|
padding: 15px 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fff;
|
||||||
|
width: 100px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=file] {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields-group {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input.boolean {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-size: 14px;
|
||||||
|
color: #9baec8;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=checkbox] {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: -13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=text], input[type=email], input[type=password], textarea {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid #9baec8;
|
||||||
|
padding: 7px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fff;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
outline: 0;
|
||||||
|
font-family: 'Roboto';
|
||||||
|
|
||||||
|
&:invalid {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus:invalid {
|
||||||
|
border-bottom-color: #df405a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:required:valid {
|
||||||
|
border-bottom-color: #79bd9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active, &:focus {
|
||||||
|
border-bottom-color: #2b90d9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input.field_with_errors {
|
||||||
|
input[type=text], input[type=email], input[type=password] {
|
||||||
|
border-bottom-color: #df405a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #df405a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #9baec8;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.prompt-highlight {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code.copypasteable {
|
||||||
|
display: block;
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
background: #282c37;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 30px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #2b90d9;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
outline: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: lighten(#2b90d9, 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active, &:focus {
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
background-color: darken(#2b90d9, 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.negative {
|
||||||
|
background: #df405a;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: lighten(#df405a, 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active, &:focus {
|
||||||
|
background-color: darken(#df405a, 5%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-message {
|
||||||
|
background: #282c37;
|
||||||
|
color: #9baec8;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px 10px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #9baec8;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #d9e1e8;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -46,13 +46,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.activity-stream-headless {
|
.entry:first-child {
|
||||||
.entry:first-child {
|
border-radius: 4px 4px 0 0;
|
||||||
border-radius: 4px 4px 0 0;
|
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,19 +73,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 360px) {
|
|
||||||
.avatar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry__container {
|
.entry__container {
|
||||||
display: flex;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
|
float: left;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
@@ -98,7 +91,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entry__container__container {
|
.entry__container__container {
|
||||||
flex-grow: 1;
|
margin-left: 86px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -153,10 +146,12 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 0 10px;
|
padding: 0 15px;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
padding-bottom: 15px;
|
padding-bottom: 15px;
|
||||||
color: #282c37;
|
color: #282c37;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
@@ -224,4 +219,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 360px) {
|
||||||
|
.avatar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__container__container {
|
||||||
|
margin-left: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
app/channels/public_channel.rb
Normal file
19
app/channels/public_channel.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading.
|
||||||
|
class PublicChannel < ApplicationCable::Channel
|
||||||
|
def subscribed
|
||||||
|
stream_from 'timeline:public', -> (encoded_message) do
|
||||||
|
message = ActiveSupport::JSON.decode(encoded_message)
|
||||||
|
|
||||||
|
status = Status.find_by(id: message['id'])
|
||||||
|
next if status.nil? || current_user.account.blocking?(status.account) || (status.reblog? && current_user.account.blocking?(status.reblog.account))
|
||||||
|
|
||||||
|
message['message'] = FeedManager.instance.inline_render(current_user.account, status)
|
||||||
|
|
||||||
|
transmit message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def unsubscribed
|
||||||
|
# Any cleanup needed when channel is unsubscribed
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -16,6 +16,16 @@ class AccountsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def follow
|
||||||
|
FollowService.new.call(current_user.account, @account.acct)
|
||||||
|
redirect_to account_path(@account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unfollow
|
||||||
|
UnfollowService.new.call(current_user.account, @account)
|
||||||
|
redirect_to account_path(@account)
|
||||||
|
end
|
||||||
|
|
||||||
def followers
|
def followers
|
||||||
@followers = @account.followers.order('follows.created_at desc').paginate(page: params[:page], per_page: 6)
|
@followers = @account.followers.order('follows.created_at desc').paginate(page: params[:page], per_page: 6)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ class Api::SalmonController < ApiController
|
|||||||
respond_to :txt
|
respond_to :txt
|
||||||
|
|
||||||
def update
|
def update
|
||||||
ProcessInteractionService.new.call(request.body.read, @account)
|
body = request.body.read
|
||||||
head 201
|
|
||||||
|
if body.nil?
|
||||||
|
head 200
|
||||||
|
else
|
||||||
|
ProcessInteractionService.new.call(body, @account)
|
||||||
|
head 201
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ class Api::SubscriptionsController < ApiController
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
body = request.body.read
|
body = request.body.read
|
||||||
|
subscription = @account.subscription(api_subscription_url(@account.id))
|
||||||
|
|
||||||
if @account.subscription(api_subscription_url(@account.id)).verify(body, request.headers['HTTP_X_HUB_SIGNATURE'])
|
if subscription.verify(body, request.headers['HTTP_X_HUB_SIGNATURE'])
|
||||||
ProcessFeedService.new.call(body, @account)
|
ProcessFeedService.new.call(body, @account)
|
||||||
head 201
|
head 201
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
class Api::V1::AccountsController < ApiController
|
class Api::V1::AccountsController < ApiController
|
||||||
before_action :doorkeeper_authorize!
|
before_action :doorkeeper_authorize!
|
||||||
before_action :set_account, except: :verify_credentials
|
before_action :set_account, except: [:verify_credentials, :suggestions]
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@@ -19,8 +19,13 @@ class Api::V1::AccountsController < ApiController
|
|||||||
@followers = @account.followers
|
@followers = @account.followers
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def suggestions
|
||||||
|
@accounts = FollowSuggestion.get(current_user.account_id)
|
||||||
|
end
|
||||||
|
|
||||||
def statuses
|
def statuses
|
||||||
@statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a
|
@statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a
|
||||||
|
set_maps(@statuses)
|
||||||
end
|
end
|
||||||
|
|
||||||
def follow
|
def follow
|
||||||
@@ -49,7 +54,7 @@ class Api::V1::AccountsController < ApiController
|
|||||||
|
|
||||||
def relationships
|
def relationships
|
||||||
ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i]
|
ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i]
|
||||||
@accounts = Account.find(ids)
|
@accounts = Account.where(id: ids).select('id')
|
||||||
@following = Account.following_map(ids, current_user.account_id)
|
@following = Account.following_map(ids, current_user.account_id)
|
||||||
@followed_by = Account.followed_by_map(ids, current_user.account_id)
|
@followed_by = Account.followed_by_map(ids, current_user.account_id)
|
||||||
@blocking = Account.blocking_map(ids, current_user.account_id)
|
@blocking = Account.blocking_map(ids, current_user.account_id)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class Api::V1::FollowsController < ApiController
|
|||||||
def create
|
def create
|
||||||
raise ActiveRecord::RecordNotFound if params[:uri].blank?
|
raise ActiveRecord::RecordNotFound if params[:uri].blank?
|
||||||
|
|
||||||
@account = FollowService.new.call(current_user.account, params[:uri]).try(:target_account)
|
@account = FollowService.new.call(current_user.account, params[:uri].strip).try(:target_account)
|
||||||
render action: :show
|
render action: :show
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,5 +4,9 @@ class Api::V1::MediaController < ApiController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@media = MediaAttachment.create!(account: current_user.account, file: params[:file])
|
@media = MediaAttachment.create!(account: current_user.account, file: params[:file])
|
||||||
|
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
|
||||||
|
render json: { error: 'File type of uploaded media could not be verified' }, status: 422
|
||||||
|
rescue Paperclip::Error
|
||||||
|
render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class Api::V1::StatusesController < ApiController
|
|||||||
@status = Status.find(params[:id])
|
@status = Status.find(params[:id])
|
||||||
@ancestors = @status.ancestors
|
@ancestors = @status.ancestors
|
||||||
@descendants = @status.descendants
|
@descendants = @status.descendants
|
||||||
|
set_maps([@status] + @ancestors + @descendants)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -46,9 +47,19 @@ class Api::V1::StatusesController < ApiController
|
|||||||
|
|
||||||
def home
|
def home
|
||||||
@statuses = Feed.new(:home, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
|
@statuses = Feed.new(:home, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
|
||||||
|
set_maps(@statuses)
|
||||||
|
render action: :index
|
||||||
end
|
end
|
||||||
|
|
||||||
def mentions
|
def mentions
|
||||||
@statuses = Feed.new(:mentions, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
|
@statuses = Feed.new(:mentions, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
|
||||||
|
set_maps(@statuses)
|
||||||
|
render action: :index
|
||||||
|
end
|
||||||
|
|
||||||
|
def public
|
||||||
|
@statuses = Status.as_public_timeline(current_user.account).paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a
|
||||||
|
set_maps(@statuses)
|
||||||
|
render action: :index
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -35,4 +35,10 @@ class ApiController < ApplicationController
|
|||||||
def render_empty
|
def render_empty
|
||||||
render json: {}, status: 200
|
render json: {}, status: 200
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_maps(statuses)
|
||||||
|
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact
|
||||||
|
@reblogs_map = Status.reblogs_map(status_ids, current_user.account)
|
||||||
|
@favourites_map = Status.favourites_map(status_ids, current_user.account)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class ApplicationController < ActionController::Base
|
|||||||
rescue_from ActionController::RoutingError, with: :not_found
|
rescue_from ActionController::RoutingError, with: :not_found
|
||||||
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
||||||
|
|
||||||
before_action :store_current_location, unless: :devise_controller?
|
before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
|
||||||
|
|
||||||
def raise_not_found
|
def raise_not_found
|
||||||
raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}"
|
raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}"
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def after_sign_up_path_for(_resource)
|
def after_sign_up_path_for(_resource)
|
||||||
root_path
|
new_user_session_path
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
27
app/controllers/settings/preferences_controller.rb
Normal file
27
app/controllers/settings/preferences_controller.rb
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
class Settings::PreferencesController < ApplicationController
|
||||||
|
layout 'auth'
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
current_user.settings(:notification_emails).follow = user_params[:notification_emails][:follow] == '1'
|
||||||
|
current_user.settings(:notification_emails).reblog = user_params[:notification_emails][:reblog] == '1'
|
||||||
|
current_user.settings(:notification_emails).favourite = user_params[:notification_emails][:favourite] == '1'
|
||||||
|
current_user.settings(:notification_emails).mention = user_params[:notification_emails][:mention] == '1'
|
||||||
|
|
||||||
|
if current_user.save
|
||||||
|
redirect_to settings_preferences_path, notice: 'Changes successfully saved!'
|
||||||
|
else
|
||||||
|
render action: :show
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def user_params
|
||||||
|
params.require(:user).permit(notification_emails: [:follow, :reblog, :favourite, :mention])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class SettingsController < ApplicationController
|
class Settings::ProfilesController < ApplicationController
|
||||||
layout 'auth'
|
layout 'auth'
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
@@ -9,7 +9,7 @@ class SettingsController < ApplicationController
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
if @account.update(account_params)
|
if @account.update(account_params)
|
||||||
redirect_to settings_path
|
redirect_to settings_profile_path, notice: 'Changes successfully saved!'
|
||||||
else
|
else
|
||||||
render action: :show
|
render action: :show
|
||||||
end
|
end
|
||||||
@@ -1,27 +1,40 @@
|
|||||||
class XrdController < ApplicationController
|
class XrdController < ApplicationController
|
||||||
before_action :set_format
|
before_action :set_default_format_json, only: :webfinger
|
||||||
|
before_action :set_default_format_xml, only: :host_meta
|
||||||
|
|
||||||
def host_meta
|
def host_meta
|
||||||
@webfinger_template = "#{webfinger_url}?resource={uri}"
|
@webfinger_template = "#{webfinger_url}?resource={uri}"
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.xml { render content_type: 'application/xrd+xml' }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def webfinger
|
def webfinger
|
||||||
@account = Account.find_local!(username_from_resource)
|
@account = Account.find_local!(username_from_resource)
|
||||||
@canonical_account_uri = "acct:#{@account.username}@#{Rails.configuration.x.local_domain}"
|
@canonical_account_uri = "acct:#{@account.username}@#{Rails.configuration.x.local_domain}"
|
||||||
@magic_key = pem_to_magic_key(@account.keypair.public_key)
|
@magic_key = pem_to_magic_key(@account.keypair.public_key)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.xml { render content_type: 'application/xrd+xml' }
|
||||||
|
format.json { render content_type: 'application/jrd+json' }
|
||||||
|
end
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
head 404
|
head 404
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_format
|
def set_default_format_xml
|
||||||
request.format = 'xml'
|
request.format = 'xml' if request.headers["HTTP_ACCEPT"].nil? && params[:format].nil?
|
||||||
response.headers['Content-Type'] = 'application/xrd+xml'
|
end
|
||||||
|
|
||||||
|
def set_default_format_json
|
||||||
|
request.format = 'json' if request.headers["HTTP_ACCEPT"].nil? && params[:format].nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def username_from_resource
|
def username_from_resource
|
||||||
if resource_param.start_with?('acct:')
|
if resource_param.start_with?('acct:') || resource_param.include?('@')
|
||||||
resource_param.split('@').first.gsub('acct:', '')
|
resource_param.split('@').first.gsub('acct:', '')
|
||||||
else
|
else
|
||||||
url = Addressable::URI.parse(resource_param)
|
url = Addressable::URI.parse(resource_param)
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ module AtomBuilderHelper
|
|||||||
verb xml, stream_entry.verb
|
verb xml, stream_entry.verb
|
||||||
link_self xml, account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom')
|
link_self xml, account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom')
|
||||||
link_alternate xml, account_stream_entry_url(stream_entry.account, stream_entry)
|
link_alternate xml, account_stream_entry_url(stream_entry.account, stream_entry)
|
||||||
|
object_type xml, stream_entry.object_type
|
||||||
|
|
||||||
# Comments need thread element
|
# Comments need thread element
|
||||||
if stream_entry.threaded?
|
if stream_entry.threaded?
|
||||||
@@ -157,17 +158,18 @@ module AtomBuilderHelper
|
|||||||
|
|
||||||
if stream_entry.targeted?
|
if stream_entry.targeted?
|
||||||
target(xml) do
|
target(xml) do
|
||||||
|
simple_id xml, TagManager.instance.uri_for(stream_entry.target)
|
||||||
|
|
||||||
if stream_entry.target.object_type == :person
|
if stream_entry.target.object_type == :person
|
||||||
include_author xml, stream_entry.target
|
include_author xml, stream_entry.target
|
||||||
else
|
else
|
||||||
object_type xml, stream_entry.target.object_type
|
object_type xml, stream_entry.target.object_type
|
||||||
simple_id xml, TagManager.instance.uri_for(stream_entry.target)
|
|
||||||
title xml, stream_entry.target.title
|
title xml, stream_entry.target.title
|
||||||
link_alternate xml, TagManager.instance.url_for(stream_entry.target)
|
link_alternate xml, TagManager.instance.url_for(stream_entry.target)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Statuses have content and author
|
# Statuses have content and author
|
||||||
if [:note, :comment].include? stream_entry.target.object_type
|
if stream_entry.target.is_a?(Status)
|
||||||
content xml, conditionally_formatted(stream_entry.target)
|
content xml, conditionally_formatted(stream_entry.target)
|
||||||
verb xml, stream_entry.target.verb
|
verb xml, stream_entry.target.verb
|
||||||
published_at xml, stream_entry.target.created_at
|
published_at xml, stream_entry.target.created_at
|
||||||
@@ -176,10 +178,16 @@ module AtomBuilderHelper
|
|||||||
author(xml) do
|
author(xml) do
|
||||||
include_author xml, stream_entry.target.account
|
include_author xml, stream_entry.target.account
|
||||||
end
|
end
|
||||||
|
|
||||||
|
stream_entry.target.mentions.each do |mention|
|
||||||
|
link_mention xml, mention.account
|
||||||
|
end
|
||||||
|
|
||||||
|
stream_entry.target.media_attachments.each do |media|
|
||||||
|
link_enclosure xml, media
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
|
||||||
object_type xml, stream_entry.object_type
|
|
||||||
end
|
end
|
||||||
|
|
||||||
stream_entry.mentions.each do |mentioned|
|
stream_entry.mentions.each do |mentioned|
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ module HomeHelper
|
|||||||
def default_props
|
def default_props
|
||||||
{
|
{
|
||||||
token: @token,
|
token: @token,
|
||||||
|
account: render(file: 'api/v1/accounts/show', locals: { account: current_user.account }, formats: :json)
|
||||||
account: render(file: 'api/v1/accounts/show', locals: { account: current_user.account }, formats: :json),
|
|
||||||
|
|
||||||
timelines: {
|
|
||||||
home: render(file: 'api/v1/statuses/home', locals: { statuses: @home }, formats: :json),
|
|
||||||
mentions: render(file: 'api/v1/statuses/mentions', locals: { statuses: @mentions }, formats: :json)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
module SettingsHelper
|
|
||||||
end
|
|
||||||
@@ -33,22 +33,6 @@ class FeedManager
|
|||||||
redis.zremrangebyscore(key(type, account_id), '-inf', "(#{last.last}")
|
redis.zremrangebyscore(key(type, account_id), '-inf', "(#{last.last}")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def redis
|
|
||||||
$redis
|
|
||||||
end
|
|
||||||
|
|
||||||
# Filter status out of the home feed if it is a reply to someone the user doesn't follow
|
|
||||||
def filter_from_home?(status, receiver)
|
|
||||||
replied_to_user = status.reply? ? status.thread.account : nil
|
|
||||||
(status.reply? && !(receiver.id == replied_to_user.id || replied_to_user.id == status.account_id || receiver.following?(replied_to_user)))
|
|
||||||
end
|
|
||||||
|
|
||||||
def filter_from_mentions?(status, receiver)
|
|
||||||
receiver.blocking?(status.account) || (status.reblog? && receiver.blocking?(status.reblog.account))
|
|
||||||
end
|
|
||||||
|
|
||||||
def inline_render(target_account, status)
|
def inline_render(target_account, status)
|
||||||
rabl_scope = Class.new do
|
rabl_scope = Class.new do
|
||||||
include RoutingHelper
|
include RoutingHelper
|
||||||
@@ -58,7 +42,7 @@ class FeedManager
|
|||||||
end
|
end
|
||||||
|
|
||||||
def current_user
|
def current_user
|
||||||
@account.user
|
@account.try(:user)
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_account
|
def current_account
|
||||||
@@ -68,4 +52,20 @@ class FeedManager
|
|||||||
|
|
||||||
Rabl::Renderer.new('api/v1/statuses/show', status, view_path: 'app/views', format: :json, scope: rabl_scope.new(target_account)).render
|
Rabl::Renderer.new('api/v1/statuses/show', status, view_path: 'app/views', format: :json, scope: rabl_scope.new(target_account)).render
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def redis
|
||||||
|
$redis
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter status out of the home feed if it is a reply to someone the user doesn't follow
|
||||||
|
def filter_from_home?(status, receiver)
|
||||||
|
replied_to_user = status.reply? ? status.thread.account : nil
|
||||||
|
(status.reply? && !(receiver.id == replied_to_user.id || replied_to_user.id == status.account_id || receiver.following?(replied_to_user))) || (status.reblog? && receiver.blocking?(status.reblog.account))
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_from_mentions?(status, receiver)
|
||||||
|
receiver.blocking?(status.account)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ class Formatter
|
|||||||
end
|
end
|
||||||
|
|
||||||
def link_urls(html)
|
def link_urls(html)
|
||||||
auto_link(html, link: :urls, html: { rel: 'nofollow noopener' })
|
auto_link(html, link: :urls, html: { rel: 'nofollow noopener' }) do |text|
|
||||||
|
truncate(text.gsub(/\Ahttps?:\/\/(www\.)?/, ''), length: 30)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def link_mentions(html, mentions)
|
def link_mentions(html, mentions)
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class TagManager
|
|||||||
id.start_with?("tag:#{Rails.configuration.x.local_domain}")
|
id.start_with?("tag:#{Rails.configuration.x.local_domain}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def local_domain?(domain)
|
||||||
|
domain.nil? || domain.gsub(/[\/]/, '').downcase == Rails.configuration.x.local_domain.downcase
|
||||||
|
end
|
||||||
|
|
||||||
def uri_for(target)
|
def uri_for(target)
|
||||||
return target.uri if target.respond_to?(:local?) && !target.local?
|
return target.uri if target.respond_to?(:local?) && !target.local?
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
class NotificationMailer < ApplicationMailer
|
class NotificationMailer < ApplicationMailer
|
||||||
helper StreamEntriesHelper
|
helper StreamEntriesHelper
|
||||||
helper AtomBuilderHelper
|
|
||||||
|
|
||||||
def mention(mentioned_account, status)
|
def mention(mentioned_account, status)
|
||||||
@me = mentioned_account
|
@me = mentioned_account
|
||||||
@status = status
|
@status = status
|
||||||
|
|
||||||
|
return unless @me.user.settings(:notification_emails).mention
|
||||||
mail to: @me.user.email, subject: "You were mentioned by #{@status.account.acct}"
|
mail to: @me.user.email, subject: "You were mentioned by #{@status.account.acct}"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ class NotificationMailer < ApplicationMailer
|
|||||||
@me = followed_account
|
@me = followed_account
|
||||||
@account = follower
|
@account = follower
|
||||||
|
|
||||||
|
return unless @me.user.settings(:notification_emails).follow
|
||||||
mail to: @me.user.email, subject: "#{@account.acct} is now following you"
|
mail to: @me.user.email, subject: "#{@account.acct} is now following you"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ class NotificationMailer < ApplicationMailer
|
|||||||
@account = from_account
|
@account = from_account
|
||||||
@status = target_status
|
@status = target_status
|
||||||
|
|
||||||
|
return unless @me.user.settings(:notification_emails).favourite
|
||||||
mail to: @me.user.email, subject: "#{@account.acct} favourited your status"
|
mail to: @me.user.email, subject: "#{@account.acct} favourited your status"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ class NotificationMailer < ApplicationMailer
|
|||||||
@account = from_account
|
@account = from_account
|
||||||
@status = target_status
|
@status = target_status
|
||||||
|
|
||||||
|
return unless @me.user.settings(:notification_emails).reblog
|
||||||
mail to: @me.user.email, subject: "#{@account.acct} reblogged your status"
|
mail to: @me.user.email, subject: "#{@account.acct} reblogged your status"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ class Account < ApplicationRecord
|
|||||||
validates :note, length: { maximum: 124 }, if: 'local?'
|
validates :note, length: { maximum: 124 }, if: 'local?'
|
||||||
|
|
||||||
# Timelines
|
# Timelines
|
||||||
has_many :stream_entries, inverse_of: :account
|
has_many :stream_entries, inverse_of: :account, dependent: :destroy
|
||||||
has_many :statuses, inverse_of: :account
|
has_many :statuses, inverse_of: :account, dependent: :destroy
|
||||||
has_many :favourites, inverse_of: :account
|
has_many :favourites, inverse_of: :account, dependent: :destroy
|
||||||
has_many :mentions, inverse_of: :account
|
has_many :mentions, inverse_of: :account, dependent: :destroy
|
||||||
|
|
||||||
# Follow relations
|
# Follow relations
|
||||||
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
|
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
|
||||||
@@ -48,6 +48,8 @@ class Account < ApplicationRecord
|
|||||||
scope :with_followers, -> { where('(select count(f.id) from follows as f where f.target_account_id = accounts.id) > 0') }
|
scope :with_followers, -> { where('(select count(f.id) from follows as f where f.target_account_id = accounts.id) > 0') }
|
||||||
scope :expiring, -> (time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
|
scope :expiring, -> (time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
|
||||||
|
|
||||||
|
scope :with_counters, -> { select('accounts.*, (select count(f.id) from follows as f where f.target_account_id = accounts.id) as followers_count, (select count(f.id) from follows as f where f.account_id = accounts.id) as following_count, (select count(s.id) from statuses as s where s.account_id = accounts.id) as statuses_count') }
|
||||||
|
|
||||||
def follow!(other_account)
|
def follow!(other_account)
|
||||||
active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
|
active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
|
||||||
end
|
end
|
||||||
@@ -125,7 +127,7 @@ class Account < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.find_remote!(username, domain)
|
def self.find_remote!(username, domain)
|
||||||
where(arel_table[:username].matches(username)).where(domain: domain).take!
|
where(arel_table[:username].matches(username)).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain)).take!
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.find_local(username)
|
def self.find_local(username)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ module Paginable
|
|||||||
included do
|
included do
|
||||||
def self.paginate_by_max_id(limit, max_id = nil, since_id = nil)
|
def self.paginate_by_max_id(limit, max_id = nil, since_id = nil)
|
||||||
query = order('id desc').limit(limit)
|
query = order('id desc').limit(limit)
|
||||||
query = query.where('id < ?', max_id) unless max_id.blank?
|
query = query.where(arel_table[:id].lt(max_id)) unless max_id.blank?
|
||||||
query = query.where('id > ?', since_id) unless since_id.blank?
|
query = query.where(arel_table[:id].gt(since_id)) unless since_id.blank?
|
||||||
query
|
query
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
7
app/models/domain_block.rb
Normal file
7
app/models/domain_block.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
class DomainBlock < ApplicationRecord
|
||||||
|
validates :domain, presence: true, uniqueness: true
|
||||||
|
|
||||||
|
def self.blocked?(domain)
|
||||||
|
where(domain: domain).exists?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -22,4 +22,32 @@ class Follow < ApplicationRecord
|
|||||||
def title
|
def title
|
||||||
destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}"
|
destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
after_create :add_to_graph
|
||||||
|
after_destroy :remove_from_graph
|
||||||
|
|
||||||
|
def sync!
|
||||||
|
add_to_graph
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def add_to_graph
|
||||||
|
neo = Neography::Rest.new
|
||||||
|
|
||||||
|
a = neo.create_unique_node('account_index', 'Account', account_id.to_s, account_id: account_id)
|
||||||
|
b = neo.create_unique_node('account_index', 'Account', target_account_id.to_s, account_id: target_account_id)
|
||||||
|
|
||||||
|
neo.create_unique_relationship('follow_index', 'Follow', id.to_s, 'follows', a, b)
|
||||||
|
rescue Neography::NeographyError, Excon::Error::Socket => e
|
||||||
|
Rails.logger.error e
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_from_graph
|
||||||
|
neo = Neography::Rest.new
|
||||||
|
rel = neo.get_relationship_index('follow_index', 'Follow', id.to_s)
|
||||||
|
neo.delete_relationship(rel)
|
||||||
|
rescue Neography::NeographyError, Excon::Error::Socket => e
|
||||||
|
Rails.logger.error e
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
50
app/models/follow_suggestion.rb
Normal file
50
app/models/follow_suggestion.rb
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
class FollowSuggestion
|
||||||
|
def self.get(for_account_id, limit = 10)
|
||||||
|
neo = Neography::Rest.new
|
||||||
|
|
||||||
|
query = <<END
|
||||||
|
START a=node:account_index(Account={id})
|
||||||
|
MATCH (a)-[:follows]->(b)-[:follows]->(c)
|
||||||
|
WHERE a <> c
|
||||||
|
AND NOT (a)-[:follows]->(c)
|
||||||
|
RETURN DISTINCT c.account_id, count(b), c.nodeRank
|
||||||
|
ORDER BY count(b) DESC, c.nodeRank DESC
|
||||||
|
LIMIT {limit}
|
||||||
|
END
|
||||||
|
|
||||||
|
results = neo.execute_query(query, id: for_account_id, limit: limit)
|
||||||
|
|
||||||
|
if results.empty? || results['data'].empty?
|
||||||
|
results = fallback(for_account_id, limit)
|
||||||
|
elsif results['data'].size < limit
|
||||||
|
results['data'] = (results['data'] + fallback(for_account_id, limit - results['data'].size)['data']).uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
account_ids = results['data'].map(&:first)
|
||||||
|
blocked_ids = Block.where(account_id: for_account_id).pluck(:target_account_id)
|
||||||
|
accounts_map = Account.where(id: account_ids - blocked_ids).with_counters.map { |a| [a.id, a] }.to_h
|
||||||
|
|
||||||
|
account_ids.map { |id| accounts_map[id] }.compact
|
||||||
|
rescue Neography::NeographyError, Excon::Error::Socket => e
|
||||||
|
Rails.logger.error e
|
||||||
|
return []
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def self.fallback(for_account_id, limit)
|
||||||
|
neo = Neography::Rest.new
|
||||||
|
|
||||||
|
query = <<END
|
||||||
|
START a=node:account_index(Account={id})
|
||||||
|
MATCH (b)
|
||||||
|
WHERE a <> b
|
||||||
|
AND NOT (a)-[:follows]->(b)
|
||||||
|
RETURN b.account_id
|
||||||
|
ORDER BY b.nodeRank DESC
|
||||||
|
LIMIT {limit}
|
||||||
|
END
|
||||||
|
|
||||||
|
neo.execute_query(query, id: for_account_id, limit: limit)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
class MediaAttachment < ApplicationRecord
|
class MediaAttachment < ApplicationRecord
|
||||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
||||||
VIDEO_MIME_TYPES = ['video/webm'].freeze
|
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
|
||||||
|
|
||||||
belongs_to :account, inverse_of: :media_attachments
|
belongs_to :account, inverse_of: :media_attachments
|
||||||
belongs_to :status, inverse_of: :media_attachments
|
belongs_to :status, inverse_of: :media_attachments
|
||||||
|
|
||||||
has_attached_file :file, styles: -> (f) { f.instance.image? ? { small: '510x680>' } : { small: { convert_options: { output: { vf: 'scale="min(510\, iw):min(680\, ih)":force_original_aspect_ratio=decrease' } }, format: 'png', time: 1 } } }, processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] }
|
has_attached_file :file,
|
||||||
|
styles: -> (f) { file_styles f },
|
||||||
|
processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] },
|
||||||
|
convert_options: { all: "-strip" }
|
||||||
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
|
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
|
||||||
validates_attachment_size :file, less_than: 4.megabytes
|
validates_attachment_size :file, less_than: 4.megabytes
|
||||||
|
|
||||||
@@ -30,4 +33,31 @@ class MediaAttachment < ApplicationRecord
|
|||||||
def type
|
def type
|
||||||
image? ? 'image' : 'video'
|
image? ? 'image' : 'video'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def self.file_styles(f)
|
||||||
|
if f.instance.image?
|
||||||
|
{
|
||||||
|
original: '100%',
|
||||||
|
small: '510x680>'
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
original: {
|
||||||
|
convert_options: {},
|
||||||
|
format: 'webm'
|
||||||
|
},
|
||||||
|
|
||||||
|
small: {
|
||||||
|
convert_options: {
|
||||||
|
output: {
|
||||||
|
vf: 'scale=\'min(510\, iw):min(680\, ih)\':force_original_aspect_ratio=decrease'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
format: 'png',
|
||||||
|
time: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class Status < ApplicationRecord
|
|||||||
include Paginable
|
include Paginable
|
||||||
include Streamable
|
include Streamable
|
||||||
|
|
||||||
belongs_to :account, inverse_of: :statuses
|
belongs_to :account, -> { with_counters }, inverse_of: :statuses
|
||||||
|
|
||||||
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies
|
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies
|
||||||
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs
|
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs
|
||||||
@@ -18,6 +18,8 @@ class Status < ApplicationRecord
|
|||||||
validates :text, presence: true, length: { maximum: 500 }, if: proc { |s| s.local? && !s.reblog? }
|
validates :text, presence: true, length: { maximum: 500 }, if: proc { |s| s.local? && !s.reblog? }
|
||||||
validates :reblog, uniqueness: { scope: :account, message: 'of status already exists' }, if: 'reblog?'
|
validates :reblog, uniqueness: { scope: :account, message: 'of status already exists' }, if: 'reblog?'
|
||||||
|
|
||||||
|
default_scope { order('id desc') }
|
||||||
|
|
||||||
scope :with_counters, -> { select('statuses.*, (select count(r.id) from statuses as r where r.reblog_of_id = statuses.id) as reblogs_count, (select count(f.id) from favourites as f where f.status_id = statuses.id) as favourites_count') }
|
scope :with_counters, -> { select('statuses.*, (select count(r.id) from statuses as r where r.reblog_of_id = statuses.id) as reblogs_count, (select count(f.id) from favourites as f where f.status_id = statuses.id) as favourites_count') }
|
||||||
scope :with_includes, -> { includes(:account, :media_attachments, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) }
|
scope :with_includes, -> { includes(:account, :media_attachments, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) }
|
||||||
|
|
||||||
@@ -83,12 +85,16 @@ class Status < ApplicationRecord
|
|||||||
where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
|
where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.as_public_timeline(account)
|
||||||
|
joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id').where('(reblogs.account_id IS NULL OR reblogs.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)) AND statuses.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)', account.id, account.id).with_includes.with_counters
|
||||||
|
end
|
||||||
|
|
||||||
def self.favourites_map(status_ids, account_id)
|
def self.favourites_map(status_ids, account_id)
|
||||||
Favourite.where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
|
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.reblogs_map(status_ids, account_id)
|
def self.reblogs_map(status_ids, account_id)
|
||||||
where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h
|
select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
before_validation do
|
before_validation do
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class StreamEntry < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def threaded?
|
def threaded?
|
||||||
verb == :favorite || object_type == :comment
|
(verb == :favorite || object_type == :comment) && !thread.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def thread
|
def thread
|
||||||
|
|||||||
@@ -9,4 +9,8 @@ class User < ApplicationRecord
|
|||||||
scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
|
scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
|
||||||
scope :recent, -> { order('created_at desc') }
|
scope :recent, -> { order('created_at desc') }
|
||||||
scope :admins, -> { where(admin: true) }
|
scope :admins, -> { where(admin: true) }
|
||||||
|
|
||||||
|
has_settings do |s|
|
||||||
|
s.key :notification_emails, defaults: { follow: true, reblog: true, favourite: true, mention: true }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
13
app/services/block_domain_service.rb
Normal file
13
app/services/block_domain_service.rb
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
class BlockDomainService < BaseService
|
||||||
|
def call(domain)
|
||||||
|
block = DomainBlock.find_or_create_by!(domain: domain)
|
||||||
|
|
||||||
|
Account.where(domain: domain).find_each do |account|
|
||||||
|
if account.subscribed?
|
||||||
|
account.subscription(api_subscription_url(account.id)).unsubscribe
|
||||||
|
end
|
||||||
|
|
||||||
|
account.destroy!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,6 +5,7 @@ class FanOutOnWriteService < BaseService
|
|||||||
deliver_to_self(status) if status.account.local?
|
deliver_to_self(status) if status.account.local?
|
||||||
deliver_to_followers(status)
|
deliver_to_followers(status)
|
||||||
deliver_to_mentioned(status)
|
deliver_to_mentioned(status)
|
||||||
|
deliver_to_public(status)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -27,4 +28,8 @@ class FanOutOnWriteService < BaseService
|
|||||||
FeedManager.instance.push(:mentions, mentioned_account, status)
|
FeedManager.instance.push(:mentions, mentioned_account, status)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def deliver_to_public(status)
|
||||||
|
FeedManager.instance.broadcast(:public, id: status.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class FetchRemoteAccountService < BaseService
|
|||||||
Rails.logger.debug "Going to webfinger #{username}@#{domain}"
|
Rails.logger.debug "Going to webfinger #{username}@#{domain}"
|
||||||
|
|
||||||
return FollowRemoteAccountService.new.call("#{username}@#{domain}")
|
return FollowRemoteAccountService.new.call("#{username}@#{domain}")
|
||||||
|
rescue TypeError => e
|
||||||
|
Rails.logger.debug "Unparseable URL given: #{url}"
|
||||||
rescue Nokogiri::XML::XPath::SyntaxError
|
rescue Nokogiri::XML::XPath::SyntaxError
|
||||||
Rails.logger.debug "Invalid XML or missing namespace"
|
Rails.logger.debug "Invalid XML or missing namespace"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
class FollowRemoteAccountService < BaseService
|
class FollowRemoteAccountService < BaseService
|
||||||
|
include OStatus2::MagicKey
|
||||||
|
|
||||||
|
DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'.freeze
|
||||||
|
|
||||||
# Find or create a local account for a remote user.
|
# Find or create a local account for a remote user.
|
||||||
# When creating, look up the user's webfinger and fetch all
|
# When creating, look up the user's webfinger and fetch all
|
||||||
# important information from their feed
|
# important information from their feed
|
||||||
@@ -7,7 +11,8 @@ class FollowRemoteAccountService < BaseService
|
|||||||
def call(uri)
|
def call(uri)
|
||||||
username, domain = uri.split('@')
|
username, domain = uri.split('@')
|
||||||
|
|
||||||
return Account.find_local(username) if domain == Rails.configuration.x.local_domain || domain.nil?
|
return Account.find_local(username) if TagManager.instance.local_domain?(domain)
|
||||||
|
return nil if DomainBlock.blocked?(domain)
|
||||||
|
|
||||||
account = Account.find_remote(username, domain)
|
account = Account.find_remote(username, domain)
|
||||||
|
|
||||||
@@ -18,27 +23,21 @@ class FollowRemoteAccountService < BaseService
|
|||||||
|
|
||||||
data = Goldfinger.finger("acct:#{uri}")
|
data = Goldfinger.finger("acct:#{uri}")
|
||||||
|
|
||||||
|
raise Goldfinger::Error, 'Missing resource links' if data.link('http://schemas.google.com/g/2010#updates-from').nil? || data.link('salmon').nil? || data.link('http://webfinger.net/rel/profile-page').nil? || data.link('magic-public-key').nil?
|
||||||
|
|
||||||
account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href
|
account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href
|
||||||
account.salmon_url = data.link('salmon').href
|
account.salmon_url = data.link('salmon').href
|
||||||
account.url = data.link('http://webfinger.net/rel/profile-page').href
|
account.url = data.link('http://webfinger.net/rel/profile-page').href
|
||||||
account.public_key = magic_key_to_pem(data.link('magic-public-key').href)
|
account.public_key = magic_key_to_pem(data.link('magic-public-key').href)
|
||||||
account.private_key = nil
|
account.private_key = nil
|
||||||
|
|
||||||
feed = get_feed(account.remote_url)
|
xml = get_feed(account.remote_url)
|
||||||
hubs = feed.xpath('//xmlns:link[@rel="hub"]')
|
hubs = get_hubs(xml)
|
||||||
|
|
||||||
if hubs.empty? || hubs.first.attribute('href').nil?
|
account.uri = get_account_uri(xml)
|
||||||
raise Goldfinger::Error, 'No PubSubHubbub hubs found'
|
|
||||||
end
|
|
||||||
|
|
||||||
if feed.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri').nil?
|
|
||||||
raise Goldfinger::Error, 'No author URI found'
|
|
||||||
end
|
|
||||||
|
|
||||||
account.uri = feed.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri').content
|
|
||||||
account.hub_url = hubs.first.attribute('href').value
|
account.hub_url = hubs.first.attribute('href').value
|
||||||
|
|
||||||
get_profile(feed, account)
|
get_profile(xml, account)
|
||||||
account.save!
|
account.save!
|
||||||
|
|
||||||
return account
|
return account
|
||||||
@@ -51,20 +50,27 @@ class FollowRemoteAccountService < BaseService
|
|||||||
Nokogiri::XML(response)
|
Nokogiri::XML(response)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_profile(xml, account)
|
def get_hubs(xml)
|
||||||
author = xml.at_xpath('/xmlns:feed/xmlns:author')
|
hubs = xml.xpath('//xmlns:link[@rel="hub"]')
|
||||||
update_remote_profile_service.call(author, account)
|
raise Goldfinger::Error, 'No PubSubHubbub hubs found' if hubs.empty? || hubs.first.attribute('href').nil?
|
||||||
|
hubs
|
||||||
end
|
end
|
||||||
|
|
||||||
def magic_key_to_pem(magic_key)
|
def get_account_uri(xml)
|
||||||
_, modulus, exponent = magic_key.split('.')
|
author_uri = xml.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri')
|
||||||
modulus, exponent = [modulus, exponent].map { |n| Base64.urlsafe_decode64(n).bytes.inject(0) { |a, e| (a << 8) | e } }
|
|
||||||
|
|
||||||
key = OpenSSL::PKey::RSA.new
|
if author_uri.nil?
|
||||||
key.n = modulus
|
owner = xml.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS)
|
||||||
key.e = exponent
|
author_uri = owner.at_xpath('./xmlns:uri') unless owner.nil?
|
||||||
|
end
|
||||||
|
|
||||||
key.to_pem
|
raise Goldfinger::Error, 'Author URI could not be found' if author_uri.nil?
|
||||||
|
author_uri.content
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_profile(xml, account)
|
||||||
|
author = xml.at_xpath('/xmlns:feed/xmlns:author') || xml.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS)
|
||||||
|
update_remote_profile_service.call(author, account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_remote_profile_service
|
def update_remote_profile_service
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class FollowService < BaseService
|
|||||||
def call(source_account, uri)
|
def call(source_account, uri)
|
||||||
target_account = follow_remote_account_service.call(uri)
|
target_account = follow_remote_account_service.call(uri)
|
||||||
|
|
||||||
return nil if target_account.nil? || target_account.id == source_account.id
|
raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id
|
||||||
|
|
||||||
follow = source_account.follow!(target_account)
|
follow = source_account.follow!(target_account)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
class ProcessFeedService < BaseService
|
class ProcessFeedService < BaseService
|
||||||
|
ACTIVITY_NS = 'http://activitystrea.ms/spec/1.0/'.freeze
|
||||||
|
THREAD_NS = 'http://purl.org/syndication/thread/1.0'.freeze
|
||||||
|
|
||||||
# Create local statuses from an Atom feed
|
# Create local statuses from an Atom feed
|
||||||
# @param [String] body Atom feed
|
# @param [String] body Atom feed
|
||||||
# @param [Account] account Account this feed belongs to
|
# @param [Account] account Account this feed belongs to
|
||||||
@@ -34,23 +37,27 @@ class ProcessFeedService < BaseService
|
|||||||
else
|
else
|
||||||
add_reply!(entry, status)
|
add_reply!(entry, status)
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# If we added a status, go through accounts it mentions and create respective relations
|
# If we added a status, go through accounts it mentions and create respective relations
|
||||||
# Also record all media attachments for the status and for the reblogged status if present
|
# Also record all media attachments for the status and for the reblogged status if present
|
||||||
unless status.new_record?
|
unless status.new_record?
|
||||||
record_remote_mentions(status, entry.xpath('./xmlns:link[@rel="mentioned"]'))
|
record_remote_mentions(status, entry.xpath('./xmlns:link[@rel="mentioned"]'))
|
||||||
|
record_remote_mentions(status.reblog, entry.at_xpath('./activity:object', activity: ACTIVITY_NS).xpath('./xmlns:link[@rel="mentioned"]')) if status.reblog?
|
||||||
|
|
||||||
process_attachments(entry, status)
|
process_attachments(entry, status)
|
||||||
process_attachments(entry.xpath('./activity:object'), status.reblog) if status.reblog?
|
process_attachments(entry.xpath('./activity:object', activity: ACTIVITY_NS), status.reblog) if status.reblog?
|
||||||
|
|
||||||
DistributionWorker.perform_async(status.id)
|
DistributionWorker.perform_async(status.id)
|
||||||
|
return status
|
||||||
end
|
end
|
||||||
|
|
||||||
return status
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def record_remote_mentions(status, links)
|
def record_remote_mentions(status, links)
|
||||||
|
return if status.local?
|
||||||
|
|
||||||
# Here we have to do a reverse lookup of local accounts by their URL!
|
# Here we have to do a reverse lookup of local accounts by their URL!
|
||||||
# It's not pretty at all! I really wish all these protocols sticked to
|
# It's not pretty at all! I really wish all these protocols sticked to
|
||||||
# using acct:username@domain only! It would make things so much easier
|
# using acct:username@domain only! It would make things so much easier
|
||||||
@@ -63,7 +70,7 @@ class ProcessFeedService < BaseService
|
|||||||
|
|
||||||
href = Addressable::URI.parse(href_val)
|
href = Addressable::URI.parse(href_val)
|
||||||
|
|
||||||
if href.host == Rails.configuration.x.local_domain
|
if TagManager.instance.local_domain?(href.host)
|
||||||
# A local user is mentioned
|
# A local user is mentioned
|
||||||
mentioned_account = Account.find_local(href.path.gsub('/users/', ''))
|
mentioned_account = Account.find_local(href.path.gsub('/users/', ''))
|
||||||
|
|
||||||
@@ -88,6 +95,8 @@ class ProcessFeedService < BaseService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def process_attachments(entry, status)
|
def process_attachments(entry, status)
|
||||||
|
return if status.local?
|
||||||
|
|
||||||
entry.xpath('./xmlns:link[@rel="enclosure"]').each do |enclosure_link|
|
entry.xpath('./xmlns:link[@rel="enclosure"]').each do |enclosure_link|
|
||||||
next if enclosure_link.attribute('href').nil?
|
next if enclosure_link.attribute('href').nil?
|
||||||
|
|
||||||
@@ -95,9 +104,14 @@ class ProcessFeedService < BaseService
|
|||||||
|
|
||||||
next unless media.nil?
|
next unless media.nil?
|
||||||
|
|
||||||
media = MediaAttachment.new(account: status.account, status: status, remote_url: enclosure_link.attribute('href').value)
|
begin
|
||||||
media.file_remote_url = enclosure_link.attribute('href').value
|
media = MediaAttachment.new(account: status.account, status: status, remote_url: enclosure_link.attribute('href').value)
|
||||||
media.save
|
media.file_remote_url = enclosure_link.attribute('href').value
|
||||||
|
media.save
|
||||||
|
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
|
||||||
|
Rails.logger.debug "Error saving attachment from #{enclosure_link.attribute('href').value}"
|
||||||
|
next
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -142,10 +156,10 @@ class ProcessFeedService < BaseService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def fetch_remote_status(xml)
|
def fetch_remote_status(xml)
|
||||||
username = xml.at_xpath('./activity:object/xmlns:author/xmlns:name').content
|
username = xml.at_xpath('./activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:author/xmlns:name').content
|
||||||
url = xml.at_xpath('./activity:object/xmlns:author/xmlns:uri').content
|
url = xml.at_xpath('./activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:author/xmlns:uri').content
|
||||||
domain = Addressable::URI.parse(url).host
|
domain = Addressable::URI.parse(url).host
|
||||||
account = Account.find_by(username: username, domain: domain)
|
account = Account.find_remote(username, domain)
|
||||||
|
|
||||||
if account.nil?
|
if account.nil?
|
||||||
account = follow_remote_account_service.call("#{username}@#{domain}")
|
account = follow_remote_account_service.call("#{username}@#{domain}")
|
||||||
@@ -172,23 +186,23 @@ class ProcessFeedService < BaseService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def content(xml)
|
def content(xml)
|
||||||
xml.at_xpath('./xmlns:content').content
|
xml.at_xpath('./xmlns:content').try(:content)
|
||||||
end
|
end
|
||||||
|
|
||||||
def thread_id(xml)
|
def thread_id(xml)
|
||||||
xml.at_xpath('./thr:in-reply-to').attribute('ref').value
|
xml.at_xpath('./thr:in-reply-to', thr: THREAD_NS).attribute('ref').value
|
||||||
rescue
|
rescue
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def thread_href(xml)
|
def thread_href(xml)
|
||||||
xml.at_xpath('./thr:in-reply-to').attribute('href').value
|
xml.at_xpath('./thr:in-reply-to', thr: THREAD_NS).attribute('href').value
|
||||||
rescue
|
rescue
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def target_id(xml)
|
def target_id(xml)
|
||||||
xml.at_xpath('.//activity:object/xmlns:id').content
|
xml.at_xpath('.//activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:id').content
|
||||||
rescue
|
rescue
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
@@ -204,21 +218,21 @@ class ProcessFeedService < BaseService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def target_content(xml)
|
def target_content(xml)
|
||||||
xml.at_xpath('.//activity:object/xmlns:content').content
|
xml.at_xpath('.//activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:content').content
|
||||||
end
|
end
|
||||||
|
|
||||||
def target_url(xml)
|
def target_url(xml)
|
||||||
xml.at_xpath('.//activity:object/xmlns:link[@rel="alternate"]').attribute('href').value
|
xml.at_xpath('.//activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:link[@rel="alternate"]').attribute('href').value
|
||||||
end
|
end
|
||||||
|
|
||||||
def object_type(xml)
|
def object_type(xml)
|
||||||
xml.at_xpath('./activity:object-type').content.gsub('http://activitystrea.ms/schema/1.0/', '').to_sym
|
xml.at_xpath('./activity:object-type', activity: ACTIVITY_NS).content.gsub('http://activitystrea.ms/schema/1.0/', '').gsub('http://ostatus.org/schema/1.0/', '').to_sym
|
||||||
rescue
|
rescue
|
||||||
:note
|
:activity
|
||||||
end
|
end
|
||||||
|
|
||||||
def verb(xml)
|
def verb(xml)
|
||||||
xml.at_xpath('./activity:verb').content.gsub('http://activitystrea.ms/schema/1.0/', '').to_sym
|
xml.at_xpath('./activity:verb', activity: ACTIVITY_NS).content.gsub('http://activitystrea.ms/schema/1.0/', '').gsub('http://ostatus.org/schema/1.0/', '').to_sym
|
||||||
rescue
|
rescue
|
||||||
:post
|
:post
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class ProcessInteractionService < BaseService
|
class ProcessInteractionService < BaseService
|
||||||
|
ACTIVITY_NS = 'http://activitystrea.ms/spec/1.0/'.freeze
|
||||||
|
|
||||||
# Record locally the remote interaction with our user
|
# Record locally the remote interaction with our user
|
||||||
# @param [String] envelope Salmon envelope
|
# @param [String] envelope Salmon envelope
|
||||||
# @param [Account] target_account Account the Salmon was addressed to
|
# @param [Account] target_account Account the Salmon was addressed to
|
||||||
@@ -13,6 +15,8 @@ class ProcessInteractionService < BaseService
|
|||||||
domain = Addressable::URI.parse(url).host
|
domain = Addressable::URI.parse(url).host
|
||||||
account = Account.find_by(username: username, domain: domain)
|
account = Account.find_by(username: username, domain: domain)
|
||||||
|
|
||||||
|
return if DomainBlock.blocked?(domain)
|
||||||
|
|
||||||
if account.nil?
|
if account.nil?
|
||||||
account = follow_remote_account_service.call("#{username}@#{domain}")
|
account = follow_remote_account_service.call("#{username}@#{domain}")
|
||||||
end
|
end
|
||||||
@@ -35,7 +39,7 @@ class ProcessInteractionService < BaseService
|
|||||||
delete_post!(xml, account)
|
delete_post!(xml, account)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
rescue Goldfinger::Error, HTTP::Error
|
rescue Goldfinger::Error, HTTP::Error, OStatus2::BadSalmonError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -51,7 +55,7 @@ class ProcessInteractionService < BaseService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def verb(xml)
|
def verb(xml)
|
||||||
xml.at_xpath('//activity:verb').content.gsub('http://activitystrea.ms/schema/1.0/', '').gsub('http://ostatus.org/schema/1.0/', '').to_sym
|
xml.at_xpath('//activity:verb', activity: ACTIVITY_NS).content.gsub('http://activitystrea.ms/schema/1.0/', '').gsub('http://ostatus.org/schema/1.0/', '').to_sym
|
||||||
rescue
|
rescue
|
||||||
:post
|
:post
|
||||||
end
|
end
|
||||||
@@ -90,7 +94,7 @@ class ProcessInteractionService < BaseService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def activity_id(xml)
|
def activity_id(xml)
|
||||||
xml.at_xpath('//activity:object/xmlns:id').content
|
xml.at_xpath('//activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:id').content
|
||||||
end
|
end
|
||||||
|
|
||||||
def salmon
|
def salmon
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ class RemoveStatusService < BaseService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def send_delete_salmon(account, status)
|
def send_delete_salmon(account, status)
|
||||||
NotificationWorker.perform_async(status.stream_entry_id, account.id)
|
return unless status.local?
|
||||||
|
NotificationWorker.perform_async(status.stream_entry.id, account.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def remove_reblogs(status)
|
def remove_reblogs(status)
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
class UpdateRemoteProfileService < BaseService
|
class UpdateRemoteProfileService < BaseService
|
||||||
|
POCO_NS = 'http://portablecontacts.net/spec/1.0'
|
||||||
|
|
||||||
def call(author_xml, account)
|
def call(author_xml, account)
|
||||||
if author_xml.at_xpath('./poco:displayName').nil?
|
return if author_xml.nil?
|
||||||
|
|
||||||
|
if author_xml.at_xpath('./poco:displayName', poco: POCO_NS).nil?
|
||||||
account.display_name = account.username
|
account.display_name = account.username
|
||||||
else
|
else
|
||||||
account.display_name = author_xml.at_xpath('./poco:displayName').content
|
account.display_name = author_xml.at_xpath('./poco:displayName', poco: POCO_NS).content
|
||||||
end
|
end
|
||||||
|
|
||||||
unless author_xml.at_xpath('./poco:note').nil?
|
unless author_xml.at_xpath('./poco:note').nil?
|
||||||
account.note = author_xml.at_xpath('./poco:note').content
|
account.note = author_xml.at_xpath('./poco:note', poco: POCO_NS).content
|
||||||
end
|
end
|
||||||
|
|
||||||
unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]').nil?
|
unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]').nil?
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
- content_for :page_title do
|
||||||
|
= Rails.configuration.x.local_domain
|
||||||
|
|
||||||
.wrapper
|
.wrapper
|
||||||
%h1
|
%h1
|
||||||
= image_tag 'logo.png'
|
= image_tag 'logo.png'
|
||||||
@@ -16,4 +19,5 @@
|
|||||||
is a Mastodon instance.
|
is a Mastodon instance.
|
||||||
|
|
||||||
.actions
|
.actions
|
||||||
= link_to 'Get started', new_user_session_path, class: 'button'
|
= link_to 'Get started', new_user_registration_path, class: 'button'
|
||||||
|
= link_to 'Log in', new_user_session_path, class: 'button'
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
.card{ style: "background-image: url(#{@account.header.url(:medium)})" }
|
.card{ style: "background-image: url(#{@account.header.url(:medium)})" }
|
||||||
|
- if user_signed_in? && current_account.id != @account.id
|
||||||
|
.controls
|
||||||
|
- if current_account.following?(@account)
|
||||||
|
= link_to 'Unfollow', unfollow_account_path(@account), data: { method: :post }, class: 'button'
|
||||||
|
- else
|
||||||
|
= link_to 'Follow', follow_account_path(@account), data: { method: :post }, class: 'button'
|
||||||
|
|
||||||
.avatar= image_tag @account.avatar.url(:large)
|
.avatar= image_tag @account.avatar.url(:large)
|
||||||
%h1.name
|
%h1.name
|
||||||
= display_name(@account)
|
= display_name(@account)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user