Add unread indicator to conversations (#9009)
This commit is contained in:
		
							parent
							
								
									bebe8ec887
								
							
						
					
					
						commit
						a38a452481
					
				
					 13 changed files with 98 additions and 11 deletions
				
			
		|  | @ -3,9 +3,11 @@ | ||||||
| class Api::V1::ConversationsController < Api::BaseController | class Api::V1::ConversationsController < Api::BaseController | ||||||
|   LIMIT = 20 |   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! |   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 |   respond_to :json | ||||||
| 
 | 
 | ||||||
|  | @ -14,8 +16,22 @@ class Api::V1::ConversationsController < Api::BaseController | ||||||
|     render json: @conversations, each_serializer: REST::ConversationSerializer |     render json: @conversations, each_serializer: REST::ConversationSerializer | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def read | ||||||
|  |     @conversation.update!(unread: false) | ||||||
|  |     render json: @conversation, serializer: REST::ConversationSerializer | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def destroy | ||||||
|  |     @conversation.destroy! | ||||||
|  |     render_empty | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|  |   def set_conversation | ||||||
|  |     @conversation = AccountConversation.where(account: current_account).find(params[:id]) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def paginated_conversations |   def paginated_conversations | ||||||
|     AccountConversation.where(account: current_account) |     AccountConversation.where(account: current_account) | ||||||
|                        .paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) |                        .paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) | ||||||
|  |  | ||||||
|  | @ -1,7 +1,6 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class Api::V1::ReportsController < Api::BaseController | 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 -> { doorkeeper_authorize! :write, :'write:reports' }, only: [:create] | ||||||
|   before_action :require_user! |   before_action :require_user! | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -13,6 +13,8 @@ export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS'; | ||||||
| export const CONVERSATIONS_FETCH_FAIL    = 'CONVERSATIONS_FETCH_FAIL'; | export const CONVERSATIONS_FETCH_FAIL    = 'CONVERSATIONS_FETCH_FAIL'; | ||||||
| export const CONVERSATIONS_UPDATE        = 'CONVERSATIONS_UPDATE'; | export const CONVERSATIONS_UPDATE        = 'CONVERSATIONS_UPDATE'; | ||||||
| 
 | 
 | ||||||
|  | export const CONVERSATIONS_READ = 'CONVERSATIONS_READ'; | ||||||
|  | 
 | ||||||
| export const mountConversations = () => ({ | export const mountConversations = () => ({ | ||||||
|   type: CONVERSATIONS_MOUNT, |   type: CONVERSATIONS_MOUNT, | ||||||
| }); | }); | ||||||
|  | @ -21,6 +23,15 @@ export const unmountConversations = () => ({ | ||||||
|   type: CONVERSATIONS_UNMOUNT, |   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) => { | export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => { | ||||||
|   dispatch(expandConversationsRequest()); |   dispatch(expandConversationsRequest()); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -8,6 +8,7 @@ import DisplayName from '../../../components/display_name'; | ||||||
| import Avatar from '../../../components/avatar'; | import Avatar from '../../../components/avatar'; | ||||||
| import AttachmentList from '../../../components/attachment_list'; | import AttachmentList from '../../../components/attachment_list'; | ||||||
| import { HotKeys } from 'react-hotkeys'; | import { HotKeys } from 'react-hotkeys'; | ||||||
|  | import classNames from 'classnames'; | ||||||
| 
 | 
 | ||||||
| export default class Conversation extends ImmutablePureComponent { | export default class Conversation extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|  | @ -19,8 +20,10 @@ export default class Conversation extends ImmutablePureComponent { | ||||||
|     conversationId: PropTypes.string.isRequired, |     conversationId: PropTypes.string.isRequired, | ||||||
|     accounts: ImmutablePropTypes.list.isRequired, |     accounts: ImmutablePropTypes.list.isRequired, | ||||||
|     lastStatus: ImmutablePropTypes.map.isRequired, |     lastStatus: ImmutablePropTypes.map.isRequired, | ||||||
|  |     unread:PropTypes.bool.isRequired, | ||||||
|     onMoveUp: PropTypes.func, |     onMoveUp: PropTypes.func, | ||||||
|     onMoveDown: PropTypes.func, |     onMoveDown: PropTypes.func, | ||||||
|  |     markRead: PropTypes.func.isRequired, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   handleClick = () => { |   handleClick = () => { | ||||||
|  | @ -28,7 +31,12 @@ export default class Conversation extends ImmutablePureComponent { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const { lastStatus } = this.props; |     const { lastStatus, unread, markRead } = this.props; | ||||||
|  | 
 | ||||||
|  |     if (unread) { | ||||||
|  |       markRead(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     this.context.router.history.push(`/statuses/${lastStatus.get('id')}`); |     this.context.router.history.push(`/statuses/${lastStatus.get('id')}`); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -41,7 +49,7 @@ export default class Conversation extends ImmutablePureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { accounts, lastStatus, lastAccount } = this.props; |     const { accounts, lastStatus, lastAccount, unread } = this.props; | ||||||
| 
 | 
 | ||||||
|     if (lastStatus === null) { |     if (lastStatus === null) { | ||||||
|       return null; |       return null; | ||||||
|  | @ -61,7 +69,7 @@ export default class Conversation extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <HotKeys handlers={handlers}> |       <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__header'> | ||||||
|             <div className='conversation__avatars'> |             <div className='conversation__avatars'> | ||||||
|               <div>{accounts.map(account => <Avatar key={account.get('id')} size={36} account={account} />)}</div> |               <div>{accounts.map(account => <Avatar key={account.get('id')} size={36} account={account} />)}</div> | ||||||
|  |  | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import Conversation from '../components/conversation'; | import Conversation from '../components/conversation'; | ||||||
|  | import { markConversationRead } from '../../../actions/conversations'; | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = (state, { conversationId }) => { | const mapStateToProps = (state, { conversationId }) => { | ||||||
|   const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId); |   const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId); | ||||||
|  | @ -7,9 +8,14 @@ const mapStateToProps = (state, { conversationId }) => { | ||||||
| 
 | 
 | ||||||
|   return { |   return { | ||||||
|     accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)), |     accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)), | ||||||
|  |     unread: conversation.get('unread'), | ||||||
|     lastStatus, |     lastStatus, | ||||||
|     lastAccount: lastStatus === null ? null : state.getIn(['accounts', lastStatus.get('account')], null), |     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); | ||||||
|  |  | ||||||
|  | @ -6,6 +6,7 @@ import { | ||||||
|   CONVERSATIONS_FETCH_SUCCESS, |   CONVERSATIONS_FETCH_SUCCESS, | ||||||
|   CONVERSATIONS_FETCH_FAIL, |   CONVERSATIONS_FETCH_FAIL, | ||||||
|   CONVERSATIONS_UPDATE, |   CONVERSATIONS_UPDATE, | ||||||
|  |   CONVERSATIONS_READ, | ||||||
| } from '../actions/conversations'; | } from '../actions/conversations'; | ||||||
| import compareId from '../compare_id'; | import compareId from '../compare_id'; | ||||||
| 
 | 
 | ||||||
|  | @ -18,6 +19,7 @@ const initialState = ImmutableMap({ | ||||||
| 
 | 
 | ||||||
| const conversationToMap = item => ImmutableMap({ | const conversationToMap = item => ImmutableMap({ | ||||||
|   id: item.id, |   id: item.id, | ||||||
|  |   unread: item.unread, | ||||||
|   accounts: ImmutableList(item.accounts.map(a => a.id)), |   accounts: ImmutableList(item.accounts.map(a => a.id)), | ||||||
|   last_status: item.last_status.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); |     return state.update('mounted', count => count + 1); | ||||||
|   case CONVERSATIONS_UNMOUNT: |   case CONVERSATIONS_UNMOUNT: | ||||||
|     return state.update('mounted', count => count - 1); |     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: |   default: | ||||||
|     return state; |     return state; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -5503,6 +5503,11 @@ noscript { | ||||||
|   border-bottom: 1px solid lighten($ui-base-color, 8%); |   border-bottom: 1px solid lighten($ui-base-color, 8%); | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
| 
 | 
 | ||||||
|  |   &--unread { | ||||||
|  |     background: lighten($ui-base-color, 8%); | ||||||
|  |     border-bottom-color: lighten($ui-base-color, 12%); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   &__header { |   &__header { | ||||||
|     display: flex; |     display: flex; | ||||||
|     margin-bottom: 15px; |     margin-bottom: 15px; | ||||||
|  |  | ||||||
|  | @ -10,6 +10,7 @@ | ||||||
| #  status_ids              :bigint(8)        default([]), not null, is an Array | #  status_ids              :bigint(8)        default([]), not null, is an Array | ||||||
| #  last_status_id          :bigint(8) | #  last_status_id          :bigint(8) | ||||||
| #  lock_version            :integer          default(0), not null | #  lock_version            :integer          default(0), not null | ||||||
|  | #  unread                  :boolean          default(FALSE), not null | ||||||
| # | # | ||||||
| 
 | 
 | ||||||
| class AccountConversation < ApplicationRecord | class AccountConversation < ApplicationRecord | ||||||
|  | @ -58,6 +59,7 @@ class AccountConversation < ApplicationRecord | ||||||
|     def add_status(recipient, status) |     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 = 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.status_ids << status.id | ||||||
|  |       conversation.unread = status.account_id != recipient.id | ||||||
|       conversation.save |       conversation.save | ||||||
|       conversation |       conversation | ||||||
|     rescue ActiveRecord::StaleObjectError |     rescue ActiveRecord::StaleObjectError | ||||||
|  |  | ||||||
|  | @ -1,7 +1,8 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class REST::ConversationSerializer < ActiveModel::Serializer | class REST::ConversationSerializer < ActiveModel::Serializer | ||||||
|   attribute :id |   attributes :id, :unread | ||||||
|  | 
 | ||||||
|   has_many :participant_accounts, key: :accounts, serializer: REST::AccountSerializer |   has_many :participant_accounts, key: :accounts, serializer: REST::AccountSerializer | ||||||
|   has_one :last_status, serializer: REST::StatusSerializer |   has_one :last_status, serializer: REST::StatusSerializer | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -58,6 +58,7 @@ Doorkeeper.configure do | ||||||
|   optional_scopes :write, |   optional_scopes :write, | ||||||
|                   :'write:accounts', |                   :'write:accounts', | ||||||
|                   :'write:blocks', |                   :'write:blocks', | ||||||
|  |                   :'write:conversations', | ||||||
|                   :'write:favourites', |                   :'write:favourites', | ||||||
|                   :'write:filters', |                   :'write:filters', | ||||||
|                   :'write:follows', |                   :'write:follows', | ||||||
|  | @ -76,7 +77,6 @@ Doorkeeper.configure do | ||||||
|                   :'read:lists', |                   :'read:lists', | ||||||
|                   :'read:mutes', |                   :'read:mutes', | ||||||
|                   :'read:notifications', |                   :'read:notifications', | ||||||
|                   :'read:reports', |  | ||||||
|                   :'read:search', |                   :'read:search', | ||||||
|                   :'read:statuses', |                   :'read:statuses', | ||||||
|                   :follow, |                   :follow, | ||||||
|  |  | ||||||
|  | @ -261,7 +261,12 @@ Rails.application.routes.draw do | ||||||
|       resources :streaming, only: [:index] |       resources :streaming, only: [:index] | ||||||
|       resources :custom_emojis, only: [:index] |       resources :custom_emojis, only: [:index] | ||||||
|       resources :suggestions, only: [:index, :destroy] |       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 |       get '/search', to: 'search#index', as: :search | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | @ -10,7 +10,7 @@ | ||||||
| # | # | ||||||
| # It's strongly recommended that you check this file into your version control system. | # 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 |   # These are extensions that must be enabled in order to support this database | ||||||
|   enable_extension "plpgsql" |   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 "status_ids", default: [], null: false, array: true | ||||||
|     t.bigint "last_status_id" |     t.bigint "last_status_id" | ||||||
|     t.integer "lock_version", default: 0, null: false |     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", "conversation_id", "participant_account_ids"], name: "index_unique_conversations", unique: true | ||||||
|     t.index ["account_id"], name: "index_account_conversations_on_account_id" |     t.index ["account_id"], name: "index_account_conversations_on_account_id" | ||||||
|     t.index ["conversation_id"], name: "index_account_conversations_on_conversation_id" |     t.index ["conversation_id"], name: "index_account_conversations_on_conversation_id" | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue