forked from cybrespace/mastodon
		
	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
 | 
			
		||||
  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))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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!
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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());
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.
 | 
			
		||||
 | 
			
		||||
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"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue