about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2018-10-19 01:47:29 +0200
committerGitHub <noreply@github.com>2018-10-19 01:47:29 +0200
commita38a452481d0f5207bb27ba7a2707c0028d2ac18 (patch)
tree5dfe4cab0fd6ebe15c924bd83e3abb6efca210db
parentbebe8ec887ba67c51353e09d7758819b117bf62d (diff)
Add unread indicator to conversations (#9009)
-rw-r--r--app/controllers/api/v1/conversations_controller.rb20
-rw-r--r--app/controllers/api/v1/reports_controller.rb1
-rw-r--r--app/javascript/mastodon/actions/conversations.js11
-rw-r--r--app/javascript/mastodon/features/direct_timeline/components/conversation.js14
-rw-r--r--app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js8
-rw-r--r--app/javascript/mastodon/reducers/conversations.js10
-rw-r--r--app/javascript/styles/mastodon/components.scss5
-rw-r--r--app/models/account_conversation.rb2
-rw-r--r--app/serializers/rest/conversation_serializer.rb3
-rw-r--r--config/initializers/doorkeeper.rb2
-rw-r--r--config/routes.rb7
-rw-r--r--db/migrate/20181018205649_add_unread_to_account_conversations.rb23
-rw-r--r--db/schema.rb3
13 files changed, 98 insertions, 11 deletions
diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb
index 736cb21ca..b19f27ebf 100644
--- a/app/controllers/api/v1/conversations_controller.rb
+++ b/app/controllers/api/v1/conversations_controller.rb
@@ -3,9 +3,11 @@
 class Api::V1::ConversationsController < Api::BaseController
   LIMIT = 20
 
-  before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
+  before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index
+  before_action -> { doorkeeper_authorize! :write, :'write:conversations' }, except: :index
   before_action :require_user!
-  after_action :insert_pagination_headers
+  before_action :set_conversation, except: :index
+  after_action :insert_pagination_headers, only: :index
 
   respond_to :json
 
@@ -14,8 +16,22 @@ class Api::V1::ConversationsController < Api::BaseController
     render json: @conversations, each_serializer: REST::ConversationSerializer
   end
 
+  def read
+    @conversation.update!(unread: false)
+    render json: @conversation, serializer: REST::ConversationSerializer
+  end
+
+  def destroy
+    @conversation.destroy!
+    render_empty
+  end
+
   private
 
+  def set_conversation
+    @conversation = AccountConversation.where(account: current_account).find(params[:id])
+  end
+
   def paginated_conversations
     AccountConversation.where(account: current_account)
                        .paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb
index 726817927..e182a9c6c 100644
--- a/app/controllers/api/v1/reports_controller.rb
+++ b/app/controllers/api/v1/reports_controller.rb
@@ -1,7 +1,6 @@
 # frozen_string_literal: true
 
 class Api::V1::ReportsController < Api::BaseController
-  before_action -> { doorkeeper_authorize! :read, :'read:reports' }, except: [:create]
   before_action -> { doorkeeper_authorize! :write, :'write:reports' }, only: [:create]
   before_action :require_user!
 
diff --git a/app/javascript/mastodon/actions/conversations.js b/app/javascript/mastodon/actions/conversations.js
index cab05c1ba..aefd2fef7 100644
--- a/app/javascript/mastodon/actions/conversations.js
+++ b/app/javascript/mastodon/actions/conversations.js
@@ -13,6 +13,8 @@ export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS';
 export const CONVERSATIONS_FETCH_FAIL    = 'CONVERSATIONS_FETCH_FAIL';
 export const CONVERSATIONS_UPDATE        = 'CONVERSATIONS_UPDATE';
 
+export const CONVERSATIONS_READ = 'CONVERSATIONS_READ';
+
 export const mountConversations = () => ({
   type: CONVERSATIONS_MOUNT,
 });
@@ -21,6 +23,15 @@ export const unmountConversations = () => ({
   type: CONVERSATIONS_UNMOUNT,
 });
 
+export const markConversationRead = conversationId => (dispatch, getState) => {
+  dispatch({
+    type: CONVERSATIONS_READ,
+    id: conversationId,
+  });
+
+  api(getState).post(`/api/v1/conversations/${conversationId}/read`);
+};
+
 export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => {
   dispatch(expandConversationsRequest());
 
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.js b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
index f9a8d4f72..52e33c3c8 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversation.js
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
@@ -8,6 +8,7 @@ import DisplayName from '../../../components/display_name';
 import Avatar from '../../../components/avatar';
 import AttachmentList from '../../../components/attachment_list';
 import { HotKeys } from 'react-hotkeys';
+import classNames from 'classnames';
 
 export default class Conversation extends ImmutablePureComponent {
 
@@ -19,8 +20,10 @@ export default class Conversation extends ImmutablePureComponent {
     conversationId: PropTypes.string.isRequired,
     accounts: ImmutablePropTypes.list.isRequired,
     lastStatus: ImmutablePropTypes.map.isRequired,
+    unread:PropTypes.bool.isRequired,
     onMoveUp: PropTypes.func,
     onMoveDown: PropTypes.func,
+    markRead: PropTypes.func.isRequired,
   };
 
   handleClick = () => {
@@ -28,7 +31,12 @@ export default class Conversation extends ImmutablePureComponent {
       return;
     }
 
-    const { lastStatus } = this.props;
+    const { lastStatus, unread, markRead } = this.props;
+
+    if (unread) {
+      markRead();
+    }
+
     this.context.router.history.push(`/statuses/${lastStatus.get('id')}`);
   }
 
@@ -41,7 +49,7 @@ export default class Conversation extends ImmutablePureComponent {
   }
 
   render () {
-    const { accounts, lastStatus, lastAccount } = this.props;
+    const { accounts, lastStatus, lastAccount, unread } = this.props;
 
     if (lastStatus === null) {
       return null;
@@ -61,7 +69,7 @@ export default class Conversation extends ImmutablePureComponent {
 
     return (
       <HotKeys handlers={handlers}>
-        <div className='conversation focusable' tabIndex='0' onClick={this.handleClick} role='button'>
+        <div className={classNames('conversation', 'focusable', { 'conversation--unread': unread })} tabIndex='0' onClick={this.handleClick} role='button'>
           <div className='conversation__header'>
             <div className='conversation__avatars'>
               <div>{accounts.map(account => <Avatar key={account.get('id')} size={36} account={account} />)}</div>
diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
index 4166ee2ac..e2e2e3afb 100644
--- a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
+++ b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
@@ -1,5 +1,6 @@
 import { connect } from 'react-redux';
 import Conversation from '../components/conversation';
+import { markConversationRead } from '../../../actions/conversations';
 
 const mapStateToProps = (state, { conversationId }) => {
   const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
@@ -7,9 +8,14 @@ const mapStateToProps = (state, { conversationId }) => {
 
   return {
     accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
+    unread: conversation.get('unread'),
     lastStatus,
     lastAccount: lastStatus === null ? null : state.getIn(['accounts', lastStatus.get('account')], null),
   };
 };
 
-export default connect(mapStateToProps)(Conversation);
+const mapDispatchToProps = (dispatch, { conversationId }) => ({
+  markRead: () => dispatch(markConversationRead(conversationId)),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Conversation);
diff --git a/app/javascript/mastodon/reducers/conversations.js b/app/javascript/mastodon/reducers/conversations.js
index 6b3f22d25..ea39fccee 100644
--- a/app/javascript/mastodon/reducers/conversations.js
+++ b/app/javascript/mastodon/reducers/conversations.js
@@ -6,6 +6,7 @@ import {
   CONVERSATIONS_FETCH_SUCCESS,
   CONVERSATIONS_FETCH_FAIL,
   CONVERSATIONS_UPDATE,
+  CONVERSATIONS_READ,
 } from '../actions/conversations';
 import compareId from '../compare_id';
 
@@ -18,6 +19,7 @@ const initialState = ImmutableMap({
 
 const conversationToMap = item => ImmutableMap({
   id: item.id,
+  unread: item.unread,
   accounts: ImmutableList(item.accounts.map(a => a.id)),
   last_status: item.last_status.id,
 });
@@ -80,6 +82,14 @@ export default function conversations(state = initialState, action) {
     return state.update('mounted', count => count + 1);
   case CONVERSATIONS_UNMOUNT:
     return state.update('mounted', count => count - 1);
+  case CONVERSATIONS_READ:
+    return state.update('items', list => list.map(item => {
+      if (item.get('id') === action.id) {
+        return item.set('unread', false);
+      }
+
+      return item;
+    }));
   default:
     return state;
   }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 129bde856..24b614a37 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -5503,6 +5503,11 @@ noscript {
   border-bottom: 1px solid lighten($ui-base-color, 8%);
   cursor: pointer;
 
+  &--unread {
+    background: lighten($ui-base-color, 8%);
+    border-bottom-color: lighten($ui-base-color, 12%);
+  }
+
   &__header {
     display: flex;
     margin-bottom: 15px;
diff --git a/app/models/account_conversation.rb b/app/models/account_conversation.rb
index c12c8d233..b7447d805 100644
--- a/app/models/account_conversation.rb
+++ b/app/models/account_conversation.rb
@@ -10,6 +10,7 @@
 #  status_ids              :bigint(8)        default([]), not null, is an Array
 #  last_status_id          :bigint(8)
 #  lock_version            :integer          default(0), not null
+#  unread                  :boolean          default(FALSE), not null
 #
 
 class AccountConversation < ApplicationRecord
@@ -58,6 +59,7 @@ class AccountConversation < ApplicationRecord
     def add_status(recipient, status)
       conversation = find_or_initialize_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(recipient, status))
       conversation.status_ids << status.id
+      conversation.unread = status.account_id != recipient.id
       conversation.save
       conversation
     rescue ActiveRecord::StaleObjectError
diff --git a/app/serializers/rest/conversation_serializer.rb b/app/serializers/rest/conversation_serializer.rb
index 884253f89..b09ca6341 100644
--- a/app/serializers/rest/conversation_serializer.rb
+++ b/app/serializers/rest/conversation_serializer.rb
@@ -1,7 +1,8 @@
 # frozen_string_literal: true
 
 class REST::ConversationSerializer < ActiveModel::Serializer
-  attribute :id
+  attributes :id, :unread
+
   has_many :participant_accounts, key: :accounts, serializer: REST::AccountSerializer
   has_one :last_status, serializer: REST::StatusSerializer
 
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
index fe2490b32..367eead6a 100644
--- a/config/initializers/doorkeeper.rb
+++ b/config/initializers/doorkeeper.rb
@@ -58,6 +58,7 @@ Doorkeeper.configure do
   optional_scopes :write,
                   :'write:accounts',
                   :'write:blocks',
+                  :'write:conversations',
                   :'write:favourites',
                   :'write:filters',
                   :'write:follows',
@@ -76,7 +77,6 @@ Doorkeeper.configure do
                   :'read:lists',
                   :'read:mutes',
                   :'read:notifications',
-                  :'read:reports',
                   :'read:search',
                   :'read:statuses',
                   :follow,
diff --git a/config/routes.rb b/config/routes.rb
index a2468c9bd..b203e1329 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -261,7 +261,12 @@ Rails.application.routes.draw do
       resources :streaming, only: [:index]
       resources :custom_emojis, only: [:index]
       resources :suggestions, only: [:index, :destroy]
-      resources :conversations, only: [:index]
+
+      resources :conversations, only: [:index, :destroy] do
+        member do
+          post :read
+        end
+      end
 
       get '/search', to: 'search#index', as: :search
 
diff --git a/db/migrate/20181018205649_add_unread_to_account_conversations.rb b/db/migrate/20181018205649_add_unread_to_account_conversations.rb
new file mode 100644
index 000000000..3c28b9a64
--- /dev/null
+++ b/db/migrate/20181018205649_add_unread_to_account_conversations.rb
@@ -0,0 +1,23 @@
+require Rails.root.join('lib', 'mastodon', 'migration_helpers')
+
+class AddUnreadToAccountConversations < ActiveRecord::Migration[5.2]
+  include Mastodon::MigrationHelpers
+
+  disable_ddl_transaction!
+
+  def up
+    safety_assured do
+      add_column_with_default(
+        :account_conversations,
+        :unread,
+        :boolean,
+        allow_null: false,
+        default: false
+      )
+    end
+  end
+
+  def down
+    remove_column :account_conversations, :unread, :boolean
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index f79f26f16..046975ac9 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2018_10_10_141500) do
+ActiveRecord::Schema.define(version: 2018_10_18_205649) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -22,6 +22,7 @@ ActiveRecord::Schema.define(version: 2018_10_10_141500) do
     t.bigint "status_ids", default: [], null: false, array: true
     t.bigint "last_status_id"
     t.integer "lock_version", default: 0, null: false
+    t.boolean "unread", default: false, null: false
     t.index ["account_id", "conversation_id", "participant_account_ids"], name: "index_unique_conversations", unique: true
     t.index ["account_id"], name: "index_account_conversations_on_account_id"
     t.index ["conversation_id"], name: "index_account_conversations_on_conversation_id"