feat(react): add all chat message variants

This commit is contained in:
Pavel Prichodko 2022-04-13 14:24:37 +02:00
parent 1832ee9eae
commit f69a37c041
No known key found for this signature in database
GPG Key ID: 0EB8D75C775AB6F1
5 changed files with 628 additions and 359 deletions

View File

@ -1,359 +0,0 @@
import React from 'react'
import { useChatState } from '~/src/contexts/chat-context'
import { BellIcon } from '~/src/icons/bell-icon'
import { PencilIcon } from '~/src/icons/pencil-icon'
import { PinIcon } from '~/src/icons/pin-icon'
import { ReactionIcon } from '~/src/icons/reaction-icon'
import { ReplyIcon } from '~/src/icons/reply-icon'
import { TrashIcon } from '~/src/icons/trash-icon'
import { styled } from '~/src/styles/config'
import {
AlertDialog,
AlertDialogTrigger,
Avatar,
Box,
Button,
ContextMenu,
ContextMenuTrigger,
DropdownMenu,
DropdownMenuTrigger,
EmojiHash,
Flex,
IconButton,
Image,
Text,
Tooltip,
} from '~/src/system'
interface Props {
reply?: 'text' | 'image' | 'image-text'
image?: boolean
mention?: boolean
pinned?: boolean
}
const MessageLink = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const { onClick } = props
return (
<a
{...props}
href="https://specs.status.im/spec/"
onClick={e => {
onClick?.(e)
e.preventDefault()
}}
>
https://specs.status.im/spec/
</a>
)
}
export const ChatMessage = (props: Props) => {
const { reply, image, mention, pinned } = props
const { dispatch } = useChatState()
return (
<>
{reply && <MessageReply reply={reply} />}
<ContextMenuTrigger>
<Wrapper mention={mention} pinned={pinned}>
<div>
<DropdownMenuTrigger>
<button type="button">
<Avatar size={44} />
</button>
<DropdownMenu>
<div>
<Avatar size={36} />
<Text>simon.eth</Text>
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item icon={<BellIcon />}>
View Profile
</DropdownMenu.Item>
<DropdownMenu.Item icon={<BellIcon />}>
Send Message
</DropdownMenu.Item>
<DropdownMenu.Item icon={<BellIcon />}>
Verify Identity
</DropdownMenu.Item>
<DropdownMenu.Item icon={<BellIcon />}>
Send Contact Request
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item icon={<BellIcon />} danger>
Mark as Untrustworthy
</DropdownMenu.Item>
</DropdownMenu>
</DropdownMenuTrigger>
</div>
<div style={{ flex: 1 }}>
<Flex>
<Text color="primary" weight="500">
carmen
</Text>
<Text size="10" color="gray">
10:00
</Text>
</Flex>
<Text>
My first hoya{' '}
<AlertDialogTrigger>
<MessageLink href="https://specs.status.im/spec">
https://specs.status.im/spec
</MessageLink>
<AlertDialog
title="Are you sure you want to visit this website?"
description="https://specs.status.im/spec"
actionLabel="Yes, take me there"
/>
</AlertDialogTrigger>{' '}
bloom has started to develop alongside my first aphid issue 😩
</Text>
{image && (
<Flex gap={1} css={{ paddingTop: '$1' }}>
<Image
width={147}
alt="message"
height={196}
src="https://images.unsplash.com/photo-1648492678772-8b52fe36cebc?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3570&q=80"
radius="bubble"
/>
</Flex>
)}
</div>
<Actions>
<Tooltip label="React">
<IconButton label="Pick reaction" intent="info" color="gray">
<ReactionIcon />
</IconButton>
</Tooltip>
<Tooltip label="Reply">
<IconButton
label="Reply to message"
intent="info"
color="gray"
onClick={() =>
dispatch({
type: 'SET_REPLY',
message: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
type: reply!,
text: 'bloom has started to develop alongside my first aphid issue 😩',
},
})
}
>
<ReplyIcon />
</IconButton>
</Tooltip>
<Tooltip label="Edit">
<IconButton label="Edit message" intent="info" color="gray">
<PencilIcon />
</IconButton>
</Tooltip>
{pinned ? (
<Tooltip label="Unpin">
<IconButton label="Unpin message" intent="info" color="gray">
<UnpinIcon />
</IconButton>
</Tooltip>
) : (
<Tooltip label="Pin">
<IconButton label="Pin message" intent="info" color="gray">
<PinIcon />
</IconButton>
</Tooltip>
)}
<Tooltip label="Delete">
<IconButton label="Delete message" intent="danger" color="gray">
<TrashIcon />
</IconButton>
</Tooltip>
</Actions>
</Wrapper>
<ContextMenu>
<ContextMenu.Item>Reply</ContextMenu.Item>
<ContextMenu.Item>Pin</ContextMenu.Item>
</ContextMenu>
</ContextMenuTrigger>
</>
)
}
const MessageReply = ({
reply,
}: {
reply: 'text' | 'image' | 'image-text'
}) => {
return (
<Reply>
<Flex gap="1" align="center">
<Avatar size={20} />
<Text color="gray" size="13" weight="500">
vitalik.eth
</Text>
</Flex>
{reply === 'text' && (
<Flex>
<Text color="gray" size="13" truncate={false}>
This a very very very very very very very very very very very very
very very very very very very very very very very very very very
very very very very long message that is going to be truncated.
</Text>
</Flex>
)}
{reply === 'image' && (
<Image
src="https://images.unsplash.com/photo-1647531041383-fe7103712f16?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80"
width={56}
height={56}
fit="cover"
radius="1"
alt="message"
/>
)}
{reply === 'image-text' && (
<Box>
<Text color="gray" size="13" truncate={false}>
This a very very very very very very very very very very very very
very very very very very very very very very very very very very
very very very very long message that is going to be truncated.
</Text>
<Image
src="https://images.unsplash.com/photo-1647531041383-fe7103712f16?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80"
width={56}
height={56}
fit="cover"
radius="1"
alt="message"
/>
</Box>
)}
</Reply>
)
}
const Wrapper = styled('div', {
position: 'relative',
padding: '10px 16px',
display: 'flex',
gap: '$2',
transitionProperty: 'background-color, border-color, color, fill, stroke',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '100ms',
'&:hover, &[data-open="true"]': {
background: '#EEF2F5',
// [`& ${Actions}`]: {
// marginLeft: '5px',
// },
},
a: {
textDecoration: 'underline',
},
variants: {
mention: {
true: {
background: '$mention-4',
'&:hover, &[data-open="true"]': {
background: '$mention-3',
// [`& ${Actions}`]: {
// marginLeft: '5px',
// },
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: 3,
background: '$mention-1',
},
},
},
pinned: {
true: {
background: '$pin-3',
'&:hover, &[data-open="true"]': {
background: '$pin-2',
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: 3,
background: '$pin-1',
},
},
},
},
})
const Reply = styled('div', {
position: 'relative',
// height: 40,
marginLeft: 68,
display: 'flex',
flexDirection: 'column',
gap: '$1',
'&::before, &::after': {
content: '""',
position: 'absolute',
'--background-accent': 'rgba(147, 155, 161, 0.4)',
'--avatar-size': '44px',
'--gutter': '8px',
'--width': '2px',
},
'&::before': {
display: 'block',
position: 'absolute',
top: 10,
right: 'calc(100% + 10px)',
bottom: '0',
left: 'calc(var(--avatar-size)/2*-1 + var(--gutter)*-1)',
marginRight: 'var(--reply-spacing)',
marginTop: 'calc(var(--width)*-1/2)',
marginLeft: 'calc(var(--width)*-1/2)',
marginBottom: 'calc(0.125rem - 4px)',
borderLeft: 'var(--width) solid var(--background-accent)',
borderBottom: '0 solid var(--background-accent)',
borderRight: '0 solid var(--background-accent)',
borderTop: 'var(--width) solid var(--background-accent)',
borderTopLeftRadius: '10px',
},
})
const Actions = styled('div', {
position: 'absolute',
top: -18,
right: 16,
padding: 2,
boxShadow: '0px 4px 12px rgba(0, 34, 51, 0.08)',
background: '#fff',
borderRadius: 8,
display: 'none',
':hover > &': {
display: 'flex',
},
})

