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/assets
|
||||
node_modules
|
||||
storybook
|
||||
neo4j
|
||||
|
@@ -6,6 +6,8 @@ DB_USER=postgres
|
||||
DB_NAME=postgres
|
||||
DB_PASS=
|
||||
DB_PORT=5432
|
||||
NEO4J_HOST=neo4j
|
||||
NEO4J_PORT=7474
|
||||
|
||||
# Federation
|
||||
LOCAL_DOMAIN=example.com
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ public/assets
|
||||
.env
|
||||
.env.*
|
||||
node_modules/
|
||||
neo4j/
|
||||
|
17
.travis.yml
17
.travis.yml
@@ -1,11 +1,18 @@
|
||||
language: ruby
|
||||
cache: bundler
|
||||
|
||||
notifications:
|
||||
email: false
|
||||
|
||||
env:
|
||||
matrix:
|
||||
- TRAVIS_NODE_VERSION="4"
|
||||
global:
|
||||
- LOCAL_DOMAIN=cb6e6126.ngrok.io
|
||||
- LOCAL_HTTPS=true
|
||||
- RAILS_ENV=test
|
||||
- NEO4J_HOST=localhost
|
||||
- NEO4J_PORT=7575
|
||||
|
||||
addons:
|
||||
postgresql: 9.4
|
||||
@@ -19,11 +26,15 @@ services:
|
||||
bundler_args: --without development production --retry=3 --jobs=3
|
||||
|
||||
install:
|
||||
- npm install -g npm@2
|
||||
- npm install
|
||||
- nvm install $TRAVIS_NODE_VERSION
|
||||
- npm install -g npm@3
|
||||
- npm install -g yarn
|
||||
- bundle install
|
||||
- yarn install
|
||||
|
||||
before_script:
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
WORKDIR /mastodon
|
||||
@@ -13,7 +15,8 @@ ADD Gemfile.lock /mastodon/Gemfile.lock
|
||||
RUN bundle install --deployment --without test development
|
||||
|
||||
ADD package.json /mastodon/package.json
|
||||
RUN npm install
|
||||
ADD yarn.lock /mastodon/yarn.lock
|
||||
RUN yarn
|
||||
|
||||
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 'rack-attack'
|
||||
gem 'sidekiq'
|
||||
gem 'ledermann-rails-settings'
|
||||
gem 'neography'
|
||||
|
||||
gem 'react-rails'
|
||||
gem 'browserify-rails'
|
||||
|
26
Gemfile.lock
26
Gemfile.lock
@@ -97,6 +97,7 @@ GEM
|
||||
dotenv (= 2.1.1)
|
||||
railties (>= 4.0, < 5.1)
|
||||
erubis (2.7.0)
|
||||
excon (0.53.0)
|
||||
execjs (2.7.0)
|
||||
fabrication (2.15.2)
|
||||
fast_blank (1.0.0)
|
||||
@@ -107,7 +108,7 @@ GEM
|
||||
ruby-progressbar (~> 1.4)
|
||||
globalid (0.3.7)
|
||||
activesupport (>= 4.1.0)
|
||||
goldfinger (1.0.5)
|
||||
goldfinger (1.1.0)
|
||||
addressable (~> 2.4)
|
||||
http (~> 2.0)
|
||||
nokogiri (~> 1.6)
|
||||
@@ -145,6 +146,8 @@ GEM
|
||||
json (1.8.3)
|
||||
launchy (2.4.3)
|
||||
addressable (~> 2.3)
|
||||
ledermann-rails-settings (2.4.2)
|
||||
activerecord (>= 3.1)
|
||||
letter_opener (1.4.1)
|
||||
launchy (~> 2.2)
|
||||
libv8 (3.16.14.15)
|
||||
@@ -163,15 +166,22 @@ GEM
|
||||
mime-types-data (3.2016.0521)
|
||||
mimemagic (0.3.0)
|
||||
mini_portile2 (2.1.0)
|
||||
minitest (5.9.0)
|
||||
minitest (5.9.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)
|
||||
nokogiri (1.6.8)
|
||||
nokogiri (1.6.8.1)
|
||||
mini_portile2 (~> 2.1.0)
|
||||
pkg-config (~> 1.1.7)
|
||||
oj (2.17.3)
|
||||
orm_adapter (0.5.0)
|
||||
ostatus2 (0.3.1)
|
||||
os (0.9.6)
|
||||
ostatus2 (1.0.2)
|
||||
addressable (~> 2.4)
|
||||
http (~> 2.0)
|
||||
nokogiri (~> 1.6)
|
||||
@@ -187,7 +197,6 @@ GEM
|
||||
parser (2.3.1.2)
|
||||
ast (~> 2.2)
|
||||
pg (0.18.4)
|
||||
pkg-config (1.1.7)
|
||||
powerpack (0.1.1)
|
||||
pry (0.10.4)
|
||||
coderay (~> 1.1.0)
|
||||
@@ -236,7 +245,7 @@ GEM
|
||||
rake (>= 0.8.7)
|
||||
thor (>= 0.18.1, < 2.0)
|
||||
rainbow (2.1.0)
|
||||
rake (11.2.2)
|
||||
rake (11.3.0)
|
||||
rdoc (4.2.2)
|
||||
json (~> 1.4)
|
||||
react-rails (1.8.2)
|
||||
@@ -281,6 +290,7 @@ GEM
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (~> 1.0, >= 1.0.1)
|
||||
ruby-progressbar (1.8.1)
|
||||
rubyzip (1.2.0)
|
||||
safe_yaml (1.0.4)
|
||||
sass (3.4.22)
|
||||
sass-rails (5.0.6)
|
||||
@@ -366,9 +376,11 @@ DEPENDENCIES
|
||||
httplog
|
||||
jbuilder (~> 2.0)
|
||||
jquery-rails
|
||||
ledermann-rails-settings
|
||||
letter_opener
|
||||
link_header
|
||||
lograge
|
||||
neography
|
||||
nokogiri
|
||||
oj
|
||||
ostatus2
|
||||
|
@@ -57,6 +57,8 @@ Consult the example configuration file, `.env.production.sample` for the full li
|
||||
|
||||
- PostgreSQL
|
||||
- Redis
|
||||
- Neo4J (optional)
|
||||
- GraphAware NodeRank
|
||||
|
||||
## 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: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:graphs:sync` re-imports all follow relationships into Neo4J. Only for troubleshooting
|
||||
|
||||
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.ReactDOM = require('react-dom');
|
||||
window.Perf = require('react-addons-perf');
|
||||
|
||||
//= require_tree ./components
|
||||
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import api from '../api'
|
||||
import axios from 'axios';
|
||||
import api from '../api'
|
||||
import axios from 'axios';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
|
||||
|
||||
@@ -66,7 +67,7 @@ export function fetchAccountTimeline(id) {
|
||||
|
||||
export function expandAccountTimeline(id) {
|
||||
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));
|
||||
|
||||
|
@@ -1,3 +1,4 @@
|
||||
export const NOTIFICATION_SHOW = 'NOTIFICATION_SHOW';
|
||||
export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS';
|
||||
export const NOTIFICATION_CLEAR = 'NOTIFICATION_CLEAR';
|
||||
|
||||
@@ -13,3 +14,11 @@ export function clearNotifications() {
|
||||
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 () {
|
||||
this._updateMomentText();
|
||||
this.interval = setInterval(this._updateMomentText, 6000);
|
||||
this.interval = setInterval(this._updateMomentText, 60000);
|
||||
},
|
||||
|
||||
componentWillUnmount () {
|
||||
|
@@ -41,16 +41,21 @@ const Status = React.createClass({
|
||||
},
|
||||
|
||||
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 (
|
||||
<div style={{ cursor: 'pointer' }} onClick={this.handleClick}>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<Status {...other} wrapped={true} status={status.get('reblog')} />
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from './icon_button';
|
||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||
import DropdownMenu from './dropdown_menu';
|
||||
|
||||
const StatusActionBar = React.createClass({
|
||||
propTypes: {
|
||||
@@ -26,23 +26,16 @@ const StatusActionBar = React.createClass({
|
||||
this.props.onReblog(this.props.status);
|
||||
},
|
||||
|
||||
handleDeleteClick(e) {
|
||||
e.preventDefault();
|
||||
handleDeleteClick () {
|
||||
this.props.onDelete(this.props.status);
|
||||
},
|
||||
|
||||
render () {
|
||||
const { status, me } = this.props;
|
||||
let menu = '';
|
||||
let menu = [];
|
||||
|
||||
if (status.getIn(['account', 'id']) === me) {
|
||||
menu = (
|
||||
<ul>
|
||||
<li><a href='#' onClick={this.handleDeleteClick}>Delete</a></li>
|
||||
</ul>
|
||||
);
|
||||
} else {
|
||||
menu = <ul />;
|
||||
menu.push({ text: 'Delete', action: this.handleDeleteClick });
|
||||
}
|
||||
|
||||
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 onClick={e => e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}>
|
||||
<Dropdown>
|
||||
<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>
|
||||
<DropdownMenu items={menu} icon='ellipsis-h' size={18} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -22,11 +22,11 @@ const StatusContent = React.createClass({
|
||||
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
|
||||
|
||||
if (mention) {
|
||||
link.addEventListener('click', this.onMentionClick.bind(this, mention));
|
||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||
} else {
|
||||
link.setAttribute('target', '_blank');
|
||||
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;
|
||||
|
||||
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>
|
||||
{statuses.map((status) => {
|
||||
return <Status key={status.get('id')} {...other} status={status} />;
|
||||
|
@@ -1,20 +1,29 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from '../store/configureStore';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from '../store/configureStore';
|
||||
import {
|
||||
refreshTimelineSuccess,
|
||||
updateTimeline,
|
||||
deleteFromTimelines,
|
||||
refreshTimeline
|
||||
} from '../actions/timelines';
|
||||
import { setAccessToken } from '../actions/meta';
|
||||
import { setAccountSelf } from '../actions/accounts';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import { Router, Route, hashHistory } from 'react-router';
|
||||
import Account from '../features/account';
|
||||
import Settings from '../features/settings';
|
||||
import Status from '../features/status';
|
||||
import Subscriptions from '../features/subscriptions';
|
||||
import UI from '../features/ui';
|
||||
} from '../actions/timelines';
|
||||
import { setAccessToken } from '../actions/meta';
|
||||
import { setAccountSelf } from '../actions/accounts';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import {
|
||||
Router,
|
||||
Route,
|
||||
hashHistory,
|
||||
IndexRoute
|
||||
} 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();
|
||||
|
||||
@@ -32,21 +41,8 @@ const Mastodon = React.createClass({
|
||||
store.dispatch(setAccessToken(this.props.token));
|
||||
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') {
|
||||
App.timeline = App.cable.subscriptions.create("TimelineChannel", {
|
||||
connected () {
|
||||
|
||||
},
|
||||
|
||||
disconnected () {
|
||||
|
||||
},
|
||||
this.subscription = App.cable.subscriptions.create('TimelineChannel', {
|
||||
|
||||
received (data) {
|
||||
switch(data.type) {
|
||||
@@ -61,19 +57,31 @@ const Mastodon = React.createClass({
|
||||
return store.dispatch(refreshTimeline('mentions'));
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount () {
|
||||
if (typeof this.subscription !== 'undefined') {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Router history={hashHistory}>
|
||||
<Route path='/' component={UI}>
|
||||
<Route path='/settings' component={Settings} />
|
||||
<Route path='/subscriptions' component={Subscriptions} />
|
||||
<IndexRoute component={GettingStarted} />
|
||||
<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='/accounts/:accountId' component={Account} />
|
||||
<Route path='/accounts/:accountId' component={Account}>
|
||||
<IndexRoute component={AccountTimeline} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Router>
|
||||
</Provider>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Button from '../../../components/button';
|
||||
import DropdownMenu from '../../../components/dropdown_menu';
|
||||
|
||||
const ActionBar = React.createClass({
|
||||
|
||||
@@ -16,47 +16,42 @@ const ActionBar = React.createClass({
|
||||
render () {
|
||||
const { account, me } = this.props;
|
||||
|
||||
let infoText = '';
|
||||
let follow = '';
|
||||
let buttonText = '';
|
||||
let block = '';
|
||||
let disabled = false;
|
||||
let menu = [];
|
||||
|
||||
if (account.get('id') === me) {
|
||||
buttonText = 'This is you!';
|
||||
disabled = true;
|
||||
menu.push({ text: 'Edit profile', href: '/settings/profile' });
|
||||
} 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 {
|
||||
let blockText = '';
|
||||
|
||||
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} />;
|
||||
menu.push({ text: 'Follow', action: this.props.onFollow });
|
||||
menu.push({ text: 'Block', action: this.props.onBlock });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto' }}>
|
||||
{follow} {block}
|
||||
<span style={{ color: '#616b86', fontWeight: '500', textTransform: 'uppercase', float: 'right', display: 'block' }}>{infoText}</span>
|
||||
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto', display: 'flex' }}>
|
||||
<div style={{ flex: '1 1 auto', display: 'flex', lineHeight: '18px' }}>
|
||||
<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>
|
||||
);
|
||||
},
|
||||
|
@@ -4,24 +4,41 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
const Header = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
account: ImmutablePropTypes.map.isRequired
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
me: React.PropTypes.number.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
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 (
|
||||
<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={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}>
|
||||
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
|
||||
</div>
|
||||
<a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
|
||||
<div style={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}>
|
||||
<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>
|
||||
<p style={{ color: '#616b86', fontSize: '14px' }}>{account.get('note')}</p>
|
||||
|
||||
{info}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -10,30 +10,17 @@ 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 Header from './components/header';
|
||||
import {
|
||||
selectStatus,
|
||||
selectAccount
|
||||
} from '../../reducers/timelines';
|
||||
import StatusList from '../../components/status_list';
|
||||
import Immutable from 'immutable';
|
||||
getAccountTimeline,
|
||||
getAccount
|
||||
} from '../../selectors';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import ActionBar from './components/action_bar';
|
||||
|
||||
function selectStatuses(state, accountId) {
|
||||
return state.getIn(['timelines', 'accounts_timelines', accountId], Immutable.List()).map(id => selectStatus(state, id)).filterNot(status => status === null);
|
||||
};
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: selectAccount(state, Number(props.params.accountId)),
|
||||
statuses: selectStatuses(state, Number(props.params.accountId)),
|
||||
account: getAccount(state, Number(props.params.accountId)),
|
||||
me: state.getIn(['timelines', 'me'])
|
||||
});
|
||||
|
||||
@@ -43,20 +30,18 @@ const Account = React.createClass({
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
account: ImmutablePropTypes.map,
|
||||
statuses: ImmutablePropTypes.list
|
||||
me: React.PropTypes.number.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
|
||||
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(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 () {
|
||||
const { account, statuses, me } = this.props;
|
||||
const { account, me } = this.props;
|
||||
|
||||
if (account === null) {
|
||||
return <div>Loading {this.props.params.accountId}...</div>;
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}>
|
||||
<Header account={account} />
|
||||
<Column>
|
||||
<Header account={account} me={me} />
|
||||
|
||||
<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 IconButton from '../../../components/icon_button';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import DropdownMenu from '../../../components/dropdown_menu';
|
||||
|
||||
const ActionBar = React.createClass({
|
||||
|
||||
|
||||
propTypes: {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onReply: 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],
|
||||
|
||||
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 (
|
||||
<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 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' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -4,20 +4,24 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { fetchStatus } from '../../actions/statuses';
|
||||
import Immutable from 'immutable';
|
||||
import EmbeddedStatus from '../../components/status';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import DetailedStatus from './components/detailed_status';
|
||||
import ActionBar from './components/action_bar';
|
||||
import Column from '../ui/components/column';
|
||||
import { favourite, reblog } from '../../actions/interactions';
|
||||
import { replyCompose } from '../../actions/compose';
|
||||
import { selectStatus } from '../../reducers/timelines';
|
||||
|
||||
function selectStatuses(state, ids) {
|
||||
return ids.map(id => selectStatus(state, id)).filterNot(status => status === null);
|
||||
};
|
||||
import { deleteStatus } from '../../actions/statuses';
|
||||
import {
|
||||
getStatus,
|
||||
getStatusAncestors,
|
||||
getStatusDescendants
|
||||
} from '../../selectors';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
status: selectStatus(state, Number(props.params.statusId)),
|
||||
ancestors: selectStatuses(state, state.getIn(['timelines', 'ancestors', Number(props.params.statusId)], Immutable.OrderedSet())),
|
||||
descendants: selectStatuses(state, state.getIn(['timelines', 'descendants', Number(props.params.statusId)], Immutable.OrderedSet()))
|
||||
status: getStatus(state, Number(props.params.statusId)),
|
||||
ancestors: getStatusAncestors(state, Number(props.params.statusId)),
|
||||
descendants: getStatusDescendants(state, Number(props.params.statusId)),
|
||||
me: state.getIn(['timelines', 'me'])
|
||||
});
|
||||
|
||||
const Status = React.createClass({
|
||||
@@ -54,28 +58,38 @@ const Status = React.createClass({
|
||||
this.props.dispatch(reblog(status));
|
||||
},
|
||||
|
||||
handleDeleteClick (status) {
|
||||
this.props.dispatch(deleteStatus(status.get('id')));
|
||||
},
|
||||
|
||||
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 () {
|
||||
const { status, ancestors, descendants } = this.props;
|
||||
const { status, ancestors, descendants, me } = this.props;
|
||||
|
||||
if (status === null) {
|
||||
return <div>Loading {this.props.params.statusId}...</div>;
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const account = status.get('account');
|
||||
|
||||
return (
|
||||
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
|
||||
<div>{this.renderChildren(ancestors)}</div>
|
||||
<Column>
|
||||
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
|
||||
<div>{this.renderChildren(ancestors)}</div>
|
||||
|
||||
<DetailedStatus status={status} />
|
||||
<ActionBar status={status} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} />
|
||||
<DetailedStatus status={status} me={me} />
|
||||
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} />
|
||||
|
||||
<div>{this.renderChildren(descendants)}</div>
|
||||
</div>
|
||||
<div>{this.renderChildren(descendants)}</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;
|
||||
}
|
||||
|
||||
node.scrollTo(0, easingOutQuint(0, elapsed, offset, targetY, duration));
|
||||
node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration);
|
||||
requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
@@ -29,6 +29,12 @@ const scrollTop = (node) => {
|
||||
};
|
||||
};
|
||||
|
||||
const style = {
|
||||
boxSizing: 'border-box',
|
||||
background: '#282c37',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
};
|
||||
|
||||
const Column = React.createClass({
|
||||
|
||||
@@ -50,10 +56,6 @@ const Column = React.createClass({
|
||||
}
|
||||
},
|
||||
|
||||
handleScroll () {
|
||||
// todo
|
||||
},
|
||||
|
||||
render () {
|
||||
let header = '';
|
||||
|
||||
@@ -61,10 +63,8 @@ const Column = React.createClass({
|
||||
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 (
|
||||
<div style={style} onWheel={this.handleWheel} onScroll={this.handleScroll}>
|
||||
<div className='column' style={style} onWheel={this.handleWheel}>
|
||||
{header}
|
||||
{this.props.children}
|
||||
</div>
|
||||
|
@@ -1,12 +1,19 @@
|
||||
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({
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'row', flex: '1', marginRight: '10px', marginBottom: '10px', overflowX: 'auto' }}>
|
||||
<div className='columns-area' style={style}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,12 +1,21 @@
|
||||
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({
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
|
@@ -33,7 +33,7 @@ const FollowForm = React.createClass({
|
||||
render () {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
|
@@ -17,9 +17,9 @@ const NavigationBar = React.createClass({
|
||||
<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>
|
||||
|
||||
<div style={{ flex: '1 1 auto', marginLeft: '8px' }}>
|
||||
<strong style={{ fontWeight: '500', display: 'block' }}>{this.props.account.get('acct')}</strong>
|
||||
<a href='/settings' style={{ color: '#9baec8', textDecoration: 'none' }}>Settings <i className='fa fa fa-cog' /></a>
|
||||
<div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}>
|
||||
<strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong>
|
||||
<a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}>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>
|
||||
);
|
||||
|
@@ -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 ComposeForm from '../components/compose_form';
|
||||
import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose';
|
||||
import { selectStatus } from '../../../reducers/timelines';
|
||||
import { getStatus } from '../../../selectors';
|
||||
|
||||
const mapStateToProps = function (state, props) {
|
||||
return {
|
||||
text: state.getIn(['compose', 'text']),
|
||||
is_submitting: state.getIn(['compose', 'is_submitting']),
|
||||
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,
|
||||
clearNotifications
|
||||
} from '../../../actions/notifications';
|
||||
import { getNotifications } from '../../../selectors';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
notifications: state.get('notifications').map((item, i) => ({
|
||||
message: item.get('message'),
|
||||
title: item.get('title'),
|
||||
key: item.get('key'),
|
||||
dismissAfter: 5000
|
||||
})).toJS()
|
||||
notifications: getNotifications(state)
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => {
|
||||
|
@@ -8,14 +8,18 @@ import {
|
||||
unfavourite
|
||||
} from '../../../actions/interactions';
|
||||
import { expandTimeline } from '../../../actions/timelines';
|
||||
import { selectStatus } from '../../../reducers/timelines';
|
||||
import { makeGetTimeline } from '../../../selectors';
|
||||
import { deleteStatus } from '../../../actions/statuses';
|
||||
|
||||
const mapStateToProps = function (state, props) {
|
||||
return {
|
||||
statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id)),
|
||||
const makeMapStateToProps = () => {
|
||||
const getTimeline = makeGetTimeline();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
statuses: getTimeline(state, props.type),
|
||||
me: state.getIn(['timelines', 'me'])
|
||||
};
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
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 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 NavigationContainer from './containers/navigation_container';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
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({
|
||||
|
||||
propTypes: {
|
||||
router: React.PropTypes.object
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
const layoutBreakpoint = 1024;
|
||||
|
||||
return (
|
||||
<div style={{ flex: '0 0 auto', display: 'flex', width: '100%', height: '100%', background: '#1a1c23' }}>
|
||||
<Drawer>
|
||||
<div style={{ flex: '1 1 auto' }}>
|
||||
<NavigationContainer />
|
||||
<ComposeFormContainer />
|
||||
<UploadFormContainer />
|
||||
</div>
|
||||
<div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', width: '100%', height: '100%', background: '#1a1c23' }}>
|
||||
<MediaQuery maxWidth={layoutBreakpoint}>
|
||||
<TabsBar />
|
||||
</MediaQuery>
|
||||
|
||||
<FollowFormContainer />
|
||||
</Drawer>
|
||||
<MediaQuery maxWidth={layoutBreakpoint} component={ColumnsArea}>
|
||||
{this.props.children}
|
||||
</MediaQuery>
|
||||
|
||||
<ColumnsArea>
|
||||
<Column icon='home' heading='Home'>
|
||||
<StatusListContainer type='home' />
|
||||
</Column>
|
||||
|
||||
<Column icon='at' heading='Mentions'>
|
||||
<StatusListContainer type='mentions' />
|
||||
</Column>
|
||||
|
||||
<Column>
|
||||
<MediaQuery minWidth={layoutBreakpoint}>
|
||||
<ColumnsArea>
|
||||
<Compose />
|
||||
<HomeTimeline />
|
||||
<MentionsTimeline />
|
||||
{this.props.children}
|
||||
</Column>
|
||||
</ColumnsArea>
|
||||
</ColumnsArea>
|
||||
</MediaQuery>
|
||||
|
||||
<NotificationsContainer />
|
||||
<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 {
|
||||
REBLOG_FAIL,
|
||||
UNREBLOG_FAIL,
|
||||
FAVOURITE_FAIL,
|
||||
UNFAVOURITE_FAIL
|
||||
} from '../actions/interactions';
|
||||
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';
|
||||
NOTIFICATION_SHOW,
|
||||
NOTIFICATION_DISMISS,
|
||||
NOTIFICATION_CLEAR
|
||||
} from '../actions/notifications';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
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);
|
||||
};
|
||||
const initialState = Immutable.List([]);
|
||||
|
||||
export default function notifications(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case COMPOSE_SUBMIT_FAIL:
|
||||
case COMPOSE_UPLOAD_FAIL:
|
||||
case FOLLOW_SUBMIT_FAIL:
|
||||
case REBLOG_FAIL:
|
||||
case FAVOURITE_FAIL:
|
||||
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_SHOW:
|
||||
return state.push(Immutable.Map({
|
||||
key: state.size > 0 ? state.last().get('key') + 1 : 0,
|
||||
title: action.title,
|
||||
message: action.message
|
||||
}));
|
||||
case NOTIFICATION_DISMISS:
|
||||
return state.filterNot(item => item.get('key') === action.notification.key);
|
||||
case NOTIFICATION_CLEAR:
|
||||
|
@@ -25,53 +25,30 @@ import {
|
||||
STATUS_DELETE_SUCCESS
|
||||
} from '../actions/statuses';
|
||||
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
|
||||
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const initialState = Immutable.Map({
|
||||
home: Immutable.List([]),
|
||||
mentions: Immutable.List([]),
|
||||
public: Immutable.List([]),
|
||||
statuses: Immutable.Map(),
|
||||
accounts: Immutable.Map(),
|
||||
accounts_timelines: Immutable.Map(),
|
||||
me: null,
|
||||
ancestors: 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) {
|
||||
// Separate account
|
||||
let account = status.get('account');
|
||||
status = status.set('account', account.get('id'));
|
||||
|
||||
// Separate reblog, repeat for reblog
|
||||
let reblog = status.get('reblog');
|
||||
let reblog = status.get('reblog', null);
|
||||
|
||||
if (reblog !== null) {
|
||||
status = status.set('reblog', reblog.get('id'));
|
||||
@@ -101,16 +78,18 @@ function normalizeStatus(state, status) {
|
||||
};
|
||||
|
||||
function normalizeTimeline(state, timeline, statuses) {
|
||||
let ids = Immutable.List([]);
|
||||
|
||||
statuses.forEach((status, i) => {
|
||||
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) {
|
||||
let moreIds = Immutable.List();
|
||||
let moreIds = Immutable.List([]);
|
||||
|
||||
statuses.forEach((status, i) => {
|
||||
state = normalizeStatus(state, status);
|
||||
@@ -121,29 +100,44 @@ function appendNormalizedTimeline(state, timeline, 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) => {
|
||||
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;
|
||||
};
|
||||
|
||||
function appendNormalizedAccountTimeline(state, accountId, statuses) {
|
||||
let moreIds = Immutable.List();
|
||||
let moreIds = Immutable.List([]);
|
||||
|
||||
statuses.forEach((status, i) => {
|
||||
state = normalizeStatus(state, status);
|
||||
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) {
|
||||
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;
|
||||
};
|
||||
@@ -161,7 +155,7 @@ function deleteStatus(state, id) {
|
||||
});
|
||||
|
||||
// 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
|
||||
const references = state.get('statuses').filter(item => item.get('reblog') === id);
|
||||
@@ -183,6 +177,10 @@ function normalizeAccount(state, account, 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);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
switch(action.type) {
|
||||
case TIMELINE_REFRESH_SUCCESS:
|
||||
@@ -242,6 +248,8 @@ export default function timelines(state = initialState, action) {
|
||||
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
||||
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
||||
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
||||
case SUGGESTIONS_FETCH_SUCCESS:
|
||||
return normalizeSuggestions(state, Immutable.fromJS(action.suggestions));
|
||||
default:
|
||||
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 appReducer from '../reducers';
|
||||
import { loadingBarMiddleware } from 'react-redux-loading-bar';
|
||||
import errorsMiddleware from '../middleware/errors';
|
||||
|
||||
export default function configureStore(initialState) {
|
||||
return createStore(appReducer, initialState, compose(applyMiddleware(thunk, loadingBarMiddleware({
|
||||
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 {
|
||||
display: flex;
|
||||
margin-top: 30px;
|
||||
|
@@ -57,6 +57,43 @@ table {
|
||||
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 {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
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 {
|
||||
list-style: none;
|
||||
|
||||
@@ -359,6 +229,7 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@import 'forms';
|
||||
@import 'accounts';
|
||||
@import 'stream_entries';
|
||||
@import 'components';
|
||||
|
@@ -71,6 +71,7 @@
|
||||
line-height: 20px;
|
||||
word-wrap: break-word;
|
||||
font-weight: 300;
|
||||
overflow: hidden;
|
||||
|
||||
p {
|
||||
margin-bottom: 20px;
|
||||
@@ -197,7 +198,7 @@
|
||||
font-size: 13px;
|
||||
display: block;
|
||||
padding: 6px 16px;
|
||||
width: 120px;
|
||||
width: 100px;
|
||||
text-decoration: none;
|
||||
background: #d9e1e8;
|
||||
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 {
|
||||
border-radius: 4px 4px 0 0;
|
||||
.entry:first-child {
|
||||
border-radius: 4px 4px 0 0;
|
||||
|
||||
&:last-child {
|
||||
border-radius: 4px;
|
||||
}
|
||||
&:last-child {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,19 +73,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 360px) {
|
||||
.avatar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.entry__container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 56px;
|
||||
padding: 15px;
|
||||
float: left;
|
||||
|
||||
img {
|
||||
width: 56px;
|
||||
@@ -98,7 +91,7 @@
|
||||
}
|
||||
|
||||
.entry__container__container {
|
||||
flex-grow: 1;
|
||||
margin-left: 86px;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -153,10 +146,12 @@
|
||||
|
||||
.content {
|
||||
font-size: 14px;
|
||||
padding: 0 10px;
|
||||
padding: 0 15px;
|
||||
padding-left: 8px;
|
||||
padding-bottom: 15px;
|
||||
color: #282c37;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
|
||||
p {
|
||||
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
|
||||
|
||||
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
|
||||
@followers = @account.followers.order('follows.created_at desc').paginate(page: params[:page], per_page: 6)
|
||||
end
|
||||
|
@@ -3,8 +3,14 @@ class Api::SalmonController < ApiController
|
||||
respond_to :txt
|
||||
|
||||
def update
|
||||
ProcessInteractionService.new.call(request.body.read, @account)
|
||||
head 201
|
||||
body = request.body.read
|
||||
|
||||
if body.nil?
|
||||
head 200
|
||||
else
|
||||
ProcessInteractionService.new.call(body, @account)
|
||||
head 201
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
@@ -13,8 +13,9 @@ class Api::SubscriptionsController < ApiController
|
||||
|
||||
def update
|
||||
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)
|
||||
head 201
|
||||
else
|
||||
|
@@ -1,6 +1,6 @@
|
||||
class Api::V1::AccountsController < ApiController
|
||||
before_action :doorkeeper_authorize!
|
||||
before_action :set_account, except: :verify_credentials
|
||||
before_action :set_account, except: [:verify_credentials, :suggestions]
|
||||
respond_to :json
|
||||
|
||||
def show
|
||||
@@ -19,8 +19,13 @@ class Api::V1::AccountsController < ApiController
|
||||
@followers = @account.followers
|
||||
end
|
||||
|
||||
def suggestions
|
||||
@accounts = FollowSuggestion.get(current_user.account_id)
|
||||
end
|
||||
|
||||
def statuses
|
||||
@statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a
|
||||
set_maps(@statuses)
|
||||
end
|
||||
|
||||
def follow
|
||||
@@ -49,7 +54,7 @@ class Api::V1::AccountsController < ApiController
|
||||
|
||||
def relationships
|
||||
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)
|
||||
@followed_by = Account.followed_by_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
|
||||
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
|
||||
end
|
||||
end
|
||||
|
@@ -4,5 +4,9 @@ class Api::V1::MediaController < ApiController
|
||||
|
||||
def create
|
||||
@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
|
||||
|
@@ -10,6 +10,7 @@ class Api::V1::StatusesController < ApiController
|
||||
@status = Status.find(params[:id])
|
||||
@ancestors = @status.ancestors
|
||||
@descendants = @status.descendants
|
||||
set_maps([@status] + @ancestors + @descendants)
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -46,9 +47,19 @@ class Api::V1::StatusesController < ApiController
|
||||
|
||||
def home
|
||||
@statuses = Feed.new(:home, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
|
||||
set_maps(@statuses)
|
||||
render action: :index
|
||||
end
|
||||
|
||||
def mentions
|
||||
@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
|
||||
|
@@ -35,4 +35,10 @@ class ApiController < ApplicationController
|
||||
def render_empty
|
||||
render json: {}, status: 200
|
||||
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
|
||||
|
@@ -10,7 +10,7 @@ class ApplicationController < ActionController::Base
|
||||
rescue_from ActionController::RoutingError, 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
|
||||
raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}"
|
||||
|
@@ -17,6 +17,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
end
|
||||
|
||||
def after_sign_up_path_for(_resource)
|
||||
root_path
|
||||
new_user_session_path
|
||||
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'
|
||||
|
||||
before_action :authenticate_user!
|
||||
@@ -9,7 +9,7 @@ class SettingsController < ApplicationController
|
||||
|
||||
def update
|
||||
if @account.update(account_params)
|
||||
redirect_to settings_path
|
||||
redirect_to settings_profile_path, notice: 'Changes successfully saved!'
|
||||
else
|
||||
render action: :show
|
||||
end
|
@@ -1,27 +1,40 @@
|
||||
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
|
||||
@webfinger_template = "#{webfinger_url}?resource={uri}"
|
||||
|
||||
respond_to do |format|
|
||||
format.xml { render content_type: 'application/xrd+xml' }
|
||||
end
|
||||
end
|
||||
|
||||
def webfinger
|
||||
@account = Account.find_local!(username_from_resource)
|
||||
@canonical_account_uri = "acct:#{@account.username}@#{Rails.configuration.x.local_domain}"
|
||||
@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
|
||||
head 404
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_format
|
||||
request.format = 'xml'
|
||||
response.headers['Content-Type'] = 'application/xrd+xml'
|
||||
def set_default_format_xml
|
||||
request.format = 'xml' if request.headers["HTTP_ACCEPT"].nil? && params[:format].nil?
|
||||
end
|
||||
|
||||
def set_default_format_json
|
||||
request.format = 'json' if request.headers["HTTP_ACCEPT"].nil? && params[:format].nil?
|
||||
end
|
||||
|
||||
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:', '')
|
||||
else
|
||||
url = Addressable::URI.parse(resource_param)
|
||||
|
@@ -149,6 +149,7 @@ module AtomBuilderHelper
|
||||
verb xml, stream_entry.verb
|
||||
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)
|
||||
object_type xml, stream_entry.object_type
|
||||
|
||||
# Comments need thread element
|
||||
if stream_entry.threaded?
|
||||
@@ -157,17 +158,18 @@ module AtomBuilderHelper
|
||||
|
||||
if stream_entry.targeted?
|
||||
target(xml) do
|
||||
simple_id xml, TagManager.instance.uri_for(stream_entry.target)
|
||||
|
||||
if stream_entry.target.object_type == :person
|
||||
include_author xml, stream_entry.target
|
||||
else
|
||||
object_type xml, stream_entry.target.object_type
|
||||
simple_id xml, TagManager.instance.uri_for(stream_entry.target)
|
||||
title xml, stream_entry.target.title
|
||||
link_alternate xml, TagManager.instance.url_for(stream_entry.target)
|
||||
end
|
||||
|
||||
# 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)
|
||||
verb xml, stream_entry.target.verb
|
||||
published_at xml, stream_entry.target.created_at
|
||||
@@ -176,10 +178,16 @@ module AtomBuilderHelper
|
||||
author(xml) do
|
||||
include_author xml, stream_entry.target.account
|
||||
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
|
||||
else
|
||||
object_type xml, stream_entry.object_type
|
||||
end
|
||||
|
||||
stream_entry.mentions.each do |mentioned|
|
||||
|
@@ -2,13 +2,7 @@ module HomeHelper
|
||||
def default_props
|
||||
{
|
||||
token: @token,
|
||||
|
||||
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)
|
||||
}
|
||||
account: render(file: 'api/v1/accounts/show', locals: { account: current_user.account }, formats: :json)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
@@ -1,2 +0,0 @@
|
||||
module SettingsHelper
|
||||
end
|
@@ -33,22 +33,6 @@ class FeedManager
|
||||
redis.zremrangebyscore(key(type, account_id), '-inf', "(#{last.last}")
|
||||
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)
|
||||
rabl_scope = Class.new do
|
||||
include RoutingHelper
|
||||
@@ -58,7 +42,7 @@ class FeedManager
|
||||
end
|
||||
|
||||
def current_user
|
||||
@account.user
|
||||
@account.try(:user)
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
|
@@ -29,7 +29,9 @@ class Formatter
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def link_mentions(html, mentions)
|
||||
|
@@ -17,6 +17,10 @@ class TagManager
|
||||
id.start_with?("tag:#{Rails.configuration.x.local_domain}")
|
||||
end
|
||||
|
||||
def local_domain?(domain)
|
||||
domain.nil? || domain.gsub(/[\/]/, '').downcase == Rails.configuration.x.local_domain.downcase
|
||||
end
|
||||
|
||||
def uri_for(target)
|
||||
return target.uri if target.respond_to?(:local?) && !target.local?
|
||||
|
||||
|
@@ -1,11 +1,11 @@
|
||||
class NotificationMailer < ApplicationMailer
|
||||
helper StreamEntriesHelper
|
||||
helper AtomBuilderHelper
|
||||
|
||||
def mention(mentioned_account, status)
|
||||
@me = mentioned_account
|
||||
@status = status
|
||||
|
||||
return unless @me.user.settings(:notification_emails).mention
|
||||
mail to: @me.user.email, subject: "You were mentioned by #{@status.account.acct}"
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@ class NotificationMailer < ApplicationMailer
|
||||
@me = followed_account
|
||||
@account = follower
|
||||
|
||||
return unless @me.user.settings(:notification_emails).follow
|
||||
mail to: @me.user.email, subject: "#{@account.acct} is now following you"
|
||||
end
|
||||
|
||||
@@ -21,6 +22,7 @@ class NotificationMailer < ApplicationMailer
|
||||
@account = from_account
|
||||
@status = target_status
|
||||
|
||||
return unless @me.user.settings(:notification_emails).favourite
|
||||
mail to: @me.user.email, subject: "#{@account.acct} favourited your status"
|
||||
end
|
||||
|
||||
@@ -29,6 +31,7 @@ class NotificationMailer < ApplicationMailer
|
||||
@account = from_account
|
||||
@status = target_status
|
||||
|
||||
return unless @me.user.settings(:notification_emails).reblog
|
||||
mail to: @me.user.email, subject: "#{@account.acct} reblogged your status"
|
||||
end
|
||||
end
|
||||
|
@@ -24,10 +24,10 @@ class Account < ApplicationRecord
|
||||
validates :note, length: { maximum: 124 }, if: 'local?'
|
||||
|
||||
# Timelines
|
||||
has_many :stream_entries, inverse_of: :account
|
||||
has_many :statuses, inverse_of: :account
|
||||
has_many :favourites, inverse_of: :account
|
||||
has_many :mentions, inverse_of: :account
|
||||
has_many :stream_entries, inverse_of: :account, dependent: :destroy
|
||||
has_many :statuses, inverse_of: :account, dependent: :destroy
|
||||
has_many :favourites, inverse_of: :account, dependent: :destroy
|
||||
has_many :mentions, inverse_of: :account, dependent: :destroy
|
||||
|
||||
# Follow relations
|
||||
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 :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)
|
||||
active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
|
||||
end
|
||||
@@ -125,7 +127,7 @@ class Account < ApplicationRecord
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def self.find_local(username)
|
||||
|
@@ -4,8 +4,8 @@ module Paginable
|
||||
included do
|
||||
def self.paginate_by_max_id(limit, max_id = nil, since_id = nil)
|
||||
query = order('id desc').limit(limit)
|
||||
query = query.where('id < ?', max_id) unless max_id.blank?
|
||||
query = query.where('id > ?', since_id) unless since_id.blank?
|
||||
query = query.where(arel_table[:id].lt(max_id)) unless max_id.blank?
|
||||
query = query.where(arel_table[:id].gt(since_id)) unless since_id.blank?
|
||||
query
|
||||
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
|
||||
destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}"
|
||||
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
|
||||
|
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
|
||||
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 :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_size :file, less_than: 4.megabytes
|
||||
|
||||
@@ -30,4 +33,31 @@ class MediaAttachment < ApplicationRecord
|
||||
def type
|
||||
image? ? 'image' : 'video'
|
||||
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
|
||||
|
@@ -2,7 +2,7 @@ class Status < ApplicationRecord
|
||||
include Paginable
|
||||
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 :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 :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_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
|
||||
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)
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
before_validation do
|
||||
|
@@ -39,7 +39,7 @@ class StreamEntry < ApplicationRecord
|
||||
end
|
||||
|
||||
def threaded?
|
||||
verb == :favorite || object_type == :comment
|
||||
(verb == :favorite || object_type == :comment) && !thread.nil?
|
||||
end
|
||||
|
||||
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 :recent, -> { order('created_at desc') }
|
||||
scope :admins, -> { where(admin: true) }
|
||||
|
||||
has_settings do |s|
|
||||
s.key :notification_emails, defaults: { follow: true, reblog: true, favourite: true, mention: true }
|
||||
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_followers(status)
|
||||
deliver_to_mentioned(status)
|
||||
deliver_to_public(status)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -27,4 +28,8 @@ class FanOutOnWriteService < BaseService
|
||||
FeedManager.instance.push(:mentions, mentioned_account, status)
|
||||
end
|
||||
end
|
||||
|
||||
def deliver_to_public(status)
|
||||
FeedManager.instance.broadcast(:public, id: status.id)
|
||||
end
|
||||
end
|
||||
|
@@ -19,6 +19,8 @@ class FetchRemoteAccountService < BaseService
|
||||
Rails.logger.debug "Going to webfinger #{username}@#{domain}"
|
||||
|
||||
return FollowRemoteAccountService.new.call("#{username}@#{domain}")
|
||||
rescue TypeError => e
|
||||
Rails.logger.debug "Unparseable URL given: #{url}"
|
||||
rescue Nokogiri::XML::XPath::SyntaxError
|
||||
Rails.logger.debug "Invalid XML or missing namespace"
|
||||
end
|
||||
|
@@ -1,4 +1,8 @@
|
||||
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.
|
||||
# When creating, look up the user's webfinger and fetch all
|
||||
# important information from their feed
|
||||
@@ -7,7 +11,8 @@ class FollowRemoteAccountService < BaseService
|
||||
def call(uri)
|
||||
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)
|
||||
|
||||
@@ -18,27 +23,21 @@ class FollowRemoteAccountService < BaseService
|
||||
|
||||
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.salmon_url = data.link('salmon').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.private_key = nil
|
||||
|
||||
feed = get_feed(account.remote_url)
|
||||
hubs = feed.xpath('//xmlns:link[@rel="hub"]')
|
||||
xml = get_feed(account.remote_url)
|
||||
hubs = get_hubs(xml)
|
||||
|
||||
if hubs.empty? || hubs.first.attribute('href').nil?
|
||||
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.uri = get_account_uri(xml)
|
||||
account.hub_url = hubs.first.attribute('href').value
|
||||
|
||||
get_profile(feed, account)
|
||||
get_profile(xml, account)
|
||||
account.save!
|
||||
|
||||
return account
|
||||
@@ -51,20 +50,27 @@ class FollowRemoteAccountService < BaseService
|
||||
Nokogiri::XML(response)
|
||||
end
|
||||
|
||||
def get_profile(xml, account)
|
||||
author = xml.at_xpath('/xmlns:feed/xmlns:author')
|
||||
update_remote_profile_service.call(author, account)
|
||||
def get_hubs(xml)
|
||||
hubs = xml.xpath('//xmlns:link[@rel="hub"]')
|
||||
raise Goldfinger::Error, 'No PubSubHubbub hubs found' if hubs.empty? || hubs.first.attribute('href').nil?
|
||||
hubs
|
||||
end
|
||||
|
||||
def magic_key_to_pem(magic_key)
|
||||
_, modulus, exponent = magic_key.split('.')
|
||||
modulus, exponent = [modulus, exponent].map { |n| Base64.urlsafe_decode64(n).bytes.inject(0) { |a, e| (a << 8) | e } }
|
||||
def get_account_uri(xml)
|
||||
author_uri = xml.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri')
|
||||
|
||||
key = OpenSSL::PKey::RSA.new
|
||||
key.n = modulus
|
||||
key.e = exponent
|
||||
if author_uri.nil?
|
||||
owner = xml.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS)
|
||||
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
|
||||
|
||||
def update_remote_profile_service
|
||||
|
@@ -5,7 +5,7 @@ class FollowService < BaseService
|
||||
def call(source_account, 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)
|
||||
|
||||
|
@@ -1,4 +1,7 @@
|
||||
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
|
||||
# @param [String] body Atom feed
|
||||
# @param [Account] account Account this feed belongs to
|
||||
@@ -34,23 +37,27 @@ class ProcessFeedService < BaseService
|
||||
else
|
||||
add_reply!(entry, status)
|
||||
end
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
# 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
|
||||
unless status.new_record?
|
||||
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.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)
|
||||
return status
|
||||
end
|
||||
|
||||
return status
|
||||
end
|
||||
|
||||
def record_remote_mentions(status, links)
|
||||
return if status.local?
|
||||
|
||||
# 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
|
||||
# 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)
|
||||
|
||||
if href.host == Rails.configuration.x.local_domain
|
||||
if TagManager.instance.local_domain?(href.host)
|
||||
# A local user is mentioned
|
||||
mentioned_account = Account.find_local(href.path.gsub('/users/', ''))
|
||||
|
||||
@@ -88,6 +95,8 @@ class ProcessFeedService < BaseService
|
||||
end
|
||||
|
||||
def process_attachments(entry, status)
|
||||
return if status.local?
|
||||
|
||||
entry.xpath('./xmlns:link[@rel="enclosure"]').each do |enclosure_link|
|
||||
next if enclosure_link.attribute('href').nil?
|
||||
|
||||
@@ -95,9 +104,14 @@ class ProcessFeedService < BaseService
|
||||
|
||||
next unless media.nil?
|
||||
|
||||
media = MediaAttachment.new(account: status.account, status: status, remote_url: enclosure_link.attribute('href').value)
|
||||
media.file_remote_url = enclosure_link.attribute('href').value
|
||||
media.save
|
||||
begin
|
||||
media = MediaAttachment.new(account: status.account, status: status, remote_url: enclosure_link.attribute('href').value)
|
||||
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
|
||||
|
||||
@@ -142,10 +156,10 @@ class ProcessFeedService < BaseService
|
||||
end
|
||||
|
||||
def fetch_remote_status(xml)
|
||||
username = xml.at_xpath('./activity:object/xmlns:author/xmlns:name').content
|
||||
url = xml.at_xpath('./activity:object/xmlns:author/xmlns:uri').content
|
||||
username = xml.at_xpath('./activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:author/xmlns:name').content
|
||||
url = xml.at_xpath('./activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:author/xmlns:uri').content
|
||||
domain = Addressable::URI.parse(url).host
|
||||
account = Account.find_by(username: username, domain: domain)
|
||||
account = Account.find_remote(username, domain)
|
||||
|
||||
if account.nil?
|
||||
account = follow_remote_account_service.call("#{username}@#{domain}")
|
||||
@@ -172,23 +186,23 @@ class ProcessFeedService < BaseService
|
||||
end
|
||||
|
||||
def content(xml)
|
||||
xml.at_xpath('./xmlns:content').content
|
||||
xml.at_xpath('./xmlns:content').try(:content)
|
||||
end
|
||||
|
||||
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
|
||||
nil
|
||||
end
|
||||
|
||||
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
|
||||
nil
|
||||
end
|
||||
|
||||
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
|
||||
nil
|
||||
end
|
||||
@@ -204,21 +218,21 @@ class ProcessFeedService < BaseService
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
:note
|
||||
:activity
|
||||
end
|
||||
|
||||
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
|
||||
:post
|
||||
end
|
||||
|
@@ -1,4 +1,6 @@
|
||||
class ProcessInteractionService < BaseService
|
||||
ACTIVITY_NS = 'http://activitystrea.ms/spec/1.0/'.freeze
|
||||
|
||||
# Record locally the remote interaction with our user
|
||||
# @param [String] envelope Salmon envelope
|
||||
# @param [Account] target_account Account the Salmon was addressed to
|
||||
@@ -13,6 +15,8 @@ class ProcessInteractionService < BaseService
|
||||
domain = Addressable::URI.parse(url).host
|
||||
account = Account.find_by(username: username, domain: domain)
|
||||
|
||||
return if DomainBlock.blocked?(domain)
|
||||
|
||||
if account.nil?
|
||||
account = follow_remote_account_service.call("#{username}@#{domain}")
|
||||
end
|
||||
@@ -35,7 +39,7 @@ class ProcessInteractionService < BaseService
|
||||
delete_post!(xml, account)
|
||||
end
|
||||
end
|
||||
rescue Goldfinger::Error, HTTP::Error
|
||||
rescue Goldfinger::Error, HTTP::Error, OStatus2::BadSalmonError
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -51,7 +55,7 @@ class ProcessInteractionService < BaseService
|
||||
end
|
||||
|
||||
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
|
||||
:post
|
||||
end
|
||||
@@ -90,7 +94,7 @@ class ProcessInteractionService < BaseService
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def salmon
|
||||
|
@@ -34,7 +34,8 @@ class RemoveStatusService < BaseService
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def remove_reblogs(status)
|
||||
|
@@ -1,13 +1,17 @@
|
||||
class UpdateRemoteProfileService < BaseService
|
||||
POCO_NS = 'http://portablecontacts.net/spec/1.0'
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
%h1
|
||||
= image_tag 'logo.png'
|
||||
@@ -16,4 +19,5 @@
|
||||
is a Mastodon instance.
|
||||
|
||||
.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)})" }
|
||||
- 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)
|
||||
%h1.name
|
||||
= 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