Add unread indicator to conversations (#9009)

This commit is contained in:
Eugen Rochko 2018-10-19 01:47:29 +02:00 committed by GitHub
parent bebe8ec887
commit a38a452481
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 98 additions and 11 deletions

View File

@ -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))

View File

@ -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!

View File

@ -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());

View File

@ -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>

View File

@ -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);

View File

@ -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;
} }

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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"