Skip to content

feat: Reactions #1

New issue

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

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

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 5 additions & 16 deletions react-chat/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import {useEffect, useMemo, useState} from 'react'
import { AnyDocumentId, DocHandle, Repo } from '@automerge/automerge-repo';
import { BrowserWebSocketClientAdapter } from '@automerge/automerge-repo-network-websocket';
import { IndexedDBStorageAdapter } from '@automerge/automerge-repo-storage-indexeddb';
Expand All @@ -8,6 +8,7 @@ import { Chat } from './components/Chat'
import { UserSettings } from './components/UserSettings'
import { LoginRequiredError, createClient, createVerifier } from '@featherscloud/auth'
import { ChatDocument, CloudAuthUser, Message, User, sha256 } from './utils';
import {ChatContext, ChatContextValue} from "./components/ChatContext.tsx";

// Initialize Feathers Cloud Auth
const appId = import.meta.env.VITE_CLOUD_APP_ID as string;
Expand Down Expand Up @@ -48,20 +49,6 @@ function App() {
}
}

// Create a new Message
const createMessage = (text: string) => {
if (handle && user) {
handle.change(doc => {
doc.messages.push({
id: crypto.randomUUID(),
text: text,
createdAt: Date.now(),
userId: user.id
})
})
}
}

// Initialize the application
const init = async () => {
try {
Expand Down Expand Up @@ -91,14 +78,16 @@ function App() {
}
}

const contextValue = useMemo<ChatContextValue>(()=>({user, handle}), [user, handle]);

useEffect(() => {
init()
}, [])

if (handle?.isReady()) {
return user === null
? <UserSettings onSubmit={createUser} />
: <Chat messages={messages} user={user} users={users} createMessage={createMessage} />
: <ChatContext.Provider value={contextValue}><Chat messages={messages} users={users} /></ChatContext.Provider>
}
}

Expand Down
24 changes: 20 additions & 4 deletions react-chat/src/components/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
import { Message, User } from "../utils";
import { CreateMessage } from "./CreateMessage"
import { MessageList } from "./MessageList"
import {useChat} from "./ChatContext.tsx";
import {useCallback} from "react";

export type ChatOptions = {
user: User;
users: User[];
messages: Message[];
createMessage: (text: string) => void;
};

export const Chat = ({ messages, user, users, createMessage }: ChatOptions) => {
export const Chat = ({ messages, users }: ChatOptions) => {
const {user, handle} = useChat();

// Create a new Message
const createMessage = useCallback((text: string) => {
if (handle && user) {
handle.change(doc => {
doc.messages.push({
id: crypto.randomUUID(),
text: text,
createdAt: Date.now(),
userId: user.id
})
})
}
}, [user, handle])

return <div className="drawer drawer-mobile"><input id="drawer-left" type="checkbox" className="drawer-toggle" />
<div className="drawer-content flex flex-col">
<div className="navbar w-full">
Expand All @@ -34,7 +50,7 @@ export const Chat = ({ messages, user, users, createMessage }: ChatOptions) => {
<ul className="menu user-list compact p-2 w-60 bg-base-300 text-base-content">
<li className="menu-title"><span>Users</span></li>
{users.map(current => <li className="user" key={current.id}>
<a className={ user.id === current.id ? 'text-secondary font-bold' : ''}>
<a className={ user!.id === current.id ? 'text-secondary font-bold' : ''}>
<div className="avatar indicator">
<div className="w-6 rounded"><img src={current.avatar} alt={current.username!} /></div>
</div><span>{current.username}</span>
Expand Down
9 changes: 9 additions & 0 deletions react-chat/src/components/ChatContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {createContext, useContext} from "react";
import {DocHandle} from "@automerge/automerge-repo";
import {ChatDocument, User} from "../utils.ts";

export type ChatContextValue = {handle: DocHandle<ChatDocument> | null; user: User | null};

export const ChatContext = createContext<ChatContextValue>({handle: null, user: null});

export const useChat = ()=>useContext(ChatContext);
92 changes: 72 additions & 20 deletions react-chat/src/components/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useEffect, useRef } from "react"
import { Message, User } from "../utils"
import {useCallback, useEffect, useRef, MouseEvent} from "react"
import {Message as MessageType, User} from "../utils"
import {useChat} from "./ChatContext.tsx";

type MessageListProps = {
messages: Message[]
users: User[]
users: User[];
messages: MessageType[];
}

const formatDate = (timestamp: number) =>
Expand All @@ -12,31 +13,82 @@ const formatDate = (timestamp: number) =>
dateStyle: 'medium'
}).format(new Date(timestamp))

export const MessageList = ({ messages, users }: MessageListProps) => {
const messagesEndRef = useRef(null)
const Reaction = ({emoji, total, onClick}: { emoji: string, total: number, onClick: (event: MouseEvent) => void }) => {
return <button className='reaction' onClick={onClick} data-emoji={emoji}>
<span>{emoji}</span>
<span>{total}</span>
</button>;
}

const Message = ({message, author}: { message: MessageType; author?: User; }) => {
const {user, handle} = useChat();
const handleAddReactClick = useCallback(() => {
handle?.change((chat) => {
const writeableMessage = chat.messages.find(({id}) => id === message.id);
if (writeableMessage) {
writeableMessage.reactions ??= {};
writeableMessage.reactions['✨'] ??= {};
writeableMessage.reactions['✨'][user!.id] = true;
}
});
}, [handle, message, user]);
const handleRemoveReactClick = useCallback((event: MouseEvent) => {
const emoji = ((event.target as HTMLElement).closest('[data-emoji]') as HTMLButtonElement)?.getAttribute('data-emoji');
console.log('[handle remove]', emoji);
if (emoji && handle) {
handle?.change((chat) => {
const writeableMessage = chat.messages.find(({id}) => id === message.id);
if (writeableMessage) {
writeableMessage.reactions ??= {};
writeableMessage.reactions[emoji] ??= {};
delete writeableMessage.reactions[emoji][user!.id];
if(Object.keys(writeableMessage.reactions[emoji]).length < 1){
delete writeableMessage.reactions[emoji];
}
}
});
}
}, [handle, message, user]);
const reactions = Object.entries(message.reactions ?? {}).reduce((acc: Record<string, number>, [emoji, peanutGallery]) => {
acc[emoji] = Object.keys(peanutGallery).length;
return acc;
}, {});
return <div className="chat chat-start py-2" key={message.id}>
<div className="chat-image avatar">
<div className="w-10 rounded-full">
<img src={author?.avatar}/>
</div>
</div>
<div className="chat-header pb-1">
{author?.username}
<time className="text-xs opacity-50">{formatDate(message.createdAt)}</time>
</div>
<div className="chat-bubble relative">
{message.text}
<div className='absolute bottom-[-2rem] flex flex-wrap gap-1'>
{Object.entries(reactions).map(([emoji, total]) => <Reaction key={emoji} emoji={emoji} total={total}
onClick={handleRemoveReactClick}/>)}
{/*TODO(thure): Add emoji picker*/}
<button className='reaction' onClick={handleAddReactClick}>+</button>
</div>
</div>
</div>;
}

export const MessageList = ({messages, users}: MessageListProps) => {
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const usersById = users.reduce((acc, user) => {
acc[user.id] = user
return acc
}, {} as Record<string, User>)

// Scroll to bottom when messages change
useEffect(() => {
(messagesEndRef.current as any)?.scrollIntoView({ behavior: "smooth" })
messagesEndRef.current?.scrollIntoView({behavior: "smooth"})
})

return <div id="chat" className="h-full overflow-y-auto px-3">
{messages.map(message => <div className="chat chat-start py-2" key={message.id}>
<div className="chat-image avatar">
<div className="w-10 rounded-full">
<img src={usersById[message.userId]?.avatar} />
</div>
</div>
<div className="chat-header pb-1">
{usersById[message.userId]?.username}
<time className="text-xs opacity-50">{formatDate(message.createdAt)}</time>
</div>
<div className="chat-bubble">{message.text}</div>
</div>)}
<div ref={messagesEndRef} />
{messages.map(message => <Message key={message.id} message={message} author={usersById[message.userId]}/>)}
<div ref={messagesEndRef}/>
</div>
}
8 changes: 8 additions & 0 deletions react-chat/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,11 @@ button:focus-visible {
background-color: #f9f9f9;
}
}

.reaction {
font-size: .8rem;
display: block;
padding: 0rem .5rem .125rem .25rem;
border-radius: 999px;
background: #0f172a;
}
3 changes: 3 additions & 0 deletions react-chat/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ export type User = {
avatar: string;
};

export type Reactions = Record<string, Record<string, true>>;

export type Message = {
id: string;
text: string;
createdAt: number;
userId: string;
reactions?: Reactions;
};

export type ChatDocument = {
Expand Down