Browse Source
BANK-1145: Масові дії з повідомленнями в додатку Reviewed-on: #28 Co-authored-by: Oksana Stepanenko <oksana.stepanenko@jetup.team> Co-committed-by: Oksana Stepanenko <oksana.stepanenko@jetup.team>fix/conversation
33 changed files with 6120 additions and 9970 deletions
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
import React from 'react' |
||||
import { $size, Txt } from '@/shared' |
||||
import { StyleSheet, View } from 'react-native' |
||||
import { ToastConfig, ToastConfigParams } from 'react-native-toast-message' |
||||
|
||||
interface IToastMessages { |
||||
text1: string |
||||
text2: string |
||||
} |
||||
|
||||
export const toastConfig: ToastConfig = { |
||||
rwsToast: ({ text1, text2 }: ToastConfigParams<IToastMessages>) => ( |
||||
<View style={styles.container}> |
||||
<Txt>{text1}</Txt> |
||||
|
||||
{text2 ? <Txt>{text2}</Txt> : null} |
||||
</View> |
||||
), |
||||
} |
||||
|
||||
const styles = StyleSheet.create({ |
||||
container: { |
||||
flexDirection: 'column', |
||||
alignItems: 'center', |
||||
justifyContent: 'center', |
||||
paddingHorizontal: $size(12), |
||||
paddingVertical: $size(6), |
||||
marginTop: 100, |
||||
height: $size(30, 26), |
||||
borderRadius: 4, |
||||
backgroundColor: '#ffffff', |
||||
}, |
||||
}) |
@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
|
||||
import React, { FC } from 'react' |
||||
import { $size, ChatType, useTheme } from '@/shared' |
||||
import { ActivityIndicator, Platform, StyleSheet, View } from 'react-native' |
||||
import { |
||||
ChatHeaderLabel, |
||||
ChatHeaderGoBackBtn, |
||||
ChatHeaderRightBtn, |
||||
} from '../atoms' |
||||
import { PartialTheme } from '@/shared/themes/interfaces' |
||||
|
||||
interface IProps { |
||||
title: { |
||||
previewUrl: string |
||||
title: string |
||||
label: string |
||||
} |
||||
isLoading: boolean |
||||
chatType: ChatType |
||||
goBack: () => void |
||||
onPressRightBtn: () => void |
||||
onPressLeftBtn?: () => void |
||||
} |
||||
|
||||
export const ChatInfoRowAtom: FC<IProps> = ({ |
||||
title, |
||||
chatType, |
||||
isLoading, |
||||
goBack, |
||||
onPressRightBtn, |
||||
onPressLeftBtn, |
||||
}) => { |
||||
const { styles, theme } = useTheme(createStyles) |
||||
|
||||
return ( |
||||
<View style={styles.container}> |
||||
{isLoading ? ( |
||||
<ActivityIndicator color={theme.$loaderPrimary} /> |
||||
) : ( |
||||
<> |
||||
<View style={styles.leftWrap}> |
||||
<ChatHeaderGoBackBtn goBack={goBack} /> |
||||
|
||||
<ChatHeaderLabel |
||||
imageUrl={title.previewUrl} |
||||
chatName={title.title} |
||||
info={title.label} |
||||
/> |
||||
</View> |
||||
|
||||
<ChatHeaderRightBtn |
||||
chatType={chatType} |
||||
onPressRight={onPressRightBtn} |
||||
onPressLeft={onPressLeftBtn} |
||||
/> |
||||
</> |
||||
)} |
||||
</View> |
||||
) |
||||
} |
||||
|
||||
const createStyles = (theme: PartialTheme) => |
||||
StyleSheet.create({ |
||||
container: { |
||||
paddingHorizontal: $size(16, 12), |
||||
marginTop: Platform.select({ android: $size(10) }), |
||||
flexDirection: 'row', |
||||
justifyContent: 'space-between', |
||||
paddingBottom: $size(10), |
||||
borderBottomWidth: 0.5, |
||||
borderBottomColor: theme?.$border, |
||||
}, |
||||
leftWrap: { |
||||
alignItems: 'center', |
||||
flexDirection: 'row', |
||||
}, |
||||
}) |
@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
|
||||
import React, { FC, useMemo } from 'react' |
||||
import { $size, IconComponent, Txt, useTheme } from '@/shared' |
||||
import { Platform, StyleSheet, TouchableOpacity, View } from 'react-native' |
||||
import { PartialTheme } from '@/shared/themes/interfaces' |
||||
import { getTitleByCount } from '@/shared/helpers' |
||||
|
||||
interface IProps { |
||||
selectedCount: number |
||||
onPressCancel: () => void |
||||
onPressActions: () => void |
||||
} |
||||
|
||||
export const ChatSelectedMessagesInfoRowAtom: FC<IProps> = ({ |
||||
selectedCount, |
||||
onPressCancel, |
||||
onPressActions, |
||||
}) => { |
||||
const { styles, theme } = useTheme(createStyles) |
||||
|
||||
const infoText = useMemo(() => { |
||||
if (selectedCount >= 1) |
||||
return `Вибрано ${getTitleByCount(selectedCount, [ |
||||
'повідомлення', |
||||
'повідомлення', |
||||
'повідомлень', |
||||
])}` |
||||
return 'Виберіть повідомлення' |
||||
}, [selectedCount]) |
||||
|
||||
return ( |
||||
<View style={styles.container}> |
||||
<TouchableOpacity onPress={onPressCancel} style={{ flexGrow: 1 }}> |
||||
<Txt style={styles.btnText}>Скасувати</Txt> |
||||
</TouchableOpacity> |
||||
|
||||
<View style={{ flexGrow: 2 }}> |
||||
<Txt style={styles.infoText}>{infoText}</Txt> |
||||
</View> |
||||
|
||||
<TouchableOpacity |
||||
onPress={onPressActions} |
||||
style={styles.actionsBtnBlock}> |
||||
{selectedCount > 0 && ( |
||||
<> |
||||
<Txt style={[styles.btnText, { textAlign: 'right' }]}> |
||||
Дії |
||||
</Txt> |
||||
<IconComponent |
||||
color={theme.$errorText} |
||||
name="3-dots" |
||||
size={$size(20, 18)} |
||||
/> |
||||
</> |
||||
)} |
||||
</TouchableOpacity> |
||||
</View> |
||||
) |
||||
} |
||||
|
||||
const createStyles = (theme: PartialTheme) => |
||||
StyleSheet.create({ |
||||
container: { |
||||
flexDirection: 'row', |
||||
justifyContent: 'space-between', |
||||
alignItems: 'center', |
||||
marginTop: Platform.select({ android: $size(10) }), |
||||
paddingHorizontal: $size(16, 12), |
||||
paddingVertical: $size(15), |
||||
borderBottomWidth: 0.5, |
||||
borderBottomColor: theme?.$border, |
||||
}, |
||||
btn: {}, |
||||
btnText: { |
||||
fontSize: $size(14, 12), |
||||
color: theme.$errorText, |
||||
}, |
||||
infoText: { |
||||
fontSize: $size(13, 11), |
||||
color: theme.$textPrimary, |
||||
fontWeight: '500', |
||||
textAlign: 'center', |
||||
}, |
||||
actionsBtnBlock: { |
||||
flexGrow: 1, |
||||
minWidth: $size(32), |
||||
minHeight: $size(20), |
||||
flexDirection: 'row', |
||||
justifyContent: 'flex-end', |
||||
alignItems: 'center', |
||||
}, |
||||
}) |
@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
|
||||
import { ChatMessageActionEnum } from '../enums' |
||||
|
||||
interface IProps { |
||||
onPress: (actionType: ChatMessageActionEnum) => void |
||||
canShare?: boolean |
||||
canCopy?: boolean |
||||
canDeleteForAll?: boolean |
||||
hasOfflineMessages?: boolean |
||||
} |
||||
|
||||
export const getSelectedMessagesMenuOptions = ({ |
||||
onPress, |
||||
canShare, |
||||
canCopy, |
||||
canDeleteForAll, |
||||
hasOfflineMessages, |
||||
}: IProps) => { |
||||
const menuOptions = { |
||||
forward: { |
||||
name: 'Переслати', |
||||
onPress: () => onPress(ChatMessageActionEnum.FORWARD), |
||||
}, |
||||
share: { |
||||
name: 'Переслати назовні', |
||||
onPress: () => onPress(ChatMessageActionEnum.SHARE), |
||||
}, |
||||
copy: { |
||||
name: 'Копіювати', |
||||
onPress: () => onPress(ChatMessageActionEnum.COPY), |
||||
}, |
||||
delete: { |
||||
name: 'Видалити', |
||||
onPress: () => onPress(ChatMessageActionEnum.DELETE), |
||||
}, |
||||
deleteForAll: { |
||||
name: 'Видалити для всіх', |
||||
onPress: () => onPress(ChatMessageActionEnum.DELETE_FOR_ALL), |
||||
}, |
||||
noAction: { |
||||
name: 'Доступних дій нема', |
||||
onPress: () => {}, |
||||
}, |
||||
} |
||||
|
||||
if (hasOfflineMessages) { |
||||
if (canCopy) return [menuOptions.copy] |
||||
return [menuOptions.noAction] |
||||
} |
||||
|
||||
const optionsKeys = ['forward'] |
||||
if (canShare) optionsKeys.push('share') |
||||
if (canCopy) optionsKeys.push('copy') |
||||
optionsKeys.push('delete') |
||||
if (canDeleteForAll) optionsKeys.push('deleteForAll') |
||||
|
||||
const options = optionsKeys.map(key => menuOptions[key]) |
||||
|
||||
return options |
||||
} |
@ -1,2 +1,6 @@
@@ -1,2 +1,6 @@
|
||||
import { MessageType } from '@/shared' |
||||
|
||||
export * from './chat-header.consts' |
||||
export * from './chat-detail.consts' |
||||
export * from './chat-detail.consts' |
||||
|
||||
export const COPY_ENABLED_MESSAGES_TYPES = [MessageType.Text, MessageType.Image] |
||||
|
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
export enum ChatViewModeEnum { |
||||
DEFAULT, |
||||
SELECT, |
||||
} |
@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
|
||||
export * from './member-card-action.enum' |
||||
export * from './chat-details-footer-action.enum' |
||||
export * from './chat-card-action.enum' |
||||
export * from './chat-message-action.enum' |
||||
export * from './chat-message-action.enum' |
||||
export * from './chat-view-mode.enum' |
||||
|
@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
|
||||
import { MessageType } from '@/shared' |
||||
import { IChatMessage } from '@/shared/components/plugins/chat' |
||||
import _ from 'lodash' |
||||
import moment from 'moment' |
||||
|
||||
export const getCopiedContent = (items: IChatMessage[]) => { |
||||
if (items.length === 1 && items[0].type === MessageType.Image) { |
||||
return items[0].content.fileUrl |
||||
} else if (_.every(items, it => it.type === MessageType.Text)) { |
||||
return createCopiedMessagesContent(items) |
||||
} |
||||
} |
||||
|
||||
function createCopiedMessagesContent(items: IChatMessage[]) { |
||||
const text = [] |
||||
|
||||
const sortedItems = _.orderBy(items, ['createdAt'], ['asc']) |
||||
|
||||
for (const item of sortedItems) { |
||||
const formattedContent = getFormattedMessageContent(item) |
||||
text.push(formattedContent) |
||||
} |
||||
|
||||
return text.join('\n') |
||||
} |
||||
|
||||
function getFormattedMessageContent(item: IChatMessage) { |
||||
const dateAndTime = getDateAndTime(item) |
||||
const authorName = _.defaultTo(item.author?.name, '') |
||||
const text = _.defaultTo(item.content?.message, '') |
||||
|
||||
return `[${dateAndTime}] ${authorName}: ${text}` |
||||
} |
||||
|
||||
function getDateAndTime(item: IChatMessage) { |
||||
if (!item.createdAt) return '' |
||||
return moment(item.createdAt).format('DD.MM.YY, HH:mm') |
||||
} |
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
import { create } from 'zustand' |
||||
import { ChatViewModeEnum } from '../enums' |
||||
|
||||
interface IChatViewModeState { |
||||
mode: ChatViewModeEnum |
||||
setMode: (mode: ChatViewModeEnum) => void |
||||
} |
||||
|
||||
export const useChatViewModeState = create<IChatViewModeState>()(set => ({ |
||||
mode: ChatViewModeEnum.DEFAULT, |
||||
setMode: (mode: ChatViewModeEnum) => { |
||||
set({ mode }) |
||||
}, |
||||
})) |
@ -0,0 +1,211 @@
@@ -0,0 +1,211 @@
|
||||
import _ from 'lodash' |
||||
import { IChatMessage } from '@/shared/components/plugins/chat' |
||||
import { create } from 'zustand' |
||||
import { |
||||
ChatMemberRole, |
||||
MessageType, |
||||
RouteKey, |
||||
appEvents, |
||||
useNav, |
||||
} from '@/shared' |
||||
import { COPY_ENABLED_MESSAGES_TYPES } from '../consts' |
||||
import { ChatMessageActionEnum, ChatViewModeEnum } from '../enums' |
||||
import { getSelectedMessagesMenuOptions } from '../configs' |
||||
import { isMessageEqualURL } from '@/shared/helpers' |
||||
import { fsService } from '@/services/system' |
||||
import { useChatViewModeState } from './use-chat-view-mode-state.hook' |
||||
import { chatMessagesService } from '@/services/domain' |
||||
import Clipboard from '@react-native-community/clipboard' |
||||
import { getCopiedContent } from '../helpers' |
||||
import Toast from 'react-native-toast-message' |
||||
|
||||
interface IChatSelectedMessagesState { |
||||
messages: IChatMessage[] |
||||
selectMessage: (message: IChatMessage) => void |
||||
unselectAll: () => void |
||||
} |
||||
|
||||
export const useChatSelectedMessagesState = |
||||
create<IChatSelectedMessagesState>()(set => ({ |
||||
messages: [], |
||||
selectMessage(message: IChatMessage) { |
||||
set(state => { |
||||
if (_.find(state.messages, it => it.id === message.id)) |
||||
return { |
||||
messages: state.messages.filter( |
||||
it => it.id !== message.id, |
||||
), |
||||
} |
||||
else |
||||
return { |
||||
messages: [ |
||||
...state.messages, |
||||
_.pick(message, [ |
||||
'id', |
||||
'type', |
||||
'content', |
||||
'authorId', |
||||
'chatId', |
||||
'read', |
||||
'isMy', |
||||
'author', |
||||
'isPined', |
||||
'createdAt', |
||||
]), |
||||
], |
||||
} |
||||
}) |
||||
}, |
||||
unselectAll() { |
||||
set({ messages: [] }) |
||||
}, |
||||
})) |
||||
|
||||
export const useChatSelectedMessages = () => { |
||||
const nav = useNav() |
||||
|
||||
const messages = useChatSelectedMessagesState(s => s.messages) |
||||
const { unselectAll } = useChatSelectedMessagesState() |
||||
const { setMode } = useChatViewModeState() |
||||
|
||||
const actions = { |
||||
[ChatMessageActionEnum.FORWARD]: forwardMany, |
||||
[ChatMessageActionEnum.SHARE]: shareMessage, |
||||
[ChatMessageActionEnum.COPY]: copyMany, |
||||
[ChatMessageActionEnum.DELETE]: deleteMany, |
||||
[ChatMessageActionEnum.DELETE_FOR_ALL]: deleteForAllMany, |
||||
} |
||||
|
||||
const afterAction = () => { |
||||
unselectAll() |
||||
setMode(ChatViewModeEnum.DEFAULT) |
||||
} |
||||
|
||||
function forwardMany() { |
||||
nav.navigate(RouteKey.ForwardMessage, { |
||||
messageId: messages.map(it => it.id), |
||||
}) |
||||
|
||||
afterAction() |
||||
} |
||||
|
||||
function shareMessage() { |
||||
const { type, author, content } = messages[0] |
||||
const title = author?.name |
||||
|
||||
if (type === MessageType.Text) { |
||||
if (!isMessageEqualURL(content?.message)) { |
||||
fsService.shareText(title, content?.message) |
||||
} else fsService.shareUrl(title, content?.message) |
||||
} |
||||
|
||||
if ( |
||||
type === MessageType.Image || |
||||
type === MessageType.Video || |
||||
type === MessageType.Audio || |
||||
type === MessageType.File |
||||
) { |
||||
fsService.shareFileOutside(content?.fileUrl, content?.name) |
||||
} |
||||
|
||||
afterAction() |
||||
} |
||||
|
||||
function copyMany() { |
||||
const copiedContent = getCopiedContent(messages) |
||||
Clipboard.setString(copiedContent) |
||||
Toast.show({ |
||||
type: 'rwsToast', |
||||
text1: 'Повідомлення скопійовано', |
||||
}) |
||||
afterAction() |
||||
} |
||||
|
||||
function deleteMany() { |
||||
deleteMessages() |
||||
} |
||||
|
||||
function deleteForAllMany() { |
||||
deleteMessages(true) |
||||
} |
||||
|
||||
function deleteMessages(deleteForAll?: boolean) { |
||||
setTimeout( |
||||
() => |
||||
appEvents.emit('openConfirmModal', { |
||||
title: `Ви впевнені, що хочете \n видалити вибрані повідомлення${ |
||||
deleteForAll ? ' для всіх' : '' |
||||
}?`,
|
||||
buttonToHighlight: 'allow', |
||||
allowBtnAction: async () => { |
||||
await chatMessagesService.deleteChatMessage( |
||||
messages as IChatMessage[], |
||||
deleteForAll, |
||||
), |
||||
afterAction() |
||||
}, |
||||
|
||||
notAllowBtnAction: () => {}, |
||||
}), |
||||
300, |
||||
) |
||||
} |
||||
|
||||
const onPressActions = ( |
||||
userRoleInChat: ChatMemberRole, |
||||
isConnected: boolean, |
||||
) => { |
||||
const canShare = isConnected && messages.length === 1 |
||||
const canCopy = checkCanCopy() |
||||
const canDeleteForAll = checkCanDeleteForAll() |
||||
const hasOfflineMessages = _.some(messages, it => |
||||
_.isNaN(Number(it.id)), |
||||
) |
||||
|
||||
function checkCanCopy() { |
||||
if (!isConnected) |
||||
return _.every(messages, it => it.type === MessageType.Text) |
||||
|
||||
if ( |
||||
messages.length === 1 && |
||||
COPY_ENABLED_MESSAGES_TYPES.includes(messages[0].type) |
||||
) |
||||
return true |
||||
|
||||
if ( |
||||
messages.length > 1 && |
||||
_.every(messages, it => it.type === MessageType.Text) |
||||
) |
||||
return true |
||||
|
||||
return false |
||||
} |
||||
|
||||
function checkCanDeleteForAll() { |
||||
if (userRoleInChat === ChatMemberRole.Admin) return true |
||||
|
||||
return _.every(messages, it => { |
||||
if (!it.read && it.isMy) return true |
||||
|
||||
return false |
||||
}) |
||||
} |
||||
|
||||
const options = getSelectedMessagesMenuOptions({ |
||||
canDeleteForAll, |
||||
canCopy, |
||||
canShare, |
||||
hasOfflineMessages, |
||||
onPress: (actionType: ChatMessageActionEnum) => |
||||
actions[actionType](messages), |
||||
}) |
||||
|
||||
appEvents.emit('openActionSheet', { |
||||
items: options, |
||||
}) |
||||
} |
||||
|
||||
return { |
||||
onPressActions, |
||||
} |
||||
} |
Loading…
Reference in new issue