forked from fedi/mastodon
Add unread indicator to conversations (#9009)
This commit is contained in:
parent
bebe8ec887
commit
a38a452481
|
@ -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…
Reference in a new issue