View File

@ -0,0 +1,129 @@
import React, { useState } from 'react'
import { ReactionPopover } from '~/src/components/reaction-popover'
import { PencilIcon } from '~/src/icons/pencil-icon'
import { PinIcon } from '~/src/icons/pin-icon'
import { ReactionIcon } from '~/src/icons/reaction-icon'
import { ReplyIcon } from '~/src/icons/reply-icon'
import { TrashIcon } from '~/src/icons/trash-icon'
import { UnpinIcon } from '~/src/icons/unpin-icon'
import { styled } from '~/src/styles/config'
import {
AlertDialog,
AlertDialogTrigger,
IconButton,
Tooltip,
} from '~/src/system'
interface Props {
owner: boolean
pinned: boolean
onReplyClick: () => void
onEditClick: () => void
reacting: boolean
onReactingChange: (reacting: boolean) => void
}
export const Actions = (props: Props) => {
const {
owner,
pinned,
onReplyClick,
onEditClick,
reacting,
onReactingChange,
} = props
return (
<Wrapper open={reacting}>
<ReactionPopover
open={reacting}
onOpenChange={onReactingChange}
onClick={emoji => {
console.log(emoji)
onReactingChange(false)
}}
>
<Tooltip label="React">
<IconButton label="Pick reaction" intent="info" color="gray">
<ReactionIcon />
</IconButton>
</Tooltip>
</ReactionPopover>
<Tooltip label="Reply">
<IconButton
label="Reply to message"
intent="info"
color="gray"
onClick={onReplyClick}
>
<ReplyIcon />
</IconButton>
</Tooltip>
{owner && (
<Tooltip label="Edit">
<IconButton
label="Edit message"
intent="info"
color="gray"
onClick={onEditClick}
>
<PencilIcon />
</IconButton>
</Tooltip>
)}
{pinned ? (
<Tooltip label="Unpin">
<IconButton label="Unpin message" intent="info" color="gray">
<UnpinIcon />
</IconButton>
</Tooltip>
) : (
<Tooltip label="Pin">
<IconButton label="Pin message" intent="info" color="gray">
<PinIcon />
</IconButton>
</Tooltip>
)}
{owner && (
<AlertDialogTrigger>
<Tooltip label="Delete">
<IconButton label="Delete message" intent="danger" color="gray">
<TrashIcon />
</IconButton>
</Tooltip>
<AlertDialog
title="Delete Message"
description="Are you sure you want to delete this message?"
actionLabel="Delete"
actionVariant="danger"
cancelLabel="Cancel"
/>
</AlertDialogTrigger>
)}
</Wrapper>
)
}
const Wrapper = styled('div', {
position: 'absolute',
top: -18,
right: 16,
padding: 2,
boxShadow: '0px 4px 12px rgba(0, 34, 51, 0.08)',
background: '#fff',
borderRadius: 8,
display: 'none',
':hover > &': {
display: 'flex',
},
variants: {
open: {
true: {
display: 'flex',
},
},
},
})

View File

@ -0,0 +1,279 @@
import React, { useState } from 'react'
import { useChatContext } from '~/src/contexts/chat-context'
import { BellIcon } from '~/src/icons/bell-icon'
import { PinIcon } from '~/src/icons/pin-icon'
import { styled } from '~/src/styles/config'
import {
Avatar,
Box,
Button,
ContextMenu,
ContextMenuTrigger,
DropdownMenu,
DropdownMenuTrigger,
EmojiHash,
Flex,
Image,
Text,
} from '~/src/system'
import { ChatInput } from '../chat-input'
import { Actions } from './actions'
import { MessageReply } from './message-reply'
import { Reactions } from './reactions'
import type { Message } from '~/src/protocol/use-messages'
interface Props {
message: Message
}
// const MessageLink = forwardRef(function MessageLink(
// props: React.AnchorHTMLAttributes<HTMLAnchorElement>,
// ref: Ref<HTMLAnchorElement>
// ) {
// const { onClick } = props
// return (
// <a
// {...props}
// ref={ref}
// href="https://specs.status.im/spec/"
// onClick={e => {
// onClick?.(e)
// e.preventDefault()
// }}
// >
// https://specs.status.im/spec/
// </a>
// )
// })
export const ChatMessage = (props: Props) => {
const { message } = props
const { type, contact, owner, mention, pinned, reply } = message
const [editing, setEditing] = useState(false)
const [reacting, setReacting] = useState(false)
const { dispatch } = useChatContext()
if (editing) {
return (
<Wrapper>
<Avatar size={44} />
<Box>
<ChatInput value={text} />
<Flex gap={2}>
<Button
variant="outline"
size="small"
onClick={() => setEditing(false)}
>
Cancel
</Button>
<Button size="small">Save</Button>
</Flex>
</Box>
</Wrapper>
)
}
const handleReplyClick = () => {
dispatch({
type: 'SET_REPLY',
message,
})
}
const handleReaction = (reaction: string) => {
console.log(reaction)
}
const renderMessage = () => {
switch (type) {
case 'text': {
// <AlertDialogTrigger>
// <MessageLink href="https://specs.status.im/spec">
// https://specs.status.im/spec
// </MessageLink>
// <AlertDialog
// title="Are you sure you want to visit this website?"
// description="https://specs.status.im/spec"
// actionLabel="Yes, take me there"
// />
// </AlertDialogTrigger>{' '}
return <Text>{message.text}</Text>
}
case 'image': {
return (
<Flex gap={1} css={{ paddingTop: '$2' }}>
<Image
width={147}
alt="message"
height={196}
src={message.imageUrl}
radius="bubble"
/>
</Flex>
)
}
case 'image-text': {
const { text, imageUrl } = message
return (
<>
<Text>{text}</Text>
<Flex gap={1} css={{ paddingTop: '$1' }}>
<Image
width={147}
alt="message"
height={196}
src={imageUrl}
radius="bubble"
/>
</Flex>
</>
)
}
}
}
return (
<>
<ContextMenuTrigger>
<Wrapper mention={mention} pinned={pinned} data-active={reacting}>
{reply && <MessageReply reply={reply} />}
<Flex gap={2}>
<Box>
<DropdownMenuTrigger>
<button type="button">
<Avatar size={44} src={contact.imageUrl} />
</button>
<DropdownMenu>
<div>
<Avatar size="36" src={contact.imageUrl} />
<Text>{contact.name}</Text>
<EmojiHash />
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item icon={<BellIcon />}>
View Profile
</DropdownMenu.Item>
<DropdownMenu.Item icon={<BellIcon />}>
Send Message
</DropdownMenu.Item>
<DropdownMenu.Item icon={<BellIcon />}>
Verify Identity
</DropdownMenu.Item>
<DropdownMenu.Item icon={<BellIcon />}>
Send Contact Request
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item icon={<BellIcon />} danger>
Mark as Untrustworthy
</DropdownMenu.Item>
</DropdownMenu>
</DropdownMenuTrigger>
</Box>
<Box css={{ flex: 1 }}>
{pinned && (
<Flex gap={1}>
<PinIcon width={8} />
<Text size="13">Pinned by carmen.eth</Text>
</Flex>
)}
<Flex gap="1" align="center">
<Text color="primary" weight="500" size="15">
{contact.name}
</Text>
<Text size="10" color="gray">
10:00 AM
</Text>
</Flex>
{renderMessage()}
<Reactions onClick={handleReaction} />
</Box>
</Flex>
<Actions
owner={owner}
pinned={pinned}
onEditClick={() => setEditing(true)}
onReplyClick={handleReplyClick}
reacting={reacting}
onReactingChange={setReacting}
/>
</Wrapper>
<ContextMenu>
<ContextMenu.Item>Reply</ContextMenu.Item>
<ContextMenu.Item>Pin</ContextMenu.Item>
</ContextMenu>
</ContextMenuTrigger>
</>
)
}
// TODO: Use compound variants https://stitches.dev/docs/variants#compound-variants
const Wrapper = styled('div', {
position: 'relative',
padding: '10px 16px',
gap: '$2',
transitionProperty: 'background-color, border-color, color, fill, stroke',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '100ms',
'&:hover, &[data-open="true"], &[data-active="true"]': {
background: '#EEF2F5',
},
a: {
textDecoration: 'underline',
},
variants: {
mention: {
true: {
background: '$mention-4',
'&:hover, &[data-open="true"], &[data-active="true"]': {
background: '$mention-3',
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: 3,
background: '$mention-1',
},
},
},
pinned: {
true: {
background: '$pin-3',
'&:hover, &[data-open="true"], &[data-active="true"]': {
background: '$pin-2',
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: 3,
background: '$pin-1',
},
},
},
},
})

View File

@ -0,0 +1,105 @@
import React from 'react'
import { styled } from '~/src/styles/config'
import { Avatar, Box, Flex, Image, Text } from '~/src/system'
import type { Reply } from '~/src/protocol/use-messages'
interface Props {
reply: Reply
}
export const MessageReply = (props: Props) => {
const { reply } = props
const { contact } = reply
return (
<Wrapper>
<Flex gap="1" align="center">
<Avatar size={20} src={contact.imageUrl} />
<Text color="gray" size="13" weight="500">
{contact.name}
</Text>
</Flex>
{reply.type === 'text' && (
<Flex>
<Text
color="gray"
size="13"
truncate={false}
css={{
lineClamp: 1,
}}
>
{reply.text}
</Text>
</Flex>
)}
{reply.type === 'image' && (
<Box css={{ paddingTop: '$1' }}>
<Image
src={reply.imageUrl}
width={56}
height={56}
fit="cover"
radius="1"
alt="message"
/>
</Box>
)}
{reply.type === 'image-text' && (
<Flex direction="column" gap={1}>
<Text color="gray" size="13" truncate={false}>
{reply.text}
</Text>
<Image
src={reply.imageUrl}
width={56}
height={56}
fit="cover"
radius="1"
alt="message"
/>
</Flex>
)}
</Wrapper>
)
}
const Wrapper = styled('div', {
position: 'relative',
// height: 40,
marginLeft: 52,
display: 'flex',
flexDirection: 'column',
// gap: '$1',
paddingBottom: 8,
'&::before, &::after': {
content: '""',
position: 'absolute',
'--background-accent': 'rgba(147, 155, 161, 0.4)',
'--avatar-size': '44px',
'--gutter': '8px',
'--width': '2px',
},
'&::before': {
display: 'block',
position: 'absolute',
top: 10,
right: 'calc(100% + 10px)',
bottom: 10,
left: 'calc(var(--avatar-size)/2*-1 + var(--gutter)*-1)',
marginRight: 'var(--reply-spacing)',
marginTop: 'calc(var(--width)*-1/2)',
marginLeft: 'calc(var(--width)*-1/2)',
marginBottom: 'calc(0.125rem - 4px)',
borderLeft: 'var(--width) solid var(--background-accent)',
borderBottom: '0 solid var(--background-accent)',
borderRight: '0 solid var(--background-accent)',
borderTop: 'var(--width) solid var(--background-accent)',
borderTopLeftRadius: '10px',
},
})

