Progress on dms, code cleanup

Progress on dms, code cleanup
This commit is contained in:
mgabdev 2020-12-02 23:22:51 -05:00
parent 20d4fc09af
commit 9a43c51085
103 changed files with 3656 additions and 859 deletions

View File

@ -51,22 +51,6 @@ module Admin
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params) redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
end end
def enable
authorize @custom_emoji, :enable?
@custom_emoji.update!(disabled: false)
log_action :enable, @custom_emoji
flash[:notice] = I18n.t('admin.custom_emojis.enabled_msg')
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
end
def disable
authorize @custom_emoji, :disable?
@custom_emoji.update!(disabled: true)
log_action :disable, @custom_emoji
flash[:notice] = I18n.t('admin.custom_emojis.disabled_msg')
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
end
private private
def set_custom_emoji def set_custom_emoji

View File

@ -3,12 +3,15 @@
class Api::BaseController < ApplicationController class Api::BaseController < ApplicationController
DEFAULT_STATUSES_LIMIT = 20 DEFAULT_STATUSES_LIMIT = 20
DEFAULT_ACCOUNTS_LIMIT = 20 DEFAULT_ACCOUNTS_LIMIT = 20
DEFAULT_CHAT_CONVERSATION_LIMIT = 12
DEFAULT_CHAT_CONVERSATION_MESSAGE_LIMIT = 10
include RateLimitHeaders include RateLimitHeaders
skip_before_action :store_current_location skip_before_action :store_current_location
skip_before_action :check_user_permissions skip_before_action :check_user_permissions
before_action :block_if_doorkeeper
before_action :set_cache_headers before_action :set_cache_headers
protect_from_forgery with: :null_session protect_from_forgery with: :null_session
@ -90,6 +93,14 @@ class Api::BaseController < ApplicationController
doorkeeper_authorize!(*scopes) if doorkeeper_token doorkeeper_authorize!(*scopes) if doorkeeper_token
end end
def superapp?
doorkeeper_token && doorkeeper_token.application.superapp? || false
end
def block_if_doorkeeper
raise GabSocial::NotPermittedError unless superapp?
end
def set_cache_headers def set_cache_headers
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
end end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
class Api::V1::Accounts::SearchController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }
before_action :require_user!
def show
@accounts = account_search
render json: @accounts, each_serializer: REST::AccountSerializer
end
private
def account_search
AccountSearchService.new.call(
params[:q],
current_account,
limit: limit_param(DEFAULT_ACCOUNTS_LIMIT),
resolve: truthy_param?(:resolve),
following: truthy_param?(:following),
offset: params[:offset]
)
end
end

View File

@ -0,0 +1,60 @@
# frozen_string_literal: true
class Api::V1::ChatConversationAccounts::BlockedAccountsController < Api::BaseController
before_action -> { doorkeeper_authorize! :follow, :'read:blocks' }
before_action :require_user!
after_action :insert_pagination_headers
def index
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
private
def load_accounts
paginated_blocks.map(&:target_account)
end
def paginated_blocks
@paginated_blocks ||= ChatBlock.eager_load(target_account: :account_stat)
.where(account: current_account)
.paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
if records_continue?
api_v1_chat_conversation_accounts_blocked_accounts_url pagination_params(max_id: pagination_max_id)
end
end
def prev_path
unless paginated_blocks.empty?
api_v1_chat_conversation_accounts_blocked_accounts_url pagination_params(since_id: pagination_since_id)
end
end
def pagination_max_id
paginated_blocks.last.id
end
def pagination_since_id
paginated_blocks.first.id
end
def records_continue?
paginated_blocks.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end

View File

@ -0,0 +1,60 @@
# frozen_string_literal: true
class Api::V1::ChatConversationAccounts::MutedAccountsController < Api::BaseController
before_action -> { doorkeeper_authorize! :follow, :'read:mutes' }
before_action :require_user!
after_action :insert_pagination_headers
def index
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
private
def load_accounts
paginated_mutes.map(&:target_account)
end
def paginated_mutes
@paginated_mutes ||= ChatMute.eager_load(target_account: :account_stat)
.where(account: current_account)
.paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
if records_continue?
api_v1_chat_conversation_accounts_muted_accounts_url pagination_params(max_id: pagination_max_id)
end
end
def prev_path
unless paginated_mutes.empty?
api_v1_chat_conversation_accounts_muted_accounts_url pagination_params(since_id: pagination_since_id)
end
end
def pagination_max_id
paginated_mutes.last.id
end
def pagination_since_id
paginated_mutes.first.id
end
def records_continue?
paginated_mutes.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end

View File

@ -1,24 +1,16 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::AccountsController < Api::BaseController class Api::V1::ChatConversationAccountsController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :block, :unblock, :mute, :unmute] before_action -> { authorize_if_got_token! :read, :'read:chats' }, except: [:create, :follow, :unfollow, :block, :unblock, :mute, :unmute]
before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow] before_action -> { doorkeeper_authorize! :write, :'write:chats' }, only: [:create]
before_action -> { doorkeeper_authorize! :follow, :'write:mutes' }, only: [:mute, :unmute]
before_action -> { doorkeeper_authorize! :follow, :'write:blocks' }, only: [:block, :unblock]
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:create]
before_action :require_user!, except: [:show, :create] before_action :require_user!
before_action :set_account, except: [:create] before_action :set_account, except: [:create]
before_action :check_account_suspension, only: [:show]
def show def show
# #
end end
def create
#
end
def block def block
BlockMessengerService.new.call(current_user.account, @account) BlockMessengerService.new.call(current_user.account, @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
@ -42,18 +34,19 @@ class Api::V1::AccountsController < Api::BaseController
private private
def set_account def set_account
@account = Account.find(params[:id]) # @account = Account.find(params[:id])
end end
def relationships(**options) # def relationships(**options)
AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options) # AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options)
end # end
def check_account_suspension def check_account_suspension
gone if @account.suspended? gone if @account.suspended?
end end
def account_params # def account_params
params.permit(:username, :email, :password, :agreement, :locale) # params.permit(:username, :email, :password, :agreement, :locale)
end # end
end end

View File

@ -0,0 +1,82 @@
# frozen_string_literal: true
class Api::V1::ChatConversationController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:chats' }
before_action -> { doorkeeper_authorize! :write, :'write:chats' }
before_action :require_user!
before_action :set_account, only: :create
before_action :set_chat_conversation, only: [:show, :mark_chat_conversation_approved, :mark_chat_conversation_hidden, :mark_chat_conversation_unread]
def show
puts "tilly ChatConversationsController-0"
render json: {}, each_serializer: REST::ChatConversationAccountSerializer
end
def create
puts "tilly ChatConversationsController-1"
# : todo :
# check if already created
# check if blocked
# check if chat blocked
# check if allow anyone to message then create with approved:true
# unique account id, participants
chat_conversation_account = find_or_create_conversation
render json: chat_conversation_account, each_serializer: REST::ChatConversationAccountSerializer
end
def mark_chat_conversation_unread
@chat_conversation_account.update!(is_unread: true)
render json: @chat_conversation_account, serializer: REST::ChatConversationAccountSerializer
end
def mark_chat_conversation_hidden
@chat_conversation_account.update!(is_hidden: true)
render json: @chat_conversation_account, serializer: REST::ChatConversationAccountSerializer
end
def mark_chat_conversation_approved
@chat_conversation_account.update!(is_approved: true)
render json: @chat_conversation_account, serializer: REST::ChatConversationAccountSerializer
end
private
def find_or_create_conversation
chat = ChatConversationAccount.find_by(account: current_account, participant_account_ids: [@account.id.to_s])
return chat unless chat.nil?
chat_conversation = ChatConversation.create
my_chat = ChatConversationAccount.create!(
account: current_account,
participant_account_ids: [@account.id.to_s],
chat_conversation: chat_conversation,
is_approved: true
)
# : todo : if multiple ids
their_chat = ChatConversationAccount.create!(
account: @account,
participant_account_ids: [current_account.id.to_s],
chat_conversation: chat_conversation,
is_approved: false # default as request
)
return my_chat
end
def set_account
@account = Account.find(params[:account_id])
end
def set_chat_conversation
@chat_conversation = ChatConversation.find(params[:id])
@chat_conversation_account = ChatConversationAccount.where(
account: current_account,
chat_conversation: @chat_conversation
).first
end
end

View File

@ -0,0 +1,71 @@
# frozen_string_literal: true
class Api::V1::ChatConversations::ApprovedConversationsController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:chats' }
before_action :require_user!
after_action :insert_pagination_headers
def index
puts "tilly ApprovedConversationsController-0"
@chat_conversations = load_chat_conversations
render json: @chat_conversations, each_serializer: REST::ChatConversationAccountSerializer
end
def show
puts "tilly ApprovedConversationsController-1"
@chat_conversations = load_chat_conversations
render json: @chat_conversations, each_serializer: REST::ChatConversationAccountSerializer
end
private
def load_chat_conversations
paginated_chat_conversations
end
def paginated_chat_conversations
ChatConversationAccount.where(
account: current_account,
is_hidden: false,
is_approved: true
).paginate_by_max_id(
limit_param(DEFAULT_CHAT_CONVERSATION_LIMIT),
params[:max_id],
params[:since_id]
)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
if records_continue?
api_v1_chat_conversations_approved_conversations_url pagination_params(max_id: pagination_max_id)
end
end
def prev_path
unless paginated_chat_conversations.empty?
api_v1_chat_conversations_approved_conversations_url pagination_params(since_id: pagination_since_id)
end
end
def pagination_max_id
paginated_chat_conversations.last.id
end
def pagination_since_id
paginated_chat_conversations.first.id
end
def records_continue?
paginated_chat_conversations.size == limit_param(DEFAULT_CHAT_CONVERSATION_LIMIT)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
class Api::V1::ChatConversations::MessagesController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:chats' }
before_action -> { doorkeeper_authorize! :write, :'write:chats' }
before_action :require_user!
before_action :set_chat_conversation
before_action :set_chat_messages
after_action :insert_pagination_headers, unless: -> { @chats.empty? }
def show
puts "tilly chat_message_conversations - 1: " + @chats.count.inspect
render json: @chats, each_serializer: REST::ChatMessageSerializer
end
private
def set_chat_conversation
@chat_conversation = ChatConversation.find(params[:id])
end
def set_chat_messages
@chats = cached_conversation_chats
end
def cached_conversation_chats
cache_collection conversation_chats, ChatMessage
end
def conversation_chats
chats = ChatMessage.where(
chat_conversation: @chat_conversation
).paginate_by_id(
limit_param(DEFAULT_CHAT_CONVERSATION_MESSAGE_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
end
def insert_pagination_headers
set_pagination_headers(next_path, nil)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
def next_path
api_v1_chat_conversations_message_url params[:id], pagination_params(since_id: pagination_since_id)
end
def pagination_since_id
@chats.first.id
end
end

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
class Api::V1::ChatConversations::RequestedConversationsController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:chats' }
before_action :require_user!
after_action :insert_pagination_headers
def index
@chat_conversations = load_chat_conversations
render json: @chat_conversations, each_serializer: REST::ChatConversationAccountSerializer
end
def count
count = ChatConversationAccount.where(account: current_account, is_hidden: false, is_approved: false).count
render json: count
end
private
def load_chat_conversations
paginated_chat_conversations
end
def paginated_chat_conversations
ChatConversationAccount.where(
account: current_account,
is_hidden: false,
is_approved: false
).paginate_by_max_id(
limit_param(DEFAULT_CHAT_CONVERSATION_LIMIT),
params[:max_id],
params[:since_id]
)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
if records_continue?
api_v1_chat_conversations_requested_conversations_url pagination_params(max_id: pagination_max_id)
end
end
def prev_path
unless paginated_chat_conversations.empty?
api_v1_chat_conversations_requested_conversations_url pagination_params(since_id: pagination_since_id)
end
end
def pagination_max_id
paginated_chat_conversations.last.id
end
def pagination_since_id
paginated_chat_conversations.first.id
end
def records_continue?
paginated_chat_conversations.size == limit_param(DEFAULT_CHAT_CONVERSATION_LIMIT)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
class Api::V1::ChatMessagesController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:chats' }
before_action -> { doorkeeper_authorize! :write, :'write:chats' }
before_action :require_user!
before_action :set_chat_conversation
def create
@chat = ChatMessage.create!(
from_account: current_account,
chat_conversation: @chat_conversation,
text: params[:text]
)
# : todo :
# Redis.current.publish("chat_messages:10", 'hi')
Redis.current.publish("chat_messages:10", Oj.dump(event: :chat_message, payload: InlineRenderer.render(@chat, current_user.account, :chat_message)))
render json: @chat, serializer: REST::ChatMessageSerializer
end
def destroy
@chat = ChatMessage.where(account: current_user.account).find(params[:id])
authorize @chat, :destroy?
# : todo :
# make sure last_chat_message_id in chat_account_conversation gets set to last
@chat.destroy!
render json: @chat, serializer: REST::ChatMessageSerializer
end
private
def set_chat_conversation
@chat_conversation = ChatConversation.find(params[:chat_conversation_id])
end
def chat_params
params.permit(:text, :chat_conversation_id)
end
end

View File

@ -48,4 +48,16 @@ class EmptyController < ActionController::Base
nil nil
end end
protected
def limit_param(default_limit)
return default_limit unless params[:limit]
[params[:limit].to_i.abs, default_limit * 2].min
end
def truthy_param?(key)
ActiveModel::Type::Boolean.new.cast(params[key])
end
end end

View File

@ -52,7 +52,7 @@ class ReactController < ApplicationController
end end
def find_route_matches def find_route_matches
request.path.match(/\A\/(home|news|suggestions|links|messages|shortcuts|group|groups|list|lists|notifications|tags|compose|follow_requests|admin|account|settings|filters|timeline|blocks|mutes)/) request.path.match(/\A\/(home|news|api|suggestions|links|chat_conversations|chat_conversation_accounts|messages|shortcuts|group|groups|list|lists|notifications|tags|compose|follow_requests|admin|account|settings|filters|timeline|blocks|mutes)/)
end end
def find_public_route_matches def find_public_route_matches

View File

@ -163,6 +163,7 @@ export const followAccount = (id, reblogs = true) => (dispatch, getState) => {
dispatch(followAccountRequest(id, locked)) dispatch(followAccountRequest(id, locked))
api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then((response) => { api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then((response) => {
console.log("response:", response)
dispatch(followAccountSuccess(response.data, alreadyFollowing)) dispatch(followAccountSuccess(response.data, alreadyFollowing))
}).catch((error) => { }).catch((error) => {
dispatch(followAccountFail(error, locked)) dispatch(followAccountFail(error, locked))

View File

@ -1,28 +0,0 @@
import api from '../api'
import { me } from '../initial_state'
export const MESSAGE_INPUT_CHANGE = 'MESSAGE_INPUT_CHANGE'
export const MESSAGE_INPUT_RESET = 'MESSAGE_INPUT_RESET'
/**
*
*/
export const messageInputChange = (text) => (dispatch, getState) => {
if (!me) return
//Ensure has conversation
const conversationId = getState().getIn(['chat_conversations', 'current', 'conversation_id'], null)
if (!conversationId) return
dispatch({
type: MESSAGE_INPUT_CHANGE,
text,
})
}
/**
*
*/
export const messageInputReset = (dispatch) => {
dispatch({ type: MESSAGE_INPUT_RESET })
}

View File

@ -0,0 +1,111 @@
import { Map as ImmutableMap, List as ImmutableList, toJS } from 'immutable'
import noop from 'lodash.noop'
import api, { getLinks } from '../api'
import { me } from '../initial_state'
import { importFetchedChatMessages } from './importer'
export const CHAT_CONVERSATION_MESSAGES_EXPAND_REQUEST = 'CHAT_CONVERSATION_MESSAGES_EXPAND_REQUEST'
export const CHAT_CONVERSATION_MESSAGES_EXPAND_SUCCESS = 'CHAT_CONVERSATION_MESSAGES_EXPAND_SUCCESS'
export const CHAT_CONVERSATION_MESSAGES_EXPAND_FAIL = 'CHAT_CONVERSATION_MESSAGES_EXPAND_FAIL'
//
export const CHAT_CONVERSATION_MESSAGES_CONNECT = 'CHAT_CONVERSATION_MESSAGES_CONNECT'
export const CHAT_CONVERSATION_MESSAGES_DISCONNECT = 'CHAT_CONVERSATION_MESSAGES_DISCONNECT'
export const CHAT_CONVERSATION_MESSAGES_CLEAR = 'CHAT_CONVERSATION_MESSAGES_CLEAR'
export const CHAT_CONVERSATION_MESSAGES_SCROLL_BOTTOM = 'CHAT_CONVERSATION_MESSAGES_SCROLL_BOTTOM'
/**
*
*/
export const connectChatMessageConversation = (chatConversationId) => ({
type: CHAT_CONVERSATION_MESSAGES_CONNECT,
chatConversationId,
})
/**
*
*/
export const disconnectChatMessageConversation = (chatConversationId) => ({
type: CHAT_CONVERSATION_MESSAGES_DISCONNECT,
chatConversationId,
})
/**
*
*/
export const clearChatMessageConversation = (chatConversationId) => (dispatch) => {
dispatch({
type: CHAT_CONVERSATION_MESSAGES_CLEAR,
chatConversationId
})
}
/**
*
*/
export const scrollBottomChatMessageConversation = (chatConversationId, top) => ({
type: CHAT_CONVERSATION_MESSAGES_SCROLL_BOTTOM,
chatConversationId,
top,
})
/**
*
*/
export const expandChatMessages = (chatConversationId, params = {}, done = noop) => (dispatch, getState) => {
if (!me || !chatConversationId) return
const chatConversation = getState().getIn(['chat_messages', chatConversationId], ImmutableMap())
const isLoadingMore = !!params.maxId
if (!!chatConversation && (chatConversation.get('isLoading') || chatConversation.get('isError'))) {
done()
return
}
if (!params.maxId && chatConversation.get('items', ImmutableList()).size > 0) {
params.sinceId = chatConversation.getIn(['items', 0])
}
const isLoadingRecent = !!params.sinceId
dispatch(expandChatMessagesRequest(chatConversationId, isLoadingMore))
api(getState).get(`/api/v1/chat_conversations/messages/${chatConversationId}`, { params }).then((response) => {
console.log("response:", response)
const next = getLinks(response).refs.find(link => link.rel === 'next')
console.log("next:", next, getLinks(response).refs)
dispatch(importFetchedChatMessages(response.data))
dispatch(expandChatMessagesSuccess(chatConversationId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore))
done()
}).catch((error) => {
console.log("error:", error)
dispatch(expandChatMessagesFail(chatConversationId, error, isLoadingMore))
done()
})
}
export const expandChatMessagesRequest = (chatConversationId, isLoadingMore) => ({
type: CHAT_CONVERSATION_MESSAGES_EXPAND_REQUEST,
chatConversationId,
skipLoading: !isLoadingMore,
})
export const expandChatMessagesSuccess = (chatConversationId, chatMessages, next, partial, isLoadingRecent, isLoadingMore) => ({
type: CHAT_CONVERSATION_MESSAGES_EXPAND_SUCCESS,
chatConversationId,
chatMessages,
next,
partial,
isLoadingRecent,
skipLoading: !isLoadingMore,
})
export const expandChatMessagesFail = (chatConversationId, error, isLoadingMore) => ({
type: CHAT_CONVERSATION_MESSAGES_EXPAND_FAIL,
chatConversationId,
error,
skipLoading: !isLoadingMore,
})

View File

@ -3,13 +3,35 @@ import { fetchRelationships } from './accounts'
import { importFetchedAccounts } from './importer' import { importFetchedAccounts } from './importer'
import { me } from '../initial_state' import { me } from '../initial_state'
export const CONVERSATION_BLOCKS_FETCH_REQUEST = 'CONVERSATION_BLOCKS_FETCH_REQUEST' //
export const CONVERSATION_BLOCKS_FETCH_SUCCESS = 'CONVERSATION_BLOCKS_FETCH_SUCCESS'
export const CONVERSATION_BLOCKS_FETCH_FAIL = 'CONVERSATION_BLOCKS_FETCH_FAIL'
export const CONVERSATION_BLOCKS_EXPAND_REQUEST = 'CONVERSATION_BLOCKS_EXPAND_REQUEST' export const CHAT_CONVERSATIONS_APPROVED_FETCH_REQUEST = 'CHAT_CONVERSATIONS_APPROVED_FETCH_REQUEST'
export const CONVERSATION_BLOCKS_EXPAND_SUCCESS = 'CONVERSATION_BLOCKS_EXPAND_SUCCESS' export const CHAT_CONVERSATIONS_APPROVED_FETCH_SUCCESS = 'CHAT_CONVERSATIONS_APPROVED_FETCH_SUCCESS'
export const CONVERSATION_BLOCKS_EXPAND_FAIL = 'CONVERSATION_BLOCKS_EXPAND_FAIL' export const CHAT_CONVERSATIONS_APPROVED_FETCH_FAIL = 'CHAT_CONVERSATIONS_APPROVED_FETCH_FAIL'
export const CHAT_CONVERSATIONS_APPROVED_EXPAND_REQUEST = 'CHAT_CONVERSATIONS_APPROVED_EXPAND_REQUEST'
export const CHAT_CONVERSATIONS_APPROVED_EXPAND_SUCCESS = 'CHAT_CONVERSATIONS_APPROVED_EXPAND_SUCCESS'
export const CHAT_CONVERSATIONS_APPROVED_EXPAND_FAIL = 'CHAT_CONVERSATIONS_APPROVED_EXPAND_FAIL'
//
export const CHAT_CONVERSATIONS_CREATE_REQUEST = 'CHAT_CONVERSATIONS_CREATE_REQUEST'
export const CHAT_CONVERSATIONS_CREATE_SUCCESS = 'CHAT_CONVERSATIONS_CREATE_SUCCESS'
export const CHAT_CONVERSATIONS_CREATE_FAIL = 'CHAT_CONVERSATIONS_CREATE_FAIL'
export const CHAT_CONVERSATIONS_DELETE_REQUEST = 'CHAT_CONVERSATIONS_DELETE_REQUEST'
export const CHAT_CONVERSATIONS_DELETE_SUCCESS = 'CHAT_CONVERSATIONS_DELETE_SUCCESS'
export const CHAT_CONVERSATIONS_DELETE_FAIL = 'CHAT_CONVERSATIONS_DELETE_FAIL'
//
export const CHAT_CONVERSATION_BLOCKS_FETCH_REQUEST = 'CHAT_CONVERSATION_BLOCKS_FETCH_REQUEST'
export const CHAT_CONVERSATION_BLOCKS_FETCH_SUCCESS = 'CHAT_CONVERSATION_BLOCKS_FETCH_SUCCESS'
export const CHAT_CONVERSATION_BLOCKS_FETCH_FAIL = 'CHAT_CONVERSATION_BLOCKS_FETCH_FAIL'
export const CHAT_CONVERSATION_BLOCKS_EXPAND_REQUEST = 'CHAT_CONVERSATION_BLOCKS_EXPAND_REQUEST'
export const CHAT_CONVERSATION_BLOCKS_EXPAND_SUCCESS = 'CHAT_CONVERSATION_BLOCKS_EXPAND_SUCCESS'
export const CHAT_CONVERSATION_BLOCKS_EXPAND_FAIL = 'CHAT_CONVERSATION_BLOCKS_EXPAND_FAIL'
export const BLOCK_MESSAGER_REQUEST = 'BLOCK_MESSAGER_REQUEST' export const BLOCK_MESSAGER_REQUEST = 'BLOCK_MESSAGER_REQUEST'
export const BLOCK_MESSAGER_SUCCESS = 'BLOCK_MESSAGER_SUCCESS' export const BLOCK_MESSAGER_SUCCESS = 'BLOCK_MESSAGER_SUCCESS'
@ -21,13 +43,13 @@ export const UNBLOCK_MESSAGER_FAIL = 'UNBLOCK_MESSAGER_FAIL'
// //
export const CONVERSATION_MUTES_FETCH_REQUEST = 'CONVERSATION_MUTES_FETCH_REQUEST' export const CHAT_CONVERSATION_MUTES_FETCH_REQUEST = 'CHAT_CONVERSATION_MUTES_FETCH_REQUEST'
export const CONVERSATION_MUTES_FETCH_SUCCESS = 'CONVERSATION_MUTES_FETCH_SUCCESS' export const CHAT_CONVERSATION_MUTES_FETCH_SUCCESS = 'CHAT_CONVERSATION_MUTES_FETCH_SUCCESS'
export const CONVERSATION_MUTES_FETCH_FAIL = 'CONVERSATION_MUTES_FETCH_FAIL' export const CHAT_CONVERSATION_MUTES_FETCH_FAIL = 'CHAT_CONVERSATION_MUTES_FETCH_FAIL'
export const CONVERSATION_MUTES_EXPAND_REQUEST = 'CONVERSATION_MUTES_EXPAND_REQUEST' export const CHAT_CONVERSATION_MUTES_EXPAND_REQUEST = 'CHAT_CONVERSATION_MUTES_EXPAND_REQUEST'
export const CONVERSATION_MUTES_EXPAND_SUCCESS = 'CONVERSATION_MUTES_EXPAND_SUCCESS' export const CHAT_CONVERSATION_MUTES_EXPAND_SUCCESS = 'CHAT_CONVERSATION_MUTES_EXPAND_SUCCESS'
export const CONVERSATION_MUTES_EXPAND_FAIL = 'CONVERSATION_MUTES_EXPAND_FAIL' export const CHAT_CONVERSATION_MUTES_EXPAND_FAIL = 'CHAT_CONVERSATION_MUTES_EXPAND_FAIL'
export const MUTE_MESSAGER_REQUEST = 'BLOCK_MESSAGER_REQUEST' export const MUTE_MESSAGER_REQUEST = 'BLOCK_MESSAGER_REQUEST'
export const MUTE_MESSAGER_SUCCESS = 'BLOCK_MESSAGER_SUCCESS' export const MUTE_MESSAGER_SUCCESS = 'BLOCK_MESSAGER_SUCCESS'
@ -39,25 +61,238 @@ export const UNMUTE_MESSAGER_FAIL = 'UNMUTE_MESSAGER_FAIL'
// //
export const CONVERSATION_REQUEST_APPROVE_SUCCESS = 'CONVERSATION_REQUEST_APPROVE_SUCCESS' export const CHAT_CONVERSATION_REQUESTED_COUNT_FETCH_SUCCESS = 'CHAT_CONVERSATION_REQUESTED_COUNT_FETCH_SUCCESS'
export const CONVERSATION_REQUEST_APPROVE_FAIL = 'CONVERSATION_REQUEST_APPROVE_FAIL'
export const CONVERSATION_REQUEST_REJECT_SUCCESS = 'CONVERSATION_REQUEST_REJECT_SUCCESS' export const CHAT_CONVERSATIONS_REQUESTED_FETCH_REQUEST = 'CHAT_CONVERSATIONS_REQUESTED_FETCH_REQUEST'
export const CONVERSATION_REQUEST_REJECT_FAIL = 'CONVERSATION_REQUEST_REJECT_FAIL' export const CHAT_CONVERSATIONS_REQUESTED_FETCH_SUCCESS = 'CHAT_CONVERSATIONS_REQUESTED_FETCH_SUCCESS'
export const CHAT_CONVERSATIONS_REQUESTED_FETCH_FAIL = 'CHAT_CONVERSATIONS_REQUESTED_FETCH_FAIL'
export const CONVERSATION_DELETE_REQUEST = 'CONVERSATION_DELETE_REQUEST' export const CHAT_CONVERSATIONS_REQUESTED_EXPAND_REQUEST = 'CHAT_CONVERSATIONS_REQUESTED_EXPAND_REQUEST'
export const CONVERSATION_DELETE_SUCCESS = 'CONVERSATION_DELETE_SUCCESS' export const CHAT_CONVERSATIONS_REQUESTED_EXPAND_SUCCESS = 'CHAT_CONVERSATIONS_REQUESTED_EXPAND_SUCCESS'
export const CONVERSATION_DELETE_FAIL = 'CONVERSATION_DELETE_FAIL' export const CHAT_CONVERSATIONS_REQUESTED_EXPAND_FAIL = 'CHAT_CONVERSATIONS_REQUESTED_EXPAND_FAIL'
// //
export const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST' export const CHAT_CONVERSATION_REQUEST_APPROVE_SUCCESS = 'CHAT_CONVERSATION_REQUEST_APPROVE_SUCCESS'
export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS' export const CHAT_CONVERSATION_REQUEST_APPROVE_FAIL = 'CHAT_CONVERSATION_REQUEST_APPROVE_FAIL'
export const CONVERSATIONS_FETCH_FAIL = 'CONVERSATIONS_FETCH_FAIL'
export const CHAT_CONVERSATION_DELETE_REQUEST = 'CHAT_CONVERSATION_DELETE_REQUEST'
export const CHAT_CONVERSATION_DELETE_SUCCESS = 'CHAT_CONVERSATION_DELETE_SUCCESS'
export const CHAT_CONVERSATION_DELETE_FAIL = 'CHAT_CONVERSATION_DELETE_FAIL'
/**
*
*/
export const fetchChatConversations = () => (dispatch, getState) => {
if (!me) return
dispatch(fetchChatConversationsRequest())
api(getState).get('/api/v1/chat_conversations/approved_conversations').then((response) => {
console.log("chat_conversations response: ", response)
const next = getLinks(response).refs.find(link => link.rel === 'next')
const conversationsAccounts = [].concat.apply([], response.data.map((c) => c.other_accounts))
const conversationsChatMessages = response.data.map((c) => c.last_chat_message)
dispatch(importFetchedAccounts(conversationsAccounts))
// dispatch(importFetchedChatMessages(conversationsChatMessages))
dispatch(fetchChatConversationsSuccess(response.data, next ? next.uri : null))
}).catch((error) => {
console.log("fetchChatConversationsFail:", error)
dispatch(fetchChatConversationsFail(error))
})
}
export const fetchChatConversationsRequest = () => ({
type: CHAT_CONVERSATIONS_APPROVED_FETCH_REQUEST,
})
export const fetchChatConversationsSuccess = (chatConversations, next) => ({
type: CHAT_CONVERSATIONS_APPROVED_FETCH_SUCCESS,
chatConversations,
next,
})
export const fetchChatConversationsFail = (error) => ({
type: CHAT_CONVERSATIONS_APPROVED_FETCH_FAIL,
error,
})
/**
*
*/
export const expandChatConversations = () => (dispatch, getState) => {
if (!me) return
const url = getState().getIn(['chat_conversations', 'approved', 'next'])
const isLoading = getState().getIn(['chat_conversations', 'approved', 'isLoading'])
if (url === null || isLoading) return
dispatch(expandChatConversationsRequest())
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next')
const conversationsAccounts = [].concat.apply([], response.data.map((c) => c.other_accounts))
const conversationsChatMessages = response.data.map((c) => c.last_chat_message)
dispatch(importFetchedAccounts(conversationsAccounts))
// dispatch(importFetchedChatMessages(conversationsChatMessages))
dispatch(expandChatConversationsSuccess(response.data, next ? next.uri : null))
}).catch(error => dispatch(expandChatConversationsFail(error)))
}
export const expandChatConversationsRequest = () => ({
type: CHAT_CONVERSATIONS_APPROVED_EXPAND_SUCCESS,
})
export const expandChatConversationsSuccess = (chatConversations, next) => ({
type: CHAT_CONVERSATIONS_APPROVED_EXPAND_SUCCESS,
chatConversations,
next,
})
export const expandChatConversationsFail = (error) => ({
type: CHAT_CONVERSATIONS_APPROVED_EXPAND_SUCCESS,
error,
})
/**
*
*/
export const fetchChatConversationRequested = () => (dispatch, getState) => {
if (!me) return
dispatch(fetchChatConversationRequestedRequest())
api(getState).get('/api/v1/chat_conversations/requested_conversations').then((response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next')
const conversationsAccounts = [].concat.apply([], response.data.map((c) => c.other_accounts))
const conversationsChatMessages = response.data.map((c) => c.last_chat_message)
dispatch(importFetchedAccounts(conversationsAccounts))
// dispatch(importFetchedChatMessages(conversationsChatMessages))
dispatch(fetchChatConversationRequestedSuccess(response.data, next ? next.uri : null))
}).catch((error) => {
console.log("error:", error)
dispatch(fetchChatConversationRequestedFail(error))
})
}
export const fetchChatConversationRequestedRequest = () => ({
type: CHAT_CONVERSATIONS_REQUESTED_FETCH_REQUEST,
})
export const fetchChatConversationRequestedSuccess = (chatConversations, next) => ({
type: CHAT_CONVERSATIONS_REQUESTED_FETCH_SUCCESS,
chatConversations,
next,
})
export const fetchChatConversationRequestedFail = (error) => ({
type: CHAT_CONVERSATIONS_REQUESTED_FETCH_FAIL,
error,
})
/**
*
*/
export const expandChatConversationRequested = () => (dispatch, getState) => {
if (!me) return
const url = getState().getIn(['chat_conversations', 'requested', 'next'])
const isLoading = getState().getIn(['chat_conversations', 'requested', 'isLoading'])
if (url === null || isLoading) return
dispatch(expandChatConversationRequestedRequest())
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next')
const conversationsAccounts = [].concat.apply([], response.data.map((c) => c.other_accounts))
const conversationsChatMessages = response.data.map((c) => c.last_chat_message)
dispatch(importFetchedAccounts(conversationsAccounts))
// dispatch(importFetchedChatMessages(conversationsChatMessages))
dispatch(expandChatConversationRequestedSuccess(response.data, next ? next.uri : null))
}).catch(error => dispatch(expandChatConversationRequestedFail(error)))
}
export const expandChatConversationRequestedRequest = () => ({
type: CHAT_CONVERSATIONS_REQUESTED_EXPAND_REQUEST,
})
export const expandChatConversationRequestedSuccess = (chatConversations, next) => ({
type: CHAT_CONVERSATIONS_REQUESTED_EXPAND_SUCCESS,
chatConversations,
next,
})
export const expandChatConversationRequestedFail = (error) => ({
type: CHAT_CONVERSATIONS_REQUESTED_EXPAND_FAIL,
error,
})
/**
*
*/
export const createChatConversation = (accountId) => (dispatch, getState) => {
if (!me || !accountId) return
dispatch(createChatConversationRequest())
api(getState).post('/api/v1/chat_conversation', { account_id: accountId }).then((response) => {
dispatch(createChatConversationSuccess(response.data))
}).catch((error) => {
dispatch(createChatConversationFail(error))
})
}
export const createChatConversationRequest = () => ({
type: CHAT_CONVERSATIONS_CREATE_REQUEST,
})
export const createChatConversationSuccess = (chatConversation) => ({
type: CHAT_CONVERSATIONS_CREATE_SUCCESS,
chatConversation,
})
export const createChatConversationFail = (error) => ({
type: CHAT_CONVERSATIONS_CREATE_FAIL,
error,
})
/**
*
*/
export const deleteChatConversation = (chatConversationId) => (dispatch, getState) => {
if (!me || !chatConversationId) return
dispatch(deleteChatConversationRequest(conversationId))
api(getState).delete(`/api/v1/chat_conversation/${chatConversationId}`).then((response) => {
console.log("chat_conversations delete response: ", response)
dispatch(deleteChatConversationSuccess())
}).catch((error) => {
dispatch(deleteChatConversationFail(error))
})
}
export const deleteChatConversationRequest = (conversationId) => ({
type: CHAT_CONVERSATIONS_DELETE_REQUEST,
conversationId,
})
export const deleteChatConversationSuccess = () => ({
type: CHAT_CONVERSATIONS_DELETE_SUCCESS,
})
export const deleteChatConversationFail = (error) => ({
type: CHAT_CONVERSATIONS_DELETE_FAIL,
error,
})
export const CONVERSATIONS_EXPAND_REQUEST = 'CONVERSATIONS_EXPAND_REQUEST'
export const CONVERSATIONS_EXPAND_SUCCESS = 'CONVERSATIONS_EXPAND_SUCCESS'
export const CONVERSATIONS_EXPAND_FAIL = 'CONVERSATIONS_EXPAND_FAIL'
/** /**
* *
@ -110,12 +345,12 @@ const unblockMessengerRequest = (accountId) => ({
accountId, accountId,
}) })
const blockMessengerSuccess = (data) => ({ const unblockMessengerSuccess = (data) => ({
type: UNBLOCK_MESSAGER_REQUEST, type: UNBLOCK_MESSAGER_REQUEST,
data, data,
}) })
const blockMessengerFail = (accountId, error) => ({ const unblockMessengerFail = (accountId, error) => ({
type: UNBLOCK_MESSAGER_REQUEST, type: UNBLOCK_MESSAGER_REQUEST,
accountId, accountId,
error, error,
@ -137,20 +372,20 @@ export const fetchBlocks = () => (dispatch, getState) => {
}).catch(error => dispatch(fetchBlocksFail(error))) }).catch(error => dispatch(fetchBlocksFail(error)))
} }
export const fetchBlocksRequest = () => ({ // export const fetchBlocksRequest = () => ({
type: BLOCKS_FETCH_REQUEST, // type: BLOCKS_FETCH_REQUEST,
}) // })
export const fetchBlocksSuccess = (accounts, next) => ({ // export const fetchBlocksSuccess = (accounts, next) => ({
type: BLOCKS_FETCH_SUCCESS, // type: BLOCKS_FETCH_SUCCESS,
accounts, // accounts,
next, // next,
}) // })
export const fetchBlocksFail = (error) => ({ // export const fetchBlocksFail = (error) => ({
type: BLOCKS_FETCH_FAIL, // type: BLOCKS_FETCH_FAIL,
error, // error,
}) // })
/** /**
* *
@ -173,17 +408,51 @@ export const expandBlocks = () => (dispatch, getState) => {
}).catch(error => dispatch(expandBlocksFail(error))) }).catch(error => dispatch(expandBlocksFail(error)))
} }
export const expandBlocksRequest = () => ({ // export const expandBlocksRequest = () => ({
type: BLOCKS_EXPAND_REQUEST, // type: BLOCKS_EXPAND_REQUEST,
// })
// export const expandBlocksSuccess = (accounts, next) => ({
// type: BLOCKS_EXPAND_SUCCESS,
// accounts,
// next,
// })
// export const expandBlocksFail = (error) => ({
// type: BLOCKS_EXPAND_FAIL,
// error,
// })
/**
*
*/
export const fetchChatConversationRequestedCount = () => (dispatch, getState) => {
if (!me) return
api(getState).get('/api/v1/chat_conversations/requested_conversations/count').then(response => {
dispatch({
type: CHAT_CONVERSATION_REQUESTED_COUNT_FETCH_SUCCESS,
count: response.data,
})
})
}
/**
*
*/
export const approveChatConversationRequest = (chatConversationId) => (dispatch, getState) => {
if (!me|| !chatConversationId) return
api(getState).post(`/api/v1/chat_conversation/${chatConversationId}/mark_chat_conversation_approved`).then((response) => {
dispatch(approveChatConversationRequestSuccess(response.data))
}).catch((error) => dispatch(approveChatConversationRequestFail(error)))
}
export const approveChatConversationRequestSuccess = (chatConversation) => ({
type: CHAT_CONVERSATION_REQUEST_APPROVE_SUCCESS,
chatConversation,
}) })
export const expandBlocksSuccess = (accounts, next) => ({ export const approveChatConversationRequestFail = () => ({
type: BLOCKS_EXPAND_SUCCESS, type: CHAT_CONVERSATION_REQUEST_APPROVE_FAIL,
accounts,
next,
})
export const expandBlocksFail = (error) => ({
type: BLOCKS_EXPAND_FAIL,
error,
}) })

View File

@ -1,86 +1,85 @@
import { Map as ImmutableMap, List as ImmutableList, toJS } from 'immutable'
import noop from 'lodash.noop'
import api from '../api' import api from '../api'
import { me } from '../initial_state' import { me } from '../initial_state'
import { importFetchedChatMessages } from './importer'
export const MESSAGE_SEND_REQUEST = 'MESSAGE_SEND_REQUEST' export const CHAT_MESSAGES_SEND_REQUEST = 'CHAT_MESSAGES_SEND_REQUEST'
export const MESSAGE_SEND_SUCCESS = 'MESSAGE_SEND_SUCCESS' export const CHAT_MESSAGES_SEND_SUCCESS = 'CHAT_MESSAGES_SEND_SUCCESS'
export const MESSAGE_SEND_FAIL = 'MESSAGE_SEND_FAIL' export const CHAT_MESSAGES_SEND_FAIL = 'CHAT_MESSAGES_SEND_FAIL'
export const MESSAGE_DELETE_REQUEST = 'MESSAGE_DELETE_REQUEST' export const CHAT_MESSAGES_DELETE_REQUEST = 'CHAT_MESSAGES_DELETE_REQUEST'
export const MESSAGE_DELETE_SUCCESS = 'MESSAGE_DELETE_SUCCESS' export const CHAT_MESSAGES_DELETE_SUCCESS = 'CHAT_MESSAGES_DELETE_SUCCESS'
export const MESSAGE_DELETE_FAIL = 'MESSAGE_DELETE_FAIL' export const CHAT_MESSAGES_DELETE_FAIL = 'CHAT_MESSAGES_DELETE_FAIL'
/** /**
* *
*/ */
const sendMessage = (text, conversationId) => (dispatch, getState) => { export const sendChatMessage = (text = '', chatConversationId) => (dispatch, getState) => {
if (!me) return if (!me || !chatConversationId) return
if (text.length === 0) return
// : todo :
// let text = getState().getIn(['chat_messages', 'text'], '')
// let conversationId = getState().getIn(['chat_messags', 'conversation_id'], '')
dispatch(sendMessageRequest()) dispatch(sendMessageRequest())
api(getState).put('/api/v1/messages/chat', { api(getState).post('/api/v1/chat_messages', {
text, text,
conversationId, chat_conversation_id: chatConversationId,
}, { }, {
headers: { // headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), // 'Idempotency-Key': getState().getIn(['chat_compose`', 'idempotencyKey']),
}, // },
}).then((response) => { }).then((response) => {
sendMessageSuccess(response) dispatch(importFetchedChatMessages([response.data]))
dispatch(sendMessageSuccess(response.data, chatConversationId))
}).catch((error) => { }).catch((error) => {
dispatch(sendMessageFail(error)) dispatch(sendMessageFail(error))
}) })
} }
const sendMessageRequest = (text, conversationId) => ({ const sendMessageRequest = () => ({
type: MESSAGE_SEND_REQUEST, type: CHAT_MESSAGES_SEND_REQUEST,
text,
conversationId,
}) })
const sendMessageSuccess = () => ({ const sendMessageSuccess = (chatMessage, chatConversationId) => ({
type: MESSAGE_SEND_SUCCESS, type: CHAT_MESSAGES_SEND_SUCCESS,
chatMessage,
chatConversationId,
}) })
const sendMessageFail = (error) => ({ const sendMessageFail = (error) => ({
type: MESSAGE_SEND_FAIL, type: CHAT_MESSAGES_SEND_FAIL,
error, error,
}) })
/** /**
* *
*/ */
const deleteMessage = (messageId) => (dispatch, getState) => { const deleteMessage = (chatMessageId) => (dispatch, getState) => {
if (!me || !messageId) return if (!me || !chatMessageId) return
// : todo : dispatch(deleteMessageRequest(chatMessageId))
dispatch(sendMessageRequest()) api(getState).delete(`/api/v1/chat_messages/${chatMessageId}`, {}, {
// headers: {
api(getState).delete(`/api/v1/messages/chat/${messageId}`, {}, { // 'Idempotency-Key': getState().getIn(['chat_compose', 'idempotencyKey']),
headers: { // },
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
},
}).then((response) => { }).then((response) => {
sendMessageSuccess(response) deleteMessageSuccess(response)
}).catch((error) => { }).catch((error) => {
dispatch(sendMessageFail(error)) dispatch(deleteMessageFail(error))
}) })
} }
const deleteMessageRequest = (messageId) => ({ const deleteMessageRequest = (chatMessageId) => ({
type: MESSAGE_DELETE_REQUEST, type: CHAT_MESSAGES_DELETE_REQUEST,
messageId, chatMessageId,
}) })
const deleteMessageSuccess = () => ({ const deleteMessageSuccess = () => ({
type: MESSAGE_DELETE_SUCCESS, type: CHAT_MESSAGES_DELETE_SUCCESS,
}) })
const deleteMessageFail = (error) => ({ const deleteMessageFail = (error) => ({
type: MESSAGE_DELETE_FAIL, type: CHAT_MESSAGES_DELETE_FAIL,
error, error,
}) })

View File

@ -0,0 +1,41 @@
import throttle from 'lodash.throttle'
import api, { getLinks } from '../api'
import { importFetchedAccounts } from './importer'
import { me } from '../initial_state'
export const CHAT_CONVERSATION_CREATE_SEARCH_ACCOUNTS_SUCCESS = 'CHAT_CONVERSATION_CREATE_SEARCH_ACCOUNTS_SUCCESS'
export const SET_CHAT_CONVERSATION_SELECTED = 'SET_CHAT_CONVERSATION_SELECTED'
/**
*
*/
export const fetchChatConversationAccountSuggestions = (query) => throttle((dispatch, getState) => {
api(getState).get('/api/v1/accounts/search', {
params: {
q: query,
resolve: false,
limit: 4,
},
}).then((response) => {
dispatch(importFetchedAccounts(response.data))
dispatch(fetchChatConversationAccountSuggestionsSuccess(response.data))
}).catch((error) => {
//
})
}, 200, { leading: true, trailing: true })
const fetchChatConversationAccountSuggestionsSuccess = (accounts) => ({
type: CHAT_CONVERSATION_CREATE_SEARCH_ACCOUNTS_SUCCESS,
accounts,
})
/**
*
*/
export const setChatConversationSelected = (chatConversationId) => (dispatch) => {
dispatch({
type: SET_CHAT_CONVERSATION_SELECTED,
chatConversationId,
})
}

View File

@ -12,6 +12,7 @@ export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'
export const STATUS_IMPORT = 'STATUS_IMPORT' export const STATUS_IMPORT = 'STATUS_IMPORT'
export const STATUSES_IMPORT = 'STATUSES_IMPORT' export const STATUSES_IMPORT = 'STATUSES_IMPORT'
export const POLLS_IMPORT = 'POLLS_IMPORT' export const POLLS_IMPORT = 'POLLS_IMPORT'
export const CHAT_MESSAGES_IMPORT = 'CHAT_MESSAGES_IMPORT'
export const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP' export const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP'
/** /**
@ -48,6 +49,11 @@ export const importPolls = (polls) => ({
polls, polls,
}) })
export const importChatMessages = (chatMessages) => ({
type: CHAT_MESSAGES_IMPORT,
chatMessages,
})
export const importFetchedAccount = (account) => { export const importFetchedAccount = (account) => {
return importFetchedAccounts([account]); return importFetchedAccounts([account]);
} }
@ -113,3 +119,7 @@ export const importErrorWhileFetchingAccountByUsername = (username) => ({
type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP,
username username
}) })
export const importFetchedChatMessages = (chatMessages) => (dispatch, getState) => {
dispatch(importChatMessages(chatMessages))
}

View File

@ -297,7 +297,7 @@ export const fetchListSuggestions = (q) => (dispatch, getState) => {
/** /**
* *
*/ */
export const fetchListSuggestionsReady = (query, accounts) => ({ const fetchListSuggestionsReady = (query, accounts) => ({
type: LIST_EDITOR_SUGGESTIONS_READY, type: LIST_EDITOR_SUGGESTIONS_READY,
query, query,
accounts, accounts,

View File

@ -75,15 +75,18 @@ export const connectUserStream = () => connectTimelineStream('home', 'user')
/** /**
* *
*/ */
export const connectMessageStream = () => { export const connectChatMessagesStream = (accountId) => {
return connectStream(`chat_messages:${accountId}`, null, (dispatch, getState) => {
return connectStream('chat_messages', null, (dispatch, getState) => {
return { return {
onConnect() {}, onConnect() {
onDisconnect() {}, // console.log("chat messages connected")
},
onDisconnect() {
// console.log("chat messages disconnected")
},
onReceive (data) { onReceive (data) {
// // : todo :
console.log("chat messages onReceive:", data)
}, },
} }
}) })

View File

@ -24,10 +24,13 @@ import Avatar from './avatar'
import DisplayName from './display_name' import DisplayName from './display_name'
import Button from './button' import Button from './button'
import Text from './text' import Text from './text'
class Account extends ImmutablePureComponent { class Account extends ImmutablePureComponent {
handleAction = (e) => { handleAction = (e) => {
this.props.onActionClick(this.props.account, e) this.props.onActionClick(this.props.account, e)
e.preventDefault()
return false
} }
handleUnrequest = () => { handleUnrequest = () => {

View File

@ -0,0 +1,61 @@
import React from 'react'
import PropTypes from 'prop-types'
import ImmutablePropTypes from 'react-immutable-proptypes'
import ImmutablePureComponent from 'react-immutable-pure-component'
import Image from './image'
/**
* Renders an avatar component
* @param {list} [props.accounts] - the accounts for images
* @param {number} [props.size=40] - the size of the avatar
*/
class AvatarGroup extends ImmutablePureComponent {
render() {
const { accounts, size } = this.props
return (
<div className={[_s.d].join(' ')}>
{
accounts.map((account) => {
const isPro = account.get('is_pro')
const alt = `${account.get('display_name')} ${isPro ? '(PRO)' : ''}`.trim()
const className = [_s.d, _s.circle, _s.overflowHidden]
if (isPro) {
className.push(_s.boxShadowAvatarPro)
}
const options = {
alt,
className,
src: account.get('avatar_static'),
style: {
width: `${size}px`,
height: `${size}px`,
},
}
return (
<div className={[_s.d].join(' ')}>
<Image {...options} />
</div>
)
})
}
</div>
)
}
}
AvatarGroup.propTypes = {
accounts: ImmutablePropTypes.list,
size: PropTypes.number,
}
AvatarGroup.defaultProps = {
size: 40,
}
export default AvatarGroup

View File

@ -0,0 +1,81 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import ImmutablePropTypes from 'react-immutable-proptypes'
import ImmutablePureComponent from 'react-immutable-pure-component'
import { me } from '../initial_state'
import { CX } from '../constants'
import Icon from './icon'
import Text from './text'
class DisplayNameGroup extends ImmutablePureComponent {
render() {
const {
accounts,
isMultiline,
isLarge,
noHover,
isSmall,
} = this.props
if (!account) return null
const containerClassName = CX({
d: 1,
maxW100PC: 1,
aiCenter: !isMultiline,
flexRow: !isMultiline,
cursorPointer: !noHover,
aiCenter: isCentered,
})
const displayNameClasses = CX({
text: 1,
overflowWrapBreakWord: 1,
whiteSpaceNoWrap: 1,
fw600: 1,
cPrimary: 1,
mr2: 1,
lineHeight125: !isSmall,
fs14PX: isSmall,
fs15PX: !isLarge,
fs24PX: isLarge && !isSmall,
})
const usernameClasses = CX({
text: 1,
displayFlex: 1,
flexNormal: 1,
flexShrink1: 1,
overflowWrapBreakWord: 1,
textOverflowEllipsis: 1,
cSecondary: 1,
fw400: 1,
lineHeight15: isMultiline,
lineHeight125: !isMultiline,
ml5: !isMultiline,
fs14PX: isSmall,
fs15PX: !isLarge,
fs16PX: isLarge && !isSmall,
})
const iconSize =
!!isLarge ? 19 :
!!isComment ? 12 :
!!isSmall ? 14 : 15
return (
<div />
)
}
}
DisplayNameGroup.propTypes = {
accounts: ImmutablePropTypes.map,
isLarge: PropTypes.bool,
isMultiline: PropTypes.bool,
isSmall: PropTypes.bool,
}
export default DisplayNameGroup

View File

@ -3,21 +3,44 @@ import PropTypes from 'prop-types'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { defineMessages, injectIntl } from 'react-intl' import { defineMessages, injectIntl } from 'react-intl'
import { me } from '../initial_state' import { me } from '../initial_state'
import { CX } from '../constants' import { getWindowDimension } from '../utils/is_mobile'
import {
CX,
MODAL_COMPOSE,
BREAKPOINT_EXTRA_SMALL,
} from '../constants'
import { openModal } from '../actions/modal' import { openModal } from '../actions/modal'
import Button from './button' import Button from './button'
const initialState = getWindowDimension()
class FloatingActionButton extends React.PureComponent { class FloatingActionButton extends React.PureComponent {
state = {
width: initialState.width,
}
componentDidMount() {
this.handleResize()
window.addEventListener('resize', this.handleResize, false)
}
handleResize = () => {
const { width } = getWindowDimension()
this.setState({ width })
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize, false)
}
render() { render() {
const { const { intl, onOpenCompose } = this.props
intl, const { width } = this.state
onOpenCompose,
isDesktop,
} = this.props
if (!me) return null if (!me) return null
const isDesktop = width > BREAKPOINT_EXTRA_SMALL
const message = intl.formatMessage(messages.gab) const message = intl.formatMessage(messages.gab)
const containerClasses = CX({ const containerClasses = CX({
@ -56,13 +79,12 @@ const messages = defineMessages({
}) })
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
onOpenCompose: () => dispatch(openModal('COMPOSE')), onOpenCompose: () => dispatch(openModal(MODAL_COMPOSE)),
}) })
FloatingActionButton.propTypes = { FloatingActionButton.propTypes = {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
onOpenCompose: PropTypes.func.isRequired, onOpenCompose: PropTypes.func.isRequired,
isDesktop: PropTypes.bool,
} }
export default injectIntl(connect(null, mapDispatchToProps)(FloatingActionButton)) export default injectIntl(connect(null, mapDispatchToProps)(FloatingActionButton))

View File

@ -0,0 +1,31 @@
import React from 'react'
import PropTypes from 'prop-types'
import ModalLayout from './modal_layout'
import { ChatConversationCreate } from '../../features/ui/util/async_components'
import WrappedBundle from '../../features/ui/util/wrapped_bundle'
class ChatConversationCreateModal extends React.PureComponent {
render() {
const { onClose, chatConversationId } = this.props
return (
<ModalLayout
title='New Conversation'
width={440}
onClose={onClose}
noPadding
>
<WrappedBundle component={ChatConversationCreate} componentParams={{ chatConversationId, onCloseModal: onClose }} />
</ModalLayout>
)
}
}
ChatConversationCreateModal.propTypes = {
onClose: PropTypes.func.isRequired,
chatConversationId: PropTypes.string,
}
export default ChatConversationCreateModal

View File

@ -0,0 +1,40 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { deleteChatConversation } from '../../actions/chat_conversations'
import ConfirmationModal from './confirmation_modal'
class ChatConversationDeleteModal extends React.PureComponent {
handleClick = () => {
this.props.onConfirm(this.props.chatConversationId)
}
render() {
const { onClose } = this.props
return (
<ConfirmationModal
title='Delete Conversation'
message='Are you sure you want to delete this chat conversation? The messages will not be deleted and you the other participant can still view messages.'
confirm='Delete'
onConfirm={this.handleClick}
onClose={onClose}
/>
)
}
}
const mapDispatchToProps = (dispatch) => ({
onDeleteChatConversation: (chatConversationId) => {
dispatch(deleteChatConversation(chatConversationId))
},
})
ChatConversationDeleteModal.propTypes = {
chatConversationId: PropTypes.string.isRequired,
onDeleteChatConversation: PropTypes.func.isRequired,
}
export default connect(null, mapDispatchToProps)(ChatConversationDeleteModal)

View File

@ -10,6 +10,8 @@ import LoadingModal from './loading_modal'
import { import {
MODAL_BLOCK_ACCOUNT, MODAL_BLOCK_ACCOUNT,
MODAL_BOOST, MODAL_BOOST,
MODAL_CHAT_CONVERSATION_CREATE,
MODAL_CHAT_CONVERSATION_DELETE,
MODAL_COMMUNITY_TIMELINE_SETTINGS, MODAL_COMMUNITY_TIMELINE_SETTINGS,
MODAL_COMPOSE, MODAL_COMPOSE,
MODAL_CONFIRM, MODAL_CONFIRM,
@ -42,6 +44,8 @@ import {
import { import {
BlockAccountModal, BlockAccountModal,
BoostModal, BoostModal,
ChatConversationCreateModal,
ChatConversationDeleteModal,
CommunityTimelineSettingsModal, CommunityTimelineSettingsModal,
ComposeModal, ComposeModal,
ConfirmationModal, ConfirmationModal,
@ -74,37 +78,40 @@ import {
VideoModal, VideoModal,
} from '../../features/ui/util/async_components' } from '../../features/ui/util/async_components'
const MODAL_COMPONENTS = {} const MODAL_COMPONENTS = {
MODAL_COMPONENTS[MODAL_BLOCK_ACCOUNT] = BlockAccountModal [MODAL_BLOCK_ACCOUNT]: BlockAccountModal,
MODAL_COMPONENTS[MODAL_BOOST] = BoostModal [MODAL_BOOST]: BoostModal,
MODAL_COMPONENTS[MODAL_COMMUNITY_TIMELINE_SETTINGS] = CommunityTimelineSettingsModal [MODAL_CHAT_CONVERSATION_CREATE]: ChatConversationCreateModal,
MODAL_COMPONENTS[MODAL_COMPOSE] = ComposeModal [MODAL_CHAT_CONVERSATION_DELETE]: ChatConversationDeleteModal,
MODAL_COMPONENTS[MODAL_CONFIRM] = ConfirmationModal [MODAL_COMMUNITY_TIMELINE_SETTINGS]: CommunityTimelineSettingsModal,
MODAL_COMPONENTS[MODAL_DISPLAY_OPTIONS] = DisplayOptionsModal [MODAL_COMPOSE]: ComposeModal,
MODAL_COMPONENTS[MODAL_EDIT_SHORTCUTS] = EditShortcutsModal [MODAL_CONFIRM]: ConfirmationModal,
MODAL_COMPONENTS[MODAL_EDIT_PROFILE] = EditProfileModal [MODAL_DISPLAY_OPTIONS]: DisplayOptionsModal,
MODAL_COMPONENTS[MODAL_EMAIL_CONFIRMATION_REMINDER] = EmailConfirmationReminderModal [MODAL_EDIT_SHORTCUTS]: EditShortcutsModal,
MODAL_COMPONENTS[MODAL_GROUP_CREATE] = GroupCreateModal [MODAL_EDIT_PROFILE]: EditProfileModal,
MODAL_COMPONENTS[MODAL_GROUP_DELETE] = GroupDeleteModal [MODAL_EMAIL_CONFIRMATION_REMINDER]: EmailConfirmationReminderModal,
MODAL_COMPONENTS[MODAL_GROUP_PASSWORD] = GroupPasswordModal [MODAL_GROUP_CREATE]: GroupCreateModal,
MODAL_COMPONENTS[MODAL_HASHTAG_TIMELINE_SETTINGS] = HashtagTimelineSettingsModal [MODAL_GROUP_DELETE]: GroupDeleteModal,
MODAL_COMPONENTS[MODAL_HOME_TIMELINE_SETTINGS] = HomeTimelineSettingsModal [MODAL_GROUP_PASSWORD]: GroupPasswordModal,
MODAL_COMPONENTS[MODAL_HOTKEYS] = HotkeysModal [MODAL_HASHTAG_TIMELINE_SETTINGS]: HashtagTimelineSettingsModal,
MODAL_COMPONENTS[MODAL_LIST_ADD_USER] = ListAddUserModal [MODAL_HOME_TIMELINE_SETTINGS]: HomeTimelineSettingsModal,
MODAL_COMPONENTS[MODAL_LIST_CREATE] = ListCreateModal [MODAL_HOTKEYS]: HotkeysModal,
MODAL_COMPONENTS[MODAL_LIST_DELETE] = ListDeleteModal [MODAL_LIST_ADD_USER]: ListAddUserModal,
MODAL_COMPONENTS[MODAL_LIST_EDITOR] = ListEditorModal [MODAL_LIST_CREATE]: ListCreateModal,
MODAL_COMPONENTS[MODAL_LIST_TIMELINE_SETTINGS] = ListTimelineSettingsModal [MODAL_LIST_DELETE]: ListDeleteModal,
MODAL_COMPONENTS[MODAL_MEDIA] = MediaModal [MODAL_LIST_EDITOR]: ListEditorModal,
MODAL_COMPONENTS[MODAL_MUTE] = MuteModal [MODAL_LIST_TIMELINE_SETTINGS]: ListTimelineSettingsModal,
MODAL_COMPONENTS[MODAL_PRO_UPGRADE] = ProUpgradeModal [MODAL_MEDIA]: MediaModal,
MODAL_COMPONENTS[MODAL_REPORT] = ReportModal [MODAL_MUTE]: MuteModal,
MODAL_COMPONENTS[MODAL_STATUS_LIKES] = StatusLikesModal [MODAL_PRO_UPGRADE]: ProUpgradeModal,
MODAL_COMPONENTS[MODAL_STATUS_REPOSTS] = StatusRepostsModal [MODAL_REPORT]: ReportModal,
MODAL_COMPONENTS[MODAL_STATUS_REVISIONS] = StatusRevisionsModal [MODAL_STATUS_LIKES]: StatusLikesModal,
MODAL_COMPONENTS[MODAL_UNAUTHORIZED] = UnauthorizedModal [MODAL_STATUS_REPOSTS]: StatusRepostsModal,
MODAL_COMPONENTS[MODAL_UNFOLLOW] = UnfollowModal [MODAL_STATUS_REVISIONS]: StatusRevisionsModal,
MODAL_COMPONENTS[MODAL_VIDEO] = VideoModal [MODAL_UNAUTHORIZED]: UnauthorizedModal,
[MODAL_UNFOLLOW]: UnfollowModal,
[MODAL_VIDEO]: VideoModal,
}
const CENTERED_XS_MODALS = [ const CENTERED_XS_MODALS = [
MODAL_BLOCK_ACCOUNT, MODAL_BLOCK_ACCOUNT,

View File

@ -0,0 +1,31 @@
import React from 'react'
import PropTypes from 'prop-types'
import { getRandomInt } from '../../utils/numbers'
import PlaceholderLayout from './placeholder_layout'
export default class ChatMessagePlaceholder extends React.PureComponent {
render() {
const alt = getRandomInt(0, 1) === 1
const width = getRandomInt(120, 240)
const height = getRandomInt(40, 110)
if (alt) {
return (
<PlaceholderLayout viewBox='0 0 400 110' preserveAspectRatio='xMaxYMin meet'>
<rect x='80' y='0' rx='20' ry='20' width='260' height={height} />
<circle cx='380' cy='20' r='20' />
</PlaceholderLayout>
)
}
return (
<PlaceholderLayout viewBox='0 0 400 110' preserveAspectRatio='xMinYMax meet'>
<circle cx='20' cy='20' r='20' />
<rect x='60' y='0' rx='20' ry='20' width={width} height={height} />
</PlaceholderLayout>
)
}
}

View File

@ -12,6 +12,7 @@ class PlaceholderLayout extends React.PureComponent {
intl, intl,
theme, theme,
viewBox, viewBox,
preserveAspectRatio,
} = this.props } = this.props
const isLight = ['light', 'white', ''].indexOf(theme) > -1 const isLight = ['light', 'white', ''].indexOf(theme) > -1
@ -26,6 +27,7 @@ class PlaceholderLayout extends React.PureComponent {
viewBox={viewBox} viewBox={viewBox}
backgroundColor={backgroundColor} backgroundColor={backgroundColor}
foregroundColor={foregroundColor} foregroundColor={foregroundColor}
preserveAspectRatio={preserveAspectRatio}
> >
{this.props.children} {this.props.children}
</ContentLoader> </ContentLoader>
@ -47,6 +49,7 @@ PlaceholderLayout.propTypes = {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
theme: PropTypes.string.isRequired, theme: PropTypes.string.isRequired,
viewBox: PropTypes.string.isRequired, viewBox: PropTypes.string.isRequired,
preserveAspectRatio: PropTypes.string,
} }
export default injectIntl(connect(mapStateToProps)(PlaceholderLayout)) export default injectIntl(connect(mapStateToProps)(PlaceholderLayout))

View File

@ -94,30 +94,32 @@ class ScrollableList extends React.PureComponent {
handleScroll = throttle(() => { handleScroll = throttle(() => {
if (this.window) { if (this.window) {
const { scrollTop, scrollHeight } = this.documentElement; const { scrollTop, scrollHeight } = this.documentElement
const { innerHeight } = this.window; const { innerHeight } = this.window
const offset = scrollHeight - scrollTop - innerHeight; const offset = scrollHeight - scrollTop - innerHeight
if (600 > offset && this.props.onLoadMore && this.props.hasMore && !this.props.isLoading && !this.props.disableInfiniteScroll) { if (600 > offset && this.props.onLoadMore && this.props.hasMore && !this.props.isLoading && !this.props.disableInfiniteScroll) {
this.props.onLoadMore(); this.props.onLoadMore()
} }
if (scrollTop < 100 && this.props.onScrollToTop) { if (scrollTop < 100 && this.props.onScrollToTop) {
this.props.onScrollToTop(); this.props.onScrollToTop()
} else if (scrollTop < 100 && this.props.onScrollToBottom) {
this.props.onScrollToBottom()
} else if (this.props.onScroll) { } else if (this.props.onScroll) {
this.props.onScroll(); this.props.onScroll()
} }
if (!this.lastScrollWasSynthetic) { if (!this.lastScrollWasSynthetic) {
// If the last scroll wasn't caused by setScrollTop(), assume it was // If the last scroll wasn't caused by setScrollTop(), assume it was
// intentional and cancel any pending scroll reset on mouse idle // intentional and cancel any pending scroll reset on mouse idle
this.scrollToTopOnMouseIdle = false; this.scrollToTopOnMouseIdle = false
} }
this.lastScrollWasSynthetic = false; this.lastScrollWasSynthetic = false
} }
}, 150, { }, 150, {
trailing: true, trailing: true,
}); })
handleWheel = throttle(() => { handleWheel = throttle(() => {
this.scrollToTopOnMouseIdle = false; this.scrollToTopOnMouseIdle = false;
@ -175,6 +177,11 @@ class ScrollableList extends React.PureComponent {
this.props.onLoadMore(); this.props.onLoadMore();
} }
setRef = (c) => {
this.node = c
if (this.props.scrollRef) this.props.scrollRef(c)
}
render() { render() {
const { const {
children, children,
@ -186,6 +193,8 @@ class ScrollableList extends React.PureComponent {
onLoadMore, onLoadMore,
placeholderComponent: Placeholder, placeholderComponent: Placeholder,
placeholderCount, placeholderCount,
onScrollToTop,
onScrollToBottom,
} = this.props } = this.props
const childrenCount = React.Children.count(children); const childrenCount = React.Children.count(children);
@ -210,8 +219,18 @@ class ScrollableList extends React.PureComponent {
return <ColumnIndicator type='loading' /> return <ColumnIndicator type='loading' />
} else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) { } else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) {
return ( return (
<div onMouseMove={this.handleMouseMove}> <div onMouseMove={this.handleMouseMove} ref={this.setRef}>
<div role='feed'> <div role='feed'>
{
(hasMore && onLoadMore && !isLoading) && !!onScrollToBottom &&
<LoadMore onClick={this.handleLoadMore} />
}
{
isLoading && !!onScrollToBottom &&
<ColumnIndicator type='loading' />
}
{ {
!!this.props.children && !!this.props.children &&
React.Children.map(this.props.children, (child, index) => ( React.Children.map(this.props.children, (child, index) => (
@ -234,12 +253,12 @@ class ScrollableList extends React.PureComponent {
} }
{ {
(hasMore && onLoadMore && !isLoading) && (hasMore && onLoadMore && !isLoading) && !!onScrollToTop &&
<LoadMore onClick={this.handleLoadMore} /> <LoadMore onClick={this.handleLoadMore} />
} }
{ {
isLoading && isLoading && !!onScrollToTop &&
<ColumnIndicator type='loading' /> <ColumnIndicator type='loading' />
} }
</div> </div>
@ -268,9 +287,10 @@ ScrollableList.propTypes = {
]), ]),
children: PropTypes.node, children: PropTypes.node,
onScrollToTop: PropTypes.func, onScrollToTop: PropTypes.func,
onScrollToBottom: PropTypes.func,
onScroll: PropTypes.func, onScroll: PropTypes.func,
placeholderComponent: PropTypes.node, placeholderComponent: PropTypes.node,
placeholderCount: PropTypes.node, placeholderCount: PropTypes.number,
disableInfiniteScroll: PropTypes.bool, disableInfiniteScroll: PropTypes.bool,
} }

View File

@ -1,27 +1,18 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { import { CX } from '../../constants'
CX,
BREAKPOINT_SMALL,
} from '../../constants'
import { import {
me, me,
emailConfirmed, emailConfirmed,
} from '../../initial_state' } from '../../initial_state'
import Button from '../button' import Button from '../button'
import { openModal } from '../../actions/modal'
import Responsive from '../../features/ui/util/responsive_component'
import Heading from '../heading' import Heading from '../heading'
import BackButton from '../back_button' import BackButton from '../back_button'
import Pills from '../pills' import Pills from '../pills'
class SidebarLayout extends React.PureComponent { class SidebarLayout extends React.PureComponent {
handleOpenComposeModal = () => {
this.props.onOpenComposeModal()
}
render() { render() {
const { const {
actions, actions,
@ -91,30 +82,6 @@ class SidebarLayout extends React.PureComponent {
{children} {children}
</nav> </nav>
{
!!me &&
<Responsive min={BREAKPOINT_SMALL}>
<Button
onClick={this.handleOpenComposeModal}
className={_s.py15}
icon='pencil'
iconSize='18px'
iconClassName={[_s.py5, _s.px5].join(' ')}
/>
</Responsive>
}
{
!!me &&
<Responsive max={BREAKPOINT_SMALL}>
<Button
onClick={this.handleOpenComposeModal}
className={_s.py15}
icon='pencil'
/>
</Responsive>
}
</div> </div>
</div> </div>
</div> </div>
@ -124,18 +91,11 @@ class SidebarLayout extends React.PureComponent {
} }
const mapDispatchToProps = (dispatch) => ({
onOpenComposeModal() {
dispatch(openModal('COMPOSE'))
},
})
SidebarLayout.propTypes = { SidebarLayout.propTypes = {
onOpenComposeModal: PropTypes.func.isRequired,
actions: PropTypes.array, actions: PropTypes.array,
tabs: PropTypes.array, tabs: PropTypes.array,
title: PropTypes.string, title: PropTypes.string,
showBackBtn: PropTypes.bool, showBackBtn: PropTypes.bool,
} }
export default connect(null, mapDispatchToProps)(SidebarLayout) export default SidebarLayout

View File

@ -290,7 +290,7 @@ class StatusList extends ImmutablePureComponent {
hasMore={hasMore} hasMore={hasMore}
/> />
<ScrollableList <ScrollableList
ref={this.setRef} scrollRef={this.setRef}
isLoading={isLoading || isRefreshing} isLoading={isLoading || isRefreshing}
showLoading={isRefreshing || (isLoading && statusIds.size === 0)} showLoading={isRefreshing || (isLoading && statusIds.size === 0)}
onLoadMore={onLoadMore && this.handleLoadOlder} onLoadMore={onLoadMore && this.handleLoadOlder}

View File

@ -4,6 +4,7 @@ import { CX } from '../constants'
// Define colors for enumeration for Text component `color` prop // Define colors for enumeration for Text component `color` prop
const COLORS = { const COLORS = {
alt: 'alt',
primary: 'primary', primary: 'primary',
secondary: 'secondary', secondary: 'secondary',
tertiary: 'tertiary', tertiary: 'tertiary',
@ -76,6 +77,7 @@ class Text extends React.PureComponent {
lineHeight15: isBadge, lineHeight15: isBadge,
px5: isBadge, px5: isBadge,
cAlt: color === COLORS.alt,
cPrimary: color === COLORS.primary, cPrimary: color === COLORS.primary,
cSecondary: color === COLORS.secondary, cSecondary: color === COLORS.secondary,
cTertiary: color === COLORS.tertiary, cTertiary: color === COLORS.tertiary,

View File

@ -35,19 +35,31 @@ class Toast extends React.PureComponent {
message, message,
date, date,
to, to,
type,
} = this.props } = this.props
const contentClasses = CX({ const containerClasses = CX({
default: 1, d: 1,
radiusSmall: 1,
w228PX: 1,
mt5: 1, mt5: 1,
pt2: 1, mb5: 1,
maxWidth240PX: 1, px15: 1,
pt10: 1,
pb15: !!title,
pb10: !title,
bgToast: 1,
boxShadowToast: 1,
})
const contentClasses = CX({
d: 1,
mt5: !!title,
pt2: !!title,
flexRow: !!image, flexRow: !!image,
}) })
const innerContentClasses = CX({ const innerContentClasses = CX({
default: 1, d: 1,
flexNormal: 1, flexNormal: 1,
pl10: !!image, pl10: !!image,
pt2: !!image, pt2: !!image,
@ -65,19 +77,11 @@ class Toast extends React.PureComponent {
}) })
return ( return (
<div className={[_s.default, _s.radiusSmall, _s.mb5, _s.px15, _s.pt10, _s.pb15, _s.bgPrimary, _s.boxShadowToast].join(' ')}> <div className={containerClasses}>
<div className={[_s.default, _s.flexRow, _s.alignItemsCenter, _s.justifyContentCenter].join(' ')}> <div className={[_s.default, _s.flexRow, _s.alignItemsCenter, _s.justifyContentCenter].join(' ')}>
<Text size='media' weight='medium' className={[_s.mr15, _s.minWidth160PX].join(' ')}> <Text size='medium' color='alt' weight='bold'>
{title} {title}
</Text> </Text>
<Button
backgroundColor='secondary'
color='primary'
icon='close'
iconSize='6px'
onClick={this.handleOnDismiss}
className={[_s.mlAuto, _s.px10].join(' ')}
/>
</div> </div>
<div className={contentClasses}> <div className={contentClasses}>
{ {
@ -90,12 +94,16 @@ class Toast extends React.PureComponent {
/> />
} }
<div className={innerContentClasses}> <div className={innerContentClasses}>
<Text size='small'> <Text size='small' color='alt'>
{message} {message}
</Text> </Text>
{ {
date && date &&
<Text color='secondary' size='extraSmall' className={dateClasses}> <Text color='tertiary' size='extraSmall' className={dateClasses}>
{
!image &&
<Text size='small' color='tertiary' className={[_s.ml5, _s.mr5].join(' ')}>·</Text>
}
<RelativeTimestamp timestamp={date} /> <RelativeTimestamp timestamp={date} />
</Text> </Text>
} }
@ -116,10 +124,6 @@ Toast.propTypes = {
onDismiss: PropTypes.func.isRequired, onDismiss: PropTypes.func.isRequired,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
to: PropTypes.string, to: PropTypes.string,
type: PropTypes.oneOf([
TOAST_TYPE_ERROR,
TOAST_TYPE_SUCCESS,
]).isRequired,
} }
export default Toast export default Toast

View File

@ -43,6 +43,8 @@ export const POPOVER_VIDEO_STATS = 'VIDEO_STATS'
export const MODAL_BLOCK_ACCOUNT = 'BLOCK_ACCOUNT' export const MODAL_BLOCK_ACCOUNT = 'BLOCK_ACCOUNT'
export const MODAL_BOOST = 'BOOST' export const MODAL_BOOST = 'BOOST'
export const MODAL_CHAT_CONVERSATION_CREATE = 'CHAT_CONVERSATION_CREATE'
export const MODAL_CHAT_CONVERSATION_DELETE = 'CHAT_CONVERSATION_DELETE'
export const MODAL_COMMUNITY_TIMELINE_SETTINGS = 'COMMUNITY_TIMELINE_SETTINGS' export const MODAL_COMMUNITY_TIMELINE_SETTINGS = 'COMMUNITY_TIMELINE_SETTINGS'
export const MODAL_COMPOSE = 'COMPOSE' export const MODAL_COMPOSE = 'COMPOSE'
export const MODAL_CONFIRM = 'CONFIRM' export const MODAL_CONFIRM = 'CONFIRM'

View File

@ -16,6 +16,7 @@ import { MIN_ACCOUNT_CREATED_AT_ONBOARDING } from '../constants'
import { import {
connectUserStream, connectUserStream,
connectStatusUpdateStream, connectStatusUpdateStream,
connectChatMessagesStream,
} from '../actions/streaming' } from '../actions/streaming'
import { getLocale } from '../locales' import { getLocale } from '../locales'
import initialState from '../initial_state' import initialState from '../initial_state'
@ -95,6 +96,7 @@ export default class GabSocial extends React.PureComponent {
if (!!me) { if (!!me) {
this.disconnect = store.dispatch(connectUserStream()) this.disconnect = store.dispatch(connectUserStream())
store.dispatch(connectStatusUpdateStream()) store.dispatch(connectStatusUpdateStream())
store.dispatch(connectChatMessagesStream(me))
} }
console.log('%cGab Social ', [ console.log('%cGab Social ', [

View File

@ -13,11 +13,58 @@ class ToastsContainer extends React.PureComponent {
} }
render() { render() {
const { notifications } = this.props // const { notifications } = this.props
const notifications = [
{
key: '1',
title: 'Error',
to: 'to',
image: 'https://gab.com/media/user/58077e8a49705.jpg',
message: 'Unable to follow @andrew',
date: new Date(),
isImageAccount: true,
},
{
key: '2',
title: 'Success',
to: 'to',
image: 'https://gab.com/media/user/58077e8a49705.jpg',
message: 'Your gab was posted. Click here to view',
date: new Date(),
isImageAccount: false,
},
{
key: '3',
title: '',
to: 'to',
image: 'https://gab.com/media/user/58077e8a49705.jpg',
message: 'Unable to follow @andrew',
date: new Date(),
isImageAccount: true,
},
{
key: '4',
title: '',
to: 'to',
image: 'https://gab.com/media/user/58077e8a49705.jpg',
message: 'Your gab was posted. Click here to view',
date: new Date(),
isImageAccount: false,
},
{
key: '5',
title: '',
to: 'to',
message: 'Your gab was deleted',
date: new Date(),
isImageAccount: false,
},
]
const hasNotifications = Array.isArray(notifications) && notifications.length > 0 const hasNotifications = Array.isArray(notifications) && notifications.length > 0
const containerClasses = CX({ const containerClasses = CX({
default: 1, d: 1,
z5: 1, z5: 1,
posFixed: 1, posFixed: 1,
bottom0: 1, bottom0: 1,
@ -29,16 +76,16 @@ class ToastsContainer extends React.PureComponent {
displayNone: !hasNotifications displayNone: !hasNotifications
}) })
return ( return (
<div className={containerClasses}> <div className={containerClasses}>
{ {
hasNotifications && notifications.map((notification) => ( !hasNotifications && notifications.map((notification) => (
<Toast <Toast
onDismiss={this.handleOnDismiss} onDismiss={this.handleOnDismiss}
key={notification.key} key={notification.key}
id={notification.key} id={notification.key}
title={notification.title} title={notification.title}
type={notification.type}
to={notification.to} to={notification.to}
image={notification.image} image={notification.image}
message={notification.message} message={notification.message}

View File

@ -0,0 +1,93 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { defineMessages, injectIntl } from 'react-intl'
import ImmutablePureComponent from 'react-immutable-pure-component'
import ImmutablePropTypes from 'react-immutable-proptypes'
import debounce from 'lodash.debounce'
import { me } from '../initial_state'
import { fetchBlocks, expandBlocks } from '../actions/blocks'
import Account from '../components/account'
import Block from '../components/block'
import BlockHeading from '../components/block_heading'
import Divider from '../components/divider'
import ScrollableList from '../components/scrollable_list'
import AccountPlaceholder from '../components/placeholder/account_placeholder'
class MessagesBlockedAccounts extends ImmutablePureComponent {
componentDidMount() {
this.props.onFetchBlocks()
}
handleLoadMore = debounce(() => {
this.props.onExpandBlocks()
}, 300, { leading: true })
render() {
const {
intl,
accountIds,
hasMore,
isLoading,
} = this.props
const emptyMessage = intl.formatMessage(messages.empty)
return (
<div className={[_s.d, _s.w100PC, _s.boxShadowNone].join(' ')}>
<div className={[_s.d, _s.h60PX, _s.w100PC, _s.px10, _s.py10, _s.borderBottom1PX, _s.borderColorSecondary].join(' ')}>
<BlockHeading title={intl.formatMessage(messages.blocks)} />
</div>
<ScrollableList
scrollKey='blocked_accounts'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
showLoading={isLoading}
emptyMessage={emptyMessage}
placeholderComponent={AccountPlaceholder}
placeholderCount={3}
>
{
accountIds && accountIds.map((id) => (
<Account
key={`blocked-accounts-${id}`}
id={id}
compact
/>
))
}
</ScrollableList>
</div>
)
}
}
const messages = defineMessages({
empty: { id: 'empty_column.blocks', defaultMessage: 'You haven\'t blocked any users yet.' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
})
const mapStateToProps = (state) => ({
accountIds: state.getIn(['user_lists', 'blocks', me, 'items']),
hasMore: !!state.getIn(['user_lists', 'blocks', me, 'next']),
isLoading: state.getIn(['user_lists', 'blocks', me, 'isLoading']),
})
const mapDispatchToProps = (dispatch) => ({
onFetchBlocks: () => dispatch(fetchBlocks()),
onExpandBlocks: () => dispatch(expandBlocks()),
})
MessagesBlockedAccounts.propTypes = {
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
intl: PropTypes.object.isRequired,
isLoading: PropTypes.bool,
onExpandBlocks: PropTypes.func.isRequired,
onFetchBlocks: PropTypes.func.isRequired,
}
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(MessagesBlockedAccounts))

View File

@ -0,0 +1,86 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { fetchChatConversationAccountSuggestions } from '../actions/chats'
import { createChatConversation } from '../actions/chat_conversations'
import Account from '../components/account'
import Button from '../components/button'
import Input from '../components/input'
import Form from '../components/form'
import Text from '../components/text'
class ChatConversationCreate extends React.PureComponent {
state = {
query: '',
}
onChange = (query) => {
this.setState({ query })
this.props.onChange(query)
}
handleOnCreateChatConversation = (accountId) => {
console.log("handleOnCreateChatConversation:", accountId)
this.props.onCreateChatConversation(accountId)
}
render() {
const { query, suggestionsIds } = this.props
return (
<Form>
<div className={[_s.d, _s.px15, _s.pt10].join(' ')}>
<Input
title='Search for a user'
value={query}
onChange={this.onChange}
/>
</div>
<div className={[_s.d, _s.pt10].join(' ')}>
<div className={[_s.d].join(' ')}>
<Text weight='bold' size='small' color='secondary' className={[_s.d, _s.px15, _s.ml15, _s.mt5, _s.mb15].join(' ')}>
Search results ({suggestionsIds.size})
</Text>
{
suggestionsIds &&
suggestionsIds.map((accountId) => (
<Account
compact
key={`remove-from-list-${accountId}`}
id={accountId}
onActionClick={() => this.handleOnCreateChatConversation(accountId)}
actionIcon='add'
/>
))
}
</div>
</div>
</Form>
)
}
}
const mapStateToProps = (state) => ({
suggestionsIds: state.getIn(['chats', 'createChatConversationSuggestionIds']),
})
const mapDispatchToProps = (dispatch) => ({
onChange: (value) => {
console.log("value", value)
dispatch(fetchChatConversationAccountSuggestions(value))
},
onCreateChatConversation: (accountId) => {
dispatch(createChatConversation(accountId))
},
})
ChatConversationCreate.propTypes = {
onChange: PropTypes.func.isRequired,
onCreateChatConversation: PropTypes.func.isRequired,
isModal: PropTypes.bool,
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationCreate)

View File

@ -0,0 +1,79 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { injectIntl, FormattedMessage } from 'react-intl'
import ImmutablePureComponent from 'react-immutable-pure-component'
import ImmutablePropTypes from 'react-immutable-proptypes'
import debounce from 'lodash.debounce'
import { me } from '../initial_state'
import { fetchMutes, expandMutes } from '../actions/mutes'
import Account from '../components/account'
import Block from '../components/block'
import BlockHeading from '../components/block_heading'
import ScrollableList from '../components/scrollable_list'
class MessagesMutedAccounts extends ImmutablePureComponent {
componentWillMount() {
this.props.onFetchMutes()
}
handleLoadMore = debounce(() => {
this.props.onExpandMutes()
}, 300, { leading: true })
render() {
const {
accountIds,
hasMore,
isLoading,
} = this.props
return (
<div className={[_s.d, _s.w100PC, _s.boxShadowNone].join(' ')}>
<div className={[_s.d, _s.h60PX, _s.w100PC, _s.px10, _s.py10, _s.borderBottom1PX, _s.borderColorSecondary].join(' ')}>
<BlockHeading title={<FormattedMessage id='navigation_bar.mutes' defaultMessage='Muted users' />} />
</div>
<ScrollableList
scrollKey='mutes'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={<FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />}
>
{
accountIds && accountIds.map((id) =>
<Account
key={`mutes-${id}`}
id={id}
compact
/>
)
}
</ScrollableList>
</div>
)
}
}
const mapStateToProps = (state) => ({
accountIds: state.getIn(['user_lists', 'mutes', me, 'items']),
hasMore: !!state.getIn(['user_lists', 'mutes', me, 'next']),
isLoading: state.getIn(['user_lists', 'mutes', me, 'isLoading']),
})
const mapDispatchToProps = (dispatch) => ({
onFetchMutes: () => dispatch(fetchMutes()),
onExpandMutes: () => dispatch(expandMutes()),
})
MessagesMutedAccounts.propTypes = {
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
onExpandMutes: PropTypes.func.isRequired,
onFetchMutes: PropTypes.func.isRequired,
}
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(MessagesMutedAccounts))

View File

@ -0,0 +1,21 @@
import React from 'react'
import PropTypes from 'prop-types'
import BlockHeading from '../components/block_heading'
import ChatConversationsList from './messages/components/chat_conversations_list'
class ChatConversationRequests extends React.PureComponent {
render() {
return (
<div className={[_s.d, _s.w100PC, _s.boxShadowNone].join(' ')}>
<div className={[_s.d, _s.h60PX, _s.w100PC, _s.px10, _s.py10, _s.borderBottom1PX, _s.borderColorSecondary].join(' ')}>
<BlockHeading title={'Message Requests'} />
</div>
<ChatConversationsList source='requested' />
</div>
)
}
}
export default ChatConversationRequests

View File

@ -157,7 +157,7 @@ class PollFormOption extends ImmutablePureComponent {
<Input <Input
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
maxLength={25} maxLength={160}
value={title} value={title}
onChange={this.handleOptionTitleChange} onChange={this.handleOptionTitleChange}
/> />

View File

@ -0,0 +1,47 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { openModal } from '../../../actions/modal'
import { MODAL_CHAT_CONVERSATION_CREATE } from '../../../constants'
import Text from '../../../components/text'
import Button from '../../../components/button'
class ChatEmptyMessageBlock extends React.PureComponent {
handleOnCreateNewChat = () => {
this.props.onOpenChatConversationCreateModal()
}
render () {
return (
<div className={[_s.d, _s.bgPrimary, _s.h100PC, _s.w100PC].join(' ')}>
<div className={[_s.d, _s.w100PC, _s.h100PC, _s.aiCenter, _s.jcCenter].join(' ')}>
<Text weight='bold' size='extraLarge'>
You dont have a message selected
</Text>
<Text size='medium' color='secondary' className={_s.py10}>
Choose one from your existing messages, or start a new one.
</Text>
<Button className={_s.mt10} onClick={this.handleOnCreateNewChat}>
<Text color='inherit' weight='bold' className={_s.px15}>
New Message
</Text>
</Button>
</div>
</div>
)
}
}
const mapDispatchToProps = (dispatch) => ({
onOpenChatConversationCreateModal() {
dispatch(openModal(MODAL_CHAT_CONVERSATION_CREATE))
}
})
ChatEmptyMessageBlock.propTypes = {
onOpenChatConversationCreateModal: PropTypes.func.isRequired,
}
export default connect(null, mapDispatchToProps)(ChatEmptyMessageBlock)

View File

@ -0,0 +1,98 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import debounce from 'lodash.debounce'
import ImmutablePureComponent from 'react-immutable-pure-component'
import ImmutablePropTypes from 'react-immutable-proptypes'
import {
fetchChatConversations,
expandChatConversations,
fetchChatConversationRequested,
expandChatConversationRequested,
} from '../../../actions/chat_conversations'
import AccountPlaceholder from '../../../components/placeholder/account_placeholder'
import ChatConversationsListItem from './chat_conversations_list_item'
import ScrollableList from '../../../components/scrollable_list'
class ChatConversationsList extends ImmutablePureComponent {
componentDidMount() {
console.log("componentDidMount:", this.props.source)
this.props.onFetchChatConversations(this.props.source)
}
handleLoadMore = debounce(() => {
this.props.onExpandChatConversations(this.props.source)
}, 300, { leading: true })
render() {
const {
hasMore,
isLoading,
source,
chatConversationIds,
} = this.props
console.log("---source:", source, chatConversationIds)
return (
<div className={[_s.d, _s.w100PC, _s.overflowHidden, _s.boxShadowNone].join(' ')}>
<ScrollableList
scrollKey='chat-conversations'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
showLoading={isLoading}
placeholderComponent={AccountPlaceholder}
placeholderCount={3}
emptyMessage='Empty'
>
{
!!chatConversationIds && chatConversationIds.map((chatConversationId, i) => (
<ChatConversationsListItem
key={`chat-conversation-${i}`}
chatConversationId={chatConversationId}
source={source}
/>
))
}
</ScrollableList>
</div>
)
}
}
const mapStateToProps = (state, { source }) => ({
chatConversationIds: state.getIn(['chat_conversation_lists', source, 'items']),
hasMore: !!state.getIn(['chat_conversation_lists', source, 'next']),
isLoading: state.getIn(['chat_conversation_lists', source, 'isLoading']),
})
const mapDispatchToProps = (dispatch) => ({
onFetchChatConversations(source) {
if (source ==='approved') {
dispatch(fetchChatConversations())
} else if (source ==='requested') {
dispatch(fetchChatConversationRequested())
}
},
onExpandChatConversations(source) {
if (source ==='approved') {
dispatch(expandChatConversations())
} else if (source ==='requested') {
dispatch(expandChatConversationRequested())
}
},
})
ChatConversationsList.propTypes = {
chatConversationIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
onFetchChatConversations: PropTypes.func.isRequired,
onExpandChatConversations: PropTypes.func.isRequired,
source: PropTypes.string.isRequired,
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationsList)

View File

@ -0,0 +1,116 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import ImmutablePureComponent from 'react-immutable-pure-component'
import ImmutablePropTypes from 'react-immutable-proptypes'
import { makeGetChatConversation } from '../../../selectors'
import { setChatConversationSelected } from '../../../actions/chats'
import { CX } from '../../../constants'
import Input from '../../../components/input'
import DisplayNameGroup from '../../../components/display_name_group'
import DisplayName from '../../../components/display_name'
import AvatarGroup from '../../../components/avatar_group'
import Text from '../../../components/text'
import RelativeTimestamp from '../../../components/relative_timestamp'
class ChatConversationsListItem extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
}
handleOnClick = () => {
const { chatConversationId } = this.props
this.props.onSetChatConversationSelected(chatConversationId)
this.context.router.history.push(`/messages/${chatConversationId}`)
}
render() {
const {
selected,
selectedId,
chatConversation,
chatConversationId,
} = this.props
if (!chatConversation) return <div/>
const containerClasses = CX({
d: 1,
w100PC: 1,
bgTransparent: 1,
bgSubtle_onHover: 1,
borderBottom1PX: 1,
borderColorSecondary: 1,
noUnderline: 1,
outlineNone: 1,
cursorPointer: 1,
})
const innerContainerClasses = CX({
d: 1,
flexRow: 1,
aiStart: 1,
aiCenter: 0,
px15: 1,
py15: 1,
borderRight4PX: selected,
borderColorBrand: selected,
})
const avatarSize = 46
const otherAccounts = chatConversation.get('other_accounts')
const lastMessage = chatConversation.get('last_chat_message', null)
const content = { __html: !!lastMessage ? lastMessage.get('text', '') : '' }
const date = !!lastMessage ? lastMessage.get('created_at') : chatConversation.get('created_at')
return (
<button
className={containerClasses}
onClick={this.handleOnClick}
>
<div className={innerContainerClasses}>
<AvatarGroup accounts={otherAccounts} size={avatarSize} noHover />
<div className={[_s.d, _s.pl10, _s.overflowHidden, _s.flexNormal].join(' ')}>
<div className={[_s.d, _s.flexRow, _s.aiCenter].join(' ')}>
<div className={[_s.d, _s.pt2, _s.pr5, _s.noUnderline, _s.overflowHidden, _s.flexNormal, _s.flexRow, _s.aiStart, _s.aiCenter].join(' ')}>
<div className={_s.maxW100PC42PX}>
<DisplayName account={otherAccounts.get(0)} noHover />
</div>
<Text size='extraSmall' color='secondary' className={_s.mlAuto}>
<RelativeTimestamp timestamp={date} />
</Text>
</div>
</div>
<div className={[_s.py5, _s.dangerousContent, _s.textAlignLeft].join(' ')} dangerouslySetInnerHTML={content} />
</div>
</div>
</button>
)
}
}
const mapStateToProps = (state, { chatConversationId }) => ({
chatConversation: makeGetChatConversation()(state, { id: chatConversationId }),
selectedId: state.getIn(['chats', 'selectedChatConversationId'], null),
selected: state.getIn(['chats', 'selectedChatConversationId'], null) === chatConversationId,
})
const mapDispatchToProps = (dispatch) => ({
onSetChatConversationSelected: (chatConversationId) => {
dispatch(setChatConversationSelected(chatConversationId))
},
})
ChatConversationsListItem.propTypes = {
chatConversationId: PropTypes.string.isRequired,
chatConversation: ImmutablePropTypes.map,
onSetChatConversationSelected: PropTypes.func.isRequired,
selected: PropTypes.bool.isRequired,
source: PropTypes.string.isRequired,
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationsListItem)

View File

@ -0,0 +1,57 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { NavLink } from 'react-router-dom'
import { shortNumberFormat } from '../../../utils/numbers'
import { fetchChatConversationRequestedCount } from '../../../actions/chat_conversations'
import Text from '../../../components/text'
import Icon from '../../../components/icon'
class ChatConversationRequestsListItem extends React.PureComponent {
componentDidMount() {
this.props.onFetchChatConversationRequestedCount()
}
render() {
const { requestCount } = this.props
if (!requestCount || requestCount < 1) return null
return (
<NavLink
className={[_s.d, _s.w100PC, _s.bgTransparent, _s.bgSubtle_onHover, _s.borderBottom1PX, _s.borderColorSecondary, _s.noUnderline, _s.outlineNone, _s.cursorPointer].join(' ')}
to='/messages/requests'
>
<div className={[_s.d, _s.px15, _s.py15, _s.aiCenter, _s.flexRow].join(' ')}>
<Text size='medium'>Message Requests</Text>
<Text size='medium' className={[_s.mlAuto, _s.mr15].join(' ')}>
{shortNumberFormat(requestCount)}
</Text>
<Icon id='angle-right' size='10px' className={_s.cPrimary} />
</div>
</NavLink>
)
}
}
const mapStateToProps = (state) => ({
requestCount: state.getIn(['chats', 'chatConversationRequestCount'], 0),
})
const mapDispatchToProps = (dispatch) => ({
onFetchChatConversationRequestedCount: () => dispatch(fetchChatConversationRequestedCount()),
})
ChatConversationRequestsListItem.propTypes = {
requestCount: PropTypes.number,
onFetchChatConversationRequestedCount: PropTypes.func.isRequired,
}
ChatConversationRequestsListItem.defaultProps = {
requestCount: 0,
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationRequestsListItem)

View File

@ -1,11 +1,8 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { defineMessages, injectIntl } from 'react-intl'
import ImmutablePureComponent from 'react-immutable-pure-component'
import ImmutablePropTypes from 'react-immutable-proptypes'
import Input from '../../../components/input' import Input from '../../../components/input'
class MessagesSearch extends ImmutablePureComponent { class ChatConversationsSearch extends React.PureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
@ -17,7 +14,7 @@ class MessagesSearch extends ImmutablePureComponent {
render() { render() {
const { const {
intl, children
} = this.props } = this.props
return ( return (
@ -34,12 +31,8 @@ class MessagesSearch extends ImmutablePureComponent {
} }
const messages = defineMessages({ ChatConversationsSearch.propTypes = {
placeholder: { id: 'compose_form.placeholder', defaultMessage: "What's on your mind?" }, //
})
MessagesSearch.propTypes = {
intl: PropTypes.object.isRequired,
} }
export default injectIntl(MessagesSearch) export default ChatConversationsSearch

View File

@ -0,0 +1,140 @@
import React from 'react'
import PropTypes from 'prop-types'
import ImmutablePureComponent from 'react-immutable-pure-component'
import ImmutablePropTypes from 'react-immutable-proptypes'
import { connect } from 'react-redux'
import Textarea from 'react-textarea-autosize'
import { openModal } from '../../../actions/modal'
import { sendChatMessage } from '../../../actions/chat_messages'
import { CX } from '../../../constants'
import Button from '../../../components/button'
import Input from '../../../components/input'
class ChatMessagesComposeForm extends React.PureComponent {
state = {
focused: false,
value: '',
}
handleOnSendChatMessage = () => {
this.props.onSendChatMessage(this.state.value, this.props.chatConversationId)
this.setState({ value: '' })
}
onChange = (e) => {
this.setState({ value: e.target.value })
}
onBlur = () => {
this.setState({ focused: false });
}
onFocus = () => {
this.setState({ focused: true });
}
onKeyDown = (e) => {
const { disabled } = this.props;
if (disabled) {
e.preventDefault();
return;
}
// Ignore key events during text composition
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
if (e.which === 229 || e.isComposing) return;
switch (e.key) {
case 'Escape':
document.querySelector('#gabsocial').focus()
break;
case 'Enter':
case 'Tab':
//
break;
}
// if (e.defaultPrevented || !this.props.onKeyDown) return;
}
setTextbox = (c) => {
this.textbox = c
}
render () {
const { chatConversationId } = this.props
const { value } = this.state
const disabled = false
const textareaContainerClasses = CX({
d: 1,
maxW100PC: 1,
flexGrow1: 1,
jcCenter: 1,
py5: 1,
})
const textareaClasses = CX({
d: 1,
font: 1,
wrap: 1,
resizeNone: 1,
bgTransparent: 1,
outlineNone: 1,
lineHeight125: 1,
cPrimary: 1,
px10: 1,
fs14PX: 1,
maxH200PX: 1,
borderColorSecondary: 1,
border1PX: 1,
radiusSmall: 1,
py10: 1,
})
return (
<div className={[_s.d, _s.posAbs, _s.bottom0, _s.left0, _s.right0, _s.flexRow, _s.aiCenter, _s.minH58PX, _s.bgPrimary, _s.w100PC, _s.borderTop1PX, _s.borderColorSecondary, _s.px15, _s.py5].join(' ')}>
<div className={[_s.d, _s.pr15, _s.flexGrow1].join(' ')}>
<Textarea
id='chat-message-compose-input'
inputRef={this.setTextbox}
className={textareaClasses}
disabled={disabled}
placeholder='Type a new message...'
autoFocus={false}
value={value}
onChange={this.onChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
aria-autocomplete='list'
/>
</div>
<div className={[_s.d, _s.h100PC, _s.mtAuto, _s.pb5].join(' ')}>
<Button
disabled={disabled}
onClick={this.handleOnSendChatMessage}
>
Send
</Button>
</div>
</div>
)
}
}
const mapDispatchToProps = (dispatch) => ({
onSendChatMessage(text, chatConversationId) {
dispatch(sendChatMessage(text, chatConversationId))
},
})
ChatMessagesComposeForm.propTypes = {
chatConversationId: PropTypes.string,
onSendMessage: PropTypes.func.isRequired,
}
export default connect(null, mapDispatchToProps)(ChatMessagesComposeForm)

View File

@ -0,0 +1,84 @@
import React from 'react'
import PropTypes from 'prop-types'
import ImmutablePureComponent from 'react-immutable-pure-component'
import ImmutablePropTypes from 'react-immutable-proptypes'
import { connect } from 'react-redux'
import { makeGetChatConversation } from '../../../selectors'
import { openModal } from '../../../actions/modal'
import { approveChatConversationRequest } from '../../../actions/chat_conversations'
import { MODAL_CHAT_CONVERSATION_CREATE } from '../../../constants'
import Button from '../../../components/button'
import AvatarGroup from '../../../components/avatar_group'
import DisplayName from '../../../components/display_name'
import Text from '../../../components/text'
class ChatMessageHeader extends React.PureComponent {
handleOnApproveMessageRequest = () => {
this.props.onApproveChatConversationRequest(this.props.chatConversationId)
}
render () {
const { chatConversation } = this.props
const isChatConversationRequest = !!chatConversation ? !chatConversation.get('is_approved') : false
const otherAccounts = !!chatConversation ? chatConversation.get('other_accounts') : null
return (
<div className={[_s.d, _s.posAbs, _s.top0, _s.left0, _s.right0, _s.flexRow, _s.aiCenter, _s.h60PX, _s.w100PC, _s.borderBottom1PX, _s.borderColorSecondary, _s.bgPrimary, _s.px15, _s.py5].join(' ')}>
{
!!otherAccounts &&
<React.Fragment>
<AvatarGroup accounts={otherAccounts} size={34} noHover />
<div className={[_s.d, _s.pl10, _s.maxW100PC86PX, _s.overflowHidden].join(' ')}>
<DisplayName account={otherAccounts.get(0)} isMultiline />
</div>
</React.Fragment>
}
<Button
isNarrow
onClick={undefined}
color='primary'
backgroundColor='secondary'
className={[_s.mlAuto, _s.px5].join(' ')}
icon='ellipsis'
iconSize='18px'
/>
{
isChatConversationRequest &&
<Button
isNarrow
onClick={this.handleOnApproveMessageRequest}
className={_s.ml10}
>
<Text>
Approve Message Request
</Text>
</Button>
}
</div>
)
}
}
const mapStateToProps = (state, { chatConversationId }) => ({
chatConversation: makeGetChatConversation()(state, { id: chatConversationId }),
})
const mapDispatchToProps = (dispatch) => ({
onOpenChatConversationCreateModal() {
dispatch(openModal(MODAL_CHAT_CONVERSATION_CREATE))
},
onApproveChatConversationRequest(chatConversationId) {
dispatch(approveChatConversationRequest(chatConversationId))
}
})
ChatMessageHeader.propTypes = {
onOpenChatConversationCreateModal: PropTypes.func.isRequired,
chatConversationId: PropTypes.string,
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatMessageHeader)

View File

@ -0,0 +1,179 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import moment from 'moment-mini'
import ImmutablePureComponent from 'react-immutable-pure-component'
import ImmutablePropTypes from 'react-immutable-proptypes'
import { NavLink } from 'react-router-dom'
import { CX } from '../../../constants'
import { me } from '../../../initial_state'
import Input from '../../../components/input'
import Avatar from '../../../components/avatar'
import Button from '../../../components/button'
import Text from '../../../components/text'
import { makeGetChatMessage } from '../../../selectors'
class ChatMessageItem extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
}
state = {
hovering: false,
isNewDay: false,
isCloseToMyLast: false,
}
componentDidMount() {
const { lastChatMessageSameSender, lastChatMessageDate } = this.props
if (lastChatMessageDate) {
const createdAt = this.props.chatMessage.get('created_at')
const isNewDay = moment(createdAt).format('L') !== moment(lastChatMessageDate).format('L')
const isCloseToMyLast = moment(lastChatMessageDate).diff(createdAt, 'minutes') < 60 && lastChatMessageSameSender && !isNewDay
this.setState({
isNewDay,
isCloseToMyLast,
})
}
}
handleOnMouseEnter = () => {
this.setState({ isHovering: true })
}
handleOnMouseLeave = () => {
this.setState({ isHovering: false })
}
handleMoreClick = () => {
//
}
render() {
const {
chatMessage,
isHidden,
lastChatMessageDate,
} = this.props
const {
isCloseToMyLast,
isHovering,
isNewDay,
} = this.state
if (!chatMessage) return <div />
const account = chatMessage.get('account')
const content = { __html: chatMessage.get('text') }
const alt = account.get('id', null) === me
const createdAt = chatMessage.get('created_at')
if (isHidden) {
return (
<React.Fragment>
{account.get('display_name')}
<div dangerouslySetInnerHTML={content} />
</React.Fragment>
)
}
const messageContainerClasses = CX({
d: 1,
flexRow: !alt,
flexRowReverse: alt,
pb5: 1,
})
const messageInnerContainerClasses = CX({
d: 1,
px15: 1,
py5: 1,
bgTertiary: !alt,
bgSecondary: alt,
circle: 1,
ml10: 1,
mr10: 1,
})
const lowerContainerClasses = CX({
d: 1,
pt10: 1,
posAbs: 1,
bottom0: 1,
right0: alt,
left0: !alt,
displayNone: !isHovering,
pl50: !alt,
pr50: alt,
})
const buttonContainerClasses = CX({
d: 1,
flexRow: 1,
displayNone: !isHovering && alt,
})
return (
<div
className={[_s.d, _s.w100PC, _s.pb10].join(' ')}
onMouseEnter={this.handleOnMouseEnter}
onMouseLeave={this.handleOnMouseLeave}
>
{
!!lastChatMessageDate && isNewDay &&
<Text color='secondary' size='small' align='center' className={[_s.d, _s.py10].join(' ')}>
{moment(createdAt).format('lll')}
</Text>
}
<div className={[_s.d, _s.w100PC, _s.pb15].join(' ')}>
<div className={messageContainerClasses}>
<Avatar account={chatMessage.get('account')} size={38} />
<div className={messageInnerContainerClasses}>
<div className={[_s.py5, _s.dangerousContent, _s.cPrimary].join(' ')} dangerouslySetInnerHTML={content} />
</div>
{
alt &&
<div className={buttonContainerClasses}>
<Button
onClick={this.handleMoreClick}
color='tertiary'
backgroundColor='none'
icon='ellipsis'
iconSize='18px'
/>
</div>
}
</div>
<div className={lowerContainerClasses}>
<Text size='extraSmall' color='tertiary' align={alt ? 'right' : 'left'}>
{moment(createdAt).format('lll')}
</Text>
</div>
</div>
</div>
)
}
}
const mapStateToProps = (state, { lastChatMessageId, chatMessageId }) => ({
chatMessage: makeGetChatMessage()(state, { id: chatMessageId }),
lastChatMessageDate: lastChatMessageId ? state.getIn(['chat_messages', `${lastChatMessageId}`, 'created_at'], null) : null,
lastChatMessageSameSender: lastChatMessageId ? state.getIn(['chat_messages', `${lastChatMessageId}`, 'from_account_id'], null) === state.getIn(['chat_messages', `${chatMessageId}`, 'from_account_id'], null) : false,
})
ChatMessageItem.propTypes = {
intl: PropTypes.object.isRequired,
lastChatMessageId: PropTypes.string,
lastChatMessageDate: PropTypes.string,
lastChatMessageSameSender: PropTypes.string,
chatMessageId: PropTypes.string.isRequired,
chatMessage: ImmutablePropTypes.map,
isHidden: PropTypes.bool,
alt: PropTypes.bool,
}
export default connect(mapStateToProps)(ChatMessageItem)

View File

@ -0,0 +1,212 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import moment from 'moment-mini'
import { List as ImmutableList } from 'immutable'
import ImmutablePropTypes from 'react-immutable-proptypes'
import ImmutablePureComponent from 'react-immutable-pure-component'
import { createSelector } from 'reselect'
import debounce from 'lodash.debounce'
import { me } from '../../../initial_state'
import { setChatConversationSelected } from '../../../actions/chats'
import {
expandChatMessages,
scrollBottomChatMessageConversation,
} from '../../../actions/chat_conversation_messages'
import ScrollableList from '../../../components/scrollable_list'
import ChatMessagePlaceholder from '../../../components/placeholder/chat_message_placeholder'
import ChatMessageItem from './chat_message_item'
class ChatMessageScrollingList extends ImmutablePureComponent {
state = {
isRefreshing: false,
}
componentDidMount () {
const { chatConversationId } = this.props
this.props.onExpandChatMessages(chatConversationId)
}
componentWillUnmount() {
this.props.onSetChatConversationSelected(null)
}
componentWillReceiveProps (nextProps) {
const { chatConversationId } = nextProps
if (chatConversationId !== this.props.chatConversationId) {
this.props.onExpandChatMessages(chatConversationId)
}
}
handleLoadMore = (sinceId) => {
const { chatConversationId, dispatch } = this.props
this.props.onExpandChatMessages(chatConversationId, { sinceId })
}
componentDidUpdate(prevProps, prevState) {
if (this.state.isRefreshing) {
this.setState({ isRefreshing: false })
}
if (prevProps.chatMessageIds.size === 0 && this.props.chatMessageIds.size > 0) {
this.containerNode.scrollTop = this.containerNode.scrollHeight
}
}
getCurrentChatMessageIndex = (id) => {
// : todo :
return this.props.chatMessageIds.indexOf(id)
}
handleMoveUp = (id) => {
const elementIndex = this.getCurrentChatMessageIndex(id) - 1
this._selectChild(elementIndex, true)
}
handleMoveDown = (id) => {
const elementIndex = this.getCurrentChatMessageIndex(id) + 1
this._selectChild(elementIndex, false)
}
handleLoadOlder = debounce(() => {
this.handleLoadMore(this.props.chatMessageIds.size > 0 ? this.props.chatMessageIds.last() : undefined)
}, 300, { leading: true })
handleOnReload = debounce(() => {
this.handleLoadMore()
this.setState({ isRefreshing: true })
}, 300, { trailing: true })
_selectChild(index, align_top) {
const container = this.node.node
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`)
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true)
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false)
}
element.focus()
}
}
setRef = (c) => {
this.node = c
}
containerRef = (c) => {
this.containerNode = c
}
render() {
const {
chatConversationId,
chatMessageIds,
isLoading,
isPartial,
hasMore,
onScrollToBottom,
onScroll,
} = this.props
const { isRefreshing } = this.state
if (isPartial || (isLoading && chatMessageIds.size === 0)) {
return null
}
let scrollableContent = []
let emptyContent = []
if (isLoading || chatMessageIds.size > 0) {
for (let i = 0; i < chatMessageIds.count(); i++) {
const chatMessageId = chatMessageIds.get(i)
const lastChatMessageId = i > 0 ? chatMessageIds.get(i - 1) : null
if (!chatMessageId) {
scrollableContent.unshift(
<div
key={`chat-message-gap:${(i + 1)}`}
disabled={isLoading}
sinceId={i > 0 ? chatMessageIds.get(i - 1) : null}
onClick={this.handleLoadMore}
/>
)
} else {
scrollableContent.unshift(
<ChatMessageItem
key={`chat-message-${chatConversationId}-${i}`}
chatMessageId={chatMessageId}
lastChatMessageId={lastChatMessageId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
commentsLimited
/>
)
}
}
}
return (
<div
className={[_s.d, _s.boxShadowNone, _s.posAbs, _s.bottom60PX, _s.left0, _s.right0, _s.px15, _s.py15, _s.top60PX, _s.w100PC, _s.overflowYScroll].join(' ')}
ref={this.containerRef}
>
<ScrollableList
scrollRef={this.setRef}
onLoadMore={this.handleLoadMore && this.handleLoadOlder}
scrollKey='chat_messages'
hasMore={hasMore}
emptyMessage='No chats found'
onScrollToBottom={onScrollToBottom}
onScroll={onScroll}
isLoading={isLoading}
>
{scrollableContent}
</ScrollableList>
</div>
)
}
}
const mapStateToProps = (state, { chatConversationId }) => {
if (!chatConversationId) return {}
return {
chatMessageIds: state.getIn(['chat_conversation_messages', chatConversationId, 'items'], ImmutableList()),
isLoading: state.getIn(['chat_conversation_messages', chatConversationId, 'isLoading'], true),
isPartial: state.getIn(['chat_conversation_messages', chatConversationId, 'isPartial'], false),
hasMore: state.getIn(['chat_conversation_messages', chatConversationId, 'hasMore']),
}
}
const mapDispatchToProps = (dispatch, ownProps) => ({
onScrollToBottom: debounce(() => {
dispatch(scrollBottomChatMessageConversation(ownProps.chatConversationId, true))
}, 100),
onScroll: debounce(() => {
dispatch(scrollBottomChatMessageConversation(ownProps.chatConversationId, false))
}, 100),
onExpandChatMessages(chatConversationId, params) {
dispatch(expandChatMessages(chatConversationId, params))
},
onSetChatConversationSelected: (chatConversationId) => {
dispatch(setChatConversationSelected(chatConversationId))
},
})
ChatMessageScrollingList.propTypes = {
chatMessageIds: ImmutablePropTypes.list.isRequired,
chatConversationId: PropTypes.string.isRequired,
onExpandChatMessages: PropTypes.func.isRequired,
isLoading: PropTypes.bool,
isPartial: PropTypes.bool,
hasMore: PropTypes.bool,
onClearTimeline: PropTypes.func.isRequired,
onScrollToTop: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired,
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatMessageScrollingList)

View File

@ -6,7 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'
import Heading from '../../../components/heading' import Heading from '../../../components/heading'
import Button from '../../../components/button' import Button from '../../../components/button'
class MessagesHeader extends ImmutablePureComponent { class ChatSettingsHeader extends ImmutablePureComponent {
render() { render() {
const { const {
@ -16,27 +16,19 @@ class MessagesHeader extends ImmutablePureComponent {
return ( return (
<div className={[_s.d, _s.w100PC, _s.h60PX, _s.borderBottom1PX, _s.borderColorSecondary].join(' ')}> <div className={[_s.d, _s.w100PC, _s.h60PX, _s.borderBottom1PX, _s.borderColorSecondary].join(' ')}>
<div className={[_s.d, _s.flexRow, _s.pl15, _s.pr5, _s.py15].join(' ')}> <div className={[_s.d, _s.flexRow, _s.pl15, _s.pr5, _s.py15].join(' ')}>
<Heading size='h1'>
Messages
</Heading>
<div className={[_s.d, _s.bgTransparent, _s.flexRow, _s.aiCenter, _s.jcCenter, _s.mlAuto].join(' ')}>
<Button <Button
isNarrow noClasses
onClick={undefined} className={[_s.d, _s.noUnderline, _s.jcCenter, _s.mr5, _s.aiCenter, _s.bgTransparent, _s.cursorPointer, _s.outlineNone].join(' ')}
className={[_s.ml5, _s.px15].join(' ')} to='/messages'
> color='primary'
New
</Button>
<Button
isNarrow
onClick={undefined}
color='brand'
backgroundColor='none' backgroundColor='none'
className={_s.ml5} icon='angle-left'
icon='cog' iconSize='16px'
iconSize='18px' iconClassName={[_s.mr5, _s.cPrimary].join(' ')}
/> />
</div> <Heading size='h1'>
Chat Settings
</Heading>
</div> </div>
</div> </div>
) )
@ -44,4 +36,4 @@ class MessagesHeader extends ImmutablePureComponent {
} }
export default MessagesHeader export default ChatSettingsHeader

View File

@ -1,116 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import ImmutablePureComponent from 'react-immutable-pure-component'
import ImmutablePropTypes from 'react-immutable-proptypes'
import { NavLink } from 'react-router-dom'
import { CX } from '../../../constants'
import Input from '../../../components/input'
import Avatar from '../../../components/avatar'
import Button from '../../../components/button'
import Text from '../../../components/text'
import RelativeTimestamp from '../../../components/relative_timestamp'
import { makeGetAccount } from '../../../selectors'
class MessagesItem extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
}
state = {
hovering: false,
}
handleOnMouseEnter = () => {
this.setState({ isHovering: true })
}
handleOnMouseLeave = () => {
this.setState({ isHovering: false })
}
render() {
const {
account,
intl,
alt,
} = this.props
const { isHovering } = this.state
const content = { __html: 'REEEE i heard you have the sauce2?' }
const messageContainerClasses = CX({
d: 1,
flexRow: !alt,
flexRowReverse: alt,
})
const messageInnerContainerClasses = CX({
d: 1,
px15: 1,
py5: 1,
bgSecondary: !alt,
bgBrandLight: alt,
circle: 1,
ml10: 1,
mr10: 1,
})
const lowerContainerClasses = CX({
d: 1,
pt10: 1,
pl50: !alt,
pr50: alt,
})
const buttonContainerClasses = CX({
d: 1,
flexRow: 1,
displayNone: !isHovering,
})
return (
<div
className={[_s.d, _s.w100PC, _s.pb10].join(' ')}
onMouseEnter={this.handleOnMouseEnter}
onMouseLeave={this.handleOnMouseLeave}
>
<div className={[_s.d, _s.w100PC, _s.pb15].join(' ')}>
<div className={messageContainerClasses}>
<Avatar account={account} size={38} />
<div className={messageInnerContainerClasses}>
<div className={[_s.py5, _s.dangerousContent].join(' ')} dangerouslySetInnerHTML={content} />
</div>
<div className={buttonContainerClasses}>
<Button
onClick={undefined}
color='tertiary'
backgroundColor='none'
icon='ellipsis'
iconSize='18px'
/>
</div>
</div>
<div className={lowerContainerClasses}>
<Text size='small' color='tertiary' align={alt ? 'right' : 'left'}>
Apr 16, 2020, 8:20 AM
{ /* <RelativeTimestamp timestamp={'2020-20-10'} /> */ }
</Text>
</div>
</div>
</div>
)
}
}
const mapStateToProps = (state, props) => ({
account: makeGetAccount()(state, '1'),
})
MessagesItem.propTypes = {
intl: PropTypes.object.isRequired,
alt: PropTypes.bool,
}
export default connect(mapStateToProps)(MessagesItem)

View File

@ -1,36 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import ImmutablePureComponent from 'react-immutable-pure-component'
import ImmutablePropTypes from 'react-immutable-proptypes'
import MessagesListItem from './messages_list_item'
import { makeGetAccount } from '../../../selectors'
class MessagesList extends ImmutablePureComponent {
render() {
const {
account,
} = this.props
return (
<div className={[_s.d, _s.w100PC].join(' ')}>
<MessagesListItem />
<MessagesListItem selected />
<MessagesListItem />
<MessagesListItem />
</div>
)
}
}
const mapStateToProps = (state, props) => ({
account: makeGetAccount()(state, '1'),
})
MessagesList.propTypes = {
intl: PropTypes.object.isRequired,
}
export default connect(mapStateToProps)(MessagesList)

View File

@ -1,107 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import ImmutablePureComponent from 'react-immutable-pure-component'
import ImmutablePropTypes from 'react-immutable-proptypes'
import { NavLink } from 'react-router-dom'
import { CX } from '../../../constants'
import Input from '../../../components/input'
import DisplayName from '../../../components/display_name'
import Avatar from '../../../components/avatar'
import Text from '../../../components/text'
import RelativeTimestamp from '../../../components/relative_timestamp'
import { makeGetAccount } from '../../../selectors'
class MessagesListItem extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
}
state = {
composeFocused: false,
}
render() {
const {
account,
intl,
selected,
} = this.props
const buttonClasses = CX({
d: 1,
pt2: 1,
pr5: 1,
noUnderline: 1,
overflowHidden: 1,
flexNormal: 1,
flexRow: 1,
aiStart: 1,
aiCenter: 1,
})
const containerClasses = CX({
d: 1,
bgSubtle_onHover: 1,
borderBottom1PX: 1,
borderColorSecondary: 1,
noUnderline: 1,
})
const innerContainerClasses = CX({
d: 1,
flexRow: 1,
aiStart: 1,
aiCenter: 0,
px15: 1,
py15: 1,
borderRight4PX: selected,
borderColorBrand: selected,
})
const avatarSize = 49
const content = { __html: 'REEEE i heard you have the sauce?' }
return (
<NavLink
className={containerClasses}
title={account.get('acct')}
to={`/messages/conversation-id`}
>
<div className={innerContainerClasses}>
<Avatar account={account} size={avatarSize} noHover />
<div className={[_s.d, _s.pl10, _s.overflowHidden, _s.flexNormal].join(' ')}>
<div className={[_s.d, _s.flexRow, _s.aiCenter].join(' ')}>
<div className={buttonClasses}>
<div className={_s.maxW100PC42PX}>
<DisplayName account={account} noHover />
</div>
<Text size='extraSmall' color='secondary' className={_s.mlAuto}>
May 1
{ /* <RelativeTimestamp timestamp={'2020-20-10'} /> */ }
</Text>
</div>
</div>
<div className={[_s.py5, _s.dangerousContent].join(' ')} dangerouslySetInnerHTML={content} />
</div>
</div>
</NavLink>
)
}
}
const mapStateToProps = (state, props) => ({
account: makeGetAccount()(state, '1'),
})
MessagesListItem.propTypes = {
intl: PropTypes.object.isRequired,
selected: PropTypes.bool,
}
export default connect(mapStateToProps)(MessagesListItem)

View File

@ -1,88 +1,35 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { makeGetAccount } from '../../selectors' import ChatEmptyMessageBlock from './components/chat_conversations_empty_block'
import Text from '../../components/text' import ChatMessageHeader from './components/chat_message_header'
import Button from '../../components/button' import ChatMessageScrollingList from './components/chat_message_scrolling_list'
import Avatar from '../../components/avatar' import ChatMessagesComposeForm from './components/chat_message_compose_form'
import DisplayName from '../../components/display_name'
import Input from '../../components/input'
import EmojiPickerButton from '../compose/components/emoji_picker_button'
import UploadButton from '../compose/components/media_upload_button'
import MessageItem from './components/message_item'
// import MessagesContainer from './containers/messages_container'
class Messages extends React.PureComponent { class Messages extends React.PureComponent {
render () { render () {
const { account } = this.props const {
account,
const selectedMessage = true selectedChatConversationId,
chatConverationIsRequest,
} = this.props
return ( return (
<div className={[_s.d, _s.bgPrimary, _s.h100PC, _s.w100PC].join(' ')}> <div className={[_s.d, _s.bgPrimary, _s.h100PC, _s.w100PC].join(' ')}>
{ {
!selectedMessage && !selectedChatConversationId &&
<div className={[_s.d, _s.w100PC, _s.h100PC, _s.aiCenter, _s.jcCenter].join(' ')}> <ChatEmptyMessageBlock />
<Text weight='bold' size='extraLarge'>
You dont have a message selected
</Text>
<Text size='medium' color='secondary' className={_s.py10}>
Choose one from your existing messages, or start a new one.
</Text>
<Button className={_s.mt10}>
<Text color='inherit' weight='bold' className={_s.px15}>
New Message
</Text>
</Button>
</div>
} }
{ {
selectedMessage && !!selectedChatConversationId &&
<div className={[_s.d, _s.h100PC, _s.w100PC].join(' ')}> <div className={[_s.d, _s.h100PC, _s.w100PC].join(' ')}>
<div className={[_s.d, _s.posAbs, _s.top0, _s.left0, _s.right0, _s.flexRow, _s.aiCenter, _s.h60PX, _s.w100PC, _s.borderBottom1PX, _s.borderColorSecondary, _s.px15, _s.py5].join(' ')}> <ChatMessageHeader chatConversationId={selectedChatConversationId} />
<Avatar account={account} size={34} /> <ChatMessageScrollingList chatConversationId={selectedChatConversationId} />
<div className={[_s.d, _s.pl10, _s.maxW100PC86PX, _s.overflowHidden].join(' ')}> {
<DisplayName account={account} isMultiline /> !chatConverationIsRequest &&
</div> <ChatMessagesComposeForm chatConversationId={selectedChatConversationId} />
<Button }
isNarrow
onClick={undefined}
color='brand'
backgroundColor='none'
className={_s.mlAuto}
icon='more'
iconSize='18px'
/>
</div>
<div className={[_s.d, _s.posAbs, _s.bottom60PX, _s.left0, _s.right0, _s.px15, _s.py15, _s.top60PX, _s.w100PC, _s.overflowYScroll].join(' ')}>
<MessageItem />
<MessageItem />
<MessageItem alt />
<MessageItem />
<MessageItem alt />
<MessageItem alt />
<MessageItem />
<MessageItem />
<MessageItem />
<MessageItem alt />
<MessageItem />
</div>
<div className={[_s.d, _s.posAbs, _s.bottom0, _s.left0, _s.right0, _s.flexRow, _s.aiCenter, _s.h60PX, _s.w100PC, _s.borderTop1PX, _s.borderColorSecondary, _s.px15, _s.py5].join(' ')}>
<EmojiPickerButton />
<UploadButton />
<div className={[_s.d, _s.px15, _s.flexGrow1].join(' ')}>
<Input
placeholder='Type a message...'
/>
</div>
<Button>
Send
</Button>
</div>
</div> </div>
} }
</div> </div>
@ -91,13 +38,18 @@ class Messages extends React.PureComponent {
} }
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => {
account: makeGetAccount()(state, '1'), const selectedChatConversationId = state.getIn(['chats', 'selectedChatConversationId'], null)
}) const chatConverationIsRequest = selectedChatConversationId ? !state.getIn(['chat_conversations', selectedChatConversationId, 'is_approved'], null) : false
return {
selectedChatConversationId,
chatConverationIsRequest,
}
}
Messages.propTypes = { Messages.propTypes = {
intl: PropTypes.object.isRequired, selectedChatConversationId: PropTypes.string,
selected: PropTypes.bool, chatConverationIsRequest: PropTypes.bool.isRequired,
} }
export default connect(mapStateToProps)(Messages) export default connect(mapStateToProps)(Messages)

View File

@ -0,0 +1,87 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { injectIntl, FormattedMessage } from 'react-intl'
import ImmutablePureComponent from 'react-immutable-pure-component'
import ImmutablePropTypes from 'react-immutable-proptypes'
import debounce from 'lodash.debounce'
import { me } from '../initial_state'
import { fetchMutes, expandMutes } from '../actions/mutes'
import Account from '../components/account'
import BlockHeading from '../components/block_heading'
import Button from '../components/button'
import Form from '../components/form'
import Switch from '../components/switch'
import Text from '../components/text'
import Divider from '../components/divider'
class MessagesSettings extends ImmutablePureComponent {
componentWillMount() {
this.props.onFetchMutes()
}
handleLoadMore = debounce(() => {
this.props.onExpandMutes()
}, 300, { leading: true })
render() {
const {
accountIds,
hasMore,
isLoading,
} = this.props
return (
<div className={[_s.d, _s.w100PC, _s.boxShadowNone].join(' ')}>
<div className={[_s.d, _s.h60PX, _s.w100PC, _s.px10, _s.py10, _s.borderBottom1PX, _s.borderColorSecondary].join(' ')}>
<BlockHeading title={'Chat Preferences'} />
</div>
<div className={[_s.d, _s.px15, _s.py15].join(' ')}>
<Form>
<Switch
label='Restrict messages from people you dont follow'
checked={true}
onChange={this.handleLockedChange}
/>
<div className={[_s.d, _s.w100PC, _s.my10, _s.borderColorSecondary, _s.borderBottom1PX].join(' ')} />
<Switch
label='Show when you are active'
checked={false}
onChange={this.handleLockedChange}
/>
<div className={[_s.d, _s.w100PC, _s.my10, _s.borderColorSecondary, _s.borderBottom1PX].join(' ')} />
<Switch
label='Notification sound enabled'
checked={false}
onChange={this.handleLockedChange}
/>
</Form>
</div>
</div>
)
}
}
const mapStateToProps = (state) => ({
accountIds: state.getIn(['user_lists', 'mutes', me, 'items']),
hasMore: !!state.getIn(['user_lists', 'mutes', me, 'next']),
isLoading: state.getIn(['user_lists', 'mutes', me, 'isLoading']),
})
const mapDispatchToProps = (dispatch) => ({
onFetchMutes: () => dispatch(fetchMutes()),
onExpandMutes: () => dispatch(expandMutes()),
})
MessagesSettings.propTypes = {
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
onExpandMutes: PropTypes.func.isRequired,
onFetchMutes: PropTypes.func.isRequired,
}
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(MessagesSettings))

View File

@ -12,7 +12,7 @@ import Block from '../components/block'
import BlockHeading from '../components/block_heading' import BlockHeading from '../components/block_heading'
import ScrollableList from '../components/scrollable_list' import ScrollableList from '../components/scrollable_list'
class Mutes extends ImmutablePureComponent { class MutedAccounts extends ImmutablePureComponent {
componentWillMount() { componentWillMount() {
this.props.onFetchMutes() this.props.onFetchMutes()
@ -66,7 +66,7 @@ const mapDispatchToProps = (dispatch) => ({
onExpandMutes: () => dispatch(expandMutes()), onExpandMutes: () => dispatch(expandMutes()),
}) })
Mutes.propTypes = { MutedAccounts.propTypes = {
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
@ -74,4 +74,4 @@ Mutes.propTypes = {
onFetchMutes: PropTypes.func.isRequired, onFetchMutes: PropTypes.func.isRequired,
} }
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Mutes)) export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(MutedAccounts))

View File

@ -56,6 +56,10 @@ import {
Assets, Assets,
BlockedAccounts, BlockedAccounts,
BookmarkedStatuses, BookmarkedStatuses,
ChatConversationCreate,
ChatConversationRequests,
ChatConversationBlockedAccounts,
ChatConversationMutedAccounts,
CommunityTimeline, CommunityTimeline,
Compose, Compose,
DMCA, DMCA,
@ -86,7 +90,8 @@ import {
ListEdit, ListEdit,
ListTimeline, ListTimeline,
Messages, Messages,
Mutes, MessagesSettings,
MutedAccounts,
News, News,
NewsView, NewsView,
Notifications, Notifications,
@ -198,8 +203,12 @@ class SwitchingArea extends React.PureComponent {
<WrappedRoute path='/news' exact publicRoute page={NewsPage} component={News} content={children} componentParams={{ title: 'News' }} /> <WrappedRoute path='/news' exact publicRoute page={NewsPage} component={News} content={children} componentParams={{ title: 'News' }} />
<WrappedRoute path='/news/view/:trendsRSSId' page={NewsPage} component={NewsView} content={children} componentParams={{ title: 'News RSS Feed' }} /> <WrappedRoute path='/news/view/:trendsRSSId' page={NewsPage} component={NewsView} content={children} componentParams={{ title: 'News RSS Feed' }} />
<WrappedRoute path='/messages' exact page={MessagesPage} component={Messages} content={children} /> <WrappedRoute path='/messages' exact page={MessagesPage} component={Messages} content={children} componentParams={{ source: 'approved' }} />
<WrappedRoute path='/messages/:conversationId' exact page={MessagesPage} component={Messages} content={children} /> <WrappedRoute path='/messages/settings' exact page={MessagesPage} component={MessagesSettings} content={children} componentParams={{ isSettings: true }} />
<WrappedRoute path='/messages/requests' exact page={MessagesPage} component={ChatConversationRequests} content={children} componentParams={{ isSettings: true, source: 'requested' }} />
<WrappedRoute path='/messages/blocks' exact page={MessagesPage} component={ChatConversationBlockedAccounts} content={children} componentParams={{ isSettings: true }} />
<WrappedRoute path='/messages/mutes' exact page={MessagesPage} component={ChatConversationMutedAccounts} content={children} componentParams={{ isSettings: true }} />
<WrappedRoute path='/messages/:conversationId' exact page={MessagesPage} component={Messages} content={children} componentParams={{ source: 'approved' }} />
<WrappedRoute path='/timeline/all' exact page={CommunityPage} component={CommunityTimeline} content={children} componentParams={{ title: 'Community Feed' }} /> <WrappedRoute path='/timeline/all' exact page={CommunityPage} component={CommunityTimeline} content={children} componentParams={{ title: 'Community Feed' }} />
<WrappedRoute path='/timeline/pro' exact page={ProPage} component={ProTimeline} content={children} componentParams={{ title: 'Pro Feed' }} /> <WrappedRoute path='/timeline/pro' exact page={ProPage} component={ProTimeline} content={children} componentParams={{ title: 'Pro Feed' }} />
@ -244,39 +253,24 @@ class SwitchingArea extends React.PureComponent {
<WrappedRoute path='/search/links' exact page={SearchPage} component={Search} content={children} /> <WrappedRoute path='/search/links' exact page={SearchPage} component={Search} content={children} />
<WrappedRoute path='/settings/blocks' exact page={SettingsPage} component={BlockedAccounts} content={children} componentParams={{ title: 'Blocked Users' }} /> <WrappedRoute path='/settings/blocks' exact page={SettingsPage} component={BlockedAccounts} content={children} componentParams={{ title: 'Blocked Users' }} />
<WrappedRoute path='/settings/mutes' exact page={SettingsPage} component={Mutes} content={children} componentParams={{ title: 'Muted Users' }} /> <WrappedRoute path='/settings/mutes' exact page={SettingsPage} component={MutedAccounts} content={children} componentParams={{ title: 'Muted Users' }} />
<Redirect from='/@:username' to='/:username' exact />
<WrappedRoute path='/:username' publicRoute exact page={ProfilePage} component={AccountTimeline} content={children} /> <WrappedRoute path='/:username' publicRoute exact page={ProfilePage} component={AccountTimeline} content={children} />
<Redirect from='/@:username/comments' to='/:username/comments' />
<WrappedRoute path='/:username/comments' page={ProfilePage} component={AccountTimeline} content={children} componentParams={{ commentsOnly: true }} /> <WrappedRoute path='/:username/comments' page={ProfilePage} component={AccountTimeline} content={children} componentParams={{ commentsOnly: true }} />
<Redirect from='/@:username/followers' to='/:username/followers' />
<WrappedRoute path='/:username/followers' page={ProfilePage} component={Followers} content={children} /> <WrappedRoute path='/:username/followers' page={ProfilePage} component={Followers} content={children} />
<Redirect from='/@:username/following' to='/:username/following' />
<WrappedRoute path='/:username/following' page={ProfilePage} component={Following} content={children} /> <WrappedRoute path='/:username/following' page={ProfilePage} component={Following} content={children} />
<Redirect from='/@:username/media' to='/:username/photos' />
<Redirect from='/@:username/photos' to='/:username/photos' />
<Redirect from='/:username/media' to='/:username/photos' />
<WrappedRoute path='/:username/photos' page={ProfilePage} component={AccountGallery} content={children} componentParams={{ noSidebar: true, mediaType: 'photo' }} /> <WrappedRoute path='/:username/photos' page={ProfilePage} component={AccountGallery} content={children} componentParams={{ noSidebar: true, mediaType: 'photo' }} />
<WrappedRoute path='/:username/videos' page={ProfilePage} component={AccountGallery} content={children} componentParams={{ noSidebar: true, mediaType: 'video' }} /> <WrappedRoute path='/:username/videos' page={ProfilePage} component={AccountGallery} content={children} componentParams={{ noSidebar: true, mediaType: 'video' }} />
<Redirect from='/@:username/likes' to='/:username/likes' />
<WrappedRoute path='/:username/likes' page={ProfilePage} component={LikedStatuses} content={children} /> <WrappedRoute path='/:username/likes' page={ProfilePage} component={LikedStatuses} content={children} />
<Redirect from='/@:username/bookmarks' to='/:username/bookmarks' />
<WrappedRoute path='/:username/bookmarks' page={ProfilePage} component={BookmarkedStatuses} content={children} /> <WrappedRoute path='/:username/bookmarks' page={ProfilePage} component={BookmarkedStatuses} content={children} />
<Redirect from='/@:username/posts/:statusId' to='/:username/posts/:statusId' exact />
<WrappedRoute path='/:username/posts/:statusId' publicRoute exact page={BasicPage} component={StatusFeature} content={children} componentParams={{ title: 'Status', page: 'status' }} /> <WrappedRoute path='/:username/posts/:statusId' publicRoute exact page={BasicPage} component={StatusFeature} content={children} componentParams={{ title: 'Status', page: 'status' }} />
<Redirect from='/@:username/posts/:statusId/reposts' to='/:username/posts/:statusId/reposts' />
<WrappedRoute path='/:username/posts/:statusId/reposts' publicRoute page={ModalPage} component={StatusReposts} content={children} componentParams={{ title: 'Reposts' }} /> <WrappedRoute path='/:username/posts/:statusId/reposts' publicRoute page={ModalPage} component={StatusReposts} content={children} componentParams={{ title: 'Reposts' }} />
<Redirect from='/@:username/posts/:statusId/likes' to='/:username/posts/:statusId/likes' />
<WrappedRoute path='/:username/posts/:statusId/likes' page={ModalPage} component={StatusLikes} content={children} componentParams={{ title: 'Likes' }} /> <WrappedRoute path='/:username/posts/:statusId/likes' page={ModalPage} component={StatusLikes} content={children} componentParams={{ title: 'Likes' }} />
<WrappedRoute page={ErrorPage} component={GenericNotFound} content={children} /> <WrappedRoute page={ErrorPage} component={GenericNotFound} content={children} />

View File

@ -7,6 +7,12 @@ export function BlockAccountModal() { return import(/* webpackChunkName: "compon
export function BlockedAccounts() { return import(/* webpackChunkName: "features/blocked_accounts" */'../../blocked_accounts') } export function BlockedAccounts() { return import(/* webpackChunkName: "features/blocked_accounts" */'../../blocked_accounts') }
export function BookmarkedStatuses() { return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses') } export function BookmarkedStatuses() { return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses') }
export function BoostModal() { return import(/* webpackChunkName: "components/boost_modal" */'../../../components/modal/boost_modal') } export function BoostModal() { return import(/* webpackChunkName: "components/boost_modal" */'../../../components/modal/boost_modal') }
export function ChatConversationBlockedAccounts() { return import(/* webpackChunkName: "features/chat_conversation_blocked_accounts" */'../../chat_conversation_blocked_accounts') }
export function ChatConversationCreate() { return import(/* webpackChunkName: "features/chat_conversation_create" */'../../chat_conversation_create') }
export function ChatConversationCreateModal() { return import(/* webpackChunkName: "components/chat_conversation_create_modal" */'../../../components/modal/chat_conversation_create_modal') }
export function ChatConversationDeleteModal() { return import(/* webpackChunkName: "components/chat_conversation_delete_modal" */'../../../components/modal/chat_conversation_delete_modal') }
export function ChatConversationMutedAccounts() { return import(/* webpackChunkName: "features/chat_conversation_muted_accounts" */'../../chat_conversation_muted_accounts') }
export function ChatConversationRequests() { return import(/* webpackChunkName: "features/chat_conversation_requests" */'../../chat_conversation_requests') }
export function CommentSortingOptionsPopover() { return import(/* webpackChunkName: "components/comment_sorting_options_popover" */'../../../components/popover/comment_sorting_options_popover') } export function CommentSortingOptionsPopover() { return import(/* webpackChunkName: "components/comment_sorting_options_popover" */'../../../components/popover/comment_sorting_options_popover') }
export function CommunityTimeline() { return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline') } export function CommunityTimeline() { return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline') }
export function CommunityTimelineSettingsModal() { return import(/* webpackChunkName: "components/community_timeline_settings_modal" */'../../../components/modal/community_timeline_settings_modal') } export function CommunityTimelineSettingsModal() { return import(/* webpackChunkName: "components/community_timeline_settings_modal" */'../../../components/modal/community_timeline_settings_modal') }
@ -78,7 +84,8 @@ export function MediaGallery() { return import(/* webpackChunkName: "components/
export function MediaGalleryPanel() { return import(/* webpackChunkName: "components/media_gallery_panel" */'../../../components/panel/media_gallery_panel') } export function MediaGalleryPanel() { return import(/* webpackChunkName: "components/media_gallery_panel" */'../../../components/panel/media_gallery_panel') }
export function MediaModal() { return import(/* webpackChunkName: "components/media_modal" */'../../../components/modal/media_modal') } export function MediaModal() { return import(/* webpackChunkName: "components/media_modal" */'../../../components/modal/media_modal') }
export function Messages() { return import(/* webpackChunkName: "features/messages" */'../../messages') } export function Messages() { return import(/* webpackChunkName: "features/messages" */'../../messages') }
export function Mutes() { return import(/* webpackChunkName: "features/mutes" */'../../mutes') } export function MessagesSettings() { return import(/* webpackChunkName: "features/messages_settings" */'../../messages_settings') }
export function MutedAccounts() { return import(/* webpackChunkName: "features/muted_accounts" */'../../muted_accounts') }
export function MuteModal() { return import(/* webpackChunkName: "modals/mute_modal" */'../../../components/modal/mute_modal') } export function MuteModal() { return import(/* webpackChunkName: "modals/mute_modal" */'../../../components/modal/mute_modal') }
export function NavSettingsPopover() { return import(/* webpackChunkName: "modals/nav_settings_popover" */'../../../components/popover/nav_settings_popover') } export function NavSettingsPopover() { return import(/* webpackChunkName: "modals/nav_settings_popover" */'../../../components/popover/nav_settings_popover') }
export function News() { return import(/* webpackChunkName: "features/news" */'../../news') } export function News() { return import(/* webpackChunkName: "features/news" */'../../news') }

View File

@ -142,9 +142,7 @@ class Layout extends React.PureComponent {
{ {
!noComposeButton && !noComposeButton &&
<Responsive max={BREAKPOINT_EXTRA_SMALL}>
<FloatingActionButton /> <FloatingActionButton />
</Responsive>
} }
</ResponsiveClassesComponent> </ResponsiveClassesComponent>

View File

@ -1,26 +1,36 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import ImmutablePropTypes from 'react-immutable-proptypes' import { connect } from 'react-redux'
import ImmutablePureComponent from 'react-immutable-pure-component'
import { me } from '../initial_state' import { me } from '../initial_state'
import { openModal } from '../actions/modal'
import { import {
CX, CX,
BREAKPOINT_EXTRA_SMALL, BREAKPOINT_EXTRA_SMALL,
MODAL_CHAT_CONVERSATION_CREATE,
} from '../constants' } from '../constants'
import Layout from './layout' import Layout from './layout'
import Responsive from '../features/ui/util/responsive_component' import Responsive from '../features/ui/util/responsive_component'
import List from '../components/list'
import ResponsiveClassesComponent from '../features/ui/util/responsive_classes_component' import ResponsiveClassesComponent from '../features/ui/util/responsive_classes_component'
import MessagesSearch from '../features/messages/components/messages_search' import ChatConversationsSearch from '../features/messages/components/chat_conversations_search'
import MessagesList from '../features/messages/components/messages_list' import ChatConversationsList from '../features/messages/components/chat_conversations_list'
import MessagesHeader from '../features/messages/components/messages_header' import ChatSettingsHeader from '../features/messages/components/chat_settings_header'
import ChatConversationRequestsListItem from '../features/messages/components/chat_conversations_requests_list_item'
class MessagesLayout extends ImmutablePureComponent { class MessagesLayout extends React.PureComponent {
onClickAdd = () => {
this.props.onOpenChatConversationCreateModal()
}
render() { render() {
const { const {
children,
showBackBtn,
title, title,
children,
isSettings,
showBackBtn,
source,
currentConversationIsRequest,
} = this.props } = this.props
const mainBlockClasses = CX({ const mainBlockClasses = CX({
@ -31,22 +41,25 @@ class MessagesLayout extends ImmutablePureComponent {
jcEnd: 1, jcEnd: 1,
}) })
console.log("currentConversationIsRequest:",currentConversationIsRequest)
return ( return (
<Layout <Layout
showBackBtn showBackBtn
showGlobalFooter
noRightSidebar noRightSidebar
noComposeButton
showGlobalFooter
showLinkFooterInSidebar showLinkFooterInSidebar
page='messages' page='messages'
title='Chats' title='Chats'
actions={[ actions={[
{ {
icon: 'cog', icon: 'cog',
onClick: this.onOpenCommunityPageSettingsModal, to: '/messages/settings',
}, },
{ {
icon: 'pencil', icon: 'pencil',
onClick: this.onOpenCommunityPageSettingsModal, onClick: () => this.onClickAdd(),
}, },
]} ]}
> >
@ -63,16 +76,51 @@ class MessagesLayout extends ImmutablePureComponent {
<Responsive min={BREAKPOINT_EXTRA_SMALL}> <Responsive min={BREAKPOINT_EXTRA_SMALL}>
<div className={[_s.d, _s.w340PX, _s.h100PC, _s.bgPrimary, _s.borderLeft1PX, _s.borderRight1PX, _s.borderColorSecondary].join(' ')}> <div className={[_s.d, _s.w340PX, _s.h100PC, _s.bgPrimary, _s.borderLeft1PX, _s.borderRight1PX, _s.borderColorSecondary].join(' ')}>
<div className={[_s.d, _s.w340PX].join(' ')}> <div className={[_s.d, _s.h100PC, _s.overflowHidden, _s.w100PC, _s.boxShadowNone].join(' ')}>
{ /* <MessagesHeader /> */ } {
<MessagesSearch /> (isSettings || currentConversationIsRequest) &&
<MessagesList /> <React.Fragment>
<ChatSettingsHeader />
<List
items={[
{
title: 'Preferences',
to: '/messages/settings',
},
{
title: 'Message Requests',
to: '/messages/requests',
},
{
title: 'Blocked Chats',
to: '/messages/blocks',
},
{
title: 'Muted Chats',
to: '/messages/mutes',
},
]}
/>
</React.Fragment>
}
{
!isSettings && !currentConversationIsRequest &&
<React.Fragment>
<ChatConversationsSearch />
<div className={[_s.d, _s.w100PC, _s.posAbs, _s.bottom0, _s.top60PX, _s.overflowYScroll].join(' ')}>
<ChatConversationRequestsListItem />
<ChatConversationsList source={source} />
</div>
</React.Fragment>
}
</div> </div>
</div> </div>
</Responsive> </Responsive>
<div className={[_s.d, _s.flexGrow1, _s.h100PC, _s.bgPrimary, _s.borderColorSecondary, _s.borderRight1PX, _s.z1].join(' ')}> <div className={[_s.d, _s.flexGrow1, _s.h100PC, _s.bgPrimary, _s.borderColorSecondary, _s.borderRight1PX, _s.z1].join(' ')}>
<div className={[_s.d, _s.w100PC, _s.h100PC].join(' ')}> <div className={[_s.d, _s.w100PC, _s.h100PC].join(' ')}>
{children} {children}
@ -87,10 +135,26 @@ class MessagesLayout extends ImmutablePureComponent {
} }
MessagesLayout.propTypes = { const mapStateToProps = (state) => {
children: PropTypes.node, const selectedId = state.getIn(['chats', 'selectedChatConversationId'], null)
showBackBtn: PropTypes.bool, const currentConversationIsRequest = selectedId ? !state.getIn(['chat_conversations', selectedId, 'is_approved'], true) : false
title: PropTypes.string,
return { currentConversationIsRequest }
} }
export default MessagesLayout const mapDispatchToProps = (dispatch) => ({
onOpenChatConversationCreateModal() {
dispatch(openModal(MODAL_CHAT_CONVERSATION_CREATE))
}
})
MessagesLayout.propTypes = {
title: PropTypes.string,
children: PropTypes.node,
isSettings: PropTypes.bool,
showBackBtn: PropTypes.bool,
source: PropTypes.string,
onOpenChatConversationCreateModal: PropTypes.func.isRequired,
}
export default connect(mapStateToProps, mapDispatchToProps)(MessagesLayout)

View File

@ -177,7 +177,7 @@ class ProfileLayout extends ImmutablePureComponent {
</div> </div>
<FloatingActionButton isDesktop /> <FloatingActionButton />
</main> </main>
</Responsive> </Responsive>

View File

@ -1,38 +1,37 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { defineMessages, injectIntl } from 'react-intl'
import PageTitle from '../features/ui/util/page_title' import PageTitle from '../features/ui/util/page_title'
import MessagesLayout from '../layouts/messages_layout' import MessagesLayout from '../layouts/messages_layout'
class MessagesPage extends React.PureComponent { class MessagesPage extends React.PureComponent {
render() { render() {
const { children, intl } = this.props const {
children,
const title = intl.formatMessage(messages.chats) isSettings,
source,
} = this.props
return ( return (
<MessagesLayout <MessagesLayout
showBackBtn showBackBtn
title={title} isSettings={isSettings}
title='Chats'
source={source}
> >
<PageTitle path={title} /> <PageTitle path='Chats' />
{children}
</MessagesLayout> </MessagesLayout>
) )
} }
} }
const messages = defineMessages({
chats: { id: 'chats', defaultMessage: 'Chats' },
})
MessagesPage.propTypes = { MessagesPage.propTypes = {
intl: PropTypes.object.isRequired,
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
dispatch: PropTypes.func.isRequired, isSettings: PropTypes.func,
source: PropTypes.string,
} }
export default injectIntl(connect()(MessagesPage)) export default MessagesPage

View File

@ -1,43 +0,0 @@
import {
MESSAGE_INPUT_CHANGE,
MESSAGE_INPUT_RESET,
MESSAGE_SEND_REQUEST,
MESSAGE_SEND_SUCCESS,
MESSAGE_SEND_FAIL,
MESSAGE_DELETE_REQUEST,
MESSAGE_DELETE_SUCCESS,
MESSAGE_DELETE_FAIL,
} from '../actions/lists'
import { Map as ImmutableMap, fromJS } from 'immutable'
const initialState = ImmutableMap({
text: '',
conversationId: null,
idempotencyKey: null,
})
const normalizeList = (state, list) => state.set(list.id, fromJS(list))
const normalizeLists = (state, lists) => {
lists.forEach(list => {
state = normalizeList(state, list)
})
return state
}
export default function lists(state = initialState, action) {
switch(action.type) {
case LIST_FETCH_SUCCESS:
case LIST_CREATE_SUCCESS:
case LIST_UPDATE_SUCCESS:
return normalizeList(state, action.list);
case LISTS_FETCH_SUCCESS:
return normalizeLists(state, action.lists);
case LIST_DELETE_SUCCESS:
case LIST_FETCH_FAIL:
return state.set(action.id, false);
default:
return state;
}
}

View File

@ -0,0 +1,85 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'
import {
CHAT_CONVERSATIONS_APPROVED_FETCH_REQUEST,
CHAT_CONVERSATIONS_APPROVED_FETCH_SUCCESS,
CHAT_CONVERSATIONS_APPROVED_FETCH_FAIL,
CHAT_CONVERSATIONS_APPROVED_EXPAND_REQUEST,
CHAT_CONVERSATIONS_APPROVED_EXPAND_SUCCESS,
CHAT_CONVERSATIONS_APPROVED_EXPAND_FAIL,
CHAT_CONVERSATIONS_REQUESTED_FETCH_REQUEST,
CHAT_CONVERSATIONS_REQUESTED_FETCH_SUCCESS,
CHAT_CONVERSATIONS_REQUESTED_FETCH_FAIL,
CHAT_CONVERSATIONS_REQUESTED_EXPAND_REQUEST,
CHAT_CONVERSATIONS_REQUESTED_EXPAND_SUCCESS,
CHAT_CONVERSATIONS_REQUESTED_EXPAND_FAIL,
CHAT_CONVERSATION_REQUEST_APPROVE_SUCCESS,
} from '../actions/chat_conversations'
const initialState = ImmutableMap({
approved: ImmutableMap({
next: null,
isLoading: false,
items: ImmutableList(),
}),
requested: ImmutableMap({
next: null,
isLoading: false,
items: ImmutableList(),
}),
})
const normalizeList = (state, source, chatConversations, next) => {
return state.update(source, listMap => listMap.withMutations(map => {
map.set('next', next)
map.set('loaded', true)
map.set('isLoading', false)
map.set('items', ImmutableList(chatConversations.map(chatConversation => chatConversation.chat_conversation_id)))
}))
}
const appendToList = (state, source, chatConversations, next) => {
return state.update(source, listMap => listMap.withMutations(map => {
map.set('next', next)
map.set('isLoading', false)
map.set('items', map.get('items').concat(chatConversations.map(chatConversation => chatConversation.chat_conversation_id)))
}))
}
const removeOneFromList = (state, source, chatConversationId) => {
return state.update(source, listMap => listMap.withMutations(map => {
map.set('items', map.get('items').filter(id => id !== chatConversationId))
}))
}
export default function chat_conversation_lists(state = initialState, action) {
switch (action.type) {
case CHAT_CONVERSATIONS_APPROVED_FETCH_REQUEST:
case CHAT_CONVERSATIONS_APPROVED_EXPAND_REQUEST:
return state.setIn(['approved', 'isLoading'], true)
case CHAT_CONVERSATIONS_APPROVED_FETCH_FAIL:
case CHAT_CONVERSATIONS_APPROVED_EXPAND_FAIL:
return state.setIn(['approved', 'isLoading'], false)
case CHAT_CONVERSATIONS_APPROVED_FETCH_SUCCESS:
return normalizeList(state, 'approved', action.chatConversations, action.next)
case CHAT_CONVERSATIONS_APPROVED_EXPAND_SUCCESS:
return appendToList(state, 'approved', action.chatConversations, action.next)
case CHAT_CONVERSATIONS_REQUESTED_FETCH_REQUEST:
case CHAT_CONVERSATIONS_REQUESTED_EXPAND_REQUEST:
return state.setIn(['requested', 'isLoading'], true)
case CHAT_CONVERSATIONS_REQUESTED_FETCH_FAIL:
case CHAT_CONVERSATIONS_REQUESTED_EXPAND_FAIL:
return state.setIn(['requested', 'isLoading'], false)
case CHAT_CONVERSATIONS_REQUESTED_FETCH_SUCCESS:
return normalizeList(state, 'requested', action.chatConversations, action.next)
case CHAT_CONVERSATIONS_REQUESTED_EXPAND_SUCCESS:
return appendToList(state, 'requested', action.chatConversations, action.next)
case CHAT_CONVERSATION_REQUEST_APPROVE_SUCCESS:
return removeOneFromList(state, 'requested', action.chatConversation.chat_conversation_id)
default:
return state
}
}

View File

@ -0,0 +1,105 @@
import {
List as ImmutableList,
Map as ImmutableMap,
fromJS,
} from 'immutable'
import compareId from '../utils/compare_id'
import {
CHAT_MESSAGES_SEND_SUCCESS,
CHAT_MESSAGES_DELETE_REQUEST,
} from '../actions/chat_messages'
import {
CHAT_CONVERSATION_MESSAGES_EXPAND_REQUEST,
CHAT_CONVERSATION_MESSAGES_EXPAND_SUCCESS,
CHAT_CONVERSATION_MESSAGES_EXPAND_FAIL,
CHAT_CONVERSATION_MESSAGES_CONNECT,
CHAT_CONVERSATION_MESSAGES_DISCONNECT,
CHAT_CONVERSATION_MESSAGES_CLEAR,
} from '../actions/chat_conversation_messages'
const initialState = ImmutableMap()
const initialConversation = ImmutableMap({
unread: 0,
online: false,
top: true,
isLoading: false,
isError: false,
hasMore: true,
items: ImmutableList(),
})
const expandNormalizedChatConversation = (state, chatConversationId, chatMessages, next, isPartial, isLoadingRecent) => {
return state.update(chatConversationId, initialConversation, map => map.withMutations((mMap) => {
mMap.set('isLoading', false)
mMap.set('isPartial', isPartial)
if (!next && !isLoadingRecent) mMap.set('hasMore', false)
if (!!chatMessages && !chatMessages.isEmpty()) {
mMap.update('items', ImmutableList(), oldIds => {
const newIds = chatMessages.map(chatMessage => chatMessage.get('id'));
const lastIndex = oldIds.findLastIndex((id) => id !== null && compareId(id, newIds.last()) >= 0) + 1;
const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0);
if (firstIndex < 0) {
return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex));
}
return oldIds.take(firstIndex + 1).concat(
isPartial && oldIds.get(firstIndex) !== null ? newIds.unshift(null) : newIds,
oldIds.skip(lastIndex)
);
});
}
}));
};
const updateChatMessageConversation = (state, chatConversationId, chatMessage) => {
const top = state.getIn([chatConversationId, 'top']);
const ids = state.getIn([chatConversationId, 'items'], ImmutableList());
const includesId = ids.includes(chatMessage.get('id'));
const unread = state.getIn([chatConversationId, 'unread'], 0);
if (includesId) {
return state;
}
let newIds = ids;
return state.update(chatConversationId, initialConversation, map => map.withMutations(mMap => {
if (!top) mMap.set('unread', unread + 1);
// if (top && ids.size > 40) newIds = newIds.take(20);
mMap.set('items', newIds.unshift(chatMessage.get('id')));
}));
};
export default function chat_conversation_messages(state = initialState, action) {
switch (action.type) {
case CHAT_CONVERSATION_MESSAGES_CONNECT:
return state.update(action.chatConversationId, initialConversation, map => map.set('online', true))
case CHAT_CONVERSATION_MESSAGES_DISCONNECT:
return state.update(
action.chatConversationId,
initialConversation,
map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items)
)
case CHAT_CONVERSATION_MESSAGES_CLEAR:
return state.set(chatConversationId, initialTimeline)
case CHAT_CONVERSATION_MESSAGES_EXPAND_REQUEST:
return state.update(action.chatConversationId, initialConversation, map => map.set('isLoading', true))
case CHAT_CONVERSATION_MESSAGES_EXPAND_FAIL:
return state.update(action.chatConversationId, initialConversation, map => map.withMutations((mMap) => {
map.set('isLoading', false)
map.set('isError', true)
}))
case CHAT_CONVERSATION_MESSAGES_EXPAND_SUCCESS:
return expandNormalizedChatConversation(state, action.chatConversationId, fromJS(action.chatMessages), action.next, action.partial, action.isLoadingRecent)
case CHAT_MESSAGES_SEND_SUCCESS:
return updateChatMessageConversation(state, action.chatConversationId, fromJS(action.chatMessage))
// CHAT_MESSAGES_DELETE_REQUEST
default:
return state
}
}

View File

@ -0,0 +1,43 @@
import {
Map as ImmutableMap,
List as ImmutableList,
fromJS,
} from 'immutable'
import { me } from '../initial_state'
import {
CHAT_CONVERSATIONS_APPROVED_FETCH_SUCCESS,
CHAT_CONVERSATIONS_APPROVED_EXPAND_SUCCESS,
CHAT_CONVERSATIONS_REQUESTED_FETCH_SUCCESS,
CHAT_CONVERSATIONS_REQUESTED_EXPAND_SUCCESS,
CHAT_CONVERSATION_REQUEST_APPROVE_SUCCESS,
} from '../actions/chat_conversations'
const initialState = ImmutableMap()
export const normalizeChatConversation = (chatConversation) => {
const { other_accounts, ...rest } = chatConversation
return fromJS({
...rest,
other_account_ids: other_accounts.map((a) => a.id),
})
}
const importChatConversation = (state, chatConversation) => state.set(chatConversation.chat_conversation_id, normalizeChatConversation(chatConversation))
const importChatConversations = (state, chatConversations) => {
return state.withMutations((mutable) => chatConversations.forEach((chatConversation) => importChatConversation(mutable, chatConversation)))
}
export default function chat_conversations(state = initialState, action) {
switch(action.type) {
case CHAT_CONVERSATION_REQUEST_APPROVE_SUCCESS:
return importChatConversation(state, action.chatConversation)
case CHAT_CONVERSATIONS_APPROVED_FETCH_SUCCESS:
case CHAT_CONVERSATIONS_APPROVED_EXPAND_SUCCESS:
case CHAT_CONVERSATIONS_REQUESTED_FETCH_SUCCESS:
case CHAT_CONVERSATIONS_REQUESTED_EXPAND_SUCCESS:
return importChatConversations(state, action.chatConversations)
default:
return state
}
}

View File

@ -1,84 +1,32 @@
import { Map as ImmutableMap, fromJS } from 'immutable'
import { import {
REPOST_REQUEST, CHAT_MESSAGES_SEND_SUCCESS,
UNREPOST_REQUEST, CHAT_MESSAGES_DELETE_REQUEST,
REPOST_FAIL, } from '../actions/chat_messages'
FAVORITE_REQUEST,
FAVORITE_FAIL,
UNFAVORITE_REQUEST,
} from '../actions/interactions';
import { import {
STATUS_REVEAL, CHAT_MESSAGES_IMPORT,
STATUS_HIDE, } from '../actions/importer'
UPDATE_STATUS_STATS,
} from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
import { Map as ImmutableMap, fromJS } from 'immutable';
const importStatus = (state, status) => state.set(status.id, fromJS(status)); const importChatMessage = (state, chatMessage) => state.set(chatMessage.id, fromJS(chatMessage))
const importStatuses = (state, statuses) => const importChatMessages = (state, chatMessages) =>
state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status))); state.withMutations((mutable) => chatMessages.forEach((chatMessage) => importChatMessage(mutable, chatMessage)))
const deleteStatus = (state, id, references) => { const deleteChatMessage = (state, id) => {
references.forEach(ref => { return state.delete(id)
state = deleteStatus(state, ref[0], []); }
});
return state.delete(id); const initialState = ImmutableMap()
};
const initialState = ImmutableMap(); export default function chat_messages(state = initialState, action) {
export default function statuses(state = initialState, action) {
switch(action.type) { switch(action.type) {
case STATUS_IMPORT: case CHAT_MESSAGES_IMPORT:
return importStatus(state, action.status); return importChatMessages(state, action.chatMessages)
case STATUSES_IMPORT: case CHAT_MESSAGES_SEND_SUCCESS:
return importStatuses(state, action.statuses); return importChatMessage(state, action.chatMessage)
case FAVORITE_REQUEST: case CHAT_MESSAGES_DELETE_REQUEST:
return state.setIn([action.status.get('id'), 'favourited'], true); return deleteChatMessage(state, action.chatMessageId)
case FAVORITE_FAIL:
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false);
case UNFAVORITE_REQUEST:
return state.setIn([action.status.get('id'), 'favourited'], false);
case REPOST_REQUEST:
return state.setIn([action.status.get('id'), 'reblogged'], true);
case UNREPOST_REQUEST:
return state.setIn([action.status.get('id'), 'reblogged'], false);
case REPOST_FAIL:
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'reblogged'], false);
case STATUS_REVEAL:
return state.withMutations((map) => {
action.ids.forEach(id => {
if (!(state.get(id) === undefined)) {
map.setIn([id, 'hidden'], false);
}
});
});
case STATUS_HIDE:
return state.withMutations((map) => {
action.ids.forEach(id => {
if (!(state.get(id) === undefined)) {
map.setIn([id, 'hidden'], true);
}
});
});
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case UPDATE_STATUS_STATS:
const { status_id } = action.data
return state.withMutations((map) => {
if (action.data.favourited !== undefined) map.setIn([status_id, 'favourited'], action.data.favourited)
if (action.data.favourites_count !== undefined) map.setIn([status_id, 'favourites_count'], action.data.favourites_count)
if (action.data.reblogged !== undefined) map.setIn([status_id, 'reblogged'], action.data.reblogged)
if (action.data.reblogs_count !== undefined) map.setIn([status_id, 'reblogs_count'], action.data.reblogs_count)
if (action.data.replies_count !== undefined) map.setIn([status_id, 'replies_count'], action.data.replies_count)
if (action.data.pinned !== undefined) map.setIn([status_id, 'pinned'], action.data.pinned)
if (action.data.pinned_by_group !== undefined) map.setIn([status_id, 'pinned_by_group'], action.data.pinned_by_group)
if (action.data.bookmarked !== undefined) map.setIn([status_id, 'bookmarked'], action.data.bookmarked)
})
default: default:
return state; return state
} }
}; }

View File

@ -0,0 +1,38 @@
import {
Map as ImmutableMap,
List as ImmutableList,
fromJS,
} from 'immutable'
import { me } from '../initial_state'
import {
CHAT_CONVERSATION_CREATE_SEARCH_ACCOUNTS_SUCCESS,
SET_CHAT_CONVERSATION_SELECTED,
} from '../actions/chats'
import {
CHAT_CONVERSATION_REQUESTED_COUNT_FETCH_SUCCESS,
} from '../actions/chat_conversations'
import {
CHAT_MESSAGES_SEND_SUCCESS,
CHAT_MESSAGES_DELETE_REQUEST,
CHAT_MESSAGES_FETCH_SUCCESS,
CHAT_CONVERSATION_MESSAGES_EXPAND_SUCCESS,
} from '../actions/chat_messages'
const initialState = ImmutableMap({
createChatConversationSuggestionIds: ImmutableList(),
selectedChatConversationId: null,
chatConversationRequestCount: 0,
})
export default function chats(state = initialState, action) {
switch(action.type) {
case CHAT_CONVERSATION_CREATE_SEARCH_ACCOUNTS_SUCCESS:
return state.set('createChatConversationSuggestionIds', ImmutableList(action.accounts.map((item) => item.id)))
case SET_CHAT_CONVERSATION_SELECTED:
return state.set('selectedChatConversationId', action.chatConversationId)
case CHAT_CONVERSATION_REQUESTED_COUNT_FETCH_SUCCESS:
return state.set('chatConversationRequestCount', action.count)
default:
return state
}
}

View File

@ -2,7 +2,9 @@ import { combineReducers } from 'redux-immutable'
import { loadingBarReducer } from 'react-redux-loading-bar' import { loadingBarReducer } from 'react-redux-loading-bar'
import accounts from './accounts' import accounts from './accounts'
import accounts_counters from './accounts_counters' import accounts_counters from './accounts_counters'
import chat_compose from './chat_compose' import chats from './chats'
import chat_conversation_lists from './chat_conversation_lists'
import chat_conversation_messages from './chat_conversation_messages'
import chat_conversations from './chat_conversations' import chat_conversations from './chat_conversations'
import chat_messages from './chat_messages' import chat_messages from './chat_messages'
import compose from './compose' import compose from './compose'
@ -49,6 +51,9 @@ import user_lists from './user_lists'
const reducers = { const reducers = {
accounts, accounts,
accounts_counters, accounts_counters,
chats,
chat_conversation_lists,
chat_conversation_messages,
chat_conversations, chat_conversations,
chat_messages, chat_messages,
compose, compose,
@ -88,10 +93,10 @@ const reducers = {
status_revisions, status_revisions,
suggestions, suggestions,
timelines, timelines,
timeline_injections, // timeline_injections,
toasts, // toasts,
user, // user,
user_lists, // user_lists,
} }
export default combineReducers(reducers) export default combineReducers(reducers)

View File

@ -62,7 +62,6 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0); const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0);
if (firstIndex < 0) { if (firstIndex < 0) {
console.log("----2")
return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex)); return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex));
} }

View File

@ -19,7 +19,6 @@ export default function (state = initialState, action) {
case SAVE_USER_PROFILE_INFORMATION_FETCH_SUCCESS: case SAVE_USER_PROFILE_INFORMATION_FETCH_SUCCESS:
return state return state
case SAVE_USER_PROFILE_INFORMATION_FETCH_FAIL: case SAVE_USER_PROFILE_INFORMATION_FETCH_FAIL:
alert('Failed to update your profile. Max profile image size is 2MB. Please update and try again.')
return state.set('isError', true) return state.set('isError', true)
case RESEND_USER_CONFIRMATION_EMAIL_SUCCESS: case RESEND_USER_CONFIRMATION_EMAIL_SUCCESS:
return state.set('emailConfirmationResends', state.get('emailConfirmationResends') + 1) return state.set('emailConfirmationResends', state.get('emailConfirmationResends') + 1)

View File

@ -20,6 +20,49 @@ export const makeGetAccount = () => {
}); });
}; };
export const makeGetChatMessage = () => {
return createSelector(
[
(state) => state,
(state, { id }) => state.getIn(['chat_messages', id]),
(state, { id }) => state.getIn(['accounts', `${state.getIn(['chat_messages', `${id}`, 'from_account_id'])}`]),
],
(state, base, account) => {
if (!base) return null
return base.withMutations((map) => {
map.set('account', account)
})
}
)
}
export const makeGetChatConversation = () => {
return createSelector(
[
(state) => state,
(state, { id }) => state.getIn(['chat_conversations', `${id}`]),
(state) => state.get('accounts'),
],
(state, base, allAccounts) => {
if (!base) return null
let otherAccounts = ImmutableList()
if (allAccounts) {
base.get('other_account_ids').forEach((acctId) => {
const acct = allAccounts.get(`${acctId}`, null)
if (acct) {
otherAccounts = otherAccounts.set(otherAccounts.size, acct)
}
})
}
return base.withMutations((map) => {
map.set('other_accounts', otherAccounts)
})
}
)
}
const toServerSideType = columnType => { const toServerSideType = columnType => {
switch (columnType) { switch (columnType) {
case 'home': case 'home':

View File

@ -43,6 +43,7 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
}, },
received(data) { received(data) {
console.log("received:", data)
onReceive(data); onReceive(data);
}, },

View File

@ -2,11 +2,15 @@ import React from 'react'
import { FormattedNumber } from 'react-intl' import { FormattedNumber } from 'react-intl'
export const shortNumberFormat = (number) => { export const shortNumberFormat = (number) => {
if (isNaN(number)) {
return <FormattedNumber value={0} />
}
if (number < 1000) { if (number < 1000) {
try { try {
return (<FormattedNumber value={number} />).props.value return (<FormattedNumber value={number} />).props.value
} catch (error) { } catch (error) {
return <FormattedNumber value={number} /> return <FormattedNumber value={0} />
} }
} }

View File

@ -35,10 +35,12 @@
--solid_color_tertiary: #f5f5f7; --solid_color_tertiary: #f5f5f7;
--solid_color_block: #f5f8fa; --solid_color_block: #f5f8fa;
--solid_color_highlight: rgba(224, 234, 66, .125); --solid_color_highlight: rgba(224, 234, 66, .125);
--solid_color_toast: #232425;
--text_color_primary: #2d3436; --text_color_primary: #2d3436;
--text_color_secondary: #4b4f55; --text_color_secondary: #4b4f55;
--text_color_tertiary: #777; --text_color_tertiary: #777;
--text_color_alt: var(--solid_color_secondary);
--border_color_secondary: #ececed; --border_color_secondary: #ececed;
@ -91,10 +93,12 @@
--solid_color_secondary-dark: #424343 !important; --solid_color_secondary-dark: #424343 !important;
--solid_color_tertiary: #333 !important; --solid_color_tertiary: #333 !important;
--solid_color_block: #2d2d2d !important; --solid_color_block: #2d2d2d !important;
--solid_color_toast: #fcfcfc !important;
--text_color_primary: #fff !important; --text_color_primary: #fff !important;
--text_color_secondary: #999 !important; --text_color_secondary: #999 !important;
--text_color_tertiary: #656565 !important; --text_color_tertiary: #656565 !important;
--text_color_alt: var(----solid_color_primary);
--border_color_secondary: #424141 !important; --border_color_secondary: #424141 !important;
@ -116,14 +120,16 @@
--solid_color_secondary-dark: #282828 !important; --solid_color_secondary-dark: #282828 !important;
--solid_color_tertiary: #000 !important; --solid_color_tertiary: #000 !important;
--solid_color_block: #202327 !important; --solid_color_block: #202327 !important;
--solid_color_toast: #fcfcfc !important;
--text_color_primary: #cccbcb !important; --text_color_primary: #cccbcb !important;
--text_color_secondary: #888 !important; --text_color_secondary: #888 !important;
--text_color_tertiary: #656565 !important; --text_color_tertiary: #656565 !important;
--text_color_alt: var(----solid_color_primary);
--border_color_secondary: #343434 !important; --border_color_secondary: #343434 !important;
--color_input_placeholder: var(--text_color_secondary); --color_input_placeholder: var(--text_color_secondary) !important;
/* Navigation bar. Only themes. Non-editable */ /* Navigation bar. Only themes. Non-editable */
--navigation_background: #000 !important; --navigation_background: #000 !important;
@ -423,6 +429,8 @@ pre {
.bgTertiary { background-color: var(--solid_color_tertiary); } .bgTertiary { background-color: var(--solid_color_tertiary); }
.bgToast { background-color: var(--solid_color_toast); }
.bgPrimary { background-color: var(--solid_color_primary); } .bgPrimary { background-color: var(--solid_color_primary); }
.bgPrimaryOpaque { background-color: var(--solid_color_primary-opaque) } .bgPrimaryOpaque { background-color: var(--solid_color_primary-opaque) }
@ -459,6 +467,7 @@ pre {
/* */ /* */
.cAlt { color: var(--text_color_alt); }
.cPrimary { color: var(--text_color_primary); } .cPrimary { color: var(--text_color_primary); }
.cBlack { color: var(--color_black); } .cBlack { color: var(--color_black); }
@ -582,6 +591,7 @@ pre {
.w330PX { width: 330px; } .w330PX { width: 330px; }
.w300PX { width: 300px; } .w300PX { width: 300px; }
.w240PX { width: 240px; } .w240PX { width: 240px; }
.w228PX { width: 228px; }
.w208PX { width: 208px; } .w208PX { width: 208px; }
.w115PX { width: 115px; } .w115PX { width: 115px; }
.w84PX { width: 84px; } .w84PX { width: 84px; }
@ -892,6 +902,7 @@ pre {
.boxShadowPopover { box-shadow: 0 0 15px -5px rgba(0,0,0,0.15); } .boxShadowPopover { box-shadow: 0 0 15px -5px rgba(0,0,0,0.15); }
.boxShadowBlock { box-shadow: 0 1px 2px rgba(0,0,0,0.2); } .boxShadowBlock { box-shadow: 0 1px 2px rgba(0,0,0,0.2); }
.boxShadowDot { box-shadow: inset 0 0 0 3px #fff, inset 0 0 0 6px #000; } .boxShadowDot { box-shadow: inset 0 0 0 3px #fff, inset 0 0 0 6px #000; }
.boxShadowToast { box-shadow: 0px 0px 10px -2px rgba(0, 0, 0, .2), 0px 0px 2px -1px rgba(0, 0, 0, 0.4); }
.boxShadowNone .boxShadowBlock { .boxShadowNone .boxShadowBlock {
box-shadow: none !important; box-shadow: none !important;

View File

@ -21,7 +21,7 @@ class EntityCache
end end
unless uncached_ids.empty? unless uncached_ids.empty?
uncached = CustomEmoji.where(shortcode: shortcodes, domain: domain, disabled: false).each_with_object({}) { |item, h| h[item.shortcode] = item } uncached = CustomEmoji.where(shortcode: shortcodes, domain: domain).each_with_object({}) { |item, h| h[item.shortcode] = item }
uncached.each_value { |item| Rails.cache.write(to_key(:emoji, item.shortcode, domain), item, expires_in: MAX_EXPIRATION) } uncached.each_value { |item| Rails.cache.write(to_key(:emoji, item.shortcode, domain), item, expires_in: MAX_EXPIRATION) }
end end

View File

@ -297,6 +297,11 @@ class Account < ApplicationRecord
username username
end end
def excluded_from_chat_account_ids
# : todo :
# Rails.cache.fetch("exclude_account_ids_for:#{id}") { blocking.pluck(:target_account_id) + blocked_by.pluck(:account_id) + muting.pluck(:target_account_id) }
end
def excluded_from_timeline_account_ids def excluded_from_timeline_account_ids
Rails.cache.fetch("exclude_account_ids_for:#{id}") { blocking.pluck(:target_account_id) + blocked_by.pluck(:account_id) + muting.pluck(:target_account_id) } Rails.cache.fetch("exclude_account_ids_for:#{id}") { blocking.pluck(:target_account_id) + blocked_by.pluck(:account_id) + muting.pluck(:target_account_id) }
end end

30
app/models/chat_block.rb Normal file
View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: chat_blocks
#
# id :bigint(8) not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint(8) not null
# target_account_id :bigint(8) not null
#
class ChatBlock < ApplicationRecord
include Paginable
include RelationshipCacheable
belongs_to :account
belongs_to :target_account, class_name: 'Account'
validates :account_id, uniqueness: { scope: :target_account_id }
after_commit :remove_blocking_cache
private
def remove_blocking_cache
Rails.cache.delete("exclude_chat_account_ids_for:#{account_id}")
Rails.cache.delete("exclude_chat_account_ids_for:#{target_account_id}")
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: chat_conversations
#
# id :bigint(8) not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
#
class ChatConversation < ApplicationRecord
end

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: chat_conversation_accounts
#
# id :bigint(8) not null, primary key
# account_id :bigint(8)
# chat_conversation_id :bigint(8)
# participant_account_ids :bigint(8) default([]), not null, is an Array
# last_chat_message_id :bigint(8)
# is_unread :boolean default(FALSE), not null
# is_hidden :boolean default(FALSE), not null
# is_approved :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class ChatConversationAccount < ApplicationRecord
include Paginable
belongs_to :account
belongs_to :chat_conversation
belongs_to :last_chat_message, class_name: 'ChatMessage', optional: true
# before_validation :set_last_chat_message
def participant_accounts
if participant_account_ids.empty?
[account]
else
# : todo : dont include current_account
participants = Account.where(id: participant_account_ids)
participants.empty? ? [account] : participants
end
end
private
def set_last_chat_message
self.last_chat_message_id = nil # : todo :
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: chat_messages
#
# id :bigint(8) not null, primary key
# account_id :bigint(8) not null
# chat_conversation_id :bigint(8) not null
# text :text default(""), not null
# language :string
# created_at :datetime not null
# updated_at :datetime not null
#
class ChatMessage < ApplicationRecord
include Paginable
belongs_to :from_account, class_name: 'Account'
belongs_to :chat_conversation
validates_with ChatMessageLengthValidator
default_scope { recent }
scope :recent, -> { reorder(created_at: :desc) }
end

29
app/models/chat_mute.rb Normal file
View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: chat_mutes
#
# id :bigint(8) not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint(8) not null
# target_account_id :bigint(8) not null
#
class ChatMute < ApplicationRecord
include Paginable
include RelationshipCacheable
belongs_to :account
belongs_to :target_account, class_name: 'Account'
validates :account_id, uniqueness: { scope: :target_account_id }
after_commit :remove_blocking_cache
private
def remove_blocking_cache
Rails.cache.delete("exclude_chat_account_ids_for:#{account_id}")
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class REST::ChatConversationAccountSerializer < ActiveModel::Serializer
attributes :id, :is_hidden, :is_approved, :is_unread, :chat_conversation_id, :created_at
has_many :participant_accounts, key: :other_accounts, serializer: REST::AccountSerializer
has_one :last_chat_message, serializer: REST::ChatMessageSerializer, unless: :last_chat_message_id?
def id
object.id.to_s
end
def chat_conversation_id
object.chat_conversation_id.to_s
end
def last_chat_message_id?
object.last_chat_message_id.nil?
end
end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
class REST::ChatMessageSerializer < ActiveModel::Serializer
attributes :id, :text, :language, :from_account_id,
:chat_conversation_id, :created_at
def id
object.id.to_s
end
end

View File

@ -4,6 +4,7 @@ class AccountSearchService < BaseService
attr_reader :query, :limit, :offset, :options, :account attr_reader :query, :limit, :offset, :options, :account
def call(query, account = nil, options = {}) def call(query, account = nil, options = {})
puts "query:"+query.inspect
@query = query.strip @query = query.strip
@limit = options[:limit].to_i @limit = options[:limit].to_i
@offset = options[:offset].to_i @offset = options[:offset].to_i

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
class ChatMessageLengthValidator < ActiveModel::Validator
MAX_CHARS = 1600
def validate(chatMessage)
status.errors.add(:text, I18n.t('statuses.over_character_limit', max: MAX_CHARS)) if chatMessage.text.length > MAX_CHARS
status.errors.add(:text, I18n.t('statuses.over_character_limit', max: MAX_CHARS)) if chatMessage.text.length === 0
end
end

View File

@ -2,7 +2,7 @@
class PollValidator < ActiveModel::Validator class PollValidator < ActiveModel::Validator
MAX_OPTIONS = 4 MAX_OPTIONS = 4
MAX_OPTION_CHARS = 25 MAX_OPTION_CHARS = 160
MAX_EXPIRATION = 1.month.freeze MAX_EXPIRATION = 1.month.freeze
MIN_EXPIRATION = 5.minutes.freeze MIN_EXPIRATION = 5.minutes.freeze

View File

@ -69,6 +69,7 @@ Doorkeeper.configure do
:'write:notifications', :'write:notifications',
:'write:reports', :'write:reports',
:'write:statuses', :'write:statuses',
:'write:chats',
:read, :read,
:'read:accounts', :'read:accounts',
:'read:blocks', :'read:blocks',
@ -81,6 +82,7 @@ Doorkeeper.configure do
:'read:notifications', :'read:notifications',
:'read:search', :'read:search',
:'read:statuses', :'read:statuses',
:'read:chats',
:follow, :follow,
:push :push

View File

@ -221,15 +221,33 @@ Rails.application.routes.draw do
resource :explore, only: :show, controller: :explore resource :explore, only: :show, controller: :explore
end end
namespace :messages do resources :chat_conversation_accounts, only: :show do
resource :conversations, only: [:show, :create] resources :blocked_accounts, only: :index
resource :chats, only: [:show, :create] resources :muted_accounts, only: :index
member do member do
post :block post :block_messenger
post :unblock post :unblock_messenger
post :mute post :mute_messenger
post :unmute post :unmute_messenger
end
end
namespace :chat_conversations do
resources :messages, only: :show
resources :approved_conversations
resources :requested_conversations, only: :index do
collection do
get :count
end
end
end
resources :chat_conversation, only: [:show, :create] do
member do
post :mark_chat_conversation_approved
post :mark_chat_conversation_unread
post :mark_chat_conversation_hidden
end end
end end
@ -241,6 +259,7 @@ Rails.application.routes.draw do
resources :scheduled_statuses, only: [:index, :show, :update, :destroy] resources :scheduled_statuses, only: [:index, :show, :update, :destroy]
resources :preferences, only: [:index] resources :preferences, only: [:index]
resources :group_categories, only: [:index] resources :group_categories, only: [:index]
resources :chat_messages, only: [:create, :destroy]
get '/search', to: 'search#index', as: :search get '/search', to: 'search#index', as: :search
get '/account_by_username/:username', to: 'account_by_username#show', username: username_regex get '/account_by_username/:username', to: 'account_by_username#show', username: username_regex

View File

@ -0,0 +1,9 @@
class CreateChatConversations < ActiveRecord::Migration[5.2]
def change
create_table :chat_conversations do |t|
t.timestamps null: false
end
end
end

View File

@ -0,0 +1,14 @@
class CreateChatBlocks < ActiveRecord::Migration[5.2]
def change
create_table :chat_blocks do |t|
t.integer :account_id, null: false
t.integer :target_account_id, null: false
t.timestamps null: false
end
add_index :chat_blocks, [:account_id, :target_account_id], unique: true
end
end

View File

@ -0,0 +1,12 @@
class CreateChatMutes < ActiveRecord::Migration[5.2]
def change
create_table :chat_mutes do |t|
t.integer :account_id, null: false
t.integer :target_account_id, null: false
t.timestamps null: false
end
add_index :chat_mutes, [:account_id, :target_account_id], unique: true
end
end

View File

@ -0,0 +1,16 @@
class CreateChatConversationAccounts < ActiveRecord::Migration[5.2]
def change
create_table :chat_conversation_accounts do |t|
t.belongs_to :account, foreign_key: { on_delete: :cascade }
t.belongs_to :chat_conversation, foreign_key: { on_delete: :cascade }
t.bigint :participant_account_ids, array: true, null: false, default: []
t.bigint :last_chat_message_id, null: true, default: nil
t.boolean :is_unread, null: false, default: false
t.boolean :is_hidden, null: false, default: false
t.boolean :is_approved, null: false, default: false
t.timestamps null: false
end
end
end

View File

@ -0,0 +1,14 @@
class CreateChatMessages < ActiveRecord::Migration[5.2]
def change
create_table :chat_messages do |t|
t.text :text, null: false, default: ''
t.text :language, null: false, default: ''
t.integer :from_account_id, null: false
t.integer :chat_conversation_id, null: false
t.timestamps null: false
end
add_index :chat_messages, [:from_account_id, :chat_conversation_id]
end
end

Some files were not shown because too many files have changed in this diff Show More