diff --git a/packages/status-js/src/client/chat.ts b/packages/status-js/src/client/chat.ts index 2a2a9e4d..1cb694dc 100644 --- a/packages/status-js/src/client/chat.ts +++ b/packages/status-js/src/client/chat.ts @@ -240,9 +240,10 @@ export class Chat { return } - if (!this.#messages.size) { - return - } + // fixme?: to stop the loading we need to let the listeners know even if there are no messages + // if (!this.#messages.size) { + // return + // } const messages = this.getMessages() diff --git a/packages/status-js/src/helpers/contains-only-emoji.test.ts b/packages/status-js/src/helpers/contains-only-emoji.test.ts index 2bb668a7..4a44fd1d 100644 --- a/packages/status-js/src/helpers/contains-only-emoji.test.ts +++ b/packages/status-js/src/helpers/contains-only-emoji.test.ts @@ -5,6 +5,8 @@ import { containsOnlyEmoji } from './contains-only-emoji' test('should be truthy', () => { expect(containsOnlyEmoji('💩')).toBeTruthy() expect(containsOnlyEmoji('💩💩💩💩💩💩')).toBeTruthy() + // expect(containsOnlyEmoji('1️⃣')).toBeTruthy() + // expect(containsOnlyEmoji('👨‍👩‍👧')).toBeTruthy() }) test('should be falsy', () => { @@ -14,4 +16,7 @@ test('should be falsy', () => { expect(containsOnlyEmoji('💩 ')).toBeFalsy() expect(containsOnlyEmoji('text 💩')).toBeFalsy() expect(containsOnlyEmoji('💩 text')).toBeFalsy() + expect(containsOnlyEmoji('123')).toBeFalsy() + expect(containsOnlyEmoji('💩 123')).toBeFalsy() + expect(containsOnlyEmoji('123 💩💩💩 ')).toBeFalsy() }) diff --git a/packages/status-js/src/helpers/contains-only-emoji.ts b/packages/status-js/src/helpers/contains-only-emoji.ts index 60525bbc..0ef68c73 100644 --- a/packages/status-js/src/helpers/contains-only-emoji.ts +++ b/packages/status-js/src/helpers/contains-only-emoji.ts @@ -1,4 +1,7 @@ // todo?: should ignore whitespaces with replace(/\s+/g, '').trim() +/** + * https://www.unicode.org/reports/tr51/#def_emoji_presentation + */ export function containsOnlyEmoji(text: string): boolean { - return /^\p{Emoji}+$/gu.test(text) + return /^\p{Emoji_Presentation}+$/gu.test(text) } diff --git a/packages/status-react/package.json b/packages/status-react/package.json index 61bff02d..df19fe45 100644 --- a/packages/status-react/package.json +++ b/packages/status-react/package.json @@ -42,6 +42,7 @@ "@radix-ui/react-label": "^0.1.5", "@radix-ui/react-popover": "^0.1.6", "@radix-ui/react-separator": "^0.1.4", + "@radix-ui/react-toast": "^0.1.1", "@radix-ui/react-tabs": "^1.0.0", "@radix-ui/react-toggle-group": "^0.1.5", "@radix-ui/react-tooltip": "^1.0.0", diff --git a/packages/status-react/src/components/main-sidebar/components/community-info/community-dialog.tsx b/packages/status-react/src/components/main-sidebar/components/community-info/community-dialog.tsx index f54a17ab..f8c13530 100644 --- a/packages/status-react/src/components/main-sidebar/components/community-info/community-dialog.tsx +++ b/packages/status-react/src/components/main-sidebar/components/community-info/community-dialog.tsx @@ -56,17 +56,7 @@ export const CommunityDialog = () => { fill="currentColor" /> - + diff --git a/packages/status-react/src/protocol/index.tsx b/packages/status-react/src/protocol/index.tsx index 666c0f45..f89b0dc4 100644 --- a/packages/status-react/src/protocol/index.tsx +++ b/packages/status-react/src/protocol/index.tsx @@ -1,4 +1,4 @@ -export { ProtocolProvider, useProtocol } from './provider' +export { ProtocolProvider } from './provider' export type { Account } from './use-account' export { useAccount } from './use-account' export { useActivityCenter } from './use-activity-center' @@ -8,4 +8,5 @@ export type { Member } from './use-members' export { useMembers } from './use-members' export type { Message, Reaction, Reactions } from './use-messages' export { useMessages } from './use-messages' +export { useProtocol } from './use-protocol' export { useSortedChats } from './use-sorted-chats' diff --git a/packages/status-react/src/protocol/provider.tsx b/packages/status-react/src/protocol/provider.tsx index 781bfc5f..a7517513 100644 --- a/packages/status-react/src/protocol/provider.tsx +++ b/packages/status-react/src/protocol/provider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useEffect, useReducer } from 'react' +import React, { createContext, useEffect, useReducer } from 'react' import { createClient } from '@status-im/js' @@ -6,9 +6,9 @@ import { Loading } from '../components/loading' import type { Account, Client, ClientOptions, Community } from '@status-im/js' -const Context = createContext(undefined) +export const Context = createContext(undefined) -type State = { +export type State = { loading: boolean client: Client | undefined community: Community['description'] | undefined @@ -16,7 +16,7 @@ type State = { dispatch?: React.Dispatch } -type Action = +export type Action = | { type: 'INIT'; client: Client } | { type: 'UPDATE_COMMUNITY'; community: Community['description'] } | { type: 'SET_ACCOUNT'; account: Account | undefined } @@ -99,18 +99,3 @@ export const ProtocolProvider = (props: Props) => { ) } - -export function useProtocol() { - const context = useContext(Context) - - if (!context) { - throw new Error(`useProtocol must be used within a ProtocolProvider`) - } - - // we enforce initialization of client before rendering children - return context as State & { - client: Client - community: Community['description'] - dispatch: React.Dispatch - } -} diff --git a/packages/status-react/src/protocol/use-account.tsx b/packages/status-react/src/protocol/use-account.tsx index fd942949..2b6ede98 100644 --- a/packages/status-react/src/protocol/use-account.tsx +++ b/packages/status-react/src/protocol/use-account.tsx @@ -1,4 +1,4 @@ -import { useProtocol } from './provider' +import { useProtocol } from './use-protocol' import type { Account } from '@status-im/js' diff --git a/packages/status-react/src/protocol/use-active-chat.tsx b/packages/status-react/src/protocol/use-active-chat.tsx index e3531572..14e55334 100644 --- a/packages/status-react/src/protocol/use-active-chat.tsx +++ b/packages/status-react/src/protocol/use-active-chat.tsx @@ -1,6 +1,6 @@ import { useMatch } from 'react-router-dom' -import { useProtocol } from './provider' +import { useProtocol } from './use-protocol' export const useActiveChat = () => { const { client } = useProtocol() diff --git a/packages/status-react/src/protocol/use-activity-center.tsx b/packages/status-react/src/protocol/use-activity-center.tsx index a4f6f07d..92738f21 100644 --- a/packages/status-react/src/protocol/use-activity-center.tsx +++ b/packages/status-react/src/protocol/use-activity-center.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { useProtocol } from './provider' +import { useProtocol } from './use-protocol' import type { ActivityCenterLatest } from '@status-im/js' diff --git a/packages/status-react/src/protocol/use-chat.tsx b/packages/status-react/src/protocol/use-chat.tsx index 05df3cf1..c838a970 100644 --- a/packages/status-react/src/protocol/use-chat.tsx +++ b/packages/status-react/src/protocol/use-chat.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' -import { useProtocol } from './provider' +import { useProtocol } from './use-protocol' import type { Community } from '@status-im/js' diff --git a/packages/status-react/src/protocol/use-members.tsx b/packages/status-react/src/protocol/use-members.tsx index e3f1cace..aa930731 100644 --- a/packages/status-react/src/protocol/use-members.tsx +++ b/packages/status-react/src/protocol/use-members.tsx @@ -1,4 +1,4 @@ -import { useProtocol } from './provider' +import { useProtocol } from './use-protocol' import type { Member } from '@status-im/js' diff --git a/packages/status-react/src/protocol/use-messages.tsx b/packages/status-react/src/protocol/use-messages.tsx index 2a5d3aeb..2c03b363 100644 --- a/packages/status-react/src/protocol/use-messages.tsx +++ b/packages/status-react/src/protocol/use-messages.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react' -import { useProtocol } from './provider' +import sub from 'date-fns/sub' + +import { useProtocol } from './use-protocol' import type { Message, Reactions } from '@status-im/js' @@ -9,21 +11,20 @@ type Reaction = keyof Reactions interface Result { data: Message[] loading: boolean - // error?: Error - // fetchMore: () => void } -export const useMessages = (channelId: string): Result => { +export const useMessages = (chatId: string): Result => { const { client } = useProtocol() - const chat = client.community.chats.get(channelId)! - // const [state, dispatch] = useReducer((state,action) => {}, {}) + const chat = client.community.chats.get(chatId)! const [data, setData] = useState(() => chat.getMessages()) const [loading, setLoading] = useState(true) // const [error, setError] = useState() useEffect(() => { + const messages = chat.getMessages() + setData(chat.getMessages()) const handleUpdate = (messages: Message[]) => { @@ -31,15 +32,21 @@ export const useMessages = (channelId: string): Result => { setData(messages) } + if (messages.length === 0) { + setLoading(true) + chat.fetchMessages({ start: sub(new Date(), { days: 30 }) }) + } + return chat.onMessage(handleUpdate) }, [chat]) return { data, loading, + // fetchMore, + // fetching, // error, // hasMore - // fetchMore, // refetch } } diff --git a/packages/status-react/src/protocol/use-protocol.tsx b/packages/status-react/src/protocol/use-protocol.tsx new file mode 100644 index 00000000..f898d03f --- /dev/null +++ b/packages/status-react/src/protocol/use-protocol.tsx @@ -0,0 +1,22 @@ +import { useContext } from 'react' + +import { Context } from './provider' + +import type { Action, State } from './provider' +import type { Client, Community } from '@status-im/js' +import type React from 'react' + +export function useProtocol() { + const context = useContext(Context) + + if (!context) { + throw new Error(`useProtocol must be used within a ProtocolProvider`) + } + + // we enforce initialization of client before rendering children + return context as State & { + client: Client + community: Community['description'] + dispatch: React.Dispatch + } +} diff --git a/packages/status-react/src/protocol/use-sorted-chats.tsx b/packages/status-react/src/protocol/use-sorted-chats.tsx index 093656b2..d6a792fd 100644 --- a/packages/status-react/src/protocol/use-sorted-chats.tsx +++ b/packages/status-react/src/protocol/use-sorted-chats.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' -import { useProtocol } from './provider' +import { useProtocol } from './use-protocol' import type { Community } from '@status-im/js' diff --git a/packages/status-react/src/routes/chat/components/chat-input/index.tsx b/packages/status-react/src/routes/chat/components/chat-input/index.tsx index 618a5756..3db7bb52 100644 --- a/packages/status-react/src/routes/chat/components/chat-input/index.tsx +++ b/packages/status-react/src/routes/chat/components/chat-input/index.tsx @@ -82,7 +82,8 @@ const Wrapper = styled('div', { display: 'flex', overflow: 'hidden', alignItems: 'flex-end', - padding: '12px 8px 12px 4px', + // padding: '12px 8px 12px 4px', + padding: '12px 8px 12px 8px', gap: 4, }) diff --git a/packages/status-react/src/routes/chat/components/chat-message/index.tsx b/packages/status-react/src/routes/chat/components/chat-message/index.tsx index de1125c6..d42f4eef 100644 --- a/packages/status-react/src/routes/chat/components/chat-message/index.tsx +++ b/packages/status-react/src/routes/chat/components/chat-message/index.tsx @@ -31,7 +31,7 @@ import type { Message, Reaction } from '../../../../protocol' interface Props { message: Message - prevMessage?: Message + collapse: boolean highlight?: boolean } @@ -57,16 +57,17 @@ interface Props { // }) export const ChatMessage = (props: Props) => { + const { message, collapse, highlight } = props + const { client, account } = useProtocol() const { params } = useMatch(':id')! const chatId = params.id! - const { message, highlight } = props const mention = false const pinned = false - const { messageId, contentType, clock, reactions, signer, responseTo } = + const { messageId, contentType, timestamp, reactions, signer, responseTo } = message // TODO: remove usage of 0x prefix @@ -74,6 +75,7 @@ export const ChatMessage = (props: Props) => { const chat = client.community.getChat(chatId)! const member = client.community.getMember(signer)! + const response = client.community.getChat(params.id!)!.getMessage(responseTo) const [editing, setEditing] = useState(false) const [reacting, setReacting] = useState(false) @@ -82,6 +84,7 @@ export const ChatMessage = (props: Props) => { // const userProfileDialog = useDialog(UserProfileDialog) + // TODO: fix saving of edited message const handleMessageSubmit = (message: string) => { chat.sendTextMessage(message) } @@ -107,7 +110,7 @@ export const ChatMessage = (props: Props) => { // TODO: pin message } - const renderMessage = () => { + const renderContent = () => { if (editing) { return ( @@ -170,100 +173,85 @@ export const ChatMessage = (props: Props) => { } } - return ( - <> - {/* */} - - {responseTo && } - - - {/* - */} - {/* - - - {member!.username} - - - - } - onSelect={() => userProfileDialog.open({ member })} - > - View Profile - - }> - Send Message - - }> - Verify Identity - - }> - Send Contact Request - - - } danger> - Mark as Untrustworthy - - - */} - + const renderMessage = () => { + if (collapse) { + return ( + + {renderContent()} + + + ) + } - - {/* {pinned && ( + return ( + + + + + + + {/* {pinned && ( + + + + {/* {pinned && ( Pinned by {contact.name} )} */} - - - {member!.username} - - - {new Date(Number(clock)).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} - - + + + {member!.username} + + + {new Date(Number(timestamp)).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + - {renderMessage()} + {renderContent()} - - - + + + + ) + } - setEditing(true)} - onReplyClick={handleReplyClick} - onPinClick={handlePinClick} - onDeleteClick={handleMessageDelete} - onReactionClick={handleReaction} - reacting={reacting} - onReactingChange={setReacting} - reactions={reactions} - /> + return ( + <> + {/* */} + + {response && } + {renderMessage()} + + {account && ( + setEditing(true)} + onReplyClick={handleReplyClick} + onPinClick={handlePinClick} + onDeleteClick={handleMessageDelete} + onReactionClick={handleReaction} + reacting={reacting} + onReactingChange={setReacting} + reactions={reactions} + /> + )} - {/* - Reply - Pin - */} - {/* */} ) } @@ -280,7 +268,8 @@ const backgroundAnimation = keyframes({ // TODO: Use compound variants https://stitches.dev/docs/variants#compound-variants const Wrapper = styled('div', { position: 'relative', - padding: '10px 16px', + padding: '2px 16px', + marginTop: 14, gap: '$2', transitionProperty: 'background-color, border-color, color, fill, stroke', diff --git a/packages/status-react/src/routes/chat/components/chat-message/message-reply.tsx b/packages/status-react/src/routes/chat/components/chat-message/message-reply.tsx index 3c33a6f6..683f5745 100644 --- a/packages/status-react/src/routes/chat/components/chat-message/message-reply.tsx +++ b/packages/status-react/src/routes/chat/components/chat-message/message-reply.tsx @@ -1,47 +1,37 @@ import React from 'react' -import { useMatch } from 'react-router-dom' - import { useProtocol } from '../../../../protocol' import { styled } from '../../../../styles/config' import { Avatar, Box, Flex, Image, Text } from '../../../../system' +import type { Message } from '../../../../protocol' + interface Props { - messageId: string + message: Message } export const MessageReply = (props: Props) => { - const { messageId } = props + const { message } = props const { client } = useProtocol() - // TODO: use protocol hook - const { params } = useMatch(':id')! // eslint-disable-line @typescript-eslint/no-non-null-assertion - const message = client.community.getChat(params.id!)!.getMessage(messageId) - - if (!message) { - return ( - - - Message not available. - - - ) - } + // if (!message) { + // return ( + // + // + // Message not available. + // + // + // ) + // } const { contentType, text, signer } = message - - // TODO: can this happen? - const member = client.community.getMember(signer) - - if (!member) { - return null - } + const member = client.community.getMember(signer)! return ( - + {member.username} diff --git a/packages/status-react/src/routes/chat/components/date-divider/index.tsx b/packages/status-react/src/routes/chat/components/date-divider/index.tsx new file mode 100644 index 00000000..0a7ec31d --- /dev/null +++ b/packages/status-react/src/routes/chat/components/date-divider/index.tsx @@ -0,0 +1,32 @@ +import React from 'react' + +import isSameDay from 'date-fns/isSameDay' + +import { Flex, Text } from '../../../../system' + +interface Props { + date: Date +} + +export const DateDivider = (props: Props) => { + const { date } = props + + let label = date.toLocaleDateString([], { weekday: 'long' }) + + const today = new Date() + const yesterday = new Date().setDate(today.getDate() - 1) + + if (isSameDay(date, today)) { + label = 'Today' + } else if (isSameDay(date, yesterday)) { + label = 'Yesterday' + } + + return ( + + + {label} + + + ) +} diff --git a/packages/status-react/src/routes/chat/components/loading-toast/index.tsx b/packages/status-react/src/routes/chat/components/loading-toast/index.tsx new file mode 100644 index 00000000..5766063d --- /dev/null +++ b/packages/status-react/src/routes/chat/components/loading-toast/index.tsx @@ -0,0 +1,68 @@ +import React from 'react' + +import { keyframes } from '../../../../styles/config' +import { Box, Text } from '../../../../system' + +interface Props { + label: string +} +const fadeIn = keyframes({ + from: { opacity: 0, top: 0 }, + to: { opacity: 1 }, +}) + +const spin = keyframes({ + to: { + transform: 'rotate(1turn)', + }, +}) + +export const LoadingToast = (props: Props) => { + const { label } = props + + return ( + + + + + + + Loading {label}... + + + ) +} diff --git a/packages/status-react/src/routes/chat/components/message-loader/index.tsx b/packages/status-react/src/routes/chat/components/message-loader/index.tsx new file mode 100644 index 00000000..369f61bd --- /dev/null +++ b/packages/status-react/src/routes/chat/components/message-loader/index.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +import ContentLoader from 'react-content-loader' + +export const MessageLoader = () => { + return ( + + + + + + + ) +} diff --git a/packages/status-react/src/routes/chat/index.tsx b/packages/status-react/src/routes/chat/index.tsx index 5346567c..cd0f909b 100644 --- a/packages/status-react/src/routes/chat/index.tsx +++ b/packages/status-react/src/routes/chat/index.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useRef } from 'react' +import React, { Fragment, useEffect, useRef } from 'react' +import isSameDay from 'date-fns/isSameDay' import { useLocation, useMatch } from 'react-router-dom' import { MemberSidebar } from '../../components/member-sidebar' @@ -10,6 +11,9 @@ import { styled } from '../../styles/config' import { Avatar, Flex, Heading, Text } from '../../system' import { ChatInput } from './components/chat-input' import { ChatMessage } from './components/chat-message' +import { DateDivider } from './components/date-divider' +import { LoadingToast } from './components/loading-toast' +import { MessageLoader } from './components/message-loader' import { Navbar } from './components/navbar' interface ChatStartProps { @@ -75,20 +79,52 @@ const Body = () => { chat.sendTextMessage(message, state.reply?.message.messageId) } + const renderContent = () => { + if (messages.loading) { + return ( + <> + + + + + + + ) + } + + if (messages.data.length === 0) { + return + } + + return messages.data.map((message, index) => { + const sentDate = new Date(Number(message.timestamp)) + const previousMessage = messages.data[index - 1] + + let hasDateSeparator = true + + if (previousMessage) { + const prevSentDate = new Date(Number(previousMessage.timestamp)) + + if (isSameDay(prevSentDate, sentDate)) { + hasDateSeparator = false + } + } + + const shouldCollapse = + !message.responseTo && message.signer === previousMessage?.signer + + return ( + + {hasDateSeparator && } + + + ) + }) + } + return ( <> - - - {messages.data.map(message => { - return ( - - ) - })} - + {renderContent()} {account && } ) @@ -130,7 +166,7 @@ const ContentWrapper = styled('div', { overscrollBehavior: 'contain', // scrollSnapType: 'y proximity', - + paddingBottom: 16, // '& > div:last-child': { // scrollSnapAlign: 'end', // scrollMarginBlockEnd: '1px', diff --git a/packages/status-react/src/system/button/button.tsx b/packages/status-react/src/system/button/button.tsx index c8882407..36e763da 100644 --- a/packages/status-react/src/system/button/button.tsx +++ b/packages/status-react/src/system/button/button.tsx @@ -5,40 +5,46 @@ import { Base } from './styles' import type { Variants } from './styles' import type { Ref } from 'react' -type ButtonProps = React.ButtonHTMLAttributes - -interface Props { - children: string - disabled?: boolean +type ButtonProps = React.ButtonHTMLAttributes & { loading?: boolean - active?: boolean - type?: ButtonProps['type'] - onClick?: ButtonProps['onClick'] +} +type AnchorProps = React.AnchorHTMLAttributes & { + href: string +} + +type Props = (AnchorProps | ButtonProps) & { + children: string variant?: Variants['variant'] size?: Variants['size'] + disabled?: boolean } const Button = (props: Props, ref: Ref) => { - const { - type = 'button', - children, - disabled, - loading, - onClick, - variant = 'default', - ...buttonProps - } = props + const { children } = props + + if ('href' in props) { + const { href, ...linkProps } = props + const external = href.startsWith('http') + + return ( + + {children} + + ) + } + + const { type = 'button', loading, ...buttonProps } = props return ( - + {children} ) diff --git a/packages/status-react/src/system/button/styles.tsx b/packages/status-react/src/system/button/styles.tsx index 65fa7401..f4c804b2 100644 --- a/packages/status-react/src/system/button/styles.tsx +++ b/packages/status-react/src/system/button/styles.tsx @@ -81,4 +81,7 @@ export const Base = styled('button', { }, }, }, + defaultVariants: { + variant: 'default', + }, }) diff --git a/yarn.lock b/yarn.lock index 971d4795..770d0875 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1916,6 +1916,24 @@ "@radix-ui/react-roving-focus" "1.0.0" "@radix-ui/react-use-controllable-state" "1.0.0" +"@radix-ui/react-toast@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-0.1.1.tgz#d544e796b307e56f1298e40f356f468680958e93" + integrity sha512-9JWC4mPP78OE6muDrpaPf/71dIeozppdcnik1IvsjTxZpDnt9PbTtQj94DdWjlCphbv3S5faD3KL0GOpqKBpTQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "0.1.0" + "@radix-ui/react-compose-refs" "0.1.0" + "@radix-ui/react-context" "0.1.1" + "@radix-ui/react-dismissable-layer" "0.1.5" + "@radix-ui/react-portal" "0.1.4" + "@radix-ui/react-presence" "0.1.2" + "@radix-ui/react-primitive" "0.1.4" + "@radix-ui/react-use-callback-ref" "0.1.0" + "@radix-ui/react-use-controllable-state" "0.1.0" + "@radix-ui/react-use-layout-effect" "0.1.0" + "@radix-ui/react-visually-hidden" "0.1.4" + "@radix-ui/react-toggle-group@^0.1.5": version "0.1.5" resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle-group/-/react-toggle-group-0.1.5.tgz#9e4d65e22c4fc0ba3a42fbc8d5496c430e5e9852" @@ -5397,6 +5415,7 @@ netmask@^2.0.2: node-fetch@^2.x.x: version "2.6.7" + uid "1b5d62978f2ed07b99444f64f0df39f960a6d34d" resolved "https://registry.npmjs.org/@achingbrain/node-fetch/-/node-fetch-2.6.7.tgz#1b5d62978f2ed07b99444f64f0df39f960a6d34d" node-forge@^1.1.0, node-forge@^1.3.1: