Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Messaging #324

Merged
merged 9 commits into from
Aug 8, 2023
24 changes: 24 additions & 0 deletions src/components/NetworkStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import useNetworkStatus from '@/hooks/useNetworkStatus'
import { cx } from '@/utils/class-names'
import { ComponentProps } from 'react'

export type NetworkStatusProps = ComponentProps<'div'>

export default function NetworkStatus({ ...props }: NetworkStatusProps) {
const status = useNetworkStatus()

return (
<div
{...props}
className={cx(
'h-2 w-2 rounded-full',
{
'bg-orange-500': status === 'connecting',
'bg-background-red': status === 'error',
'bg-green-600': status === 'connected',
},
props.className
)}
/>
)
}
5 changes: 4 additions & 1 deletion src/components/captcha/CaptchaInvisible.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,19 @@ export default function CaptchaInvisible({ children }: CaptchaInvisibleProps) {
const runCaptcha = async () => {
let token: string | null = null
try {
console.log('waiting captcha...')
token = (await captchaRef.current?.executeAsync()) ?? null
console.log('done captcha')
} catch (e) {
console.error(e)
console.error('Captcha Error: ', e)
}
if (!token) {
toast.custom((t) => (
<Toast t={t} title='Captcha Failed' description='Please try again' />
))
return null
}

captchaRef.current?.reset()
return token
}
Expand Down
83 changes: 65 additions & 18 deletions src/components/chats/ChatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import TextArea, { TextAreaProps } from '@/components/inputs/TextArea'
import EmailSubscribeModal from '@/components/modals/EmailSubscribeModal'
import { ESTIMATED_ENERGY_FOR_ONE_TX } from '@/constants/subsocial'
import useAutofocus from '@/hooks/useAutofocus'
import useNetworkStatus from '@/hooks/useNetworkStatus'
import useRequestTokenAndSendMessage from '@/hooks/useRequestTokenAndSendMessage'
import useToastError from '@/hooks/useToastError'
import { ApiRequestTokenResponse } from '@/pages/api/request-token'
Expand All @@ -25,8 +26,12 @@ import {
useRef,
useState,
} from 'react'
import { toast } from 'react-hot-toast'
import { IoRefresh } from 'react-icons/io5'
import { BeforeMessageResult } from '../extensions/common/CommonExtensionModal'
import { interceptPastedData } from '../extensions/config'
import PopOver from '../floating/PopOver'
import Toast from '../Toast'

const CaptchaInvisible = dynamic(
() => import('@/components/captcha/CaptchaInvisible'),
Expand Down Expand Up @@ -72,6 +77,8 @@ export default function ChatForm({
beforeMesageSend,
...props
}: ChatFormProps) {
const networkStatus = useNetworkStatus()

const replyTo = useMessageData((state) => state.replyTo)
const clearReplyTo = useMessageData((state) => state.clearReplyTo)

Expand Down Expand Up @@ -134,7 +141,9 @@ export default function ChatForm({
const shouldSendMessage =
isRequestingEnergy || (isLoggedIn && hasEnoughEnergy)

const isNetworkConnected = networkStatus === 'connected'
const isDisabled =
!isNetworkConnected ||
(mustHaveMessageBody && !processMessage(messageBody)) ||
sendButtonProps?.disabled

Expand All @@ -154,6 +163,25 @@ export default function ChatForm({

const processedMessage = processMessage(messageBody)

if (!isNetworkConnected) {
toast.custom((t) => (
<Toast
t={t}
title='Network is reconnecting'
description='Please try again later, or refresh the page'
action={
<Button
size='circle'
className='ml-2'
onClick={() => window.location.reload()}
>
<IoRefresh />
</Button>
}
/>
))
}

if (isDisabled) return

const additionalTxParams = await buildAdditionalTxParams?.()
Expand Down Expand Up @@ -209,24 +237,43 @@ export default function ChatForm({
handleSubmit(token)
}

const renderSendButton = (classNames: string) => (
<Button
onTouchEnd={(e) => {
// For mobile, to prevent keyboard from hiding
if (shouldSendMessage) {
submitForm(e)
}
}}
tabIndex={-1}
onClick={submitForm}
size='circle'
variant={isDisabled ? 'mutedOutline' : 'primary'}
{...sendButtonProps}
className={cx(classNames, sendButtonProps?.className)}
>
<Send className='relative top-px h-4 w-4' />
</Button>
)
const renderSendButton = (classNames: string) => {
const sendButton = (
<Button
onTouchEnd={(e) => {
// For mobile, to prevent keyboard from hiding
if (shouldSendMessage) {
submitForm(e)
}
}}
tabIndex={-1}
onClick={submitForm}
size='circle'
variant={isDisabled ? 'mutedOutline' : 'primary'}
{...sendButtonProps}
className={cx(
isNetworkConnected && classNames,
sendButtonProps?.className
)}
>
<Send className='relative top-px h-4 w-4' />
</Button>
)

if (isNetworkConnected) return sendButton
return (
<PopOver
triggerOnHover
yOffset={8}
triggerClassName={classNames}
trigger={sendButton}
placement='top-end'
panelSize='sm'
>
<p>Network connecting...</p>
</PopOver>
)
}

return (
<form
Expand Down
23 changes: 23 additions & 0 deletions src/hooks/useNetworkStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getApiPromiseInstance } from '@/subsocial-query/subsocial/connection'
import { useEffect, useState } from 'react'

export default function useNetworkStatus() {
const [status, setStatus] = useState<'connecting' | 'error' | 'connected'>(
'connecting'
)

useEffect(() => {
;(async () => {
const api = await getApiPromiseInstance()
if (!api) return

api.on('error', () => setStatus('error'))
api.on('disconnected', () => setStatus('connecting'))

api.on('connected', () => setStatus('connected'))
api.on('ready', () => setStatus('connected'))
})()
}, [])

return status
}
42 changes: 26 additions & 16 deletions src/hooks/useWaitHasEnergy.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,42 @@
import { useMyAccount } from '@/stores/my-account'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef } from 'react'

export default function useWaitHasEnergy() {
export default function useWaitHasEnergy(timeout = 5_000) {
const address = useMyAccount((state) => state.address)
const energy = useMyAccount((state) => state.energy)

const hasEnergyResolvers = useRef<(() => void)[]>([])

const generateNewPromise = useCallback(() => {
return new Promise<void>((resolve) => {
hasEnergyResolvers.current.push(() => resolve())
return new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(
new Error(
"Energy timeout: You don't have enough energy to perform this action."
)
)
}, timeout)
hasEnergyResolvers.current.push(() => {
clearTimeout(timeoutId)
resolve()
})
})
}, [])
// save current and previous account's promises, so there are no dangling promises
const [hasEnergyPromises, setHasEnergyPromises] = useState<Promise<void>[]>(
() => [generateNewPromise()]
)

useEffect(() => {
const newPromise = generateNewPromise()
setHasEnergyPromises((prev) => [...prev, newPromise])
}, [address, generateNewPromise])
}, [timeout])

useEffect(() => {
if (!energy || energy <= 0) return
hasEnergyResolvers.current.forEach((resolve) => resolve())
hasEnergyResolvers.current = []
}, [energy, generateNewPromise, hasEnergyPromises])
}, [energy, generateNewPromise])

useEffect(() => {
return () => {
hasEnergyResolvers.current.forEach((resolve) => resolve())
hasEnergyResolvers.current = []
}
}, [address])

return () => hasEnergyPromises[hasEnergyPromises.length - 1]
return () => {
return !energy ? generateNewPromise() : Promise.resolve()
}
}
27 changes: 17 additions & 10 deletions src/modules/chat/ChatPage/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import Router, { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import urlJoin from 'url-join'

const NetworkStatus = dynamic(() => import('@/components/NetworkStatus'), {
ssr: false,
})
const AboutChatModal = dynamic(
() => import('@/components/modals/about/AboutChatModal'),
{
Expand Down Expand Up @@ -171,16 +174,20 @@ function BottomPanel() {
return (
<Container as='div' className='pb-2 text-center text-sm text-text-muted'>
{shouldSendMessageWithoutCaptcha ? (
<p>
Powered by{' '}
<LinkText
variant='primary'
href='https://subsocial.network/'
openInNewTab
>
Subsocial
</LinkText>
</p>
<div className='flex items-center justify-center'>
<p>
Powered by{' '}
<LinkText
variant='primary'
href='https://subsocial.network/'
openInNewTab
>
Subsocial
</LinkText>
</p>

<NetworkStatus className='ml-2' />
</div>
) : (
<CaptchaTermsAndService />
)}
Expand Down
4 changes: 2 additions & 2 deletions src/services/subsocial/posts/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ export function useHideUnhidePost(

return {
tx: substrateApi.tx.posts.updatePost(postId, {
hidden: action === 'hide' ? true : false,
hidden: action === 'hide',
}),
summary: 'Hide/Unhide chat',
}
Expand All @@ -248,7 +248,7 @@ export function useHideUnhidePost(
...post,
struct: {
...post.struct,
hidden: data.action === 'hide' ? true : false,
hidden: data.action === 'hide',
},
}
})
Expand Down
36 changes: 29 additions & 7 deletions src/subsocial-query/subsocial/connection.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,45 @@
import type { WsProvider } from '@polkadot/api'
import { type SubsocialApi } from '@subsocial/api'
import { getConnectionConfig, SubsocialConnectionConfig } from './config'

let subsocialApi: Promise<SubsocialApi> | null = null
let provider: Promise<WsProvider> | null = null
const getApiWithProvider = async (renew?: boolean) => {
if (subsocialApi && !renew) return { subsocialApi, provider }

const apiWithProvider = connectToSubsocialApi(getConnectionConfig())
subsocialApi = apiWithProvider.then(({ api }) => api)
provider = apiWithProvider.then(({ provider }) => provider)

return { subsocialApi, provider }
}

export const getSubsocialApi = async (renew?: boolean) => {
if (subsocialApi && !renew) return subsocialApi
const api = connectToSubsocialApi(getConnectionConfig())
subsocialApi = api
return subsocialApi
return getApiWithProvider(renew).then(({ subsocialApi }) => subsocialApi)
}
export const getApiPromiseInstance = async () => {
const { ApiPromise } = await import('@polkadot/api')
const provider = await getApiWithProvider().then(({ provider }) => provider)
if (!provider) return null

return new ApiPromise({ provider })
}

async function connectToSubsocialApi(config: SubsocialConnectionConfig) {
const { SubsocialApi } = await import('@subsocial/api')
const { WsProvider, ApiPromise } = await import('@polkadot/api')

const { ipfsNodeUrl, substrateUrl, postConnectConfig, ipfsAdminNodeUrl } =
config
const api = await SubsocialApi.create({

const provider = new WsProvider(substrateUrl, 15_000, {}, 5000)
const substrateApi = await ApiPromise.create({ provider })
const api = new SubsocialApi({
substrateApi,
ipfsNodeUrl,
substrateNodeUrl: substrateUrl,
ipfsAdminNodeUrl,
})

postConnectConfig?.(api)
return api
return { api, provider }
}
6 changes: 6 additions & 0 deletions src/subsocial-query/subsocial/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,13 @@ const noncePromise = generatePromiseQueue()
*/
async function getNonce(substrateApi: ApiPromise, address: string) {
const previousQueue = noncePromise.addQueue()

const timeoutId = setTimeout(() => {
throw new Error('Timeout: Cannot get nonce for the next transaction.')
}, 10_000)
await previousQueue
clearTimeout(timeoutId)

const nonce = await substrateApi.rpc.system.accountNextIndex(address)
return { nonce, nonceResolver: noncePromise.resolveQueue }
}
Expand Down