about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js4
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js6
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js8
-rw-r--r--app/javascript/flavours/glitch/components/poll.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/timelines.js9
-rw-r--r--app/javascript/flavours/glitch/util/stream.js8
-rw-r--r--app/javascript/mastodon/actions/compose.js4
-rw-r--r--app/javascript/mastodon/actions/streaming.js6
-rw-r--r--app/javascript/mastodon/actions/timelines.js8
-rw-r--r--app/javascript/mastodon/components/poll.js2
-rw-r--r--app/javascript/mastodon/reducers/timelines.js9
-rw-r--r--app/javascript/mastodon/stream.js8
-rw-r--r--app/lib/activitypub/activity/create.rb1
-rw-r--r--app/models/poll.rb16
-rw-r--r--app/serializers/activitypub/note_serializer.rb6
-rw-r--r--app/serializers/rest/poll_serializer.rb12
-rw-r--r--app/services/activitypub/fetch_remote_poll_service.rb17
-rw-r--r--app/services/vote_service.rb2
-rw-r--r--app/views/stream_entries/_poll.html.haml6
-rw-r--r--spec/lib/activitypub/activity/create_spec.rb22
20 files changed, 109 insertions, 47 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 7fa35351f..ac09adceb 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -175,7 +175,9 @@ export function submitCompose(routerHistory) {
       // To make the app more responsive, immediately get the status into the columns
 
       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 }));
         }
       };
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
index 8c1bd1f08..b5dd70989 100644
--- a/app/javascript/flavours/glitch/actions/streaming.js
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -3,6 +3,7 @@ import {
   updateTimeline,
   deleteFromTimelines,
   expandHomeTimeline,
+  connectTimeline,
   disconnectTimeline,
 } from './timelines';
 import { updateNotifications, expandNotifications } from './notifications';