View File

@ -0,0 +1,115 @@
import React from 'react'
import { ReactionPopover } from '~/src/components/reaction-popover'
import { ReactionIcon } from '~/src/icons/reaction-icon'
import { styled } from '~/src/styles/config'
import { Flex, Image } from '~/src/system'
interface Props {
onClick: (reaction: string) => void
}
export const Reactions = (props: Props) => {
const { onClick } = props
return (
<Flex css={{ paddingTop: 6 }} gap={1}>
<Button
onClick={() => onClick('')}
active={false}
aria-label="❤️, 1 reaction, press to react"
>
<Image
width={16}
src="https://twemoji.maxcdn.com/v/latest/svg/2764.svg"
alt="❤️"
/>
1
</Button>
<Button
onClick={() => onClick('')}
active={true}
aria-label="👍️, 1 reaction, press to react"
>
<Image
width={16}
src="https://twemoji.maxcdn.com/v/latest/svg/1f44d.svg"
alt="👍️"
/>
2
</Button>
<Button
onClick={() => onClick('')}
active={true}
aria-label="👎️, 1 reaction, press to react"
>
<Image
width={16}
src="https://twemoji.maxcdn.com/v/latest/svg/1f44e.svg"
alt="👎️"
/>
3
</Button>
<Button
onClick={() => onClick('')}
active={false}
aria-label="😆, 1 reaction, press to react"
>
<Image
width={16}
src="https://twemoji.maxcdn.com/v/latest/svg/1f606.svg"
alt="😆"
/>
1
</Button>
<Button
onClick={() => onClick('')}
active={false}
aria-label="😭, 1 reaction, press to react"
>
<Image
width={16}
src="https://twemoji.maxcdn.com/v/latest/svg/1f62d.svg"
alt="😭"
/>
</Button>
<Button
onClick={() => onClick('')}
active={false}
aria-label="😡, 1 reaction, press to react"
>
<Image
width={16}
src="https://twemoji.maxcdn.com/v/latest/svg/1f621.svg"
alt="😡"
/>
</Button>
<ReactionPopover
onClick={emoji => {
console.log(emoji)
}}
>
<Button>
<ReactionIcon />
</Button>
</ReactionPopover>
</Flex>
)
}
const Button = styled('button', {
padding: 2,
boxShadow: '0px 4px 12px rgba(0, 34, 51, 0.08)',
background: '$accent-8',
borderRadius: '2px 10px 10px 10px',
minWidth: 36,
height: 20,
display: 'inline-flex',
alignItems: 'center',
variants: {
active: {
true: {},
},
},
})