Merge pull request #945 from ThibG/glitch-soc/merge-upstream

Merge upstream changes
main
ThibG 2019-03-08 01:47:36 +01:00 committed by GitHub
commit 29a09858f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 109 additions and 47 deletions

View File

@ -175,7 +175,9 @@ export function submitCompose(routerHistory) {
// To make the app more responsive, immediately get the status into the columns // To make the app more responsive, immediately get the status into the columns
const insertIfOnline = (timelineId) => { const insertIfOnline = (timelineId) => {
if (getState().getIn(['timelines', timelineId, 'items', 0]) !== null) { const timeline = getState().getIn(['timelines', timelineId]);
if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) {
dispatch(updateTimeline(timelineId, { ...response.data })); dispatch(updateTimeline(timelineId, { ...response.data }));
} }
}; };

View File

@ -3,6 +3,7 @@ import {
updateTimeline, updateTimeline,
deleteFromTimelines, deleteFromTimelines,
expandHomeTimeline, expandHomeTimeline,
connectTimeline,
disconnectTimeline, disconnectTimeline,
} from './timelines'; } from './timelines';
import { updateNotifications, expandNotifications } from './notifications'; import { updateNotifications, expandNotifications } from './notifications';
@ -15,7 +16,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
return connectStream (path, pollingRefresh, (dispatch, getState) => { return connectStream (path, pollingRefresh, (dispatch, getState) => {
const locale = getState().getIn(['meta', 'locale']); const locale = getState().getIn(['meta', 'locale']);
return { return {
onConnect() {
dispatch(connectTimeline(timelineId));
},
onDisconnect() { onDisconnect() {
dispatch(disconnectTimeline(timelineId)); dispatch(disconnectTimeline(timelineId));
}, },

View File

@ -12,6 +12,7 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export function updateTimeline(timeline, status, accept) { export function updateTimeline(timeline, status, accept) {
@ -145,6 +146,13 @@ export function scrollTopTimeline(timeline, top) {
}; };
}; };
export function connectTimeline(timeline) {
return {
type: TIMELINE_CONNECT,
timeline,
};
};
export function disconnectTimeline(timeline) { export function disconnectTimeline(timeline) {
return { return {
type: TIMELINE_DISCONNECT, type: TIMELINE_DISCONNECT,

View File

@ -94,7 +94,7 @@ class Poll extends ImmutablePureComponent {
renderOption (option, optionIndex) { renderOption (option, optionIndex) {
const { poll, disabled } = this.props; const { poll, disabled } = this.props;
const percent = (option.get('votes_count') / poll.get('votes_count')) * 100; const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100;
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count')); const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count'));
const active = !!this.state.selected[`${optionIndex}`]; const active = !!this.state.selected[`${optionIndex}`];
const showResults = poll.get('voted') || poll.get('expired'); const showResults = poll.get('voted') || poll.get('expired');

View File

@ -6,6 +6,7 @@ import {
TIMELINE_EXPAND_REQUEST, TIMELINE_EXPAND_REQUEST,
TIMELINE_EXPAND_FAIL, TIMELINE_EXPAND_FAIL,
TIMELINE_SCROLL_TOP, TIMELINE_SCROLL_TOP,
TIMELINE_CONNECT,
TIMELINE_DISCONNECT, TIMELINE_DISCONNECT,
} from 'flavours/glitch/actions/timelines'; } from 'flavours/glitch/actions/timelines';
import { import {
@ -20,6 +21,7 @@ const initialState = ImmutableMap();
const initialTimeline = ImmutableMap({ const initialTimeline = ImmutableMap({
unread: 0, unread: 0,
online: false,
top: true, top: true,
isLoading: false, isLoading: false,
hasMore: true, hasMore: true,
@ -137,14 +139,13 @@ export default function timelines(state = initialState, action) {
return filterTimeline('home', state, action.relationship, action.statuses); return filterTimeline('home', state, action.relationship, action.statuses);
case TIMELINE_SCROLL_TOP: case TIMELINE_SCROLL_TOP:
return updateTop(state, action.timeline, action.top); return updateTop(state, action.timeline, action.top);
case TIMELINE_CONNECT:
return state.update(action.timeline, initialTimeline, map => map.set('online', true));
case TIMELINE_DISCONNECT: case TIMELINE_DISCONNECT:
return state.update( return state.update(
action.timeline, action.timeline,
initialTimeline, initialTimeline,
map => map.update( map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items)
'items',
items => items.first() ? items.unshift(null) : items
)
); );
default: default:
return state; return state;

View File

@ -2,11 +2,11 @@ import WebSocketClient from 'websocket.js';
const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));
export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) { export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
return (dispatch, getState) => { return (dispatch, getState) => {
const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
const accessToken = getState().getIn(['meta', 'access_token']); const accessToken = getState().getIn(['meta', 'access_token']);
const { onDisconnect, onReceive } = callbacks(dispatch, getState); const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
let polling = null; let polling = null;
@ -28,6 +28,8 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
if (pollingRefresh) { if (pollingRefresh) {
clearPolling(); clearPolling();
} }
onConnect();
}, },
disconnected () { disconnected () {
@ -47,6 +49,8 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
clearPolling(); clearPolling();
pollingRefresh(dispatch); pollingRefresh(dispatch);
} }
onConnect();
}, },
}); });

View File

@ -158,7 +158,9 @@ export function submitCompose(routerHistory) {
// into the columns // into the columns
const insertIfOnline = timelineId => { const insertIfOnline = timelineId => {
if (getState().getIn(['timelines', timelineId, 'items', 0]) !== null) { const timeline = getState().getIn(['timelines', timelineId]);
if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) {
dispatch(updateTimeline(timelineId, { ...response.data })); dispatch(updateTimeline(timelineId, { ...response.data }));
} }
}; };

View File

@ -3,6 +3,7 @@ import {
updateTimeline, updateTimeline,
deleteFromTimelines, deleteFromTimelines,
expandHomeTimeline, expandHomeTimeline,
connectTimeline,
disconnectTimeline, disconnectTimeline,
} from './timelines'; } from './timelines';
import { updateNotifications, expandNotifications } from './notifications'; import { updateNotifications, expandNotifications } from './notifications';
@ -16,7 +17,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
return connectStream (path, pollingRefresh, (dispatch, getState) => { return connectStream (path, pollingRefresh, (dispatch, getState) => {
const locale = getState().getIn(['meta', 'locale']); const locale = getState().getIn(['meta', 'locale']);
return { return {
onConnect() {
dispatch(connectTimeline(timelineId));
},
onDisconnect() { onDisconnect() {
dispatch(disconnectTimeline(timelineId)); dispatch(disconnectTimeline(timelineId));
}, },

View File

@ -12,6 +12,7 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export function updateTimeline(timeline, status, accept) { export function updateTimeline(timeline, status, accept) {
@ -143,6 +144,13 @@ export function scrollTopTimeline(timeline, top) {
}; };
}; };
export function connectTimeline(timeline) {
return {
type: TIMELINE_CONNECT,
timeline,
};
};
export function disconnectTimeline(timeline) { export function disconnectTimeline(timeline) {
return { return {
type: TIMELINE_DISCONNECT, type: TIMELINE_DISCONNECT,

View File

@ -94,7 +94,7 @@ class Poll extends ImmutablePureComponent {
renderOption (option, optionIndex) { renderOption (option, optionIndex) {
const { poll, disabled } = this.props; const { poll, disabled } = this.props;
const percent = (option.get('votes_count') / poll.get('votes_count')) * 100; const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100;
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count')); const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count'));
const active = !!this.state.selected[`${optionIndex}`]; const active = !!this.state.selected[`${optionIndex}`];
const showResults = poll.get('voted') || poll.get('expired'); const showResults = poll.get('voted') || poll.get('expired');

View File

@ -6,6 +6,7 @@ import {
TIMELINE_EXPAND_REQUEST, TIMELINE_EXPAND_REQUEST,
TIMELINE_EXPAND_FAIL, TIMELINE_EXPAND_FAIL,
TIMELINE_SCROLL_TOP, TIMELINE_SCROLL_TOP,
TIMELINE_CONNECT,
TIMELINE_DISCONNECT, TIMELINE_DISCONNECT,
} from '../actions/timelines'; } from '../actions/timelines';
import { import {
@ -20,6 +21,7 @@ const initialState = ImmutableMap();
const initialTimeline = ImmutableMap({ const initialTimeline = ImmutableMap({
unread: 0, unread: 0,
online: false,
top: true, top: true,
isLoading: false, isLoading: false,
hasMore: true, hasMore: true,
@ -142,14 +144,13 @@ export default function timelines(state = initialState, action) {
return filterTimeline('home', state, action.relationship, action.statuses); return filterTimeline('home', state, action.relationship, action.statuses);
case TIMELINE_SCROLL_TOP: case TIMELINE_SCROLL_TOP:
return updateTop(state, action.timeline, action.top); return updateTop(state, action.timeline, action.top);
case TIMELINE_CONNECT:
return state.update(action.timeline, initialTimeline, map => map.set('online', true));
case TIMELINE_DISCONNECT: case TIMELINE_DISCONNECT:
return state.update( return state.update(
action.timeline, action.timeline,
initialTimeline, initialTimeline,
map => map.update( map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items)
'items',
items => items.first() ? items.unshift(null) : items
)
); );
default: default:
return state; return state;

View File

@ -2,11 +2,11 @@ import WebSocketClient from 'websocket.js';
const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));
export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) { export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
return (dispatch, getState) => { return (dispatch, getState) => {
const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
const accessToken = getState().getIn(['meta', 'access_token']); const accessToken = getState().getIn(['meta', 'access_token']);
const { onDisconnect, onReceive } = callbacks(dispatch, getState); const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
let polling = null; let polling = null;
@ -28,6 +28,8 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
if (pollingRefresh) { if (pollingRefresh) {
clearPolling(); clearPolling();
} }
onConnect();
}, },
disconnected () { disconnected () {
@ -47,6 +49,8 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
clearPolling(); clearPolling();
pollingRefresh(dispatch); pollingRefresh(dispatch);
} }
onConnect();
}, },
}); });

View File

@ -241,6 +241,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def poll_vote? def poll_vote?
return false if replied_to_status.nil? || replied_to_status.poll.nil? || !replied_to_status.local? || !replied_to_status.poll.options.include?(@object['name']) return false if replied_to_status.nil? || replied_to_status.poll.nil? || !replied_to_status.local? || !replied_to_status.poll.options.include?(@object['name'])
return true if replied_to_status.poll.expired?
replied_to_status.poll.votes.create!(account: @account, choice: replied_to_status.poll.options.index(@object['name']), uri: @object['id']) replied_to_status.poll.votes.create!(account: @account, choice: replied_to_status.poll.options.index(@object['name']), uri: @object['id'])
end end

View File

@ -28,7 +28,7 @@ class Poll < ApplicationRecord
validates :options, presence: true validates :options, presence: true
validates :expires_at, presence: true, if: :local? validates :expires_at, presence: true, if: :local?
validates_with PollValidator, if: :local? validates_with PollValidator, on: :create, if: :local?
scope :attached, -> { where.not(status_id: nil) } scope :attached, -> { where.not(status_id: nil) }
scope :unattached, -> { where(status_id: nil) } scope :unattached, -> { where(status_id: nil) }
@ -41,17 +41,17 @@ class Poll < ApplicationRecord
after_commit :reset_parent_cache, on: :update after_commit :reset_parent_cache, on: :update
def loaded_options def loaded_options
options.map.with_index { |title, key| Option.new(self, key.to_s, title, cached_tallies[key]) } options.map.with_index { |title, key| Option.new(self, key.to_s, title, show_totals_now? ? cached_tallies[key] : nil) }
end
def unloaded_options
options.map.with_index { |title, key| Option.new(self, key.to_s, title, nil) }
end end
def possibly_stale? def possibly_stale?
remote? && last_fetched_before_expiration? && time_passed_since_last_fetch? remote? && last_fetched_before_expiration? && time_passed_since_last_fetch?
end end
def voted?(account)
account.id == account_id || votes.where(account: account).exists?
end
delegate :local?, to: :account delegate :local?, to: :account
def remote? def remote?
@ -95,4 +95,8 @@ class Poll < ApplicationRecord
def time_passed_since_last_fetch? def time_passed_since_last_fetch?
last_fetched_at.nil? || last_fetched_at < 1.minute.ago last_fetched_at.nil? || last_fetched_at < 1.minute.ago
end end
def show_totals_now?
expired? || !hide_totals?
end
end end

View File

@ -122,12 +122,8 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
end end
def poll_options def poll_options
if !object.poll.expired? && object.poll.hide_totals?
object.poll.unloaded_options
else
object.poll.loaded_options object.poll.loaded_options
end end
end
def poll_and_multiple? def poll_and_multiple?
object.poll&.multiple? object.poll&.multiple?

View File

@ -4,7 +4,7 @@ class REST::PollSerializer < ActiveModel::Serializer
attributes :id, :expires_at, :expired, attributes :id, :expires_at, :expired,
:multiple, :votes_count :multiple, :votes_count
has_many :dynamic_options, key: :options has_many :loaded_options, key: :options
attribute :voted, if: :current_user? attribute :voted, if: :current_user?
@ -12,20 +12,12 @@ class REST::PollSerializer < ActiveModel::Serializer
object.id.to_s object.id.to_s
end end
def dynamic_options
if !object.expired? && object.hide_totals?
object.unloaded_options
else
object.loaded_options
end
end
def expired def expired
object.expired? object.expired?
end end
def voted def voted
object.votes.where(account: current_user.account).exists? object.voted?(current_user.account)
end end
def current_user? def current_user?

View File

@ -32,12 +32,17 @@ class ActivityPub::FetchRemotePollService < BaseService
# votes, so we need to remove them # votes, so we need to remove them
poll.votes.delete_all if latest_options != poll.options poll.votes.delete_all if latest_options != poll.options
begin
poll.update!( poll.update!(
last_fetched_at: Time.now.utc, last_fetched_at: Time.now.utc,
expires_at: expires_at, expires_at: expires_at,
options: latest_options, options: latest_options,
cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 } cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }
) )
rescue ActiveRecord::StaleObjectError
poll.reload
retry
end
end end
private private

View File

@ -11,6 +11,8 @@ class VoteService < BaseService
@choices = choices @choices = choices
@votes = [] @votes = []
return if @poll.expired?
ApplicationRecord.transaction do ApplicationRecord.transaction do
@choices.each do |choice| @choices.each do |choice|
@votes << @poll.votes.create!(account: @account, choice: choice) @votes << @poll.votes.create!(account: @account, choice: choice)

View File

@ -1,10 +1,8 @@
- options = (!poll.expired? && poll.hide_totals?) ? poll.unloaded_options : poll.loaded_options - show_results = (user_signed_in? && poll.voted?(current_account)) || poll.expired?
- voted = user_signed_in? && poll.votes.where(account: current_account).exists?
- show_results = voted || poll.expired?
.poll .poll
%ul %ul
- options.each do |option| - poll.loaded_options.each do |option|
%li %li
- if show_results - if show_results
- percent = poll.votes_count > 0 ? 100 * option.votes_count / poll.votes_count : 0 - percent = poll.votes_count > 0 ? 100 * option.votes_count / poll.votes_count : 0

View File

@ -482,6 +482,28 @@ RSpec.describe ActivityPub::Activity::Create do
expect(poll.reload.cached_tallies).to eq [1, 0] expect(poll.reload.cached_tallies).to eq [1, 0]
end end
end end
context 'when a vote to an expired local poll' do
let(:poll) do
poll = Fabricate.build(:poll, options: %w(Yellow Blue), expires_at: 1.day.ago)
poll.save(validate: false)
poll
end
let!(:local_status) { Fabricate(:status, owned_poll: poll) }
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
name: 'Yellow',
inReplyTo: ActivityPub::TagManager.instance.uri_for(local_status)
}
end
it 'does not add a vote to the poll' do
expect(poll.votes.first).to be_nil
end
end
end end
context 'when sender is followed by local users' do context 'when sender is followed by local users' do