feat: add realtime with socket.io

This commit is contained in:
Divlo 2022-01-13 18:21:45 +01:00
parent 5c03a9b944
commit 9229131c1a
No known key found for this signature in database
GPG Key ID: 8F9478F220CE65E9
14 changed files with 1707 additions and 1528 deletions

View File

@ -35,6 +35,7 @@
}
}
],
"@typescript-eslint/no-namespace": "off"
"@typescript-eslint/no-namespace": "off",
"@next/next/no-img-element": "off"
}
}

View File

@ -1,5 +1,4 @@
import { useState, useEffect } from 'react'
import Image from 'next/image'
import axios from 'axios'
import prettyBytes from 'pretty-bytes'
import { DownloadIcon } from '@heroicons/react/solid'
@ -48,13 +47,11 @@ export const MessageFile: React.FC<MessageContentProps> = (props) => {
if (message.mimetype.startsWith('image/')) {
return (
<a href={file.url} target='_blank' rel='noreferrer'>
<Image
<img
data-cy={`message-file-image-${message.id}`}
className='max-w-xs max-h-xs'
className='sm:max-w-xs max-h-80'
src={file.url}
alt={message.value}
width={320}
height={320}
/>
</a>
)
@ -69,7 +66,7 @@ export const MessageFile: React.FC<MessageContentProps> = (props) => {
if (message.mimetype.startsWith('video/')) {
return (
<video
className='max-w-xs max-h-xs'
className='max-w-xs max-h-80'
controls
data-cy={`message-file-video-${message.id}`}
>

View File

@ -29,7 +29,7 @@ export const Messages: React.FC = () => {
>
<InfiniteScroll
scrollableTarget='messages'
className='messages-list'
className='messages-list !overflow-x-hidden'
dataLength={messages.length}
next={nextPage}
inverse

View File

@ -1,6 +1,8 @@
import { Meta, Story } from '@storybook/react'
import { SendMessage as Component } from './SendMessage'
import { channelExample } from '../../../cypress/fixtures/channels/channel'
import { guildExample } from '../../../cypress/fixtures/guilds/guild'
import { SendMessage as Component, SendMessageProps } from './SendMessage'
const Stories: Meta = {
title: 'SendMessage',
@ -9,7 +11,9 @@ const Stories: Meta = {
export default Stories
export const SendMessage: Story = (arguments_) => {
export const SendMessage: Story<SendMessageProps> = (arguments_) => {
return <Component {...arguments_} />
}
SendMessage.args = {}
SendMessage.args = {
path: { channelId: channelExample.id, guildId: guildExample.id }
}

View File

@ -1,10 +0,0 @@
import { render } from '@testing-library/react'
import { SendMessage } from './SendMessage'
describe('<SendMessage />', () => {
it('should render successfully', () => {
const { baseElement } = render(<SendMessage />)
expect(baseElement).toBeTruthy()
})
})

View File

@ -1,38 +1,126 @@
import { useState, useRef } from 'react'
import useTranslation from 'next-translate/useTranslation'
import TextareaAutosize from 'react-textarea-autosize'
export const SendMessage: React.FC = () => {
import { GuildsChannelsPath } from '..'
import { useAuthentication } from '../../../tools/authentication'
import { EmojiPicker, EmojiPickerOnClick } from '../../Emoji'
export interface SendMessageProps {
path: GuildsChannelsPath
}
export const SendMessage: React.FC<SendMessageProps> = (props) => {
const { path } = props
const { t } = useTranslation()
const { authentication } = useAuthentication()
const [isVisibleEmojiPicker, setIsVisibleEmojiPicker] = useState(false)
const [message, setMessage] = useState('')
const textareaReference = useRef<HTMLTextAreaElement>(null)
const handleTextareaKeyDown: React.KeyboardEventHandler<HTMLFormElement> = (
event
) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
event.currentTarget.dispatchEvent(
new Event('submit', { cancelable: true, bubbles: true })
)
}
}
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault()
if (typeof message === 'string' && message.length > 0) {
await authentication.api.post(`/channels/${path.channelId}/messages`, {
value: message
})
setMessage('')
}
}
const handleTextareaChange: React.ChangeEventHandler<HTMLTextAreaElement> = (
event
) => {
setMessage(event.target.value)
}
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (
event
) => {
const files = event?.target?.files
if (files != null && files.length === 1) {
const file = files[0]
const formData = new FormData()
formData.append('file', file)
await authentication.api.post(
`/channels/${path.channelId}/messages/uploads`,
formData
)
}
}
const handleVisibleEmojiPicker = (): void => {
setIsVisibleEmojiPicker((isVisible) => !isVisible)
}
const handleEmojiPicker: EmojiPickerOnClick = (emoji) => {
const emojiColons = emoji.colons ?? ''
setMessage((oldMessage) => {
return oldMessage + emojiColons
})
handleVisibleEmojiPicker()
textareaReference.current?.focus()
}
return (
<div className='p-6 pb-4'>
<div className='w-full h-full py-1 flex rounded-lg bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-200'>
<form className='w-full h-full flex items-center'>
<TextareaAutosize
className='w-full scrollbar-firefox-support p-2 px-6 my-2 bg-transparent outline-none font-paragraph tracking-wide resize-none'
placeholder={t('application:write-a-message')}
wrap='soft'
maxRows={6}
/>
</form>
<div className='h-full flex items-center justify-around pr-6'>
<button className='w-full h-full flex items-center justify-center p-1 text-2xl transition hover:-translate-y-1'>
🙂
</button>
<button className='relative w-full h-full flex items-center justify-center p-1 text-green-800 dark:text-green-400 transition hover:-translate-y-1'>
<input
type='file'
className='absolute w-full h-full opacity-0 cursor-pointer'
<>
{isVisibleEmojiPicker && <EmojiPicker onClick={handleEmojiPicker} />}
<div className='p-6 pb-4'>
<div className='w-full h-full py-1 flex rounded-lg bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-200'>
<form
className='w-full h-full flex items-center'
onSubmit={handleSubmit}
onKeyDown={handleTextareaKeyDown}
>
<TextareaAutosize
className='w-full scrollbar-firefox-support p-2 px-6 my-2 bg-transparent outline-none font-paragraph tracking-wide resize-none'
placeholder={t('application:write-a-message')}
wrap='soft'
maxRows={6}
name='message'
onChange={handleTextareaChange}
value={message}
ref={textareaReference}
autoFocus
/>
<svg width='25' height='25' viewBox='0 0 22 22'>
<path
d='M11 0C4.925 0 0 4.925 0 11C0 17.075 4.925 22 11 22C17.075 22 22 17.075 22 11C22 4.925 17.075 0 11 0ZM12 15C12 15.2652 11.8946 15.5196 11.7071 15.7071C11.5196 15.8946 11.2652 16 11 16C10.7348 16 10.4804 15.8946 10.2929 15.7071C10.1054 15.5196 10 15.2652 10 15V12H7C6.73478 12 6.48043 11.8946 6.29289 11.7071C6.10536 11.5196 6 11.2652 6 11C6 10.7348 6.10536 10.4804 6.29289 10.2929C6.48043 10.1054 6.73478 10 7 10H10V7C10 6.73478 10.1054 6.48043 10.2929 6.29289C10.4804 6.10536 10.7348 6 11 6C11.2652 6 11.5196 6.10536 11.7071 6.29289C11.8946 6.48043 12 6.73478 12 7V10H15C15.2652 10 15.5196 10.1054 15.7071 10.2929C15.8946 10.4804 16 10.7348 16 11C16 11.2652 15.8946 11.5196 15.7071 11.7071C15.5196 11.8946 15.2652 12 15 12H12V15Z'
fill='currentColor'
</form>
<div className='h-full flex items-center justify-around pr-6'>
<button
className='w-full h-full flex items-center justify-center p-1 text-2xl transition hover:-translate-y-1'
onClick={handleVisibleEmojiPicker}
>
🙂
</button>
<button className='cursor-pointer relative w-full h-full flex items-center justify-center p-1 text-green-800 dark:text-green-400 transition hover:-translate-y-1'>
<input
type='file'
className='absolute w-full h-full opacity-0 cursor-pointer'
onChange={handleFileChange}
/>
</svg>
</button>
<svg width='25' height='25' viewBox='0 0 22 22'>
<path
d='M11 0C4.925 0 0 4.925 0 11C0 17.075 4.925 22 11 22C17.075 22 22 17.075 22 11C22 4.925 17.075 0 11 0ZM12 15C12 15.2652 11.8946 15.5196 11.7071 15.7071C11.5196 15.8946 11.2652 16 11 16C10.7348 16 10.4804 15.8946 10.2929 15.7071C10.1054 15.5196 10 15.2652 10 15V12H7C6.73478 12 6.48043 11.8946 6.29289 11.7071C6.10536 11.5196 6 11.2652 6 11C6 10.7348 6.10536 10.4804 6.29289 10.2929C6.48043 10.1054 6.73478 10 7 10H10V7C10 6.73478 10.1054 6.48043 10.2929 6.29289C10.4804 6.10536 10.7348 6 11 6C11.2652 6 11.5196 6.10536 11.7071 6.29289C11.8946 6.48043 12 6.73478 12 7V10H15C15.2652 10 15.5196 10.1054 15.7071 10.2929C15.8946 10.4804 16 10.7348 16 11C16 11.2652 15.8946 11.5196 15.7071 11.7071C15.5196 11.8946 15.2652 12 15 12H12V15Z'
fill='currentColor'
/>
</svg>
</button>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -4,6 +4,7 @@ import { NextPage, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'tools/authentication'
import { MessageWithMember } from 'models/Message'
import { GuildsChannelsPath } from 'components/Application'
import { handleSocketData, SocketData } from 'tools/handleSocketData'
export interface Messages {
messages: MessageWithMember[]
@ -20,20 +21,44 @@ export interface MessagesProviderProps {
export const MessagesProvider: React.FC<MessagesProviderProps> = (props) => {
const { path, children } = props
const { authentication } = useAuthentication()
const {
items: messages,
hasMore,
nextPage,
resetPagination
resetPagination,
setItems
} = usePagination<MessageWithMember>({
api: authentication.api,
url: `/channels/${path.channelId}/messages`,
inverse: true
})
useEffect(() => {
authentication.socket.on(
'messages',
(data: SocketData<MessageWithMember>) => {
if (data.item.channelId === path.channelId) {
const messagesDiv = window.document.getElementById(
'messages'
) as HTMLDivElement
const isAtBottom =
messagesDiv.scrollHeight - messagesDiv.scrollTop <=
messagesDiv.clientHeight
handleSocketData({ data, setItems })
if (data.action === 'create' && isAtBottom) {
messagesDiv.scrollTo(0, messagesDiv.scrollHeight)
}
}
}
)
return () => {
authentication.socket.off('messages')
}
}, [authentication.socket, setItems, path])
useEffect(() => {
resetPagination()
nextPage(undefined, () => {

View File

@ -9,7 +9,7 @@ const errorObject: ErrorObject = {
schemaPath: '/path'
}
describe('Authentication/getErrorTranslationKey', () => {
describe('hooks/useForm/getErrorTranslationKey', () => {
it('returns `errors:invalid` with unknown keyword', async () => {
expect(
getErrorTranslationKey({

View File

@ -15,11 +15,14 @@ export interface UsePaginationOptions {
inverse?: boolean
}
export type SetItems<T> = React.Dispatch<React.SetStateAction<T[]>>
export interface UsePaginationResult<T> {
items: T[]
nextPage: NextPage
resetPagination: () => void
hasMore: boolean
setItems: SetItems<T>
}
export const usePagination = <T extends { id: number }>(
@ -81,5 +84,5 @@ export const usePagination = <T extends { id: number }>(
setItems([])
}, [])
return { items, hasMore, nextPage, resetPagination }
return { items, hasMore, nextPage, resetPagination, setItems }
}

2940
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@
"generate": "plop",
"lint:commit": "commitlint",
"lint:editorconfig": "editorconfig-checker",
"lint:markdown": "markdownlint '**/*.md' --dot --ignore 'node_modules'",
"lint:markdown": "markdownlint '**/*.md' --dot --ignore-path '.gitignore'",
"lint:typescript": "eslint '**/*.{js,ts,jsx,tsx}'",
"lint:prettier": "prettier '.' --check",
"lint:staged": "lint-staged",
@ -43,9 +43,9 @@
"ajv-formats": "2.1.1",
"axios": "0.24.0",
"classnames": "2.3.1",
"date-and-time": "2.0.1",
"date-and-time": "2.1.0",
"emoji-mart": "3.0.1",
"katex": "0.15.1",
"katex": "0.15.2",
"next": "12.0.7",
"next-pwa": "5.4.4",
"next-themes": "0.0.15",
@ -71,16 +71,16 @@
"universal-cookie": "4.0.4"
},
"devDependencies": {
"@commitlint/cli": "16.0.1",
"@commitlint/cli": "16.0.2",
"@commitlint/config-conventional": "16.0.0",
"@lhci/cli": "0.8.2",
"@saithodev/semantic-release-backmerge": "2.1.0",
"@storybook/addon-essentials": "6.4.9",
"@storybook/addon-links": "6.4.9",
"@storybook/addon-essentials": "6.4.12",
"@storybook/addon-links": "6.4.12",
"@storybook/addon-postcss": "2.0.0",
"@storybook/builder-webpack5": "6.4.9",
"@storybook/manager-webpack5": "6.4.9",
"@storybook/react": "6.4.9",
"@storybook/builder-webpack5": "6.4.12",
"@storybook/manager-webpack5": "6.4.12",
"@storybook/react": "6.4.12",
"@testing-library/jest-dom": "5.16.1",
"@testing-library/react": "12.1.2",
"@types/date-and-time": "0.13.0",
@ -93,8 +93,8 @@
"@types/react-responsive": "8.0.5",
"@types/unist": "2.0.6",
"@typescript-eslint/eslint-plugin": "4.33.0",
"autoprefixer": "10.4.1",
"cypress": "9.2.0",
"autoprefixer": "10.4.2",
"cypress": "9.2.1",
"editorconfig-checker": "4.0.2",
"eslint": "7.32.0",
"eslint-config-next": "12.0.7",
@ -106,10 +106,10 @@
"eslint-plugin-promise": "5.1.1",
"eslint-plugin-storybook": "0.5.5",
"eslint-plugin-unicorn": "40.0.0",
"husky": "7.0.4",
"html-w3c-validator": "1.0.0",
"husky": "7.0.4",
"jest": "27.4.7",
"lint-staged": "12.1.5",
"lint-staged": "12.1.7",
"markdownlint-cli": "0.30.0",
"mockttp": "2.5.0",
"next-secure-headers": "2.2.0",
@ -120,9 +120,9 @@
"serve": "13.0.2",
"start-server-and-test": "1.14.0",
"storybook-tailwind-dark-mode": "1.0.11",
"tailwindcss": "3.0.11",
"tailwindcss": "3.0.13",
"typescript": "4.4.4",
"vercel": "23.1.2",
"webpack": "5.65.0"
"webpack": "5.66.0"
}
}

View File

@ -47,7 +47,7 @@ const ChannelPage: NextPage<ChannelPageProps> = (props) => {
title={`# ${selectedChannel.name}`}
>
<Messages />
<SendMessage />
<SendMessage path={path} />
</Application>
</MessagesProvider>
</ChannelsProvider>

View File

@ -23,7 +23,9 @@ export const AuthenticationProvider: React.FC<PagePropsWithAuthentication> = (
const authentication = useMemo(() => {
return new Authentication(props.authentication.tokens)
}, [props.authentication.tokens])
// eslint-disable-next-line react-hooks/exhaustive-deps -- We only want to run this memo once
}, [])
useEffect(() => {
setLanguage(user.settings.language).catch(() => {})

49
tools/handleSocketData.ts Normal file
View File

@ -0,0 +1,49 @@
import { SetItems } from '../hooks/usePagination'
export interface Item {
id: number
[key: string]: any
}
export interface SocketData<T extends Item = Item> {
action: 'create' | 'update' | 'delete'
item: T
}
export interface HandleSocketDataOptions<T extends Item = Item> {
setItems: SetItems<T>
data: SocketData<T>
}
export type SocketListener = (data: SocketData) => void
export const handleSocketData = <T extends Item = Item>(
options: HandleSocketDataOptions<T>
): void => {
const { data, setItems } = options
setItems((oldItems) => {
const newItems = [...oldItems]
switch (data.action) {
case 'create': {
newItems.push(data.item)
break
}
case 'delete': {
const itemIndex = newItems.findIndex((item) => item.id === data.item.id)
if (itemIndex !== -1) {
newItems.splice(itemIndex, 1)
}
break
}
case 'update': {
const itemIndex = newItems.findIndex((item) => item.id === data.item.id)
if (itemIndex !== -1) {
newItems[itemIndex] = data.item
}
break
}
}
return newItems
})
}