Feature/v2 (#28)
* Update types and corresponding properties (#27) * Update types and corresponding properties * Change authorId to author object * Fixes after PR Co-authored-by: Alex Demchenko <alexdemchenko@yahoo.com> * Update utils * Add onMessagePRess and disableImageGallery * Change message types * Change onMessagePress * Change Message and tests * Add excludeInitialMessage * Fixes after PR Co-authored-by: vdanylov <vitaliidanylov1992@gmail.com>
@@ -28,13 +28,12 @@ const App = () => {
|
||||
|
||||
if (response?.base64) {
|
||||
const imageMessage: MessageType.Image = {
|
||||
authorId: userId,
|
||||
author: user,
|
||||
createdAt: Date.now(),
|
||||
height: response.height,
|
||||
id: uuidv4(),
|
||||
imageName:
|
||||
response.fileName ?? response.uri?.split('/').pop() ?? '🖼',
|
||||
name: response.fileName ?? response.uri?.split('/').pop() ?? '🖼',
|
||||
size: response.fileSize ?? 0,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
type: 'image',
|
||||
uri: `data:image/*;base64,${response.base64}`,
|
||||
width: response.width,
|
||||
@@ -66,7 +65,7 @@ You can use this URL https://bit.ly/2P0cn2g to test the file message presentatio
|
||||
|
||||
:::
|
||||
|
||||
On tap, images will be previewed inside an interactive image gallery.
|
||||
On tap, images will be previewed inside an interactive image gallery. To disable the image gallery pass `disableImageGallery` prop to the `Chat` component.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -84,12 +83,12 @@ const App = () => {
|
||||
type: [DocumentPicker.types.allFiles],
|
||||
})
|
||||
const fileMessage: MessageType.File = {
|
||||
authorId: userId,
|
||||
fileName: response.name,
|
||||
author: user,
|
||||
createdAt: Date.now(),
|
||||
id: uuidv4(),
|
||||
mimeType: response.type,
|
||||
name: response.name,
|
||||
size: response.size,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
type: 'file',
|
||||
uri: response.uri,
|
||||
}
|
||||
@@ -124,16 +123,20 @@ import FileViewer from 'react-native-file-viewer'
|
||||
|
||||
const App = () => {
|
||||
// ...
|
||||
const handleFilePress = async (message: MessageType.File) => {
|
||||
try {
|
||||
await FileViewer.open(message.uri, { showOpenWithDialog: true })
|
||||
} catch {}
|
||||
const handleMessagePress = async (
|
||||
message: MessageType.DerivedUserMessage
|
||||
) => {
|
||||
if (message.type === 'file') {
|
||||
try {
|
||||
await FileViewer.open(message.uri, { showOpenWithDialog: true })
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Chat
|
||||
// ...
|
||||
onFilePress={handleFilePress}
|
||||
onMessagePress={handleMessagePress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -203,15 +206,15 @@ const uuidv4 = () => {
|
||||
const v = c === 'x' ? r : (r % 4) + 8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
const userId = '06c33e8b-e835-4736-80f4-63f44b66666c'
|
||||
const { showActionSheetWithOptions } = useActionSheet()
|
||||
const [messages, setMessages] = useState<MessageType.Any[]>([])
|
||||
const user = { id: '06c33e8b-e835-4736-80f4-63f44b66666c' }
|
||||
|
||||
const addMessage = (message: MessageType.Any) => {
|
||||
setMessages([{ ...message, status: 'read' }, ...messages])
|
||||
setMessages([message, ...messages])
|
||||
}
|
||||
|
||||
const handleAttachmentPress = () => {
|
||||
@@ -245,12 +248,12 @@ const App = () => {
|
||||
type: [DocumentPicker.types.allFiles],
|
||||
})
|
||||
const fileMessage: MessageType.File = {
|
||||
authorId: userId,
|
||||
fileName: response.name,
|
||||
author: user,
|
||||
createdAt: Date.now(),
|
||||
id: uuidv4(),
|
||||
mimeType: response.type,
|
||||
name: response.name,
|
||||
size: response.size,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
type: 'file',
|
||||
uri: response.uri,
|
||||
}
|
||||
@@ -275,13 +278,12 @@ const App = () => {
|
||||
|
||||
if (response?.base64) {
|
||||
const imageMessage: MessageType.Image = {
|
||||
authorId: userId,
|
||||
author: user,
|
||||
createdAt: Date.now(),
|
||||
height: response.height,
|
||||
id: uuidv4(),
|
||||
imageName:
|
||||
response.fileName ?? response.uri?.split('/').pop() ?? '🖼',
|
||||
name: response.fileName ?? response.uri?.split('/').pop() ?? '🖼',
|
||||
size: response.fileSize ?? 0,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
type: 'image',
|
||||
uri: `data:image/*;base64,${response.base64}`,
|
||||
width: response.width,
|
||||
@@ -308,10 +310,10 @@ const App = () => {
|
||||
|
||||
const handleSendPress = (message: MessageType.PartialText) => {
|
||||
const textMessage: MessageType.Text = {
|
||||
authorId: userId,
|
||||
author: user,
|
||||
createdAt: Date.now(),
|
||||
id: uuidv4(),
|
||||
text: message.text,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
type: 'text',
|
||||
}
|
||||
addMessage(textMessage)
|
||||
@@ -327,7 +329,7 @@ const App = () => {
|
||||
onFilePress={handleFilePress}
|
||||
onPreviewDataFetched={handlePreviewDataFetched}
|
||||
onSendPress={handleSendPress}
|
||||
user={{ id: userId }}
|
||||
user={user}
|
||||
/>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
|
||||
@@ -5,15 +5,15 @@ title: Basic Usage
|
||||
|
||||
You start with a `<Chat />` component that will render a chat screen. It has 3 required props:
|
||||
|
||||
* `messages` - an array of messages to be rendered. Accepts any message, see [types](types). If you have your message types you will need to map those to any of the defined ones. Let us know if we need to add more message types or add more fields to the existing ones.
|
||||
* `onSendPress` - a function that will have a partial text message as a parameter. See [types](types) for more info on how types are structured. From the partial text message you need to create a text message which will at least have `authorId`, `id`, `text` and `type: 'text'`, this is done by you because we wanted to give you more control over those values.
|
||||
* `user` - a [User](types#user) object, that has only one required field - an `id`, used to determine the message author.
|
||||
- `messages` - an array of messages to be rendered. Accepts any message, see [types](types). If you have your message types you will need to map those to any of the defined ones. Let us know if we need to add more message types or add more fields to the existing ones.
|
||||
- `onSendPress` - a function that will have a partial text message as a parameter. See [types](types) for more info on how types are structured. From the partial text message you need to create a text message which will at least have `author`, `id`, `text` and `type: 'text'`, this is done by you because we wanted to give you more control over those values.
|
||||
- `user` - a [User](types#user) object, that has only one required field - an `id`, used to determine the message author.
|
||||
|
||||
Below you will find a drop-in example of the chat with only text messages.
|
||||
|
||||
:::note
|
||||
|
||||
Try to write any URL, for example, https://flyer.chat, it should be unwrapped in a rich preview.
|
||||
Try to write any URL, for example, flyer.chat, it should be unwrapped in a rich preview.
|
||||
|
||||
:::
|
||||
|
||||
@@ -35,22 +35,22 @@ const uuidv4 = () => {
|
||||
const v = c === 'x' ? r : (r % 4) + 8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
const userId = '06c33e8b-e835-4736-80f4-63f44b66666c'
|
||||
const [messages, setMessages] = useState<MessageType.Any[]>([])
|
||||
const user = { id: '06c33e8b-e835-4736-80f4-63f44b66666c' }
|
||||
|
||||
const addMessage = (message: MessageType.Any) => {
|
||||
setMessages([{ ...message, status: 'read' }, ...messages])
|
||||
setMessages([message, ...messages])
|
||||
}
|
||||
|
||||
const handleSendPress = (message: MessageType.PartialText) => {
|
||||
const textMessage: MessageType.Text = {
|
||||
authorId: userId,
|
||||
author: user,
|
||||
createdAt: Date.now(),
|
||||
id: uuidv4(),
|
||||
text: message.text,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
type: 'text',
|
||||
}
|
||||
addMessage(textMessage)
|
||||
@@ -63,7 +63,7 @@ const App = () => {
|
||||
<Chat
|
||||
messages={messages}
|
||||
onSendPress={handleSendPress}
|
||||
user={{ id: userId }}
|
||||
user={user}
|
||||
/>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
|
||||
@@ -10,15 +10,14 @@ Question mark shows optional types.
|
||||
:::
|
||||
|
||||
| Name | Type | Description |
|
||||
|------------------------|---------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------|
|
||||
| dateDividerFormat? | string | Date format used for dividers |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| dateFormat? | string | Date format used for dividers |
|
||||
| flatListProps? | `FlatListProps<MessageType.Any[]>` | Main flat list props |
|
||||
| inputProps? | [InputAdditionalProps](#inputadditionalprops) | Additional props for the components that are on the bottom bar |
|
||||
| inputProps? | [InputAdditionalProps](#inputadditionalprops) | Additional props for the components that are on the bottom bar |
|
||||
| isAttachmentUploading? | boolean | If true, shows spinner instead of attachment button, useful to show while something is being uploaded |
|
||||
| l10nOverride? | ... | See [localization](localization) |
|
||||
| locale? | ... | See [localization](localization) |
|
||||
| messages | MessageType.Any[] | Messages array |
|
||||
| messageTimeFormat? | string | Time format under messages |
|
||||
| onAttachmentPress? | () => void | Called when attachment button is pressed |
|
||||
| onFilePress? | (message: MessageType.File) => void | Called when user taps on a file message |
|
||||
| onPreviewDataFetched? | ({ message, previewData }: { message: MessageType.Text; previewData: PreviewData }) => void | Called when a link that is found in the text is unwrapped in a rich preview. Use it to save the data. |
|
||||
@@ -30,24 +29,23 @@ Question mark shows optional types.
|
||||
| theme? | ... | See [themes](themes) |
|
||||
| user | User | Current logged in user, used to determine the message author |
|
||||
|
||||
|
||||
### InputAdditionalProps
|
||||
|
||||
| Name | Type | Description |
|
||||
|-------------------------------------------|---------------------------------------------------------------------|---------------------------------------------|
|
||||
| ----------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| attachmentButtonProps? | [AttachmentButtonAdditionalProps](#attachmentbuttonadditionalprops) | Additional props for the attachment button |
|
||||
| attachmentCircularActivityIndicatorProps? | [CircularActivityIndicatorProps](#circularactivityindicatorprops) | Spinner props (see `isAttachmentUploading`) |
|
||||
|
||||
### AttachmentButtonAdditionalProps
|
||||
|
||||
| Name | Type | Description |
|
||||
|------------------------|-----------------------|-------------------------------------|
|
||||
| ---------------------- | --------------------- | ----------------------------------- |
|
||||
| touchableOpacityProps? | TouchableOpacityProps | Attachment button's touchable props |
|
||||
|
||||
### CircularActivityIndicatorProps
|
||||
|
||||
| Name | Type | Description |
|
||||
|--------|------------------------|----------------------------|
|
||||
| ------ | ---------------------- | -------------------------- |
|
||||
| color | ColorValue | Spinner's color |
|
||||
| size? | number | Spinner's size |
|
||||
| style? | `StyleProp<ViewStyle>` | Spinner's container styles |
|
||||
|
||||
@@ -45,25 +45,11 @@ Question mark shows optional types.
|
||||
| Name | Type | Description |
|
||||
|-----------------|------------|--------------------------------------------------------------------|
|
||||
| background | ColorValue | Used as a background color of a chat component |
|
||||
| caption | ColorValue | Color usually goes with a `caption` text style |
|
||||
| error | ColorValue | Color to indicate something bad happened (usually - shades of red) |
|
||||
| inputBackground | ColorValue | Color of the bottom bar where text input is |
|
||||
| inputText | ColorValue | Color of the text input's text and attachment/send buttons |
|
||||
| primary | ColorValue | Primary color of the chat used as a background of sent messages |
|
||||
| primaryText | ColorValue | Color of the text on a `primary` color |
|
||||
| secondary | ColorValue | Secondary color, used as a background of received messages |
|
||||
| secondaryText | ColorValue | Color of the text on a `secondary` color |
|
||||
| subtitle2 | ColorValue | Color of the `subtitle2` text style |
|
||||
|
||||
### ThemeFonts
|
||||
|
||||
| Name | Type | Description |
|
||||
|-----------|------------------------|-----------------------------------------------------------------|
|
||||
| body1 | `StyleProp<TextStyle>` | Used as a primary text style in messages |
|
||||
| body2 | `StyleProp<TextStyle>` | Slightly smaller `body1` |
|
||||
| caption | `StyleProp<TextStyle>` | Smallest text style, used for displaying message's time |
|
||||
| subtitle1 | `StyleProp<TextStyle>` | Largest text style, used for displaying title of a link preview |
|
||||
| subtitle2 | `StyleProp<TextStyle>` | Subtitle, used for date dividers in the chat |
|
||||
|
||||
### ThemeIcons
|
||||
|
||||
@@ -72,5 +58,5 @@ Question mark shows optional types.
|
||||
| attachmentButtonIcon? | ImageSourcePropType | Icon for select attachment button |
|
||||
| deliveredIcon? | ImageSourcePropType | Icon for message's `delivered` status |
|
||||
| documentIcon? | ImageSourcePropType | Icon inside file message |
|
||||
| readIcon? | ImageSourcePropType | Icon for message's `read` status |
|
||||
| seenIcon? | ImageSourcePropType | Icon for message's `seen` status |
|
||||
| sendButtonIcon? | ImageSourcePropType | Icon for send button |
|
||||
|
||||
@@ -3,7 +3,7 @@ id: types
|
||||
title: Types
|
||||
---
|
||||
|
||||
There are 3 supported message types at the moment - `File`, `Image` and `Text`. All of them have corresponding "partial" message types, that include only the message's content. "Partial" messages are useful to create the content and then pass it to some kind of a backend service, which will assign fields like `id` or `authorId` etc, returning a "full" message which can be passed to `messages` prop of the `<Chat />`.
|
||||
There are 3 supported message types at the moment - `File`, `Image` and `Text`. All of them have corresponding "partial" message types, that include only the message's content. "Partial" messages are useful to create the content and then pass it to some kind of a backend service, which will assign fields like `id` or `author` etc, returning a "full" message which can be passed to `messages` prop of the `<Chat />`. In addition to that, there are `Custom` and `Unsupported` types. `Custom` can be used to build anything you want, and `Unsupported` is just a placeholder to have backwards compatibility.
|
||||
|
||||
## Base
|
||||
|
||||
@@ -15,20 +15,22 @@ Question mark shows optional types.
|
||||
|
||||
:::
|
||||
|
||||
| Name | Type | Description |
|
||||
|------------|--------------------------------------|--------------------------|
|
||||
| authorId | string | Message's author |
|
||||
| id | string | Message's ID |
|
||||
| status? | `delivered` `error` `read` `sending` | Message's status |
|
||||
| timestamp? | number | Timestamp in **seconds** |
|
||||
| type | `file` `image` `text` | Message's type |
|
||||
| Name | Type | Description |
|
||||
| ---------- | -------------------------------------------- | --------------------------------------------------------------- |
|
||||
| author | [User](#user) | Message's author |
|
||||
| createdAt? | number | Timestamp in **milliseconds** |
|
||||
| id | string | Message's ID |
|
||||
| metadata? | Record<string, any> | Additional custom metadata or attributes related to the message |
|
||||
| roomId? | string | ID of the room where this message is sent |
|
||||
| status? | `delivered` `error` `seen` `sending` `sent` | Message's status |
|
||||
| type | `custom` `file` `image` `text` `unsupported` | Message's type |
|
||||
|
||||
## Partial file
|
||||
|
||||
| Name | Type | Description |
|
||||
|-----------|--------|---------------------------------------------|
|
||||
| fileName | string | File's name |
|
||||
| --------- | ------ | ------------------------------------------- |
|
||||
| mimeType? | string | File's MIME type |
|
||||
| name | string | File's name |
|
||||
| size | number | Size in **bytes** |
|
||||
| uri | string | Supports both local resource and remote URL |
|
||||
|
||||
@@ -40,13 +42,13 @@ File message is a combination of base and partial file types, where the base's `
|
||||
|
||||
Even though `height` and `width` are optional, we recommend setting those (because you will anyway have them from the image picker) for a better overall look and feel, since the placeholder of this size will be rendered and when the image is available it will just replace it.
|
||||
|
||||
| Name | Type | Description |
|
||||
|-----------|--------|---------------------------------------------|
|
||||
| height? | number | Image's height |
|
||||
| imageName | string | Image's name |
|
||||
| size | number | Size in **bytes** |
|
||||
| uri | string | Supports both local resource and remote URL |
|
||||
| width? | number | Image's width |
|
||||
| Name | Type | Description |
|
||||
| ------- | ------ | ------------------------------------------- |
|
||||
| height? | number | Image's height |
|
||||
| name | string | Image's name |
|
||||
| size | number | Size in **bytes** |
|
||||
| uri | string | Supports both local resource and remote URL |
|
||||
| width? | number | Image's width |
|
||||
|
||||
### Image
|
||||
|
||||
@@ -55,7 +57,7 @@ Image message is a combination of base and partial image types, where the base's
|
||||
## Partial text
|
||||
|
||||
| Name | Type | Description |
|
||||
|--------------|--------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|
|
||||
| ------------ | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
|
||||
| previewData? | [PreviewData](https://github.com/flyerhq/react-native-link-preview/blob/main/src/types.ts) | You shouldn't probably set this field directly, use `onPreviewDataFetched` callback |
|
||||
| text | string | Text |
|
||||
|
||||
@@ -67,9 +69,13 @@ Text message is a combination of base and partial text types, where the base's `
|
||||
|
||||
The only required field for the user is the `id`, used to determine the message author, however, you can pass additional data if you will want to render all available users for the chat or a conversation tile.
|
||||
|
||||
| Name | Type | Description |
|
||||
|------------|--------|--------------------------|
|
||||
| avatarUrl? | string | User's avatar remote URL |
|
||||
| firstName? | string | User's first name |
|
||||
| id | string | Unique ID |
|
||||
| lastName? | string | User's last name |
|
||||
| Name | Type | Description |
|
||||
| ---------- | ---------------------------------- | ------------------------------------------------------------ |
|
||||
| createdAt? | number | Created user timestamp, in **milliseconds** |
|
||||
| firstName? | string | User's first name |
|
||||
| id | string | Unique ID |
|
||||
| imageUrl? | string | User's avatar remote URL |
|
||||
| lastName? | string | User's last name |
|
||||
| lastSeen? | number | Timestamp when user was last visible, in **milliseconds** |
|
||||
| metadata? | Record<string, any> | Additional custom metadata or attributes related to the user |
|
||||
| role? | `admin` `agent` `moderator` `user` | User's role |
|
||||
|
||||
@@ -267,7 +267,7 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-get-random-values (1.7.0):
|
||||
- React-Core
|
||||
- react-native-image-picker (3.8.0):
|
||||
- react-native-image-picker (4.0.3):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (3.2.0):
|
||||
- React-Core
|
||||
@@ -509,7 +509,7 @@ SPEC CHECKSUMS:
|
||||
React-jsinspector: 500a59626037be5b3b3d89c5151bc3baa9abf1a9
|
||||
react-native-document-picker: 1a7518132d4a06b67f459be9bb1464a567d2b3b4
|
||||
react-native-get-random-values: 237bffb1c7e05fb142092681531810a29ba53015
|
||||
react-native-image-picker: ec63410edbcacc543acd77dcccfc056da39320ab
|
||||
react-native-image-picker: 474cf2c33c2b6671da53d293a16c97995f0aec15
|
||||
react-native-safe-area-context: f0906bf8bc9835ac9a9d3f97e8bde2a997d8da79
|
||||
React-perflogger: aad6d4b4a267936b3667260d1f649b6f6069a675
|
||||
React-RCTActionSheet: fc376be462c9c8d6ad82c0905442fd77f82a9d2a
|
||||
|
||||
@@ -3,8 +3,16 @@ const fs = require('fs')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
|
||||
const users = [
|
||||
{ id: '06c33e8b-e835-4736-80f4-63f44b66666c', name: 'Alex' },
|
||||
{ id: '8c72d647-8c5d-4248-b195-e24a4372ea3d', name: 'Daria' },
|
||||
{
|
||||
firstName: 'John',
|
||||
id: 'b4878b96-efbc-479a-8291-474ef323dec7',
|
||||
imageUrl: 'https://avatars.githubusercontent.com/u/14123304?v=4',
|
||||
},
|
||||
{
|
||||
firstName: 'Jane',
|
||||
id: '06c33e8b-e835-4736-80f4-63f44b66666c',
|
||||
imageUrl: 'https://avatars.githubusercontent.com/u/33809426?v=4',
|
||||
},
|
||||
]
|
||||
|
||||
let numberOfMessages = 10
|
||||
@@ -17,15 +25,15 @@ if (!isNaN(arg) && parseInt(arg) > 0) {
|
||||
const messages = [...Array(numberOfMessages)].map((_, index) => {
|
||||
const randomText = Math.round(Math.random())
|
||||
const text = randomText ? casual.text : casual.sentence
|
||||
const randomAuthorId = Math.round(Math.random())
|
||||
const authorId = randomAuthorId ? users[0].id : users[1].id
|
||||
const timestamp = Math.floor(Date.now() / 1000) - index
|
||||
const randomAuthor = Math.round(Math.random())
|
||||
const author = randomAuthor ? users[0] : users[1]
|
||||
const createdAt = Date.now() - index
|
||||
const data = {
|
||||
authorId,
|
||||
author,
|
||||
createdAt,
|
||||
id: uuidv4(),
|
||||
status: 'read',
|
||||
status: 'seen',
|
||||
text,
|
||||
timestamp,
|
||||
type: 'text',
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -10,12 +10,12 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import data from './messages.json'
|
||||
|
||||
const App = () => {
|
||||
const userId = '06c33e8b-e835-4736-80f4-63f44b66666c'
|
||||
const { showActionSheetWithOptions } = useActionSheet()
|
||||
const [messages, setMessages] = useState(data as MessageType.Any[])
|
||||
const user = { id: '06c33e8b-e835-4736-80f4-63f44b66666c' }
|
||||
|
||||
const addMessage = (message: MessageType.Any) => {
|
||||
setMessages([{ ...message, status: 'read' }, ...messages])
|
||||
setMessages([message, ...messages])
|
||||
}
|
||||
|
||||
const handleAttachmentPress = () => {
|
||||
@@ -37,24 +37,18 @@ const App = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const handleFilePress = async (message: MessageType.File) => {
|
||||
try {
|
||||
await FileViewer.open(message.uri, { showOpenWithDialog: true })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleFileSelection = async () => {
|
||||
try {
|
||||
const response = await DocumentPicker.pick({
|
||||
type: [DocumentPicker.types.allFiles],
|
||||
})
|
||||
const fileMessage: MessageType.File = {
|
||||
authorId: userId,
|
||||
fileName: response.name,
|
||||
author: user,
|
||||
createdAt: Date.now(),
|
||||
id: uuidv4(),
|
||||
mimeType: response.type,
|
||||
name: response.name,
|
||||
size: response.size,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
type: 'file',
|
||||
uri: response.uri,
|
||||
}
|
||||
@@ -79,13 +73,12 @@ const App = () => {
|
||||
|
||||
if (response?.base64) {
|
||||
const imageMessage: MessageType.Image = {
|
||||
authorId: userId,
|
||||
author: user,
|
||||
createdAt: Date.now(),
|
||||
height: response.height,
|
||||
id: uuidv4(),
|
||||
imageName:
|
||||
response.fileName ?? response.uri?.split('/').pop() ?? '🖼',
|
||||
name: response.fileName ?? response.uri?.split('/').pop() ?? '🖼',
|
||||
size: response.fileSize ?? 0,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
type: 'image',
|
||||
uri: `data:image/*;base64,${response.base64}`,
|
||||
width: response.width,
|
||||
@@ -96,6 +89,14 @@ const App = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const handleMessagePress = async (message: MessageType.Any) => {
|
||||
if (message.type === 'file') {
|
||||
try {
|
||||
await FileViewer.open(message.uri, { showOpenWithDialog: true })
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewDataFetched = ({
|
||||
message,
|
||||
previewData,
|
||||
@@ -112,10 +113,10 @@ const App = () => {
|
||||
|
||||
const handleSendPress = (message: MessageType.PartialText) => {
|
||||
const textMessage: MessageType.Text = {
|
||||
authorId: userId,
|
||||
author: user,
|
||||
createdAt: Date.now(),
|
||||
id: uuidv4(),
|
||||
text: message.text,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
type: 'text',
|
||||
}
|
||||
addMessage(textMessage)
|
||||
@@ -125,10 +126,10 @@ const App = () => {
|
||||
<Chat
|
||||
messages={messages}
|
||||
onAttachmentPress={handleAttachmentPress}
|
||||
onFilePress={handleFilePress}
|
||||
onMessagePress={handleMessagePress}
|
||||
onPreviewDataFetched={handlePreviewDataFetched}
|
||||
onSendPress={handleSendPress}
|
||||
user={{ id: userId }}
|
||||
user={user}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,43 +1,71 @@
|
||||
import { MessageType, Size, User } from '../src/types'
|
||||
|
||||
export const defaultDerivedMessageProps = {
|
||||
nextMessageInGroup: false,
|
||||
offset: 12,
|
||||
showName: false,
|
||||
showStatus: true,
|
||||
}
|
||||
|
||||
export const fileMessage: MessageType.File = {
|
||||
authorId: 'userId',
|
||||
fileName: 'flyer.pdf',
|
||||
author: {
|
||||
id: 'userId',
|
||||
},
|
||||
createdAt: 2000000,
|
||||
id: 'file-uuidv4',
|
||||
mimeType: 'application/pdf',
|
||||
name: 'flyer.pdf',
|
||||
size: 15000,
|
||||
status: 'read',
|
||||
timestamp: 2000000,
|
||||
status: 'seen',
|
||||
type: 'file',
|
||||
uri: 'file:///Users/admin/flyer.pdf',
|
||||
}
|
||||
|
||||
export const derivedFileMessage: MessageType.DerivedFile = {
|
||||
...fileMessage,
|
||||
...defaultDerivedMessageProps,
|
||||
}
|
||||
|
||||
export const imageMessage: MessageType.Image = {
|
||||
authorId: 'image-userId',
|
||||
author: {
|
||||
id: 'image-userId',
|
||||
},
|
||||
createdAt: 0,
|
||||
height: 100,
|
||||
id: 'image-uuidv4',
|
||||
imageName: 'imageName',
|
||||
name: 'name',
|
||||
size: 15000,
|
||||
status: 'sending',
|
||||
timestamp: 0,
|
||||
type: 'image',
|
||||
uri: 'https://avatars1.githubusercontent.com/u/59206044',
|
||||
width: 100,
|
||||
}
|
||||
|
||||
export const derivedImageMessage: MessageType.DerivedImage = {
|
||||
...imageMessage,
|
||||
...defaultDerivedMessageProps,
|
||||
}
|
||||
|
||||
export const size: Size = {
|
||||
height: 896,
|
||||
width: 414,
|
||||
}
|
||||
|
||||
export const textMessage: MessageType.Text = {
|
||||
authorId: 'userId',
|
||||
author: {
|
||||
id: 'userId',
|
||||
},
|
||||
createdAt: 0,
|
||||
id: 'uuidv4',
|
||||
text: 'text',
|
||||
timestamp: 0,
|
||||
type: 'text',
|
||||
}
|
||||
|
||||
export const derivedTextMessage: MessageType.DerivedText = {
|
||||
...textMessage,
|
||||
...defaultDerivedMessageProps,
|
||||
}
|
||||
|
||||
export const user: User = {
|
||||
id: 'userId',
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.0.3",
|
||||
"description": "Actively maintained, community-driven chat UI implementation with an optional Firebase BaaS.",
|
||||
"homepage": "https://flyer.chat",
|
||||
"main": "lib/index.js",
|
||||
"main": "src/index.ts",
|
||||
"types": "lib/index.d.ts",
|
||||
"author": "Oleksandr Demchenko <alexdemchenko@yahoo.com>",
|
||||
"contributors": [
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@flyerhq/react-native-keyboard-accessory-view": "^2.2.0",
|
||||
"@flyerhq/react-native-link-preview": "^1.3.1",
|
||||
"@flyerhq/react-native-link-preview": "^1.3.8",
|
||||
"dayjs": "^1.10.5",
|
||||
"react-native-image-viewing": "^0.2.0",
|
||||
"react-native-parsed-text": "^0.0.22"
|
||||
@@ -102,7 +102,10 @@
|
||||
"typeCoverage": {
|
||||
"cache": true,
|
||||
"ignoreCatch": true,
|
||||
"ignoreNonNullAssertion": true,
|
||||
"ignoreUnread": true,
|
||||
"is": 100,
|
||||
"showRelativePath": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 199 B After Width: | Height: | Size: 186 B |
|
Before Width: | Height: | Size: 220 B After Width: | Height: | Size: 217 B |
|
Before Width: | Height: | Size: 250 B After Width: | Height: | Size: 259 B |
BIN
src/assets/icon-error.png
Normal file
|
After Width: | Height: | Size: 292 B |
BIN
src/assets/icon-error@2x.png
Normal file
|
After Width: | Height: | Size: 526 B |
BIN
src/assets/icon-error@3x.png
Normal file
|
After Width: | Height: | Size: 791 B |
|
Before Width: | Height: | Size: 209 B |
|
Before Width: | Height: | Size: 232 B |
|
Before Width: | Height: | Size: 271 B |
BIN
src/assets/icon-reply.png
Normal file
|
After Width: | Height: | Size: 239 B |
BIN
src/assets/icon-reply@2x.png
Normal file
|
After Width: | Height: | Size: 316 B |
BIN
src/assets/icon-reply@3x.png
Normal file
|
After Width: | Height: | Size: 412 B |
BIN
src/assets/icon-seen.png
Normal file
|
After Width: | Height: | Size: 190 B |
BIN
src/assets/icon-seen@2x.png
Normal file
|
After Width: | Height: | Size: 226 B |
BIN
src/assets/icon-seen@3x.png
Normal file
|
After Width: | Height: | Size: 304 B |
BIN
src/assets/icon-x.png
Normal file
|
After Width: | Height: | Size: 210 B |
BIN
src/assets/icon-x@2x.png
Normal file
|
After Width: | Height: | Size: 263 B |
BIN
src/assets/icon-x@3x.png
Normal file
|
After Width: | Height: | Size: 343 B |
@@ -36,16 +36,12 @@ export const AttachmentButton = ({
|
||||
{...touchableOpacityProps}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<Image
|
||||
source={
|
||||
theme.icons?.attachmentButtonIcon ??
|
||||
require('../../assets/icon-attachment.png')
|
||||
}
|
||||
style={StyleSheet.flatten([
|
||||
styles.image,
|
||||
{ tintColor: theme.colors.inputText },
|
||||
])}
|
||||
/>
|
||||
{theme.icons?.attachmentButtonIcon?.() ?? (
|
||||
<Image
|
||||
source={require('../../assets/icon-attachment.png')}
|
||||
style={[styles.image, { tintColor: theme.colors.inputText }]}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
76
src/components/Avatar/Avatar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react'
|
||||
import { Image, StyleSheet, Text, View } from 'react-native'
|
||||
|
||||
import { MessageType, Theme } from '../../types'
|
||||
import { getUserAvatarNameColor, getUserName } from '../../utils'
|
||||
|
||||
export const Avatar = React.memo(
|
||||
({
|
||||
author,
|
||||
currentUserIsAuthor,
|
||||
showAvatar,
|
||||
showUserAvatars,
|
||||
theme,
|
||||
}: {
|
||||
author: MessageType.Any['author']
|
||||
currentUserIsAuthor: boolean
|
||||
showAvatar: boolean
|
||||
showUserAvatars?: boolean
|
||||
theme: Theme
|
||||
}) => {
|
||||
const renderAvatar = () => {
|
||||
const color = getUserAvatarNameColor(
|
||||
author,
|
||||
theme.colors.userAvatarNameColors
|
||||
)
|
||||
const name = getUserName(author)
|
||||
|
||||
if (author.imageUrl) {
|
||||
return (
|
||||
<Image
|
||||
accessibilityRole='image'
|
||||
resizeMode='cover'
|
||||
source={{ uri: author.imageUrl }}
|
||||
style={styles.image}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.avatarBackground, { backgroundColor: color }]}>
|
||||
<Text style={theme.fonts.userAvatarTextStyle}>
|
||||
{name ? name[0].toUpperCase() : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return !currentUserIsAuthor && showUserAvatars ? (
|
||||
<View testID='AvatarContainer'>
|
||||
{showAvatar ? renderAvatar() : <View style={styles.placeholder} />}
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
avatarBackground: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
height: 32,
|
||||
justifyContent: 'center',
|
||||
marginRight: 8,
|
||||
width: 32,
|
||||
},
|
||||
image: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
height: 32,
|
||||
justifyContent: 'center',
|
||||
marginRight: 8,
|
||||
width: 32,
|
||||
},
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
})
|
||||
57
src/components/Avatar/__tests__/Avatar.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { render } from '@testing-library/react-native'
|
||||
import * as React from 'react'
|
||||
|
||||
import { user } from '../../../../jest/fixtures'
|
||||
import { defaultTheme } from '../../../theme'
|
||||
import { Avatar } from '../Avatar'
|
||||
|
||||
describe('avatar', () => {
|
||||
it(`should render container with a placeholder`, () => {
|
||||
expect.assertions(1)
|
||||
const { getByTestId } = render(
|
||||
<Avatar
|
||||
author={user}
|
||||
currentUserIsAuthor={false}
|
||||
showAvatar={false}
|
||||
showUserAvatars
|
||||
theme={defaultTheme}
|
||||
/>
|
||||
)
|
||||
expect(getByTestId('AvatarContainer')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should render background with a first letter', () => {
|
||||
expect.assertions(1)
|
||||
const authorWithName = { ...user, firstName: 'John' }
|
||||
const { getByText } = render(
|
||||
<Avatar
|
||||
author={authorWithName}
|
||||
currentUserIsAuthor={false}
|
||||
showAvatar
|
||||
showUserAvatars
|
||||
theme={defaultTheme}
|
||||
/>
|
||||
)
|
||||
expect(getByText(authorWithName.firstName[0])).toBeDefined()
|
||||
})
|
||||
|
||||
it('should render image background', () => {
|
||||
expect.assertions(2)
|
||||
const imageUrl = 'https://avatars.githubusercontent.com/u/14123304?v=4'
|
||||
const { getAllByRole } = render(
|
||||
<Avatar
|
||||
author={{
|
||||
...user,
|
||||
imageUrl,
|
||||
}}
|
||||
currentUserIsAuthor={false}
|
||||
showAvatar
|
||||
showUserAvatars
|
||||
theme={defaultTheme}
|
||||
/>
|
||||
)
|
||||
const image = getAllByRole('image')
|
||||
expect(image).toBeDefined()
|
||||
expect(image[0]).toHaveProperty('props.source.uri', imageUrl)
|
||||
})
|
||||
})
|
||||
1
src/components/Avatar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Avatar'
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
SafeAreaView,
|
||||
StatusBar,
|
||||
StatusBarProps,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native'
|
||||
@@ -22,6 +21,7 @@ import { l10n } from '../../l10n'
|
||||
import { defaultTheme } from '../../theme'
|
||||
import { MessageType, Theme, User } from '../../types'
|
||||
import {
|
||||
calculateChatMessages,
|
||||
initLocale,
|
||||
L10nContext,
|
||||
ThemeContext,
|
||||
@@ -37,39 +37,49 @@ dayjs.extend(calendar)
|
||||
export type ChatTopLevelProps = InputTopLevelProps & MessageTopLevelProps
|
||||
|
||||
export interface ChatProps extends ChatTopLevelProps {
|
||||
dateDividerFormat?: string
|
||||
flatListProps?: FlatListProps<MessageType.Any[]>
|
||||
customDateHeaderText?: (dateTime: number) => string
|
||||
dateFormat?: string
|
||||
disableImageGallery?: boolean
|
||||
flatListProps?: FlatListProps<MessageType.DerivedAny[]>
|
||||
inputProps?: InputAdditionalProps
|
||||
l10nOverride?: Partial<Record<keyof typeof l10n[keyof typeof l10n], string>>
|
||||
locale?: keyof typeof l10n
|
||||
messages: MessageType.Any[]
|
||||
showUserNames?: boolean
|
||||
theme?: Theme
|
||||
timeFormat?: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export const Chat = ({
|
||||
dateDividerFormat = 'DD MMMM',
|
||||
customDateHeaderText,
|
||||
dateFormat,
|
||||
disableImageGallery,
|
||||
flatListProps,
|
||||
inputProps,
|
||||
isAttachmentUploading,
|
||||
l10nOverride,
|
||||
locale = 'en',
|
||||
messages,
|
||||
messageTimeFormat,
|
||||
onAttachmentPress,
|
||||
onFilePress,
|
||||
onMessageLongPress,
|
||||
onMessagePress,
|
||||
onPreviewDataFetched,
|
||||
onSendPress,
|
||||
renderCustomMessage,
|
||||
renderFileMessage,
|
||||
renderImageMessage,
|
||||
renderTextMessage,
|
||||
showUserAvatars = false,
|
||||
showUserNames = false,
|
||||
textInputProps,
|
||||
theme = defaultTheme,
|
||||
timeFormat,
|
||||
usePreviewData = true,
|
||||
user,
|
||||
}: ChatProps) => {
|
||||
const {
|
||||
container,
|
||||
dateDivider,
|
||||
emptyComponentContainer,
|
||||
emptyComponentTitle,
|
||||
flatList,
|
||||
@@ -79,23 +89,29 @@ export const Chat = ({
|
||||
} = styles({ theme })
|
||||
|
||||
const { onLayout, size } = useComponentSize()
|
||||
const list = React.useRef<FlatList<MessageType.DerivedAny>>(null)
|
||||
const [isImageViewVisible, setIsImageViewVisible] = React.useState(false)
|
||||
const [imageViewIndex, setImageViewIndex] = React.useState(0)
|
||||
const [stackEntry, setStackEntry] = React.useState<StatusBarProps>({})
|
||||
const images = messages.reduce<{ uri: string }[]>(
|
||||
(acc, curr) => (curr.type === 'image' ? [{ uri: curr.uri }, ...acc] : acc),
|
||||
[]
|
||||
)
|
||||
const list = React.useRef<FlatList<MessageType.Any>>(null)
|
||||
const messageWidth = Math.floor(Math.min(size.width * 0.77, 440))
|
||||
|
||||
const { chatMessages, gallery } = calculateChatMessages(messages, user, {
|
||||
customDateHeaderText,
|
||||
dateFormat,
|
||||
showUserNames,
|
||||
timeFormat,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
initLocale(locale)
|
||||
}, [locale])
|
||||
|
||||
const handleImagePress = React.useCallback(
|
||||
(uri: string) => {
|
||||
setImageViewIndex(images.findIndex((image) => image.uri === uri))
|
||||
(message: MessageType.Image) => {
|
||||
setImageViewIndex(
|
||||
gallery.findIndex(
|
||||
(image) => image.id === message.id && image.uri === message.uri
|
||||
)
|
||||
)
|
||||
setIsImageViewVisible(true)
|
||||
setStackEntry(
|
||||
StatusBar.pushStackEntry({
|
||||
@@ -104,7 +120,17 @@ export const Chat = ({
|
||||
})
|
||||
)
|
||||
},
|
||||
[images]
|
||||
[gallery]
|
||||
)
|
||||
|
||||
const handleMessagePress = React.useCallback(
|
||||
(message: MessageType.Any) => {
|
||||
if (message.type === 'image' && !disableImageGallery) {
|
||||
handleImagePress(message)
|
||||
}
|
||||
onMessagePress?.(message)
|
||||
},
|
||||
[disableImageGallery, handleImagePress, onMessagePress]
|
||||
)
|
||||
|
||||
// TODO: Tapping on a close button results in the next warning:
|
||||
@@ -123,93 +149,61 @@ export const Chat = ({
|
||||
})
|
||||
}
|
||||
|
||||
const keyExtractor = React.useCallback((item: MessageType.Any) => item.id, [])
|
||||
const keyExtractor = React.useCallback(
|
||||
({ id }: MessageType.DerivedAny) => id,
|
||||
[]
|
||||
)
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({ item: message, index }: { item: MessageType.Any; index: number }) => {
|
||||
// TODO: Update the logic after pagination is introduced
|
||||
const isFirst = index === 0
|
||||
const isLast = index === messages.length - 1
|
||||
const nextMessage = isLast ? undefined : messages[index + 1]
|
||||
const previousMessage = isFirst ? undefined : messages[index - 1]
|
||||
({ item: message }: { item: MessageType.DerivedAny; index: number }) => {
|
||||
const messageWidth =
|
||||
showUserAvatars &&
|
||||
message.type !== 'dateHeader' &&
|
||||
message.author.id !== user.id
|
||||
? Math.floor(Math.min(size.width * 0.72, 440))
|
||||
: Math.floor(Math.min(size.width * 0.77, 440))
|
||||
|
||||
let nextMessageDifferentDay = false
|
||||
let nextMessageSameAuthor = false
|
||||
let previousMessageSameAuthor = false
|
||||
let shouldRenderTime = !!message.timestamp
|
||||
|
||||
if (nextMessage) {
|
||||
nextMessageDifferentDay =
|
||||
!!message.timestamp &&
|
||||
!!nextMessage.timestamp &&
|
||||
!dayjs
|
||||
.unix(message.timestamp)
|
||||
.isSame(dayjs.unix(nextMessage.timestamp), 'day')
|
||||
nextMessageSameAuthor = nextMessage.authorId === message.authorId
|
||||
}
|
||||
|
||||
if (previousMessage) {
|
||||
previousMessageSameAuthor =
|
||||
previousMessage.authorId === message.authorId
|
||||
shouldRenderTime =
|
||||
!!message.timestamp &&
|
||||
!!previousMessage.timestamp &&
|
||||
(!previousMessageSameAuthor ||
|
||||
previousMessage.timestamp - message.timestamp >= 60)
|
||||
}
|
||||
const roundBorder =
|
||||
message.type !== 'dateHeader' && message.nextMessageInGroup
|
||||
const showAvatar =
|
||||
message.type !== 'dateHeader' && !message.nextMessageInGroup
|
||||
const showName = message.type !== 'dateHeader' && message.showName
|
||||
const showStatus = message.type !== 'dateHeader' && message.showStatus
|
||||
|
||||
return (
|
||||
<>
|
||||
<Message
|
||||
{...{
|
||||
message,
|
||||
messageTimeFormat,
|
||||
messageWidth,
|
||||
onFilePress,
|
||||
onImagePress: handleImagePress,
|
||||
onPreviewDataFetched,
|
||||
previousMessageSameAuthor,
|
||||
renderFileMessage,
|
||||
renderImageMessage,
|
||||
renderTextMessage,
|
||||
shouldRenderTime,
|
||||
}}
|
||||
/>
|
||||
{(nextMessageDifferentDay || (isLast && message.timestamp)) && (
|
||||
<Text
|
||||
style={StyleSheet.flatten([
|
||||
dateDivider,
|
||||
{ marginTop: nextMessageSameAuthor ? 24 : 16 },
|
||||
])}
|
||||
>
|
||||
{/* At this point we know that timestamp exists, so we can safely force unwrap it */}
|
||||
{/* type-coverage:ignore-next-line */}
|
||||
{dayjs.unix(message.timestamp!).calendar(undefined, {
|
||||
sameDay: `[${l10n[locale].today}]`,
|
||||
nextDay: dateDividerFormat,
|
||||
nextWeek: dateDividerFormat,
|
||||
lastDay: `[${l10n[locale].yesterday}]`,
|
||||
lastWeek: dateDividerFormat,
|
||||
sameElse: dateDividerFormat,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
<Message
|
||||
{...{
|
||||
message,
|
||||
messageWidth,
|
||||
onMessageLongPress,
|
||||
onMessagePress: handleMessagePress,
|
||||
onPreviewDataFetched,
|
||||
renderCustomMessage,
|
||||
renderFileMessage,
|
||||
renderImageMessage,
|
||||
renderTextMessage,
|
||||
roundBorder,
|
||||
showAvatar,
|
||||
showName,
|
||||
showStatus,
|
||||
showUserAvatars,
|
||||
usePreviewData,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[
|
||||
dateDivider,
|
||||
dateDividerFormat,
|
||||
handleImagePress,
|
||||
locale,
|
||||
messageTimeFormat,
|
||||
messageWidth,
|
||||
messages,
|
||||
onFilePress,
|
||||
handleMessagePress,
|
||||
onMessageLongPress,
|
||||
onPreviewDataFetched,
|
||||
renderCustomMessage,
|
||||
renderFileMessage,
|
||||
renderImageMessage,
|
||||
renderTextMessage,
|
||||
showUserAvatars,
|
||||
size.width,
|
||||
usePreviewData,
|
||||
user.id,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -233,17 +227,17 @@ export const Chat = ({
|
||||
contentContainerStyle={[
|
||||
flatListContentContainer,
|
||||
// eslint-disable-next-line react-native/no-inline-styles
|
||||
{ justifyContent: messages.length !== 0 ? undefined : 'center' },
|
||||
{ justifyContent: chatMessages.length !== 0 ? undefined : 'center' },
|
||||
]}
|
||||
initialNumToRender={10}
|
||||
ListEmptyComponent={renderListEmptyComponent}
|
||||
ListFooterComponent={renderListFooterComponent}
|
||||
ListFooterComponentStyle={footer}
|
||||
maxToRenderPerBatch={6}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={flatList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
{...unwrap(flatListProps)}
|
||||
data={messages}
|
||||
data={chatMessages}
|
||||
inverted
|
||||
keyboardDismissMode='interactive'
|
||||
keyExtractor={keyExtractor}
|
||||
@@ -253,12 +247,12 @@ export const Chat = ({
|
||||
/>
|
||||
),
|
||||
[
|
||||
chatMessages,
|
||||
flatList,
|
||||
flatListContentContainer,
|
||||
flatListProps,
|
||||
footer,
|
||||
keyExtractor,
|
||||
messages,
|
||||
renderItem,
|
||||
renderListEmptyComponent,
|
||||
renderListFooterComponent,
|
||||
@@ -288,8 +282,8 @@ export const Chat = ({
|
||||
/>
|
||||
</KeyboardAccessoryView>
|
||||
<ImageView
|
||||
images={images}
|
||||
imageIndex={imageViewIndex}
|
||||
images={gallery}
|
||||
onRequestClose={handleRequestClose}
|
||||
visible={isImageViewVisible}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
user,
|
||||
} from '../../../../jest/fixtures'
|
||||
import { l10n } from '../../../l10n'
|
||||
import { MessageType } from '../../../types'
|
||||
import { Chat } from '../Chat'
|
||||
|
||||
jest.useFakeTimers()
|
||||
@@ -21,9 +22,9 @@ describe('chat', () => {
|
||||
fileMessage,
|
||||
{
|
||||
...textMessage,
|
||||
createdAt: 1,
|
||||
id: 'new-uuidv4',
|
||||
status: 'delivered' as const,
|
||||
timestamp: 1,
|
||||
},
|
||||
]
|
||||
const onSendPress = jest.fn()
|
||||
@@ -43,13 +44,13 @@ describe('chat', () => {
|
||||
fileMessage,
|
||||
{
|
||||
...imageMessage,
|
||||
timestamp: 1,
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
...textMessage,
|
||||
createdAt: 2,
|
||||
id: 'new-uuidv4',
|
||||
status: 'sending' as const,
|
||||
timestamp: 2,
|
||||
},
|
||||
]
|
||||
const onSendPress = jest.fn()
|
||||
@@ -71,11 +72,17 @@ describe('chat', () => {
|
||||
const messages = [fileMessage, textMessage, imageMessage]
|
||||
const onSendPress = jest.fn()
|
||||
const onFilePress = jest.fn()
|
||||
const onMessagePress = (message: MessageType.Any) => {
|
||||
if (message.type === 'file') {
|
||||
onFilePress(message)
|
||||
}
|
||||
}
|
||||
const { getByLabelText } = render(
|
||||
<Chat
|
||||
onFilePress={onFilePress}
|
||||
onMessagePress={onMessagePress}
|
||||
messages={messages}
|
||||
onSendPress={onSendPress}
|
||||
showUserAvatars
|
||||
user={user}
|
||||
/>
|
||||
)
|
||||
@@ -85,15 +92,73 @@ describe('chat', () => {
|
||||
expect(onFilePress).toHaveBeenCalledWith(fileMessage)
|
||||
})
|
||||
|
||||
it('opens image on image message press', () => {
|
||||
expect.assertions(1)
|
||||
const messages = [imageMessage]
|
||||
const onSendPress = jest.fn()
|
||||
const onImagePress = jest.fn()
|
||||
const onMessagePress = (message: MessageType.Any) => {
|
||||
if (message.type === 'image') {
|
||||
onImagePress(message)
|
||||
}
|
||||
}
|
||||
|
||||
const onMessageLongPress = jest.fn()
|
||||
|
||||
const { getByTestId } = render(
|
||||
<Chat
|
||||
onMessagePress={onMessagePress}
|
||||
onMessageLongPress={onMessageLongPress}
|
||||
messages={messages}
|
||||
onSendPress={onSendPress}
|
||||
showUserAvatars
|
||||
user={user}
|
||||
/>
|
||||
)
|
||||
|
||||
const button = getByTestId('ContentContainer')
|
||||
fireEvent.press(button)
|
||||
expect(onImagePress).toHaveBeenCalledWith(imageMessage)
|
||||
})
|
||||
|
||||
it('fires image on image message long press', () => {
|
||||
expect.assertions(1)
|
||||
const messages = [imageMessage]
|
||||
const onSendPress = jest.fn()
|
||||
const onImagePress = jest.fn()
|
||||
const onMessagePress = (message: MessageType.Any) => {
|
||||
if (message.type === 'image') {
|
||||
onImagePress(message)
|
||||
}
|
||||
}
|
||||
|
||||
const onMessageLongPress = jest.fn()
|
||||
|
||||
const { getByTestId } = render(
|
||||
<Chat
|
||||
onMessagePress={onMessagePress}
|
||||
onMessageLongPress={onMessageLongPress}
|
||||
messages={messages}
|
||||
onSendPress={onSendPress}
|
||||
showUserAvatars
|
||||
user={user}
|
||||
/>
|
||||
)
|
||||
|
||||
const button = getByTestId('ContentContainer')
|
||||
fireEvent(button, 'onLongPress')
|
||||
expect(onMessageLongPress).toHaveBeenCalledWith(imageMessage)
|
||||
})
|
||||
|
||||
it('renders empty chat placeholder', () => {
|
||||
expect.assertions(1)
|
||||
const messages = []
|
||||
const onSendPress = jest.fn()
|
||||
const onFilePress = jest.fn()
|
||||
const onMessagePress = jest.fn()
|
||||
const { getByText } = render(
|
||||
<Chat
|
||||
onFilePress={onFilePress}
|
||||
messages={messages}
|
||||
onMessagePress={onMessagePress}
|
||||
onSendPress={onSendPress}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
@@ -8,23 +8,12 @@ export default ({ theme }: { theme: Theme }) =>
|
||||
backgroundColor: theme.colors.background,
|
||||
flex: 1,
|
||||
},
|
||||
dateDivider: StyleSheet.flatten([
|
||||
theme.fonts.subtitle2,
|
||||
{
|
||||
color: theme.colors.subtitle2,
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
]),
|
||||
emptyComponentContainer: {
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 24,
|
||||
},
|
||||
emptyComponentTitle: {
|
||||
// Ignore because it is object
|
||||
// @ts-ignore
|
||||
...theme.fonts.body1,
|
||||
color: theme.colors.caption,
|
||||
...theme.fonts.emptyChatPlaceholderTextStyle,
|
||||
textAlign: 'center',
|
||||
transform: [{ rotateX: '180deg' }],
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ColorValue,
|
||||
Easing,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
|
||||
@@ -38,7 +37,7 @@ export const CircularActivityIndicator = ({
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={StyleSheet.flatten([
|
||||
style={[
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
@@ -51,7 +50,7 @@ export const CircularActivityIndicator = ({
|
||||
},
|
||||
circle,
|
||||
style,
|
||||
])}
|
||||
]}
|
||||
testID='CircularActivityIndicator'
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ const styles = ({ color, size }: { color: ColorValue; size: number }) =>
|
||||
borderRadius: size / 2,
|
||||
borderRightColor: color,
|
||||
borderTopColor: color,
|
||||
// TODO: Check 1.5
|
||||
borderWidth: 2,
|
||||
height: size,
|
||||
width: size,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import { Image, Text, TouchableOpacity, View } from 'react-native'
|
||||
import { Image, Text, View } from 'react-native'
|
||||
|
||||
import { MessageType } from '../../types'
|
||||
import {
|
||||
@@ -11,11 +11,10 @@ import {
|
||||
import styles from './styles'
|
||||
|
||||
export interface FileMessageProps {
|
||||
message: MessageType.File
|
||||
onPress?: (message: MessageType.File) => void
|
||||
message: MessageType.DerivedFile
|
||||
}
|
||||
|
||||
export const FileMessage = ({ message, onPress }: FileMessageProps) => {
|
||||
export const FileMessage = ({ message }: FileMessageProps) => {
|
||||
const l10n = React.useContext(L10nContext)
|
||||
const theme = React.useContext(ThemeContext)
|
||||
const user = React.useContext(UserContext)
|
||||
@@ -25,29 +24,23 @@ export const FileMessage = ({ message, onPress }: FileMessageProps) => {
|
||||
user,
|
||||
})
|
||||
|
||||
const handlePress = () => onPress?.(message)
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<View
|
||||
accessibilityLabel={l10n.fileButtonAccessibilityLabel}
|
||||
accessibilityRole='button'
|
||||
onPress={handlePress}
|
||||
style={container}
|
||||
>
|
||||
<View style={container}>
|
||||
<View style={iconContainer}>
|
||||
<View style={iconContainer}>
|
||||
{theme.icons?.documentIcon?.() ?? (
|
||||
<Image
|
||||
source={
|
||||
theme.icons?.documentIcon ??
|
||||
require('../../assets/icon-document.png')
|
||||
}
|
||||
source={require('../../assets/icon-document.png')}
|
||||
style={icon}
|
||||
/>
|
||||
</View>
|
||||
<View style={textContainer}>
|
||||
<Text style={name}>{message.fileName}</Text>
|
||||
<Text style={size}>{formatBytes(message.size)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={textContainer}>
|
||||
<Text style={name}>{message.name}</Text>
|
||||
<Text style={size}>{formatBytes(message.size)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const styles = ({
|
||||
theme,
|
||||
user,
|
||||
}: {
|
||||
message: MessageType.File
|
||||
message: MessageType.DerivedFile
|
||||
theme: Theme
|
||||
user?: User
|
||||
}) =>
|
||||
@@ -20,40 +20,31 @@ const styles = ({
|
||||
},
|
||||
icon: {
|
||||
tintColor:
|
||||
user?.id === message.authorId
|
||||
? theme.colors.primaryText
|
||||
: theme.colors.primary,
|
||||
user?.id === message.author.id
|
||||
? theme.colors.sentMessageDocumentIconColor
|
||||
: theme.colors.receivedMessageDocumentIconColor,
|
||||
},
|
||||
iconContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor:
|
||||
user?.id === message.authorId
|
||||
? `${String(theme.colors.primaryText)}33`
|
||||
: `${String(theme.colors.primary)}33`,
|
||||
user?.id === message.author.id
|
||||
? `${String(theme.colors.sentMessageDocumentIconColor)}33`
|
||||
: `${String(theme.colors.receivedMessageDocumentIconColor)}33`,
|
||||
borderRadius: 21,
|
||||
height: 42,
|
||||
justifyContent: 'center',
|
||||
width: 42,
|
||||
},
|
||||
name: StyleSheet.flatten([
|
||||
theme.fonts.body1,
|
||||
{
|
||||
color:
|
||||
user?.id === message.authorId
|
||||
? theme.colors.primaryText
|
||||
: theme.colors.secondaryText,
|
||||
},
|
||||
]),
|
||||
size: StyleSheet.flatten([
|
||||
theme.fonts.caption,
|
||||
{
|
||||
color:
|
||||
user?.id === message.authorId
|
||||
? `${String(theme.colors.primaryText)}80`
|
||||
: theme.colors.caption,
|
||||
marginTop: 4,
|
||||
},
|
||||
]),
|
||||
name:
|
||||
user?.id === message.author.id
|
||||
? theme.fonts.sentMessageBodyTextStyle
|
||||
: theme.fonts.receivedMessageBodyTextStyle,
|
||||
size: {
|
||||
...(user?.id === message.author.id
|
||||
? theme.fonts.sentMessageCaptionTextStyle
|
||||
: theme.fonts.receivedMessageCaptionTextStyle),
|
||||
marginTop: 4,
|
||||
},
|
||||
textContainer: {
|
||||
flexShrink: 1,
|
||||
marginLeft: 16,
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Image,
|
||||
ImageBackground,
|
||||
Text,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { Image, ImageBackground, Text, View } from 'react-native'
|
||||
|
||||
import { MessageType, Size } from '../../types'
|
||||
import { formatBytes, ThemeContext, UserContext } from '../../utils'
|
||||
import styles from './styles'
|
||||
|
||||
export interface ImageMessageProps {
|
||||
message: MessageType.Image
|
||||
message: MessageType.DerivedImage
|
||||
messageWidth: number
|
||||
onPress: (uri: string) => void
|
||||
onPress?: (message: MessageType.DerivedImage) => void
|
||||
}
|
||||
|
||||
export const ImageMessage = ({
|
||||
message,
|
||||
messageWidth,
|
||||
onPress,
|
||||
}: ImageMessageProps) => {
|
||||
export const ImageMessage = ({ message, messageWidth }: ImageMessageProps) => {
|
||||
const theme = React.useContext(ThemeContext)
|
||||
const user = React.useContext(UserContext)
|
||||
const defaultHeight = message.height ?? 0
|
||||
@@ -56,34 +46,30 @@ export const ImageMessage = ({
|
||||
)
|
||||
}, [defaultHeight, defaultWidth, message.uri])
|
||||
|
||||
const handlePress = () => onPress(message.uri)
|
||||
|
||||
const renderImage = () => {
|
||||
return (
|
||||
<Image
|
||||
accessibilityRole='image'
|
||||
resizeMode={isMinimized ? 'cover' : 'contain'}
|
||||
source={{ uri: message.uri }}
|
||||
style={isMinimized ? minimizedImage : image}
|
||||
/>
|
||||
<View>
|
||||
<Image
|
||||
accessibilityRole='image'
|
||||
resizeMode={isMinimized ? 'cover' : 'contain'}
|
||||
source={{ uri: message.uri }}
|
||||
style={isMinimized ? minimizedImage : image}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return isMinimized ? (
|
||||
<View style={minimizedImageContainer}>
|
||||
<TouchableWithoutFeedback onPress={handlePress}>
|
||||
{renderImage()}
|
||||
</TouchableWithoutFeedback>
|
||||
{renderImage()}
|
||||
<View style={textContainer}>
|
||||
<Text style={nameText}>{message.imageName}</Text>
|
||||
<Text style={nameText}>{message.name}</Text>
|
||||
<Text style={sizeText}>{formatBytes(message.size)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<ImageBackground blurRadius={26} source={{ uri: message.uri }} style={{}}>
|
||||
<TouchableWithoutFeedback onPress={handlePress}>
|
||||
{renderImage()}
|
||||
</TouchableWithoutFeedback>
|
||||
{renderImage()}
|
||||
</ImageBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { act, fireEvent, render } from '@testing-library/react-native'
|
||||
import { act, render } from '@testing-library/react-native'
|
||||
import * as React from 'react'
|
||||
import { Image } from 'react-native'
|
||||
|
||||
import { imageMessage, size } from '../../../../jest/fixtures'
|
||||
import { derivedImageMessage, size } from '../../../../jest/fixtures'
|
||||
import { ImageMessage } from '../ImageMessage'
|
||||
|
||||
describe('image message', () => {
|
||||
@@ -10,14 +10,18 @@ describe('image message', () => {
|
||||
expect.assertions(5)
|
||||
const getSizeMock = jest.spyOn(Image, 'getSize')
|
||||
getSizeMock.mockImplementation(() => {})
|
||||
const message = { ...imageMessage, height: undefined, width: undefined }
|
||||
const message = {
|
||||
...derivedImageMessage,
|
||||
height: undefined,
|
||||
width: undefined,
|
||||
}
|
||||
const onPress = jest.fn()
|
||||
const { getByRole } = render(
|
||||
<ImageMessage message={message} messageWidth={440} onPress={onPress} />
|
||||
)
|
||||
expect(getSizeMock).toHaveBeenCalledTimes(1)
|
||||
const getSizeArgs = getSizeMock.mock.calls[0]
|
||||
expect(getSizeArgs[0]).toBe(imageMessage.uri)
|
||||
expect(getSizeArgs[0]).toBe(derivedImageMessage.uri)
|
||||
const success = getSizeArgs[1]
|
||||
const error = getSizeArgs[2]
|
||||
act(() => {
|
||||
@@ -43,19 +47,4 @@ describe('image message', () => {
|
||||
expect(errorImageComponent.props).toHaveProperty('style.width', 64)
|
||||
getSizeMock.mockRestore()
|
||||
})
|
||||
|
||||
it('handles press', () => {
|
||||
expect.assertions(1)
|
||||
const onPress = jest.fn()
|
||||
const { getByRole } = render(
|
||||
<ImageMessage
|
||||
message={imageMessage}
|
||||
messageWidth={440}
|
||||
onPress={onPress}
|
||||
/>
|
||||
)
|
||||
const button = getByRole('image').parent
|
||||
fireEvent.press(button)
|
||||
expect(onPress).toHaveBeenCalledWith(imageMessage.uri)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,30 +31,21 @@ const styles = ({
|
||||
minimizedImageContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor:
|
||||
user?.id === message.authorId
|
||||
user?.id === message.author.id
|
||||
? theme.colors.primary
|
||||
: theme.colors.secondary,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
nameText: StyleSheet.flatten([
|
||||
theme.fonts.body1,
|
||||
{
|
||||
color:
|
||||
user?.id === message.authorId
|
||||
? theme.colors.primaryText
|
||||
: theme.colors.secondaryText,
|
||||
},
|
||||
]),
|
||||
sizeText: StyleSheet.flatten([
|
||||
theme.fonts.caption,
|
||||
{
|
||||
color:
|
||||
user?.id === message.authorId
|
||||
? `${String(theme.colors.primaryText)}80`
|
||||
: theme.colors.caption,
|
||||
marginTop: 4,
|
||||
},
|
||||
]),
|
||||
nameText:
|
||||
user?.id === message.author.id
|
||||
? theme.fonts.sentMessageBodyTextStyle
|
||||
: theme.fonts.receivedMessageBodyTextStyle,
|
||||
sizeText: {
|
||||
...(user?.id === message.author.id
|
||||
? theme.fonts.sentMessageCaptionTextStyle
|
||||
: theme.fonts.receivedMessageCaptionTextStyle),
|
||||
marginTop: 4,
|
||||
},
|
||||
textContainer: {
|
||||
flexShrink: 1,
|
||||
marginRight: 24,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import { StyleSheet, TextInput, TextInputProps, View } from 'react-native'
|
||||
import { TextInput, TextInputProps, View } from 'react-native'
|
||||
|
||||
import { MessageType } from '../../types'
|
||||
import { L10nContext, ThemeContext, unwrap, UserContext } from '../../utils'
|
||||
@@ -53,8 +53,15 @@ export const Input = ({
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
onSendPress({ text: value.trim() })
|
||||
setText('')
|
||||
const trimmedValue = value.trim()
|
||||
|
||||
// Impossible to test since button is not visible when value is empty.
|
||||
// Additional check for the keyboard input.
|
||||
/* istanbul ignore next */
|
||||
if (trimmedValue) {
|
||||
onSendPress({ text: trimmedValue })
|
||||
setText('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -83,7 +90,7 @@ export const Input = ({
|
||||
underlineColorAndroid='transparent'
|
||||
{...textInputProps}
|
||||
// Keep our implementation but allow user to use these `TextInputProps`
|
||||
style={StyleSheet.flatten([input, textInputProps?.style])}
|
||||
style={[input, textInputProps?.style]}
|
||||
onChangeText={handleChangeText}
|
||||
value={value}
|
||||
/>
|
||||
|
||||
@@ -10,17 +10,15 @@ export default ({ theme }: { theme: Theme }) =>
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
input: StyleSheet.flatten([
|
||||
theme.fonts.body1,
|
||||
{
|
||||
color: theme.colors.inputText,
|
||||
flex: 1,
|
||||
maxHeight: 100,
|
||||
// Fixes default paddings for Android
|
||||
paddingBottom: 0,
|
||||
paddingTop: 0,
|
||||
},
|
||||
]),
|
||||
input: {
|
||||
...theme.fonts.inputTextStyle,
|
||||
color: theme.colors.inputText,
|
||||
flex: 1,
|
||||
maxHeight: 100,
|
||||
// Fixes default paddings for Android
|
||||
paddingBottom: 0,
|
||||
paddingTop: 0,
|
||||
},
|
||||
marginRight: {
|
||||
marginRight: 16,
|
||||
},
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { oneOf } from '@flyerhq/react-native-link-preview'
|
||||
import dayjs from 'dayjs'
|
||||
import * as React from 'react'
|
||||
import { Image, ImageSourcePropType, Text, View } from 'react-native'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
|
||||
import { MessageType } from '../../types'
|
||||
import { ThemeContext, UserContext } from '../../utils'
|
||||
import { CircularActivityIndicator } from '../CircularActivityIndicator'
|
||||
import {
|
||||
excludeDerivedMessageProps,
|
||||
ThemeContext,
|
||||
UserContext,
|
||||
} from '../../utils'
|
||||
import { Avatar } from '../Avatar'
|
||||
import { FileMessage } from '../FileMessage'
|
||||
import { ImageMessage } from '../ImageMessage'
|
||||
import { StatusIcon } from '../StatusIcon'
|
||||
import { TextMessage, TextMessageTopLevelProps } from '../TextMessage'
|
||||
import styles from './styles'
|
||||
|
||||
export interface MessageTopLevelProps extends TextMessageTopLevelProps {
|
||||
messageTimeFormat?: string
|
||||
onFilePress?: (message: MessageType.File) => void
|
||||
onMessageLongPress?: (message: MessageType.Any) => void
|
||||
onMessagePress?: (message: MessageType.Any) => void
|
||||
renderCustomMessage?: (
|
||||
message: MessageType.Custom,
|
||||
messageWidth: number
|
||||
) => React.ReactNode
|
||||
renderFileMessage?: (
|
||||
message: MessageType.File,
|
||||
messageWidth: number
|
||||
@@ -26,48 +34,74 @@ export interface MessageTopLevelProps extends TextMessageTopLevelProps {
|
||||
message: MessageType.Text,
|
||||
messageWidth: number
|
||||
) => React.ReactNode
|
||||
showUserAvatars?: boolean
|
||||
}
|
||||
|
||||
export interface MessageProps extends MessageTopLevelProps {
|
||||
message: MessageType.Any
|
||||
message: MessageType.DerivedAny
|
||||
messageWidth: number
|
||||
onImagePress: (uri: string) => void
|
||||
previousMessageSameAuthor: boolean
|
||||
shouldRenderTime: boolean
|
||||
roundBorder: boolean
|
||||
showAvatar: boolean
|
||||
showName: boolean
|
||||
showStatus: boolean
|
||||
}
|
||||
|
||||
export const Message = React.memo(
|
||||
({
|
||||
message,
|
||||
messageTimeFormat = 'h:mm a',
|
||||
messageWidth,
|
||||
onFilePress,
|
||||
onImagePress,
|
||||
onMessagePress,
|
||||
onMessageLongPress,
|
||||
onPreviewDataFetched,
|
||||
previousMessageSameAuthor,
|
||||
renderCustomMessage,
|
||||
renderFileMessage,
|
||||
renderImageMessage,
|
||||
renderTextMessage,
|
||||
shouldRenderTime,
|
||||
roundBorder,
|
||||
showAvatar,
|
||||
showName,
|
||||
showStatus,
|
||||
showUserAvatars,
|
||||
usePreviewData,
|
||||
}: MessageProps) => {
|
||||
const theme = React.useContext(ThemeContext)
|
||||
const user = React.useContext(UserContext)
|
||||
const { container, contentContainer, status, statusContainer, time } =
|
||||
styles({
|
||||
message,
|
||||
messageWidth,
|
||||
previousMessageSameAuthor,
|
||||
theme,
|
||||
user,
|
||||
})
|
||||
|
||||
const renderMessage = React.useCallback(() => {
|
||||
const currentUserIsAuthor =
|
||||
message.type !== 'dateHeader' && user?.id === message.author.id
|
||||
|
||||
const { container, contentContainer, dateHeader } = styles({
|
||||
currentUserIsAuthor,
|
||||
message,
|
||||
messageWidth,
|
||||
roundBorder,
|
||||
theme,
|
||||
})
|
||||
|
||||
if (message.type === 'dateHeader') {
|
||||
return (
|
||||
<View style={dateHeader}>
|
||||
<Text style={theme.fonts.dateDividerTextStyle}>{message.text}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const renderMessage = () => {
|
||||
switch (message.type) {
|
||||
case 'custom':
|
||||
return (
|
||||
renderCustomMessage?.(
|
||||
// It's okay to cast here since we checked message type above
|
||||
// type-coverage:ignore-next-line
|
||||
excludeDerivedMessageProps(message) as MessageType.Custom,
|
||||
messageWidth
|
||||
) ?? null
|
||||
)
|
||||
case 'file':
|
||||
return oneOf(
|
||||
renderFileMessage,
|
||||
<FileMessage message={message} onPress={onFilePress} />
|
||||
)(message, messageWidth)
|
||||
return oneOf(renderFileMessage, <FileMessage message={message} />)(
|
||||
message,
|
||||
messageWidth
|
||||
)
|
||||
case 'image':
|
||||
return oneOf(
|
||||
renderImageMessage,
|
||||
@@ -75,7 +109,6 @@ export const Message = React.memo(
|
||||
{...{
|
||||
message,
|
||||
messageWidth,
|
||||
onPress: onImagePress,
|
||||
}}
|
||||
/>
|
||||
)(message, messageWidth)
|
||||
@@ -87,58 +120,45 @@ export const Message = React.memo(
|
||||
message,
|
||||
messageWidth,
|
||||
onPreviewDataFetched,
|
||||
showName,
|
||||
usePreviewData,
|
||||
}}
|
||||
/>
|
||||
)(message, messageWidth)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [
|
||||
message,
|
||||
messageWidth,
|
||||
onFilePress,
|
||||
onImagePress,
|
||||
onPreviewDataFetched,
|
||||
renderFileMessage,
|
||||
renderImageMessage,
|
||||
renderTextMessage,
|
||||
])
|
||||
|
||||
const readIcon: ImageSourcePropType =
|
||||
theme.icons?.readIcon ?? require('../../assets/icon-read.png')
|
||||
|
||||
const deliveredIcon: ImageSourcePropType =
|
||||
theme.icons?.deliveredIcon ?? require('../../assets/icon-delivered.png')
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={container}>
|
||||
<View style={contentContainer}>{renderMessage()}</View>
|
||||
{shouldRenderTime && (
|
||||
<View style={statusContainer}>
|
||||
<Text style={time}>
|
||||
{/* `shouldRenderTime` will only be true if timestamp exists, so we can safely force unwrap it */}
|
||||
{/* type-coverage:ignore-next-line */}
|
||||
{dayjs.unix(message.timestamp!).format(messageTimeFormat)}
|
||||
</Text>
|
||||
{user?.id === message.authorId && (
|
||||
<>
|
||||
{message.status === 'sending' && (
|
||||
<CircularActivityIndicator
|
||||
color={theme.colors.primary}
|
||||
size={12}
|
||||
/>
|
||||
)}
|
||||
{(message.status === 'read' ||
|
||||
message.status === 'delivered') && (
|
||||
<Image
|
||||
source={
|
||||
message.status === 'read' ? readIcon : deliveredIcon
|
||||
}
|
||||
style={status}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<Avatar
|
||||
{...{
|
||||
author: message.author,
|
||||
currentUserIsAuthor,
|
||||
showAvatar,
|
||||
showUserAvatars,
|
||||
theme,
|
||||
}}
|
||||
/>
|
||||
<Pressable
|
||||
onLongPress={() =>
|
||||
onMessageLongPress?.(excludeDerivedMessageProps(message))
|
||||
}
|
||||
onPress={() => onMessagePress?.(excludeDerivedMessageProps(message))}
|
||||
style={contentContainer}
|
||||
testID='ContentContainer'
|
||||
>
|
||||
{renderMessage()}
|
||||
</Pressable>
|
||||
<StatusIcon
|
||||
{...{
|
||||
currentUserIsAuthor,
|
||||
showStatus,
|
||||
status: message.status,
|
||||
theme,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
43
src/components/Message/__tests__/Message.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { render } from '@testing-library/react-native'
|
||||
import * as React from 'react'
|
||||
|
||||
import { derivedTextMessage } from '../../../../jest/fixtures'
|
||||
import { Message } from '../Message'
|
||||
|
||||
describe('message', () => {
|
||||
it('renders undefined in ContentContainer', () => {
|
||||
expect.assertions(2)
|
||||
const { getByTestId } = render(
|
||||
<Message
|
||||
message={{ ...derivedTextMessage, type: 'custom' }}
|
||||
messageWidth={440}
|
||||
onMessagePress={jest.fn}
|
||||
roundBorder
|
||||
showAvatar
|
||||
showName
|
||||
showStatus
|
||||
/>
|
||||
)
|
||||
const ContentContainer = getByTestId('ContentContainer')
|
||||
expect(ContentContainer).toBeDefined()
|
||||
expect(ContentContainer).toHaveProperty('props.children[0]', undefined)
|
||||
})
|
||||
|
||||
it('renders undefined in ContentContainer with wrong message type', () => {
|
||||
expect.assertions(2)
|
||||
const { getByTestId } = render(
|
||||
<Message
|
||||
message={{ ...derivedTextMessage, type: 'unsupported' }}
|
||||
messageWidth={440}
|
||||
onMessagePress={jest.fn}
|
||||
roundBorder
|
||||
showAvatar
|
||||
showName
|
||||
showStatus
|
||||
/>
|
||||
)
|
||||
const ContentContainer = getByTestId('ContentContainer')
|
||||
expect(ContentContainer).toBeDefined()
|
||||
expect(ContentContainer).toHaveProperty('props.children[0]', undefined)
|
||||
})
|
||||
})
|
||||
@@ -1,57 +1,55 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
import { MessageType, Theme, User } from '../../types'
|
||||
import { MessageType, Theme } from '../../types'
|
||||
|
||||
const styles = ({
|
||||
currentUserIsAuthor,
|
||||
message,
|
||||
messageWidth,
|
||||
previousMessageSameAuthor,
|
||||
roundBorder,
|
||||
theme,
|
||||
user,
|
||||
}: {
|
||||
message: MessageType.Any
|
||||
currentUserIsAuthor: boolean
|
||||
message: MessageType.DerivedAny
|
||||
messageWidth: number
|
||||
previousMessageSameAuthor: boolean
|
||||
roundBorder: boolean
|
||||
theme: Theme
|
||||
user?: User
|
||||
}) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
alignSelf: user?.id === message.authorId ? 'flex-end' : 'flex-start',
|
||||
alignItems: 'flex-end',
|
||||
alignSelf: currentUserIsAuthor ? 'flex-end' : 'flex-start',
|
||||
justifyContent: !currentUserIsAuthor ? 'flex-end' : 'flex-start',
|
||||
flex: 1,
|
||||
marginBottom: previousMessageSameAuthor ? 8 : 16,
|
||||
marginHorizontal: 24,
|
||||
flexDirection: 'row',
|
||||
marginBottom: message.type === 'dateHeader' ? 0 : 4 + message.offset,
|
||||
marginLeft: 20,
|
||||
},
|
||||
contentContainer: {
|
||||
backgroundColor:
|
||||
user?.id !== message.authorId || message.type === 'image'
|
||||
!currentUserIsAuthor || message.type === 'image'
|
||||
? theme.colors.secondary
|
||||
: theme.colors.primary,
|
||||
borderBottomLeftRadius:
|
||||
user?.id === message.authorId ? theme.borders.messageBorderRadius : 0,
|
||||
borderBottomRightRadius:
|
||||
user?.id === message.authorId ? 0 : theme.borders.messageBorderRadius,
|
||||
currentUserIsAuthor || roundBorder
|
||||
? theme.borders.messageBorderRadius
|
||||
: 0,
|
||||
borderBottomRightRadius: currentUserIsAuthor
|
||||
? roundBorder
|
||||
? theme.borders.messageBorderRadius
|
||||
: 0
|
||||
: theme.borders.messageBorderRadius,
|
||||
borderColor: 'transparent',
|
||||
borderRadius: theme.borders.messageBorderRadius,
|
||||
maxWidth: messageWidth,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
status: {
|
||||
tintColor: theme.colors.primary,
|
||||
},
|
||||
statusContainer: {
|
||||
dateHeader: {
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-end',
|
||||
flexDirection: 'row',
|
||||
marginTop: 8,
|
||||
justifyContent: 'center',
|
||||
marginBottom: 32,
|
||||
marginTop: 16,
|
||||
},
|
||||
time: StyleSheet.flatten([
|
||||
theme.fonts.caption,
|
||||
{
|
||||
color: theme.colors.caption,
|
||||
marginRight: user?.id === message.authorId ? 8 : 16,
|
||||
},
|
||||
]),
|
||||
})
|
||||
|
||||
export default styles
|
||||
|
||||
@@ -2,12 +2,12 @@ import * as React from 'react'
|
||||
import {
|
||||
GestureResponderEvent,
|
||||
Image,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
} from 'react-native'
|
||||
|
||||
import { L10nContext, ThemeContext } from '../../utils'
|
||||
import styles from './styles'
|
||||
|
||||
export interface SendButtonPropsAdditionalProps {
|
||||
touchableOpacityProps?: TouchableOpacityProps
|
||||
@@ -37,12 +37,18 @@ export const SendButton = ({
|
||||
onPress={handlePress}
|
||||
style={styles.sendButton}
|
||||
>
|
||||
<Image
|
||||
source={
|
||||
theme.icons?.sendButtonIcon ?? require('../../assets/icon-send.png')
|
||||
}
|
||||
style={{ tintColor: theme.colors.inputText }}
|
||||
/>
|
||||
{theme.icons?.sendButtonIcon?.() ?? (
|
||||
<Image
|
||||
source={require('../../assets/icon-send.png')}
|
||||
style={{ tintColor: theme.colors.inputText }}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sendButton: {
|
||||
marginLeft: 16,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sendButton: {
|
||||
marginLeft: 16,
|
||||
},
|
||||
})
|
||||
|
||||
export default styles
|
||||
77
src/components/StatusIcon/StatusIcon.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react'
|
||||
import { Image, StyleSheet, View } from 'react-native'
|
||||
|
||||
import { MessageType, Theme } from '../../types'
|
||||
import { CircularActivityIndicator } from '../CircularActivityIndicator'
|
||||
|
||||
export const StatusIcon = React.memo(
|
||||
({
|
||||
currentUserIsAuthor,
|
||||
showStatus,
|
||||
status,
|
||||
theme,
|
||||
}: {
|
||||
currentUserIsAuthor: boolean
|
||||
showStatus: boolean
|
||||
status?: MessageType.Any['status']
|
||||
theme: Theme
|
||||
}) => {
|
||||
let statusIcon: React.ReactNode | null = null
|
||||
|
||||
if (showStatus) {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
case 'sent':
|
||||
statusIcon = theme.icons?.deliveredIcon?.() ?? (
|
||||
<Image
|
||||
source={require('../../assets/icon-delivered.png')}
|
||||
style={{ tintColor: theme.colors.primary }}
|
||||
testID='DeliveredIcon'
|
||||
/>
|
||||
)
|
||||
break
|
||||
case 'error':
|
||||
statusIcon = theme.icons?.errorIcon?.() ?? (
|
||||
<Image
|
||||
source={require('../../assets/icon-error.png')}
|
||||
style={{ tintColor: theme.colors.error }}
|
||||
testID='ErrorIcon'
|
||||
/>
|
||||
)
|
||||
break
|
||||
case 'seen':
|
||||
statusIcon = theme.icons?.seenIcon?.() ?? (
|
||||
<Image
|
||||
source={require('../../assets/icon-seen.png')}
|
||||
style={{ tintColor: theme.colors.primary }}
|
||||
testID='SeenIcon'
|
||||
/>
|
||||
)
|
||||
break
|
||||
case 'sending':
|
||||
statusIcon = theme.icons?.sendingIcon?.() ?? (
|
||||
<CircularActivityIndicator color={theme.colors.primary} size={10} />
|
||||
)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return currentUserIsAuthor ? (
|
||||
<View style={styles.container} testID='StatusIconContainer'>
|
||||
{statusIcon}
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
height: 16,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 4,
|
||||
width: 16,
|
||||
},
|
||||
})
|
||||
183
src/components/StatusIcon/__tests__/StatusIcon.test.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { render } from '@testing-library/react-native'
|
||||
import * as React from 'react'
|
||||
import { Image, View } from 'react-native'
|
||||
|
||||
import { defaultTheme } from '../../../theme'
|
||||
import { StatusIcon } from '../StatusIcon'
|
||||
|
||||
describe('status icon', () => {
|
||||
it('should render null if show status is false', () => {
|
||||
expect.assertions(1)
|
||||
const { queryByTestId } = render(
|
||||
<StatusIcon
|
||||
currentUserIsAuthor={false}
|
||||
showStatus={false}
|
||||
theme={defaultTheme}
|
||||
/>
|
||||
)
|
||||
expect(queryByTestId('StatusIconContainer')).toBeNull()
|
||||
})
|
||||
|
||||
it('should render delivered icon', () => {
|
||||
expect.assertions(1)
|
||||
const { getByTestId } = render(
|
||||
<StatusIcon
|
||||
currentUserIsAuthor
|
||||
showStatus
|
||||
status='delivered'
|
||||
theme={defaultTheme}
|
||||
/>
|
||||
)
|
||||
expect(getByTestId('DeliveredIcon')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should render delivered icon from theme', () => {
|
||||
expect.assertions(1)
|
||||
const { queryByTestId } = render(
|
||||
<StatusIcon
|
||||
currentUserIsAuthor
|
||||
showStatus
|
||||
status='delivered'
|
||||
theme={{
|
||||
...defaultTheme,
|
||||
icons: {
|
||||
deliveredIcon: () => (
|
||||
<Image source={require('../../../assets/icon-delivered.png')} />
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(queryByTestId('DeliveredIcon')).toBeNull()
|
||||
})
|
||||
|
||||
it('should render delivered icon with sent status', () => {
|
||||
expect.assertions(1)
|
||||
const { getByTestId } = render(
|
||||
<StatusIcon
|
||||
currentUserIsAuthor
|
||||
showStatus
|
||||
status='sent'
|
||||
theme={defaultTheme}
|
||||
/>
|
||||
)
|
||||
expect(getByTestId('DeliveredIcon')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should render delivered icon with sent status from theme', () => {
|
||||
expect.assertions(1)
|
||||
const { queryByTestId } = render(
|
||||
<StatusIcon
|
||||
currentUserIsAuthor
|
||||
showStatus
|
||||
status='sent'
|
||||
theme={{
|
||||
...defaultTheme,
|
||||
icons: {
|
||||
deliveredIcon: () => (
|
||||
<Image source={require('../../../assets/icon-delivered.png')} />
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(queryByTestId('DeliveredIcon')).toBeNull()
|
||||
})
|
||||
|
||||
it('should render error icon', () => {
|
||||
expect.assertions(1)
|
||||
const { getByTestId } = render(
|
||||
<StatusIcon
|
||||
currentUserIsAuthor
|
||||
showStatus
|
||||
status='error'
|
||||
theme={defaultTheme}
|
||||
/>
|
||||
)
|
||||
expect(getByTestId('ErrorIcon')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should render error icon from theme', () => {
|
||||
expect.assertions(1)
|
||||
const { queryByTestId } = render(
|
||||
<StatusIcon
|
||||
currentUserIsAuthor
|
||||
showStatus
|
||||
status='error'
|
||||
theme={{
|
||||
...defaultTheme,
|
||||
icons: {
|
||||
errorIcon: () => (
|
||||
<Image source={require('../../../assets/icon-error.png')} />
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(queryByTestId('ErrorIcon')).toBeNull()
|
||||
})
|
||||
|
||||
it('should render seen icon', () => {
|
||||
expect.assertions(1)
|
||||
const { getByTestId } = render(
|
||||
<StatusIcon
|
||||
currentUserIsAuthor
|
||||
showStatus
|
||||
status='seen'
|
||||
theme={defaultTheme}
|
||||
/>
|
||||
)
|
||||
expect(getByTestId('SeenIcon')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should render seen icon from theme', () => {
|
||||
expect.assertions(1)
|
||||
const { queryByTestId } = render(
|
||||
<StatusIcon
|
||||
currentUserIsAuthor
|
||||
showStatus
|
||||
status='seen'
|
||||
theme={{
|
||||
...defaultTheme,
|
||||
icons: {
|
||||
seenIcon: () => (
|
||||
<Image source={require('../../../assets/icon-seen.png')} />
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(queryByTestId('SeenIcon')).toBeNull()
|
||||
})
|
||||
|
||||
it('should render activity indicator', () => {
|
||||
expect.assertions(1)
|
||||
const { getByTestId } = render(
|
||||
<StatusIcon
|
||||
currentUserIsAuthor
|
||||
showStatus
|
||||
status='sending'
|
||||
theme={defaultTheme}
|
||||
/>
|
||||
)
|
||||
expect(getByTestId('CircularActivityIndicator')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should render sending icon from theme', () => {
|
||||
expect.assertions(1)
|
||||
const { queryByTestId } = render(
|
||||
<StatusIcon
|
||||
currentUserIsAuthor
|
||||
showStatus
|
||||
status='sending'
|
||||
theme={{
|
||||
...defaultTheme,
|
||||
icons: {
|
||||
sendingIcon: () => <View />,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(queryByTestId('CircularActivityIndicator')).toBeNull()
|
||||
})
|
||||
})
|
||||
1
src/components/StatusIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './StatusIcon'
|
||||
@@ -4,11 +4,11 @@ import {
|
||||
REGEX_LINK,
|
||||
} from '@flyerhq/react-native-link-preview'
|
||||
import * as React from 'react'
|
||||
import { Linking, StyleSheet, Text } from 'react-native'
|
||||
import { Linking, Text, View } from 'react-native'
|
||||
import ParsedText from 'react-native-parsed-text'
|
||||
|
||||
import { MessageType } from '../../types'
|
||||
import { ThemeContext, UserContext } from '../../utils'
|
||||
import { getUserName, ThemeContext, UserContext } from '../../utils'
|
||||
import styles from './styles'
|
||||
|
||||
export interface TextMessageTopLevelProps {
|
||||
@@ -16,29 +16,34 @@ export interface TextMessageTopLevelProps {
|
||||
message,
|
||||
previewData,
|
||||
}: {
|
||||
message: MessageType.Text
|
||||
message: MessageType.DerivedText
|
||||
previewData: PreviewData
|
||||
}) => void
|
||||
usePreviewData?: boolean
|
||||
}
|
||||
|
||||
export interface TextMessageProps extends TextMessageTopLevelProps {
|
||||
message: MessageType.Text
|
||||
message: MessageType.DerivedText
|
||||
messageWidth: number
|
||||
showName: boolean
|
||||
}
|
||||
|
||||
export const TextMessage = ({
|
||||
message,
|
||||
messageWidth,
|
||||
onPreviewDataFetched,
|
||||
showName,
|
||||
usePreviewData,
|
||||
}: TextMessageProps) => {
|
||||
const theme = React.useContext(ThemeContext)
|
||||
const user = React.useContext(UserContext)
|
||||
const [previewData, setPreviewData] = React.useState(message.previewData)
|
||||
const { descriptionText, titleText, text } = styles({
|
||||
message,
|
||||
theme,
|
||||
user,
|
||||
})
|
||||
const { descriptionText, headerText, titleText, text, textContainer } =
|
||||
styles({
|
||||
message,
|
||||
theme,
|
||||
user,
|
||||
})
|
||||
|
||||
const handlePreviewDataFetched = (data: PreviewData) => {
|
||||
setPreviewData(data)
|
||||
@@ -59,6 +64,14 @@ export const TextMessage = ({
|
||||
)
|
||||
}
|
||||
|
||||
const renderPreviewHeader = (header: string) => {
|
||||
return (
|
||||
<Text numberOfLines={1} style={headerText}>
|
||||
{header}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
const renderPreviewText = (previewText: string) => {
|
||||
return (
|
||||
<ParsedText
|
||||
@@ -67,10 +80,7 @@ export const TextMessage = ({
|
||||
{
|
||||
onPress: handleUrlPress,
|
||||
pattern: REGEX_LINK,
|
||||
style: StyleSheet.flatten([
|
||||
text,
|
||||
{ textDecorationLine: 'underline' },
|
||||
]),
|
||||
style: [text, { textDecorationLine: 'underline' }],
|
||||
},
|
||||
]}
|
||||
style={text}
|
||||
@@ -88,12 +98,16 @@ export const TextMessage = ({
|
||||
)
|
||||
}
|
||||
|
||||
return REGEX_LINK.test(message.text) ? (
|
||||
return usePreviewData &&
|
||||
!!onPreviewDataFetched &&
|
||||
REGEX_LINK.test(message.text.toLowerCase()) ? (
|
||||
<LinkPreview
|
||||
containerStyle={{ width: previewData?.image ? messageWidth : undefined }}
|
||||
header={showName ? getUserName(message.author) : undefined}
|
||||
onPreviewDataFetched={handlePreviewDataFetched}
|
||||
previewData={previewData}
|
||||
renderDescription={renderPreviewDescription}
|
||||
renderHeader={renderPreviewHeader}
|
||||
renderText={renderPreviewText}
|
||||
renderTitle={renderPreviewTitle}
|
||||
text={message.text}
|
||||
@@ -104,13 +118,14 @@ export const TextMessage = ({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={StyleSheet.flatten([
|
||||
text,
|
||||
{ marginHorizontal: 24, marginVertical: 16 },
|
||||
])}
|
||||
>
|
||||
{message.text}
|
||||
</Text>
|
||||
<View style={textContainer}>
|
||||
{
|
||||
// Tested inside the link preview
|
||||
/* istanbul ignore next */ showName
|
||||
? renderPreviewHeader(getUserName(message.author))
|
||||
: null
|
||||
}
|
||||
<Text style={text}>{message.text}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fireEvent, render, waitFor } from '@testing-library/react-native'
|
||||
import * as React from 'react'
|
||||
import { Linking } from 'react-native'
|
||||
|
||||
import { textMessage } from '../../../../jest/fixtures'
|
||||
import { derivedTextMessage } from '../../../../jest/fixtures'
|
||||
import { TextMessage } from '../TextMessage'
|
||||
|
||||
describe('text message', () => {
|
||||
@@ -25,8 +25,15 @@ describe('text message', () => {
|
||||
const openUrlMock = jest.spyOn(Linking, 'openURL')
|
||||
const { getByRole, getByText } = render(
|
||||
<TextMessage
|
||||
message={{ ...textMessage, text: link }}
|
||||
message={{
|
||||
...derivedTextMessage,
|
||||
author: { id: 'newUserId', firstName: 'John' },
|
||||
text: link,
|
||||
}}
|
||||
messageWidth={440}
|
||||
onPreviewDataFetched={jest.fn}
|
||||
showName
|
||||
usePreviewData
|
||||
/>
|
||||
)
|
||||
await waitFor(() => getByRole('image'))
|
||||
@@ -57,8 +64,11 @@ describe('text message', () => {
|
||||
const openUrlMock = jest.spyOn(Linking, 'openURL')
|
||||
const { getByRole, getByText } = render(
|
||||
<TextMessage
|
||||
message={{ ...textMessage, text: link }}
|
||||
message={{ ...derivedTextMessage, text: link }}
|
||||
messageWidth={440}
|
||||
onPreviewDataFetched={jest.fn}
|
||||
showName={false}
|
||||
usePreviewData
|
||||
/>
|
||||
)
|
||||
await waitFor(() => getByRole('image'))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
import { MessageType, Theme, User } from '../../types'
|
||||
import { getUserAvatarNameColor } from '../../utils'
|
||||
|
||||
const styles = ({
|
||||
message,
|
||||
@@ -12,34 +13,32 @@ const styles = ({
|
||||
user?: User
|
||||
}) =>
|
||||
StyleSheet.create({
|
||||
descriptionText: StyleSheet.flatten([
|
||||
theme.fonts.body2,
|
||||
{
|
||||
color:
|
||||
user?.id === message.authorId
|
||||
? theme.colors.primaryText
|
||||
: theme.colors.secondaryText,
|
||||
marginTop: 4,
|
||||
},
|
||||
]),
|
||||
titleText: StyleSheet.flatten([
|
||||
theme.fonts.subtitle1,
|
||||
{
|
||||
color:
|
||||
user?.id === message.authorId
|
||||
? theme.colors.primaryText
|
||||
: theme.colors.secondaryText,
|
||||
},
|
||||
]),
|
||||
text: StyleSheet.flatten([
|
||||
theme.fonts.body1,
|
||||
{
|
||||
color:
|
||||
user?.id === message.authorId
|
||||
? theme.colors.primaryText
|
||||
: theme.colors.secondaryText,
|
||||
},
|
||||
]),
|
||||
descriptionText: {
|
||||
...(user?.id === message.author.id
|
||||
? theme.fonts.sentMessageLinkDescriptionTextStyle
|
||||
: theme.fonts.receivedMessageLinkDescriptionTextStyle),
|
||||
marginTop: 4,
|
||||
},
|
||||
headerText: {
|
||||
...theme.fonts.userNameTextStyle,
|
||||
color: getUserAvatarNameColor(
|
||||
message.author,
|
||||
theme.colors.userAvatarNameColors
|
||||
),
|
||||
marginBottom: 6,
|
||||
},
|
||||
titleText:
|
||||
user?.id === message.author.id
|
||||
? theme.fonts.sentMessageLinkTitleTextStyle
|
||||
: theme.fonts.receivedMessageLinkTitleTextStyle,
|
||||
text:
|
||||
user?.id === message.author.id
|
||||
? theme.fonts.sentMessageBodyTextStyle
|
||||
: theme.fonts.receivedMessageBodyTextStyle,
|
||||
textContainer: {
|
||||
marginHorizontal: 24,
|
||||
marginVertical: 16,
|
||||
},
|
||||
})
|
||||
|
||||
export default styles
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export * from './AttachmentButton'
|
||||
export * from './Avatar'
|
||||
export * from './Chat'
|
||||
export * from './CircularActivityIndicator'
|
||||
export * from './FileMessage'
|
||||
export * from './ImageMessage'
|
||||
export * from './Input'
|
||||
export * from './Message'
|
||||
export * from './SendButton'
|
||||
export * from './StatusIcon'
|
||||
export * from './TextMessage'
|
||||
|
||||
24
src/l10n.ts
@@ -5,8 +5,6 @@ export const l10n = {
|
||||
fileButtonAccessibilityLabel: 'File',
|
||||
inputPlaceholder: 'Message',
|
||||
sendButtonAccessibilityLabel: 'Send',
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
},
|
||||
es: {
|
||||
attachmentButtonAccessibilityLabel: 'Enviar multimedia',
|
||||
@@ -14,8 +12,13 @@ export const l10n = {
|
||||
fileButtonAccessibilityLabel: 'Archivo',
|
||||
inputPlaceholder: 'Mensaje',
|
||||
sendButtonAccessibilityLabel: 'Enviar',
|
||||
today: 'Hoy',
|
||||
yesterday: 'Ayer',
|
||||
},
|
||||
ko: {
|
||||
attachmentButtonAccessibilityLabel: '미디어 보내기',
|
||||
emptyChatPlaceholder: '주고받은 메시지가 없습니다',
|
||||
fileButtonAccessibilityLabel: '파일',
|
||||
inputPlaceholder: '메시지',
|
||||
sendButtonAccessibilityLabel: '보내기',
|
||||
},
|
||||
pl: {
|
||||
attachmentButtonAccessibilityLabel: 'Wyślij multimedia',
|
||||
@@ -23,8 +26,13 @@ export const l10n = {
|
||||
fileButtonAccessibilityLabel: 'Plik',
|
||||
inputPlaceholder: 'Napisz wiadomość',
|
||||
sendButtonAccessibilityLabel: 'Wyślij',
|
||||
today: 'Dzisiaj',
|
||||
yesterday: 'Wczoraj',
|
||||
},
|
||||
pt: {
|
||||
attachmentButtonAccessibilityLabel: 'Envia mídia',
|
||||
emptyChatPlaceholder: 'Ainda não há mensagens aqui',
|
||||
fileButtonAccessibilityLabel: 'Arquivo',
|
||||
inputPlaceholder: 'Mensagem',
|
||||
sendButtonAccessibilityLabel: 'Enviar',
|
||||
},
|
||||
ru: {
|
||||
attachmentButtonAccessibilityLabel: 'Отправить медиа',
|
||||
@@ -32,8 +40,6 @@ export const l10n = {
|
||||
fileButtonAccessibilityLabel: 'Файл',
|
||||
inputPlaceholder: 'Сообщение',
|
||||
sendButtonAccessibilityLabel: 'Отправить',
|
||||
today: 'Сегодня',
|
||||
yesterday: 'Вчера',
|
||||
},
|
||||
uk: {
|
||||
attachmentButtonAccessibilityLabel: 'Надіслати медіа',
|
||||
@@ -41,7 +47,5 @@ export const l10n = {
|
||||
fileButtonAccessibilityLabel: 'Файл',
|
||||
inputPlaceholder: 'Повідомлення',
|
||||
sendButtonAccessibilityLabel: 'Надіслати',
|
||||
today: 'Сьогодні',
|
||||
yesterday: 'Учора',
|
||||
},
|
||||
}
|
||||
|
||||
170
src/theme.ts
@@ -1,48 +1,151 @@
|
||||
import { ColorValue } from 'react-native'
|
||||
|
||||
import { Theme } from './types'
|
||||
|
||||
// For internal usage only. Use values from theme itself.
|
||||
|
||||
/// See [ChatTheme.userAvatarNameColors]
|
||||
export const COLORS: ColorValue[] = [
|
||||
'#ff6767',
|
||||
'#66e0da',
|
||||
'#f5a2d9',
|
||||
'#f0c722',
|
||||
'#6a85e5',
|
||||
'#fd9a6f',
|
||||
'#92db6e',
|
||||
'#73b8e5',
|
||||
'#fd7590',
|
||||
'#c78ae5',
|
||||
]
|
||||
|
||||
/// Dark
|
||||
const DARK = '#1f1c38'
|
||||
|
||||
/// Error
|
||||
const ERROR = '#ff6767'
|
||||
|
||||
/// N0
|
||||
const NEUTRAL_0 = '#1d1c21'
|
||||
|
||||
/// N2
|
||||
const NEUTRAL_2 = '#9e9cab'
|
||||
|
||||
/// N7
|
||||
const NEUTRAL_7 = '#ffffff'
|
||||
|
||||
/// N7 with opacity
|
||||
const NEUTRAL_7_WITH_OPACITY = '#ffffff80'
|
||||
|
||||
/// Primary
|
||||
const PRIMARY = '#6f61e8'
|
||||
|
||||
/// Secondary
|
||||
const SECONDARY = '#f5f5f7'
|
||||
|
||||
/// Secondary dark
|
||||
const SECONDARY_DARK = '#2b2250'
|
||||
|
||||
export const defaultTheme: Theme = {
|
||||
borders: {
|
||||
inputBorderRadius: 20,
|
||||
messageBorderRadius: 20,
|
||||
},
|
||||
colors: {
|
||||
background: '#ffffff',
|
||||
caption: '#9e9cab',
|
||||
error: '#ff6767',
|
||||
inputBackground: '#1d1d21',
|
||||
inputText: '#ffffff',
|
||||
primary: '#6f61e8',
|
||||
primaryText: '#ffffff',
|
||||
secondary: '#f7f7f8',
|
||||
secondaryText: '#1d1d21',
|
||||
subtitle2: '#1d1d21',
|
||||
background: NEUTRAL_7,
|
||||
error: ERROR,
|
||||
inputBackground: NEUTRAL_0,
|
||||
inputText: NEUTRAL_7,
|
||||
primary: PRIMARY,
|
||||
receivedMessageDocumentIconColor: PRIMARY,
|
||||
secondary: SECONDARY,
|
||||
sentMessageDocumentIconColor: NEUTRAL_7,
|
||||
userAvatarNameColors: COLORS,
|
||||
},
|
||||
fonts: {
|
||||
body1: {
|
||||
dateDividerTextStyle: {
|
||||
color: NEUTRAL_2,
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 12,
|
||||
fontWeight: '800',
|
||||
lineHeight: 16,
|
||||
},
|
||||
emptyChatPlaceholderTextStyle: {
|
||||
color: NEUTRAL_2,
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
lineHeight: 24,
|
||||
},
|
||||
body2: {
|
||||
inputTextStyle: {
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 14,
|
||||
fontWeight: '400',
|
||||
lineHeight: 20,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
lineHeight: 24,
|
||||
},
|
||||
caption: {
|
||||
receivedMessageBodyTextStyle: {
|
||||
color: NEUTRAL_0,
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
lineHeight: 24,
|
||||
},
|
||||
receivedMessageCaptionTextStyle: {
|
||||
color: NEUTRAL_2,
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
lineHeight: 16,
|
||||
},
|
||||
subtitle1: {
|
||||
receivedMessageLinkDescriptionTextStyle: {
|
||||
color: NEUTRAL_0,
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 14,
|
||||
fontWeight: '400',
|
||||
lineHeight: 20,
|
||||
},
|
||||
receivedMessageLinkTitleTextStyle: {
|
||||
color: NEUTRAL_0,
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
lineHeight: 22,
|
||||
},
|
||||
subtitle2: {
|
||||
sentMessageBodyTextStyle: {
|
||||
color: NEUTRAL_7,
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
lineHeight: 24,
|
||||
},
|
||||
sentMessageCaptionTextStyle: {
|
||||
color: NEUTRAL_7_WITH_OPACITY,
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
lineHeight: 16,
|
||||
},
|
||||
sentMessageLinkDescriptionTextStyle: {
|
||||
color: NEUTRAL_7,
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 14,
|
||||
fontWeight: '400',
|
||||
lineHeight: 20,
|
||||
},
|
||||
sentMessageLinkTitleTextStyle: {
|
||||
color: NEUTRAL_7,
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
lineHeight: 22,
|
||||
},
|
||||
userAvatarTextStyle: {
|
||||
color: NEUTRAL_7,
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 12,
|
||||
fontWeight: '800',
|
||||
lineHeight: 16,
|
||||
},
|
||||
userNameTextStyle: {
|
||||
fontFamily: 'Avenir',
|
||||
fontSize: 12,
|
||||
fontWeight: '800',
|
||||
@@ -55,10 +158,31 @@ export const darkTheme: Theme = {
|
||||
...defaultTheme,
|
||||
colors: {
|
||||
...defaultTheme.colors,
|
||||
background: '#1f1c38',
|
||||
inputBackground: '#2b2250',
|
||||
secondary: '#2b2250',
|
||||
secondaryText: '#ffffff',
|
||||
subtitle2: '#ffffff',
|
||||
background: DARK,
|
||||
inputBackground: SECONDARY_DARK,
|
||||
secondary: SECONDARY_DARK,
|
||||
},
|
||||
fonts: {
|
||||
...defaultTheme.fonts,
|
||||
dateDividerTextStyle: {
|
||||
...defaultTheme.fonts.dateDividerTextStyle,
|
||||
color: NEUTRAL_7,
|
||||
},
|
||||
receivedMessageBodyTextStyle: {
|
||||
...defaultTheme.fonts.receivedMessageBodyTextStyle,
|
||||
color: NEUTRAL_7,
|
||||
},
|
||||
receivedMessageCaptionTextStyle: {
|
||||
...defaultTheme.fonts.receivedMessageCaptionTextStyle,
|
||||
color: NEUTRAL_7_WITH_OPACITY,
|
||||
},
|
||||
receivedMessageLinkDescriptionTextStyle: {
|
||||
...defaultTheme.fonts.receivedMessageLinkDescriptionTextStyle,
|
||||
color: NEUTRAL_7,
|
||||
},
|
||||
receivedMessageLinkTitleTextStyle: {
|
||||
...defaultTheme.fonts.receivedMessageLinkTitleTextStyle,
|
||||
color: NEUTRAL_7,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
125
src/types.ts
@@ -1,26 +1,66 @@
|
||||
import { PreviewData } from '@flyerhq/react-native-link-preview'
|
||||
import {
|
||||
ColorValue,
|
||||
ImageSourcePropType,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
} from 'react-native'
|
||||
import * as React from 'react'
|
||||
import { ColorValue, ImageURISource, TextStyle } from 'react-native'
|
||||
|
||||
export namespace MessageType {
|
||||
export type Any = File | Image | Text
|
||||
export type Any = Custom | File | Image | Text | Unsupported
|
||||
|
||||
export type DerivedMessage =
|
||||
| DerivedCustom
|
||||
| DerivedFile
|
||||
| DerivedImage
|
||||
| DerivedText
|
||||
| DerivedUnsupported
|
||||
export type DerivedAny = DateHeader | DerivedMessage
|
||||
|
||||
export type PartialAny = PartialFile | PartialImage | PartialText
|
||||
|
||||
interface Base {
|
||||
authorId: string
|
||||
author: User
|
||||
createdAt?: number
|
||||
id: string
|
||||
status?: 'delivered' | 'error' | 'read' | 'sending'
|
||||
timestamp?: number
|
||||
type: 'file' | 'image' | 'text'
|
||||
metadata?: Record<string, any>
|
||||
roomId?: string
|
||||
status?: 'delivered' | 'error' | 'seen' | 'sending' | 'sent'
|
||||
type: 'custom' | 'file' | 'image' | 'text' | 'unsupported'
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
export interface DerivedMessageProps extends Base {
|
||||
nextMessageInGroup: boolean
|
||||
// TODO: Check name?
|
||||
offset: number
|
||||
showName: boolean
|
||||
showStatus: boolean
|
||||
}
|
||||
|
||||
export interface DerivedCustom extends DerivedMessageProps {
|
||||
type: Custom['type']
|
||||
}
|
||||
|
||||
export interface DerivedFile extends DerivedMessageProps, File {
|
||||
type: File['type']
|
||||
}
|
||||
|
||||
export interface DerivedImage extends DerivedMessageProps, Image {
|
||||
type: Image['type']
|
||||
}
|
||||
|
||||
export interface DerivedText extends DerivedMessageProps, Text {
|
||||
type: Text['type']
|
||||
}
|
||||
|
||||
export interface DerivedUnsupported extends DerivedMessageProps {
|
||||
type: Unsupported['type']
|
||||
}
|
||||
|
||||
export interface Custom extends Base {
|
||||
type: 'custom'
|
||||
}
|
||||
|
||||
export interface PartialFile {
|
||||
fileName: string
|
||||
mimeType?: string
|
||||
name: string
|
||||
size: number
|
||||
uri: string
|
||||
}
|
||||
@@ -31,7 +71,7 @@ export namespace MessageType {
|
||||
|
||||
export interface PartialImage {
|
||||
height?: number
|
||||
imageName: string
|
||||
name: string
|
||||
size: number
|
||||
uri: string
|
||||
width?: number
|
||||
@@ -49,6 +89,21 @@ export namespace MessageType {
|
||||
export interface Text extends Base, PartialText {
|
||||
type: 'text'
|
||||
}
|
||||
|
||||
export interface Unsupported extends Base {
|
||||
type: 'unsupported'
|
||||
}
|
||||
|
||||
export interface DateHeader {
|
||||
id: string
|
||||
text: string
|
||||
type: 'dateHeader'
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreviewImage {
|
||||
id: string
|
||||
uri: ImageURISource['uri']
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
@@ -70,36 +125,50 @@ export interface ThemeBorders {
|
||||
|
||||
export interface ThemeColors {
|
||||
background: ColorValue
|
||||
caption: ColorValue
|
||||
error: ColorValue
|
||||
inputBackground: ColorValue
|
||||
inputText: ColorValue
|
||||
primary: ColorValue
|
||||
primaryText: ColorValue
|
||||
secondary: ColorValue
|
||||
secondaryText: ColorValue
|
||||
subtitle2: ColorValue
|
||||
receivedMessageDocumentIconColor: ColorValue
|
||||
sentMessageDocumentIconColor: ColorValue
|
||||
userAvatarNameColors: ColorValue[]
|
||||
}
|
||||
|
||||
export interface ThemeFonts {
|
||||
body1: StyleProp<TextStyle>
|
||||
body2: StyleProp<TextStyle>
|
||||
caption: StyleProp<TextStyle>
|
||||
subtitle1: StyleProp<TextStyle>
|
||||
subtitle2: StyleProp<TextStyle>
|
||||
dateDividerTextStyle: TextStyle
|
||||
emptyChatPlaceholderTextStyle: TextStyle
|
||||
inputTextStyle: TextStyle
|
||||
receivedMessageBodyTextStyle: TextStyle
|
||||
receivedMessageCaptionTextStyle: TextStyle
|
||||
receivedMessageLinkDescriptionTextStyle: TextStyle
|
||||
receivedMessageLinkTitleTextStyle: TextStyle
|
||||
sentMessageBodyTextStyle: TextStyle
|
||||
sentMessageCaptionTextStyle: TextStyle
|
||||
sentMessageLinkDescriptionTextStyle: TextStyle
|
||||
sentMessageLinkTitleTextStyle: TextStyle
|
||||
userAvatarTextStyle: TextStyle
|
||||
userNameTextStyle: TextStyle
|
||||
}
|
||||
|
||||
export interface ThemeIcons {
|
||||
attachmentButtonIcon?: ImageSourcePropType
|
||||
deliveredIcon?: ImageSourcePropType
|
||||
documentIcon?: ImageSourcePropType
|
||||
readIcon?: ImageSourcePropType
|
||||
sendButtonIcon?: ImageSourcePropType
|
||||
attachmentButtonIcon?: () => React.ReactNode
|
||||
deliveredIcon?: () => React.ReactNode
|
||||
documentIcon?: () => React.ReactNode
|
||||
errorIcon?: () => React.ReactNode
|
||||
seenIcon?: () => React.ReactNode
|
||||
sendButtonIcon?: () => React.ReactNode
|
||||
sendingIcon?: () => React.ReactNode
|
||||
}
|
||||
|
||||
export interface User {
|
||||
avatarUrl?: string
|
||||
createdAt?: number
|
||||
firstName?: string
|
||||
id: string
|
||||
imageUrl?: ImageURISource['uri']
|
||||
lastName?: string
|
||||
lastSeen?: number
|
||||
metadata?: Record<string, any>
|
||||
role?: 'admin' | 'agent' | 'moderator' | 'user'
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import dayjs from 'dayjs'
|
||||
import * as React from 'react'
|
||||
import { ColorValue } from 'react-native'
|
||||
import Blob from 'react-native/Libraries/Blob/Blob'
|
||||
|
||||
import { l10n } from '../l10n'
|
||||
import { defaultTheme } from '../theme'
|
||||
import { Theme, User } from '../types'
|
||||
import { MessageType, PreviewImage, Theme, User } from '../types'
|
||||
|
||||
export const L10nContext = React.createContext<typeof l10n[keyof typeof l10n]>(
|
||||
l10n.en
|
||||
@@ -12,6 +13,7 @@ export const L10nContext = React.createContext<typeof l10n[keyof typeof l10n]>(
|
||||
export const ThemeContext = React.createContext<Theme>(defaultTheme)
|
||||
export const UserContext = React.createContext<User | undefined>(undefined)
|
||||
|
||||
/// Returns text representation of a provided bytes value (e.g. 1kB, 1GB)
|
||||
export const formatBytes = (size: number, fractionDigits = 2) => {
|
||||
if (size <= 0) return '0 B'
|
||||
const multiple = Math.floor(Math.log(size) / Math.log(1024))
|
||||
@@ -22,13 +24,41 @@ export const formatBytes = (size: number, fractionDigits = 2) => {
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns size in bytes of the provided text
|
||||
export const getTextSizeInBytes = (text: string) => new Blob([text]).size
|
||||
|
||||
/// Returns user avatar and name color based on the ID
|
||||
export const getUserAvatarNameColor = (user: User, colors: ColorValue[]) =>
|
||||
colors[hashCode(user.id) % colors.length]
|
||||
|
||||
/// Returns user name as joined firstName and lastName
|
||||
export const getUserName = ({ firstName, lastName }: User) =>
|
||||
`${firstName ?? ''} ${lastName ?? ''}`.trim()
|
||||
|
||||
/// Returns hash code of the provided text
|
||||
const hashCode = (text = '') => {
|
||||
let i,
|
||||
chr,
|
||||
hash = 0
|
||||
if (text.length === 0) return hash
|
||||
for (i = 0; i < text.length; i++) {
|
||||
chr = text.charCodeAt(i)
|
||||
// eslint-disable-next-line no-bitwise
|
||||
hash = (hash << 5) - hash + chr
|
||||
// eslint-disable-next-line no-bitwise
|
||||
hash |= 0 // Convert to 32bit integer
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
/// Inits dayjs locale
|
||||
export const initLocale = (locale?: keyof typeof l10n) => {
|
||||
const locales: { [key in keyof typeof l10n]: unknown } = {
|
||||
en: require('dayjs/locale/en'),
|
||||
es: require('dayjs/locale/es'),
|
||||
ko: require('dayjs/locale/ko'),
|
||||
pl: require('dayjs/locale/pl'),
|
||||
pt: require('dayjs/locale/pt'),
|
||||
ru: require('dayjs/locale/ru'),
|
||||
uk: require('dayjs/locale/uk'),
|
||||
}
|
||||
@@ -37,4 +67,179 @@ export const initLocale = (locale?: keyof typeof l10n) => {
|
||||
dayjs.locale(locale)
|
||||
}
|
||||
|
||||
/// Returns either prop or empty object if null or undefined
|
||||
export const unwrap = <T>(prop: T) => prop ?? {}
|
||||
|
||||
/// Returns formatted date used as a divider between different days in the
|
||||
/// chat history
|
||||
const getVerboseDateTimeRepresentation = (
|
||||
dateTime: number,
|
||||
{
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
}: {
|
||||
dateFormat?: string
|
||||
timeFormat?: string
|
||||
}
|
||||
) => {
|
||||
const formattedDate = dateFormat
|
||||
? dayjs(dateTime).format(dateFormat)
|
||||
: dayjs(dateTime).format('MMM D')
|
||||
|
||||
const formattedTime = timeFormat
|
||||
? dayjs(dateTime).format(timeFormat)
|
||||
: dayjs(dateTime).format('HH:mm')
|
||||
|
||||
const localDateTime = dayjs(dateTime)
|
||||
const now = dayjs()
|
||||
|
||||
if (
|
||||
localDateTime.isSame(now, 'day') &&
|
||||
localDateTime.isSame(now, 'month') &&
|
||||
localDateTime.isSame(now, 'year')
|
||||
) {
|
||||
return formattedTime
|
||||
}
|
||||
|
||||
return `${formattedDate}, ${formattedTime}`
|
||||
}
|
||||
|
||||
/// Parses provided messages to chat messages (with headers) and
|
||||
/// returns them with a gallery
|
||||
export const calculateChatMessages = (
|
||||
messages: MessageType.Any[],
|
||||
user: User,
|
||||
{
|
||||
customDateHeaderText,
|
||||
dateFormat,
|
||||
showUserNames,
|
||||
timeFormat,
|
||||
}: {
|
||||
customDateHeaderText?: (dateTime: number) => string
|
||||
dateFormat?: string
|
||||
showUserNames: boolean
|
||||
timeFormat?: string
|
||||
}
|
||||
) => {
|
||||
let chatMessages: MessageType.DerivedAny[] = []
|
||||
let gallery: PreviewImage[] = []
|
||||
|
||||
let shouldShowName = false
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const isFirst = i === messages.length - 1
|
||||
const isLast = i === 0
|
||||
const message = messages[i]
|
||||
const messageHasCreatedAt = !!message.createdAt
|
||||
const nextMessage = isLast ? undefined : messages[i - 1]
|
||||
const nextMessageHasCreatedAt = !!nextMessage?.createdAt
|
||||
const nextMessageSameAuthor = message.author.id === nextMessage?.author.id
|
||||
const notMyMessage = message.author.id !== user.id
|
||||
|
||||
let nextMessageDateThreshold = false
|
||||
let nextMessageDifferentDay = false
|
||||
let nextMessageInGroup = false
|
||||
let showName = false
|
||||
|
||||
if (showUserNames) {
|
||||
const previousMessage = isFirst ? undefined : messages[i + 1]
|
||||
|
||||
const isFirstInGroup =
|
||||
notMyMessage &&
|
||||
(message.author.id !== previousMessage?.author.id ||
|
||||
(messageHasCreatedAt &&
|
||||
!!previousMessage?.createdAt &&
|
||||
message.createdAt! - previousMessage!.createdAt! > 60000))
|
||||
|
||||
if (isFirstInGroup) {
|
||||
shouldShowName = false
|
||||
if (message.type === 'text') {
|
||||
showName = true
|
||||
} else {
|
||||
shouldShowName = true
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'text' && shouldShowName) {
|
||||
showName = true
|
||||
shouldShowName = false
|
||||
}
|
||||
}
|
||||
|
||||
if (messageHasCreatedAt && nextMessageHasCreatedAt) {
|
||||
nextMessageDateThreshold =
|
||||
nextMessage!.createdAt! - message.createdAt! >= 900000
|
||||
|
||||
nextMessageDifferentDay = !dayjs(message.createdAt!).isSame(
|
||||
nextMessage!.createdAt!,
|
||||
'day'
|
||||
)
|
||||
|
||||
nextMessageInGroup =
|
||||
nextMessageSameAuthor &&
|
||||
nextMessage!.createdAt! - message.createdAt! <= 60000
|
||||
}
|
||||
|
||||
if (isFirst && messageHasCreatedAt) {
|
||||
const text =
|
||||
customDateHeaderText?.(message.createdAt!) ??
|
||||
getVerboseDateTimeRepresentation(message.createdAt!, {
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
})
|
||||
chatMessages = [{ id: text, text, type: 'dateHeader' }, ...chatMessages]
|
||||
}
|
||||
|
||||
chatMessages = [
|
||||
{
|
||||
...message,
|
||||
nextMessageInGroup,
|
||||
// TODO: Check this
|
||||
offset: !nextMessageInGroup ? 12 : 0,
|
||||
showName:
|
||||
notMyMessage &&
|
||||
showUserNames &&
|
||||
showName &&
|
||||
!!getUserName(message.author),
|
||||
showStatus: true,
|
||||
},
|
||||
...chatMessages,
|
||||
]
|
||||
|
||||
if (nextMessageDifferentDay || nextMessageDateThreshold) {
|
||||
const text =
|
||||
customDateHeaderText?.(nextMessage!.createdAt!) ??
|
||||
getVerboseDateTimeRepresentation(nextMessage!.createdAt!, {
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
})
|
||||
|
||||
chatMessages = [
|
||||
{
|
||||
id: text,
|
||||
text,
|
||||
type: 'dateHeader',
|
||||
},
|
||||
...chatMessages,
|
||||
]
|
||||
}
|
||||
|
||||
if (message.type === 'image') {
|
||||
gallery = [...gallery, { id: message.id, uri: message.uri }]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chatMessages,
|
||||
gallery,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes all derived message props from the derived message
|
||||
export const excludeDerivedMessageProps = (
|
||||
message: MessageType.DerivedMessage
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { nextMessageInGroup, offset, showName, showStatus, ...rest } = message
|
||||
return { ...rest } as MessageType.Any
|
||||
}
|
||||
|
||||
@@ -765,10 +765,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@flyerhq/react-native-keyboard-accessory-view/-/react-native-keyboard-accessory-view-2.2.0.tgz#b9aa613d10541ff0a8a4984ee16ddb5627b75496"
|
||||
integrity sha512-6tBsrLXJ6u2ChjVAmbMMiSJmLOQJ7aneroS8HTUzPhefBbZXKKODQjmZ+pVF2tXmwuZr+CjZSzOQrfKXeXd68A==
|
||||
|
||||
"@flyerhq/react-native-link-preview@^1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@flyerhq/react-native-link-preview/-/react-native-link-preview-1.3.1.tgz#620c5405ce3b51084673b963125e65db8fb92c0b"
|
||||
integrity sha512-b6kExHnWt4Shwbgtv11MClVFl1v46RDciS/fAv3+Aw8RVbk12HgMkCDLGbXCcYpXAzp3iiTqtAyUHQOj70/Qag==
|
||||
"@flyerhq/react-native-link-preview@^1.3.8":
|
||||
version "1.3.8"
|
||||
resolved "https://registry.yarnpkg.com/@flyerhq/react-native-link-preview/-/react-native-link-preview-1.3.8.tgz#70c10d08281dd59993d68a1e9afe8d03bd068aae"
|
||||
integrity sha512-QabGtFSh8+VlBQxZoS1nM6ZkHqCBlwuTEVElcrRdHm/BjzB4t3IwHLqxZ9/RFTvDvO7VTtEJBIgLFc7NnPVQlA==
|
||||
dependencies:
|
||||
html-entities "^2.3.2"
|
||||
|
||||
|
||||