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

update orbis comment and DM components

This commit is contained in:
marcoelissa 2022-11-20 14:34:19 +07:00
parent 1cf6ded9ef
commit 1e0985cf65
41 changed files with 2766 additions and 1260 deletions

887
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -29,7 +29,7 @@
"@oceanprotocol/lib": "^2.4.0",
"@oceanprotocol/typographies": "^0.1.0",
"@oceanprotocol/use-dark-mode": "^2.4.3",
"@orbisclub/orbis-sdk": "^0.3.60",
"@orbisclub/orbis-sdk": "^0.4.4-beta.39",
"@tippyjs/react": "^4.2.6",
"@urql/exchange-refocus": "^1.0.0",
"@walletconnect/web3-provider": "^1.8.0",

View File

@ -7,77 +7,191 @@ import React, {
ReactElement
} from 'react'
import { sleep } from '@utils/index'
import { useInterval } from '@hooks/useInterval'
import { Orbis } from '@orbisclub/orbis-sdk'
import { useWeb3 } from './Web3'
import { accountTruncate } from '@utils/web3'
import { didToAddress } from '@utils/orbis'
const OrbisContext = createContext(null)
type IOrbisProvider = {
orbis: IOrbis
account: IOrbisProfile
hasLit: boolean
openConversations: boolean
conversationId: string
conversations: IOrbisConversation[]
conversationTitle: string
unreadMessages: IOrbisNotification[]
connectOrbis: () => Promise<void>
disconnectOrbis: () => void
checkConnection: (value: boolean) => Promise<void>
connectLit: () => Promise<{
status?: number
error?: any
result?: string
}>
setOpenConversations: (value: boolean) => void
setConversationId: (value: string) => void
setConversations: (value: IOrbisConversation[]) => void
getConversations: () => Promise<void>
createConversation: (value: string) => Promise<void>
checkConversation: (value: string) => IOrbisConversation[]
getDid: (value: string) => Promise<string>
}
const OrbisContext = createContext({} as IOrbisProvider)
const orbis: IOrbis = new Orbis()
const NOTIFICATION_REFRESH_INTERVAL = 10000
const CONVERSATION_CONTEXT = 'ocean_market'
function OrbisProvider({ children }: { children: ReactNode }): ReactElement {
const { web3Provider } = useWeb3()
const [orbis, setOrbis] = useState<OrbisInterface>()
const [account, setAccount] = useState<OrbisAccountInterface>()
const [convOpen, setConvOpen] = useState(false)
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([])
const [conversationTitle, setConversationTitle] = useState(null)
const [unreadMessages, setUnreadMessages] = useState<IOrbisNotification[]>([])
// Connecting to Orbis
const connectOrbis = async (provider: object): Promise<void> => {
if (!orbis) return
const res = await orbis.connect(provider)
if (res.status !== 200) {
await sleep(2000)
connectOrbis(provider)
const connectOrbis = async () => {
const res = await orbis.connect_v2({
provider: web3Provider,
chain: 'ethereum'
})
if (res.status === 200) {
const { data } = await orbis.getProfile(res.did)
setAccount(data)
} else {
setAccount(res)
await sleep(2000)
await connectOrbis()
}
}
const checkConnection = async (): Promise<void> => {
const disconnectOrbis = () => {
const res = orbis.logout()
if (res.status === 200) {
setAccount(null)
}
}
const connectLit = async () => {
const res = await orbis.connectLit(web3Provider)
setHasLit(res.status === 200)
return res
}
const checkConnection = async (autoConnect = true) => {
const res = await orbis.isConnected()
if (res.status === 200) {
setAccount(res)
} else {
connectOrbis(web3Provider)
setHasLit(res.details.hasLit)
const { data } = await orbis.getProfile(res.did)
setAccount(data)
} else if (autoConnect) {
await connectOrbis()
}
}
// Init Orbis
useEffect(() => {
const _orbis = new Orbis()
setOrbis(_orbis)
}, [])
const getConversations = async () => {
const { data, error } = await orbis.getConversations({
did: account?.did,
context: 'ocean_market'
context: CONVERSATION_CONTEXT
})
if (data) {
console.log(data)
setConversations(data)
}
if (error) {
console.log(error)
}
if (data) {
setConversations(data)
}
}
useEffect(() => {
// Check if wallet connected
if (!account && orbis && web3Provider) {
checkConnection()
const checkConversation = (userDid: string) => {
const filtered: IOrbisConversation[] = conversations.filter(
(conversation: IOrbisConversation) => {
return conversation.recipients.includes(userDid)
}
)
return filtered
}
const createConversation = async (userDid: string) => {
if (!hasLit) {
const res = await connectLit()
if (res.status !== 200) return
}
// Fetch conversations
if (account && orbis) {
getConversations()
const convoExists = checkConversation(userDid)
if (convoExists.length > 0) {
setConversationId(convoExists[0].stream_id)
setOpenConversations(true)
} else {
const res = await orbis.createConversation({
recipients: [userDid],
context: CONVERSATION_CONTEXT
})
if (res.status === 200) {
setConversationId(res.doc)
setOpenConversations(true)
}
}
}, [account, orbis, web3Provider])
}
const getMessageNotifs = async () => {
const { data, error } = await orbis.api.rpc('orbis_f_notifications', {
user_did: account?.did || 'none',
notif_type: 'messages'
})
if (error) {
console.log(error)
setUnreadMessages([])
} else if (data.length > 0) {
const _unreads = data.filter((o: IOrbisNotification) => {
return o.status === 'new'
})
setUnreadMessages(_unreads)
}
}
const getDid = async (address: string) => {
if (!address) return null
const { data, error } = await orbis.getDids(address)
if (error) {
console.log(error)
return
}
let _did: string = null
if (data && data.length > 0) {
_did = data[0].did
}
return _did
}
useInterval(async () => {
await getMessageNotifs()
}, NOTIFICATION_REFRESH_INTERVAL)
useEffect(() => {
if (account) {
// Fetch conversations
getConversations()
} else if (web3Provider) {
// Check if wallet connected
checkConnection()
}
}, [account, web3Provider])
useEffect(() => {
if (conversationId && conversations.length) {
@ -86,7 +200,7 @@ function OrbisProvider({ children }: { children: ReactNode }): ReactElement {
)
if (conversation) {
const recipient = conversation.recipients_details.find(
(o: any) => o.did !== account.did
(o: IOrbisProfile) => o.did !== account.did
)
const address =
@ -108,15 +222,23 @@ function OrbisProvider({ children }: { children: ReactNode }): ReactElement {
value={{
orbis,
account,
connectOrbis,
setConvOpen,
convOpen,
hasLit,
openConversations,
conversationId,
setConversationId,
conversations,
conversationTitle,
unreadMessages,
connectOrbis,
disconnectOrbis,
checkConnection,
connectLit,
setOpenConversations,
setConversationId,
setConversations,
getConversations,
conversationTitle
createConversation,
checkConversation,
getDid
}}
>
{children}

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

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 232 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

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

626
src/@types/Orbis.d.ts vendored
View File

@ -1,125 +1,478 @@
// import { CeramicClient } from '@ceramicnetwork/http-client'
// import type { SupabaseClient } from '@supabase/supabase-js'
declare module '@orbisclub/orbis-sdk'
declare interface OrbisInterface {
connect: function
connectLit: function
connectWithSeed: function
createChannel: function
createContext: function
createConversation: function
createGroup: function
createPost: (options: any) => void
createTileDocument: function
decryptMessage: function
decryptPost: function
deletePost: function
deterministicDocument: function
editPost: function
getChannel: function
getConversation: function
getConversations: function
getDids: function
getGroup: function
getGroupMembers: function
getGroups: function
getIsFollowing: function
getIsGroupMember: function
getMessages: function
getNotifications: function
getPost: function
getPosts: function
getProfile: function
getProfileFollowers: function
getProfileFollowing: function
getProfileGroups: function
getProfilesByUsername: function
isConnected: function
logout: function
react: function
sendMessage: function
setFollow: function
setGroupMember: function
setNotificationsReadTime: function
testConnectSolana: function
updateChannel: function
updateContext: function
updateGroup: function
updatePost: function
updateProfile: function
updateTileDocument: function
declare interface IOrbisConstructor {
ceramic?: any
node?: any
store?: string
PINATA_GATEWAY?: string
PINATA_API_KEY?: string
PINATA_SECRET_API_KEY?: string
useLit?: boolean
}
declare interface OrbisAccountInterface {
details: object
did: string
result: string
status: number
}
declare interface OrbisPostCreatorDetailsInterface {
a_r: number
did: string
metadata: {
address: string
chain: string
ensName: string
declare interface IOrbis {
api: any
connect: (provider: any, lit?: boolean) => Promise<IOrbisConnectReturns>
connect_v2: (opts?: {
provider?: any
chain?: string
lit?: boolean
oauth?: any
}) => Promise<IOrbisConnectReturns>
connectLit: (provider: any) => Promise<{
status?: number
error?: any
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: any,
tags: string[],
schema: string,
family: string
) => Promise<{
status: number
doc?: string
error?: any
result: string
}>
decryptMessage: (content: {
conversation_id: string
encryptedMessage: IOrbisEncryptedBody
}) => Promise<any>
decryptPost: (content: IOrbisPostContent) => Promise<any>
deletePost: (stream_id: string) => Promise<{
status: number
result: string
}>
deterministicDocument: (
content: any,
tags: string[],
schema?: string,
family?: string
) => Promise<{
status: number
doc?: string
error?: any
result: string
}>
editPost: (
stream_id: string,
content: IOrbisPostContent,
encryptionRules?: IOrbisEncryptionRules
) => Promise<{
status: number
result: string
}>
getChannel: (channel_id: string) => Promise<{
data: IOrbisChannel
error: any
status: number
}>
getConversation: (conversation_id: string) => Promise<{
status: number
data: any
error: any
}>
getConversations: (opts: { did: string; context?: string }) => Promise<any>
getDids: (address: string) => Promise<{
data: any
error: any
status: number
}>
getGroup: (group_id: string) => Promise<{
data: IOrbisGroup
error: any
status: any
}>
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: any
status: number
}>
getGroups: () => Promise<{
data: IOrbisGroup[]
error: any
status: number
}>
getIsFollowing: (
did_following: string,
did_followed: string
) => Promise<{
data: boolean
error: any
status: number
}>
getIsGroupMember: (
group_id: string,
did: string
) => Promise<{
data: boolean
error: any
status: number
}>
getMessages: (
conversation_id: string,
page: number
) => Promise<{
data: IOrbisMessage[]
error: any
status: number
}>
getNotifications: (options: { type: string; context?: string }) => Promise<{
data: any
error: any
status: number
}>
getPost: (post_id: string) => Promise<{
data: IOrbisPost
error: any
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: any
status: number
}>
getReaction: (
post_id: string,
did: string
) => Promise<{
data: { type: string }
error: any
status: number
}>
getProfile: (did: string) => Promise<{
data: IOrbisProfile
error: any
status: number
}>
getProfileFollowers: (did: string) => Promise<{
data: IOrbisProfile['details'][]
error: any
status: number
}>
getProfileFollowing: (did: string) => Promise<{
data: IOrbisProfile['details'][]
error: any
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: any
status: number
}>
getProfilesByUsername: (username: string) => Promise<{
data: IOrbisProfile[]
error: any
status: number
}>
isConnected: (sessionString?: string) => Promise<IOrbisConnectReturns>
logout: () => {
status: number
result: string
error: any
}
nonces?: object
profile?: OrbisCreatorProfileInterface
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?: any
result: string
}>
setNotificationsReadTime: (
type: string,
timestamp: number,
context?: string
) => Promise<{
status: number
doc?: string
error?: any
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: any,
tags: string[],
schema: string,
family?: string
) => Promise<{
status: number
doc?: string
error?: any
result: string
}>
uploadMedia: (file: File) => Promise<{
status: number
error?: any
result: any
}>
}
declare interface OrbisPostMentionsInterface {
interface IOrbisConnectReturns {
status: number
did: string
details: any
result: string
}
declare enum IOrbisGetPostsAlgorithm {
'recommendations',
'all-posts',
'all-master-posts',
'all-did-master-posts',
'all-context-master-posts',
'all-posts-non-filtered',
''
}
declare enum OrbisReaction {
'like',
'haha',
'downvote'
}
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
}
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'
}
declare interface IOrbisProfile {
address: string
count_followers: number
count_following: number
details: {
a_r?: number
did: string
metadata?: {
address?: string
chain?: string
ensName?: string
}
nonces?: any
profile?: {
cover?: string
data?: object
description?: string
pfp?: string
pfpIsNft?: {
chain: string
contract: string
timestamp: string
tokenId: string
}
username?: string
}
twitter_details?: any
}
did: string
last_activity_timestamp: number
username: string
}
interface IOrbisEncryptionRules {
type: 'token-gated' | 'custom'
chain: string
contractType: 'ERC20' | 'ERC721' | 'ERC1155'
contractAddress: string
minTokenBalance: string
tokenId: string
accessControlConditions?: object
}
interface IOrbisEncryptedBody {
accessControlConditions: string
encryptedString: string
encryptedSymmetricKey: string
}
interface IOrbisPostMention {
did: string
username: string
}
interface OrbisPostContentInterface {
interface IOrbisPostContent {
body: string
title?: string
context?: string
master?: string
mentions?: OrbisPostMentionsInterface[]
mentions?: IOrbisPostMention[]
reply_to?: string
type?: string
encryptedMessage?: object
tags?: {
slug: string
title: string
}[]
data?: object
encryptionRules?: IOrbisEncryptionRules | null
encryptedMessage?: object | null
encryptedBody?: IOrbisEncryptedBody | null
}
interface OrbisCreatorMetadataInterface {
address?: string
chain?: string
ensName?: string
}
interface OrbisCreatorProfileInterface {
description?: string
pfp?: string
pfpIsNft?: {
chain: string
contract: string
timestamp: string
tokenId: string
}
username?: string
}
declare interface OrbisPostInterface {
content: OrbisPostContentInterface
declare interface IOrbisPost {
content: IOrbisPostContent
context?: string
context_details?: {
channel_details?: {
description: string
group_id: string
name: string
type: string
}
channel_details?: IOrbisChannel['content']
channel_id?: string
group_details?: {
description: string
name: string
pfp: string
}
group_details?: IOrbisGroup['content']
group_id?: string
}
count_commits?: number
@ -128,28 +481,59 @@ declare interface OrbisPostInterface {
count_likes?: number
count_replies?: number
creator: string
creator_details?: {
a_r?: number
did: string
metadata?: OrbisCreatorMetadataInterface
nonces?: object
profile?: OrbisCreatorProfileInterface
}
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?: {
did: string
metadata: OrbisCreatorMetadataInterface
profile: OrbisCreatorProfileInterface
}
reply_to_details?: OrbisPostContentInterface
reply_to_creator_details?: Pick<
IOrbisProfile['details'],
'did' | 'metadata' | 'profile'
>
reply_to_details?: IOrbisPostContent
stream_id: string
timestamp: number
type?: string
}
declare interface OrbisConversationInterface {
declare 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
}
declare 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
}
declare interface IOrbisConversation {
content: {
recipients: string[]
}
@ -163,18 +547,14 @@ declare interface OrbisConversationInterface {
last_message_timestamp: number
last_timestamp_read: number
recipients: string[]
recipients_details: OrbisPostCreatorDetailsInterface[]
recipients_details: IOrbisProfile['details'][]
stream_id: string
}
declare interface OrbisNotificationInterface {
declare interface IOrbisNotification {
content: {
conversation_id: string
encryptedMessage: {
accessControlConditions: string
encryptedString: string
encryptedSymmetricKey: string
}
encryptedMessage: IOrbisEncryptedBody
}
family: string
post_details: object
@ -182,6 +562,6 @@ declare interface OrbisNotificationInterface {
type: string
user_notifying_details: {
did: string
profile: OrbisCreatorProfileInterface
profile: IOrbisProfile['details']['profile']
}
}

View File

@ -12,11 +12,19 @@ export function didToAddress(did: string) {
return _did[4]
}
export function formatMessage(content: OrbisPostContentInterface): ReactNode {
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} />

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,27 @@
.masterPost {
padding: calc(var(--spacer) / 2) var(--spacer);
border-bottom: 1px solid var(--border-color);
}
.showReplies {
cursor: pointer;
color: var(--brand-pink);
font-size: var(--font-size-small);
display: inline-flex;
justify-content: flex-start;
gap: calc(var(--spacer) / 4);
margin-left: calc(
calc(var(--font-size-large) * 1.5) + calc(var(--spacer) / 2)
);
margin-top: calc(var(--spacer) / 2);
}
.showReplies svg {
fill: currentColor;
width: var(--font-size-mini);
margin-right: calc(var(--spacer) / 8);
}
.showReplies.opened svg {
transform: scaleY(-1);
}

View File

@ -0,0 +1,107 @@
import React, { useState, useRef, useEffect } from 'react'
import styles from './MasterPost.module.css'
import Post from './Post'
import Replies from './Replies'
import Caret from '@images/caret.svg'
export default function MasterPost({ post }: { post: IOrbisPost }) {
const masterPost = useRef<HTMLDivElement | null>(null)
const innerPostbox = useRef<HTMLDivElement | null>(null)
const [showReplies, setShowReplies] = useState(false)
const [replyTo, setReplyTo] = useState<IOrbisPost | null>(null)
const [scrollToEl, setScrollToEl] = useState<HTMLElement | string | null>(
null
)
const deletePost = async (post: IOrbisPost) => {
console.log('delete post:', post)
}
useEffect(() => {
if (scrollToEl !== null) {
const _scrollable: HTMLElement = masterPost.current.closest(
'.comment-scrollable'
)
if (scrollToEl === 'masterPost') {
setTimeout(() => {
const _master = masterPost.current
_scrollable.scrollTo({
top: _master.offsetTop,
behavior: 'smooth'
})
}, 500)
} else if (scrollToEl === 'postbox') {
setTimeout(() => {
const _postbox = innerPostbox.current
const halfScrollable = _scrollable.offsetHeight / 2
const targetOffset = _postbox.offsetTop - halfScrollable
_scrollable.scrollTo({
top: targetOffset,
behavior: 'smooth'
})
}, 500)
} else {
setTimeout(() => {
const _el = scrollToEl as HTMLElement
const halfScrollable = _scrollable.offsetHeight / 2
const targetOffset = _el.offsetTop - halfScrollable
_scrollable.scrollTo({
top: targetOffset,
behavior: 'smooth'
})
}, 500)
}
}
return () => {
setTimeout(() => {
setScrollToEl(null)
}, 500)
}
}, [scrollToEl])
useEffect(() => {
if (!showReplies) setReplyTo(null)
}, [showReplies])
return (
<div ref={masterPost} className={styles.masterPost}>
<Post
post={post}
onClickReply={() => {
setReplyTo(post)
setShowReplies(true)
setScrollToEl('postbox')
}}
/>
{post?.count_replies !== undefined && post?.count_replies > 0 && (
<div
className={`${styles.showReplies} ${
showReplies ? styles.opened : ''
}`}
onClick={() => {
setScrollToEl(!showReplies ? 'masterPost' : null)
setShowReplies((prev) => !prev)
}}
>
<Caret role="img" aria-label="Caret" />
{showReplies ? 'Hide' : 'View'} replies
</div>
)}
{showReplies && (
<div>
<Replies
master={post}
innerPostbox={innerPostbox}
replyTo={replyTo}
setReplyTo={(post) => {
setReplyTo(post)
if (post) setScrollToEl('postbox')
}}
onNewPost={(el: HTMLElement | null) => setScrollToEl(el)}
/>
</div>
)}
</div>
)
}

View File

@ -1,13 +1,26 @@
@keyframes pulse {
50% {
opacity: 0.5;
}
}
.post {
display: flex;
width: 100%;
padding: calc(var(--spacer) / 2) var(--spacer);
border-bottom: 1px solid var(--border-color);
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.post.deleted {
opacity: 40%;
pointer-events: none;
}
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.post:last-of-type {
border-bottom: 0;
}
@ -42,7 +55,36 @@
margin-right: calc(var(--spacer) / 2);
}
.reactions {
.body {
position: relative;
overflow-wrap: break-word;
white-space: pre-wrap;
}
.readMore {
cursor: pointer;
color: var(--brand-pink);
font-size: var(--font-size-small);
display: inline-flex;
justify-content: flex-start;
gap: calc(var(--spacer) / 4);
margin: calc(var(--spacer) / 2) auto 0;
}
.readMore svg {
fill: currentColor;
width: var(--font-size-mini);
margin-right: calc(var(--spacer) / 8);
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.reactions,
.menu {
display: flex;
justify-content: flex-start;
align-items: center;
@ -50,7 +92,9 @@
margin-top: calc(var(--spacer) / 2);
}
.reactions button {
.reactions button,
.menu button {
position: relative;
display: flex;
align-items: center;
background: transparent;
@ -61,10 +105,42 @@
}
.reactions button:hover,
.reactions button.reacted {
.reactions button.reacted,
.menu button:hover {
color: var(--brand-pink);
}
.options {
display: flex;
flex-direction: column;
gap: calc(var(--spacer) / 4);
}
.options button {
background: transparent;
border: 0;
cursor: pointer;
padding: calc(var(--spacer) / 6) calc(var(--spacer) / 3);
border-radius: var(--border-radius);
}
.postEdit {
color: var(--font-color-text);
}
.postEdit:hover {
background: var(--background-highlight);
}
.postDelete {
color: var(--brand-alert-red);
}
.postDelete:hover {
color: var(--font-color-text);
background: var(--brand-alert-red);
}
.reactions svg {
fill: currentColor;
width: var(--font-size-large);

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, ReactNode } from 'react'
import React, { useState, useMemo, useEffect, ReactNode } from 'react'
import Link from 'next/link'
import { accountTruncate } from '@utils/web3'
import { didToAddress, formatMessage } from '@utils/orbis'
@ -6,138 +6,233 @@ import Avatar from '@shared/atoms/Avatar'
import Time from '@shared/atoms/Time'
import { useOrbis } from '@context/Orbis'
import styles from './Post.module.css'
import Tooltip from '@shared/atoms/Tooltip'
import Postbox from './Postbox'
import Reply from '@images/reply.svg'
import ThumbsUp from '@images/thumbsup.svg'
import ThumbsDown from '@images/thumbsdown.svg'
import Laugh from '@images/laugh.svg'
import Ellipsis from '@images/ellipsis.svg'
import Caret from '@images/caret.svg'
type Reactions = Pick<
IOrbisPost,
'count_likes' | 'count_downvotes' | 'count_haha'
>
export default function Post({
post,
showProfile = true
onClickReply
}: {
post: OrbisPostInterface
showProfile?: boolean
post: IOrbisPost
onClickReply: (value: IOrbisPost | boolean) => void
}) {
const { orbis, account } = useOrbis()
const [address, setAddress] = useState('')
const [name, setName] = useState('')
const [postClone, setPostClone] = useState<IOrbisPost>({ ...post })
const [parsedBody, setParsedBody] = useState<ReactNode>()
const [reacted, setReacted] = useState('')
const [reacted, setReacted] = useState<string>('')
const [isEditing, setIsEditing] = useState(false)
const [isDeleted, setIsDeleted] = useState<number>(0)
const [hideOverflow, setHideOverflow] = useState<boolean>(
postClone.content.body.length >= 285
)
const reactions = [
// {
// ctx: 'reply',
// countKey: 'count_replies',
// count: post.count_replies,
// title: 'Reply',
// icon:
// },
{
ctx: 'like',
countKey: 'count_likes',
count: post.count_likes,
title: 'Like',
icon: <ThumbsUp role="img" aria-label="Like" />
},
{
ctx: 'downvote',
countKey: 'count_downvotes',
count: post.count_downvotes,
title: 'Downvote',
icon: <ThumbsDown role="img" aria-label="Downvote" />
},
{
ctx: 'haha',
countKey: 'count_haha',
count: post.count_haha,
title: 'HA HA',
icon: <Laugh role="img" aria-label="Downvote" />
}
]
const getUserReaction = async () => {
if (!account?.did || !post?.stream_id) return
const { data, error } = await orbis.api
.from('orbis_reactions')
.select('type')
.eq('post_id', post.stream_id)
.eq('creator', account.did)
if (error) {
console.log(error)
}
if (data) {
if (data.length > 0) {
console.log(data[0].type, post.content.body)
setReacted(data[0].type)
const reactions = useMemo(() => {
const items = [
{
type: 'like',
count: postClone.count_likes,
title: 'Like',
icon: <ThumbsUp role="img" aria-label="Like" />
},
{
type: 'downvote',
count: postClone.count_downvotes,
title: 'Downvote',
icon: <ThumbsDown role="img" aria-label="Downvote" />
},
{
type: 'haha',
count: postClone.count_haha,
title: 'HA HA!',
icon: <Laugh role="img" aria-label="Downvote" />
}
]
return items
}, [postClone])
const handleReaction = async (type: string) => {
// Quick return if already reacted
if (type === reacted) return
// Optimistically increase reaction count
setReacted(type)
const _post: IOrbisPost = { ...postClone }
const keys = {
like: 'count_likes' as keyof Reactions,
haha: 'count_haha' as keyof Reactions,
downvote: 'count_downvotes' as keyof Reactions
}
// Decrease old reaction
if (reacted) {
_post[keys[reacted as keyof typeof keys]] -= 1
}
// Increase new reaction
_post[keys[type as keyof typeof keys]] += 1
setPostClone({ ..._post })
const res = await orbis.react(postClone.stream_id, type)
// Revert back if failed
if (res.status !== 200) {
setPostClone({ ...postClone })
}
}
const handleReaction = (type: string) => {
console.log(type)
const callbackEdit = async (content: IOrbisPostContent) => {
setPostClone({
...postClone,
content,
count_commits: postClone.count_commits + 1
})
setIsEditing(false)
}
const handleDelete = async () => {
if (
confirm(
'Are you sure you want to delete this post?\r\nIf you ask for deletion your post might be removed from the Ceramic nodes hosting it.'
)
) {
setIsDeleted(1)
await orbis.deletePost(postClone.stream_id)
setIsDeleted(2)
}
}
useEffect(() => {
if (!post) return
const address =
post?.creator_details?.metadata?.address ||
didToAddress(post?.creator_details?.did)
setAddress(address)
if (post?.creator_details?.metadata?.ensName) {
setName(post?.creator_details?.metadata?.ensName)
} else if (post?.creator_details?.profile?.username) {
setName(post?.creator_details?.profile?.username)
} else {
setName(accountTruncate(address))
const getUserReaction = async () => {
if (!account) return
const { data } = await orbis.getReaction(postClone.stream_id, account.did)
if (data) setReacted(data.type)
}
setParsedBody(formatMessage(post.content))
if (postClone) {
const address =
postClone?.creator_details?.metadata?.address ||
didToAddress(postClone?.creator_details?.did)
getUserReaction()
}, [post])
setAddress(address)
setParsedBody(formatMessage(postClone.content, hideOverflow))
useEffect(() => {
if (orbis && account) getUserReaction()
}, [orbis, account])
if (account) getUserReaction()
}
}, [postClone, account, orbis, hideOverflow])
return (
<div className={styles.post}>
<div
className={`${styles.post} ${postClone.stream_id} ${
postClone.stream_id.startsWith('new_post-') || isDeleted === 1
? styles.pulse
: ''
} ${isDeleted === 2 ? styles.deleted : ''}`}
>
<Avatar accountId={address} className={styles.blockies} />
<div className={styles.content}>
{showProfile && (
<div className={styles.profile}>
<Link href={`/profile/${address}`}>
<a className={styles.name}>{name}</a>
</Link>
<span>&bull;</span>
<div className={styles.metadata}>{accountTruncate(address)}</div>
<span>&bull;</span>
<div className={styles.metadata}>
<Time date={post.timestamp.toString()} isUnix={true} relative />
</div>
<div className={styles.profile}>
<Link href={`/profile/${address}`}>
<a className={styles.name}>{accountTruncate(address)}</a>
</Link>
<span>&bull;</span>
<div className={styles.metadata}>
{postClone?.creator_details?.metadata?.ensName ||
accountTruncate(address)}
</div>
<span>&bull;</span>
<div className={styles.metadata}>
<Time
date={post.timestamp.toString()}
isUnix={true}
displayFormat="Pp"
/>
</div>
</div>
{isDeleted === 2 ? (
<div className={styles.body}>-- This post is deleted -- </div>
) : !isEditing ? (
<div className={styles.body}>{parsedBody}</div>
) : (
<Postbox
context={postClone?.context}
editPost={postClone}
callback={callbackEdit}
/>
)}
{hideOverflow && (
<div
className={styles.readMore}
onClick={() => {
setHideOverflow(false)
}}
>
<Caret role="img" aria-label="Caret" />
Read more
</div>
)}
{isDeleted !== 2 && (
<div className={styles.footer}>
<div className={styles.reactions}>
<button title="Reply" onClick={() => onClickReply(post)}>
<Reply role="img" aria-label="Reply" />
<span>Reply</span>
</button>
{reactions.map(({ type, count, icon, title }) => (
<button
key={type}
onClick={() => handleReaction(type)}
title={title}
className={type === reacted ? styles.reacted : ''}
>
{icon}
<span>{count}</span>
</button>
))}
</div>
{postClone.creator_details.did === account?.did && (
<div className={styles.menu}>
<Tooltip
content={
<div className={styles.options}>
<button
className={styles.postEdit}
onClick={() => setIsEditing(true)}
>
Edit post
</button>
<button
className={styles.postDelete}
onClick={handleDelete}
>
Delete post
</button>
</div>
}
zIndex={21}
placement={'top'}
>
<button
title="Options"
onClick={() => console.log('clicked')}
>
<Ellipsis role="img" aria-label="Options" />
</button>
</Tooltip>
</div>
)}
</div>
)}
<div className={styles.message}>{parsedBody}</div>
<div className={styles.reactions}>
{reactions.map(({ ctx, count, icon, title }) => (
<button
key={ctx}
onClick={() => handleReaction(ctx)}
title={title}
className={ctx === reacted && styles.reacted}
>
{icon}
<span>{count}</span>
</button>
))}
</div>
</div>
</div>
)

View File

@ -1,13 +1,27 @@
.postbox {
width: 100%;
flex-shrink: 0;
}
.postbox .postboxInput {
position: relative;
display: flex;
align-items: flex-end;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
padding: calc(var(--spacer) / 4);
margin-bottom: calc(var(--spacer) / 4);
}
.postbox .editable {
padding: calc(var(--spacer) / 4);
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
min-height: 80px;
margin-bottom: calc(var(--spacer) / 4);
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;
}
.postbox .editable:empty:before {
@ -16,10 +30,40 @@
pointer-events: none;
}
.postbox .editable::-webkit-scrollbar {
display: none;
}
.postbox .sendButtonWrap {
text-align: right;
}
.postbox .postboxInput {
.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;
}

View File

@ -1,29 +1,41 @@
import React, { useRef, useState } from 'react'
import Button from '@shared/atoms/Button'
import React, { useRef, useState, KeyboardEvent } from 'react'
import styles from './Postbox.module.css'
import { useOrbis } from '@context/Orbis'
import EmojiPicker from '../EmojiPicker'
import { EmojiClickData } from 'emoji-picker-react'
import { useOrbis } from '@context/Orbis'
import Button from '@shared/atoms/Button'
import EmojiPicker from '../EmojiPicker'
import { accountTruncate } from '@utils/web3'
import { didToAddress } from '@utils/orbis'
export default function Postbox({
assetId,
context,
placeholder = 'Share your post here...',
callbackPost
replyTo = null,
editPost = null,
enterToSend = false,
cancelReplyTo,
callback
}: {
placeholder: string
assetId: string
callbackPost: (post: OrbisPostInterface) => void
context: string
placeholder?: string
replyTo?: IOrbisPost
editPost?: IOrbisPost
enterToSend?: boolean
cancelReplyTo?: () => void
callback: (value: any) => void
}) {
const [post, setPost] = useState('')
const [focusOffset, setFocusOffset] = useState(null)
const [focusNode, setFocusNode] = useState(null)
const [focusOffset, setFocusOffset] = useState<number | undefined>()
const [focusNode, setFocusNode] = useState<Node | undefined>()
const postBoxArea = useRef(null)
const { orbis, account } = useOrbis()
const saveCaretPos = (_sel: { focusOffset: number; focusNode: Node }) => {
setFocusOffset(_sel.focusOffset)
setFocusNode(_sel.focusNode)
const saveCaretPos = () => {
const sel = document.getSelection()
if (sel) {
setFocusOffset(sel.focusOffset)
setFocusNode(sel.focusNode)
}
}
const restoreCaretPos = () => {
@ -32,88 +44,127 @@ export default function Postbox({
sel.collapse(focusNode, focusOffset)
}
const handleInput = (e: any) => {
setPost(e.currentTarget.innerText)
saveCaretPos(document.getSelection())
const share = async () => {
if (!account) return false
const body = postBoxArea.current.innerText
// Cleaning up mentions
// const _mentions = mentions.filter((o) => body.includes(o.username))
if (editPost) {
const newContent = { ...editPost.content, body }
if (callback) callback(newContent)
await orbis.editPost(editPost.stream_id, newContent)
} else {
const content = {
body,
context,
master: replyTo ? replyTo.master || replyTo.stream_id : undefined,
reply_to: replyTo ? replyTo.stream_id : undefined
// mentions: _mentions || undefined
}
const timestamp = Math.floor(Date.now() / 1000)
const _callbackContent = {
content,
context,
creator: account.did,
creator_details: {
did: account.did,
profile: account.details?.profile,
metadata: account.details?.metadata
},
stream_id: 'new_post-' + timestamp,
timestamp,
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,
count_commits: 1,
count_likes: 0,
count_haha: 0,
count_downvotes: 0,
count_replies: 0,
type: replyTo ? 'reply' : null
}
console.log(_callbackContent)
if (callback) callback(_callbackContent)
postBoxArea.current.innerText = ''
const res = await orbis.createPost(content)
if (res.status === 200) {
_callbackContent.stream_id = res.doc
if (callback) callback(_callbackContent)
}
}
}
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!e.key) return
if (enterToSend && e.key === 'Enter' && !e.shiftKey) {
// Don't generate a new line
e.preventDefault()
share()
}
}
const onEmojiClick = (emojiData: EmojiClickData) => {
restoreCaretPos()
document.execCommand('insertHTML', false, emojiData.emoji)
// setPost((prevInput) => prevInput + emojiData.emoji)
// postBoxArea.current.innerText += emojiData.emoji
}
const createPost = async () => {
// console.log(post)
const _callbackContent: OrbisPostInterface = {
creator: account.did,
creator_details: {
did: account.did,
profile: account.profile
},
stream_id: null,
content: {
body: post
},
timestamp: Math.floor(Date.now() / 1000),
count_replies: 0,
count_likes: 0,
count_downvotes: 0,
count_haha: 0
}
console.log(_callbackContent)
callbackPost(_callbackContent)
const res = await orbis.createPost({ body: post, context: assetId })
if (res.status === 200) {
console.log('success with,', res)
setPost(null)
if (postBoxArea.current) {
postBoxArea.current.textContent = ''
postBoxArea.current.focus()
}
setTimeout(async () => {
const { data, error } = await orbis.getPost(res.doc)
console.log(data)
if (data) {
callbackPost(data)
}
if (error) {
console.log(error)
}
}, 2000)
if (focusOffset === undefined || focusNode === undefined) {
postBoxArea.current.innerText += emojiData.emoji
} else {
restoreCaretPos()
document.execCommand('insertHTML', false, emojiData.emoji)
}
}
return (
<>
<div className={styles.postbox}>
<div className={styles.postboxInput}>
<div
id="postbox-area"
ref={postBoxArea}
className={styles.editable}
contentEditable={true}
data-placeholder={placeholder}
onInput={handleInput}
/>
<EmojiPicker onEmojiClick={onEmojiClick} />
</div>
<div className={styles.sendButtonWrap}>
<Button
style="primary"
type="submit"
size="small"
disabled={false}
onClick={createPost}
>
Send
</Button>
<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>
<br />
{replyTo.content?.body}
</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={placeholder}
onKeyDown={handleKeyDown}
onKeyUp={saveCaretPos}
onMouseUp={saveCaretPos}
/>
<EmojiPicker onEmojiClick={onEmojiClick} />
</div>
</>
<div className={styles.sendButtonWrap}>
<Button
style="primary"
type="submit"
size="small"
disabled={false}
onClick={share}
>
Send
</Button>
</div>
</div>
)
}

View File

@ -1,26 +1,26 @@
import React from 'react'
import Loader from '@shared/atoms/Loader'
import Button from '@shared/atoms/Button'
import Post from './Post'
import MasterPost from './MasterPost'
import styles from './Posts.module.css'
export default function Posts({
context,
posts,
loadPosts,
fetchPosts,
hasMore,
loading
}: {
posts: OrbisPostInterface[]
loadPosts: () => void
context: string
posts: IOrbisPost[]
fetchPosts: () => Promise<void>
hasMore: boolean
loading: boolean
}) {
return (
<div className={styles.posts}>
<div>
{posts.length > 0 &&
posts.map((post, index) => <Post key={index} post={post} />)}
</div>
{posts.length > 0 &&
posts.map((post, index) => <MasterPost key={index} post={post} />)}
{loading ? (
<div className={styles.loader}>
@ -34,7 +34,7 @@ export default function Posts({
{!loading && hasMore && (
<div className={styles.loadMore}>
<Button style="text" size="small" onClick={loadPosts}>
<Button style="text" size="small" onClick={fetchPosts}>
Load More
</Button>
</div>

View File

@ -0,0 +1,19 @@
.replies {
border-left: 1px solid var(--border-color);
margin-left: calc(var(--spacer) / 2);
padding-left: calc(var(--spacer) / 2);
}
.replies:first-child {
margin-top: calc(var(--spacer) / 2);
}
.repliesItem {
margin-top: calc(var(--spacer) / 2);
margin-bottom: calc(var(--spacer) / 2);
}
.repliesPostbox {
margin-left: calc(var(--spacer) / 2);
margin-top: calc(var(--spacer) / 2);
}

View File

@ -0,0 +1,136 @@
import React, { useState, useEffect, useMemo, useRef, LegacyRef } from 'react'
import styles from './Replies.module.css'
import { useOrbis } from '@context/Orbis'
import Post from './Post'
import Postbox from './Postbox'
const RepliesGroup = ({
items,
replies,
setReplyTo
}: {
items: IOrbisPost[]
replies: Record<string, IOrbisPost[]>
setReplyTo: (value: IOrbisPost | boolean) => void
}) => {
return (
<>
{items.map((item) => (
<div key={item.stream_id} className={styles.replies}>
<div className={styles.repliesItem}>
<Post post={item} onClickReply={() => setReplyTo(item)} />
</div>
{replies[item.stream_id] !== undefined && (
<RepliesGroup
items={replies[item.stream_id]}
replies={replies}
setReplyTo={setReplyTo}
/>
)}
</div>
))}
</>
)
}
const Replies = ({
master,
replyTo,
innerPostbox,
setReplyTo,
onNewPost
}: {
master: IOrbisPost
replyTo: IOrbisPost
innerPostbox: LegacyRef<HTMLDivElement> | null
setReplyTo: (value: IOrbisPost) => void
onNewPost: (el: HTMLElement | null) => void
}) => {
const { orbis } = useOrbis()
const mainGroup = useRef<HTMLDivElement | null>(null)
const [posts, setPosts] = useState<IOrbisPost[]>([])
const [currentPage, setCurrentPage] = useState<number>(0)
const [isLoading, setIsLoading] = useState<boolean>(false)
// const [hasMore, setHasMore] = useState<boolean>(false)
const repliesGroups = useMemo(() => {
const grouped = posts.reduce((result, a) => {
result[a.reply_to] = [...(result[a.reply_to] || []), a]
return result
}, {} as Record<string, IOrbisPost[]>)
return grouped
}, [posts])
const getPosts = async () => {
if (!master || isLoading) return
setIsLoading(true)
const { data, error } = await orbis.getPosts(
{
context: master.context,
master: master.stream_id
},
currentPage
)
if (data) {
data.reverse()
setPosts([...posts, ...data])
setCurrentPage((prev) => prev + 1)
// setHasMore(data.length >= 50)
}
if (error) {
console.error(error)
}
setIsLoading(false)
}
const callback = (newPost: IOrbisPost) => {
const _posts = [...posts, newPost]
setPosts(_posts)
setReplyTo(null)
// Try scroll to newly created post
if (newPost.stream_id.startsWith('new_post-')) {
setTimeout(() => {
const el: HTMLElement = mainGroup.current?.querySelector(
`.${newPost.stream_id}`
)
onNewPost(el)
}, 100)
}
}
useEffect(() => {
if (master) getPosts()
}, [master])
return (
<div ref={mainGroup}>
{repliesGroups[master.stream_id] !== undefined && (
<RepliesGroup
items={repliesGroups[master.stream_id]}
replies={repliesGroups}
setReplyTo={setReplyTo}
/>
)}
{replyTo && (
<div ref={innerPostbox} className={styles.repliesPostbox}>
<Postbox
context={master.context}
replyTo={replyTo}
placeholder="Reply this comment..."
cancelReplyTo={() => setReplyTo(null)}
callback={callback}
/>
</div>
)}
</div>
)
}
export default Replies

View File

@ -5,7 +5,7 @@
position: relative;
display: flex;
flex-direction: column;
max-height: 620px;
max-height: 720px;
}
.header {
@ -33,5 +33,6 @@
}
.content {
position: relative;
overflow-y: auto;
}

View File

@ -5,20 +5,24 @@ import Postbox from './Postbox'
import CommentIcon from '@images/comment.svg'
import { useOrbis } from '@context/Orbis'
export default function Comment({ asset }: { asset: AssetExtended }) {
export default function Comment({ context }: { context: string }) {
const { orbis } = useOrbis()
const [posts, setPosts] = useState<OrbisPostInterface[]>([])
const [posts, setPosts] = useState<IOrbisPost[]>([])
const [page, setPage] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(false)
const loadPosts = async () => {
const _context =
process.env.NODE_ENV === 'development'
? 'kjzl6cwe1jw145gun3sei0a4puw586yxa614le1tfh434y7quv2wsm0ivhbge7x'
: context
const fetchPosts = async () => {
setLoading(true)
// const context =
// process.env.NODE_ENV === 'development'
// ? 'kjzl6cwe1jw149vvm1f8p9qlohhtkjuc302f22mipq95q7mevdljgx3tv9swujy'
// : asset?.id
const { data, error } = await orbis.getPosts({ context: asset?.id }, page)
const { data, error } = await orbis.getPosts(
{ context: _context, algorithm: 'all-context-master-posts' },
page
)
if (error) {
console.log(error)
}
@ -35,12 +39,12 @@ export default function Comment({ asset }: { asset: AssetExtended }) {
}
useEffect(() => {
if (asset?.id && !posts.length && orbis) {
loadPosts()
if (context && !posts.length && orbis) {
fetchPosts()
}
}, [asset, posts, orbis])
}, [context, posts, orbis])
const callbackPost = (nPost: OrbisPostInterface) => {
const callback = (nPost: IOrbisPost) => {
// console.log(nPost)
if (nPost.stream_id) {
// Search and replace
@ -59,10 +63,6 @@ export default function Comment({ asset }: { asset: AssetExtended }) {
}
}
useEffect(() => {
console.log(posts)
}, [posts])
return (
<div className={styles.comment}>
<div className={styles.header}>
@ -71,16 +71,17 @@ export default function Comment({ asset }: { asset: AssetExtended }) {
</div>
<div className={styles.postBox}>
<Postbox
callbackPost={callbackPost}
assetId={asset?.id}
callback={callback}
context={_context}
placeholder="Share your comment here..."
/>
</div>
<div className={styles.content}>
<div className={`${styles.content} comment-scrollable`}>
<Posts
context={_context}
posts={posts}
loading={loading}
loadPosts={loadPosts}
fetchPosts={fetchPosts}
hasMore={hasMore}
/>
</div>

View File

@ -1,61 +0,0 @@
.chatToolbar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
width: 100%;
background-color: var(--background-content);
padding: calc(var(--spacer) / 2);
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}
.chatMessage {
position: relative;
display: flex;
justify-content: space-between;
gap: calc(var(--spacer) / 2);
}
.chatMessage div {
width: 100%;
margin-bottom: 0;
}
.chatMessage label {
margin: 0;
}
.button {
right: calc(var(--spacer) / 4);
background-color: var(--background-content);
border: none;
cursor: pointer;
color: var(--font-color-text);
font-size: var(--font-size-title);
transition: color 200ms ease;
}
.button:hover,
.button:focus {
color: var(--color-primary);
}
.buttonIcon {
fill: currentColor;
}
.inputWrap {
position: relative;
}
.inputWrap input {
transition: none;
padding-right: calc(var(--spacer) * 1.3);
}
.emojiPicker {
position: absolute;
bottom: calc(var(--spacer) * 1.7);
left: calc(var(--spacer) / 2);
}

View File

@ -1,68 +0,0 @@
import React, { useState } from 'react'
import styles from './ChatToolbar.module.css'
import SendIcon from '@images/send.svg'
import Input from '@shared/FormInput'
import { useOrbis } from '@context/Orbis'
import EmojiPicker from '../EmojiPicker'
import { EmojiClickData } from 'emoji-picker-react'
export default function ChatToolbar({
callbackMessage
}: {
callbackMessage: (post: OrbisPostInterface) => void
}) {
const { orbis, conversationId, account } = useOrbis()
const [content, setContent] = useState<string>('')
const onEmojiClick = (emojiData: EmojiClickData) => {
setContent((prevInput) => prevInput + emojiData.emoji)
}
const sendMessage = async () => {
const _callbackMessage: OrbisPostInterface = {
creator: account.did,
creator_details: {
did: account.did,
profile: account.profile
},
stream_id: null,
content: {
body: content
},
timestamp: Date.now()
}
callbackMessage(_callbackMessage)
setContent('')
const res = await orbis.sendMessage({
conversation_id: conversationId,
body: content
})
if (res.status === 200) {
console.log('succes send message with,', res)
setContent('')
}
}
return (
<div className={styles.chatToolbar}>
<div className={styles.chatMessage}>
<div className={styles.inputWrap}>
<Input
type="input"
name="message"
size="small"
placeholder="Type Message"
value={content}
onChange={(e: any) => setContent(e.target.value)}
/>
<EmojiPicker onEmojiClick={onEmojiClick} />
</div>
<button className={styles.button} onClick={sendMessage}>
<SendIcon className={styles.buttonIcon} />
</button>
</div>
</div>
)
}

View File

@ -1,9 +1,61 @@
.conversationBox {
padding: calc(var(--spacer) / 2);
height: calc(100% - (var(--spacer) * 3.5));
.conversation {
height: 100%;
display: flex;
flex-direction: column;
}
.loading {
padding: calc(var(--spacer) / 4);
text-align: center;
color: var(--color-secondary);
-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;
}
.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);
}
.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 {
@ -59,3 +111,15 @@
.message:not(.showTime).left + .message.left .chatBubble {
border-top-left-radius: 0;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View File

@ -1,17 +1,69 @@
import React, { useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { useOrbis } from '@context/Orbis'
import { useIsMounted } from '@hooks/useIsMounted'
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'
export default function DmConversation({
messages
}: {
messages: OrbisPostInterface[]
}) {
const { account } = useOrbis()
export default function DmConversation() {
const { orbis, account, conversationId, hasLit, connectLit } = useOrbis()
const isMounted = useIsMounted()
const conversationBox = useRef(null)
const messagesWrapper = useRef(null)
const [isLoading, setIsLoading] = useState(false)
const [messages, setMessages] = useState<IOrbisMessage[]>([])
const [currentPage, setCurrentPage] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [newMessages, setNewMessages] = useState(0)
const scrollToBottom = (smooth = false) => {
setTimeout(() => {
messagesWrapper.current.scrollTo({
top: messagesWrapper.current.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
})
}, 300)
}
const getMessages = async (polling = false) => {
if (isLoading) return
if (!polling) setIsLoading(true)
const _page = polling ? 0 : currentPage
const { data, error } = await orbis.getMessages(conversationId, _page)
if (error) {
console.log(error)
}
if (data) {
data.reverse()
if (!polling) {
setHasMore(data.length >= 50)
const _messages = [...data, ...messages]
setMessages(_messages)
if (currentPage === 0) scrollToBottom()
setCurrentPage((prev) => prev + 1)
} else {
const unique = data.filter(
(a) => !messages.some((b) => a.stream_id === b.stream_id)
)
setNewMessages(unique.length)
setMessages([...messages, ...unique])
}
}
setIsLoading(false)
}
useInterval(async () => {
await getMessages(true)
}, 7000)
const showTime = (index: number): boolean => {
const nextMessage = messages[index + 1]
@ -21,43 +73,104 @@ export default function DmConversation({
return nextMessage.timestamp - messages[index].timestamp > 60
}
useEffect(() => {
console.log(messages)
if (messages.length && conversationBox) {
setTimeout(() => {
console.log(
conversationBox.current.scrollTop,
conversationBox.current.scrollHeight
)
conversationBox.current.scrollTop = conversationBox.current.scrollHeight
}, 100)
const callback = (nMessage: IOrbisMessage) => {
const _messages = [...messages, nMessage]
setMessages(_messages)
scrollToBottom()
}
const onScrollMessages = throttle(() => {
const el = messagesWrapper.current
if (hasMore && el.scrollTop === 0) {
getMessages()
}
}, [messages, conversationBox])
if (
Math.ceil(el.scrollTop) >= Math.floor(el.scrollHeight - el.offsetHeight)
) {
setNewMessages(0)
}
}, 1000)
useEffect(() => {
if (isMounted) {
if (conversationId && orbis) {
getMessages()
} else {
setMessages([])
}
}
}, [conversationId, orbis, isMounted])
useEffect(() => {
const el = messagesWrapper.current
el.addEventListener('scroll', onScrollMessages)
return () => {
el.removeEventListener('scroll', onScrollMessages)
}
}, [messagesWrapper])
return (
<div ref={conversationBox} className={styles.conversationBox}>
{messages.map((message, index) => (
<div
key={index}
className={`${styles.message} ${
account?.did === message.creator_details.did
? styles.right
: styles.left
} ${showTime(index) && styles.showTime}`}
>
<div className={styles.chatBubble}>
<DecryptedMessage content={message.content} />
</div>
<div className={styles.time}>
<Time
date={message.timestamp.toString()}
isUnix={true}
relative={false}
displayFormat="MMM d, yyyy, h:mm aa"
/>
</div>
<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}>
<div ref={messagesWrapper} className={styles.scrollContent}>
{messages.map((message, index) => (
<div
key={index}
className={`${styles.message} ${
account?.did === message.creator_details.did
? styles.right
: styles.left
} ${showTime(index) && styles.showTime}`}
>
<div className={styles.chatBubble}>
<DecryptedMessage content={message.content} />
</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 conversationId={conversationId} callback={callback} />
</>
)}
</div>
)
}

View File

@ -5,28 +5,30 @@ import styles from './DecryptedMessage.module.css'
export default function DecryptedMessage({
content
}: {
content: OrbisPostContentInterface
content: IOrbisMessageContent & { decryptedMessage?: string }
}) {
const { orbis } = useOrbis()
const [loading, setLoading] = useState(true)
const [decrypted, setDecrypted] = useState(null)
useEffect(() => {
const decryptMessage = async (content: OrbisPostContentInterface) => {
if (!content?.encryptedMessage) {
setLoading(false)
setDecrypted(content.body)
return
}
const decryptMessage = async () => {
setLoading(true)
const res = await orbis.decryptMessage(content)
setDecrypted(res.result)
if (content?.decryptedMessage) {
setDecrypted(content?.decryptedMessage)
} else {
const res = await orbis.decryptMessage({
conversation_id: content?.conversation_id,
encryptedMessage: content?.encryptedMessage
})
setDecrypted(res.result)
}
setLoading(false)
}
if (content && orbis) {
console.log(content)
decryptMessage(content)
decryptMessage()
}
}, [content, orbis])

View File

@ -0,0 +1,66 @@
.header {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
padding: calc(0.35 * var(--spacer)) 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);
}
.icon {
margin-left: 0;
width: 1.5rem;
margin-right: calc(0.5 * var(--spacer));
fill: var(--font-color-text);
}
.back {
margin-left: 0;
width: 1rem;
margin-right: calc(0.5 * var(--spacer));
fill: var(--font-color-text);
transform: rotate(180deg);
}
.btnBack {
border: none;
background-color: transparent;
padding: 0;
margin: 0;
cursor: pointer;
}
.toggle {
cursor: pointer;
margin-left: auto;
background: transparent;
border: none;
color: var(--font-color-text);
width: 32px;
height: 32px;
}
.toggle .icon {
fill: transparent;
}
.isFlipped {
transform: scaleY(-1);
}
.notificationCount {
background-color: var(--color-primary);
width: 20px;
height: 20px;
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,58 @@
import React from 'react'
import styles from './Header.module.css'
import { useOrbis } from '@context/Orbis'
import ChatBubble from '@images/chatbubble.svg'
import ArrowBack from '@images/arrow.svg'
import ChevronUp from '@images/chevronup.svg'
export default function Header() {
const {
// unreadMessages,
conversationId,
openConversations,
conversationTitle,
setOpenConversations,
setConversationId
} = useOrbis()
return (
<div className={styles.header}>
{!conversationId ? (
<>
<ChatBubble role="img" aria-label="Chat" className={styles.icon} />
<span>Direct Messages</span>
{/* {unreadMessages.length > 0 && (
<span className={styles.notificationCount}>
{unreadMessages.length}
</span>
)} */}
</>
) : (
<>
<button
type="button"
aria-label="button"
className={styles.btnBack}
onClick={() => setConversationId(null)}
>
<ArrowBack role="img" aria-label="arrow" className={styles.back} />
</button>
<span>{conversationTitle}</span>
</>
)}
<button
type="button"
className={styles.toggle}
onClick={() => setOpenConversations(!openConversations)}
>
<ChevronUp
role="img"
aria-label="Toggle"
className={`${styles.icon} ${
openConversations ? styles.isFlipped : ''
}`}
/>
</button>
</div>
)
}

View File

@ -0,0 +1,20 @@
.conversations {
height: 100%;
background-color: var(--background-content);
overflow-y: auto;
}
.conversations .empty {
height: 100%;
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) / 4);
}

View File

@ -0,0 +1,36 @@
import React from 'react'
import { useOrbis } from '@context/Orbis'
import ListItem from './ListItem'
import ChatBubble from '@images/chatbubble.svg'
import styles from './List.module.css'
export default function List() {
const { conversations, unreadMessages, setConversationId } = useOrbis()
const getConversationUnreads = (conversationId: string) => {
const _unreads = unreadMessages.filter(
(o) => o.content.conversation_id === conversationId
)
return _unreads.length
}
return (
<div className={styles.conversations}>
{conversations.length > 0 ? (
conversations.map((conversation: IOrbisConversation, index: number) => (
<ListItem
key={index}
conversation={conversation}
unreads={getConversationUnreads(conversation.stream_id)}
setConversationId={setConversationId}
/>
))
) : (
<div className={styles.empty}>
<ChatBubble role="img" aria-label="Chat" className={styles.icon} />
<span>No conversation yet...</span>
</div>
)}
</div>
)
}

View File

@ -1,4 +1,4 @@
.conversationItem {
.conversation {
display: flex;
align-items: center;
padding: calc(var(--spacer) / 2) var(--spacer);
@ -6,17 +6,15 @@
cursor: pointer;
}
.conversation:hover {
background: var(--background-highlight);
}
.accountAvatarSet {
position: relative;
flex-shrink: 0;
}
.accountAvatarSet .notificationCount {
position: absolute;
bottom: -4px;
right: -4px;
}
.accountAvatar {
aspect-ratio: 1/1;
border-radius: 100%;
@ -28,6 +26,9 @@
}
.notificationCount {
position: absolute;
bottom: -4px;
right: -4px;
background-color: var(--color-primary);
width: 20px;
height: 20px;
@ -44,22 +45,14 @@
}
.accountInfo {
-webkit-box-flex: 1;
flex: 1;
padding-left: calc(var(--spacer) / 2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.accountHeading {
display: flex;
flex-grow: 1;
justify-content: space-between;
align-items: center;
}
.accountName {
margin-bottom: calc(var(--spacer) / 6);
font-size: var(--font-size-medium);
color: var(--color-primary);
}
@ -70,12 +63,3 @@
min-width: calc(var(--spacer) * 1.5);
text-align: right;
}
.accountChat {
font-size: var(--font-size-small);
color: var(--color-secondary);
margin-bottom: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -5,14 +5,14 @@ import { accountTruncate } from '@utils/web3'
import { didToAddress } from '@utils/orbis'
import Avatar from '@shared/atoms/Avatar'
import Time from '@shared/atoms/Time'
import styles from './ConversationItem.module.css'
import styles from './ListItem.module.css'
export default function ConversationItem({
conversation,
unreads,
setConversationId
}: {
conversation: OrbisConversationInterface
conversation: IOrbisConversation
unreads: number
setConversationId: (value: string) => void
}) {
@ -48,7 +48,7 @@ export default function ConversationItem({
return (
<div
className={styles.conversationItem}
className={styles.conversation}
onClick={() => setConversationId(conversation.stream_id)}
>
<div className={styles.accountAvatarSet}>
@ -58,17 +58,15 @@ export default function ConversationItem({
)}
</div>
<div className={styles.accountInfo}>
<div className={styles.accountHeading}>
<h3 className={styles.accountName}>{name}</h3>
<span className={styles.lastMessageDate}>
<Time
date={conversation.last_message_timestamp.toString()}
isUnix={true}
relative
/>
</span>
</div>
{/* <p className={styles.accountChat}>{conversation.chat}</p> */}
<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,66 @@
.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);
padding: calc(var(--spacer) / 4);
}
.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;
}
.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;
}

View File

@ -0,0 +1,135 @@
import React, { useRef, useState, KeyboardEvent } from 'react'
import styles from './Postbox.module.css'
import { EmojiClickData } from 'emoji-picker-react'
import { useOrbis } from '@context/Orbis'
import EmojiPicker from '../EmojiPicker'
import { accountTruncate } from '@utils/web3'
import { didToAddress } from '@utils/orbis'
export default function Postbox({
conversationId,
replyTo = null,
cancelReplyTo,
callback
}: {
conversationId: string
replyTo?: IOrbisMessage
cancelReplyTo?: () => void
callback: (value: any) => void
}) {
const [focusOffset, setFocusOffset] = useState<number | undefined>()
const [focusNode, setFocusNode] = useState<Node | undefined>()
const postBoxArea = useRef(null)
const { orbis, account } = useOrbis()
const saveCaretPos = () => {
const sel = document.getSelection()
if (sel) {
setFocusOffset(sel.focusOffset)
setFocusNode(sel.focusNode)
}
}
const restoreCaretPos = () => {
postBoxArea.current.focus()
const sel = document.getSelection()
sel.collapse(focusNode, focusOffset)
}
const share = async () => {
if (!account) return false
const body = 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 _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: Math.floor(Date.now() / 1000)
}
console.log(_callbackContent)
if (callback) callback(_callbackContent)
postBoxArea.current.innerText = ''
const res = await orbis.sendMessage({
conversation_id: conversationId,
body
})
if (res.status === 200) {
_callbackContent.stream_id = res.doc
if (callback) callback(_callbackContent)
}
}
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!e.key) return
if (e.key === 'Enter' && !e.shiftKey) {
// Don't generate a new line
e.preventDefault()
share()
}
}
const onEmojiClick = (emojiData: EmojiClickData) => {
if (focusOffset === undefined || focusNode === undefined) {
postBoxArea.current.innerText += emojiData.emoji
} else {
restoreCaretPos()
document.execCommand('insertHTML', false, emojiData.emoji)
}
}
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}
onKeyUp={saveCaretPos}
onMouseUp={saveCaretPos}
/>
<EmojiPicker onEmojiClick={onEmojiClick} />
</div>
</div>
)
}

View File

@ -29,93 +29,21 @@
transform: translateY(0);
}
.headerWrapper {
flex-shrink: 0;
}
.bodyWrapper {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.isClosed {
transform: translateY(90%);
}
.header {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
padding: calc(0.35 * var(--spacer)) 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);
}
.icon {
margin-left: 0;
width: 1.5rem;
margin-right: calc(0.5 * var(--spacer));
fill: var(--font-color-text);
}
.back {
margin-left: 0;
width: 1rem;
margin-right: calc(0.5 * var(--spacer));
fill: var(--font-color-text);
transform: rotate(180deg);
}
.btnBack {
border: none;
background-color: transparent;
padding: 0;
margin: 0;
cursor: pointer;
}
.toggle {
cursor: pointer;
margin-left: auto;
background: transparent;
border: none;
color: var(--font-color-text);
width: 32px;
height: 32px;
}
.toggle .icon {
fill: transparent;
}
.isFlipped {
transform: scaleY(-1);
}
.conversations {
position: relative;
overflow: auto;
height: 100%;
}
.notificationCount {
background-color: var(--color-primary);
width: 20px;
height: 20px;
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);
}
.conversation {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100%;
background-color: var(--background-content);
}
@media screen and (min-width: 42rem) {
.wrapper {
padding: 0 calc(var(--spacer) / 2);

View File

@ -1,163 +1,24 @@
import React, { useState, useEffect } from 'react'
import ChatBubble from '@images/chatbubble.svg'
import ArrowBack from '@images/arrow.svg'
import ChevronDoubleUp from '@images/chevrondoubleup.svg'
import React from 'react'
import styles from './index.module.css'
import Conversation from './Conversation'
import ChatToolbar from './ChatToolbar'
import { useOrbis } from '@context/Orbis'
import ConversationItem from './ConversationItem'
import Header from './Header'
import List from './List'
export default function DirectMessages() {
const {
orbis,
account,
convOpen,
setConvOpen,
conversationId,
setConversationId,
conversations,
conversationTitle
} = useOrbis()
const [messages, setMessages] = useState<OrbisPostInterface[]>([])
const [unreads, setUnreads] = useState([])
const getConversationUnreads = (conversationId: string) => {
const _unreads = unreads.filter(
(o) => o.content.conversation_id === conversationId
)
return _unreads.length
}
useEffect(() => {
const getNotifications = async () => {
const { data, error } = await orbis.api.rpc('orbis_f_notifications', {
user_did: account?.did || 'none',
notif_type: 'messages'
})
if (error) {
console.log(error)
}
if (data.length > 0) {
const _unreads = data.filter((o: OrbisNotificationInterface) => {
return o.status === 'new'
})
setUnreads(_unreads)
}
}
if (orbis && account) {
getNotifications()
}
}, [orbis, account])
const callbackMessage = (nMessage: OrbisPostInterface) => {
if (nMessage.stream_id) {
const _nMessage = messages.findIndex((o) => {
return !o.stream_id
})
console.log(_nMessage)
if (_nMessage > -1) {
const _messages = [...messages]
_messages[_nMessage] = nMessage
setMessages(_messages)
}
} else {
const _messages = [...messages, nMessage]
setMessages(_messages)
}
}
useEffect(() => {
const getMessages = async (id: string) => {
const { data, error } = await orbis.getMessages(id)
if (data) {
data.reverse()
// const _messages = [...messages, ...data]
setMessages(data)
}
if (error) {
console.log(error)
}
}
if (conversationId && orbis) {
getMessages(conversationId)
} else {
setMessages([])
}
}, [conversationId, orbis])
const { openConversations, conversationId } = useOrbis()
return (
<div className={`${styles.wrapper} ${!convOpen && styles.isClosed}`}>
<div
className={`${styles.wrapper} ${!openConversations && styles.isClosed}`}
>
<div className={styles.floating}>
<div className={styles.header}>
<ChatBubble role="img" aria-label="Chat" className={styles.icon} />
<span>Direct Messages</span>
{unreads.length > 0 && (
<span className={styles.notificationCount}>{unreads.length}</span>
)}
<button
type="button"
className={styles.toggle}
onClick={() => setConvOpen(!convOpen)}
>
<ChevronDoubleUp
role="img"
aria-label="Toggle"
className={`${styles.icon} ${convOpen && styles.isFlipped}`}
/>
</button>
<div className={styles.headerWrapper}>
<Header />
</div>
<div className={styles.conversations}>
{conversations &&
conversations.map(
(conversation: OrbisConversationInterface, index: number) => (
<ConversationItem
key={index}
conversation={conversation}
unreads={getConversationUnreads(conversation.stream_id)}
setConversationId={setConversationId}
/>
)
)}
<div className={styles.bodyWrapper}>
{conversationId ? <Conversation /> : <List />}
</div>
{conversationId && (
<div className={styles.conversation}>
<div className={styles.header}>
<button
type="button"
aria-label="button"
className={styles.btnBack}
onClick={() => setConversationId(null)}
>
<ArrowBack
role="img"
aria-label="arrow"
className={styles.back}
/>
</button>
<span>{conversationTitle}</span>
<button
type="button"
className={styles.toggle}
onClick={() => setConvOpen(!convOpen)}
>
<ChevronDoubleUp
role="img"
aria-label="Toggle"
className={`${styles.icon} ${convOpen && styles.isFlipped}`}
/>
</button>
</div>
<Conversation messages={messages} />
<ChatToolbar callbackMessage={callbackMessage} />
</div>
)}
</div>
</div>
)

View File

@ -1,18 +1,18 @@
.emojiToolTip {
position: absolute;
padding: calc(var(--spacer) / 4.5) calc(var(--spacer) / 3);
cursor: pointer;
white-space: nowrap;
bottom: 0;
right: 0;
width: auto !important;
flex-shrink: 0;
}
.icon {
position: relative;
font-size: var(--font-size-title);
width: var(--font-size-h3);
height: var(--font-size-h3);
color: var(--font-color-text);
fill: currentColor;
}
.icon:hover,
.icon:focus {
color: var(--color-primary);

View File

@ -25,6 +25,10 @@ export default function EmojiPicker({
lazyLoadEmojis={true}
width={322}
height={399}
skinTonesDisabled={true}
previewConfig={{
showPreview: false
}}
/>
}
trigger="click focus"

View File

@ -70,7 +70,7 @@ export default function AssetContent({
{debug === true && <DebugOutput title="DDO" output={asset} />}
</div>
<Comment asset={asset} />
<Comment context={asset?.id} />
</div>
<div className={styles.actions}>

View File

@ -53,8 +53,16 @@
bottom: calc(var(--spacer) / 6);
}
.buttonWrap {
.dmButton {
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: flex-end;
margin-left: auto;
padding: calc(var(--spacer) / 4) 0;
}
.dmButton span {
font-size: var(--font-size-small);
color: var(--color-secondary);
margin-top: 4px;
}

View File

@ -7,7 +7,6 @@ import Account from './Account'
import styles from './index.module.css'
import { useProfile } from '@context/Profile'
import { useOrbis } from '@context/Orbis'
import { sleep } from '@utils/index'
const isDescriptionTextClamped = () => {
const el = document.getElementById('description')
@ -28,69 +27,30 @@ export default function AccountHeader({
accountId: string
}): ReactElement {
const { profile, ownAccount } = useProfile()
const { orbis, setConvOpen, setConversationId, conversations } = useOrbis()
const {
account: orbisAccount,
hasLit,
createConversation,
getDid
} = useOrbis()
const [isShowMore, setIsShowMore] = useState(false)
const [userDid, setUserDid] = useState<string>()
const [userDid, setUserDid] = useState<string | undefined>()
const toogleShowMore = () => {
setIsShowMore(!isShowMore)
}
const createConversation = async () => {
const res = await orbis.createConversation({
recipients: [userDid],
context: 'ocean_market'
})
if (res.status === 200) {
console.log(res)
const { data } = await orbis.getConversation(res.doc)
console.log(data)
setConversationId(res.doc)
setConvOpen(true)
}
console.log('clicked')
}
const checkConversation = () => {
const filtered = conversations.filter(
(conversation: OrbisConversationInterface) => {
// console.log(conversation)
console.log(userDid)
return conversation.recipients.includes(userDid)
}
)
if (!filtered.length && userDid) {
createConversation()
}
}
useEffect(() => {
const getDid = async () => {
const { data, error } = await orbis.getDids(accountId)
console.log(data)
if (data) {
if (data.length > 0) {
console.log(data[0].did)
setUserDid(data[0].did)
} else if (accountId) {
console.log(accountId)
setUserDid('did:pkh:eip155:1:' + accountId?.toLocaleLowerCase())
} else {
console.log('try again')
await sleep(1000)
getDid()
}
}
if (error) {
console.log(error)
}
const getUserDid = async () => {
const did = await getDid(accountId)
console.log(did)
setUserDid(did)
}
if (orbis && accountId) {
getDid()
if (accountId) {
getUserDid()
}
}, [orbis, accountId])
}, [accountId])
return (
<div className={styles.grid}>
@ -101,16 +61,19 @@ export default function AccountHeader({
<div>
{!ownAccount && (
<div className={styles.buttonWrap}>
<div className={styles.dmButton}>
<Button
style="primary"
size="small"
className={styles.sendMessage}
disabled={!userDid}
onClick={checkConversation}
disabled={!userDid || !orbisAccount}
onClick={() => createConversation(userDid)}
>
Send Direct Messages
Send Direct Message
</Button>
{userDid !== undefined && userDid === null && (
<span>User has no Ceramic Network DID</span>
)}
{!orbisAccount && <span>Please connect your wallet</span>}
</div>
)}
<Markdown text={profile?.description} className={styles.description} />