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

View File

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

View File

@ -1,6 +1,8 @@
import { Meta, Story } from '@storybook/react' 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 = { const Stories: Meta = {
title: 'SendMessage', title: 'SendMessage',
@ -9,7 +11,9 @@ const Stories: Meta = {
export default Stories export default Stories
export const SendMessage: Story = (arguments_) => { export const SendMessage: Story<SendMessageProps> = (arguments_) => {
return <Component {...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 useTranslation from 'next-translate/useTranslation'
import TextareaAutosize from 'react-textarea-autosize' 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 { 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 ( 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'> {isVisibleEmojiPicker && <EmojiPicker onClick={handleEmojiPicker} />}
<form className='w-full h-full flex items-center'> <div className='p-6 pb-4'>
<TextareaAutosize <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'>
className='w-full scrollbar-firefox-support p-2 px-6 my-2 bg-transparent outline-none font-paragraph tracking-wide resize-none' <form
placeholder={t('application:write-a-message')} className='w-full h-full flex items-center'
wrap='soft' onSubmit={handleSubmit}
maxRows={6} onKeyDown={handleTextareaKeyDown}
/> >
</form> <TextareaAutosize
<div className='h-full flex items-center justify-around pr-6'> className='w-full scrollbar-firefox-support p-2 px-6 my-2 bg-transparent outline-none font-paragraph tracking-wide resize-none'
<button className='w-full h-full flex items-center justify-center p-1 text-2xl transition hover:-translate-y-1'> placeholder={t('application:write-a-message')}
🙂 wrap='soft'
</button> maxRows={6}
<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'> name='message'
<input onChange={handleTextareaChange}
type='file' value={message}
className='absolute w-full h-full opacity-0 cursor-pointer' ref={textareaReference}
autoFocus
/> />
<svg width='25' height='25' viewBox='0 0 22 22'> </form>
<path <div className='h-full flex items-center justify-around pr-6'>
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' <button
fill='currentColor' 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> <svg width='25' height='25' viewBox='0 0 22 22'>
</button> <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> </div>
</div> </>
) )
} }

View File

@ -4,6 +4,7 @@ import { NextPage, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'tools/authentication' import { useAuthentication } from 'tools/authentication'
import { MessageWithMember } from 'models/Message' import { MessageWithMember } from 'models/Message'
import { GuildsChannelsPath } from 'components/Application' import { GuildsChannelsPath } from 'components/Application'
import { handleSocketData, SocketData } from 'tools/handleSocketData'
export interface Messages { export interface Messages {
messages: MessageWithMember[] messages: MessageWithMember[]
@ -20,20 +21,44 @@ export interface MessagesProviderProps {
export const MessagesProvider: React.FC<MessagesProviderProps> = (props) => { export const MessagesProvider: React.FC<MessagesProviderProps> = (props) => {
const { path, children } = props const { path, children } = props
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
const { const {
items: messages, items: messages,
hasMore, hasMore,
nextPage, nextPage,
resetPagination resetPagination,
setItems
} = usePagination<MessageWithMember>({ } = usePagination<MessageWithMember>({
api: authentication.api, api: authentication.api,
url: `/channels/${path.channelId}/messages`, url: `/channels/${path.channelId}/messages`,
inverse: true 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(() => { useEffect(() => {
resetPagination() resetPagination()
nextPage(undefined, () => { nextPage(undefined, () => {

View File

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

View File

@ -15,11 +15,14 @@ export interface UsePaginationOptions {
inverse?: boolean inverse?: boolean
} }
export type SetItems<T> = React.Dispatch<React.SetStateAction<T[]>>
export interface UsePaginationResult<T> { export interface UsePaginationResult<T> {
items: T[] items: T[]
nextPage: NextPage nextPage: NextPage
resetPagination: () => void resetPagination: () => void
hasMore: boolean hasMore: boolean
setItems: SetItems<T>
} }
export const usePagination = <T extends { id: number }>( export const usePagination = <T extends { id: number }>(
@ -81,5 +84,5 @@ export const usePagination = <T extends { id: number }>(
setItems([]) 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", "generate": "plop",
"lint:commit": "commitlint", "lint:commit": "commitlint",
"lint:editorconfig": "editorconfig-checker", "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:typescript": "eslint '**/*.{js,ts,jsx,tsx}'",
"lint:prettier": "prettier '.' --check", "lint:prettier": "prettier '.' --check",
"lint:staged": "lint-staged", "lint:staged": "lint-staged",
@ -43,9 +43,9 @@
"ajv-formats": "2.1.1", "ajv-formats": "2.1.1",
"axios": "0.24.0", "axios": "0.24.0",
"classnames": "2.3.1", "classnames": "2.3.1",
"date-and-time": "2.0.1", "date-and-time": "2.1.0",
"emoji-mart": "3.0.1", "emoji-mart": "3.0.1",
"katex": "0.15.1", "katex": "0.15.2",
"next": "12.0.7", "next": "12.0.7",
"next-pwa": "5.4.4", "next-pwa": "5.4.4",
"next-themes": "0.0.15", "next-themes": "0.0.15",
@ -71,16 +71,16 @@
"universal-cookie": "4.0.4" "universal-cookie": "4.0.4"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "16.0.1", "@commitlint/cli": "16.0.2",
"@commitlint/config-conventional": "16.0.0", "@commitlint/config-conventional": "16.0.0",
"@lhci/cli": "0.8.2", "@lhci/cli": "0.8.2",
"@saithodev/semantic-release-backmerge": "2.1.0", "@saithodev/semantic-release-backmerge": "2.1.0",
"@storybook/addon-essentials": "6.4.9", "@storybook/addon-essentials": "6.4.12",
"@storybook/addon-links": "6.4.9", "@storybook/addon-links": "6.4.12",
"@storybook/addon-postcss": "2.0.0", "@storybook/addon-postcss": "2.0.0",
"@storybook/builder-webpack5": "6.4.9", "@storybook/builder-webpack5": "6.4.12",
"@storybook/manager-webpack5": "6.4.9", "@storybook/manager-webpack5": "6.4.12",
"@storybook/react": "6.4.9", "@storybook/react": "6.4.12",
"@testing-library/jest-dom": "5.16.1", "@testing-library/jest-dom": "5.16.1",
"@testing-library/react": "12.1.2", "@testing-library/react": "12.1.2",
"@types/date-and-time": "0.13.0", "@types/date-and-time": "0.13.0",
@ -93,8 +93,8 @@
"@types/react-responsive": "8.0.5", "@types/react-responsive": "8.0.5",
"@types/unist": "2.0.6", "@types/unist": "2.0.6",
"@typescript-eslint/eslint-plugin": "4.33.0", "@typescript-eslint/eslint-plugin": "4.33.0",
"autoprefixer": "10.4.1", "autoprefixer": "10.4.2",
"cypress": "9.2.0", "cypress": "9.2.1",
"editorconfig-checker": "4.0.2", "editorconfig-checker": "4.0.2",
"eslint": "7.32.0", "eslint": "7.32.0",
"eslint-config-next": "12.0.7", "eslint-config-next": "12.0.7",
@ -106,10 +106,10 @@
"eslint-plugin-promise": "5.1.1", "eslint-plugin-promise": "5.1.1",
"eslint-plugin-storybook": "0.5.5", "eslint-plugin-storybook": "0.5.5",
"eslint-plugin-unicorn": "40.0.0", "eslint-plugin-unicorn": "40.0.0",
"husky": "7.0.4",
"html-w3c-validator": "1.0.0", "html-w3c-validator": "1.0.0",
"husky": "7.0.4",
"jest": "27.4.7", "jest": "27.4.7",
"lint-staged": "12.1.5", "lint-staged": "12.1.7",
"markdownlint-cli": "0.30.0", "markdownlint-cli": "0.30.0",
"mockttp": "2.5.0", "mockttp": "2.5.0",
"next-secure-headers": "2.2.0", "next-secure-headers": "2.2.0",
@ -120,9 +120,9 @@
"serve": "13.0.2", "serve": "13.0.2",
"start-server-and-test": "1.14.0", "start-server-and-test": "1.14.0",
"storybook-tailwind-dark-mode": "1.0.11", "storybook-tailwind-dark-mode": "1.0.11",
"tailwindcss": "3.0.11", "tailwindcss": "3.0.13",
"typescript": "4.4.4", "typescript": "4.4.4",
"vercel": "23.1.2", "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}`} title={`# ${selectedChannel.name}`}
> >
<Messages /> <Messages />
<SendMessage /> <SendMessage path={path} />
</Application> </Application>
</MessagesProvider> </MessagesProvider>
</ChannelsProvider> </ChannelsProvider>

View File

@ -23,7 +23,9 @@ export const AuthenticationProvider: React.FC<PagePropsWithAuthentication> = (
const authentication = useMemo(() => { const authentication = useMemo(() => {
return new Authentication(props.authentication.tokens) 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(() => { useEffect(() => {
setLanguage(user.settings.language).catch(() => {}) 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
})
}