feat: add support for files and math for messages (#5)
This commit is contained in:
@ -6,13 +6,13 @@ import { useMediaQuery } from 'react-responsive'
|
||||
import { useSwipeable } from 'react-swipeable'
|
||||
|
||||
import { Sidebar, DirectionSidebar } from './Sidebar'
|
||||
import { IconButton } from 'components/design/IconButton'
|
||||
import { IconLink } from 'components/design/IconLink'
|
||||
import { IconButton } from '../design/IconButton'
|
||||
import { IconLink } from '../design/IconLink'
|
||||
import { Guilds } from './Guilds/Guilds'
|
||||
import { Divider } from '../design/Divider'
|
||||
import { Members } from './Members'
|
||||
import { useAuthentication } from 'tools/authentication'
|
||||
import { API_URL } from 'tools/api'
|
||||
import { useAuthentication } from '../../tools/authentication'
|
||||
import { API_URL } from '../../tools/api'
|
||||
|
||||
export interface GuildsChannelsPath {
|
||||
guildId: number
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
import { channelExample } from '../../../../cypress/fixtures/channels/channel'
|
||||
|
||||
import { channelExample } from '../../../../cypress/fixtures/channels/channel'
|
||||
import { Channel as Component, ChannelProps } from './Channel'
|
||||
|
||||
const Stories: Meta = {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { channelExample } from 'cypress/fixtures/channels/channel'
|
||||
|
||||
import { channelExample } from '../../../../cypress/fixtures/channels/channel'
|
||||
import { Channel } from './Channel'
|
||||
|
||||
describe('<Channel />', () => {
|
||||
|
@ -1,9 +1,9 @@
|
||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
|
||||
import { useChannels } from '../../../contexts/Channels'
|
||||
import { GuildsChannelsPath } from '../Application'
|
||||
import { Loader } from 'components/design/Loader'
|
||||
import { Loader } from '../../design/Loader'
|
||||
import { Channel } from './Channel'
|
||||
import { useChannels } from 'contexts/Channels'
|
||||
|
||||
export interface ChannelsProps {
|
||||
path: GuildsChannelsPath
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { CogIcon, PlusIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { useGuildMember } from 'contexts/GuildMember'
|
||||
import { Divider } from 'components/design/Divider'
|
||||
import { Channels } from 'components/Application/Channels'
|
||||
import { IconButton } from 'components/design/IconButton'
|
||||
import { useGuildMember } from '../../../contexts/GuildMember'
|
||||
import { Divider } from '../../design/Divider'
|
||||
import { Channels } from '../../Application/Channels'
|
||||
import { IconButton } from '../../design/IconButton'
|
||||
import { GuildsChannelsPath } from '..'
|
||||
|
||||
export interface GuildLeftSidebarProps {
|
||||
|
@ -1,8 +1,8 @@
|
||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
|
||||
import { Loader } from 'components/design/Loader'
|
||||
import { Loader } from '../../design/Loader'
|
||||
import { Guild } from './Guild'
|
||||
import { useGuilds } from 'contexts/Guilds'
|
||||
import { useGuilds } from '../../../contexts/Guilds'
|
||||
import { GuildsChannelsPath } from '..'
|
||||
|
||||
export interface GuildsProps {
|
||||
|
@ -2,11 +2,11 @@ import useTranslation from 'next-translate/useTranslation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
|
||||
import { useAuthentication } from 'tools/authentication'
|
||||
import { GuildPublic as GuildPublicType } from 'models/Guild'
|
||||
import { Loader } from 'components/design/Loader'
|
||||
import { useAuthentication } from '../../../tools/authentication'
|
||||
import { GuildPublic as GuildPublicType } from '../../../models/Guild'
|
||||
import { Loader } from '../../design/Loader'
|
||||
import { GuildPublic } from './GuildPublic'
|
||||
import { usePagination } from 'hooks/usePagination'
|
||||
import { usePagination } from '../../../hooks/usePagination'
|
||||
|
||||
export const JoinGuildsPublic: React.FC = () => {
|
||||
const [search, setSearch] = useState('')
|
||||
|
@ -2,8 +2,8 @@ import useTranslation from 'next-translate/useTranslation'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
|
||||
import { Divider } from '../../design/Divider'
|
||||
import { Loader } from 'components/design/Loader'
|
||||
import { useMembers } from 'contexts/Members'
|
||||
import { Loader } from '../../design/Loader'
|
||||
import { useMembers } from '../../../contexts/Members'
|
||||
import { Member } from './Member'
|
||||
import { capitalize } from '../../../tools/utils/capitalize'
|
||||
|
||||
|
@ -4,7 +4,9 @@ import date from 'date-and-time'
|
||||
|
||||
import { MessageWithMember } from '../../../../models/Message'
|
||||
import { API_URL } from '../../../../tools/api'
|
||||
import { MessageContent } from './MessageContent'
|
||||
import { MessageText } from './MessageText'
|
||||
import { Loader } from '../../../design/Loader'
|
||||
import { MessageFile } from './MessageFile'
|
||||
|
||||
export interface MessageProps {
|
||||
message: MessageWithMember
|
||||
@ -14,7 +16,10 @@ export const Message: React.FC<MessageProps> = (props) => {
|
||||
const { message } = props
|
||||
|
||||
return (
|
||||
<div className='p-4 flex transition hover:bg-gray-200 dark:hover:bg-gray-900'>
|
||||
<div
|
||||
className='p-4 flex transition hover:bg-gray-200 dark:hover:bg-gray-900'
|
||||
data-cy={`message-${message.id}`}
|
||||
>
|
||||
<Link href={`/application/users/${message.member.user.id}`}>
|
||||
<a>
|
||||
<div className='w-12 h-12 mr-4 flex flex-shrink-0 items-center justify-center'>
|
||||
@ -54,7 +59,13 @@ export const Message: React.FC<MessageProps> = (props) => {
|
||||
{date.format(new Date(message.createdAt), 'DD/MM/YYYY - HH:mm:ss')}
|
||||
</span>
|
||||
</div>
|
||||
<MessageContent message={message} />
|
||||
{message.type === 'text' ? (
|
||||
<MessageText message={message} />
|
||||
) : message.type === 'file' ? (
|
||||
<MessageFile message={message} />
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -1 +0,0 @@
|
||||
export * from './MessageContent'
|
@ -0,0 +1,14 @@
|
||||
export const FileIcon: React.FC = () => {
|
||||
return (
|
||||
<svg
|
||||
className='dark:text-white text-black fill-current'
|
||||
width='21'
|
||||
height='26'
|
||||
viewBox='0 0 21 26'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path d='M2.625 0C1.92881 0 1.26113 0.273928 0.768845 0.761522C0.276562 1.24912 0 1.91044 0 2.6V23.4C0 24.0896 0.276562 24.7509 0.768845 25.2385C1.26113 25.7261 1.92881 26 2.625 26H18.375C19.0712 26 19.7389 25.7261 20.2312 25.2385C20.7234 24.7509 21 24.0896 21 23.4V7.8L13.125 0H2.625ZM13.125 9.1H11.8125V2.6L18.375 9.1H13.125Z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
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'
|
||||
|
||||
import { useAuthentication } from '../../../../../tools/authentication'
|
||||
import { MessageWithMember } from '../../../../../models/Message'
|
||||
import { Loader } from '../../../../design/Loader'
|
||||
import { FileIcon } from './FileIcon'
|
||||
|
||||
export interface FileData {
|
||||
blob: Blob
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface MessageContentProps {
|
||||
message: MessageWithMember
|
||||
}
|
||||
|
||||
export const MessageFile: React.FC<MessageContentProps> = (props) => {
|
||||
const { message } = props
|
||||
|
||||
const { authentication } = useAuthentication()
|
||||
const [file, setFile] = useState<FileData | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const ourRequest = axios.CancelToken.source()
|
||||
|
||||
const fetchData = async (): Promise<void> => {
|
||||
const { data } = await authentication.api.get(message.value, {
|
||||
responseType: 'blob',
|
||||
cancelToken: ourRequest.token
|
||||
})
|
||||
const fileURL = URL.createObjectURL(data)
|
||||
setFile({ blob: data, url: fileURL })
|
||||
}
|
||||
fetchData().catch(() => {})
|
||||
|
||||
return () => {
|
||||
ourRequest.cancel()
|
||||
}
|
||||
}, [message.value, authentication.api])
|
||||
|
||||
if (file == null) {
|
||||
return <Loader />
|
||||
}
|
||||
if (message.mimetype.startsWith('image/')) {
|
||||
return (
|
||||
<a href={file.url} target='_blank' rel='noreferrer'>
|
||||
<Image
|
||||
data-cy={`message-file-image-${message.id}`}
|
||||
className='max-w-xs max-h-xs'
|
||||
src={file.url}
|
||||
alt={message.value}
|
||||
width={320}
|
||||
height={320}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
if (message.mimetype.startsWith('audio/')) {
|
||||
return (
|
||||
<audio controls data-cy={`message-file-audio-${message.id}`}>
|
||||
<source src={file.url} type={message.mimetype} />
|
||||
</audio>
|
||||
)
|
||||
}
|
||||
if (message.mimetype.startsWith('video/')) {
|
||||
return (
|
||||
<video
|
||||
className='max-w-xs max-h-xs'
|
||||
controls
|
||||
data-cy={`message-file-video-${message.id}`}
|
||||
>
|
||||
<source src={file.url} type={message.mimetype} />
|
||||
</video>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a href={file.url} download data-cy={`message-file-download-${message.id}`}>
|
||||
<div className='flex items-center'>
|
||||
<div className='flex items-center'>
|
||||
<div>
|
||||
<FileIcon />
|
||||
</div>
|
||||
<div className='ml-4'>
|
||||
<p>{file.blob.type}</p>
|
||||
<p className='mt-1'>{prettyBytes(file.blob.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DownloadIcon className='ml-4 w-8 h-8' />
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './MessageFile'
|
@ -2,6 +2,10 @@ import { useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import gfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
import { Emoji, emojiPlugin, isStringWithOnlyOneEmoji } from '../../../../Emoji'
|
||||
import { MessageWithMember } from '../../../../../models/Message'
|
||||
@ -10,7 +14,7 @@ export interface MessageContentProps {
|
||||
message: MessageWithMember
|
||||
}
|
||||
|
||||
export const MessageContent: React.FC<MessageContentProps> = (props) => {
|
||||
export const MessageText: React.FC<MessageContentProps> = (props) => {
|
||||
const { message } = props
|
||||
|
||||
const isMessageWithOnlyOneEmoji = useMemo(() => {
|
||||
@ -31,8 +35,8 @@ export const MessageContent: React.FC<MessageContentProps> = (props) => {
|
||||
<ReactMarkdown
|
||||
disallowedElements={['table']}
|
||||
unwrapDisallowed
|
||||
remarkPlugins={[[gfm], [remarkBreaks]]}
|
||||
rehypePlugins={[emojiPlugin]}
|
||||
remarkPlugins={[[gfm], [remarkBreaks], [remarkMath]]}
|
||||
rehypePlugins={[[emojiPlugin], [rehypeKatex]]}
|
||||
linkTarget='_blank'
|
||||
components={{
|
||||
emoji: (props) => {
|
@ -0,0 +1 @@
|
||||
export * from './MessageText'
|
@ -1,9 +1,9 @@
|
||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
|
||||
import { Loader } from 'components/design/Loader'
|
||||
import { Loader } from '../../design/Loader'
|
||||
import { Message } from './Message'
|
||||
import { useMessages } from 'contexts/Messages'
|
||||
import { Emoji } from 'components/Emoji'
|
||||
import { useMessages } from '../../../contexts/Messages'
|
||||
import { Emoji } from '../../Emoji'
|
||||
|
||||
export const Messages: React.FC = () => {
|
||||
const { messages, hasMore, nextPage } = useMessages()
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import {
|
||||
userExample,
|
||||
userSettingsExample
|
||||
} from '../../../cypress/fixtures/users/user'
|
||||
|
||||
import { UserProfile as Component, UserProfileProps } from './UserProfile'
|
||||
|
||||
const Stories: Meta = {
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { user, userSettings } from '../../../cypress/fixtures/users/user'
|
||||
|
||||
import {
|
||||
userExample,
|
||||
userSettingsExample
|
||||
} from '../../../cypress/fixtures/users/user'
|
||||
import { UserProfile } from './UserProfile'
|
||||
|
||||
describe('<UserProfile />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(
|
||||
<UserProfile user={{ ...user, settings: userSettings }} />
|
||||
<UserProfile user={{ ...userExample, settings: userSettingsExample }} />
|
||||
)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
|
@ -11,7 +11,7 @@ import { Button } from '../design/Button'
|
||||
import { FormState } from '../design/FormState'
|
||||
import { AuthenticationForm } from './'
|
||||
import { userSchema } from '../../models/User'
|
||||
import { api } from 'tools/api'
|
||||
import { api } from '../../tools/api'
|
||||
import {
|
||||
Tokens,
|
||||
Authentication as AuthenticationClass
|
||||
@ -85,16 +85,16 @@ export const Authentication: React.FC<AuthenticationProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<section className='flex flex-col sm:items-center sm:w-full'>
|
||||
<div className='flex flex-col sm:items-center sm:w-full'>
|
||||
<div className='flex flex-col items-center justify-center space-y-6 sm:w-4/6 sm:flex-row sm:space-x-6 sm:space-y-0'>
|
||||
<SocialMediaButton socialMedia='Google' />
|
||||
<SocialMediaButton socialMedia='GitHub' />
|
||||
<SocialMediaButton socialMedia='Discord' />
|
||||
</div>
|
||||
</section>
|
||||
<section className='text-center text-lg font-paragraph pt-8'>
|
||||
</div>
|
||||
<div className='text-center text-lg font-paragraph pt-8'>
|
||||
{t('authentication:or')}
|
||||
</section>
|
||||
</div>
|
||||
<AuthenticationForm onSubmit={handleSubmit(onSubmit)}>
|
||||
{mode === 'signup' && (
|
||||
<Input
|
||||
|
@ -1,18 +1,36 @@
|
||||
import { forwardRef } from 'react'
|
||||
import classNames from 'classnames'
|
||||
|
||||
const className =
|
||||
'py-2 px-6 font-paragraph rounded-lg bg-transparent border border-green-800 dark:border-green-400 text-green-800 dark:text-green-400 hover:bg-green-800 hover:text-white dark:hover:bg-green-400 dark:hover:text-black fill-current stroke-current transform transition-colors duration-300 ease-in-out focus:outline-none focus:bg-green-800 focus:text-white dark:focus:bg-green-400 dark:focus:text-black'
|
||||
|
||||
export interface ButtonLinkProps extends React.ComponentPropsWithRef<'a'> {}
|
||||
|
||||
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
|
||||
(props, reference) => {
|
||||
const { children, className: givenClassName, ...rest } = props
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={reference}
|
||||
className={classNames(className, givenClassName)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ButtonLink.displayName = 'ButtonLink'
|
||||
|
||||
export interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = (props) => {
|
||||
const { children, className, ...rest } = props
|
||||
const { children, className: givenClassName, ...rest } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'py-2 px-6 font-paragraph rounded-lg bg-transparent border border-green-800 dark:border-green-400 text-green-800 dark:text-green-400 hover:bg-green-800 hover:text-white dark:hover:bg-green-400 dark:hover:text-black fill-current stroke-current transform transition-colors duration-300 ease-in-out focus:outline-none focus:bg-green-800 focus:text-white dark:focus:bg-green-400 dark:focus:text-black',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<button className={classNames(className, givenClassName)} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
@ -1,7 +1,7 @@
|
||||
import classNames from 'classnames'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
|
||||
import { FetchState as FormStateType } from 'hooks/useFetchState'
|
||||
import { FetchState as FormStateType } from '../../../hooks/useFetchState'
|
||||
import { Loader } from '../Loader'
|
||||
|
||||
export interface FormStateProps {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
|
||||
import { FormState } from '../FormState'
|
||||
|
||||
export interface InputProps extends React.ComponentPropsWithRef<'input'> {
|
||||
|
@ -17,7 +17,7 @@ describe('<SocialMediaButton />', () => {
|
||||
const { findByTestId } = render(
|
||||
<SocialMediaButton socialMedia={socialMedia} />
|
||||
)
|
||||
const button = await findByTestId('button')
|
||||
const button = await findByTestId('social-media-button')
|
||||
expect(button).toHaveStyle('color: #000')
|
||||
})
|
||||
})
|
||||
|
@ -8,19 +8,41 @@ type SocialMediaColors = {
|
||||
[key in SocialMedia]: string
|
||||
}
|
||||
|
||||
export interface SocialMediaButtonProps
|
||||
extends React.ComponentPropsWithRef<'button'> {
|
||||
socialMedia: SocialMedia
|
||||
}
|
||||
|
||||
const socialMediaColors: SocialMediaColors = {
|
||||
Discord: '#404EED',
|
||||
GitHub: '#24292E',
|
||||
Google: '#FCFCFC'
|
||||
}
|
||||
|
||||
const className =
|
||||
'py-2 px-6 inline-flex outline-none items-center font-paragraph rounded-lg cursor-pointer transition duration-300 ease-in-out hover:opacity-80 focus:outline-none'
|
||||
|
||||
interface SocialMediaChildrenProps {
|
||||
socialMedia: SocialMedia
|
||||
}
|
||||
|
||||
const SocialMediaChildren: React.FC<SocialMediaChildrenProps> = (props) => {
|
||||
const { socialMedia } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
width={20}
|
||||
height={20}
|
||||
src={`/images/svg/web/${socialMedia}.svg`}
|
||||
alt={socialMedia}
|
||||
/>
|
||||
<span className='ml-2'>{socialMedia}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SocialMediaButtonProps
|
||||
extends React.ComponentPropsWithRef<'button'>,
|
||||
SocialMediaChildrenProps {}
|
||||
|
||||
export const SocialMediaButton: React.FC<SocialMediaButtonProps> = (props) => {
|
||||
const { socialMedia, className, ...rest } = props
|
||||
const { socialMedia, className: givenClassName, ...rest } = props
|
||||
|
||||
const socialMediaColor = useMemo(() => {
|
||||
return socialMediaColors[socialMedia]
|
||||
@ -29,20 +51,11 @@ export const SocialMediaButton: React.FC<SocialMediaButtonProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
data-testid='button'
|
||||
data-testid='social-media-button'
|
||||
{...rest}
|
||||
className={classNames(
|
||||
`button py-2 px-6 inline-flex outline-none items-center font-paragraph rounded-lg cursor-pointer transition duration-300 ease-in-out hover:opacity-80 focus:outline-none`,
|
||||
className
|
||||
)}
|
||||
className={classNames(className, 'button', givenClassName)}
|
||||
>
|
||||
<Image
|
||||
width={20}
|
||||
height={20}
|
||||
src={`/images/svg/web/${socialMedia}.svg`}
|
||||
alt={socialMedia}
|
||||
/>
|
||||
<span className='ml-2'>{socialMedia}</span>
|
||||
<SocialMediaChildren socialMedia={socialMedia} />
|
||||
</button>
|
||||
|
||||
<style jsx>
|
||||
@ -60,3 +73,36 @@ export const SocialMediaButton: React.FC<SocialMediaButtonProps> = (props) => {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SocialMediaLinkProps
|
||||
extends React.ComponentPropsWithRef<'a'>,
|
||||
SocialMediaChildrenProps {}
|
||||
|
||||
export const SocialMediaLink: React.FC<SocialMediaLinkProps> = (props) => {
|
||||
const { socialMedia, className: givenClassName, ...rest } = props
|
||||
|
||||
const socialMediaColor = useMemo(() => {
|
||||
return socialMediaColors[socialMedia]
|
||||
}, [socialMedia])
|
||||
|
||||
return (
|
||||
<>
|
||||
<a {...rest} className={classNames(className, 'link', givenClassName)}>
|
||||
<SocialMediaChildren socialMedia={socialMedia} />
|
||||
</a>
|
||||
|
||||
<style jsx>
|
||||
{`
|
||||
.link {
|
||||
background: ${socialMediaColor};
|
||||
color: ${socialMedia === 'Google' ? '#000' : '#fff'};
|
||||
border: ${socialMedia === 'Google' ? '1px solid #000' : 'none'};
|
||||
}
|
||||
.link:focus {
|
||||
box-shadow: 0 0 0 2px #27b05e;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user