@@ -15,7 +16,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
 
   return connectStream (path, pollingRefresh, (dispatch, getState) => {
     const locale = getState().getIn(['meta', 'locale']);
+
     return {
+      onConnect() {
+        dispatch(connectTimeline(timelineId));
+      },
+
       onDisconnect() {
         dispatch(disconnectTimeline(timelineId));
       },
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
index c6866f81f..f218ee06b 100644
--- a/app/javascript/flavours/glitch/actions/timelines.js
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -12,6 +12,7 @@ export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
 
 export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
+export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT';
 export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
 
 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) {
   return {
     type: TIMELINE_DISCONNECT,
diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js
index bfff7b601..a1b297ce7 100644
--- a/app/javascript/flavours/glitch/components/poll.js
+++ b/app/javascript/flavours/glitch/components/poll.js
@@ -94,7 +94,7 @@ class Poll extends ImmutablePureComponent {
 
   renderOption (option, optionIndex) {
     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 active             = !!this.state.selected[`${optionIndex}`];
     const showResults        = poll.get('voted') || poll.get('expired');
diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js
index 4179cf477..ca71c3833 100644
--- a/app/javascript/flavours/glitch/reducers/timelines.js
+++ b/app/javascript/flavours/glitch/reducers/timelines.js
@@ -6,6 +6,7 @@ import {
   TIMELINE_EXPAND_REQUEST,
   TIMELINE_EXPAND_FAIL,
   TIMELINE_SCROLL_TOP,
+  TIMELINE_CONNECT,
   TIMELINE_DISCONNECT,
 } from 'flavours/glitch/actions/timelines';
 import {
@@ -20,6 +21,7 @@ const initialState = ImmutableMap();
 
 const initialTimeline = ImmutableMap({
   unread: 0,
+  online: false,
   top: true,
   isLoading: false,
   hasMore: true,
@@ -137,14 +139,13 @@ export default function timelines(state = initialState, action) {
     return filterTimeline('home', state, action.relationship, action.statuses);
   case TIMELINE_SCROLL_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:
     return state.update(
       action.timeline,
       initialTimeline,
-      map => map.update(
-        'items',
-        items => items.first() ? items.unshift(null) : items
-      )
+      map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items)
     );
   default:
     return state;
diff --git a/app/javascript/flavours/glitch/util/stream.js b/app/javascript/flavours/glitch/util/stream.js
index 9928d0dd7..306a068b7 100644
--- a/app/javascript/flavours/glitch/util/stream.js
+++ b/app/javascript/flavours/glitch/util/stream.js
@@ -2,11 +2,11 @@ import WebSocketClient from 'websocket.js';
 
 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) => {
     const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
     const accessToken = getState().getIn(['meta', 'access_token']);
-    const { onDisconnect, onReceive } = callbacks(dispatch, getState);
+    const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
 
     let polling = null;
 
@@ -28,6 +28,8 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
         if (pollingRefresh) {
           clearPolling();
         }
+
+        onConnect();
       },
 
       disconnected () {
@@ -47,6 +49,8 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
           clearPolling();
           pollingRefresh(dispatch);
         }
+
+        onConnect();
       },
 
     });
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index da7bd3cb0..d65d41048 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -158,7 +158,9 @@ export function submitCompose(routerHistory) {
       // into the columns
 
       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 }));
         }
       };
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index cd319709d..c678e9393 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -3,6 +3,7 @@ import {
   updateTimeline,
   deleteFromTimelines,
   expandHomeTimeline,
+  connectTimeline,
   disconnectTimeline,
 } from './timelines';
 import { updateNotifications, expandNotifications } from './notifications';
@@ -16,7 +17,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
 
   return connectStream (path, pollingRefresh, (dispatch, getState) => {
     const locale = getState().getIn(['meta', 'locale']);
+
     return {
+      onConnect() {
+        dispatch(connectTimeline(timelineId));
+      },
+
       onDisconnect() {
         dispatch(disconnectTimeline(timelineId));
       },
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index 6e7bd027c..d92385e95 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -12,6 +12,7 @@ export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
 
 export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
+export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT';
 export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
 
 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) {
   return {
     type: TIMELINE_DISCONNECT,
diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js
index bfff7b601..a1b297ce7 100644
--- a/app/javascript/mastodon/components/poll.js
+++ b/app/javascript/mastodon/components/poll.js
@@ -94,7 +94,7 @@ class Poll extends ImmutablePureComponent {
 
   renderOption (option, optionIndex) {
     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 active             = !!this.state.selected[`${optionIndex}`];
     const showResults        = poll.get('voted') || poll.get('expired');
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index 38af9cd09..94b570ecd 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -6,6 +6,7 @@ import {
   TIMELINE_EXPAND_REQUEST,
   TIMELINE_EXPAND_FAIL,
   TIMELINE_SCROLL_TOP,
+  TIMELINE_CONNECT,
   TIMELINE_DISCONNECT,
 } from '../actions/timelines';
 import {
@@ -20,6 +21,7 @@ const initialState = ImmutableMap();
 
 const initialTimeline = ImmutableMap({
   unread: 0,
+  online: false,
   top: true,
   isLoading: false,
   hasMore: true,
@@ -142,14 +144,13 @@ export default function timelines(state = initialState, action) {
     return filterTimeline('home', state, action.relationship, action.statuses);
   case TIMELINE_SCROLL_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:
     return state.update(
       action.timeline,
       initialTimeline,
-      map => map.update(
-        'items',
-        items => items.first() ? items.unshift(null) : items
-      )
+      map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items)
     );
   default:
     return state;
diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js
index 9928d0dd7..306a068b7 100644
--- a/app/javascript/mastodon/stream.js
+++ b/app/javascript/mastodon/stream.js
@@ -2,11 +2,11 @@ import WebSocketClient from 'websocket.js';
 
 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) => {
     const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
     const accessToken = getState().getIn(['meta', 'access_token']);
-    const { onDisconnect, onReceive } = callbacks(dispatch, getState);
+    const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
 
     let polling = null;
 
@@ -28,6 +28,8 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
         if (pollingRefresh) {
           clearPolling();
         }
+
+        onConnect();
       },
 
       disconnected () {
@@ -47,6 +49,8 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
           clearPolling();
           pollingRefresh(dispatch);
         }
+
+        onConnect();
       },
 
     });
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 87179030c..7e4e57ead 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -241,6 +241,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
   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 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'])
   end
 
diff --git a/app/models/poll.rb b/app/models/poll.rb
index da2e25e71..09f0b65ec 100644
--- a/app/models/poll.rb
+++ b/app/models/poll.rb
@@ -28,7 +28,7 @@ class Poll < ApplicationRecord
 
   validates :options, presence: true
   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 :unattached, -> { where(status_id: nil) }
@@ -41,17 +41,17 @@ class Poll < ApplicationRecord
   after_commit :reset_parent_cache, on: :update
 
   def loaded_options
-    options.map.with_index { |title, key| Option.new(self, key.to_s, title, cached_tallies[key]) }
-  end
-
-  def unloaded_options
-    options.map.with_index { |title, key| Option.new(self, key.to_s, title, nil) }
+    options.map.with_index { |title, key| Option.new(self, key.to_s, title, show_totals_now? ? cached_tallies[key] : nil) }
   end
 
   def possibly_stale?
     remote? && last_fetched_before_expiration? && time_passed_since_last_fetch?
   end
 
+  def voted?(account)
+    account.id == account_id || votes.where(account: account).exists?
+  end
+
   delegate :local?, to: :account
 
   def remote?
@@ -95,4 +95,8 @@ class Poll < ApplicationRecord
   def time_passed_since_last_fetch?
     last_fetched_at.nil? || last_fetched_at < 1.minute.ago
   end
+
+  def show_totals_now?
+    expired? || !hide_totals?
+  end
 end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 3a9e388a5..553f333d8 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -122,11 +122,7 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
   end
 
   def poll_options
-    if !object.poll.expired? && object.poll.hide_totals?
-      object.poll.unloaded_options
-    else
-      object.poll.loaded_options
-    end
+    object.poll.loaded_options
   end
 
   def poll_and_multiple?
diff --git a/app/serializers/rest/poll_serializer.rb b/app/serializers/rest/poll_serializer.rb
index b02e8ca93..4dae1c09f 100644
--- a/app/serializers/rest/poll_serializer.rb
+++ b/app/serializers/rest/poll_serializer.rb
@@ -4,7 +4,7 @@ class REST::PollSerializer < ActiveModel::Serializer
   attributes :id, :expires_at, :expired,
              :multiple, :votes_count
 
-  has_many :dynamic_options, key: :options
+  has_many :loaded_options, key: :options
 
   attribute :voted, if: :current_user?
 
@@ -12,20 +12,12 @@ class REST::PollSerializer < ActiveModel::Serializer
     object.id.to_s
   end
 
-  def dynamic_options
-    if !object.expired? && object.hide_totals?
-      object.unloaded_options
-    else
-      object.loaded_options
-    end
-  end
-
   def expired
     object.expired?
   end
 
   def voted
-    object.votes.where(account: current_user.account).exists?
+    object.voted?(current_user.account)
   end
 
   def current_user?
diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb
index 1dd587d73..4f9814fcd 100644
--- a/app/services/activitypub/fetch_remote_poll_service.rb
+++ b/app/services/activitypub/fetch_remote_poll_service.rb
@@ -32,12 +32,17 @@ class ActivityPub::FetchRemotePollService < BaseService
     # votes, so we need to remove them
     poll.votes.delete_all if latest_options != poll.options
 
-    poll.update!(
-      last_fetched_at: Time.now.utc,
-      expires_at: expires_at,
-      options: latest_options,
-      cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }
-    )
+    begin
+      poll.update!(
+        last_fetched_at: Time.now.utc,
+        expires_at: expires_at,
+        options: latest_options,
+        cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }
+      )
+    rescue ActiveRecord::StaleObjectError
+      poll.reload
+      retry
+    end
   end
 
   private
diff --git a/app/services/vote_service.rb b/app/services/vote_service.rb
index 8bab2810e..5b80da03a 100644
--- a/app/services/vote_service.rb
+++ b/app/services/vote_service.rb
@@ -11,6 +11,8 @@ class VoteService < BaseService
     @choices = choices
     @votes   = []
 
+    return if @poll.expired?
+
     ApplicationRecord.transaction do
       @choices.each do |choice|
         @votes << @poll.votes.create!(account: @account, choice: choice)
diff --git a/app/views/stream_entries/_poll.html.haml b/app/views/stream_entries/_poll.html.haml
index dad04b79c..d6b2c0cd9 100644
--- a/app/views/stream_entries/_poll.html.haml
+++ b/app/views/stream_entries/_poll.html.haml
@@ -1,10 +1,8 @@
-- options      = (!poll.expired? && poll.hide_totals?) ? poll.unloaded_options : poll.loaded_options
-- voted        = user_signed_in? && poll.votes.where(account: current_account).exists?
-- show_results = voted || poll.expired?
+- show_results = (user_signed_in? && poll.voted?(current_account)) || poll.expired?
 
 .poll
   %ul
-    - options.each do |option|
+    - poll.loaded_options.each do |option|
       %li
         - if show_results
           - percent = poll.votes_count > 0 ? 100 * option.votes_count / poll.votes_count : 0
diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb
index 56c7bfc61..3a1463d95 100644
--- a/spec/lib/activitypub/activity/create_spec.rb
+++ b/spec/lib/activitypub/activity/create_spec.rb
@@ -482,6 +482,28 @@ RSpec.describe ActivityPub::Activity::Create do
           expect(poll.reload.cached_tallies).to eq [1, 0]
         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
 
     context 'when sender is followed by local users' do