1
0
mirror of https://github.com/oceanprotocol/market.git synced 2024-12-02 05:57:29 +01:00

Encrypted Direct Messaging module (#1870)

* add orbis context

* new changes

* Add orbis floating chat

* adding orbis function

* load orbis conversations

* update message button in profile

* minor fix conversation styles

* update orbis version

* adding details conditional

* updating post comment function

* Update send message button style

* Fix send message button css

* udpate orbis

* update posts and floating function

* Add chatbox emoji

* Add Emoji Picker Theme

* useDarkMode emoji fix

* remove next dynamic on emoji picker

* show conversation title from participant

* update callbackMessage.

* Update Emoji Picker

* Add Emoji Picker to Postbox

* rename FloatingChat to DirectMessages

* create some global orbis components

* update package-lock.json

* change any type

* change Blockie to Avatar component

* fix type errors

* fix infinite loop when no comment found

* Hide send message button on ownAccount profile

* Delete unused component

* minor changes

* update orbis comment and DM components

* fix load older messages on DM scroll

* fixed orbis createPost

* update optional wallet signs

* add return value on connect

* add padding bottom to compensate DM component

* add conditional connect and sign button

* update direct message component

* update get notifications logic

* rerun npm install

* rerun npm install

* temporary push

* rerun npm install

* add new custom hooks

* run npm install

* update flow on address changed

* update custom DID string

* remove lit auth signatures on resetStates()

* add hasLit condition on getMessages

* add removeCeramicSession function

* useLocalStorage to store notifications

* minor bug fix

* update styles for conversation details

* use getEnsName util

* update create conversation flow

* rerun npm install

* update typescript

* update orbis sdk version

* temporary push

* revisions

* update orbis version

* update notifs count and conversation creation flow

* update orbis types

* add toast after copy address

* add message decryption refresh button

* rerun npm install

* remove comment from asset page

* test push

* remove lit-auth-signature on wallet changed

* update orbis SDK to v0.4.14

* update copy

* update Orbis SDK to v0.4.17

* update copy

* create new DM button component and add to asset

* add send button and remove emojiPicker

* Revert "Merge branch 'main' into orbis"

This reverts commit 3cdaf54827, reversing
changes made to 02f2acb774.

* Revert "Revert "Merge branch 'main' into orbis""

This reverts commit a5a32b1534.

* update new conversation flow

* update intro message

* minor fix typo

* remove unused package and fixed outdated versions

* remove comment component and restructured folders

* update orbis-sdk

* small cleanup

* direct message button style updates

---------

Co-authored-by: Nuary Pradipta <nuary.pradipta@gmail.com>
Co-authored-by: Dollar Bull <ramadhanakhri@gmail.com>
Co-authored-by: Bogdan Fazakas <bogdan.fazakas@gmail.com>
This commit is contained in:
Marco Elissa 2023-04-05 13:22:57 +07:00 committed by GitHub
parent 628861279f
commit 8fd3eaf8de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 6939 additions and 63 deletions

4119
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,7 @@
"@oceanprotocol/lib": "^2.7.0", "@oceanprotocol/lib": "^2.7.0",
"@oceanprotocol/typographies": "^0.1.0", "@oceanprotocol/typographies": "^0.1.0",
"@oceanprotocol/use-dark-mode": "^2.4.3", "@oceanprotocol/use-dark-mode": "^2.4.3",
"@orbisclub/orbis-sdk": "^0.4.40",
"@tippyjs/react": "^4.2.6", "@tippyjs/react": "^4.2.6",
"@uiw/react-codemirror": "^4.19.5", "@uiw/react-codemirror": "^4.19.5",
"@urql/exchange-refocus": "^1.0.0", "@urql/exchange-refocus": "^1.0.0",
@ -62,6 +63,7 @@
"react-paginate": "^8.1.4", "react-paginate": "^8.1.4",
"react-select": "^5.7.0", "react-select": "^5.7.0",
"react-spring": "^9.5.5", "react-spring": "^9.5.5",
"react-string-replace": "^1.1.0",
"react-tabs": "^6.0.0", "react-tabs": "^6.0.0",
"react-toastify": "^9.1.1", "react-toastify": "^9.1.1",
"remark": "^14.0.2", "remark": "^14.0.2",

View File

@ -0,0 +1,665 @@
export interface IOrbisConstructor {
ceramic?: unknown
node?: string
store?: string
PINATA_GATEWAY?: string
PINATA_API_KEY?: string
PINATA_SECRET_API_KEY?: string
useLit?: boolean
}
export interface IOrbis {
api: any
session: any
connect: (provider: unknown, lit?: boolean) => Promise<IOrbisConnectReturns>
connect_v2: (opts?: {
provider?: unknown
chain?: string
lit?: boolean
oauth?: unknown
}) => Promise<IOrbisConnectReturns>
connectLit: (provider: unknown) => Promise<{
status?: number
error?: unknown
result?: string
}>
connectWithSeed: (seed: Uint8Array) => Promise<IOrbisConnectReturns>
createChannel: (
group_id: string,
content: Pick<IOrbisChannel, 'content'>
) => Promise<{
status: number
doc: string
result: string
}>
createContext: () => void
createConversation: (content: {
recipients: string[]
name?: string
description?: string
context?: string
}) => Promise<{
status: number
doc: string
result: string
}>
createGroup: (content: {
name: string
pfp?: string
description?: string
}) => Promise<{
status: number
doc: string
result: string
}>
createPost: (content: IOrbisPostContent) => Promise<{
status: number
doc: string
result: string
}>
createTileDocument: (
content: unknown,
tags: string[],
schema: string,
family: string
) => Promise<{
status: number
doc?: string
error?: unknown
result: string
}>
decryptMessage: (content: {
conversation_id: string
encryptedMessage: IOrbisEncryptedBody
}) => Promise<{
result: string
status: number
}>
decryptPost: (content: IOrbisPostContent) => Promise<{ result: string }>
deletePost: (stream_id: string) => Promise<{
status: number
result: string
}>
deterministicDocument: (
content: unknown,
tags: string[],
schema?: string,
family?: string
) => Promise<{
status: number
doc?: string
error?: unknown
result: string
}>
editPost: (
stream_id: string,
content: IOrbisPostContent,
encryptionRules?: IOrbisEncryptionRules
) => Promise<{
status: number
result: string
}>
getChannel: (channel_id: string) => Promise<{
data: IOrbisChannel
error: unknown
status: number
}>
getConversation: (conversation_id: string) => Promise<{
status: number
data: IOrbisConversation | null
error: unknown
}>
getConversations: (opts: { did: string; context?: string }) => Promise<{
data: IOrbisConversation[]
error: unknown
status: number
}>
getCredentials: (did: string) => Promise<{
data: {
stream_id: string
family: string
content: unknown
issuer: string
creator: string
subject_id: string
type: string
}[]
error: unknown
status: number
}>
getDids: (address: string) => Promise<{
data: {
did: string
details: Pick<IOrbisProfile, 'did' | 'details'>
count_followers: number
count_following: number
pfp: string
}[]
error: unknown
status: number
}>
getGroup: (group_id: string) => Promise<{
data: IOrbisGroup
error: unknown
status: number
}>
getGroupMembers: (group_id: string) => Promise<{
data: {
active: 'true' | 'false'
content: {
active: boolean
group_id: string
}
created_at: string
did: string
group_id: string
profile_details: Pick<IOrbisProfile['details'], 'did' | 'profile'>
stream_id: string
timestamp: number
}[]
error: unknown
status: number
}>
getGroups: () => Promise<{
data: IOrbisGroup[]
error: unknown
status: number
}>
getIsFollowing: (
did_following: string,
did_followed: string
) => Promise<{
data: boolean
error: unknown
status: number
}>
getIsGroupMember: (
group_id: string,
did: string
) => Promise<{
data: boolean
error: unknown
status: number
}>
getMessages: (
conversation_id: string,
page: number
) => Promise<{
data: IOrbisMessage[]
error: unknown
status: number
}>
getNotifications: (
options: {
context: string
did: string
master?: string
only_master?: boolean
tag?: string
algorithm?: string
},
page: number
) => Promise<{
data: IOrbisNotification[]
error: unknown
status: number
}>
getNotificationsCount: (options: {
type: string
context?: string
conversation_id?: string
last_read_timestamp?: number
}) => Promise<{
data: { count_new_notifications: number }
error: unknown
status: number
}>
getPost: (post_id: string) => Promise<{
data: IOrbisPost
error: unknown
status: number
}>
getPosts: (
options: {
context?: string
did?: string
master?: string
only_master?: boolean
tag?: string
algorithm?: keyof typeof IOrbisGetPostsAlgorithm | null
},
page: number
) => Promise<{
data: IOrbisPost[]
error: unknown
status: number
}>
getReaction: (
post_id: string,
did: string
) => Promise<{
data: { type: string }
error: unknown
status: number
}>
getProfile: (did: string) => Promise<{
data: IOrbisProfile
error: unknown
status: number
}>
getProfileFollowers: (did: string) => Promise<{
data: IOrbisProfile['details'][]
error: unknown
status: number
}>
getProfileFollowing: (did: string) => Promise<{
data: IOrbisProfile['details'][]
error: unknown
status: number
}>
getProfileGroups: (did: string) => Promise<{
data: {
active: 'true' | 'false'
content: {
active: boolean
group_id: string
}
created_at: string
did: string
group_details: IOrbisGroup['content']
group_id: string
stream_id: string
}[]
error: unknown
status: number
}>
getProfilesByUsername: (username: string) => Promise<{
data: IOrbisProfile[]
error: unknown
status: number
}>
isConnected: (sessionString?: string) => Promise<IOrbisConnectReturns>
logout: () => {
status: number
result: string
error: unknown
}
react: (
post_id: string,
type: string
) => Promise<{
status: number
doc: string
result: string
}>
sendMessage: (content: { conversation_id: string; body: string }) => Promise<{
status: number
doc: string
result: string
}>
setFollow: (
did: string,
active: boolean
) => Promise<{
status: number
doc: string
result: string
}>
setGroupMember: (
group_id: string,
active: boolean
) => Promise<{
status: number
doc?: string
error?: unknown
result: string
}>
setNotificationsReadTime: (
type: string,
timestamp: number,
context?: string
) => Promise<{
status: number
doc?: string
error?: unknown
result: string
}>
updateChannel: (
channel_id: string,
content: Pick<IOrbisChannel, 'content'>
) => Promise<{
status: number
doc: string
result: string
}>
updateContext: () => void
updateGroup: (
stream_id: string,
content: { pfp: string; name: string; description: string }
) => Promise<{
status: number
doc: string
result: string
}>
updatePost: (stream_id: string, body: string) => void
updateProfile: (content: {
pfp: string
cover: string
username: string
description: string
pfpIsNft: {
chain: string
contract: string
tokenId: string
timestamp: string
}
data?: object
}) => Promise<{
status: number
doc: string
result: string
}>
updateTileDocument: (
stream_id: string,
content: unknown,
tags: string[],
schema: string,
family?: string
) => Promise<{
status: number
doc?: string
error?: unknown
result: string
}>
uploadMedia: (file: File) => Promise<{
status: number
error?: unknown
result: { url: string; gateway: string } | string
}>
}
export interface IOrbisConnectReturns {
status: number
did: string
details: {
did: string
profile: IOrbisProfile['details']['profile']
hasLit: boolean
}
result: string
}
export enum IOrbisGetPostsAlgorithm {
'recommendations',
'all-posts',
'all-master-posts',
'all-did-master-posts',
'all-context-master-posts',
'all-posts-non-filtered',
''
}
export enum OrbisReaction {
'like',
'haha',
'downvote'
}
export interface IOrbisGroup {
channels: Pick<IOrbisChannel, 'content' | 'stream_id'>[]
content: {
name?: string
pfp?: string
description?: string
}
count_members: number
creator: string
last_activity_timestamp: number
stream_id: string
}
export interface IOrbisChannel {
archived?: boolean
content: {
group_id: string
name: string
description: string
type: 'chat' | 'feed'
encryptionRules?: {
type: string
chain: string
contractType: 'ERC20' | 'ERC721' | 'ERC1155'
contractAddress: string
minTokenBalance: string
tokenId?: string
}
data?: object
}
created_at: string
creator: string
group_id: string
stream_id: string
timestamp: number
type: 'chat' | 'feed'
}
export interface IOrbisProfile {
address: string
count_followers: number
count_following: number
details: {
a_r?: number
did: string
metadata?: {
address?: string
chain?: string
ensName?: string
}
nonces?: number
profile?: {
cover?: string
data?: object
description?: string
pfp?: string
pfpIsNft?: {
chain: string
contract: string
timestamp: string
tokenId: string
}
username?: string
}
twitter_details?: {
credential_id: string
issuer: string
timestamp: number
username: string
}
}
did: string
last_activity_timestamp: number
username: string
}
export interface IOrbisEncryptionRules {
type: 'token-gated' | 'custom'
chain: string
contractType: 'ERC20' | 'ERC721' | 'ERC1155'
contractAddress: string
minTokenBalance: string
tokenId: string
accessControlConditions?: object
}
export interface IOrbisEncryptedBody {
accessControlConditions: string
encryptedString: string
encryptedSymmetricKey: string
}
export interface IOrbisPostMention {
did: string
username: string
}
export interface IOrbisPostContent {
body: string
title?: string
context?: string
master?: string
mentions?: IOrbisPostMention[]
reply_to?: string
type?: string
tags?: {
slug: string
title: string
}[]
data?: object
encryptionRules?: IOrbisEncryptionRules | null
encryptedMessage?: object | null
encryptedBody?: IOrbisEncryptedBody | null
}
export interface IOrbisPost {
content: IOrbisPostContent
context?: string
context_details?: {
channel_details?: IOrbisChannel['content']
channel_id?: string
group_details?: IOrbisGroup['content']
group_id?: string
}
count_commits?: number
count_downvotes?: number
count_haha?: number
count_likes?: number
count_replies?: number
creator: string
creator_details?: IOrbisProfile['details']
group_id?: string | null
indexing_metadata?: {
language?: string
urlMetadata?: Record<string, string>
}
master?: string | null
reply_to?: string | null
reply_to_creator_details?: Pick<
IOrbisProfile['details'],
'did' | 'metadata' | 'profile'
>
reply_to_details?: IOrbisPostContent
stream_id: string
timestamp: number
type?: string
}
export interface IOrbisMessageContent {
conversation_id?: string
encryptedMessage?: {
accessControlConditions: string
encryptedString: string
encryptedSymmetricKey: string
} | null
encryptedMessageSolana?: {
accessControlConditions: string
encryptedString: string
encryptedSymmetricKey: string
} | null
master?: string | null
reply_to?: string | null
}
export interface IOrbisMessage {
content: IOrbisMessageContent
conversation_id: string
created_at?: string
creator: string
creator_details: IOrbisProfile['details']
master?: string | null
recipients?: string[]
reply_to?: string | null
reply_to_creator_details?: Pick<
IOrbisProfile['details'],
'did' | 'metadata' | 'profile'
>
reply_to_details?: IOrbisMessageContent
stream_id: string
timestamp: number
}
export interface IOrbisConversation {
content: {
recipients: string[]
}
context: string | null
details: {
content: {
recipients: string[]
creator: string
}
}
last_message_timestamp: number
last_timestamp_read: number
recipients: string[]
recipients_details: IOrbisProfile['details'][]
stream_id: string
}
export interface IOrbisNotification {
content: {
conversation_id: string
encryptedMessage: IOrbisEncryptedBody
}
family: string
post_details: object
status: string
type: string
user_notifying_details: {
did: string
profile: IOrbisProfile['details']['profile']
}
}
export interface IConversationWithAdditionalData extends IOrbisConversation {
notifications_count: number
empty_message: boolean
}
export type IOrbisProvider = {
orbis: IOrbis
account: IOrbisProfile
hasLit: boolean
openConversations: boolean
conversationId: string
conversations: IConversationWithAdditionalData[]
activeConversationTitle: string
notifsLastRead: Record<string, Record<string, number>>
totalNotifications: number
connectOrbis: (options: {
address: string
lit?: boolean
}) => Promise<IOrbisProfile | null>
disconnectOrbis: (address: string) => void
checkOrbisConnection: (options: {
address: string
autoConnect?: boolean
lit?: boolean
}) => Promise<IOrbisProfile>
connectLit: () => Promise<{
status?: number
error?: unknown
result?: string
}>
setActiveConversationTitle: (title: string) => void
setOpenConversations: (open: boolean) => void
setConversationId: (conversationId: string) => void
getConversationByDid: (userDid: string) => Promise<IOrbisConversation>
createConversation: (recipients: string[]) => Promise<string>
getConversationTitle: (conversationId: string) => Promise<string>
getDid: (address: string) => Promise<string>
clearConversationNotifs: (conversationId: string) => void
updateConversationEmptyMessageStatus: (
conversationId: string,
status: boolean
) => void
}

View File

@ -0,0 +1,482 @@
import React, {
useContext,
createContext,
useState,
useMemo,
useEffect,
ReactNode,
ReactElement
} from 'react'
import { useInterval } from '@hooks/useInterval'
import { Orbis } from '@orbisclub/orbis-sdk'
import { useWeb3 } from '../Web3'
import { accountTruncate } from '@utils/web3'
import { didToAddress, sleep } from '@shared/DirectMessages/_utils'
import { getEnsName } from '@utils/ens'
import usePrevious from '@hooks/usePrevious'
import useLocalStorage from '@hooks/useLocalStorage'
import DirectMessages from '@shared/DirectMessages'
import {
IOrbis,
IOrbisProfile,
IOrbisProvider,
IConversationWithAdditionalData
} from './_types'
import { LoggerInstance } from '@oceanprotocol/lib'
const OrbisContext = createContext({} as IOrbisProvider)
const orbis: IOrbis = new Orbis()
const NOTIFICATION_REFRESH_INTERVAL = 5000
const CONVERSATION_CONTEXT =
process.env.NEXT_PUBLIC_ORBIS_CONTEXT || 'ocean_market' // Can be changed to whatever
function OrbisProvider({ children }: { children: ReactNode }): ReactElement {
const { web3Provider, accountId } = useWeb3()
const prevAccountId = usePrevious(accountId)
const [ceramicSessions, setCeramicSessions] = useLocalStorage<string[]>(
'ocean-ceramic-sessions',
[]
)
const [notifsLastRead, setNotifsLastRead] = useLocalStorage<
Record<string, Record<string, number>>
>('ocean-notifs-last-read', {})
const [account, setAccount] = useState<IOrbisProfile | null>(null)
const [hasLit, setHasLit] = useState(false)
const [openConversations, setOpenConversations] = useState(false)
const [conversationId, setConversationId] = useState(null)
const [conversations, setConversations] = useState<
IConversationWithAdditionalData[]
>([])
const [activeConversationTitle, setActiveConversationTitle] = useState(null)
// Function to reset states
const resetStates = () => {
setAccount(null)
setConversationId(null)
setConversations([])
setHasLit(false)
}
// Remove ceramic session
const removeCeramicSession = (address: string) => {
const _ceramicSessions = { ...ceramicSessions }
delete _ceramicSessions[address.toLowerCase()]
setCeramicSessions({ ..._ceramicSessions })
}
// Remove lit signature
const removeLitSignature = () => {
window.localStorage.removeItem('lit-auth-signature')
window.localStorage.removeItem('lit-auth-sol-signature')
}
// Connecting to Orbis
const connectOrbis = async ({
address,
lit = false
}: {
address: string
lit?: boolean
}) => {
const res = await orbis.connect_v2({
provider: web3Provider,
chain: 'ethereum',
lit
})
if (res.status === 200) {
const { data } = await orbis.getProfile(res.did)
setAccount(data)
setHasLit(res.details.hasLit)
const sessionString = orbis.session.serialize()
setCeramicSessions({
...ceramicSessions,
[address.toLowerCase()]: sessionString
})
return data
} else {
await connectOrbis({ address })
}
}
const disconnectOrbis = (address: string) => {
const res = orbis.logout()
if (res.status === 200) {
resetStates()
removeLitSignature()
removeCeramicSession(address)
}
}
const connectLit = async () => {
const res = await orbis.connectLit(web3Provider)
setHasLit(res.status === 200)
return res
}
const checkOrbisConnection = async ({
address,
autoConnect,
lit
}: {
address: string
autoConnect?: boolean
lit?: boolean
}) => {
const sessionString = ceramicSessions[address.toLowerCase()] || '-'
const res = await orbis.isConnected(sessionString)
if (
res.status === 200 &&
didToAddress(res.did) === accountId.toLowerCase()
) {
setHasLit(res.details.hasLit)
const { data } = await orbis.getProfile(res.did)
setAccount(data)
return data
} else if (autoConnect) {
const data = await connectOrbis({ address, lit })
return data
} else {
resetStates()
removeLitSignature()
removeCeramicSession(address)
return null
}
}
const getDid = async (address: string) => {
if (!address) return null
const { data, error } = await orbis.getDids(address)
if (error) {
return
}
let _did: string = null
if (data && data.length > 0) {
// Try to get mainnet did
const mainnetDid = data.find(
(o: {
did: string
details: Pick<IOrbisProfile, 'did' | 'details'>
}) => {
const did = o.did.split(':')
return did[3] === '1'
}
)
_did = mainnetDid?.did || data[0].did
} else {
_did = `did:pkh:eip155:1:${address.toLowerCase()}`
}
return _did
}
const getConversationNotifications: (
conversations: IConversationWithAdditionalData[]
) => Promise<void> = async (conversations) => {
if (!conversations.length || !orbis) return
let did = account?.did
if (!did && accountId) {
did = await getDid(accountId)
}
const _newConversations = await Promise.all(
conversations.map(async (conversation) => {
// Get timestamp of last read notification
const lastRead =
notifsLastRead[accountId]?.[conversation.stream_id] || 0
const { data, error } = await orbis.api
.rpc('orbis_f_count_notifications_alpha', {
user_did: did,
notif_type: 'messages',
q_context: CONVERSATION_CONTEXT,
q_conversation_id: conversation.stream_id,
q_last_read: lastRead
})
.single()
if (error) {
LoggerInstance.error(`[directMessages] orbis api error: `, error)
}
if (data) {
const newNotifsCount = data.count_new_notifications
// Get conversation by stream_id
conversation.notifications_count = newNotifsCount
}
const { data: _data, error: _error } = await orbis.getMessages(
conversation.stream_id,
0
)
if (_error) {
LoggerInstance.error(
`[directMessages] orbis getMessages sdk error: `,
_error
)
}
if (_data) {
conversation.empty_message = _data.length === 0
}
return conversation
})
)
setConversations(_newConversations)
}
const clearConversationNotifs = async (conversationId: string) => {
if (!accountId || !conversationId) return
const _notifsLastRead = { ...notifsLastRead }
// Add address if not exists
if (!_notifsLastRead[accountId]) {
_notifsLastRead[accountId] = {}
}
// Add conversationId if not exists
if (!_notifsLastRead[accountId][conversationId]) {
_notifsLastRead[accountId][conversationId] = 0
}
// Update last read
_notifsLastRead[accountId][conversationId] = Math.floor(Date.now() / 1000)
setNotifsLastRead(_notifsLastRead)
// Set conversation notifications count to 0
const _conversations = conversations.map((conversation) => {
if (conversation.stream_id === conversationId) {
conversation.notifications_count = 0
}
return conversation
})
setConversations(_conversations)
}
const getConversations = async (did: string = null) => {
if (!did) return []
const { data } = await orbis.getConversations({
did,
context: CONVERSATION_CONTEXT
})
// Only show conversations with unique recipients
const filteredConversations: IConversationWithAdditionalData[] = []
data.forEach((conversation: IConversationWithAdditionalData) => {
if (conversation.recipients.length === 2) {
// Sort recipients by alphabetical order and stringify
const sortedRecipients = conversation.recipients.sort()
const stringifiedRecipients = sortedRecipients.join(',')
// Check if conversation already exists based on sorted and stringified recipients
const found = filteredConversations.find(
(o: IConversationWithAdditionalData) =>
o.recipients.length > 1 &&
o.recipients.sort().join(',') === stringifiedRecipients
)
if (!found) {
filteredConversations.push(conversation)
}
}
})
// Also fetch message notifications
await getConversationNotifications(filteredConversations)
setConversations(filteredConversations)
return filteredConversations
}
const getConversation = async (conversationId: string) => {
if (!conversationId) return null
const { data, error } = await orbis.getConversation(conversationId)
if (error || !data) {
await sleep(2000)
await getConversation(conversationId)
} else {
return data as IConversationWithAdditionalData
}
}
const getConversationByDid = async (userDid: string) => {
if (!account || !userDid) return null
// Check from current conversations list
if (conversations.length > 0) {
const filteredConversations = conversations.filter(
(conversation: IConversationWithAdditionalData) => {
return (
conversation.recipients.length === 2 &&
conversation.recipients.includes(userDid)
)
}
)
if (filteredConversations.length) return filteredConversations[0]
}
// Refetch conversations
const _conversations = await getConversations(account?.did)
if (!_conversations.length) return null
const filteredConversations = _conversations.filter(
(conversation: IConversationWithAdditionalData) => {
return (
conversation.recipients.length === 2 &&
conversation.recipients.includes(userDid)
)
}
)
if (!filteredConversations.length) return null
return filteredConversations[0]
}
const createConversation = async (recipients: string[]) => {
if (!recipients.length) return null
const res = await orbis.createConversation({
recipients,
context: CONVERSATION_CONTEXT
})
if (res.status === 200) {
await sleep(2000)
const _newConversation = await getConversation(res.doc)
if (_newConversation) {
_newConversation.notifications_count = 0
_newConversation.empty_message = true
setConversations([_newConversation, ...conversations])
return _newConversation.stream_id
}
}
}
const getConversationTitle = async (conversationId: string) => {
if (conversationId && conversations.length) {
// Get conversation based on conversationId
const conversation = conversations.find(
(o) => o.stream_id === conversationId
)
if (!conversation) return null
// Get address from did
const did = conversation.recipients.find((o: string) => o !== account.did)
const address = didToAddress(did)
// Get ens name if exists
const ensName = await getEnsName(address)
return ensName || accountTruncate(address)
} else {
return null
}
}
const totalNotifications = useMemo(() => {
if (!conversations.length) return 0
// Loop through conversations and count notifications
let count = 0
conversations.forEach((conversation: IConversationWithAdditionalData) => {
count += conversation?.notifications_count || 0
})
return count
}, [conversations])
const updateConversationEmptyMessageStatus = async (
conversationId: string,
empty: boolean
) => {
if (!conversationId) return null
const _conversations = conversations.map((conversation) => {
if (conversation.stream_id === conversationId) {
conversation.empty_message = empty
}
return conversation
})
setConversations(_conversations)
}
useInterval(async () => {
await getConversations(account?.did)
}, NOTIFICATION_REFRESH_INTERVAL)
useEffect(() => {
if (web3Provider && accountId) {
if (accountId !== prevAccountId) {
resetStates()
removeLitSignature()
}
// Check if wallet connected
checkOrbisConnection({ address: accountId })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountId, prevAccountId, web3Provider])
useEffect(() => {
if (account) {
getConversations(account?.did)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account])
return (
<OrbisContext.Provider
value={{
orbis,
account,
hasLit,
openConversations,
conversationId,
conversations,
activeConversationTitle,
notifsLastRead,
totalNotifications,
connectOrbis,
disconnectOrbis,
checkOrbisConnection,
connectLit,
setActiveConversationTitle,
setOpenConversations,
setConversationId,
getConversationByDid,
createConversation,
getConversationTitle,
getDid,
clearConversationNotifs,
updateConversationEmptyMessageStatus
}}
>
{children}
<DirectMessages />
</OrbisContext.Provider>
)
}
const useOrbis = () => {
return useContext(OrbisContext)
}
export { OrbisProvider, useOrbis }

View File

@ -43,7 +43,7 @@ interface Web3ProviderValue {
web3Loading: boolean web3Loading: boolean
isSupportedOceanNetwork: boolean isSupportedOceanNetwork: boolean
approvedBaseTokens: TokenInfo[] approvedBaseTokens: TokenInfo[]
connect: () => Promise<void> connect: () => Promise<string>
logout: () => Promise<void> logout: () => Promise<void>
} }
@ -136,8 +136,10 @@ function Web3Provider({ children }: { children: ReactNode }): ReactElement {
const accountId = (await web3.eth.getAccounts())[0] const accountId = (await web3.eth.getAccounts())[0]
setAccountId(accountId) setAccountId(accountId)
LoggerInstance.log('[web3] account id', accountId) LoggerInstance.log('[web3] account id', accountId)
return accountId
} catch (error) { } catch (error) {
LoggerInstance.error('[web3] Error: ', error.message) LoggerInstance.error('[web3] Error: ', error.message)
return null
} finally { } finally {
setWeb3Loading(false) setWeb3Loading(false)
} }

25
src/@hooks/useInterval.ts Normal file
View File

@ -0,0 +1,25 @@
import { useEffect, useRef } from 'react'
export const useInterval = (
callback: () => Promise<void>,
delay: number | null | false
) => {
const savedCallback = useRef<() => Promise<void>>()
useEffect(() => {
savedCallback.current = callback
}, [callback])
useEffect(() => {
const tick = () => {
savedCallback?.current()
}
if (delay) {
const id = setInterval(tick, delay)
return () => {
clearInterval(id)
}
}
}, [callback, delay])
}

View File

@ -0,0 +1,43 @@
import { LoggerInstance } from '@oceanprotocol/lib'
import { useState } from 'react'
function useLocalStorage<T>(key: string, initialValue: T) {
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue
}
try {
// Get from local storage by key
const item = window.localStorage.getItem(key)
// Parse stored json or if none return initialValue
return item ? JSON.parse(item) : initialValue
} catch (error) {
// If error also return initialValue
LoggerInstance.error(`[useLocalStorage] error: `, error)
return initialValue
}
})
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue = (value: T | ((val: T) => T)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value
// Save state
setStoredValue(valueToStore)
// Save to local storage
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore))
}
} catch (error) {
// A more advanced implementation would handle the error case
LoggerInstance.error(`[useLocalStorage] error: `, error)
}
}
return [storedValue, setValue] as const
}
export default useLocalStorage

