timeline reload in UI, other small fixesmaster
@@ -2,7 +2,7 @@ FROM ruby:2.2.4 | |||
ENV RAILS_ENV=production | |||
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev libxml2-dev libxslt1-dev nodejs nodejs-legacy npm && rm -rf /var/lib/apt/lists/* | |||
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 mkdir /mastodon | |||
WORKDIR /mastodon | |||
@@ -16,6 +16,7 @@ gem 'dotenv-rails' | |||
gem 'font-awesome-rails' | |||
gem 'paperclip', '~> 4.3' | |||
gem 'paperclip-av-transcoder' | |||
gem 'http' | |||
gem 'addressable' | |||
@@ -31,12 +32,11 @@ gem 'hiredis' | |||
gem 'redis', '~>3.2' | |||
gem 'fast_blank' | |||
gem 'htmlentities' | |||
gem 'onebox' | |||
gem 'simple_form' | |||
gem 'will_paginate' | |||
gem 'rack-attack' | |||
gem 'sidekiq' | |||
gem 'sinatra', require: nil, github: 'sinatra' | |||
gem 'sinatra', require: nil, git: 'https://github.com/sinatra/sinatra.git' | |||
gem 'react-rails' | |||
gem 'browserify-rails' | |||
@@ -1,13 +1,13 @@ | |||
GIT | |||
remote: git://github.com/sinatra/sinatra.git | |||
revision: 6b5a0ef3a4598366138fefe3f2b696ddeb371f3c | |||
remote: https://github.com/sinatra/sinatra.git | |||
revision: 1b0edc0aeaaf4839cadfcec1b21da86e6af1d4c0 | |||
specs: | |||
rack-protection (2.0.0) | |||
rack-protection (2.0.0.beta2) | |||
rack | |||
sinatra (2.0.0.pre.alpha) | |||
mustermann (~> 0.4) | |||
sinatra (2.0.0.beta2) | |||
mustermann (= 1.0.0.beta2) | |||
rack (~> 2.0) | |||
rack-protection (~> 2.0) | |||
rack-protection (= 2.0.0.beta2) | |||
tilt (~> 2.0) | |||
GEM | |||
@@ -54,6 +54,8 @@ GEM | |||
addressable (2.4.0) | |||
arel (7.1.1) | |||
ast (2.3.0) | |||
av (0.9.0) | |||
cocaine (~> 0.5.3) | |||
babel-source (5.8.35) | |||
babel-transpiler (0.7.0) | |||
babel-source (>= 4.0, < 6) | |||
@@ -174,22 +176,13 @@ GEM | |||
mimemagic (0.3.0) | |||
mini_portile2 (2.1.0) | |||
minitest (5.9.0) | |||
moneta (0.8.0) | |||
multi_json (1.12.1) | |||
mustache (1.0.3) | |||
mustermann (0.4.0) | |||
tool (~> 0.2) | |||
mustermann (1.0.0.beta2) | |||
nio4r (1.2.1) | |||
nokogiri (1.6.8) | |||
mini_portile2 (~> 2.1.0) | |||
pkg-config (~> 1.1.7) | |||
oj (2.17.3) | |||
onebox (1.5.48) | |||
htmlentities (~> 4.3.4) | |||
moneta (~> 0.8) | |||
multi_json (~> 1.11) | |||
mustache | |||
nokogiri (~> 1.6.6) | |||
orm_adapter (0.5.0) | |||
ostatus2 (0.1.1) | |||
addressable (~> 2.4) | |||
@@ -201,6 +194,9 @@ GEM | |||
cocaine (~> 0.5.5) | |||
mime-types | |||
mimemagic (= 0.3.0) | |||
paperclip-av-transcoder (0.6.4) | |||
av (~> 0.9.0) | |||
paperclip (>= 2.5.2) | |||
parser (2.3.1.2) | |||
ast (~> 2.2) | |||
pg (0.18.4) | |||
@@ -336,7 +332,6 @@ GEM | |||
thor (0.19.1) | |||
thread_safe (0.3.5) | |||
tilt (2.0.5) | |||
tool (0.2.3) | |||
tzinfo (1.2.2) | |||
thread_safe (~> 0.1) | |||
uglifier (3.0.1) | |||
@@ -386,9 +381,9 @@ DEPENDENCIES | |||
lograge | |||
nokogiri | |||
oj | |||
onebox | |||
ostatus2 | |||
paperclip (~> 4.3) | |||
paperclip-av-transcoder | |||
pg | |||
pry-rails | |||
puma | |||
@@ -414,4 +409,4 @@ DEPENDENCIES | |||
will_paginate | |||
BUNDLED WITH | |||
1.12.5 | |||
1.13.0 |
@@ -1,6 +1,12 @@ | |||
export const TIMELINE_SET = 'TIMELINE_SET'; | |||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; | |||
export const TIMELINE_DELETE = 'TIMELINE_DELETE'; | |||
import api from '../api' | |||
export const TIMELINE_SET = 'TIMELINE_SET'; | |||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; | |||
export const TIMELINE_DELETE = 'TIMELINE_DELETE'; | |||
export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST'; | |||
export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS'; | |||
export const TIMELINE_REFRESH_FAIL = 'TIMELINE_REFRESH_FAIL'; | |||
export function setTimeline(timeline, statuses) { | |||
return { | |||
@@ -24,3 +30,36 @@ export function deleteFromTimelines(id) { | |||
id: id | |||
}; | |||
} | |||
export function refreshTimelineRequest(timeline) { | |||
return { | |||
type: TIMELINE_REFRESH_REQUEST, | |||
timeline: timeline | |||
}; | |||
} | |||
export function refreshTimeline(timeline) { | |||
return function (dispatch, getState) { | |||
dispatch(refreshTimelineRequest(timeline)); | |||
api(getState).get(`/api/statuses/${timeline}`).then(function (response) { | |||
dispatch(refreshTimelineSuccess(timeline, response.data)); | |||
}).catch(function (error) { | |||
dispatch(refreshTimelineFail(timeline, error)); | |||
}); | |||
}; | |||
} | |||
export function refreshTimelineSuccess(timeline, statuses) { | |||
return function (dispatch) { | |||
dispatch(setTimeline(timeline, statuses)); | |||
}; | |||
} | |||
export function refreshTimelineFail(timeline, error) { | |||
return { | |||
type: TIMELINE_REFRESH_FAIL, | |||
timeline: timeline, | |||
error: error | |||
}; | |||
} |
@@ -11,7 +11,7 @@ const Avatar = React.createClass({ | |||
render () { | |||
return ( | |||
<div style={{ width: `${this.props.size}px`, height: `${this.props.size}px` }}> | |||
<div style={{ width: `${this.props.size}px`, height: `${this.props.size}px`, borderRadius: '4px', overflow: 'hidden' }} className='transparent-background'> | |||
<img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ display: 'block', borderRadius: '4px' }} /> | |||
</div> | |||
); | |||
@@ -1,12 +1,12 @@ | |||
import { Provider } from 'react-redux'; | |||
import configureStore from '../store/configureStore'; | |||
import Frontend from '../components/frontend'; | |||
import { setTimeline, updateTimeline, deleteFromTimelines } from '../actions/timelines'; | |||
import { setAccessToken } from '../actions/meta'; | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
import { Router, Route, createMemoryHistory } from 'react-router'; | |||
import AccountRoute from '../routes/account_route'; | |||
import StatusRoute from '../routes/status_route'; | |||
import { Provider } from 'react-redux'; | |||
import configureStore from '../store/configureStore'; | |||
import Frontend from '../components/frontend'; | |||
import { setTimeline, updateTimeline, deleteFromTimelines, refreshTimeline } from '../actions/timelines'; | |||
import { setAccessToken } from '../actions/meta'; | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
import { Router, Route, createMemoryHistory } from 'react-router'; | |||
import AccountRoute from '../routes/account_route'; | |||
import StatusRoute from '../routes/status_route'; | |||
const store = configureStore(); | |||
const history = createMemoryHistory(); | |||
@@ -36,10 +36,14 @@ const Root = React.createClass({ | |||
disconnected: function() {}, | |||
received: function(data) { | |||
if (data.type === 'update') { | |||
return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message))); | |||
} else if (data.type === 'delete') { | |||
return store.dispatch(deleteFromTimelines(data.id)); | |||
switch(data.type) { | |||
case 'update': | |||
return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message))); | |||
case 'delete': | |||
return store.dispatch(deleteFromTimelines(data.id)); | |||
case 'merge': | |||
case 'unmerge': | |||
return store.dispatch(refreshTimeline('home')); | |||
} | |||
} | |||
}); | |||
@@ -10,7 +10,9 @@ module ApplicationCable | |||
protected | |||
def find_verified_user | |||
if verified_user = env['warden'].user | |||
verified_user = env['warden'].user | |||
if verified_user | |||
verified_user | |||
else | |||
reject_unauthorized_connection | |||
@@ -17,7 +17,11 @@ class FeedManager | |||
def push(timeline_type, account, status) | |||
redis.zadd(key(timeline_type, account.id), status.id, status.id) | |||
trim(timeline_type, account.id) | |||
ActionCable.server.broadcast("timeline:#{account.id}", type: 'update', timeline: timeline_type, message: inline_render(account, status)) | |||
broadcast(account.id, type: 'update', timeline: timeline_type, message: inline_render(account, status)) | |||
end | |||
def broadcast(account_id, options = {}) | |||
ActionCable.server.broadcast("timeline:#{account_id}", options) | |||
end | |||
def trim(type, account_id) | |||
@@ -35,7 +35,7 @@ class Formatter | |||
def link_mentions(html, mentions) | |||
html.gsub(Account::MENTION_RE) do |match| | |||
acct = Account::MENTION_RE.match(match)[1] | |||
mention = mentions.find { |mention| mention.account.acct.eql?(acct) } | |||
mention = mentions.find { |item| item.account.acct.eql?(acct) } | |||
mention.nil? ? match : mention_html(match, mention.account) | |||
end | |||
@@ -1,6 +1,9 @@ | |||
class Account < ApplicationRecord | |||
include Targetable | |||
MENTION_RE = /(?:^|\s|\.|>)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i | |||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'] | |||
# Local users | |||
has_one :user, inverse_of: :account | |||
validates :username, presence: true, format: { with: /\A[a-z0-9_]+\z/i, message: 'only letters, numbers and underscores' }, uniqueness: { scope: :domain, case_sensitive: false }, if: 'local?' | |||
@@ -8,12 +11,12 @@ class Account < ApplicationRecord | |||
# Avatar upload | |||
has_attached_file :avatar, styles: { large: '300x300#', medium: '96x96#', small: '48x48#' } | |||
validates_attachment_content_type :avatar, content_type: /\Aimage\/.*\Z/ | |||
validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES | |||
validates_attachment_size :avatar, less_than: 2.megabytes | |||
# Header upload | |||
has_attached_file :header, styles: { medium: '700x335#' } | |||
validates_attachment_content_type :header, content_type: /\Aimage\/.*\Z/ | |||
validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES | |||
validates_attachment_size :header, less_than: 2.megabytes | |||
# Local user profile validations | |||
@@ -35,8 +38,6 @@ class Account < ApplicationRecord | |||
has_many :media_attachments, dependent: :destroy | |||
MENTION_RE = /(?:^|\s|\.|>)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i | |||
def follow!(other_account) | |||
self.active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account) | |||
end | |||
@@ -1,9 +1,12 @@ | |||
class MediaAttachment < ApplicationRecord | |||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'] | |||
VIDEO_MIME_TYPES = ['video/webm'] | |||
belongs_to :account, inverse_of: :media_attachments | |||
belongs_to :status, inverse_of: :media_attachments | |||
has_attached_file :file, styles: { small: '510x680>' } | |||
validates_attachment_content_type :file, content_type: /\Aimage\/.*\z/ | |||
has_attached_file :file, styles: lambda { |f| f.instance.image? ? { small: '510x680>' } : { small: { format: 'webm' } } }, processors: lambda { |f| f.video? ? [:transcoder] : [:thumbnail] } | |||
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES | |||
validates_attachment_size :file, less_than: 4.megabytes | |||
validates :account, presence: true | |||
@@ -15,4 +18,12 @@ class MediaAttachment < ApplicationRecord | |||
def file_remote_url=(url) | |||
self.file = URI.parse(url) | |||
end | |||
def image? | |||
IMAGE_MIME_TYPES.include? file_content_type | |||
end | |||
def video? | |||
VIDEO_MIME_TYPES.include? file_content_type | |||
end | |||
end |
@@ -30,6 +30,7 @@ class FollowService < BaseService | |||
end | |||
FeedManager.instance.trim(:home, into_account.id) | |||
FeedManager.instance.broadcast(into_account.id, type: 'merge') | |||
end | |||
def redis | |||
@@ -16,6 +16,8 @@ class UnfollowService < BaseService | |||
from_account.statuses.find_each do |status| | |||
redis.zrem(timeline_key, status.id) | |||
end | |||
FeedManager.instance.broadcast(into_account.id, type: 'unmerge') | |||
end | |||
def redis | |||
@@ -11,24 +11,48 @@ RSpec.describe Api::MediaController, type: :controller do | |||
end | |||
describe 'POST #create' do | |||
before do | |||
post :create, params: { file: fixture_file_upload('files/attachment.jpg', 'image/jpeg') } | |||
context 'image/jpeg' do | |||
before do | |||
post :create, params: { file: fixture_file_upload('files/attachment.jpg', 'image/jpeg') } | |||
end | |||
it 'returns http success' do | |||
expect(response).to have_http_status(:success) | |||
end | |||
it 'creates a media attachment' do | |||
expect(MediaAttachment.first).to_not be_nil | |||
end | |||
it 'uploads a file' do | |||
expect(MediaAttachment.first).to have_attached_file(:file) | |||
end | |||
it 'returns media ID in JSON' do | |||
expect(body_as_json[:id]).to eq MediaAttachment.first.id | |||
end | |||
end | |||
it 'returns http success' do | |||
expect(response).to have_http_status(:success) | |||
end | |||
context 'video/webm' do | |||
before do | |||
post :create, params: { file: fixture_file_upload('files/attachment.webm', 'video/webm') } | |||
end | |||
it 'creates a media attachment' do | |||
expect(MediaAttachment.first).to_not be_nil | |||
end | |||
xit 'returns http success' do | |||
expect(response).to have_http_status(:success) | |||
end | |||
it 'uploads a file' do | |||
expect(MediaAttachment.first).to have_attached_file(:file) | |||
end | |||
xit 'creates a media attachment' do | |||
expect(MediaAttachment.first).to_not be_nil | |||
end | |||
xit 'uploads a file' do | |||
expect(MediaAttachment.first).to have_attached_file(:file) | |||
end | |||
it 'returns media ID in JSON' do | |||
expect(body_as_json[:id]).to eq MediaAttachment.first.id | |||
xit 'returns media ID in JSON' do | |||
expect(body_as_json[:id]).to eq MediaAttachment.first.id | |||
end | |||
end | |||
end | |||
end |