Skip to content

Commit

Permalink
feat: direct messages (#175)
Browse files Browse the repository at this point in the history
* feat: direct messages (#5)

* chore: use Libp2pType

* feat: add direct messaging as a custom service

* chore: move init of directmessage handler

* fix: add await to dm receive

* chore: small simplification (#6)

* chore: small simplification

* chore: remove from field which can be derived

---------

Co-authored-by: Daniel N <2color@users.noreply.github.com>

* chore: signals

* chore: remove menu, add popover

* chore: move markAsRead to hook

* chore: wrap dm receive in try catch

---------

Co-authored-by: Daniel Norman <1992255+2color@users.noreply.github.com>
Co-authored-by: Daniel N <2color@users.noreply.github.com>
  • Loading branch information
3 people authored Sep 17, 2024
1 parent 21cb6bb commit 750150c
Show file tree
Hide file tree
Showing 18 changed files with 2,614 additions and 1,035 deletions.
6 changes: 5 additions & 1 deletion js-peer/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
{
"extends": "next/core-web-vitals"
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-img-element": "off"
},
"ignorePatterns": ["node_modules/", "build/", ".next/", "src/lib/protobuf/"]
}
2 changes: 2 additions & 0 deletions js-peer/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

certificates
2,505 changes: 1,567 additions & 938 deletions js-peer/package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions js-peer/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
{
"name": "universal-connectivity-browser",
"scripts": {
"dev": "next dev",
"dev": "next dev --experimental-https",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"tsc": "tsc --noEmit -p tsconfig.json",
"protobuf": "protons src/lib/protobuf/*.proto"
},
"dependencies": {
"@chainsafe/libp2p-gossipsub": "^13.0.0",
Expand All @@ -28,8 +30,10 @@
"it-length-prefixed": "^9.0.4",
"it-map": "^3.1.0",
"it-pipe": "^3.0.1",
"it-protobuf-stream": "^1.1.4",
"libp2p": "^1.6.1",
"next": "14.2.3",
"protons-runtime": "^5.4.0",
"react": "18.3.1",
"react-18-blockies": "^1.0.6",
"react-dom": "18.3.1",
Expand All @@ -46,6 +50,7 @@
"eslint": "8.57.0",
"eslint-config-next": "14.2.3",
"postcss": "^8.4.38",
"protons": "^7.5.0",
"tailwindcss": "^3.4.3",
"typescript": "5.4.5"
}
Expand Down
32 changes: 10 additions & 22 deletions js-peer/src/components/chat-peer-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { useLibp2pContext } from '@/context/ctx'
import { CHAT_TOPIC } from '@/lib/constants'
import React, { useEffect, useState } from 'react'
import type { PeerId } from '@libp2p/interface'
import Blockies from 'react-18-blockies'
import { PeerWrapper } from './peer'

export function ChatPeerList() {
const { libp2p } = useLibp2pContext()
const [subscribers, setSubscribers] = useState<PeerId[]>([])

useEffect(() => {
const onSubscriptionChange = () => {
const subscribers = libp2p.services.pubsub.getSubscribers(CHAT_TOPIC)
const subscribers = libp2p.services.pubsub.getSubscribers(CHAT_TOPIC) as PeerId[]
setSubscribers(subscribers)
}
onSubscriptionChange()
Expand All @@ -23,28 +23,16 @@ export function ChatPeerList() {
return (
<div className="border-l border-gray-300 lg:col-span-1">
<h2 className="my-2 mb-2 ml-2 text-lg text-gray-600">Peers</h2>
<ul className="overflow-auto h-[32rem]">
{<Peer key={libp2p.peerId.toString()} peer={libp2p.peerId} self />}
<div className="overflow-auto h-[32rem]">
<div className="px-3 py-2 border-b border-gray-300 focus:outline-none">
{<PeerWrapper peer={libp2p.peerId} self withName={true} withUnread={false} />}
</div>
{subscribers.map((p) => (
<Peer key={p.toString()} peer={p} self={false} />
<div key={p.toString()} className="px-3 py-2 border-b border-gray-300 focus:outline-none">
<PeerWrapper peer={p} self={false} withName={true} withUnread={true} />
</div>
))}
</ul>
</div>
)
}

function Peer({ peer, self }: { peer: PeerId; self: boolean }) {
return (
<li className="flex items-center px-3 py-2 text-sm transition duration-150 ease-in-out border-b border-gray-300 focus:outline-none">
<Blockies seed={peer.toString()} size={15} scale={3} className="rounded max-h-10 max-w-10" />
<div className="w-full pb-2">
<div className="flex justify-between">
<span className={`block ml-2 font-semibold ${self ? 'text-indigo-700-600' : 'text-gray-600'}`}>
{peer.toString().slice(-7)}
{self && ' (You)'}
</span>
</div>
</div>
</li>
</div>
)
}
170 changes: 146 additions & 24 deletions js-peer/src/components/chat.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
import { useLibp2pContext } from '@/context/ctx'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { CHAT_FILE_TOPIC, CHAT_TOPIC, FILE_EXCHANGE_PROTOCOL } from '@/lib/constants'
import { CHAT_FILE_TOPIC, CHAT_TOPIC } from '@/lib/constants'
import { ChatFile, ChatMessage, useChatContext } from '../context/chat-ctx'
import { v4 as uuidv4 } from 'uuid'
import { MessageComponent } from './message'
import { Message } from './message'
import { forComponent } from '@/lib/logger'
import { ChatPeerList } from './chat-peer-list'
import { ChevronLeftIcon } from '@heroicons/react/20/solid'
import Blockies from 'react-18-blockies'
import { peerIdFromString } from '@libp2p/peer-id'

const log = forComponent('chat')

export const PUBLIC_CHAT_ROOM_ID = ''
const PUBLIC_CHAT_ROOM_NAME = 'Public Chat'

export default function ChatContainer() {
const { libp2p } = useLibp2pContext()
const { messageHistory, setMessageHistory, files, setFiles } = useChatContext()
const { roomId, setRoomId } = useChatContext()
const { messageHistory, setMessageHistory, directMessages, setDirectMessages, files, setFiles } = useChatContext()
const [input, setInput] = useState<string>('')
const fileRef = useRef<HTMLInputElement>(null)
const [messages, setMessages] = useState<ChatMessage[]>([])

const sendMessage = useCallback(async () => {
// Send message to public chat over gossipsub
const sendPublicMessage = useCallback(async () => {
if (input === '') return

log(
Expand All @@ -33,11 +42,56 @@ export default function ChatContainer() {

setMessageHistory([
...messageHistory,
{ msg: input, fileObjectUrl: undefined, from: 'me', peerId: myPeerId },
{
msgId: crypto.randomUUID(),
msg: input,
fileObjectUrl: undefined,
peerId: myPeerId,
read: true,
receivedAt: Date.now(),
},
])

setInput('')
}, [input, messageHistory, setInput, libp2p, setMessageHistory])

// Send direct message over custom protocol
const sendDirectMessage = useCallback(async () => {
try {
const res = await libp2p.services.directMessage.send(peerIdFromString(roomId), input)

if (!res) {
log('Failed to send message')
return
}

const myPeerId = libp2p.peerId.toString()

const newMessage: ChatMessage = {
msgId: crypto.randomUUID(),
msg: input,
fileObjectUrl: undefined,
peerId: myPeerId,
read: true,
receivedAt: Date.now(),
}

const updatedMessages = directMessages[roomId]
? [...directMessages[roomId], newMessage]
: [newMessage]

setDirectMessages({
...directMessages,
[roomId]: updatedMessages,
})

setInput('')
} catch (e: any) {
log(e)
}
}, [libp2p, setDirectMessages, directMessages, roomId, input])


const sendFile = useCallback(
async (readerEvent: ProgressEvent<FileReader>) => {
const fileBody = readerEvent.target?.result as ArrayBuffer
Expand All @@ -62,10 +116,12 @@ export default function ChatContainer() {
)

const msg: ChatMessage = {
msgId: crypto.randomUUID(),
msg: newChatFileMessage(file.id, file.body),
fileObjectUrl: window.URL.createObjectURL(new Blob([file.body])),
from: 'me',
peerId: myPeerId,
read: true,
receivedAt: Date.now(),
}
setMessageHistory([...messageHistory, msg])
},
Expand All @@ -81,16 +137,24 @@ export default function ChatContainer() {
if (e.key !== 'Enter') {
return
}
sendMessage()
if (roomId === PUBLIC_CHAT_ROOM_ID) {
sendPublicMessage()
} else {
sendDirectMessage()
}
},
[sendMessage],
[sendPublicMessage, sendDirectMessage, roomId],
)

const handleSend = useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
sendMessage()
if (roomId === PUBLIC_CHAT_ROOM_ID) {
sendPublicMessage()
} else {
sendDirectMessage()
}
},
[sendMessage],
[sendPublicMessage, sendDirectMessage, roomId],
)

const handleInput = useCallback(
Expand Down Expand Up @@ -120,33 +184,91 @@ export default function ChatContainer() {
[fileRef],
)

const handleBackToPublic = () => {
setRoomId(PUBLIC_CHAT_ROOM_ID)
setMessages(messageHistory)
}

useEffect(() => {
// assumes a chat room is a peerId thus a direct message
if (roomId === PUBLIC_CHAT_ROOM_ID) {
setMessages(messageHistory)
} else {
setMessages(directMessages[roomId] || [])
}
}, [roomId, directMessages, messageHistory])


return (
<div className="container mx-auto">
<div className="min-w-full border rounded lg:grid lg:grid-cols-6">
<div className="lg:col-span-5 lg:block">
<div className="w-full">
<div className="relative flex items-center p-3 border-b border-gray-300">
<span className="block ml-2 font-bold text-gray-600">Public Chat</span>
{roomId === PUBLIC_CHAT_ROOM_ID &&
<span className="block ml-2 font-bold text-gray-600">{PUBLIC_CHAT_ROOM_NAME}</span>
}
{roomId !== PUBLIC_CHAT_ROOM_ID && (
<>
<Blockies
seed={roomId}
size={8}
scale={3}
className="rounded mr-2 max-h-10 max-w-10"
/>
<span className={`text-gray-500 flex`}>
{roomId.toString().slice(-7)}
</span>
<button
onClick={handleBackToPublic}
className="text-gray-500 flex ml-auto"
>
<ChevronLeftIcon className="w-6 h-6 text-gray-500" />
<span>Back to Public Chat</span>
</button>
</>
)}
</div>
<div className="relative w-full flex flex-col-reverse p-3 overflow-y-auto h-[40rem] bg-gray-100">
<ul className="space-y-2">
{/* messages start */}
{messageHistory.map(({ msg, fileObjectUrl, from, peerId }, idx) => (
<MessageComponent
key={idx}
msg={msg}
fileObjectUrl={fileObjectUrl}
from={from}
peerId={peerId}
/>
))}
{/* messages end */}
{messages.map(
({
msgId,
msg,
fileObjectUrl,
peerId,
read,
receivedAt,
}: ChatMessage) => (
<Message
key={msgId}
dm={roomId !== ''}
msg={msg}
fileObjectUrl={fileObjectUrl}
peerId={peerId}
read={read}
msgId={msgId}
receivedAt={receivedAt}
/>
),
)}
</ul>
</div>

<div className="flex items-center justify-between w-full p-3 border-t border-gray-300">
<input ref={fileRef} className="hidden" type="file" onChange={handleFileInput} />
<button onClick={handleFileSend}>
<input
ref={fileRef}
className="hidden"
type="file"
onChange={handleFileInput}
disabled={roomId !== PUBLIC_CHAT_ROOM_ID}
/>
<button
onClick={handleFileSend}
disabled={roomId !== PUBLIC_CHAT_ROOM_ID}
title={roomId === PUBLIC_CHAT_ROOM_ID ? 'Upload file' : "Unsupported in DM's" }
className={roomId === PUBLIC_CHAT_ROOM_ID ? '' : 'cursor-not-allowed'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-5 h-5 text-gray-500"
Expand Down
33 changes: 25 additions & 8 deletions js-peer/src/components/message.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
import React, { useEffect } from 'react'
import { useLibp2pContext } from '@/context/ctx'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { ChatMessage } from '@/context/chat-ctx'
import Blockies from 'react-18-blockies'
import { ChatMessage, useChatContext } from '@/context/chat-ctx'
import { PeerWrapper } from './peer'
import { peerIdFromString } from '@libp2p/peer-id'
import { useMarkAsRead } from '@/hooks/useMarkAsRead'

interface MessageProps extends ChatMessage {}
interface Props extends ChatMessage {
dm: boolean
}

export function MessageComponent({ msg, fileObjectUrl, from, peerId }: MessageProps) {
export const Message = ({ msgId, msg, fileObjectUrl, peerId, read, dm, receivedAt }: Props) => {
const { libp2p } = useLibp2pContext()

const isSelf: boolean = libp2p.peerId.equals(peerId)

const timestamp = new Date(receivedAt).toLocaleString()

useMarkAsRead(msgId, peerId, read, dm)

return (
<li className={`flex ${from === 'me' && 'flex-row-reverse'} gap-2`}>
<Blockies seed={peerId} size={15} scale={3} className="rounded max-h-10 max-w-10" />
<li className={`flex ${isSelf && 'flex-row-reverse'} gap-2`}>
<PeerWrapper
key={peerId}
peer={peerIdFromString(peerId)}
self={isSelf}
withName={false}
withUnread={false}
/>
<div className="flex relative max-w-xl px-4 py-2 text-gray-700 rounded shadow bg-white">
<div className="block">
{msg}
Expand All @@ -24,8 +40,9 @@ export function MessageComponent({ msg, fileObjectUrl, from, peerId }: MessagePr
)}
</p>
<p className="italic text-gray-400">
{peerId !== libp2p.peerId.toString() ? `from: ${peerId.slice(-4)}` : null}{' '}
{!dm && peerId !== libp2p.peerId.toString() ? `from: ${peerId.slice(-4)}` : null}{' '}
</p>
<span className="relative pl-1 text-xs text-slate-400">{timestamp}</span>
</div>
</div>
</li>
Expand Down
Loading

0 comments on commit 750150c

Please sign in to comment.