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>
This commit is contained in:
Alex
2021-08-15 21:53:08 +02:00
committed by GitHub
parent 04598cddc1
commit 992464d949
61 changed files with 1529 additions and 616 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 B

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 B

After

Width:  |  Height:  |  Size: 217 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 B

After

Width:  |  Height:  |  Size: 259 B

BIN
src/assets/icon-error.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 B

BIN
src/assets/icon-reply.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 B

BIN
src/assets/icon-seen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

BIN
src/assets/icon-seen@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 B

BIN
src/assets/icon-seen@3x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

BIN
src/assets/icon-x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

BIN
src/assets/icon-x@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

BIN
src/assets/icon-x@3x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

View File

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

View 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,
},
})

View 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)
})
})

View File

@@ -0,0 +1 @@
export * from './Avatar'

View File

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

View File

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

View File

@@ -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' }],
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)
})
})

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
import { StyleSheet } from 'react-native'
const styles = StyleSheet.create({
sendButton: {
marginLeft: 16,
},
})
export default styles

View 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,
},
})

View 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()
})
})

View File

@@ -0,0 +1 @@
export * from './StatusIcon'

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: 'Учора',
},
}

View File

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

View File

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

View File

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

View File

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