15
src/@hooks/usePrevious.ts Normal file
View File

@ -0,0 +1,15 @@
import { useEffect, useRef } from 'react'
function usePrevious<T>(value: T): T {
// The ref object is a generic container whose current property is mutable ...
// ... and can hold any value, similar to an instance property on a class
const ref: any = useRef<T>()
// Store current value in ref
useEffect(() => {
ref.current = value
}, [value]) // Only re-run if value changes
// Return previous value (happens before update in useEffect above)
return ref.current
}
export default usePrevious

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="19.2" height="19.2" viewBox="-1.6 -1.6 19.2 19.2"><path d="M16 8c0 3.866-3.582 7-8 7a9.06 9.06 0 01-2.347-.306c-.584.296-1.925.864-4.181 1.234-.2.032-.352-.176-.273-.362.354-.836.674-1.95.77-2.966C.744 11.37 0 9.76 0 8c0-3.866 3.582-7 8-7s8 3.134 8 7zM5 8a1 1 0 10-2 0 1 1 0 002 0zm4 0a1 1 0 10-2 0 1 1 0 002 0zm3 1a1 1 0 100-2 1 1 0 000 2z" /></svg>

After

Width:  |  Height:  |  Size: 399 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" width="24" height="24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>

After

Width:  |  Height:  |  Size: 232 B

