feat: add realtime with socket.io
This commit is contained in:
parent
5c03a9b944
commit
9229131c1a
@ -35,6 +35,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-namespace": "off"
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"@next/next/no-img-element": "off"
|
||||
}
|
||||
}
|
||||
|
@ -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}`}
|
||||
>
|
||||
|
@ -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
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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()
|
||||
})
|
||||
})
|
@ -1,28 +1,115 @@
|
||||
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 (
|
||||
<>
|
||||
{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'>
|
||||
<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
|
||||
/>
|
||||
</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
|
||||
className='w-full h-full flex items-center justify-center p-1 text-2xl transition hover:-translate-y-1'
|
||||
onClick={handleVisibleEmojiPicker}
|
||||
>
|
||||
🙂
|
||||
</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'>
|
||||
<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 width='25' height='25' viewBox='0 0 22 22'>
|
||||
<path
|
||||
@ -34,5 +121,6 @@ export const SendMessage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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, () => {
|
||||
|
@ -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({
|
||||
|
@ -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
2940
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ const ChannelPage: NextPage<ChannelPageProps> = (props) => {
|
||||
title={`# ${selectedChannel.name}`}
|
||||
>
|
||||
<Messages />
|
||||
<SendMessage />
|
||||
<SendMessage path={path} />
|
||||
</Application>
|
||||
</MessagesProvider>
|
||||
</ChannelsProvider>
|
||||
|
@ -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
49
tools/handleSocketData.ts
Normal 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
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user