Skip to content

Commit

Permalink
Merge pull request #39 from khyrulAlam/feature/user-search
Browse files Browse the repository at this point in the history
Feature/user search
  • Loading branch information
khyrulAlam authored Jan 13, 2025
2 parents d327e45 + f353517 commit 68ac7b0
Show file tree
Hide file tree
Showing 14 changed files with 189 additions and 61 deletions.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@ dist-ssr
/dist

#config
/src/config.ts
/src/config.ts

.firebase
.vscode
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,9 @@ Quick Start:
- Refactored codebase from class components to function components.
- Utilized React Context API for state management.
- Various code optimizations and improvements.


### [Version 2.1.0](https://github.com/khyrulAlam/react-firebase-chat/releases/tag/v2.1.0)

- Contacts Search by name.
- User list sort by created at.
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
{
"name": "react-firebase-chat",
"private": true,
"version": "2.0.0",
"version": "2.1.0",
"type": "module",
"author": {
"name": "Khayrul Alam",
"email": "khyrulalam69@gmail.com",
"github": "https://github.com/khyrulAlam",
"linkedin": "https://www.linkedin.com/in/khayrul-alam-360291aa/"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
Expand Down
35 changes: 35 additions & 0 deletions src/components/chat/searchBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { LuSearch } from "react-icons/lu";
import { InputGroup } from "../ui/input-group";
import { Input } from "@chakra-ui/react/input";
import { useEffect, useState } from "react";
import useDebounce from "@/utils/useDebounce";

interface SearchBoxProps {
setSearch: (value: string) => void;
setLoading: (value: boolean) => void;
}

const SearchBox = ({ setSearch, setLoading }: SearchBoxProps) => {
const [searchToken, setSearchToken] = useState<string | null>(null);
const debouncedSearchToken = useDebounce(searchToken, 500);

useEffect(() => {
setSearch(debouncedSearchToken || "");
}, [debouncedSearchToken]);

return (
<InputGroup flex="1" startElement={<LuSearch />}>
<Input
placeholder="Search contacts"
value={searchToken || ""}
onChange={(e) => {
setLoading(true);
setSearchToken(e.currentTarget.value)
}
}
/>
</InputGroup>
);
};

export default SearchBox;
121 changes: 75 additions & 46 deletions src/components/chat/usersList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,27 @@ import { ChatActionType } from "@/context/Chat/types";
import { unSubscribeDatabase } from "@/services";
import { firebaseDatabase } from "@/config";
import { onValue, ref } from "firebase/database";
import { numberFormatter } from "@/utils";
import { DB_NAME, numberFormatter } from "@/utils";
import SearchBox from "./searchBox";

const UsersList = () => {
const [loading, setLoading] = useState(true);
const { user: authUser } = useAuth();
const { chatRoomId, oneToOneRoomId } = useChat();
const dispatch = useChatDispatch();
const { userList: storeUserList } = useChat();
const [userList, setUserList] = useState<User[] | []>([]);
const [userCount, setUserCount] = useState<number>(0);
const [startSearch, setStartSearch] = useState(false);

useEffect(() => {
setLoading(true);
const collectionRef = ref(firebaseDatabase, "usersTable");
const collectionRef = ref(firebaseDatabase, DB_NAME.USER_TABLE);
const unsubscribe = onValue(collectionRef, (snapshot) => {
const data = snapshot.val();
if (data) {
const users = Object.values(data) as User[];
setUserCount(users.length || 0);
// Remove duplicate users and the current user
const uniqueUsers = users.filter(
(user, index, self) =>
user.uid && user.uid !== authUser?.uid && index === self.findIndex((u) => u.uid === user.uid)
);
setUserList(uniqueUsers);
dispatch({
type: ChatActionType.SET_USER_LIST,
payload: data,
Expand All @@ -45,11 +42,13 @@ const UsersList = () => {
};
}, [authUser?.uid, dispatch]);

const handleUserClick = (user: User | "chatRoom") => {
const handleUserClick = (user: User | string) => {
const newChatRoomId =
user === "chatRoom" ? "chatRoom" : `${authUser?.uid}+${user.uid}`;
typeof user === "string"
? DB_NAME.CHAT_ROOM
: `${authUser?.uid}+${user.uid}`;
const newOneToOneRoomId =
user !== "chatRoom" ? `${user.uid}+${authUser?.uid}` : "";
typeof user !== "string" ? `${user.uid}+${authUser?.uid}` : "";

// Unsubscribe from the database
const roomList = [chatRoomId, oneToOneRoomId].filter(Boolean);
Expand All @@ -65,6 +64,16 @@ const UsersList = () => {
});
};

const handleSearchUserList = (token: string) => {
const sourceList = Object.values(storeUserList) as User[];
const regex = new RegExp(token, "i");
const result = sourceList
.filter((user) => user.userName.toLowerCase().match(regex))
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
setUserList(result);
setStartSearch(false);
};

return (
<Box
p={2}
Expand All @@ -78,61 +87,81 @@ const UsersList = () => {
{loading && <Spinner />}
{!loading && (
<Stack gap="2">
<SearchBox
setSearch={handleSearchUserList}
setLoading={setStartSearch}
/>
<HStack
cursor={"pointer"}
_hover={{ bg: "orange.50", color: "orange.400" }}
bg={chatRoomId === "chatRoom" ? "orange.50" : "gray.subtle"}
bg={chatRoomId === DB_NAME.CHAT_ROOM ? "orange.50" : "gray.subtle"}
color={"orange.400"}
borderRadius={"md"}
title="Common Group"
p={"2"}
onClick={() => handleUserClick("chatRoom")}
onClick={() => handleUserClick(DB_NAME.CHAT_ROOM)}
>
<AvatarGroup size="sm">
<Avatar
name={authUser?.userName}
src={authUser?.profile_picture}
colorPalette={"orange"}
/>
<Avatar colorPalette={'orange'} fallback={`+${numberFormatter.format(userCount)}`} />
<Avatar
colorPalette={"orange"}
fallback={`+${numberFormatter.format(userCount)}`}
/>
</AvatarGroup>
<Text fontWeight="medium" textStyle={"sm"} lineClamp={1}>
Common Group
</Text>
</HStack>
{userList?.map((user) => (
<HStack
key={user.uid}
cursor={"pointer"}
bg={
chatRoomId === `${authUser?.uid}+${user.uid}`
? "orange.50"
: "gray.subtle"
}
color={
chatRoomId === `${authUser?.uid}+${user.uid}`
? "orange.400"
: "initial"
}
_hover={{ bg: "orange.50", color: "orange.400" }}
borderRadius={"md"}
title={user.userName}
p={"2"}
onClick={() => handleUserClick(user)}
>
<Avatar
name={user.userName}
size="sm"
src={user.profile_picture}
captionSide={"bottom"}
/>
<Stack gap="0">
<Text fontWeight="medium" textStyle={"sm"} lineClamp={1}>
{user.userName}
</Text>
</Stack>

{startSearch ? (
<Stack alignItems={"center"}>
<Spinner />
</Stack>
) : userList.length < 1 ? (
<HStack justifyContent={"center"}>
<Text fontWeight="medium" textStyle={"xs"}>
No Search result found
</Text>
</HStack>
))}
) : (
userList?.map((user) => (
<HStack
key={user.uid}
cursor={"pointer"}
bg={
chatRoomId === `${authUser?.uid}+${user.uid}`
? "orange.50"
: "gray.subtle"
}
color={
chatRoomId === `${authUser?.uid}+${user.uid}`
? "orange.400"
: "initial"
}
_hover={{ bg: "orange.50", color: "orange.400" }}
borderRadius={"md"}
title={user.userName}
p={"2"}
onClick={() => handleUserClick(user)}
>
<Avatar
name={user.userName}
size="sm"
src={user.profile_picture}
captionSide={"bottom"}
/>
<Stack gap="1" direction={"column"} align={"flex-start"}>
<Text fontWeight="medium" textStyle={"sm"} lineClamp={1}>
{user.userName}
</Text>
</Stack>
</HStack>
))
)}
</Stack>
)}
</Box>
Expand Down
File renamed without changes.
1 change: 1 addition & 0 deletions src/context/Auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type User = {
profile_picture: string;
userName: string;
uid: string;
createdAt: number;
}

export type AuthState = {
Expand Down
3 changes: 2 additions & 1 deletion src/context/Chat/chatReducer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { DB_NAME } from "@/utils";
import { ACTION_TYPE, ChatState } from "./types";

export const initialState: ChatState = {
userList: {},
isLoading: false,
chatRoomId: "chatRoom",
chatRoomId: DB_NAME.CHAT_ROOM,
oneToOneRoomId: "",
};

Expand Down
4 changes: 2 additions & 2 deletions src/context/Chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type MessageSnapshotResponse = {
export type ChatState = {
userList: {[key: string]: User};
isLoading: boolean;
chatRoomId: string | 'chatRoom';
chatRoomId: string;
oneToOneRoomId: string;
};

Expand All @@ -38,7 +38,7 @@ type SET_LOADING = {
type SET_CHAT_ROOM_ID = {
type: ChatActionType.SET_CHAT_ROOM_ID;
payload: {
chatRoomId: string | 'chatRoom';
chatRoomId: string;
oneToOneRoomId: string;
};
};
Expand Down
5 changes: 4 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import App from './App.tsx'
import { Provider } from './components/ui/provider.tsx'
import AuthProvider from './context/Auth/index.tsx'

// unregister service worker
import './registerServiceWorker.ts'

// firebase initialization
import "./config.ts";
import "@/config.ts";

createRoot(document.getElementById('root')!).render(
<StrictMode>
Expand Down
15 changes: 15 additions & 0 deletions src/registerServiceWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// unregister previously registered service worker

if ("serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then(function (registrations) {
for (let registration of registrations) {
if (
registration.active &&
registration.active.scriptURL ==
"https://chatroom-67e21.web.app/service-worker.js"
) {
registration.unregister();
}
}
});
}
5 changes: 3 additions & 2 deletions src/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { firebaseDatabase } from "@/config";
import { User } from "@/context/Auth/types";
import { MessageSnapshotResponse } from "@/context/Chat/types";
import { DB_NAME } from "@/utils";
import {
child,
get,
Expand All @@ -13,7 +14,7 @@ import {
} from "firebase/database";

export const addUserInfo = async (user: User) => {
const collectionRef = ref(firebaseDatabase, "usersTable");
const collectionRef = ref(firebaseDatabase, DB_NAME.USER_TABLE);
const snapshot = await child(collectionRef, user.uid);
if (!snapshot.key) {
set(snapshot, user);
Expand All @@ -33,7 +34,7 @@ const fetchMessages = async (

export const fetchCommonRoomMessages =
async (): Promise<MessageSnapshotResponse> => {
const collectionRef = ref(firebaseDatabase, "chatRoom");
const collectionRef = ref(firebaseDatabase, DB_NAME.CHAT_ROOM);
// By default the fetch item is sorted by timestamp
return fetchMessages(collectionRef);
};
Expand Down
25 changes: 18 additions & 7 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
export const dateFormatter = new Intl.DateTimeFormat(navigator.language, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});

export const dateFormatterShort = new Intl.DateTimeFormat(navigator.language, {
month: "short",
day: "numeric",
year: "numeric",
});

export const numberFormatter = new Intl.NumberFormat(navigator.language, {
notation: "compact",
});
notation: "compact",
});

export const DB_NAME: { [key: string]: string } = {
USER_TABLE: "usersTable",
CHAT_ROOM: "chatRoom",
};
Loading

0 comments on commit 68ac7b0

Please sign in to comment.