2
src/@images/comment.svg Normal file
View File

@ -0,0 +1,2 @@
<svg width="19.2" height="19.2" viewBox="-1.6 -1.6 19.2 19.2"><path fill-rule="evenodd" d="M1.5 2.75a.25.25 0 01.25-.25h8.5a.25.25 0 01.25.25v5.5a.25.25 0 01-.25.25h-3.5a.75.75 0 00-.53.22L3.5 11.44V9.25a.75.75 0 00-.75-.75h-1a.25.25 0 01-.25-.25v-5.5zM1.75 1A1.75 1.75 0 000 2.75v5.5C0 9.216.784 10 1.75 10H2v1.543a1.457 1.457 0 002.487 1.03L7.061 10h3.189A1.75 1.75 0 0012 8.25v-5.5A1.75 1.75 0 0010.25 1h-8.5zM14.5 4.75a.25.25 0 00-.25-.25h-.5a.75.75 0 110-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0114.25 12H14v1.543a1.457 1.457 0 01-2.487 1.03L9.22 12.28a.75.75 0 111.06-1.06l2.22 2.22v-2.19a.75.75 0 01.75-.75h1a.25.25 0 00.25-.25v-5.5z"></path></svg>

After

Width:  |  Height:  |  Size: 667 B

1
src/@images/ellipsis.svg Normal file
View File

@ -0,0 +1 @@
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 512 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="256" cy="256" r="48"></circle><circle cx="416" cy="256" r="48"></circle><circle cx="96" cy="256" r="48"></circle></svg>

After

Width:  |  Height:  |  Size: 278 B

3
src/@images/emoji.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 1.332C11.6827 1.332 14.668 4.31733 14.668 8C14.668 11.682 11.6827 14.6667 8 14.6667C4.31733 14.6673 1.332 11.682 1.332 8C1.332 4.31733 4.31733 1.332 8 1.332ZM8 2.332C7.25054 2.32387 6.50691 2.46447 5.81215 2.74565C5.11738 3.02683 4.48529 3.44301 3.95244 3.97011C3.4196 4.49721 2.99658 5.12476 2.70788 5.81643C2.41918 6.5081 2.27053 7.25016 2.27053 7.99967C2.27053 8.74917 2.41918 9.49124 2.70788 10.1829C2.99658 10.8746 3.4196 11.5021 3.95244 12.0292C4.48529 12.5563 5.11738 12.9725 5.81215 13.2537C6.50691 13.5349 7.25054 13.6755 8 13.6673C9.49251 13.6511 10.9184 13.0469 11.9681 11.9858C13.0178 10.9246 13.6065 9.49227 13.6065 7.99967C13.6065 6.50707 13.0178 5.07471 11.9681 4.01358C10.9184 2.95244 9.49251 2.34819 8 2.332V2.332ZM5.64133 9.85533C5.92193 10.2125 6.28011 10.5012 6.68875 10.6995C7.09739 10.8978 7.54578 11.0006 8 11C8.45373 11.0006 8.90164 10.898 9.30991 10.7C9.71817 10.5021 10.0761 10.2139 10.3567 9.85733C10.3974 9.80581 10.4478 9.76281 10.5052 9.73079C10.5625 9.69877 10.6256 9.67836 10.6908 9.67071C10.756 9.66306 10.8221 9.66834 10.8853 9.68623C10.9484 9.70413 11.0075 9.73429 11.059 9.775C11.1105 9.81571 11.1535 9.86617 11.1855 9.9235C11.2176 9.98082 11.238 10.0439 11.2456 10.1091C11.2533 10.1743 11.248 10.2404 11.2301 10.3036C11.2122 10.3668 11.182 10.4258 11.1413 10.4773C10.7672 10.9524 10.29 11.3363 9.74583 11.6C9.20167 11.8638 8.60472 12.0006 8 12C7.3945 12.0005 6.79682 11.8633 6.25214 11.5988C5.70747 11.3343 5.23005 10.9495 4.856 10.4733C4.81362 10.422 4.78193 10.3626 4.7628 10.2988C4.74367 10.235 4.73747 10.168 4.74458 10.1018C4.75169 10.0356 4.77196 9.97146 4.8042 9.91318C4.83644 9.8549 4.88 9.80364 4.93232 9.76243C4.98464 9.72121 5.04466 9.69086 5.10887 9.67316C5.17309 9.65547 5.24018 9.65078 5.30623 9.65937C5.37228 9.66796 5.43595 9.68966 5.49349 9.7232C5.55104 9.75674 5.6013 9.80144 5.64133 9.85467V9.85533ZM6 5.83333C6.11137 5.83018 6.22225 5.8494 6.32607 5.88984C6.42989 5.93029 6.52454 5.99114 6.60443 6.0688C6.68432 6.14647 6.74783 6.23936 6.79119 6.342C6.83456 6.44463 6.8569 6.55492 6.8569 6.66633C6.8569 6.77775 6.83456 6.88804 6.79119 6.99067C6.74783 7.09331 6.68432 7.1862 6.60443 7.26386C6.52454 7.34153 6.42989 7.40238 6.32607 7.44283C6.22225 7.48327 6.11137 7.50248 6 7.49933C5.78315 7.4932 5.57725 7.40275 5.42604 7.2472C5.27483 7.09165 5.19024 6.88327 5.19024 6.66633C5.19024 6.4494 5.27483 6.24102 5.42604 6.08547C5.57725 5.92992 5.78315 5.83947 6 5.83333ZM10 5.83333C10.1114 5.83018 10.2222 5.8494 10.3261 5.88984C10.4299 5.93029 10.5245 5.99114 10.6044 6.0688C10.6843 6.14647 10.7478 6.23936 10.7912 6.342C10.8346 6.44463 10.8569 6.55492 10.8569 6.66633C10.8569 6.77775 10.8346 6.88804 10.7912 6.99067C10.7478 7.09331 10.6843 7.1862 10.6044 7.26386C10.5245 7.34153 10.4299 7.40238 10.3261 7.44283C10.2222 7.48327 10.1114 7.50248 10 7.49933C9.78315 7.4932 9.57725 7.40275 9.42604 7.2472C9.27483 7.09165 9.19024 6.88327 9.19024 6.66633C9.19024 6.4494 9.27483 6.24102 9.42604 6.08547C9.57725 5.92992 9.78315 5.83947 10 5.83333V5.83333Z" />
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

1
src/@images/laugh.svg Normal file
View File

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="-51.52 -43.52 599.04 599.04"><path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm141.4 389.4c-37.8 37.8-88 58.6-141.4 58.6s-103.6-20.8-141.4-58.6S48 309.4 48 256s20.8-103.6 58.6-141.4S194.6 56 248 56s103.6 20.8 141.4 58.6S448 202.6 448 256s-20.8 103.6-58.6 141.4zM343.6 196l33.6-40.3c8.6-10.3-3.8-24.8-15.4-18l-80 48c-7.8 4.7-7.8 15.9 0 20.6l80 48c11.5 6.8 24-7.6 15.4-18L343.6 196zm-209.4 58.3l80-48c7.8-4.7 7.8-15.9 0-20.6l-80-48c-11.6-6.9-24 7.7-15.4 18l33.6 40.3-33.6 40.3c-8.7 10.4 3.8 24.8 15.4 18zM362.4 288H133.6c-8.2 0-14.5 7-13.5 15 7.5 59.2 58.9 105 121.1 105h13.6c62.2 0 113.6-45.8 121.1-105 1-8-5.3-15-13.5-15z"></path></svg>

After

Width:  |  Height:  |  Size: 703 B

1
src/@images/reply.svg Normal file
View File

@ -0,0 +1 @@
<svg stroke-width="0" viewBox="0 0 16 16" width="24" height="24" xmlns="http://www.w3.org/2000/svg"><path d="M6.598 5.013a.144.144 0 0 1 .202.134V6.3a.5.5 0 0 0 .5.5c.667 0 2.013.005 3.3.822.984.624 1.99 1.76 2.595 3.876-1.02-.983-2.185-1.516-3.205-1.799a8.74 8.74 0 0 0-1.921-.306 7.404 7.404 0 0 0-.798.008h-.013l-.005.001h-.001L7.3 9.9l-.05-.498a.5.5 0 0 0-.45.498v1.153c0 .108-.11.176-.202.134L2.614 8.254a.503.503 0 0 0-.042-.028.147.147 0 0 1 0-.252.499.499 0 0 0 .042-.028l3.984-2.933zM7.8 10.386c.068 0 .143.003.223.006.434.02 1.034.086 1.7.271 1.326.368 2.896 1.202 3.94 3.08a.5.5 0 0 0 .933-.305c-.464-3.71-1.886-5.662-3.46-6.66-1.245-.79-2.527-.942-3.336-.971v-.66a1.144 1.144 0 0 0-1.767-.96l-3.994 2.94a1.147 1.147 0 0 0 0 1.946l3.994 2.94a1.144 1.144 0 0 0 1.767-.96v-.667z"></path></svg>

After

Width:  |  Height:  |  Size: 803 B

3
src/@images/send.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.17699 1.119C1.25168 1.05563 1.34333 1.01559 1.44058 1.00386C1.53782 0.992127 1.63637 1.00921 1.72399 1.053L14.724 7.553C14.8069 7.59457 14.8767 7.6584 14.9254 7.73734C14.9741 7.81628 15 7.90723 15 8C15 8.09278 14.9741 8.18372 14.9254 8.26266C14.8767 8.34161 14.8069 8.40543 14.724 8.447L1.72399 14.947C1.63643 14.9909 1.5379 15.0081 1.44065 14.9965C1.34339 14.9849 1.25168 14.945 1.17691 14.8817C1.10213 14.8185 1.04759 14.7346 1.02005 14.6406C0.992509 14.5466 0.993183 14.4466 1.02199 14.353L2.97699 8L1.02199 1.647C0.993347 1.55348 0.992767 1.45361 1.02032 1.35976C1.04787 1.26591 1.10234 1.1822 1.17699 1.119V1.119ZM3.86899 8.5L2.32199 13.53L13.382 8L2.32199 2.47L3.86899 7.5H9.49999C9.63259 7.5 9.75977 7.55268 9.85354 7.64645C9.94731 7.74022 9.99999 7.86739 9.99999 8C9.99999 8.13261 9.94731 8.25979 9.85354 8.35355C9.75977 8.44732 9.63259 8.5 9.49999 8.5H3.86999H3.86899Z"/>
</svg>

After

Width:  |  Height:  |  Size: 996 B

View File

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="-43.52 -43.52 599.04 599.04"><path d="M466.27 225.31c4.674-22.647.864-44.538-8.99-62.99 2.958-23.868-4.021-48.565-17.34-66.99C438.986 39.423 404.117 0 327 0c-7 0-15 .01-22.22.01C201.195.01 168.997 40 128 40h-10.845c-5.64-4.975-13.042-8-21.155-8H32C14.327 32 0 46.327 0 64v240c0 17.673 14.327 32 32 32h64c11.842 0 22.175-6.438 27.708-16h7.052c19.146 16.953 46.013 60.653 68.76 83.4 13.667 13.667 10.153 108.6 71.76 108.6 57.58 0 95.27-31.936 95.27-104.73 0-18.41-3.93-33.73-8.85-46.54h36.48c48.602 0 85.82-41.565 85.82-85.58 0-19.15-4.96-34.99-13.73-49.84zM64 296c-13.255 0-24-10.745-24-24s10.745-24 24-24 24 10.745 24 24-10.745 24-24 24zm330.18 16.73H290.19c0 37.82 28.36 55.37 28.36 94.54 0 23.75 0 56.73-47.27 56.73-18.91-18.91-9.46-66.18-37.82-94.54C206.9 342.89 167.28 272 138.92 272H128V85.83c53.611 0 100.001-37.82 171.64-37.82h37.82c35.512 0 60.82 17.12 53.12 65.9 15.2 8.16 26.5 36.44 13.94 57.57 21.581 20.384 18.699 51.065 5.21 65.62 9.45 0 22.36 18.91 22.27 37.81-.09 18.91-16.71 37.82-37.82 37.82z"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
src/@images/thumbsup.svg Normal file
View File

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="-43.52 -43.52 599.04 599.04"><path d="M466.27 286.69C475.04 271.84 480 256 480 236.85c0-44.015-37.218-85.58-85.82-85.58H357.7c4.92-12.81 8.85-28.13 8.85-46.54C366.55 31.936 328.86 0 271.28 0c-61.607 0-58.093 94.933-71.76 108.6-22.747 22.747-49.615 66.447-68.76 83.4H32c-17.673 0-32 14.327-32 32v240c0 17.673 14.327 32 32 32h64c14.893 0 27.408-10.174 30.978-23.95 44.509 1.001 75.06 39.94 177.802 39.94 7.22 0 15.22.01 22.22.01 77.117 0 111.986-39.423 112.94-95.33 13.319-18.425 20.299-43.122 17.34-66.99 9.854-18.452 13.664-40.343 8.99-62.99zm-61.75 53.83c12.56 21.13 1.26 49.41-13.94 57.57 7.7 48.78-17.608 65.9-53.12 65.9h-37.82c-71.639 0-118.029-37.82-171.64-37.82V240h10.92c28.36 0 67.98-70.89 94.54-97.46 28.36-28.36 18.91-75.63 37.82-94.54 47.27 0 47.27 32.98 47.27 56.73 0 39.17-28.36 56.72-28.36 94.54h103.99c21.11 0 37.73 18.91 37.82 37.82.09 18.9-12.82 37.81-22.27 37.81 13.489 14.555 16.371 45.236-5.21 65.62zM88 432c0 13.255-10.745 24-24 24s-24-10.745-24-24 10.745-24 24-24 24 10.745 24 24z"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

24
src/@utils/throttle.ts Normal file
View File

@ -0,0 +1,24 @@
export function throttle<Args extends unknown[]>(
fn: (...args: Args) => void,
cooldown: number
) {
let lastArgs: Args | undefined
const run = () => {
if (lastArgs) {
fn(...lastArgs)
lastArgs = undefined
}
}
const throttled = (...args: Args) => {
const isOnCooldown = !!lastArgs
lastArgs = args
if (isOnCooldown) {
return
}
window.setTimeout(run, cooldown)
}
return throttled
}

View File

@ -0,0 +1,138 @@
.conversation {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--background-content);
}
.loading {
position: absolute;
top: calc(var(--spacer) / 3);
left: 50%;
transform: translateX(-50%);
padding: calc(var(--spacer) / 4);
text-align: center;
color: var(--color-secondary);
/* -webkit-animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
-moz-animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; */
background-color: var(--background-content);
z-index: 2;
}
.connectLit {
height: 100%;
display: flex;
flex-direction: column;
padding: calc(var(--spacer) / 2);
align-items: center;
justify-content: center;
}
.connectLit p {
text-align: center;
font-size: var(--font-size-small);
color: var(--color-secondary);
}
.noMessages {
text-align: center;
padding: calc(var(--spacer) / 2);
color: var(--color-secondary);
}
.messages {
position: relative;
flex-grow: 1;
overflow: hidden;
}
.messages > .scrollContent {
height: 100%;
overflow-y: auto;
padding: calc(var(--spacer) / 2) calc(var(--spacer) / 2)
calc(var(--spacer) / 4);
}
.newMessagesBadge {
position: absolute;
height: auto;
bottom: calc(var(--spacer) / 2);
left: 50%;
transform: translateX(-50%);
border-radius: var(--border-radius);
background: var(--brand-pink);
color: var(--brand-white);
padding: calc(var(--spacer) / 4) calc(var(--spacer) / 2);
border: 0;
outline: 0;
cursor: pointer;
font-size: var(--font-size-text);
box-shadow: 0 2px 4px var(--box-shadow-color);
}
.message {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: calc(var(--spacer) / 4);
max-width: 80%;
}
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.chatBubble {
display: inline-block;
background-color: var(--color-primary);
padding: calc(var(--spacer) / 4) calc(var(--spacer) / 2.5);
font-size: var(--font-size-text);
border-radius: 1rem;
word-break: break-word;
flex-shrink: 1;
}
.time {
font-size: var(--font-size-mini);
color: var(--color-secondary);
padding-top: calc(var(--spacer) / 8);
}
.message:not(.showTime) .time {
display: none;
}
.message.right {
align-items: flex-end;
margin-left: auto;
}
.message.right .chatBubble {
color: var(--brand-white);
background-color: var(--color-primary);
border-bottom-right-radius: var(--border-radius);
}
.message.left .chatBubble {
background-color: var(--border-color);
border-bottom-left-radius: var(--border-radius);
}
.message:not(.showTime).right + .message.right .chatBubble {
border-top-right-radius: 0;
}
.message:not(.showTime).left + .message.left .chatBubble {
border-top-left-radius: 0;
}
@keyframes pulse {
50% {
opacity: 0.5;
}
}

View File

@ -0,0 +1,232 @@
import React, { useState, useEffect, useRef } from 'react'
import { useOrbis } from '@context/DirectMessages'
import { useInterval } from '@hooks/useInterval'
import { throttle } from '@utils/throttle'
import Time from '@shared/atoms/Time'
import Button from '@shared/atoms/Button'
import DecryptedMessage from './DecryptedMessage'
import Postbox from './Postbox'
import styles from './Conversation.module.css'
import { LoggerInstance } from '@oceanprotocol/lib'
import { IOrbisMessage } from '@context/DirectMessages/_types'
export default function DmConversation() {
const {
orbis,
account,
conversationId,
hasLit,
connectLit,
clearConversationNotifs
} = useOrbis()
const messagesWrapper = useRef(null)
const [isInitialized, setIsInitialized] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [messages, setMessages] = useState<IOrbisMessage[]>([])
const [currentPage, setCurrentPage] = useState(0)
const [hasMore, setHasMore] = useState(true)
const [newMessages, setNewMessages] = useState(0)
const scrollToBottom = (smooth = false) => {
setTimeout(() => {
messagesWrapper.current.scrollTo({
top: messagesWrapper.current.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
})
}, 300)
}
const getMessages: (options?: {
polling?: boolean
reset?: boolean
}) => Promise<void> = async ({ polling = false, reset = false }) => {
if (isLoading || !hasLit) return
if (!polling) setIsLoading(true)
const _page = polling || reset ? 0 : currentPage
let _messages = reset ? [] : [...messages]
const { data, error } = await orbis.getMessages(conversationId, _page)
if (error) {
LoggerInstance.error(error)
}
if (data.length) {
data.reverse()
if (!polling) {
setHasMore(data.length >= 50)
_messages = [...data, ..._messages]
setMessages(_messages)
if (currentPage === 0) {
clearConversationNotifs(conversationId)
scrollToBottom()
}
setCurrentPage(_page + 1)
} else {
const unique = data.filter(
(a) => !_messages.some((b) => a.stream_id === b.stream_id)
)
setMessages([..._messages, ...unique])
const el = messagesWrapper.current
if (el && el.scrollHeight > el.offsetHeight) {
setNewMessages((prev) => prev + unique.length)
}
}
}
setIsInitialized(true)
setIsLoading(false)
}
useInterval(
async () => {
getMessages({ polling: true })
},
!isLoading && hasLit && isInitialized ? 5000 : false
)
const showTime = (streamId: string): boolean => {
const index = messages.findIndex((o) => o.stream_id === streamId)
if (index < -1) return true
const nextMessage = messages[index + 1]
if (!nextMessage || messages[index].creator !== nextMessage.creator)
return true
return nextMessage.timestamp - messages[index].timestamp > 60
}
const callback = (nMessage: IOrbisMessage) => {
const _messages = [...messages, nMessage]
setMessages(_messages)
scrollToBottom()
}
const onScrollMessages = throttle(() => {
const el = messagesWrapper.current
if (!el) return
if (hasMore && el.scrollTop <= 50) {
getMessages()
}
if (
Math.ceil(el.scrollTop) >= Math.floor(el.scrollHeight - el.offsetHeight)
) {
setNewMessages(0)
clearConversationNotifs(conversationId)
}
// Remove scroll listener
messagesWrapper.current.removeEventListener('scroll', onScrollMessages)
// Readd scroll listener
setTimeout(() => {
messagesWrapper.current.addEventListener('scroll', onScrollMessages)
}, 100)
}, 1000)
useEffect(() => {
setIsInitialized(false)
setMessages([])
if (
conversationId &&
!conversationId.startsWith('new-') &&
orbis &&
hasLit
) {
getMessages({ reset: true })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId, orbis, hasLit])
useEffect(() => {
const el = messagesWrapper.current
el?.addEventListener('scroll', onScrollMessages)
return () => {
el?.removeEventListener('scroll', onScrollMessages)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages])
return (
<div className={styles.conversation}>
{!hasLit ? (
<div className={styles.connectLit}>
<p>
You need to configure your private account to access your private
conversations.
</p>
<Button
style="primary"
size="small"
disabled={false}
onClick={() => connectLit()}
>
Generate private account
</Button>
</div>
) : (
<>
{isLoading && <div className={styles.loading}>Loading...</div>}
<div className={styles.messages}>
{!isLoading && messages.length === 0 ? (
<div className={styles.noMessages}>No message yet</div>
) : (
<div ref={messagesWrapper} className={styles.scrollContent}>
{messages.map((message) => (
<div
key={message.stream_id}
className={`${styles.message} ${
message.stream_id.startsWith('new_post--')
? styles.pulse
: ''
} ${
account?.did === message.creator_details.did
? styles.right
: styles.left
} ${showTime(message.stream_id) && styles.showTime}`}
>
<div className={styles.chatBubble}>
<DecryptedMessage
content={message.content}
position={
account?.did === message.creator_details.did
? 'left'
: 'right'
}
/>
</div>
<div className={styles.time}>
<Time
date={message.timestamp.toString()}
isUnix={true}
relative={false}
displayFormat="MMM d, yyyy, h:mm aa"
/>
</div>
</div>
))}
</div>
)}
{newMessages > 0 && (
<button
className={styles.newMessagesBadge}
onClick={() => scrollToBottom(true)}
>
{newMessages} new {newMessages > 1 ? 'messages' : 'message'}
</button>
)}
</div>
<Postbox callback={callback} />
</>
)}
</div>
)
}

View File

@ -0,0 +1,39 @@
.decrypting {
-webkit-animation: pulse 2s ease-in-out 0s infinite forwards;
-moz-animation: pulse 2s ease-in-out 0s infinite forwards;
animation: pulse 2s ease-in-out 0s infinite forwards;
}
.refresh {
position: absolute;
width: 24px;
height: 24px;
top: 0;
background: transparent;
border: 0;
cursor: pointer;
}
.refreshIcon {
fill: var(--font-color-text);
}
.refresh.right {
right: -38px;
}
.refresh.left {
right: calc(100% + 16px);
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View File

@ -0,0 +1,77 @@
import React, { useState, useEffect } from 'react'
import { useOrbis } from '@context/DirectMessages'
import Refresh from '@images/refresh.svg'
import styles from './DecryptedMessage.module.css'
import { IOrbisMessageContent } from '@context/DirectMessages/_types'
import { LoggerInstance } from '@oceanprotocol/lib'
export default function DecryptedMessage({
content,
position = 'right'
}: {
content: IOrbisMessageContent & { decryptedMessage?: string }
position: 'left' | 'right'
}) {
const { orbis } = useOrbis()
const [loading, setLoading] = useState(true)
const [decrypted, setDecrypted] = useState(null)
const [encryptionError, setEncryptionError] = useState<boolean>(false)
const decryptMessage = async () => {
setLoading(true)
setEncryptionError(false)
try {
if (content?.decryptedMessage) {
setDecrypted(content?.decryptedMessage)
} else {
const res = await orbis.decryptMessage({
conversation_id: content?.conversation_id,
encryptedMessage: content?.encryptedMessage
})
if (res.status === 200) {
setEncryptionError(false)
setDecrypted(res.result)
} else {
setEncryptionError(true)
setDecrypted('Decryption error - please try later')
}
}
} catch (error) {
LoggerInstance.error(`[decryptMessage] orbis api error: `, error)
setEncryptionError(true)
setDecrypted('Decryption error - please try later')
}
setLoading(false)
}
useEffect(() => {
if (content && orbis) decryptMessage()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content, orbis])
if (loading) {
return <span className={styles.decrypting}>---</span>
}
return (
<div style={{ position: 'relative' }}>
{!loading ? decrypted : '---'}
{encryptionError && (
<button
type="button"
className={`${styles.refresh} ${styles[position]}`}
onClick={decryptMessage}
title="Refresh"
>
<Refresh
role="img"
aria-label="Refresh"
className={styles.refreshIcon}
/>
</button>
)}
</div>
)
}

View File

@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react'
import Button from '@shared/atoms/Button'
import styles from './DmButton.module.css'
import { useWeb3 } from '@context/Web3'
import { useOrbis } from '@context/DirectMessages'
export default function DmButton({
accountId,
text = 'Send Message'
}: {
accountId: string
text?: string
}) {
const { accountId: ownAccountId, connect } = useWeb3()
const {
checkOrbisConnection,
getConversationByDid,
setConversationId,
setOpenConversations,
createConversation,
getDid
} = useOrbis()
const [userDid, setUserDid] = useState<string | undefined>()
const [isCreatingConversation, setIsCreatingConversation] = useState(false)
const handleActivation = async () => {
const resConnect = await connect()
if (resConnect) {
await checkOrbisConnection({
address: resConnect,
autoConnect: true,
lit: true
})
}
}
useEffect(() => {
const getUserDid = async () => {
const did = await getDid(accountId)
setUserDid(did)
}
if (accountId) {
getUserDid()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountId])
if (accountId !== ownAccountId && userDid) {
return (
<Button
style="text"
size="small"
disabled={isCreatingConversation}
onClick={async () => {
if (!ownAccountId) {
handleActivation()
} else {
setIsCreatingConversation(true)
const conversation = await getConversationByDid(userDid)
if (conversation) {
setConversationId(conversation.stream_id)
} else {
const newConversationId = await createConversation([userDid])
console.log(newConversationId)
setConversationId(newConversationId)
}
setOpenConversations(true)
setIsCreatingConversation(false)
}
}}
>
{isCreatingConversation ? 'Loading...' : text}
</Button>
)
}
}

View File

@ -0,0 +1,101 @@
.header {
display: flex;
flex-wrap: wrap;
gap: calc(0.5 * var(--spacer));
align-items: center;
padding: calc(0.35 * var(--spacer)) calc(0.5 * var(--spacer));
border-bottom: 1px solid var(--border-color);
box-shadow: var(--box-shadow);
font-size: var(--font-size-h5);
font-weight: var(--font-weight-bold);
cursor: pointer;
}
.header > * {
pointer-events: none;
}
.icon {
width: 1.5rem;
fill: var(--font-color-text);
}
.btnBack {
border: none;
background-color: transparent;
padding: 0;
margin: 0;
cursor: pointer;
width: 2rem;
height: 2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
}
.btnBack:hover {
background-color: var(--background-highlight);
}
.btnBack .backIcon {
pointer-events: none;
width: 1rem;
fill: var(--font-color-text);
transform: rotate(180deg);
}
.btnCopy {
border: none;
background-color: transparent;
padding: 0;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
}
.btnCopy .copyIcon {
pointer-events: none;
width: 1rem;
fill: var(--color-secondary);
}
.toggleArrow {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
background: transparent;
border: none;
color: var(--font-color-text);
width: 32px;
height: 32px;
}
.toggleArrow .icon {
margin-left: auto;
margin-right: auto;
fill: transparent;
}
.isFlipped {
transform: scaleY(-1);
}
.notificationCount {
background-color: var(--color-primary);
width: 24px;
height: 24px;
color: var(--brand-white);
border-radius: 100%;
display: flex;
justify-content: center;
align-items: center;
font-size: var(--font-size-mini);
font-weight: 600;
margin-left: calc(var(--spacer) / 4);
}

View File

@ -0,0 +1,127 @@
import React, { useEffect } from 'react'
import styles from './Header.module.css'
import { useWeb3 } from '@context/Web3'
import { useOrbis } from '@context/DirectMessages'
import { didToAddress } from './_utils'
import { toast } from 'react-toastify'
import ChatBubble from '@images/chatbubble.svg'
import ArrowBack from '@images/arrow.svg'
import ChevronUp from '@images/chevronup.svg'
import Copy from '@images/copy.svg'
export default function Header() {
const { accountId } = useWeb3()
const {
conversations,
conversationId,
openConversations,
activeConversationTitle,
totalNotifications,
setActiveConversationTitle,
getConversationTitle,
setOpenConversations,
setConversationId
} = useOrbis()
const handleClick = (
e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>
) => {
e.preventDefault()
const target = e.target as HTMLElement
const { role } = target.dataset
if (role) {
if (role === 'back-button') {
setConversationId(null)
} else {
let _address = ''
const conversation = conversations.find(
(c) => c.stream_id === conversationId
)
const recipients = conversation.recipients.filter(
(r) => didToAddress(r) !== accountId.toLowerCase()
)
_address = didToAddress(recipients[0])
navigator.clipboard.writeText(_address)
toast.info('Address copied to clipboard')
}
} else {
setOpenConversations(!openConversations)
}
}
const setConversationTitle = async (conversationId: string) => {
if (conversationId.startsWith('new-')) {
setActiveConversationTitle(conversationId.replace('new-', ''))
} else {
const title = await getConversationTitle(conversationId)
setActiveConversationTitle(title)
}
}
useEffect(() => {
if (!conversationId) setActiveConversationTitle(null)
else setConversationTitle(conversationId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId])
return (
<div className={styles.header} onClick={handleClick}>
{!conversationId ? (
<>
<div>
<ChatBubble role="img" aria-label="Chat" className={styles.icon} />
</div>
<span>Direct Messages</span>
{totalNotifications > 0 && (
<span className={styles.notificationCount}>
{totalNotifications}
</span>
)}
</>
) : (
<>
{openConversations && (
<button
type="button"
aria-label="button"
data-role="back-button"
className={styles.btnBack}
>
<ArrowBack
role="img"
aria-label="arrow"
className={styles.backIcon}
/>
</button>
)}
{activeConversationTitle && (
<>
<span>{activeConversationTitle}</span>
<button
type="button"
data-role="copy-address"
title="Copy Address"
className={styles.btnCopy}
>
<Copy
role="img"
aria-label="Copy Address"
className={styles.copyIcon}
/>
</button>
</>
)}
</>
)}
<div className={styles.toggleArrow}>
<ChevronUp
role="img"
aria-label="Toggle"
className={`${styles.icon} ${
openConversations ? styles.isFlipped : ''
}`}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,24 @@
.conversations {
height: 100%;
background-color: var(--background-content);
overflow-y: auto;
}
.conversations .empty {
height: 100%;
max-width: 80%;
text-align: center;
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--color-secondary);
}
.conversations .empty .icon {
width: var(--font-size-h1);
fill: currentColor;
margin-bottom: calc(var(--spacer) / 2);
}

View File

@ -0,0 +1,45 @@
import React, { useMemo } from 'react'
import { useOrbis } from '@context/DirectMessages'
import { IConversationWithAdditionalData } from '@context/DirectMessages/_types'
import ListItem from './ListItem'
import ChatBubble from '@images/chatbubble.svg'
import styles from './List.module.css'
export default function List() {
const { conversations, setConversationId } = useOrbis()
const filteredConversations = useMemo(() => {
return conversations.filter(
(conversation: IConversationWithAdditionalData) =>
!conversation.empty_message
)
}, [conversations])
return (
<div className={styles.conversations}>
{filteredConversations.length > 0 ? (
filteredConversations.map(
(conversation: IConversationWithAdditionalData, index: number) => (
<ListItem
key={index}
conversation={conversation}
setConversationId={setConversationId}
/>
)
)
) : (
<div className={styles.empty}>
<ChatBubble role="img" aria-label="Chat" className={styles.icon} />
<p>
Hello! Any question regarding a specific dataset listed on Ocean
Marketplace?
</p>
<p>
Go over the asset detail page or directly to a publisher profile to
start a conversation!
</p>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,62 @@
.conversation {
display: flex;
align-items: center;
padding: calc(0.5 * var(--spacer)) calc(0.5 * var(--spacer));
border-bottom: 1px solid var(--border-color);
cursor: pointer;
}
.conversation:hover {
background: var(--background-highlight);
}
.accountAvatarSet {
position: relative;
flex-shrink: 0;
}
.accountAvatar {
aspect-ratio: 1/1;
border-radius: 100%;
border: 1px solid var(--border-color);
flex-shrink: 0;
width: calc(var(--font-size-large) * 2.5);
height: calc(var(--font-size-large) * 2.5);
margin-left: 0;
}
.notificationCount {
position: absolute;
bottom: -4px;
right: -4px;
background-color: var(--color-primary);
width: 24px;
height: 24px;
color: var(--brand-white);
border-radius: 100%;
display: flex;
justify-content: center;
align-items: center;
font-size: var(--font-size-mini);
font-weight: 600;
}
.accountInfo {
padding-left: calc(var(--spacer) / 2);
display: flex;
flex-grow: 1;
justify-content: space-between;
align-items: center;
}
.accountName {
font-size: var(--font-size-medium);
color: var(--color-primary);
}
.lastMessageDate {
font-size: var(--font-size-mini);
color: var(--font-color-text);
min-width: calc(var(--spacer) * 1.5);
text-align: right;
}

View File

@ -0,0 +1,66 @@
import React, { useState, useEffect } from 'react'
import { useCancelToken } from '@hooks/useCancelToken'
import { useOrbis } from '@context/DirectMessages'
import { IConversationWithAdditionalData } from '@context/DirectMessages/_types'
import { didToAddress } from './_utils'
import Avatar from '@shared/atoms/Avatar'
import Time from '@shared/atoms/Time'
import styles from './ListItem.module.css'
export default function ConversationItem({
conversation,
setConversationId
}: {
conversation: IConversationWithAdditionalData
setConversationId: (value: string) => void
}) {
const { account, getConversationTitle } = useOrbis()
const newCancelToken = useCancelToken()
const [name, setName] = useState<string>(null)
const [address, setAddress] = useState(null)
useEffect(() => {
const getProfile = async () => {
const did = conversation.recipients.find((o) => o !== account.did)
const _address = didToAddress(did)
setAddress(_address)
const _name = await getConversationTitle(conversation?.stream_id)
setName(_name)
}
if (conversation && account) {
getProfile()
}
}, [conversation, account, newCancelToken, getConversationTitle])
return (
<div
className={styles.conversation}
onClick={() => setConversationId(conversation.stream_id)}
>
<div className={styles.accountAvatarSet}>
<Avatar accountId={address} className={styles.accountAvatar} />
{conversation.notifications_count > 0 && (
<div className={styles.notificationCount}>
{conversation.notifications_count}
</div>
)}
</div>
<div className={styles.accountInfo}>
<div className={styles.accountName}>{name}</div>
<span className={styles.lastMessageDate}>
<Time
date={conversation.last_message_timestamp.toString()}
isUnix={true}
relative={false}
displayFormat="Pp"
/>
</span>
</div>
</div>
)
}

View File

@ -0,0 +1,89 @@
.postbox {
width: 100%;
flex-shrink: 0;
border-top: 1px solid var(--border-color);
padding: calc(var(--spacer) / 4) calc(var(--spacer) / 2);
}
.postbox .postboxInput {
position: relative;
display: flex;
align-items: flex-end;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
}
.postbox .editable {
overflow-wrap: break-word;
white-space: pre-wrap;
flex-grow: 1;
outline: none;
max-height: 80px;
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
padding: calc(var(--spacer) / 4);
}
.postbox .editable:empty:before {
content: attr(data-placeholder);
color: var(--color-secondary);
pointer-events: none;
}
.postbox .editable::-webkit-scrollbar {
display: none;
}
.replyto {
position: relative;
display: flex;
align-items: flex-start;
gap: calc(var(--spacer) / 2);
padding: calc(var(--spacer) / 2);
font-size: var(--font-size-small);
background: var(--background-highlight);
border-top-left-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
}
.replytoDetails {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.replytoCancel {
flex-shrink: 0;
font-size: var(--font-size-large);
line-height: 1;
font-weight: bold;
background: transparent;
border: none;
color: currentColor;
outline: none;
}
.sendButton {
background-color: transparent;
border: 0;
color: var(--color-primary);
width: 41px;
height: 41px;
display: flex;
align-items: center;
justify-content: center;
border-left: 1px solid var(--border-color);
cursor: pointer;
}
.sendButton:disabled {
color: var(--color-secondary);
cursor: default;
}
.sendButton .icon {
width: 24px;
fill: currentColor;
}

View File

@ -0,0 +1,121 @@
import React, { useRef } from 'react'
import styles from './Postbox.module.css'
import { useOrbis } from '@context/DirectMessages'
import SendIcon from '@images/send.svg'
import { accountTruncate } from '@utils/web3'
import { didToAddress } from './_utils'
import {
IOrbisMessage,
IOrbisMessageContent
} from '@context/DirectMessages/_types'
export default function Postbox({
replyTo = null,
cancelReplyTo,
callback
}: {
replyTo?: IOrbisMessage
cancelReplyTo?: () => void
callback: (value: IOrbisMessage) => void
}) {
const postBoxArea = useRef(null)
const {
orbis,
account,
conversationId,
updateConversationEmptyMessageStatus
} = useOrbis()
const share = async () => {
if (!account || postBoxArea.current.innerText.trim() === '') return false
const body = postBoxArea.current.innerText.trim()
postBoxArea.current.innerText = ''
const content: IOrbisMessageContent & { decryptedMessage?: string } = {
encryptedMessage: null,
decryptedMessage: body,
master: replyTo ? replyTo.master || replyTo.stream_id : undefined,
reply_to: replyTo ? replyTo.stream_id : undefined
}
const timestamp = Math.floor(Date.now() / 1000)
const _callbackContent: IOrbisMessage = {
conversation_id: conversationId,
content,
creator: account.did,
creator_details: {
did: account.did,
profile: account.details?.profile,
metadata: account.details?.metadata
},
master: replyTo ? replyTo.master || replyTo.stream_id : null,
reply_to: replyTo ? replyTo.stream_id : null,
reply_to_creator_details: replyTo ? replyTo.creator_details : null,
reply_to_details: replyTo ? replyTo.content : null,
stream_id: 'new_post--' + timestamp,
timestamp
}
if (callback) callback(_callbackContent)
const res = await orbis.sendMessage({
conversation_id: conversationId,
body
})
if (res.status === 200) {
_callbackContent.stream_id = res.doc
if (callback) callback(_callbackContent)
updateConversationEmptyMessageStatus(conversationId, false)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
if (!e.key) return
if (e.key === 'Enter' && !e.shiftKey) {
// Don't generate a new line
e.preventDefault()
share()
}
}
return (
<div className={styles.postbox}>
{replyTo && (
<div className={styles.replyto}>
<div className={styles.replytoDetails}>
Replying to{' '}
<strong>
{replyTo?.creator_details?.metadata?.ensName ||
accountTruncate(didToAddress(replyTo?.creator_details?.did))}
</strong>
</div>
<button className={styles.replytoCancel} onClick={cancelReplyTo}>
&times;
</button>
</div>
)}
<div className={styles.postboxInput}>
<div
id="postbox-area"
ref={postBoxArea}
className={styles.editable}
contentEditable={true}
data-placeholder="Type your message here..."
onKeyDown={handleKeyDown}
/>
<button
type="button"
className={styles.sendButton}
onClick={share}
disabled={!conversationId}
>
<SendIcon className={styles.icon} />
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,97 @@
import React, { ReactNode } from 'react'
import Link from 'next/link'
import reactStringReplace from 'react-string-replace'
import { IOrbisPostContent } from '@context/DirectMessages/_types'
/** Regex patterns to use */
const patternMentions = /\B@[a-z0-9_.⍙-]+/gi
export function didToAddress(did: string) {
if (!did) return
const _did = did.split(':')
return _did[4]
}
export function formatMessage(
content: IOrbisPostContent,
hideOverflow = false
): ReactNode {
if (!content || !content.body) return null
let { body }: { body: any } = content
if (hideOverflow && body.length > 285) {
body = body.substring(0, 280)
return body + '...'
}
/** Replace all <br> generated by the postbox to \n to handle line breaks */
body = reactStringReplace(body, '<br>', function (match, i) {
return <br key={match + i} />
})
body = reactStringReplace(body, '\n', function (match, i) {
return <br key={match + i} />
})
/** Replace URLs */
body = reactStringReplace(
body,
/(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/g,
function (match, i) {
const shortUrl =
match.substring(0, 25) +
'...' +
match.substring(match.length - 15, match.length)
return (
<a
key={match + i}
href={match}
rel="noreferrer"
target="_blank"
title={match}
>
{match.length > 40 ? shortUrl : match}
</a>
)
}
)
/** Identify and replace mentions */
/** Get mentions in post metadata */
const { mentions } = content
/** Retrieve mentions in the body */
const mentionsInBody = content.body.toString().match(patternMentions)
/** Compare both and replace in body */
if (mentionsInBody && mentions && Array.isArray(mentions)) {
mentionsInBody.forEach((_m) => {
/** Find mention with the same name */
const mention = mentions.find((obj) => obj.username === _m)
if (mention !== undefined) {
body = reactStringReplace(body, _m, (match, i) =>
mention.did ? (
<Link
href={`/profile/${didToAddress(mention.did)}`}
key={match + i}
>
{mention.username}
</Link>
) : (
<span className="link" key={i}>
{mention.username}
</span>
)
)
}
})
}
return body
}
export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms))

View File

@ -0,0 +1,73 @@
.wrapper {
width: 100%;
padding: 0 calc(var(--spacer) / 3);
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
position: fixed;
right: 0;
bottom: 0;
left: 0;
transition: transform 300ms ease 0s;
pointer-events: none;
z-index: 2;
}
.floating {
display: flex;
flex-direction: column;
pointer-events: auto;
width: 100%;
max-width: 400px;
height: 530px;
max-height: 80vh;
background-color: var(--background-content);
border: 1px solid var(--border-color);
border-bottom: 0;
border-radius: var(--border-radius);
transform: translateY(0);
}
.headerWrapper {
flex-shrink: 0;
}
.bodyWrapper {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.walletWrapper {
margin: auto;
max-width: 320px;
text-align: center;
}
.walletWrapper p {
color: var(--color-secondary);
font-size: var(--font-size-small);
}
.walletWrapper button {
margin: var(--spacer) auto 0;
}
.isClosed {
transform: translateY(90%);
}
@media screen and (min-width: 42rem) {
.wrapper {
padding: 0 calc(var(--spacer) / 2);
}
}
@media screen and (min-width: 55rem) {
.wrapper {
padding: 0 var(--spacer);
}
}

View File

@ -0,0 +1,107 @@
import React from 'react'
import styles from './index.module.css'
import Conversation from './Conversation'
import { useOrbis } from '@context/DirectMessages'
import { useWeb3 } from '@context/Web3'
import Header from './Header'
import List from './List'
import walletStyles from '../../Header/Wallet/Account.module.css'
const BodyContent = () => {
const { account, conversationId, checkOrbisConnection } = useOrbis()
const { accountId, connect } = useWeb3()
const handleActivation = async (e: React.MouseEvent) => {
e.preventDefault()
const resConnect = await connect()
if (resConnect) {
await checkOrbisConnection({
address: accountId,
autoConnect: true,
lit: true
})
}
}
const message = () => {
return (
<>
<p>A new decentralized, encrypted private messaging is here!</p>
<p>
Engage with data publishers, get your algorithms whitelisted and
establish trust.
</p>
<p>
You&apos;ll be required to sign 2 transactions, one to connect to your
decentralized identity and the other to generate your encrypted key.
</p>
</>
)
}
if (!accountId) {
return (
<div className={styles.walletWrapper}>
<div>
<h5>Connect your wallet to start messaging</h5>
{message()}
<button
className={`${walletStyles.button} ${walletStyles.initial}`}
onClick={(e) => handleActivation(e)}
>
Connect <span>Wallet</span>
</button>
</div>
</div>
)
}
if (!account) {
return (
<div className={styles.walletWrapper}>
<div>
<h5>Sign your wallet to start messaging</h5>
{message()}
<button
className={`${walletStyles.button} ${walletStyles.initial}`}
onClick={() =>
checkOrbisConnection({
address: accountId,
autoConnect: true,
lit: true
})
}
>
Sign <span>Wallet</span>
</button>
</div>
</div>
)
}
return (
<>
<List />
{conversationId && <Conversation />}
</>
)
}
export default function DirectMessages() {
const { openConversations } = useOrbis()
return (
<div
className={`${styles.wrapper} ${!openConversations && styles.isClosed}`}
>
<div className={styles.floating}>
<div className={styles.headerWrapper}>
<Header />
</div>
<div className={styles.bodyWrapper}>
<BodyContent />
</div>
</div>
</div>
)
}

View File

@ -2,5 +2,4 @@
composes: box from '@shared/atoms/Box.module.css'; composes: box from '@shared/atoms/Box.module.css';
max-width: 35rem; max-width: 35rem;
margin: auto; margin: auto;
padding: 0;
} }

View File

@ -1,8 +1,7 @@
import React, { ReactElement, useState, useEffect } from 'react' import React, { ReactElement, useState, useEffect } from 'react'
import Compute from './Compute' import Compute from './Compute'
import Consume from './Download' import Download from './Download'
import { FileInfo, LoggerInstance, Datatoken } from '@oceanprotocol/lib' import { FileInfo, LoggerInstance, Datatoken } from '@oceanprotocol/lib'
import Tabs, { TabsItem } from '@shared/atoms/Tabs'
import { compareAsBN } from '@utils/numbers' import { compareAsBN } from '@utils/numbers'
import { useAsset } from '@context/Asset' import { useAsset } from '@context/Asset'
import { useWeb3 } from '@context/Web3' import { useWeb3 } from '@context/Web3'
@ -144,8 +143,8 @@ export default function AssetActions({
} }
}, [balance, accountId, asset?.accessDetails, dtBalance]) }, [balance, accountId, asset?.accessDetails, dtBalance])
const UseContent = ( return (
<> <div className={styles.actions}>
{isCompute ? ( {isCompute ? (
<Compute <Compute
asset={asset} asset={asset}
@ -154,7 +153,7 @@ export default function AssetActions({
fileIsLoading={fileIsLoading} fileIsLoading={fileIsLoading}
/> />
) : ( ) : (
<Consume <Download
asset={asset} asset={asset}
dtBalance={dtBalance} dtBalance={dtBalance}
isBalanceSufficient={isBalanceSufficient} isBalanceSufficient={isBalanceSufficient}
@ -163,19 +162,6 @@ export default function AssetActions({
/> />
)} )}
<AssetStats /> <AssetStats />
</> </div>
)
const tabs: TabsItem[] = [{ title: 'Use', content: UseContent }]
return (
<>
<Tabs items={tabs} className={styles.actions} />
<Web3Feedback
networkId={asset?.chainId}
accountId={accountId}
isAssetNetwork={isAssetNetwork}
/>
</>
) )
} }

View File

@ -16,6 +16,9 @@ import content from '../../../../content/purgatory.json'
import Web3 from 'web3' import Web3 from 'web3'
import Button from '@shared/atoms/Button' import Button from '@shared/atoms/Button'
import RelatedAssets from '../RelatedAssets' import RelatedAssets from '../RelatedAssets'
import DmButton from '@shared/DirectMessages/DmButton'
import Web3Feedback from '@components/@shared/Web3Feedback'
import { useWeb3 } from '@context/Web3'
export default function AssetContent({ export default function AssetContent({
asset asset
@ -23,6 +26,7 @@ export default function AssetContent({
asset: AssetExtended asset: AssetExtended
}): ReactElement { }): ReactElement {
const { isInPurgatory, purgatoryData, isOwner, isAssetNetwork } = useAsset() const { isInPurgatory, purgatoryData, isOwner, isAssetNetwork } = useAsset()
const { accountId } = useWeb3()
const { debug } = useUserPreferences() const { debug } = useUserPreferences()
const [receipts, setReceipts] = useState([]) const [receipts, setReceipts] = useState([])
const [nftPublisher, setNftPublisher] = useState<string>() const [nftPublisher, setNftPublisher] = useState<string>()
@ -79,6 +83,14 @@ export default function AssetContent({
</Button> </Button>
</div> </div>
)} )}
<div className={styles.ownerActions}>
<DmButton accountId={asset?.nft?.owner} />
</div>
<Web3Feedback
networkId={asset?.chainId}
accountId={accountId}
isAssetNetwork={isAssetNetwork}
/>
<RelatedAssets /> <RelatedAssets />
</div> </div>
</article> </article>

View File

@ -1,5 +1,5 @@
.footer { .footer {
padding: var(--spacer) calc(var(--spacer) / 2); padding: var(--spacer) calc(var(--spacer) / 2) calc(var(--spacer) * 2.5);
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
max-width: var(--layout-max-width); max-width: var(--layout-max-width);

View File

@ -5,11 +5,13 @@ import Button from '@shared/atoms/Button'
import AddToken from '@shared/AddToken' import AddToken from '@shared/AddToken'
import Conversion from '@shared/Price/Conversion' import Conversion from '@shared/Price/Conversion'
import { useWeb3 } from '@context/Web3' import { useWeb3 } from '@context/Web3'
import { useOrbis } from '@context/DirectMessages'
import { getOceanConfig } from '@utils/ocean' import { getOceanConfig } from '@utils/ocean'
import styles from './Details.module.css' import styles from './Details.module.css'
export default function Details(): ReactElement { export default function Details(): ReactElement {
const { const {
accountId,
web3ProviderInfo, web3ProviderInfo,
web3Modal, web3Modal,
connect, connect,
@ -18,6 +20,7 @@ export default function Details(): ReactElement {
networkId, networkId,
balance balance
} = useWeb3() } = useWeb3()
const { checkOrbisConnection, disconnectOrbis } = useOrbis()
const { locale } = useUserPreferences() const { locale } = useUserPreferences()
const [mainCurrency, setMainCurrency] = useState<string>() const [mainCurrency, setMainCurrency] = useState<string>()
@ -83,6 +86,7 @@ export default function Details(): ReactElement {
onClick={async () => { onClick={async () => {
await web3Modal?.clearCachedProvider() await web3Modal?.clearCachedProvider()
connect() connect()
checkOrbisConnection({ address: accountId })
}} }}
> >
Switch Wallet Switch Wallet
@ -92,6 +96,7 @@ export default function Details(): ReactElement {
size="small" size="small"
onClick={() => { onClick={() => {
logout() logout()
disconnectOrbis(accountId)
location.reload() location.reload()
}} }}
> >

View File

@ -2,6 +2,13 @@
text-align: center; text-align: center;
} }
.sendMessage {
display: flex;
position: justify-end;
background-color: white;
cursor: pointer;
}
.account p { .account p {
margin: 0; margin: 0;
} }

View File

@ -30,7 +30,6 @@ export default function Account({
<Jellyfish className={styles.image} /> <Jellyfish className={styles.image} />
)} )}
</figure> </figure>
<div> <div>
<h3 className={styles.name}> <h3 className={styles.name}>
{profile?.name || accountTruncate(accountId)} {profile?.name || accountTruncate(accountId)}

View File

@ -20,6 +20,11 @@
margin-bottom: 0; margin-bottom: 0;
} }
.directMessage {
margin-top: calc(var(--spacer) / 2);
text-align: center;
}
@media (min-width: 50rem) { @media (min-width: 50rem) {
.grid { .grid {
display: grid; display: grid;
@ -32,6 +37,11 @@
margin-top: calc(var(--spacer) / 2); margin-top: calc(var(--spacer) / 2);
-webkit-line-clamp: 7 !important; -webkit-line-clamp: 7 !important;
} }
.directMessage {
margin-top: 0;
text-align: end;
}
} }
.publisherLinks { .publisherLinks {

View File

@ -5,6 +5,7 @@ import Stats from './Stats'
import Account from './Account' import Account from './Account'
import styles from './index.module.css' import styles from './index.module.css'
import { useProfile } from '@context/Profile' import { useProfile } from '@context/Profile'
import DmButton from '@shared/DirectMessages/DmButton'
const isDescriptionTextClamped = () => { const isDescriptionTextClamped = () => {
const el = document.getElementById('description') const el = document.getElementById('description')
@ -39,6 +40,9 @@ export default function AccountHeader({
</div> </div>
<div> <div>
<div className={styles.directMessage}>
<DmButton accountId={accountId} />
</div>
<Markdown text={profile?.description} className={styles.description} /> <Markdown text={profile?.description} className={styles.description} />
{isDescriptionTextClamped() ? ( {isDescriptionTextClamped() ? (
<span className={styles.more} onClick={toogleShowMore}> <span className={styles.more} onClick={toogleShowMore}>

View File

@ -6,7 +6,8 @@ import { UserPreferencesProvider } from '@context/UserPreferences'
import PricesProvider from '@context/Prices' import PricesProvider from '@context/Prices'
import UrqlProvider from '@context/UrqlProvider' import UrqlProvider from '@context/UrqlProvider'
import ConsentProvider from '@context/CookieConsent' import ConsentProvider from '@context/CookieConsent'
import App from '../../src/components/App' import { OrbisProvider } from '@context/DirectMessages'
import App from 'src/components/App'
import '@oceanprotocol/typographies/css/ocean-typo.css' import '@oceanprotocol/typographies/css/ocean-typo.css'
import '../stylesGlobal/styles.css' import '../stylesGlobal/styles.css'
@ -45,11 +46,13 @@ function MyApp({ Component, pageProps }: AppProps): ReactElement {
<UserPreferencesProvider> <UserPreferencesProvider>
<PricesProvider> <PricesProvider>
<ConsentProvider> <ConsentProvider>
<OrbisProvider>
<PostHogProvider client={posthog}> <PostHogProvider client={posthog}>
<App> <App>
<Component {...pageProps} /> <Component {...pageProps} />
</App> </App>
</PostHogProvider> </PostHogProvider>
</OrbisProvider>
</ConsentProvider> </ConsentProvider>
</PricesProvider> </PricesProvider>
</UserPreferencesProvider> </UserPreferencesProvider>

View File

@ -0,0 +1,48 @@
.EmojiPickerReact * {
font-family: var(--font-family-base) !important;
}
.EmojiPickerReact.epr-dark-theme {
--epr-bg-color: var(--background-content) !important;
--epr-category-label-bg-color: #141414e6 !important;
}
.EmojiPickerReact {
--epr-emoji-size: 24px !important;
}
.EmojiPickerReact .epr-search-container input.epr-search {
background-color: transparent !important;
border-color: var(--border-color) !important;
border-radius: var(--border-radius) !important;
color: var(--brand-black) !important;
}
.EmojiPickerReact .epr-search-container input.epr-search:focus {
background-color: var(--epr-search-input-bg-color-active);
border-color: var(--brand-black) !important;
}
.EmojiPickerReact.epr-dark-theme .epr-search-container input.epr-search {
color: var(--brand-white) !important;
}
.EmojiPickerReact.epr-dark-theme .epr-search-container input.epr-search:focus {
border-color: var(--brand-white) !important;
}
.EmojiPickerReact .epr-category-nav > button.epr-cat-btn {
-webkit-filter: hue-rotate(110deg) saturate(2);
filter: hue-rotate(110deg) saturate(2);
}
.EmojiPickerReact .epr-body::-webkit-scrollbar {
display: none;
}
.EmojiPickerReact .epr-body {
-ms-overflow-style: none;
scrollbar-width: none;
}
aside.EmojiPickerReact.epr-main {
border-width: 0 !important;
}

View File

@ -163,6 +163,7 @@ table th {
@import '_code.css'; @import '_code.css';
@import '_toast.css'; @import '_toast.css';
@import '_web3modal.css'; @import '_web3modal.css';
@import '_emojipicker.css';
/* prevent background scrolling */ /* prevent background scrolling */
.ReactModal__Body--open { .ReactModal__Body--open {